mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-29 20:48:33 -06:00
continued work
This commit is contained in:
Generated
+426
-1
@@ -2,12 +2,33 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "accesskit"
|
||||
version = "0.24.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320"
|
||||
dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aligned"
|
||||
version = "0.4.3"
|
||||
@@ -224,6 +245,15 @@ version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "color"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "color_quant"
|
||||
version = "1.1.0"
|
||||
@@ -270,12 +300,77 @@ version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "ecolor"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34"
|
||||
dependencies = [
|
||||
"emath",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"ahash",
|
||||
"bitflags",
|
||||
"emath",
|
||||
"epaint",
|
||||
"itertools 0.15.0",
|
||||
"log",
|
||||
"nohash-hasher",
|
||||
"profiling",
|
||||
"smallvec",
|
||||
"unicode-segmentation",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
|
||||
|
||||
[[package]]
|
||||
name = "emath"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1"
|
||||
|
||||
[[package]]
|
||||
name = "epaint"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"ecolor",
|
||||
"emath",
|
||||
"epaint_default_fonts",
|
||||
"font-types",
|
||||
"harfrust",
|
||||
"log",
|
||||
"nohash-hasher",
|
||||
"parking_lot",
|
||||
"profiling",
|
||||
"self_cell",
|
||||
"skrifa",
|
||||
"smallvec",
|
||||
"unicode-general-category",
|
||||
"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"
|
||||
|
||||
[[package]]
|
||||
name = "equator"
|
||||
version = "0.4.2"
|
||||
@@ -306,6 +401,15 @@ dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "euclid"
|
||||
version = "0.22.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
@@ -327,6 +431,12 @@ dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fearless_simd"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.14"
|
||||
@@ -350,6 +460,45 @@ version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "font-types"
|
||||
version = "0.12.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
@@ -383,6 +532,31 @@ dependencies = [
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glifo"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"foldhash",
|
||||
"hashbrown",
|
||||
"log",
|
||||
"peniko",
|
||||
"skrifa",
|
||||
"smallvec",
|
||||
"vello_common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "guillotiere"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab"
|
||||
dependencies = [
|
||||
"euclid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "half"
|
||||
version = "2.7.1"
|
||||
@@ -394,6 +568,27 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "harfrust"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bytemuck",
|
||||
"read-fonts",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.10"
|
||||
@@ -449,6 +644,15 @@ dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
@@ -480,6 +684,17 @@ version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0370574b86f7eca156b9f298392b5e69a23f8c86f3f865add60bbc2e79467a6"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jxl-bitstream"
|
||||
version = "1.1.0"
|
||||
@@ -656,6 +871,18 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kurbo"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"euclid",
|
||||
"polycool",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
@@ -827,12 +1054,44 @@ dependencies = [
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-ui-egui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"lightcraft-catalog",
|
||||
"lightcraft-color",
|
||||
"lightcraft-develop",
|
||||
"lightcraft-engine",
|
||||
"lightcraft-geom",
|
||||
"lightcraft-pipeline",
|
||||
"lightcraft-raster",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linebender_resource_handle"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
|
||||
dependencies = [
|
||||
"scopeguard",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.34"
|
||||
@@ -918,6 +1177,12 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nohash-hasher"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "8.0.0"
|
||||
@@ -989,6 +1254,29 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
|
||||
dependencies = [
|
||||
"lock_api",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot_core"
|
||||
version = "0.9.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"smallvec",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
@@ -1001,6 +1289,19 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||
|
||||
[[package]]
|
||||
name = "peniko"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"color",
|
||||
"kurbo",
|
||||
"linebender_resource_handle",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
@@ -1020,6 +1321,15 @@ dependencies = [
|
||||
"miniz_oxide 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polycool"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
@@ -1178,7 +1488,7 @@ dependencies = [
|
||||
"built",
|
||||
"cfg-if",
|
||||
"interpolate_name",
|
||||
"itertools",
|
||||
"itertools 0.14.0",
|
||||
"libc",
|
||||
"libfuzzer-sys",
|
||||
"log",
|
||||
@@ -1231,6 +1541,26 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "read-fonts"
|
||||
version = "0.41.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"font-types",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.11"
|
||||
@@ -1274,6 +1604,18 @@ dependencies = [
|
||||
"wait-timeout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "self_cell"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
@@ -1338,6 +1680,28 @@ dependencies = [
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "skrifa"
|
||||
version = "0.44.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"read-fonts",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
@@ -1438,12 +1802,30 @@ version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-general-category"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.26.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
|
||||
|
||||
[[package]]
|
||||
name = "v_frame"
|
||||
version = "0.3.9"
|
||||
@@ -1455,6 +1837,39 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vello_common"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"fearless_simd",
|
||||
"guillotiere",
|
||||
"log",
|
||||
"peniko",
|
||||
"smallvec",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vello_cpu"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"glifo",
|
||||
"hashbrown",
|
||||
"vello_common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wait-timeout"
|
||||
version = "0.2.1"
|
||||
@@ -1518,6 +1933,16 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "lightcraft-ui-egui"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "LightCraft egui frontend (Lightroom-style UI); swappable — nothing below depends on it (L5)"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
lightcraft-geom = { workspace = true }
|
||||
lightcraft-color = { workspace = true }
|
||||
lightcraft-raster = { workspace = true }
|
||||
lightcraft-develop = { workspace = true }
|
||||
lightcraft-pipeline = { workspace = true }
|
||||
lightcraft-catalog = { workspace = true }
|
||||
lightcraft-engine = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
@@ -0,0 +1,399 @@
|
||||
//! Our own vector icons, drawn in code on a 20×20 design grid (1.5 px strokes, rounded caps).
|
||||
//! Original work (see assets/ATTRIBUTION.md) — not derived from any third-party icon set.
|
||||
|
||||
use egui::{Color32, Painter, Pos2, Rect, Shape, Stroke, pos2, vec2};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Icon {
|
||||
Sidebar,
|
||||
Back,
|
||||
Forward,
|
||||
Search,
|
||||
Filter,
|
||||
Bell,
|
||||
Share,
|
||||
Help,
|
||||
Cloud,
|
||||
Sliders,
|
||||
Crop,
|
||||
Eraser,
|
||||
Mask,
|
||||
Eye,
|
||||
Presets,
|
||||
Versions,
|
||||
Activity,
|
||||
More,
|
||||
Tag,
|
||||
Info,
|
||||
GridPhoto,
|
||||
GridSquare,
|
||||
Single,
|
||||
Compare,
|
||||
Sort,
|
||||
Star,
|
||||
StarFilled,
|
||||
Circle,
|
||||
FlagPick,
|
||||
FlagReject,
|
||||
Gear,
|
||||
Filmstrip,
|
||||
BeforeAfter,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
TriangleLeft,
|
||||
Plus,
|
||||
Minus,
|
||||
Album,
|
||||
Folder,
|
||||
Photos,
|
||||
Clock,
|
||||
Trash,
|
||||
Curve,
|
||||
ProfileGrid,
|
||||
Close,
|
||||
Check,
|
||||
Brush,
|
||||
Linear,
|
||||
Radial,
|
||||
Sky,
|
||||
Subject,
|
||||
Picker,
|
||||
Rotate,
|
||||
Flip,
|
||||
Invert,
|
||||
Dots,
|
||||
Video,
|
||||
Heart,
|
||||
Edited,
|
||||
}
|
||||
|
||||
struct Pen<'a> {
|
||||
p: &'a Painter,
|
||||
r: Rect,
|
||||
s: Stroke,
|
||||
k: f32,
|
||||
}
|
||||
|
||||
impl Pen<'_> {
|
||||
fn pt(&self, x: f32, y: f32) -> Pos2 {
|
||||
pos2(self.r.min.x + x * self.k, self.r.min.y + y * self.k)
|
||||
}
|
||||
fn line(&self, pts: &[(f32, f32)]) {
|
||||
let v: Vec<Pos2> = pts.iter().map(|(x, y)| self.pt(*x, *y)).collect();
|
||||
self.p.add(Shape::line(v, self.s));
|
||||
}
|
||||
fn closed(&self, pts: &[(f32, f32)]) {
|
||||
let v: Vec<Pos2> = pts.iter().map(|(x, y)| self.pt(*x, *y)).collect();
|
||||
self.p.add(Shape::closed_line(v, self.s));
|
||||
}
|
||||
fn fill(&self, pts: &[(f32, f32)], c: Color32) {
|
||||
let v: Vec<Pos2> = pts.iter().map(|(x, y)| self.pt(*x, *y)).collect();
|
||||
self.p.add(Shape::convex_polygon(v, c, Stroke::NONE));
|
||||
}
|
||||
fn circle(&self, x: f32, y: f32, r: f32) {
|
||||
self.p.circle_stroke(self.pt(x, y), r * self.k, self.s);
|
||||
}
|
||||
fn dot(&self, x: f32, y: f32, r: f32) {
|
||||
self.p.circle_filled(self.pt(x, y), r * self.k, self.s.color);
|
||||
}
|
||||
fn rect(&self, x0: f32, y0: f32, x1: f32, y1: f32, round: f32) {
|
||||
self.p.rect_stroke(Rect::from_min_max(self.pt(x0, y0), self.pt(x1, y1)), round * self.k, self.s, egui::StrokeKind::Middle);
|
||||
}
|
||||
fn rect_fill(&self, x0: f32, y0: f32, x1: f32, y1: f32, round: f32) {
|
||||
self.p.rect_filled(Rect::from_min_max(self.pt(x0, y0), self.pt(x1, y1)), round * self.k, self.s.color);
|
||||
}
|
||||
fn arc(&self, cx: f32, cy: f32, r: f32, a0: f32, a1: f32) {
|
||||
let n = 24;
|
||||
let v: Vec<(f32, f32)> = (0..=n)
|
||||
.map(|i| {
|
||||
let a = (a0 + (a1 - a0) * i as f32 / n as f32).to_radians();
|
||||
(cx + r * a.cos(), cy + r * a.sin())
|
||||
})
|
||||
.collect();
|
||||
self.line(&v);
|
||||
}
|
||||
}
|
||||
|
||||
fn star_pts(cx: f32, cy: f32, r: f32) -> Vec<(f32, f32)> {
|
||||
(0..10)
|
||||
.map(|i| {
|
||||
let a = (-90.0 + i as f32 * 36.0f32).to_radians();
|
||||
let rr = if i % 2 == 0 { r } else { r * 0.45 };
|
||||
(cx + rr * a.cos(), cy + rr * a.sin())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Paint `icon` centred in `rect` (square area used) with `color`.
|
||||
pub fn paint(p: &Painter, rect: Rect, icon: Icon, color: Color32) {
|
||||
let side = rect.width().min(rect.height());
|
||||
let r = Rect::from_center_size(rect.center(), vec2(side, side));
|
||||
let k = side / 20.0;
|
||||
let pen = Pen { p, r, s: Stroke::new((1.5 * k).max(1.0), color), k };
|
||||
use Icon::*;
|
||||
match icon {
|
||||
Sidebar => {
|
||||
pen.rect(3.0, 4.0, 17.0, 16.0, 1.5);
|
||||
pen.line(&[(8.0, 4.0), (8.0, 16.0)]);
|
||||
}
|
||||
Back => pen.line(&[(16.0, 10.0), (4.0, 10.0), (9.0, 5.0)]),
|
||||
Forward => pen.line(&[(4.0, 10.0), (16.0, 10.0), (11.0, 5.0)]),
|
||||
Search => {
|
||||
pen.circle(8.5, 8.5, 5.0);
|
||||
pen.line(&[(12.2, 12.2), (16.5, 16.5)]);
|
||||
}
|
||||
Filter => pen.closed(&[(3.0, 4.0), (17.0, 4.0), (11.5, 10.5), (11.5, 16.0), (8.5, 14.5), (8.5, 10.5)]),
|
||||
Bell => {
|
||||
pen.line(&[(5.0, 14.0), (5.0, 9.0)]);
|
||||
pen.arc(10.0, 9.0, 5.0, 180.0, 360.0);
|
||||
pen.line(&[(15.0, 9.0), (15.0, 14.0)]);
|
||||
pen.line(&[(3.5, 14.0), (16.5, 14.0)]);
|
||||
pen.arc(10.0, 15.5, 1.8, 0.0, 180.0);
|
||||
}
|
||||
Share => {
|
||||
pen.line(&[(6.0, 8.0), (4.0, 8.0), (4.0, 17.0), (16.0, 17.0), (16.0, 8.0), (14.0, 8.0)]);
|
||||
pen.line(&[(10.0, 12.0), (10.0, 2.5)]);
|
||||
pen.line(&[(6.5, 6.0), (10.0, 2.5), (13.5, 6.0)]);
|
||||
}
|
||||
Help => {
|
||||
pen.circle(10.0, 10.0, 7.5);
|
||||
pen.arc(10.0, 8.0, 2.5, 180.0, 400.0);
|
||||
pen.line(&[(10.0, 10.5), (10.0, 11.8)]);
|
||||
pen.dot(10.0, 14.2, 0.9);
|
||||
}
|
||||
Cloud => {
|
||||
pen.arc(7.0, 11.5, 3.5, 90.0, 270.0);
|
||||
pen.arc(10.5, 8.5, 4.5, 190.0, 340.0);
|
||||
pen.arc(14.0, 11.8, 3.2, 270.0, 450.0);
|
||||
pen.line(&[(7.0, 15.0), (14.0, 15.0)]);
|
||||
}
|
||||
Sliders => {
|
||||
for (y, x) in [(5.0, 12.0), (10.0, 7.0), (15.0, 13.0)] {
|
||||
pen.line(&[(3.0, y), (x - 2.0, y)]);
|
||||
pen.line(&[(x + 2.0, y), (17.0, y)]);
|
||||
pen.circle(x, y, 2.0);
|
||||
}
|
||||
}
|
||||
Crop => {
|
||||
pen.line(&[(6.0, 2.5), (6.0, 14.0), (17.5, 14.0)]);
|
||||
pen.line(&[(2.5, 6.0), (14.0, 6.0), (14.0, 17.5)]);
|
||||
pen.line(&[(15.5, 2.0), (17.5, 4.0), (15.5, 6.0)]);
|
||||
}
|
||||
Eraser => {
|
||||
pen.closed(&[(3.0, 12.0), (10.0, 5.0), (16.0, 11.0), (11.0, 16.0), (7.0, 16.0)]);
|
||||
pen.line(&[(6.5, 8.5), (12.5, 14.5)]);
|
||||
pen.line(&[(7.0, 17.5), (17.0, 17.5)]);
|
||||
}
|
||||
Mask => {
|
||||
pen.circle(10.0, 10.0, 7.0);
|
||||
for i in 0..5 {
|
||||
for j in 0..5 {
|
||||
let (x, y) = (5.0 + i as f32 * 2.5, 5.0 + j as f32 * 2.5);
|
||||
if (x - 10.0).hypot(y - 10.0) < 5.8 {
|
||||
pen.dot(x, y, 0.6);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Eye => {
|
||||
pen.arc(10.0, 16.0, 9.0, 212.0, 328.0);
|
||||
pen.arc(10.0, 4.0, 9.0, 32.0, 148.0);
|
||||
pen.circle(10.0, 10.0, 2.5);
|
||||
}
|
||||
Presets => {
|
||||
pen.circle(8.0, 8.0, 5.0);
|
||||
pen.circle(12.0, 12.0, 5.0);
|
||||
}
|
||||
Versions => {
|
||||
pen.rect(5.0, 3.0, 17.0, 13.0, 1.5);
|
||||
pen.line(&[(3.0, 6.0), (3.0, 16.0), (14.0, 16.0)]);
|
||||
}
|
||||
Activity => {
|
||||
pen.circle(10.0, 10.0, 7.0);
|
||||
pen.line(&[(10.0, 6.0), (10.0, 10.0), (13.0, 12.0)]);
|
||||
}
|
||||
More | Dots => {
|
||||
for x in [5.0, 10.0, 15.0] {
|
||||
pen.dot(x, 10.0, 1.3);
|
||||
}
|
||||
}
|
||||
Tag => {
|
||||
pen.closed(&[(3.0, 3.5), (10.0, 3.5), (17.0, 10.5), (10.5, 17.0), (3.0, 10.0)]);
|
||||
pen.dot(7.0, 7.5, 1.2);
|
||||
}
|
||||
Info => {
|
||||
pen.circle(10.0, 10.0, 7.5);
|
||||
pen.line(&[(10.0, 9.0), (10.0, 14.0)]);
|
||||
pen.dot(10.0, 6.3, 0.9);
|
||||
}
|
||||
GridPhoto => {
|
||||
pen.rect_fill(3.0, 4.0, 10.0, 9.5, 0.5);
|
||||
pen.rect_fill(11.0, 4.0, 17.0, 9.5, 0.5);
|
||||
pen.rect_fill(3.0, 10.5, 8.0, 16.0, 0.5);
|
||||
pen.rect_fill(9.0, 10.5, 17.0, 16.0, 0.5);
|
||||
}
|
||||
GridSquare => {
|
||||
pen.rect_fill(3.5, 4.0, 9.5, 9.5, 0.5);
|
||||
pen.rect_fill(10.5, 4.0, 16.5, 9.5, 0.5);
|
||||
pen.rect_fill(3.5, 10.5, 9.5, 16.0, 0.5);
|
||||
pen.rect_fill(10.5, 10.5, 16.5, 16.0, 0.5);
|
||||
}
|
||||
Single => pen.rect_fill(3.0, 5.0, 17.0, 15.0, 0.5),
|
||||
Compare => {
|
||||
pen.rect_fill(3.0, 5.0, 9.5, 15.0, 0.5);
|
||||
pen.rect_fill(10.5, 5.0, 17.0, 15.0, 0.5);
|
||||
}
|
||||
Sort => {
|
||||
pen.line(&[(3.0, 5.0), (17.0, 5.0)]);
|
||||
pen.line(&[(3.0, 10.0), (13.0, 10.0)]);
|
||||
pen.line(&[(3.0, 15.0), (9.0, 15.0)]);
|
||||
}
|
||||
Star => pen.closed(&star_pts(10.0, 10.5, 7.5)),
|
||||
StarFilled => {
|
||||
let pts = star_pts(10.0, 10.5, 7.5);
|
||||
// convex fan: fill triangles from the centre
|
||||
for i in 0..10 {
|
||||
let a = pts[i];
|
||||
let b = pts[(i + 1) % 10];
|
||||
pen.fill(&[(10.0, 10.5), a, b], color);
|
||||
}
|
||||
}
|
||||
Circle => pen.circle(10.0, 10.0, 6.0),
|
||||
FlagPick => {
|
||||
pen.line(&[(5.0, 17.0), (5.0, 3.5)]);
|
||||
pen.closed(&[(5.0, 3.5), (15.0, 3.5), (12.5, 7.0), (15.0, 10.5), (5.0, 10.5)]);
|
||||
}
|
||||
FlagReject => {
|
||||
pen.line(&[(5.0, 17.0), (5.0, 3.5)]);
|
||||
pen.closed(&[(5.0, 3.5), (15.0, 3.5), (12.5, 7.0), (15.0, 10.5), (5.0, 10.5)]);
|
||||
pen.line(&[(8.0, 5.0), (12.0, 9.0)]);
|
||||
pen.line(&[(12.0, 5.0), (8.0, 9.0)]);
|
||||
}
|
||||
Gear => {
|
||||
pen.circle(10.0, 10.0, 2.5);
|
||||
for i in 0..8 {
|
||||
let a = (i as f32 * 45.0f32).to_radians();
|
||||
pen.line(&[(10.0 + 5.2 * a.cos(), 10.0 + 5.2 * a.sin()), (10.0 + 7.5 * a.cos(), 10.0 + 7.5 * a.sin())]);
|
||||
}
|
||||
pen.circle(10.0, 10.0, 5.2);
|
||||
}
|
||||
Filmstrip => {
|
||||
pen.rect(2.5, 5.0, 17.5, 15.0, 1.0);
|
||||
pen.rect_fill(5.0, 11.0, 8.5, 13.5, 0.3);
|
||||
pen.rect_fill(9.5, 11.0, 13.0, 13.5, 0.3);
|
||||
pen.line(&[(2.5, 9.5), (17.5, 9.5)]);
|
||||
}
|
||||
BeforeAfter => {
|
||||
pen.rect(3.0, 4.0, 17.0, 16.0, 1.0);
|
||||
pen.line(&[(10.0, 2.5), (10.0, 17.5)]);
|
||||
pen.rect_fill(10.0, 4.0, 17.0, 16.0, 0.5);
|
||||
}
|
||||
ChevronDown => pen.line(&[(5.0, 7.5), (10.0, 12.5), (15.0, 7.5)]),
|
||||
ChevronRight => pen.line(&[(7.5, 5.0), (12.5, 10.0), (7.5, 15.0)]),
|
||||
ChevronLeft => pen.line(&[(12.5, 5.0), (7.5, 10.0), (12.5, 15.0)]),
|
||||
TriangleLeft => pen.fill(&[(13.0, 6.0), (13.0, 14.0), (7.0, 10.0)], color),
|
||||
Plus => {
|
||||
pen.line(&[(10.0, 4.0), (10.0, 16.0)]);
|
||||
pen.line(&[(4.0, 10.0), (16.0, 10.0)]);
|
||||
}
|
||||
Minus => pen.line(&[(4.0, 10.0), (16.0, 10.0)]),
|
||||
Album => {
|
||||
pen.rect(3.0, 5.0, 15.0, 17.0, 1.0);
|
||||
pen.line(&[(6.0, 2.5), (17.5, 2.5), (17.5, 14.0)]);
|
||||
}
|
||||
Folder => pen.closed(&[(2.5, 5.0), (8.0, 5.0), (9.5, 7.0), (17.5, 7.0), (17.5, 15.5), (2.5, 15.5)]),
|
||||
Photos => {
|
||||
pen.rect(2.5, 4.0, 17.5, 16.0, 1.5);
|
||||
pen.line(&[(2.5, 13.5), (7.0, 9.5), (11.0, 13.0), (13.5, 11.0), (17.5, 14.5)]);
|
||||
pen.dot(13.5, 7.5, 1.3);
|
||||
}
|
||||
Clock => {
|
||||
pen.circle(10.0, 10.0, 7.0);
|
||||
pen.line(&[(10.0, 5.5), (10.0, 10.0), (13.5, 11.5)]);
|
||||
}
|
||||
Trash => {
|
||||
pen.line(&[(3.0, 5.5), (17.0, 5.5)]);
|
||||
pen.line(&[(8.0, 5.5), (8.0, 3.0), (12.0, 3.0), (12.0, 5.5)]);
|
||||
pen.closed(&[(5.0, 5.5), (15.0, 5.5), (14.0, 17.0), (6.0, 17.0)]);
|
||||
}
|
||||
Curve => {
|
||||
pen.rect(3.0, 3.0, 17.0, 17.0, 1.5);
|
||||
let pts: Vec<(f32, f32)> = (0..=12)
|
||||
.map(|i| {
|
||||
let t = i as f32 / 12.0;
|
||||
let y = t * t * (3.0 - 2.0 * t);
|
||||
(4.5 + 11.0 * t, 15.5 - 11.0 * y)
|
||||
})
|
||||
.collect();
|
||||
pen.line(&pts);
|
||||
}
|
||||
ProfileGrid => {
|
||||
pen.rect_fill(3.5, 3.5, 9.0, 9.0, 0.5);
|
||||
pen.rect_fill(11.0, 3.5, 16.5, 9.0, 0.5);
|
||||
pen.rect_fill(3.5, 11.0, 9.0, 16.5, 0.5);
|
||||
pen.circle(14.0, 14.0, 2.5);
|
||||
pen.line(&[(15.8, 15.8), (17.5, 17.5)]);
|
||||
}
|
||||
Close => {
|
||||
pen.line(&[(5.0, 5.0), (15.0, 15.0)]);
|
||||
pen.line(&[(15.0, 5.0), (5.0, 15.0)]);
|
||||
}
|
||||
Check => pen.line(&[(4.0, 10.5), (8.5, 15.0), (16.0, 5.5)]),
|
||||
Brush => {
|
||||
pen.line(&[(16.5, 3.5), (9.0, 11.0)]);
|
||||
pen.closed(&[(9.0, 11.0), (6.0, 11.5), (4.0, 14.5), (3.5, 17.0), (6.0, 16.5), (8.5, 14.0)]);
|
||||
}
|
||||
Linear => {
|
||||
for (i, x) in [4.0, 7.5, 11.0, 14.5].iter().enumerate() {
|
||||
let a = 1.0 - i as f32 * 0.22;
|
||||
pen.p.line_segment([pen.pt(*x, 3.0), pen.pt(*x, 17.0)], Stroke::new(pen.s.width, color.gamma_multiply(a)));
|
||||
}
|
||||
}
|
||||
Radial => {
|
||||
pen.circle(10.0, 10.0, 7.0);
|
||||
pen.p.circle_stroke(pen.pt(10.0, 10.0), 3.5 * k, Stroke::new(pen.s.width, color.gamma_multiply(0.5)));
|
||||
}
|
||||
Sky => {
|
||||
pen.arc(8.0, 11.0, 3.2, 150.0, 300.0);
|
||||
pen.arc(12.0, 9.5, 3.8, 210.0, 360.0);
|
||||
pen.line(&[(5.0, 13.5), (15.5, 13.5)]);
|
||||
pen.line(&[(2.5, 17.0), (17.5, 17.0)]);
|
||||
}
|
||||
Subject => {
|
||||
pen.circle(10.0, 7.0, 3.0);
|
||||
pen.arc(10.0, 18.0, 6.5, 200.0, 340.0);
|
||||
pen.rect(2.5, 2.5, 17.5, 17.5, 2.0);
|
||||
}
|
||||
Picker => {
|
||||
pen.line(&[(4.0, 16.0), (11.5, 8.5)]);
|
||||
pen.closed(&[(11.0, 5.0), (15.0, 9.0), (17.0, 7.0), (13.0, 3.0)]);
|
||||
}
|
||||
Rotate => {
|
||||
pen.arc(10.0, 10.0, 6.5, 200.0, 470.0);
|
||||
pen.line(&[(3.0, 5.5), (4.0, 9.0), (7.5, 8.0)]);
|
||||
}
|
||||
Flip => {
|
||||
pen.line(&[(10.0, 2.5), (10.0, 17.5)]);
|
||||
pen.closed(&[(8.0, 5.0), (8.0, 15.0), (3.0, 15.0)]);
|
||||
pen.fill(&[(12.0, 5.0), (12.0, 15.0), (17.0, 15.0)], color);
|
||||
}
|
||||
Invert => {
|
||||
pen.circle(10.0, 10.0, 7.0);
|
||||
pen.p.add(Shape::convex_polygon(
|
||||
(0..=16).map(|i| pen.pt(10.0 + 7.0 * ((90.0 + i as f32 * 180.0 / 16.0f32).to_radians()).cos(), 10.0 + 7.0 * ((90.0 + i as f32 * 180.0 / 16.0f32).to_radians()).sin())).collect(),
|
||||
color,
|
||||
Stroke::NONE,
|
||||
));
|
||||
}
|
||||
Video => {
|
||||
pen.rect(2.5, 5.0, 13.0, 15.0, 1.5);
|
||||
pen.closed(&[(13.0, 8.5), (17.5, 6.0), (17.5, 14.0), (13.0, 11.5)]);
|
||||
}
|
||||
Heart => pen.closed(&[(10.0, 16.5), (3.5, 9.5), (4.0, 5.5), (7.5, 4.0), (10.0, 6.5), (12.5, 4.0), (16.0, 5.5), (16.5, 9.5)]),
|
||||
Edited => {
|
||||
pen.circle(10.0, 10.0, 7.0);
|
||||
pen.line(&[(7.0, 13.0), (13.0, 7.0)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
//! LightCraft's egui frontend: a Lightroom-style UI over `lightcraft-engine`.
|
||||
//!
|
||||
//! The UI is thin: every action goes through [`LightcraftApp::run`], which handles UI commands
|
||||
//! (views, panels, zoom — see [`menus::UI_COMMANDS`]) and forwards everything else to the engine.
|
||||
//! The same entry point serves menus, shortcuts, buttons and the control channel ([`control`]).
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod control;
|
||||
pub mod icons;
|
||||
pub mod menus;
|
||||
pub mod panels;
|
||||
pub mod render;
|
||||
pub mod shortcuts;
|
||||
pub mod state;
|
||||
pub mod theme;
|
||||
pub mod widgets;
|
||||
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
|
||||
use lightcraft_engine::Session;
|
||||
use serde_json::Value;
|
||||
|
||||
pub use control::{ControlRequest, ControlResponse};
|
||||
pub use state::UiState;
|
||||
|
||||
pub type PickFiles = Box<dyn FnMut() -> Vec<String>>;
|
||||
pub type WriteFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>;
|
||||
pub type PngEncode = Box<dyn Fn(&lightcraft_raster::Rgba8) -> Vec<u8>>;
|
||||
|
||||
/// Platform services injected by the host app (desktop or web).
|
||||
#[derive(Default)]
|
||||
pub struct Services {
|
||||
/// Show an open dialog for photos; returns paths.
|
||||
pub pick_files: Option<PickFiles>,
|
||||
pub write: Option<WriteFn>,
|
||||
/// PNG encoder (the host links an image encoder; the UI crate stays codec-free).
|
||||
pub png: Option<PngEncode>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct Perf {
|
||||
pub frame_ms: f64,
|
||||
pub fps: f64,
|
||||
}
|
||||
|
||||
pub struct LightcraftApp {
|
||||
pub session: Session,
|
||||
pub ui: UiState,
|
||||
pub services: Services,
|
||||
pub renderer: render::Renderer,
|
||||
pub perf: Perf,
|
||||
/// macOS: the host draws the traffic lights over our top bar.
|
||||
pub integrated_titlebar: bool,
|
||||
/// The host installed a native menu bar.
|
||||
pub native_menu: bool,
|
||||
control_rx: Option<Receiver<ControlRequest>>,
|
||||
pending_screenshots: Vec<(u64, Option<String>, Sender<ControlResponse>)>,
|
||||
queued_screenshots: Vec<(u64, f64, u32)>,
|
||||
screenshot_token: u64,
|
||||
/// Synthetic input events (from the control channel) injected one step per frame.
|
||||
pub synthetic: Vec<egui::Event>,
|
||||
styled: bool,
|
||||
fonts_ready: bool,
|
||||
last_time: f64,
|
||||
/// Rect of the photo canvas and the displayed image (screen points) from the last frame.
|
||||
pub canvas_rect: Option<egui::Rect>,
|
||||
pub image_rect: Option<egui::Rect>,
|
||||
/// Widget registry from the last frame (automation ids → rects).
|
||||
pub widgets: Vec<(String, egui::Rect)>,
|
||||
/// In-progress on-canvas gesture (brush stroke points, gradient drag…).
|
||||
pub gesture: Option<panels::detail::Gesture>,
|
||||
}
|
||||
|
||||
impl LightcraftApp {
|
||||
pub fn new(session: Session, services: Services) -> Self {
|
||||
Self {
|
||||
session,
|
||||
ui: UiState::default(),
|
||||
services,
|
||||
renderer: render::Renderer::default(),
|
||||
perf: Perf::default(),
|
||||
integrated_titlebar: false,
|
||||
native_menu: false,
|
||||
control_rx: None,
|
||||
pending_screenshots: vec![],
|
||||
queued_screenshots: vec![],
|
||||
screenshot_token: 0,
|
||||
synthetic: vec![],
|
||||
styled: false,
|
||||
fonts_ready: false,
|
||||
last_time: 0.0,
|
||||
canvas_rect: None,
|
||||
image_rect: None,
|
||||
widgets: vec![],
|
||||
gesture: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_control(mut self, rx: Receiver<ControlRequest>) -> Self {
|
||||
self.control_rx = Some(rx);
|
||||
self
|
||||
}
|
||||
|
||||
/// Run a UI or engine command by id. The single entry point for every frontend path.
|
||||
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
|
||||
if let Some(r) = menus::run_ui_command(self, id, ¶ms) {
|
||||
return r;
|
||||
}
|
||||
let r = self.session.execute(id, ¶ms).map_err(|e| e.to_string());
|
||||
if let Err(e) = &r {
|
||||
self.ui.status = e.clone();
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// Show a transient toast at the bottom of the canvas (like the reference app's HUD).
|
||||
pub fn toast(&mut self, ctx: &egui::Context, text: impl Into<String>) {
|
||||
let t = ctx.input(|i| i.time);
|
||||
self.ui.toast = Some((text.into(), t + 1.4));
|
||||
}
|
||||
|
||||
fn drain_control(&mut self, ctx: &egui::Context) {
|
||||
let Some(rx) = self.control_rx.take() else { return };
|
||||
while let Ok(req) = rx.try_recv() {
|
||||
let reply = req.reply.clone();
|
||||
match control::handle(self, ctx, &req) {
|
||||
control::Outcome::Done(v) => {
|
||||
let _ = reply.send(v);
|
||||
}
|
||||
control::Outcome::Screenshot { path } => {
|
||||
self.screenshot_token += 1;
|
||||
let token = self.screenshot_token;
|
||||
self.queued_screenshots.push((token, now_ms() + 120.0, 0));
|
||||
self.pending_screenshots.push((token, path, reply));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.control_rx = Some(rx);
|
||||
}
|
||||
|
||||
fn issue_screenshots(&mut self, ctx: &egui::Context) {
|
||||
let now = now_ms();
|
||||
let busy = self.renderer.queued() > 0;
|
||||
self.queued_screenshots.retain_mut(|(token, at, frames)| {
|
||||
*frames += 1;
|
||||
// wait for pending renders (up to ~3 s) so screenshots show finished pixels
|
||||
if now >= *at && *frames >= 3 && (!busy || *frames > 180) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(*token)));
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
if !self.queued_screenshots.is_empty() || !self.pending_screenshots.is_empty() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(16));
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_screenshots(&mut self, ctx: &egui::Context) {
|
||||
if self.pending_screenshots.is_empty() {
|
||||
return;
|
||||
}
|
||||
let events: Vec<_> = ctx.input(|i| {
|
||||
i.raw
|
||||
.events
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
egui::Event::Screenshot { user_data, image, .. } => {
|
||||
let token = user_data.data.as_ref().and_then(|d| d.downcast_ref::<u64>()).copied()?;
|
||||
Some((token, image.clone()))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
for (token, image) in events {
|
||||
if let Some(i) = self.pending_screenshots.iter().position(|(t, _, _)| *t == token) {
|
||||
let (_, path, reply) = self.pending_screenshots.remove(i);
|
||||
let _ = reply.send(control::save_screenshot(self, &image, path.as_deref()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-frame logic before layout (control channel, renders, shortcuts, drops).
|
||||
pub fn logic(&mut self, ctx: &egui::Context) {
|
||||
if !self.styled {
|
||||
theme::install_fonts(ctx);
|
||||
theme::apply(ctx);
|
||||
self.styled = true;
|
||||
} else {
|
||||
self.fonts_ready = true;
|
||||
}
|
||||
let now = ctx.input(|i| i.time);
|
||||
let dt = now - self.last_time;
|
||||
if dt > 0.0 {
|
||||
self.perf.fps = self.perf.fps * 0.9 + (1.0 / dt).min(240.0) * 0.1;
|
||||
}
|
||||
self.last_time = now;
|
||||
self.drain_control(ctx);
|
||||
if !self.synthetic.is_empty() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
self.renderer.poll(ctx, &mut self.session);
|
||||
self.collect_screenshots(ctx);
|
||||
self.issue_screenshots(ctx);
|
||||
if self.fonts_ready {
|
||||
shortcuts::handle(self, ctx);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let dropped: Vec<String> = ctx.input(|i| i.raw.dropped_files.iter().filter_map(|f| f.path.as_ref().map(|p| p.to_string_lossy().to_string())).collect());
|
||||
if !dropped.is_empty() {
|
||||
let _ = self.run("library.import", serde_json::json!({"paths": dropped}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inject synthetic events (pointer events one per frame; key sequences up to the release).
|
||||
pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) {
|
||||
if self.synthetic.is_empty() {
|
||||
return;
|
||||
}
|
||||
let n = match self.synthetic[0] {
|
||||
egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } | egui::Event::MouseWheel { .. } => 1,
|
||||
_ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1),
|
||||
};
|
||||
if let Some(egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() {
|
||||
raw.events.push(egui::Event::PointerMoved(*p));
|
||||
}
|
||||
if let Some(m) = self.synthetic.iter().take(n).find_map(|e| match e {
|
||||
egui::Event::Key { modifiers, .. } | egui::Event::PointerButton { modifiers, .. } => Some(*modifiers),
|
||||
_ => None,
|
||||
}) {
|
||||
raw.modifiers = m;
|
||||
}
|
||||
raw.events.extend(self.synthetic.drain(..n));
|
||||
}
|
||||
|
||||
/// Lay out the whole window.
|
||||
pub fn ui(&mut self, ui: &mut egui::Ui) {
|
||||
let ctx = ui.ctx().clone();
|
||||
if !self.fonts_ready {
|
||||
ctx.request_repaint();
|
||||
return;
|
||||
}
|
||||
let t0 = now_ms();
|
||||
// Order matters: earlier panels take the full edge (top bar spans the window; the tool strip,
|
||||
// right panels and left panel run to the bottom; the bottom bar sits between them).
|
||||
panels::topbar::show(self, ui);
|
||||
panels::strip::show(self, ui);
|
||||
if self.ui.right != state::RightPanel::None {
|
||||
panels::right::show(self, ui);
|
||||
}
|
||||
if self.ui.presets {
|
||||
panels::presets::show(self, ui);
|
||||
}
|
||||
if self.ui.left_panel {
|
||||
panels::left::show(self, ui);
|
||||
}
|
||||
panels::bottombar::show(self, ui);
|
||||
let t = theme::Tokens::get(&ctx);
|
||||
let bg = if matches!(self.ui.view, state::ViewMode::Detail | state::ViewMode::Compare) { t.canvas } else { t.grid_bg };
|
||||
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(bg)).show(ui, |ui| match self.ui.view {
|
||||
state::ViewMode::PhotoGrid | state::ViewMode::SquareGrid => panels::grid::show(self, ui),
|
||||
state::ViewMode::Detail | state::ViewMode::Compare => panels::detail::show(self, ui),
|
||||
});
|
||||
panels::dialogs::show(self, &ctx);
|
||||
panels::toast(self, &ctx);
|
||||
self.widgets = widgets::take_registry(&ctx);
|
||||
self.perf.frame_ms = now_ms() - t0;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn now_ms() -> f64 {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
0.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
//! The bottom bar: view modes, sort, rating/flags pill, copy/paste settings, zoom and view toggles.
|
||||
|
||||
use egui::{Align2, Rect, Sense, pos2, vec2};
|
||||
use lightcraft_catalog::Flag;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::LightcraftApp;
|
||||
use crate::icons::{Icon, paint};
|
||||
use crate::state::{BeforeAfter, ViewMode, Zoom};
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{icon_button, register, stars};
|
||||
|
||||
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
egui::Panel::bottom("bottom_bar")
|
||||
.exact_size(t.bottom_bar_h)
|
||||
.frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin { left: 12, right: 12, top: 0, bottom: 0 }))
|
||||
.show(ui, |ui| {
|
||||
let full = ui.max_rect();
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 2.0;
|
||||
for (id, icon, mode, tip) in [
|
||||
("photoGrid", Icon::GridPhoto, ViewMode::PhotoGrid, "Photo Grid (G)"),
|
||||
("squareGrid", Icon::GridSquare, ViewMode::SquareGrid, "Square Grid (Shift+G)"),
|
||||
("detail", Icon::Single, ViewMode::Detail, "Detail (D)"),
|
||||
] {
|
||||
if icon_button(ui, id, icon, vec2(32.0, 32.0), app.ui.view == mode, true, tip).clicked() {
|
||||
let _ = app.run(&format!("view.{id}"), json!({}));
|
||||
}
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
let (sep, _) = ui.allocate_exact_size(vec2(1.0, 22.0), Sense::hover());
|
||||
ui.painter().rect_filled(sep, 0.0, t.button_border);
|
||||
ui.add_space(10.0);
|
||||
let sort = icon_button(ui, "sort", Icon::Sort, vec2(30.0, 30.0), false, true, "Sort");
|
||||
egui::Popup::menu(&sort).show(|ui| sort_menu(app, ui));
|
||||
});
|
||||
|
||||
// centre: rating/flag pill + copy/paste settings
|
||||
let active = app.session.active().and_then(|id| app.session.catalog.photo(id).cloned());
|
||||
let pill = Rect::from_center_size(pos2(full.center().x - 60.0, full.center().y), vec2(196.0, 30.0));
|
||||
ui.painter().rect_filled(pill, 15.0, t.canvas);
|
||||
let mut pill_ui = ui.new_child(egui::UiBuilder::new().max_rect(pill.shrink2(vec2(10.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center)));
|
||||
pill_ui.spacing_mut().item_spacing.x = 3.0;
|
||||
let rating = active.as_ref().map(|p| p.rating).unwrap_or(0);
|
||||
if let Some(r) = stars(&mut pill_ui, "bottom", rating, 19.0) {
|
||||
let _ = app.run("photo.rate", json!({"rating": r}));
|
||||
}
|
||||
pill_ui.add_space(6.0);
|
||||
let flag = active.as_ref().map(|p| p.flag).unwrap_or_default();
|
||||
for (id, icon, f) in [("unflag", Icon::Circle, Flag::None), ("pick", Icon::FlagPick, Flag::Pick), ("reject", Icon::FlagReject, Flag::Reject)] {
|
||||
let (r, resp) = pill_ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click());
|
||||
register(pill_ui.ctx(), format!("flag:{id}"), r);
|
||||
let on = flag == f && f != Flag::None;
|
||||
let c = if on {
|
||||
if f == Flag::Reject { t.reject } else { t.pick }
|
||||
} else if resp.hovered() {
|
||||
t.text
|
||||
} else {
|
||||
t.icon
|
||||
};
|
||||
paint(pill_ui.painter(), r.shrink(3.0), icon, c);
|
||||
if resp.clicked() {
|
||||
let _ = app.run("photo.flag", json!({"flag": match f { Flag::Pick => "pick", Flag::Reject => "reject", Flag::None => "none" }}));
|
||||
}
|
||||
}
|
||||
// copy / paste settings
|
||||
let copy_r = Rect::from_min_size(pos2(pill.right() + 10.0, pill.top()), vec2(136.0, 30.0));
|
||||
let has_clip = app.session.clipboard.is_some();
|
||||
let label = if has_clip { "Paste Edit Settings" } else { "Copy Edit Settings" };
|
||||
let cresp = ui.interact(copy_r, egui::Id::new("copy-settings"), Sense::click());
|
||||
register(ui.ctx(), "button:copySettings", copy_r);
|
||||
ui.painter().rect_filled(copy_r, 15.0, if cresp.hovered() { t.hover } else { t.canvas });
|
||||
ui.painter().text(copy_r.center(), Align2::CENTER_CENTER, label, t.font(13.0), if active.is_some() { t.text_label } else { t.text_disabled });
|
||||
if cresp.clicked() && active.is_some() {
|
||||
let _ = if has_clip { app.run("develop.paste", json!({})) } else { app.run("develop.copy", json!({})) };
|
||||
let msg = if has_clip { "Settings pasted" } else { "Edit settings copied" };
|
||||
app.toast(ui.ctx(), 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());
|
||||
register(ui.ctx(), "icon:copyGear", gear_r);
|
||||
ui.painter().rect_filled(gear_r, 15.0, if gresp.hovered() { t.hover } else { t.canvas });
|
||||
paint(ui.painter(), gear_r.shrink(7.0), Icon::Gear, t.icon);
|
||||
if gresp.clicked() {
|
||||
let _ = app.run("dialog.copySettings", json!({}));
|
||||
}
|
||||
|
||||
// right side
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_max(pos2(full.right() - 360.0, full.top()), full.right_bottom())).layout(egui::Layout::right_to_left(egui::Align::Center)));
|
||||
child.spacing_mut().item_spacing.x = 6.0;
|
||||
if matches!(app.ui.view, ViewMode::Detail | ViewMode::Compare) {
|
||||
if icon_button(&mut child, "beforeAfter", Icon::BeforeAfter, vec2(30.0, 30.0), app.ui.before_after != BeforeAfter::Off, true, "Before/After (Y)").clicked() {
|
||||
let _ = app.run("view.beforeAfter", json!({}));
|
||||
}
|
||||
if icon_button(&mut child, "filmstrip", Icon::Filmstrip, vec2(30.0, 30.0), app.ui.filmstrip, true, "Filmstrip (/)").clicked() {
|
||||
let _ = app.run("view.filmstrip", json!({}));
|
||||
}
|
||||
child.add_space(10.0);
|
||||
let zoom_label = match app.ui.zoom {
|
||||
Zoom::Fit => "Fit".to_string(),
|
||||
Zoom::Fill => "Fill".to_string(),
|
||||
Zoom::Percent(p) => format!("{p}%"),
|
||||
};
|
||||
let zr = child.add(egui::Button::new(egui::RichText::new(format!("{zoom_label} ⌄")).font(t.font(13.0)).color(t.text_label)).frame(false));
|
||||
register(child.ctx(), "button:zoom", zr.rect);
|
||||
egui::Popup::menu(&zr).show(|ui| {
|
||||
for (label, z) in [("Fit", Zoom::Fit), ("Fill", Zoom::Fill), ("50%", Zoom::Percent(50)), ("100%", Zoom::Percent(100)), ("200%", Zoom::Percent(200)), ("400%", Zoom::Percent(400))] {
|
||||
if ui.selectable_label(app.ui.zoom == z, label).clicked() {
|
||||
app.ui.zoom = z;
|
||||
app.ui.pan = (0.5, 0.5);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// thumbnail size slider
|
||||
let (r, resp) = child.allocate_exact_size(vec2(110.0, 20.0), Sense::click_and_drag());
|
||||
register(child.ctx(), "slider:thumbSize", r);
|
||||
let p = child.painter();
|
||||
let y = r.center().y;
|
||||
p.line_segment([pos2(r.left(), y), pos2(r.right(), y)], egui::Stroke::new(2.0, t.track));
|
||||
let f = (app.ui.thumb_size - 90.0) / (480.0 - 90.0);
|
||||
let x = r.left() + f * r.width();
|
||||
p.rect_filled(Rect::from_center_size(pos2(x, y), vec2(3.0, 14.0)), 1.0, t.thumb_hover);
|
||||
if let Some(pp) = resp.interact_pointer_pos()
|
||||
&& (resp.dragged() || resp.clicked())
|
||||
{
|
||||
app.ui.thumb_size = 90.0 + ((pp.x - r.left()) / r.width()).clamp(0.0, 1.0) * (480.0 - 90.0);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn sort_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
use lightcraft_catalog::SortKey::*;
|
||||
let cur = app.session.sort;
|
||||
ui.label(egui::RichText::new("Sort by").weak());
|
||||
for (label, key, k) in [
|
||||
("Capture Date", CaptureDate, "captureDate"),
|
||||
("Import Date", ImportDate, "importDate"),
|
||||
("Modified Date", EditDate, "editDate"),
|
||||
("File Name", FileName, "fileName"),
|
||||
("Rating", Rating, "rating"),
|
||||
("File Size", FileSize, "fileSize"),
|
||||
] {
|
||||
if ui.selectable_label(cur.key == key, label).clicked() {
|
||||
let _ = app.run("library.sort", json!({"key": k}));
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
if ui.selectable_label(cur.ascending, "Ascending").clicked() {
|
||||
let _ = app.run("library.sort", json!({"ascending": true}));
|
||||
}
|
||||
if ui.selectable_label(!cur.ascending, "Descending").clicked() {
|
||||
let _ = app.run("library.sort", json!({"ascending": false}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,634 @@
|
||||
//! The Detail (loupe) view: the developed photo, before/after, zoom & pan, the filmstrip, and the
|
||||
//! on-canvas tools (crop, brush/gradient masks, remove spots, white-balance picker).
|
||||
|
||||
use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
use lightcraft_catalog::PhotoId;
|
||||
use lightcraft_develop::{DevelopSettings, MaskShape};
|
||||
use lightcraft_geom::{Affine, Point};
|
||||
use lightcraft_pipeline::geometry::Frame;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::LightcraftApp;
|
||||
use crate::render::Slot;
|
||||
use crate::state::{BeforeAfter, CropOverlay, RightPanel, Zoom};
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::register;
|
||||
|
||||
/// An in-progress on-canvas gesture.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Gesture {
|
||||
Brush { points: Vec<Point> },
|
||||
Spot { points: Vec<Point> },
|
||||
CropHandle { handle: u8, start: lightcraft_geom::Rect, angle: f64 },
|
||||
CropRotate { start_angle: f64, a0: f32 },
|
||||
Pan { start: (f32, f32), at: Pos2 },
|
||||
MaskHandle { mask: u32, handle: u8 },
|
||||
}
|
||||
|
||||
/// Screen ↔ normalized-image mapping for the displayed frame.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct CanvasMap {
|
||||
rect: Rect,
|
||||
to_norm: Affine,
|
||||
from_norm: Affine,
|
||||
k: f32,
|
||||
}
|
||||
|
||||
impl CanvasMap {
|
||||
fn new(frame: &Frame, rect: Rect) -> CanvasMap {
|
||||
let k = 8.0f32;
|
||||
let (w, h) = ((rect.width() * k).round().max(1.0) as usize, (rect.height() * k).round().max(1.0) as usize);
|
||||
let to_norm = frame.out_to_norm(w, h);
|
||||
CanvasMap { rect, to_norm, from_norm: to_norm.inverse().unwrap_or(Affine::IDENTITY), k }
|
||||
}
|
||||
pub fn norm(&self, p: Pos2) -> Point {
|
||||
self.to_norm.apply(Point::new(((p.x - self.rect.left()) * self.k) as f64, ((p.y - self.rect.top()) * self.k) as f64))
|
||||
}
|
||||
pub fn screen(&self, n: Point) -> Pos2 {
|
||||
let q = self.from_norm.apply(n);
|
||||
pos2(self.rect.left() + q.x as f32 / self.k, self.rect.top() + q.y as f32 / self.k)
|
||||
}
|
||||
}
|
||||
|
||||
fn fit_rect(area: Rect, aspect: f32, zoom: Zoom, img_px: [usize; 2], ppp: f32, pan: (f32, f32)) -> Rect {
|
||||
let (aw, ah) = (area.width(), area.height());
|
||||
let (w, h) = match zoom {
|
||||
Zoom::Fit => {
|
||||
if aw / ah > aspect {
|
||||
(ah * aspect, ah)
|
||||
} else {
|
||||
(aw, aw / aspect)
|
||||
}
|
||||
}
|
||||
Zoom::Fill => {
|
||||
if aw / ah > aspect {
|
||||
(aw, aw / aspect)
|
||||
} else {
|
||||
(ah * aspect, ah)
|
||||
}
|
||||
}
|
||||
Zoom::Percent(p) => {
|
||||
// full-resolution pixels at p% (the photo's native width)
|
||||
let w = img_px[0] as f32 * p as f32 / 100.0 / ppp;
|
||||
(w, w / aspect)
|
||||
}
|
||||
};
|
||||
let c = if w <= aw && h <= ah {
|
||||
area.center()
|
||||
} else {
|
||||
// pan: which normalized point of the image sits at the area centre
|
||||
pos2(area.center().x - (pan.0 - 0.5) * w, area.center().y - (pan.1 - 0.5) * h)
|
||||
};
|
||||
Rect::from_center_size(c, vec2(w, h))
|
||||
}
|
||||
|
||||
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let full = ui.max_rect();
|
||||
let film_h = if app.ui.filmstrip { t.film_h } else { 0.0 };
|
||||
let canvas = Rect::from_min_max(full.min, pos2(full.right(), full.bottom() - film_h));
|
||||
app.canvas_rect = Some(canvas);
|
||||
if app.ui.filmstrip {
|
||||
filmstrip(app, ui, Rect::from_min_max(pos2(full.left(), canvas.bottom()), full.max));
|
||||
}
|
||||
let Some(id) = app.session.active() else {
|
||||
super::empty_message(ui, canvas, "No photo selected", "Choose a photo in the grid or filmstrip");
|
||||
return;
|
||||
};
|
||||
let Some(photo) = app.session.catalog.photo(id).cloned() else { return };
|
||||
let d = (*photo.develop).clone();
|
||||
let crop_tool = app.ui.right == RightPanel::Crop;
|
||||
let frame = Frame::new(photo.width.max(1) as usize, photo.height.max(1) as usize, &d, !crop_tool);
|
||||
let aspect = frame.aspect() as f32;
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let area = canvas.shrink(if crop_tool { 48.0 } else { 24.0 });
|
||||
let native = [photo.width.max(1) as usize, photo.height.max(1) as usize];
|
||||
let split = app.ui.before_after == BeforeAfter::SideBySide;
|
||||
let areas: Vec<Rect> = if split {
|
||||
let half = area.width() / 2.0 - 6.0;
|
||||
vec![Rect::from_min_size(area.min, vec2(half, area.height())), Rect::from_min_size(pos2(area.center().x + 6.0, area.top()), vec2(half, area.height()))]
|
||||
} else {
|
||||
vec![area]
|
||||
};
|
||||
let main_area = *areas.last().unwrap_or(&area);
|
||||
let img_rect = fit_rect(main_area, aspect, app.ui.zoom, native, ppp, app.ui.pan);
|
||||
app.image_rect = Some(img_rect);
|
||||
// request renders: the loupe at display resolution (drafts during drags)
|
||||
let interacting = app.session.interaction.is_some();
|
||||
let scale = if interacting { 0.6 } else { 1.0 };
|
||||
let want = (img_rect.width().max(img_rect.height()) * ppp * scale).min(2560.0) as usize;
|
||||
let (rw, rh) = if aspect >= 1.0 { (want, (want as f32 / aspect) as usize) } else { ((want as f32 * aspect) as usize, want) };
|
||||
if let Some(job) = app.session.render_job(id, rw.max(8), rh.max(8), false, !crop_tool) {
|
||||
app.renderer.request(Slot::Main, job, 100);
|
||||
}
|
||||
let show_before = app.ui.before_after == BeforeAfter::Original || ui.input(|i| i.key_down(egui::Key::Backslash));
|
||||
if split || show_before || app.ui.before_after == BeforeAfter::Split {
|
||||
if let Some(job) = app.session.render_job(id, rw.max(8), rh.max(8), true, !crop_tool) {
|
||||
app.renderer.request(Slot::Before, job, 90);
|
||||
}
|
||||
}
|
||||
let p = ui.painter_at(canvas);
|
||||
let draw = |slot: Slot, r: Rect| {
|
||||
if let Some(tex) = app.renderer.textures.get(&slot).filter(|t| t.photo == id) {
|
||||
p.image(tex.tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
true
|
||||
} else if let Some(tex) = app.renderer.textures.get(&Slot::Thumb(id)) {
|
||||
p.image(tex.tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
if split {
|
||||
let br = fit_rect(areas[0], aspect, app.ui.zoom, native, ppp, app.ui.pan);
|
||||
draw(Slot::Before, br);
|
||||
draw(Slot::Main, img_rect);
|
||||
p.text(pos2(br.left(), br.bottom() + 14.0), Align2::LEFT_CENTER, "Before", t.font(12.0), t.text_dim);
|
||||
p.text(pos2(img_rect.left(), img_rect.bottom() + 14.0), Align2::LEFT_CENTER, "After", t.font(12.0), t.text_dim);
|
||||
} else if show_before {
|
||||
draw(Slot::Before, img_rect);
|
||||
p.text(pos2(img_rect.left() + 8.0, img_rect.top() + 14.0), Align2::LEFT_CENTER, "Before", t.font(12.0), t.text);
|
||||
} else if !draw(Slot::Main, img_rect) {
|
||||
p.text(canvas.center(), Align2::CENTER_CENTER, "Rendering…", t.font(13.0), t.text_dim);
|
||||
}
|
||||
if app.ui.before_after == BeforeAfter::Split {
|
||||
let mid = img_rect.center().x;
|
||||
if let Some(tex) = app.renderer.textures.get(&Slot::Before).filter(|t| t.photo == id) {
|
||||
let left = Rect::from_min_max(img_rect.min, pos2(mid, img_rect.bottom()));
|
||||
p.image(tex.tex.id(), left, Rect::from_min_max(pos2(0.0, 0.0), pos2(0.5, 1.0)), Color32::WHITE);
|
||||
}
|
||||
p.line_segment([pos2(mid, img_rect.top()), pos2(mid, img_rect.bottom())], Stroke::new(1.5, Color32::WHITE));
|
||||
}
|
||||
if app.ui.show_clipping && !show_before {
|
||||
clipping_overlay(app, &p, img_rect);
|
||||
}
|
||||
register(ui.ctx(), "canvas:image", img_rect);
|
||||
let map = CanvasMap::new(&frame, img_rect);
|
||||
let resp = ui.interact(canvas, egui::Id::new("loupe"), Sense::click_and_drag());
|
||||
match app.ui.right {
|
||||
RightPanel::Crop => crop_overlay(app, ui, &resp, &map, &frame, &d, id),
|
||||
RightPanel::Masking => mask_overlay(app, ui, &resp, &map, &d),
|
||||
RightPanel::Remove => remove_overlay(app, ui, &resp, &map, &d),
|
||||
_ => general_interaction(app, ui, &resp, &map, img_rect, canvas, native, aspect),
|
||||
}
|
||||
resp.context_menu(|ui| super::grid::context_menu(app, ui, id));
|
||||
}
|
||||
|
||||
fn clipping_overlay(app: &LightcraftApp, p: &egui::Painter, r: Rect) {
|
||||
// Highlight clipped regions using the histogram's extremes isn't spatial; show a subtle frame hint.
|
||||
if let Some(h) = app.renderer.textures.get(&Slot::Main).and_then(|t| t.histogram.as_ref()) {
|
||||
let (lo, hi) = h.clipping();
|
||||
if hi > 0.003 {
|
||||
p.rect_stroke(r, 0.0, Stroke::new(2.0, Color32::from_rgb(255, 60, 60)), StrokeKind::Outside);
|
||||
}
|
||||
if lo > 0.003 {
|
||||
p.rect_stroke(r.expand(3.0), 0.0, Stroke::new(2.0, Color32::from_rgb(60, 120, 255)), StrokeKind::Outside);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn general_interaction(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, img: Rect, canvas: Rect, native: [usize; 2], aspect: f32) {
|
||||
if app.ui.tool == "wbPicker" {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
if resp.clicked()
|
||||
&& let Some(q) = resp.interact_pointer_pos()
|
||||
{
|
||||
let n = map.norm(q);
|
||||
let _ = app.run("develop.wbPick", json!({"x": n.x, "y": n.y}));
|
||||
app.ui.tool.clear();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// click toggles Fit ↔ 100 % at the clicked point; drag pans when zoomed
|
||||
let zoomed = img.width() > canvas.width() + 1.0 || img.height() > canvas.height() + 1.0;
|
||||
if resp.double_clicked() || (resp.clicked() && !zoomed) {
|
||||
if let Some(q) = resp.interact_pointer_pos() {
|
||||
let u = ((q.x - img.left()) / img.width()).clamp(0.0, 1.0);
|
||||
let v = ((q.y - img.top()) / img.height()).clamp(0.0, 1.0);
|
||||
app.ui.pan = (u, v);
|
||||
}
|
||||
app.ui.zoom = if matches!(app.ui.zoom, Zoom::Fit) { Zoom::Percent(100) } else { Zoom::Fit };
|
||||
} else if resp.clicked() && zoomed {
|
||||
app.ui.zoom = Zoom::Fit;
|
||||
}
|
||||
if zoomed {
|
||||
ui.ctx().set_cursor_icon(if resp.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab });
|
||||
if resp.dragged() {
|
||||
let dlt = resp.drag_delta();
|
||||
app.ui.pan.0 = (app.ui.pan.0 - dlt.x / img.width()).clamp(0.0, 1.0);
|
||||
app.ui.pan.1 = (app.ui.pan.1 - dlt.y / img.height()).clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
let _ = (native, aspect);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------ crop
|
||||
|
||||
fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, frame: &Frame, d: &DevelopSettings, id: PhotoId) {
|
||||
let quad = frame_crop_quad(d, frame);
|
||||
let pts: Vec<Pos2> = quad.iter().map(|q| map.screen(*q)).collect();
|
||||
let p = ui.painter();
|
||||
// darken outside the crop
|
||||
let img = map.rect;
|
||||
let shade = Color32::from_black_alpha(150);
|
||||
let mut mesh = egui::epaint::Mesh::default();
|
||||
let outer = [img.left_top(), img.right_top(), img.right_bottom(), img.left_bottom()];
|
||||
for i in 0..4 {
|
||||
let a = outer[i];
|
||||
let b = outer[(i + 1) % 4];
|
||||
let c = pts[(i + 1) % 4];
|
||||
let dd = pts[i];
|
||||
let base = mesh.vertices.len() as u32;
|
||||
for v in [a, b, c, dd] {
|
||||
mesh.vertices.push(egui::epaint::Vertex { pos: v, uv: Pos2::ZERO, color: shade });
|
||||
}
|
||||
mesh.indices.extend([base, base + 1, base + 2, base, base + 2, base + 3]);
|
||||
}
|
||||
p.add(mesh);
|
||||
p.add(egui::Shape::closed_line(pts.clone(), Stroke::new(1.0, Color32::WHITE)));
|
||||
// overlay guides
|
||||
let lerp = |a: Pos2, b: Pos2, t: f32| a + (b - a) * t;
|
||||
let guide = Stroke::new(0.8, Color32::from_white_alpha(150));
|
||||
let fracs: Vec<f32> = match app.ui.crop_overlay {
|
||||
CropOverlay::Thirds => vec![1.0 / 3.0, 2.0 / 3.0],
|
||||
CropOverlay::Grid => (1..8).map(|i| i as f32 / 8.0).collect(),
|
||||
CropOverlay::Golden => vec![0.382, 0.618],
|
||||
CropOverlay::Diagonal | CropOverlay::None => vec![],
|
||||
};
|
||||
for f in fracs {
|
||||
p.line_segment([lerp(pts[0], pts[1], f), lerp(pts[3], pts[2], f)], guide);
|
||||
p.line_segment([lerp(pts[0], pts[3], f), lerp(pts[1], pts[2], f)], guide);
|
||||
}
|
||||
// handles: 0..3 corners, 4..7 edges (top, right, bottom, left)
|
||||
let handles: Vec<Pos2> = (0..8)
|
||||
.map(|i| if i < 4 { pts[i] } else { lerp(pts[i - 4], pts[(i - 3) % 4], 0.5) })
|
||||
.collect();
|
||||
for (i, h) in handles.iter().enumerate() {
|
||||
register(ui.ctx(), format!("cropHandle:{i}"), Rect::from_center_size(*h, vec2(14.0, 14.0)));
|
||||
let s = if i < 4 { 12.0 } else { 9.0 };
|
||||
p.rect_filled(Rect::from_center_size(*h, vec2(s, 3.0)), 0.0, Color32::WHITE);
|
||||
p.rect_filled(Rect::from_center_size(*h, vec2(3.0, s)), 0.0, Color32::WHITE);
|
||||
}
|
||||
let inside = |q: Pos2| {
|
||||
let n = map.norm(q);
|
||||
let s = to_straight(n, d.crop.geometry.angle, frame);
|
||||
d.crop.geometry.rect.contains(Point::new(s.x.clamp(-1.0, 2.0), s.y))
|
||||
};
|
||||
if let Some(hq) = resp.hover_pos() {
|
||||
let near = handles.iter().position(|h| h.distance(hq) < 12.0);
|
||||
ui.ctx().set_cursor_icon(match near {
|
||||
Some(0 | 2) => egui::CursorIcon::ResizeNwSe,
|
||||
Some(1 | 3) => egui::CursorIcon::ResizeNeSw,
|
||||
Some(4 | 6) => egui::CursorIcon::ResizeVertical,
|
||||
Some(_) => egui::CursorIcon::ResizeHorizontal,
|
||||
None if inside(hq) => egui::CursorIcon::Move,
|
||||
None => egui::CursorIcon::Alias,
|
||||
});
|
||||
}
|
||||
if resp.drag_started()
|
||||
&& let Some(q) = resp.interact_pointer_pos()
|
||||
{
|
||||
let _ = app.run("develop.beginInteraction", json!({"label": "Crop"}));
|
||||
app.gesture = Some(match handles.iter().position(|h| h.distance(q) < 12.0) {
|
||||
Some(h) => Gesture::CropHandle { handle: h as u8, start: d.crop.geometry.rect, angle: d.crop.geometry.angle },
|
||||
None if inside(q) => Gesture::CropHandle { handle: 8, start: d.crop.geometry.rect, angle: d.crop.geometry.angle },
|
||||
None => {
|
||||
let c = map.screen(Point::new(0.5, 0.5));
|
||||
Gesture::CropRotate { start_angle: d.crop.geometry.angle, a0: (q - c).angle() }
|
||||
}
|
||||
});
|
||||
}
|
||||
if resp.dragged()
|
||||
&& let Some(q) = resp.interact_pointer_pos()
|
||||
{
|
||||
match app.gesture.clone() {
|
||||
Some(Gesture::CropHandle { handle, start, angle }) => {
|
||||
let n = to_straight(map.norm(q), angle, frame);
|
||||
let o = resp.interact_pointer_pos().map(|q0| q0 - resp.drag_delta()).unwrap_or(q);
|
||||
let _ = o;
|
||||
let mut r = start;
|
||||
let orig = ui.input(|i| i.pointer.press_origin()).map(|q0| to_straight(map.norm(q0), angle, frame)).unwrap_or(n);
|
||||
let (dx, dy) = (n.x - orig.x, n.y - orig.y);
|
||||
match handle {
|
||||
0 => (r.x0, r.y0) = (start.x0 + dx, start.y0 + dy),
|
||||
1 => (r.x1, r.y0) = (start.x1 + dx, start.y0 + dy),
|
||||
2 => (r.x1, r.y1) = (start.x1 + dx, start.y1 + dy),
|
||||
3 => (r.x0, r.y1) = (start.x0 + dx, start.y1 + dy),
|
||||
4 => r.y0 = start.y0 + dy,
|
||||
5 => r.x1 = start.x1 + dx,
|
||||
6 => r.y1 = start.y1 + dy,
|
||||
7 => r.x0 = start.x0 + dx,
|
||||
_ => r = start.translate(lightcraft_geom::Vec2::new(dx, dy)),
|
||||
}
|
||||
// aspect lock
|
||||
if let Some((aw, ah)) = d.crop.aspect
|
||||
&& handle < 4
|
||||
{
|
||||
let (iw, ih) = (frame.ow, frame.oh);
|
||||
let a = if (iw >= ih) == (aw >= ah) { aw as f64 / ah as f64 } else { ah as f64 / aw as f64 };
|
||||
let w_px = (r.x1 - r.x0).abs() * iw;
|
||||
let h_px = w_px / a;
|
||||
let hn = h_px / ih;
|
||||
if handle == 0 || handle == 1 {
|
||||
r.y0 = r.y1 - hn;
|
||||
} else {
|
||||
r.y1 = r.y0 + hn;
|
||||
}
|
||||
}
|
||||
let rr = lightcraft_geom::Rect::new(r.x0.min(r.x1), r.y0.min(r.y1), r.x0.max(r.x1), r.y0.max(r.y1));
|
||||
if rr.width() > 0.02 && rr.height() > 0.02 {
|
||||
let _ = app.run("crop.set", json!({"rect": [rr.x0, rr.y0, rr.x1, rr.y1]}));
|
||||
}
|
||||
}
|
||||
Some(Gesture::CropRotate { start_angle, a0 }) => {
|
||||
let c = map.screen(Point::new(0.5, 0.5));
|
||||
let a = (q - c).angle();
|
||||
let ang = (start_angle + (a - a0).to_degrees() as f64).clamp(-45.0, 45.0);
|
||||
let _ = app.run("crop.straighten", json!({"angle": (ang * 100.0).round() / 100.0}));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if resp.drag_stopped() {
|
||||
app.gesture = None;
|
||||
let _ = app.run("develop.endInteraction", json!({}));
|
||||
}
|
||||
let _ = id;
|
||||
}
|
||||
|
||||
/// Normalized oriented coords → the straightened (rotated) frame the crop rect lives in.
|
||||
fn to_straight(n: Point, angle: f64, frame: &Frame) -> Point {
|
||||
let (w, h) = (frame.ow, frame.oh);
|
||||
let px = Point::new(n.x * w, n.y * h);
|
||||
let r = Affine::rotate_about(angle.to_radians(), Point::new(w / 2.0, h / 2.0)).apply(px);
|
||||
Point::new(r.x / w, r.y / h)
|
||||
}
|
||||
|
||||
fn frame_crop_quad(d: &DevelopSettings, frame: &Frame) -> [Point; 4] {
|
||||
let f = Frame { crop: d.crop.geometry, ..frame.clone() };
|
||||
f.crop_quad_norm()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------ masks
|
||||
|
||||
fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, d: &DevelopSettings) {
|
||||
let red = Color32::from_rgba_unmultiplied(230, 30, 40, 90);
|
||||
let long = (map.rect.width().max(map.rect.height())) as f64;
|
||||
let active = app.session.active_mask;
|
||||
let p = ui.painter();
|
||||
for m in &d.masks {
|
||||
let sel = Some(m.id) == active;
|
||||
for c in &m.components {
|
||||
match &c.shape {
|
||||
MaskShape::Brush { strokes } if sel && app.ui.mask_overlay => {
|
||||
for s in strokes {
|
||||
let r = (s.size * long) as f32;
|
||||
for q in &s.points {
|
||||
p.circle_filled(map.screen(*q), r, if s.erase { Color32::from_black_alpha(60) } else { red });
|
||||
}
|
||||
}
|
||||
}
|
||||
MaskShape::Radial { center, rx, ry, angle, .. } => {
|
||||
let c0 = map.screen(*center);
|
||||
let pts: Vec<Pos2> = (0..64)
|
||||
.map(|i| {
|
||||
let a = i as f64 / 64.0 * std::f64::consts::TAU;
|
||||
let (s, co) = angle.to_radians().sin_cos();
|
||||
let (x, y) = (rx * a.cos(), ry * a.sin());
|
||||
let l = frame_long_norm(map);
|
||||
map.screen(Point::new(center.x + (x * co - y * s) * l.0, center.y + (x * s + y * co) * l.1))
|
||||
})
|
||||
.collect();
|
||||
if sel && app.ui.mask_overlay {
|
||||
p.add(egui::Shape::convex_polygon(pts.clone(), red, Stroke::NONE));
|
||||
}
|
||||
p.add(egui::Shape::closed_line(pts, Stroke::new(if sel { 1.5 } else { 1.0 }, Color32::from_white_alpha(if sel { 230 } else { 120 }))));
|
||||
pin(p, c0, sel);
|
||||
if sel {
|
||||
register(ui.ctx(), format!("maskPin:{}", m.id), Rect::from_center_size(c0, vec2(14.0, 14.0)));
|
||||
}
|
||||
}
|
||||
MaskShape::Linear { start, end } => {
|
||||
let (a, b) = (map.screen(*start), map.screen(*end));
|
||||
let dir = (b - a).normalized();
|
||||
let perp = vec2(-dir.y, dir.x) * 3000.0;
|
||||
for (q, alpha) in [(a, 230), (b, 110)] {
|
||||
p.line_segment([q - perp, q + perp], Stroke::new(1.0, Color32::from_white_alpha(if sel { alpha } else { 80 })));
|
||||
}
|
||||
pin(p, a + (b - a) * 0.5, sel);
|
||||
if sel {
|
||||
for (i, q) in [a, b].iter().enumerate() {
|
||||
p.circle_stroke(*q, 5.0, Stroke::new(1.5, Color32::WHITE));
|
||||
register(ui.ctx(), format!("maskHandle:{}:{i}", m.id), Rect::from_center_size(*q, vec2(14.0, 14.0)));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
// brush tool
|
||||
if app.ui.tool == "brush" {
|
||||
let r = (app.ui.brush_size as f64 * long) as f32;
|
||||
if let Some(h) = resp.hover_pos() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::None);
|
||||
p.circle_stroke(h, r, Stroke::new(1.0, Color32::WHITE));
|
||||
p.circle_stroke(h, r * (1.0 - app.ui.brush_feather / 100.0).max(0.05), Stroke::new(1.0, Color32::from_white_alpha(120)));
|
||||
}
|
||||
if let Some(q) = resp.interact_pointer_pos()
|
||||
&& (resp.drag_started() || resp.dragged() || resp.clicked())
|
||||
{
|
||||
let n = map.norm(q);
|
||||
match &mut app.gesture {
|
||||
Some(Gesture::Brush { points }) => points.push(n),
|
||||
_ => app.gesture = Some(Gesture::Brush { points: vec![n] }),
|
||||
}
|
||||
}
|
||||
if let Some(Gesture::Brush { points }) = &app.gesture {
|
||||
for q in points {
|
||||
p.circle_filled(map.screen(*q), r, red);
|
||||
}
|
||||
}
|
||||
if (resp.drag_stopped() || resp.clicked())
|
||||
&& let Some(Gesture::Brush { points }) = app.gesture.take()
|
||||
{
|
||||
let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect();
|
||||
let _ = app.run(
|
||||
"mask.brushStroke",
|
||||
json!({"points": pts, "size": app.ui.brush_size, "feather": app.ui.brush_feather, "flow": app.ui.brush_flow, "erase": app.ui.brush_erase}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// drag pins/handles of the active mask
|
||||
let Some(mid) = active else { return };
|
||||
let Some(m) = d.masks.iter().find(|m| m.id == mid) else { return };
|
||||
let Some(comp) = m.components.first() else { return };
|
||||
if resp.drag_started()
|
||||
&& let Some(q) = resp.interact_pointer_pos()
|
||||
{
|
||||
let handle = match &comp.shape {
|
||||
MaskShape::Radial { center, .. } => (map.screen(*center).distance(q) < 14.0).then_some(0),
|
||||
MaskShape::Linear { start, end } => {
|
||||
if map.screen(*start).distance(q) < 14.0 {
|
||||
Some(1)
|
||||
} else if map.screen(*end).distance(q) < 14.0 {
|
||||
Some(2)
|
||||
} else {
|
||||
Some(0)
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(h) = handle {
|
||||
let _ = app.run("develop.beginInteraction", json!({"label": "Edit Mask"}));
|
||||
app.gesture = Some(Gesture::MaskHandle { mask: mid, handle: h });
|
||||
}
|
||||
}
|
||||
if resp.dragged()
|
||||
&& let (Some(Gesture::MaskHandle { mask, handle }), Some(q)) = (app.gesture.clone(), resp.interact_pointer_pos())
|
||||
{
|
||||
let n = map.norm(q);
|
||||
let dn = {
|
||||
let q0 = q - resp.drag_delta();
|
||||
let n0 = map.norm(q0);
|
||||
Point::new(n.x - n0.x, n.y - n0.y)
|
||||
};
|
||||
let new_shape = match comp.shape.clone() {
|
||||
MaskShape::Radial { center, rx, ry, angle, feather, invert } => {
|
||||
MaskShape::Radial { center: Point::new(center.x + dn.x, center.y + dn.y), rx, ry, angle, feather, invert }
|
||||
}
|
||||
MaskShape::Linear { start, end } => match handle {
|
||||
1 => MaskShape::Linear { start: n, end },
|
||||
2 => MaskShape::Linear { start, end: n },
|
||||
_ => MaskShape::Linear { start: Point::new(start.x + dn.x, start.y + dn.y), end: Point::new(end.x + dn.x, end.y + dn.y) },
|
||||
},
|
||||
s => s,
|
||||
};
|
||||
let _ = app.run("mask.update", json!({"id": mask, "shape": new_shape}));
|
||||
}
|
||||
if resp.drag_stopped() && matches!(app.gesture, Some(Gesture::MaskHandle { .. })) {
|
||||
app.gesture = None;
|
||||
let _ = app.run("develop.endInteraction", json!({}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Long-edge units → normalized units for x and y.
|
||||
fn frame_long_norm(map: &CanvasMap) -> (f64, f64) {
|
||||
// derive from the mapping: 1 normalized unit in x and y in screen points
|
||||
let o = map.screen(Point::new(0.0, 0.0));
|
||||
let x = map.screen(Point::new(1.0, 0.0)).distance(o) as f64;
|
||||
let y = map.screen(Point::new(0.0, 1.0)).distance(o) as f64;
|
||||
let l = x.max(y);
|
||||
(l / x.max(1e-9), l / y.max(1e-9))
|
||||
}
|
||||
|
||||
fn pin(p: &egui::Painter, c: Pos2, sel: bool) {
|
||||
p.circle_filled(c, 6.0, if sel { Color32::from_rgb(1, 101, 221) } else { Color32::from_gray(200) });
|
||||
p.circle_stroke(c, 6.0, Stroke::new(1.5, Color32::WHITE));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------ remove
|
||||
|
||||
fn remove_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, d: &DevelopSettings) {
|
||||
let long = (map.rect.width().max(map.rect.height())) as f64;
|
||||
let p = ui.painter();
|
||||
for s in &d.spots {
|
||||
let r = (s.size * long) as f32;
|
||||
for q in &s.points {
|
||||
p.circle_stroke(map.screen(*q), r, Stroke::new(1.0, Color32::from_white_alpha(200)));
|
||||
}
|
||||
}
|
||||
let r = (app.ui.remove_size as f64 * long) as f32;
|
||||
if let Some(h) = resp.hover_pos() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::None);
|
||||
p.circle_stroke(h, r, Stroke::new(1.0, Color32::WHITE));
|
||||
}
|
||||
if let Some(q) = resp.interact_pointer_pos()
|
||||
&& (resp.drag_started() || resp.dragged() || resp.clicked())
|
||||
{
|
||||
let n = map.norm(q);
|
||||
match &mut app.gesture {
|
||||
Some(Gesture::Spot { points }) => points.push(n),
|
||||
_ => app.gesture = Some(Gesture::Spot { points: vec![n] }),
|
||||
}
|
||||
}
|
||||
if let Some(Gesture::Spot { points }) = &app.gesture {
|
||||
for q in points {
|
||||
p.circle_filled(map.screen(*q), r, Color32::from_white_alpha(60));
|
||||
}
|
||||
}
|
||||
if (resp.drag_stopped() || resp.clicked())
|
||||
&& let Some(Gesture::Spot { points }) = app.gesture.take()
|
||||
{
|
||||
let mode = match app.ui.tool.as_str() {
|
||||
"heal" => "heal",
|
||||
"clone" => "clone",
|
||||
_ => "remove",
|
||||
};
|
||||
let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect();
|
||||
let _ = app.run("spot.add", json!({"mode": mode, "points": pts, "size": app.ui.remove_size}));
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------ filmstrip
|
||||
|
||||
fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.painter().rect_filled(r, 0.0, t.canvas);
|
||||
ui.painter().rect_filled(Rect::from_min_size(r.min, vec2(r.width(), 4.0)), 0.0, Color32::from_gray(0x20));
|
||||
let ids = app.session.visible_cloned();
|
||||
let active = app.session.selection.active;
|
||||
let cell_w = 120.0;
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(0.0, 4.0))));
|
||||
let scroll_key = egui::Id::new("film-last-active");
|
||||
let last: Option<PhotoId> = child.data(|d| d.get_temp(scroll_key));
|
||||
child.data_mut(|d| d.insert_temp(scroll_key, active));
|
||||
egui::ScrollArea::horizontal().id_salt("filmstrip").auto_shrink([false, false]).show_viewport(&mut child, |ui, vp| {
|
||||
let (area, _) = ui.allocate_exact_size(vec2(ids.len() as f32 * cell_w, r.height() - 16.0), Sense::hover());
|
||||
for (i, id) in ids.iter().enumerate() {
|
||||
let cr = Rect::from_min_size(pos2(area.left() + i as f32 * cell_w, area.top()), vec2(cell_w, area.height()));
|
||||
if Some(*id) == active && last != active {
|
||||
ui.scroll_to_rect(cr, Some(egui::Align::Center));
|
||||
}
|
||||
let local = Rect::from_min_size(pos2(i as f32 * cell_w, 0.0), cr.size());
|
||||
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());
|
||||
register(ui.ctx(), format!("film:{}", id.0), cr);
|
||||
let sel = Some(*id) == active;
|
||||
let p = ui.painter();
|
||||
if sel {
|
||||
p.rect_filled(cr, 0.0, t.cell_selected);
|
||||
} else if resp.hovered() {
|
||||
p.rect_filled(cr, 0.0, t.cell_selected.gamma_multiply(0.6));
|
||||
}
|
||||
if let Some(ph) = app.session.catalog.photo(*id) {
|
||||
let name = ph.file_name.rsplit_once('.').map(|(n, _)| n).unwrap_or(&ph.file_name);
|
||||
let short: String = if name.len() > 14 { format!("{}…", &name[..13]) } else { name.to_string() };
|
||||
p.text(pos2(cr.left() + 8.0, cr.top() + 10.0), Align2::LEFT_CENTER, short, t.font(10.0), t.text_dim);
|
||||
p.text(pos2(cr.right() - 8.0, cr.top() + 10.0), Align2::RIGHT_CENTER, &ph.format, t.semibold(8.5), t.text_dim);
|
||||
}
|
||||
let img_area = Rect::from_min_max(cr.min + vec2(10.0, 22.0), cr.max - vec2(10.0, 8.0));
|
||||
if let Some(job) = app.session.render_job(*id, (256.0 * ppp.min(2.0) / 2.0) as usize * 2, 256, false, true) {
|
||||
app.renderer.request(Slot::Thumb(*id), job, 8);
|
||||
}
|
||||
if let Some(tex) = app.renderer.textures.get(&Slot::Thumb(*id)) {
|
||||
let [tw, th] = tex.size;
|
||||
let s = (img_area.width() / tw as f32).min(img_area.height() / th as f32);
|
||||
let fr = Rect::from_center_size(img_area.center(), vec2(tw as f32 * s, th as f32 * s));
|
||||
p.image(tex.tex.id(), fr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
if sel {
|
||||
p.rect_stroke(fr, 0.0, Stroke::new(1.5, Color32::WHITE), StrokeKind::Outside);
|
||||
}
|
||||
}
|
||||
if resp.clicked() {
|
||||
let m = ui.input(|i| i.modifiers);
|
||||
let mode = if m.command { "toggle" } else if m.shift { "range" } else { "replace" };
|
||||
let _ = app.run("library.select", json!({"ids": [id.0], "mode": mode}));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Modal dialogs (new album, rename, create preset, choose settings to copy, export, about, shortcuts).
|
||||
|
||||
use lightcraft_develop::SettingsGroup;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::LightcraftApp;
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let Some(mut dlg) = app.ui.dialog.clone() else { return };
|
||||
let t = Tokens::get(ctx);
|
||||
let screen = ctx.content_rect();
|
||||
ctx.layer_painter(egui::LayerId::new(egui::Order::Middle, egui::Id::new("dim"))).rect_filled(screen, 0.0, egui::Color32::from_black_alpha(140));
|
||||
let mut close = false;
|
||||
let mut confirm = false;
|
||||
let title = match &dlg {
|
||||
Dialog::NewAlbum { folder: true, .. } => "Create Folder",
|
||||
Dialog::NewAlbum { .. } => "Create Album",
|
||||
Dialog::RenameAlbum { .. } => "Rename Album",
|
||||
Dialog::CreatePreset { .. } => "Create Preset",
|
||||
Dialog::CopySettings { .. } => "Choose Edit Settings to Copy",
|
||||
Dialog::Export { .. } => "Export",
|
||||
Dialog::About => "About LightCraft",
|
||||
Dialog::Shortcuts => "Keyboard Shortcuts",
|
||||
};
|
||||
egui::Window::new(title).collapsible(false).resizable(false).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).default_width(380.0).show(ctx, |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 8.0;
|
||||
match &mut dlg {
|
||||
Dialog::NewAlbum { name, .. } | Dialog::RenameAlbum { name, .. } => {
|
||||
let r = ui.add(egui::TextEdit::singleline(name).hint_text("Name").desired_width(f32::INFINITY));
|
||||
r.request_focus();
|
||||
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
confirm = true;
|
||||
}
|
||||
}
|
||||
Dialog::CreatePreset { name, group } => {
|
||||
ui.add(egui::TextEdit::singleline(name).hint_text("Preset name").desired_width(f32::INFINITY));
|
||||
ui.add(egui::TextEdit::singleline(group).hint_text("Group").desired_width(f32::INFINITY));
|
||||
ui.label(egui::RichText::new("Includes the current settings except crop, masks and remove.").color(t.text_dim));
|
||||
}
|
||||
Dialog::CopySettings { groups } => {
|
||||
ui.columns(2, |cols| {
|
||||
for (i, g) in SettingsGroup::ALL.iter().enumerate() {
|
||||
let key = serde_json::to_value(g).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default();
|
||||
let mut on = groups.contains(&key);
|
||||
if cols[i % 2].checkbox(&mut on, g.label()).changed() {
|
||||
if on {
|
||||
groups.push(key);
|
||||
} else {
|
||||
groups.retain(|x| *x != key);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Dialog::Export { format, quality, long_edge } => {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Format");
|
||||
for f in ["jpeg", "png", "tiff"] {
|
||||
ui.selectable_value(format, f.to_string(), f.to_uppercase());
|
||||
}
|
||||
});
|
||||
ui.add(egui::Slider::new(quality, 10..=100).text("Quality"));
|
||||
ui.add(egui::Slider::new(long_edge, 512..=8000).text("Long edge (px)"));
|
||||
}
|
||||
Dialog::About => {
|
||||
ui.label(egui::RichText::new("LightCraft").font(t.semibold(20.0)).color(t.text));
|
||||
ui.label(format!("Version {} — a clean-room, pure-Rust photo library and raw developer.", env!("CARGO_PKG_VERSION")));
|
||||
ui.label("MIT OR Apache-2.0. Fonts: Source Sans 3 (OFL). Icons: original.");
|
||||
}
|
||||
Dialog::Shortcuts => {
|
||||
egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| {
|
||||
egui::Grid::new("shortcuts").striped(true).show(ui, |ui| {
|
||||
for (id, label, sc, _) in crate::menus::UI_COMMANDS {
|
||||
if let Some(sc) = sc {
|
||||
ui.label(*label);
|
||||
ui.label(*sc);
|
||||
ui.label(egui::RichText::new(*id).color(t.text_dim));
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
for c in lightcraft_engine::command_specs() {
|
||||
if let Some(sc) = c.shortcut {
|
||||
ui.label(c.label);
|
||||
ui.label(sc);
|
||||
ui.label(egui::RichText::new(c.id).color(t.text_dim));
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
let informational = matches!(dlg, Dialog::About | Dialog::Shortcuts);
|
||||
if !informational && ui.button("Cancel").clicked() {
|
||||
close = true;
|
||||
}
|
||||
if ui.button(if informational { "Close" } else { "OK" }).clicked() {
|
||||
if informational {
|
||||
close = true;
|
||||
} else {
|
||||
confirm = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
close = true;
|
||||
}
|
||||
if confirm {
|
||||
let _ = confirm_dialog(app, &dlg);
|
||||
close = true;
|
||||
}
|
||||
app.ui.dialog = if close { None } else { Some(dlg) };
|
||||
}
|
||||
|
||||
/// Apply a dialog's action (also used by `ui.dialog.confirm`).
|
||||
pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_json::Value, String> {
|
||||
match dlg {
|
||||
Dialog::NewAlbum { name, folder } => app.run("album.create", json!({"name": name, "folder": folder, "addSelected": !folder})),
|
||||
Dialog::RenameAlbum { id, name } => app.run("album.rename", json!({"id": id, "name": name})),
|
||||
Dialog::CreatePreset { name, group } => app.run("preset.create", json!({"name": if name.is_empty() { "My Preset" } else { name }, "group": group})),
|
||||
Dialog::CopySettings { groups } => app.run("develop.copy", json!({"groups": groups})),
|
||||
Dialog::Export { format, quality, long_edge } => app.run("app.export", json!({"format": format, "quality": quality, "longEdge": long_edge})),
|
||||
Dialog::About | Dialog::Shortcuts => Ok(serde_json::Value::Null),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
//! The Edit panel: histogram, Auto/B&W, profile, and the Light / Color / Effects / Detail / Optics
|
||||
//! sections, built from the engine's control specs.
|
||||
|
||||
use egui::epaint::{Mesh, Vertex};
|
||||
use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, pos2, vec2};
|
||||
use lightcraft_catalog::PhotoId;
|
||||
use lightcraft_develop::{ControlSpec, DevelopSettings, Section, Track, WbMode, controls};
|
||||
use lightcraft_geom::Point;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::LightcraftApp;
|
||||
use crate::icons::{Icon, paint};
|
||||
use crate::render::Slot;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{BAND_COLORS, SliderOut, divider, flyout_row, hex, register, section_header, slider, text_button};
|
||||
|
||||
/// Commit a slider interaction: begin → live updates → end, so a drag is one undo step.
|
||||
pub fn apply_slider_out(
|
||||
app: &mut LightcraftApp,
|
||||
spec: &ControlSpec,
|
||||
out: SliderOut,
|
||||
mut set: impl FnMut(&mut LightcraftApp, f64) -> Result<Value, String>,
|
||||
) {
|
||||
if out.drag_started && !out.reset {
|
||||
let _ = app.run("develop.beginInteraction", json!({"label": spec.label}));
|
||||
}
|
||||
if let Some(v) = out.value {
|
||||
let _ = set(app, v);
|
||||
}
|
||||
if out.drag_stopped || out.reset {
|
||||
let _ = app.run("develop.endInteraction", json!({}));
|
||||
}
|
||||
}
|
||||
|
||||
fn control(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, id: &str, enabled: bool) {
|
||||
let Some(spec) = controls::find(id) else { return };
|
||||
let v = controls::get(d, id).unwrap_or(spec.default);
|
||||
let out = slider(ui, spec, v, enabled, None);
|
||||
apply_slider_out(app, spec, out, |app, v| app.run("develop.set", json!({"control": spec.id, "value": v})));
|
||||
}
|
||||
|
||||
/// Relative temperature scale for rendered (non-raw) files: −100..100 ↔ Kelvin via mired shift.
|
||||
const REL_TEMP: ControlSpec = ControlSpec { id: "wb.tempRel", label: "Temp", section: Section::Color, min: -100.0, max: 100.0, default: 0.0, step: 1.0, decimals: 0, track: Track::Temp };
|
||||
const REL_TINT: ControlSpec = ControlSpec { id: "wb.tintRel", label: "Tint", section: Section::Color, min: -100.0, max: 100.0, default: 0.0, step: 1.0, decimals: 0, track: Track::Tint };
|
||||
|
||||
fn k_to_rel(k: f64) -> f64 {
|
||||
((1e6 / 6500.0 - 1e6 / k) / 0.8).clamp(-100.0, 100.0)
|
||||
}
|
||||
fn rel_to_k(r: f64) -> f64 {
|
||||
1e6 / (1e6 / 6500.0 - r * 0.8)
|
||||
}
|
||||
|
||||
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let d = app.session.develop_of(id).unwrap_or_default();
|
||||
let raw = app.session.catalog.photo(id).is_some_and(|p| p.kind == lightcraft_catalog::MediaKind::Raw);
|
||||
|
||||
if app.ui.histogram {
|
||||
histogram(app, ui, id);
|
||||
}
|
||||
// header: Edit + Auto / B&W / HDR
|
||||
let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 40.0), Sense::hover());
|
||||
ui.painter().text(pos2(hr.left() + 24.0, hr.bottom() - 10.0), Align2::LEFT_CENTER, "Edit", t.semibold(15.0), t.text);
|
||||
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 8, bottom: 14 }).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 8.0;
|
||||
if text_button(ui, "auto", "Auto", false).clicked() {
|
||||
let _ = app.run("develop.auto", json!({}));
|
||||
app.toast(ui.ctx(), "Auto settings applied");
|
||||
}
|
||||
let bw = crate::is_bw(&d);
|
||||
if text_button(ui, "bw", "B&W", bw).clicked() {
|
||||
let _ = app.run("develop.treatment", json!({}));
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if text_button(ui, "reset", "Reset", false).on_hover_text("Reset all edits (Cmd+Shift+R)").clicked() {
|
||||
let _ = app.run("develop.reset", json!({}));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
divider(ui);
|
||||
// profile row
|
||||
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 14, bottom: 14 }).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new("Profile").font(t.font(13.0)).color(t.text_dim));
|
||||
let name = lightcraft_engine::presets::PROFILES.iter().find(|p| p.id == d.profile.id).map(|p| p.name).unwrap_or("Color");
|
||||
let r = ui.add(egui::Button::new(egui::RichText::new(format!("{name} ⌄")).font(t.font(15.0)).color(t.text_label)).frame(false));
|
||||
register(ui.ctx(), "button:profile", r.rect);
|
||||
egui::Popup::menu(&r).show(|ui| {
|
||||
for p in lightcraft_engine::presets::PROFILES {
|
||||
if ui.selectable_label(p.id == d.profile.id, p.name).clicked() {
|
||||
let _ = app.run("develop.profile", json!({"id": p.id}));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
divider(ui);
|
||||
|
||||
section(app, ui, &d, "light", "Light", |app, ui, d| {
|
||||
for c in ["light.exposure", "light.contrast", "light.highlights", "light.shadows", "light.whites", "light.blacks"] {
|
||||
control(app, ui, d, c, true);
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
let open = app.ui.flyout_open("curve");
|
||||
if flyout_row(ui, "curve", "Curve", Icon::Curve, open).clicked() {
|
||||
app.ui.toggle_flyout("curve");
|
||||
}
|
||||
if open {
|
||||
curve_editor(app, ui, id, d);
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
});
|
||||
section(app, ui, &d, "color", "Color", |app, ui, d| {
|
||||
// White balance row
|
||||
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 2 }).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new("White Balance").font(t.font(13.0)).color(t.text_dim));
|
||||
let r = ui.add(egui::Button::new(egui::RichText::new(format!("{} ⌄", d.wb.mode.label())).font(t.font(14.0)).color(t.text_label)).frame(false));
|
||||
register(ui.ctx(), "button:wbMode", r.rect);
|
||||
egui::Popup::menu(&r).show(|ui| {
|
||||
for m in WbMode::ALL {
|
||||
if m == WbMode::Custom {
|
||||
continue;
|
||||
}
|
||||
if ui.selectable_label(d.wb.mode == m, m.label()).clicked() {
|
||||
let mode = serde_json::to_value(m).unwrap_or_default();
|
||||
let _ = app.run("develop.wb", json!({"mode": mode}));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let active = app.ui.tool == "wbPicker";
|
||||
if crate::widgets::icon_button(ui, "wbPicker", Icon::Picker, vec2(28.0, 28.0), active, true, "White Balance Selector (W)").clicked() {
|
||||
app.ui.tool = if active { String::new() } else { "wbPicker".into() };
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if raw {
|
||||
control(app, ui, d, "wb.temp", true);
|
||||
control(app, ui, d, "wb.tint", true);
|
||||
} else {
|
||||
let out = slider(ui, &REL_TEMP, k_to_rel(d.wb.temp), true, None);
|
||||
apply_slider_out(app, &REL_TEMP, out, |app, v| app.run("develop.set", json!({"control": "wb.temp", "value": rel_to_k(v)})));
|
||||
let out = slider(ui, &REL_TINT, d.wb.tint.clamp(-100.0, 100.0), true, None);
|
||||
apply_slider_out(app, &REL_TINT, out, |app, v| app.run("develop.set", json!({"control": "wb.tint", "value": v})));
|
||||
}
|
||||
control(app, ui, d, "color.vibrance", true);
|
||||
control(app, ui, d, "color.saturation", true);
|
||||
ui.add_space(6.0);
|
||||
let open = app.ui.flyout_open("mixer");
|
||||
if flyout_row(ui, "mixer", if crate::is_bw(d) { "B&W Mixer" } else { "Color Mixer" }, Icon::Radial, open).clicked() {
|
||||
app.ui.toggle_flyout("mixer");
|
||||
}
|
||||
if open {
|
||||
mixer(app, ui, d);
|
||||
}
|
||||
let open = app.ui.flyout_open("grading");
|
||||
if flyout_row(ui, "grading", "Color Grading", Icon::Presets, open).clicked() {
|
||||
app.ui.toggle_flyout("grading");
|
||||
}
|
||||
if open {
|
||||
grading(app, ui, d);
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
});
|
||||
section(app, ui, &d, "effects", "Effects", |app, ui, d| {
|
||||
for c in ["effects.texture", "effects.clarity", "effects.dehaze"] {
|
||||
control(app, ui, d, c, true);
|
||||
}
|
||||
sub_title(ui, "Vignette");
|
||||
for c in ["vignette.amount", "vignette.midpoint", "vignette.feather", "vignette.roundness", "vignette.highlights"] {
|
||||
let enabled = c == "vignette.amount" || d.vignette.amount != 0.0;
|
||||
control(app, ui, d, c, enabled);
|
||||
}
|
||||
sub_title(ui, "Grain");
|
||||
for c in ["grain.amount", "grain.size", "grain.roughness"] {
|
||||
control(app, ui, d, c, c == "grain.amount" || d.grain.amount != 0.0);
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
});
|
||||
section(app, ui, &d, "detail", "Detail", |app, ui, d| {
|
||||
for c in ["detail.sharpenAmount", "detail.sharpenRadius", "detail.sharpenDetail", "detail.sharpenMasking"] {
|
||||
control(app, ui, d, c, c == "detail.sharpenAmount" || d.detail.sharpen_amount > 0.0);
|
||||
}
|
||||
sub_title(ui, "Noise Reduction");
|
||||
for c in ["detail.nrLuminance", "detail.nrDetail", "detail.nrContrast", "detail.nrColor", "detail.nrColorDetail", "detail.nrColorSmoothness"] {
|
||||
control(app, ui, d, c, true);
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
});
|
||||
section(app, ui, &d, "optics", "Optics", |app, ui, d| {
|
||||
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 4, bottom: 4 }).show(ui, |ui| {
|
||||
let mut ca = d.optics.remove_ca;
|
||||
if ui.checkbox(&mut ca, "Remove Chromatic Aberration").changed() {
|
||||
let _ = app.run("develop.merge", json!({"settings": {"optics": {"remove_ca": ca}}, "label": "Remove CA"}));
|
||||
}
|
||||
let mut lp = d.optics.lens_profile;
|
||||
if ui.checkbox(&mut lp, "Use Lens Corrections").changed() {
|
||||
let _ = app.run("develop.merge", json!({"settings": {"optics": {"lens_profile": lp}}, "label": "Lens Corrections"}));
|
||||
}
|
||||
});
|
||||
for c in ["optics.distortion", "optics.vignetting", "optics.vignettingMidpoint", "optics.defringePurple", "optics.defringeGreen"] {
|
||||
control(app, ui, d, c, true);
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
});
|
||||
ui.add_space(40.0);
|
||||
}
|
||||
|
||||
fn sub_title(ui: &mut egui::Ui, title: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::hover());
|
||||
ui.painter().text(pos2(r.left() + 24.0, r.center().y + 4.0), Align2::LEFT_CENTER, title, t.semibold(13.0), t.text_label);
|
||||
}
|
||||
|
||||
fn section(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, id: &str, title: &str, body: impl FnOnce(&mut LightcraftApp, &mut egui::Ui, &DevelopSettings)) {
|
||||
let open = app.ui.section_open(id);
|
||||
let (resp, toggled) = section_header(ui, id, title, open, Some(d.section_enabled(id)));
|
||||
if let Some(on) = toggled {
|
||||
let _ = app.run("develop.sectionEnabled", json!({"section": id, "enabled": on}));
|
||||
} else if resp.clicked() {
|
||||
app.ui.toggle_section(id);
|
||||
}
|
||||
if open {
|
||||
body(app, ui, d);
|
||||
}
|
||||
divider(ui);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------ histogram
|
||||
|
||||
fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let w = ui.available_width();
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, 118.0), Sense::click());
|
||||
register(ui.ctx(), "histogram", r);
|
||||
let p = ui.painter();
|
||||
p.rect_filled(r, 0.0, t.button);
|
||||
let plot = Rect::from_min_max(r.min + vec2(0.0, 6.0), pos2(r.right(), r.bottom() - 26.0));
|
||||
if let Some(h) = app.renderer.textures.get(&Slot::Main).and_then(|t| t.histogram.clone()) {
|
||||
let smooth = |v: &[u32]| -> Vec<f32> {
|
||||
(0..256)
|
||||
.map(|i| {
|
||||
let (a, b) = (i.max(2) - 2, (i + 3).min(256));
|
||||
v[a..b].iter().map(|x| *x as f32).sum::<f32>() / (b - a) as f32
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let chans = [(smooth(&h.r), hex("#df3939")), (smooth(&h.g), hex("#44b072")), (smooth(&h.b), hex("#3b6fe0"))];
|
||||
let peak = chans.iter().flat_map(|(v, _)| v.iter().skip(2).take(252)).fold(1.0f32, |a, b| a.max(*b));
|
||||
let to = |i: usize, v: f32| pos2(plot.left() + plot.width() * i as f32 / 255.0, plot.bottom() - plot.height() * (v / peak).sqrt().min(1.0));
|
||||
// luminance fill
|
||||
let lum = smooth(&h.luma);
|
||||
let mut mesh = Mesh::default();
|
||||
for i in 0..256 {
|
||||
let top = to(i, lum[i]);
|
||||
mesh.vertices.push(Vertex { pos: top, uv: Pos2::ZERO, color: Color32::from_gray(120).gamma_multiply(0.35) });
|
||||
mesh.vertices.push(Vertex { pos: pos2(top.x, plot.bottom()), uv: Pos2::ZERO, color: Color32::from_gray(120).gamma_multiply(0.35) });
|
||||
if i > 0 {
|
||||
let k = (i as u32) * 2;
|
||||
mesh.indices.extend([k - 2, k - 1, k, k - 1, k + 1, k]);
|
||||
}
|
||||
}
|
||||
p.add(mesh);
|
||||
for (v, c) in &chans {
|
||||
let pts: Vec<Pos2> = (0..256).map(|i| to(i, v[i])).collect();
|
||||
p.add(egui::Shape::line(pts, Stroke::new(1.5, *c)));
|
||||
}
|
||||
let (lo, hi) = h.clipping();
|
||||
for (on, x, active) in [(lo > 0.003, plot.left() + 12.0, app.ui.show_clipping), (hi > 0.003, plot.right() - 12.0, app.ui.show_clipping)] {
|
||||
let tri = Rect::from_center_size(pos2(x, plot.top() + 10.0), vec2(14.0, 12.0));
|
||||
p.rect_filled(tri, 2.0, if active { t.hover } else { Color32::from_gray(60) });
|
||||
paint(p, tri.shrink(2.0), Icon::ChevronDown, if on { Color32::WHITE } else { Color32::from_gray(130) });
|
||||
}
|
||||
}
|
||||
if resp.clicked() {
|
||||
app.ui.show_clipping = !app.ui.show_clipping;
|
||||
}
|
||||
// EXIF strip
|
||||
if let Some(ph) = app.session.catalog.photo(id) {
|
||||
let m = &ph.meta;
|
||||
let items = [
|
||||
m.iso.map(|i| format!("ISO {i}")).unwrap_or_default(),
|
||||
m.focal_mm.map(|f| format!("{f:.0}mm")).unwrap_or_default(),
|
||||
m.aperture.map(|a| format!("f/{a:.1}")).unwrap_or_default(),
|
||||
m.shutter.clone(),
|
||||
];
|
||||
let y = r.bottom() - 13.0;
|
||||
for (i, s) in items.iter().enumerate() {
|
||||
let x = r.left() + 18.0 + (r.width() - 36.0) * i as f32 / 3.0;
|
||||
let align = match i {
|
||||
0 => Align2::LEFT_CENTER,
|
||||
3 => Align2::RIGHT_CENTER,
|
||||
_ => Align2::CENTER_CENTER,
|
||||
};
|
||||
p.text(pos2(x, y), align, s, t.font(12.5), t.text_label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------ tone curve
|
||||
|
||||
fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &DevelopSettings) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
// channel selector
|
||||
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 6, bottom: 4 }).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
for (ch, c) in [("parametric", Color32::from_gray(200)), ("master", Color32::WHITE), ("red", hex("#dd3333")), ("green", hex("#33bb55")), ("blue", hex("#3377ee"))] {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::click());
|
||||
register(ui.ctx(), format!("curveChannel:{ch}"), r);
|
||||
let sel = app.ui.curve_channel == ch;
|
||||
if ch == "parametric" {
|
||||
paint(ui.painter(), r.shrink(4.0), Icon::Curve, if sel { t.text } else { t.icon });
|
||||
} else {
|
||||
ui.painter().circle_stroke(r.center(), 7.0, Stroke::new(2.0, c));
|
||||
if sel {
|
||||
ui.painter().circle_filled(r.center(), 3.5, c);
|
||||
}
|
||||
}
|
||||
if sel {
|
||||
ui.painter().line_segment([r.left_bottom() + vec2(4.0, 1.0), r.right_bottom() + vec2(-4.0, 1.0)], Stroke::new(2.0, t.text));
|
||||
}
|
||||
if resp.clicked() {
|
||||
app.ui.curve_channel = ch.into();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
let ch = app.ui.curve_channel.clone();
|
||||
let w = ui.available_width();
|
||||
let side = (w - 48.0).max(80.0);
|
||||
let (outer, _) = ui.allocate_exact_size(vec2(w, side + 12.0), Sense::hover());
|
||||
let r = Rect::from_min_size(pos2(outer.left() + 24.0, outer.top() + 6.0), vec2(side, side));
|
||||
let resp = ui.interact(r, egui::Id::new(("curve", &ch)), Sense::click_and_drag());
|
||||
register(ui.ctx(), "curve", r);
|
||||
let p = ui.painter();
|
||||
p.rect_filled(r, 2.0, t.canvas);
|
||||
for i in 1..4 {
|
||||
let f = i as f32 / 4.0;
|
||||
p.line_segment([pos2(r.left() + r.width() * f, r.top()), pos2(r.left() + r.width() * f, r.bottom())], Stroke::new(1.0, Color32::from_gray(52)));
|
||||
p.line_segment([pos2(r.left(), r.top() + r.height() * f), pos2(r.right(), r.top() + r.height() * f)], Stroke::new(1.0, Color32::from_gray(52)));
|
||||
}
|
||||
p.line_segment([r.left_bottom(), r.right_top()], Stroke::new(1.0, Color32::from_gray(70)));
|
||||
let to_screen = |x: f64, y: f64| pos2(r.left() + x as f32 * r.width(), r.bottom() - y as f32 * r.height());
|
||||
let from_screen = |q: Pos2| (((q.x - r.left()) / r.width()).clamp(0.0, 1.0) as f64, ((r.bottom() - q.y) / r.height()).clamp(0.0, 1.0) as f64);
|
||||
if ch == "parametric" {
|
||||
// draw the effective parametric curve by sampling the pipeline's region curve approximation
|
||||
let c = &d.curve;
|
||||
let regions = [(0.0, c.split_shadows / 100.0, c.shadows), (c.split_shadows / 100.0, c.split_mid / 100.0, c.darks), (c.split_mid / 100.0, c.split_highlights / 100.0, c.lights), (c.split_highlights / 100.0, 1.0, c.highlights)];
|
||||
let pts: Vec<Pos2> = (0..=64)
|
||||
.map(|i| {
|
||||
let x = i as f64 / 64.0;
|
||||
let mut dd = 0.0;
|
||||
for (a, b, amt) in regions {
|
||||
let (ctr, half) = ((a + b) / 2.0, (b - a) * 0.75 + 0.05);
|
||||
let tt = ((x - ctr) / half).clamp(-1.0, 1.0);
|
||||
dd += amt / 100.0 * 0.22 * (0.5 + 0.5 * (tt * std::f64::consts::PI).cos());
|
||||
}
|
||||
to_screen(x, (x + dd * 4.0 * x * (1.0 - x)).clamp(0.0, 1.0))
|
||||
})
|
||||
.collect();
|
||||
p.add(egui::Shape::line(pts, Stroke::new(2.0, Color32::from_gray(220))));
|
||||
for c in ["curve.highlights", "curve.lights", "curve.darks", "curve.shadows"] {
|
||||
control(app, ui, d, c, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let pts_of = |d: &DevelopSettings| -> Vec<Point> {
|
||||
let v = match ch.as_str() {
|
||||
"red" => &d.curve.red,
|
||||
"green" => &d.curve.green,
|
||||
"blue" => &d.curve.blue,
|
||||
_ => &d.curve.master,
|
||||
};
|
||||
if v.is_empty() { vec![Point::new(0.0, 0.0), Point::new(1.0, 1.0)] } else { v.clone() }
|
||||
};
|
||||
let pts = pts_of(d);
|
||||
let curve = lightcraft_color::spline::MonotoneCurve::new(&pts.iter().map(|q| (q.x, q.y)).collect::<Vec<_>>());
|
||||
let color = match ch.as_str() {
|
||||
"red" => hex("#dd3333"),
|
||||
"green" => hex("#33bb55"),
|
||||
"blue" => hex("#3377ee"),
|
||||
_ => Color32::from_gray(225),
|
||||
};
|
||||
let line: Vec<Pos2> = (0..=96).map(|i| to_screen(i as f64 / 96.0, curve.eval(i as f64 / 96.0).clamp(0.0, 1.0))).collect();
|
||||
p.add(egui::Shape::line(line, Stroke::new(2.0, color)));
|
||||
for q in &pts {
|
||||
p.circle_filled(to_screen(q.x, q.y), 4.0, color);
|
||||
}
|
||||
// interaction: drag the nearest point, click empty space to add, double-click to delete
|
||||
let drag_id = egui::Id::new(("curve-drag", &ch));
|
||||
let mut dragging: Option<usize> = ui.data(|dd| dd.get_temp(drag_id));
|
||||
let nearest = |q: Pos2| pts.iter().enumerate().map(|(i, p)| (i, to_screen(p.x, p.y).distance(q))).min_by(|a, b| a.1.total_cmp(&b.1));
|
||||
let mut new_pts = None;
|
||||
if resp.double_clicked()
|
||||
&& let Some(q) = resp.interact_pointer_pos()
|
||||
&& let Some((i, dist)) = nearest(q)
|
||||
&& dist < 10.0
|
||||
&& i != 0
|
||||
&& i != pts.len() - 1
|
||||
{
|
||||
let mut v = pts.clone();
|
||||
v.remove(i);
|
||||
new_pts = Some(v);
|
||||
} else if resp.drag_started()
|
||||
&& let Some(q) = resp.interact_pointer_pos()
|
||||
{
|
||||
let _ = app.run("develop.beginInteraction", json!({"label": "Tone Curve"}));
|
||||
match nearest(q) {
|
||||
Some((i, dist)) if dist < 10.0 => dragging = Some(i),
|
||||
_ => {
|
||||
let (x, y) = from_screen(q);
|
||||
let mut v = pts.clone();
|
||||
let i = v.iter().position(|p| p.x > x).unwrap_or(v.len());
|
||||
v.insert(i, Point::new(x, y));
|
||||
dragging = Some(i);
|
||||
new_pts = Some(v);
|
||||
}
|
||||
}
|
||||
} else if resp.dragged()
|
||||
&& let (Some(i), Some(q)) = (dragging, resp.interact_pointer_pos())
|
||||
{
|
||||
let (mut x, y) = from_screen(q);
|
||||
let mut v = pts.clone();
|
||||
if i < v.len() {
|
||||
let lo = if i == 0 { 0.0 } else { v[i - 1].x + 0.01 };
|
||||
let hi = if i + 1 >= v.len() { 1.0 } else { v[i + 1].x - 0.01 };
|
||||
if i == 0 {
|
||||
x = x.min(hi);
|
||||
} else if i + 1 == v.len() {
|
||||
x = x.max(lo);
|
||||
} else {
|
||||
x = x.clamp(lo, hi);
|
||||
}
|
||||
v[i] = Point::new(x, y);
|
||||
new_pts = Some(v);
|
||||
}
|
||||
} else if resp.clicked()
|
||||
&& let Some(q) = resp.interact_pointer_pos()
|
||||
&& nearest(q).is_none_or(|(_, dist)| dist >= 10.0)
|
||||
{
|
||||
let (x, y) = from_screen(q);
|
||||
let mut v = pts.clone();
|
||||
let i = v.iter().position(|p| p.x > x).unwrap_or(v.len());
|
||||
v.insert(i, Point::new(x, y));
|
||||
new_pts = Some(v);
|
||||
}
|
||||
if let Some(v) = new_pts {
|
||||
let arr: Vec<[f64; 2]> = v.iter().map(|p| [p.x, p.y]).collect();
|
||||
let _ = app.run("develop.curve", json!({"channel": ch, "points": arr}));
|
||||
}
|
||||
if resp.drag_stopped() {
|
||||
dragging = None;
|
||||
let _ = app.run("develop.endInteraction", json!({}));
|
||||
}
|
||||
ui.data_mut(|dd| dd.insert_temp(drag_id, dragging));
|
||||
let _ = id;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------ colour mixer
|
||||
|
||||
fn mixer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let bands = lightcraft_develop::MIXER_BANDS;
|
||||
let sel = bands.iter().position(|b| *b == app.ui.mixer_mode).unwrap_or(0);
|
||||
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 8, bottom: 4 }).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 5.0;
|
||||
for (i, b) in bands.iter().enumerate() {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click());
|
||||
register(ui.ctx(), format!("mixerBand:{b}"), r);
|
||||
ui.painter().circle_filled(r.center(), 9.0, hex(BAND_COLORS[i]));
|
||||
if i == sel {
|
||||
ui.painter().circle_stroke(r.center(), 10.5, Stroke::new(1.5, t.text));
|
||||
ui.painter().circle_filled(r.center(), 2.0, Color32::WHITE);
|
||||
}
|
||||
if resp.clicked() {
|
||||
app.ui.mixer_mode = b.to_string();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
let b = bands[sel];
|
||||
if crate::is_bw(d) {
|
||||
control(app, ui, d, &format!("bw.{b}"), true);
|
||||
} else {
|
||||
for k in ["hue", "sat", "lum"] {
|
||||
control(app, ui, d, &format!("mixer.{b}.{k}"), true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------ colour grading
|
||||
|
||||
fn grading(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let w = ui.available_width();
|
||||
let (area, _) = ui.allocate_exact_size(vec2(w, 250.0), Sense::hover());
|
||||
let big = 44.0;
|
||||
let small = 38.0;
|
||||
let wheels = [
|
||||
("midtones", "Midtones", pos2(area.center().x, area.top() + 60.0), big),
|
||||
("shadows", "Shadows", pos2(area.left() + w * 0.28, area.top() + 170.0), small),
|
||||
("highlights", "Highlights", pos2(area.left() + w * 0.72, area.top() + 170.0), small),
|
||||
];
|
||||
for (key, label, c, rad) in wheels {
|
||||
let wh = match key {
|
||||
"shadows" => d.grading.shadows,
|
||||
"midtones" => d.grading.midtones,
|
||||
_ => d.grading.highlights,
|
||||
};
|
||||
let r = Rect::from_center_size(c, vec2(rad * 2.0, rad * 2.0));
|
||||
let resp = ui.interact(r, egui::Id::new(("wheel", key)), Sense::click_and_drag());
|
||||
register(ui.ctx(), format!("wheel:{key}"), r);
|
||||
paint_wheel(ui.painter(), c, rad);
|
||||
let a = (wh.hue as f32).to_radians();
|
||||
let dot = c + vec2(a.cos(), -a.sin()) * rad * (wh.sat as f32 / 100.0);
|
||||
ui.painter().circle_filled(dot, 5.0, Color32::WHITE);
|
||||
ui.painter().circle_stroke(dot, 5.0, Stroke::new(1.0, Color32::BLACK));
|
||||
ui.painter().text(pos2(c.x, c.y + rad + 12.0), Align2::CENTER_CENTER, label, t.font(12.0), t.text_label);
|
||||
if resp.drag_started() {
|
||||
let _ = app.run("develop.beginInteraction", json!({"label": format!("{label} Grading")}));
|
||||
}
|
||||
if (resp.dragged() || resp.clicked())
|
||||
&& let Some(q) = resp.interact_pointer_pos()
|
||||
{
|
||||
let v = q - c;
|
||||
let hue = ((-v.y).atan2(v.x).to_degrees() + 360.0) % 360.0;
|
||||
let sat = (v.length() / rad * 100.0).min(100.0);
|
||||
let _ = app.run("develop.set", json!({"values": {format!("grading.{key}.hue"): hue.round(), format!("grading.{key}.sat"): sat.round()}}));
|
||||
}
|
||||
if resp.drag_stopped() || resp.clicked() {
|
||||
let _ = app.run("develop.endInteraction", json!({}));
|
||||
}
|
||||
if resp.double_clicked() {
|
||||
let _ = app.run("develop.set", json!({"values": {format!("grading.{key}.hue"): 0, format!("grading.{key}.sat"): 0}}));
|
||||
}
|
||||
}
|
||||
for c in ["grading.shadows.lum", "grading.midtones.lum", "grading.highlights.lum", "grading.blending", "grading.balance"] {
|
||||
control(app, ui, d, c, true);
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_wheel(p: &egui::Painter, c: Pos2, rad: f32) {
|
||||
let mut mesh = Mesh::default();
|
||||
let n = 48;
|
||||
mesh.vertices.push(Vertex { pos: c, uv: Pos2::ZERO, color: Color32::from_gray(128) });
|
||||
for i in 0..=n {
|
||||
let a = i as f32 / n as f32 * std::f32::consts::TAU;
|
||||
let rgb = lightcraft_color::perceptual::hsv_to_rgb(a.to_degrees(), 0.75, 0.8);
|
||||
let col = Color32::from_rgb((rgb[0] * 255.0) as u8, (rgb[1] * 255.0) as u8, (rgb[2] * 255.0) as u8);
|
||||
mesh.vertices.push(Vertex { pos: c + vec2(a.cos(), -a.sin()) * rad, uv: Pos2::ZERO, color: col });
|
||||
if i > 0 {
|
||||
mesh.indices.extend([0, i as u32, i as u32 + 1]);
|
||||
}
|
||||
}
|
||||
p.add(mesh);
|
||||
p.circle_stroke(c, rad, Stroke::new(1.0, Color32::from_gray(40)));
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
//! Photo Grid (justified rows) and Square Grid. Virtualized: only visible cells request thumbnails.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
use lightcraft_catalog::{Flag, PhotoId};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::LightcraftApp;
|
||||
use crate::icons::{Icon, paint};
|
||||
use crate::render::Slot;
|
||||
use crate::state::ViewMode;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::register;
|
||||
|
||||
/// Thumbnail render size (pixels, long edge) for a cell of `pts` points.
|
||||
fn thumb_px(pts: f32, ppp: f32) -> usize {
|
||||
let px = (pts * ppp).ceil() as usize;
|
||||
if px <= 256 {
|
||||
256
|
||||
} else if px <= 384 {
|
||||
384
|
||||
} else {
|
||||
512
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let ids = app.session.visible_cloned();
|
||||
// header: source title + count
|
||||
let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover());
|
||||
let title = app.session.source.label(&app.session.catalog);
|
||||
ui.painter().rect_filled(hr, 0.0, t.canvas);
|
||||
ui.painter().text(pos2(hr.left() + 20.0, hr.center().y), Align2::LEFT_CENTER, &title, t.semibold(17.0), t.text);
|
||||
let sel_n = app.session.selection.ids.len();
|
||||
let cnt = if sel_n > 1 { format!("{sel_n} of {} photos", ids.len()) } else { format!("{} photos", ids.len()) };
|
||||
ui.painter().text(pos2(hr.right() - 20.0, hr.center().y), Align2::RIGHT_CENTER, cnt, t.font(12.5), t.text_dim);
|
||||
app.canvas_rect = Some(ui.max_rect());
|
||||
if ids.is_empty() {
|
||||
super::empty_message(ui, ui.max_rect(), "No photos", "Add photos with File → Add Photos (Cmd+Shift+I), or drop them here");
|
||||
return;
|
||||
}
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let square = app.ui.view == ViewMode::SquareGrid;
|
||||
let target = app.ui.thumb_size;
|
||||
let avail_w = ui.available_width() - 8.0;
|
||||
// layout rows
|
||||
struct Cell {
|
||||
id: PhotoId,
|
||||
rect: Rect,
|
||||
}
|
||||
let mut cells: Vec<Cell> = Vec::with_capacity(ids.len());
|
||||
let gap = if square { 1.0 } else { 6.0 };
|
||||
let mut y = 4.0f32;
|
||||
if square {
|
||||
let cols = ((avail_w + gap) / (target + gap)).floor().max(1.0);
|
||||
let cw = (avail_w - gap * (cols - 1.0)) / cols;
|
||||
for (i, id) in ids.iter().enumerate() {
|
||||
let (c, r) = ((i as f32) % cols, (i as f32 / cols).floor());
|
||||
cells.push(Cell { id: *id, rect: Rect::from_min_size(pos2(4.0 + c * (cw + gap), y + r * (cw + gap)), vec2(cw, cw)) });
|
||||
}
|
||||
} else {
|
||||
// justified rows: accumulate aspect ratios until the row is full
|
||||
let aspects: Vec<f32> = ids
|
||||
.iter()
|
||||
.map(|id| {
|
||||
let p = app.session.catalog.photo(*id);
|
||||
let (w, h) = p.map(|p| (p.width.max(1) as f32, p.height.max(1) as f32)).unwrap_or((3.0, 2.0));
|
||||
let swap = p.is_some_and(|p| p.develop.orientation.swaps_axes());
|
||||
let crop = p.map(|p| p.develop.crop.geometry.rect).unwrap_or(lightcraft_geom::Rect::UNIT);
|
||||
let (w, h) = if swap { (h, w) } else { (w, h) };
|
||||
(w * crop.width() as f32) / (h * crop.height() as f32).max(1e-3)
|
||||
})
|
||||
.collect();
|
||||
let mut i = 0;
|
||||
while i < ids.len() {
|
||||
let mut sum = 0.0;
|
||||
let mut j = i;
|
||||
while j < ids.len() {
|
||||
sum += aspects[j];
|
||||
let h = (avail_w - gap * (j - i) as f32) / sum;
|
||||
j += 1;
|
||||
if h <= target {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let full = j < ids.len() || (avail_w - gap * (j - i - 1) as f32) / sum <= target;
|
||||
let h = if full { (avail_w - gap * (j - i - 1) as f32) / sum } else { target };
|
||||
let mut x = 4.0;
|
||||
for k in i..j {
|
||||
let w = aspects[k] * h;
|
||||
cells.push(Cell { id: ids[k], rect: Rect::from_min_size(pos2(x, y), vec2(w, h)) });
|
||||
x += w + gap;
|
||||
}
|
||||
y += h + gap;
|
||||
i = j;
|
||||
}
|
||||
}
|
||||
let total_h = cells.last().map(|c| c.rect.bottom()).unwrap_or(0.0) + 12.0;
|
||||
let active = app.session.selection.active;
|
||||
// keep the active photo in view when it changes by keyboard
|
||||
let scroll_to: Option<Rect> = {
|
||||
let key = egui::Id::new("grid-last-active");
|
||||
let last: Option<PhotoId> = ui.data(|d| d.get_temp(key));
|
||||
ui.data_mut(|d| d.insert_temp(key, active));
|
||||
if last != active { active.and_then(|a| cells.iter().find(|c| c.id == a).map(|c| c.rect)) } else { None }
|
||||
};
|
||||
let mut visible_ids = HashSet::new();
|
||||
egui::ScrollArea::vertical().id_salt("grid-scroll").auto_shrink([false, false]).show_viewport(ui, |ui, viewport| {
|
||||
let (area, _) = ui.allocate_exact_size(vec2(ui.available_width(), total_h), Sense::hover());
|
||||
let origin = area.min;
|
||||
if let Some(r) = scroll_to {
|
||||
ui.scroll_to_rect(r.translate(origin.to_vec2()), None);
|
||||
}
|
||||
let prefetch = viewport.expand2(vec2(0.0, viewport.height()));
|
||||
for c in &cells {
|
||||
if !c.rect.intersects(prefetch) {
|
||||
continue;
|
||||
}
|
||||
visible_ids.insert(c.id);
|
||||
let r = c.rect.translate(origin.to_vec2());
|
||||
let onscreen = c.rect.intersects(viewport);
|
||||
cell(app, ui, c.id, r, square, onscreen, ppp);
|
||||
}
|
||||
});
|
||||
app.renderer.evict_thumbs(&visible_ids, 600);
|
||||
let _ = (Color32::BLACK, StrokeKind::Inside, Stroke::NONE);
|
||||
}
|
||||
|
||||
fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square: bool, onscreen: bool, ppp: f32) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let Some(photo) = app.session.catalog.photo(id).cloned() else { return };
|
||||
let resp = ui.interact(r, egui::Id::new(("cell", id.0)), Sense::click_and_drag());
|
||||
register(ui.ctx(), format!("thumb:{}", id.0), r);
|
||||
let selected = app.session.selection.contains(id);
|
||||
let active = app.session.selection.active == Some(id);
|
||||
let p = ui.painter();
|
||||
let img_rect = if square {
|
||||
p.rect_filled(r, 0.0, if selected { t.cell_selected } else { t.cell });
|
||||
Rect::from_min_max(r.min + vec2(10.0, 24.0), r.max - vec2(10.0, 10.0))
|
||||
} else {
|
||||
r
|
||||
};
|
||||
// thumbnail
|
||||
let size = thumb_px(img_rect.width().max(img_rect.height()), ppp);
|
||||
if let Some(job) = app.session.render_job(id, size, size, false, true) {
|
||||
app.renderer.request(Slot::Thumb(id), job, if onscreen { 10 } else { 5 });
|
||||
}
|
||||
if let Some(tex) = app.renderer.textures.get(&Slot::Thumb(id)) {
|
||||
let [tw, th] = tex.size;
|
||||
let fit = if square {
|
||||
let s = (img_rect.width() / tw as f32).min(img_rect.height() / th as f32);
|
||||
Rect::from_center_size(img_rect.center(), vec2(tw as f32 * s, th as f32 * s))
|
||||
} else {
|
||||
img_rect
|
||||
};
|
||||
p.image(tex.tex.id(), fit, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
if active {
|
||||
p.rect_stroke(fit.expand(if square { 2.0 } else { 0.0 }), 0.0, Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside);
|
||||
} else if selected {
|
||||
p.rect_stroke(fit, 0.0, Stroke::new(2.0, Color32::from_gray(170)), StrokeKind::Outside);
|
||||
}
|
||||
} else {
|
||||
p.rect_filled(img_rect.shrink(if square { 20.0 } else { 0.0 }), 0.0, Color32::from_gray(38));
|
||||
}
|
||||
// labels and badges
|
||||
if square && app.ui.show_filenames {
|
||||
let name = photo.file_name.rsplit_once('.').map(|(n, _)| n.to_string()).unwrap_or(photo.file_name.clone());
|
||||
p.text(pos2(r.left() + 8.0, r.top() + 12.0), Align2::LEFT_CENTER, name, t.font(10.5), t.text_dim);
|
||||
let fmt = photo.format.clone();
|
||||
let g = p.layout_no_wrap(fmt, t.semibold(9.0), t.text_label);
|
||||
let br = Rect::from_min_size(pos2(r.right() - g.size().x - 14.0, r.top() + 5.0), g.size() + vec2(8.0, 3.0));
|
||||
p.rect_filled(br, 2.0, Color32::from_gray(26));
|
||||
p.galley(br.min + vec2(4.0, 1.5), g, t.text_label);
|
||||
}
|
||||
let show_badges = resp.hovered() || selected || photo.rating > 0 || photo.flag != Flag::None;
|
||||
if show_badges {
|
||||
let bar = Rect::from_min_max(pos2(img_rect.left(), img_rect.bottom() - 24.0), img_rect.right_bottom());
|
||||
if resp.hovered() || selected {
|
||||
p.rect_filled(bar, 0.0, Color32::from_black_alpha(120));
|
||||
}
|
||||
let mut x = bar.left() + 6.0;
|
||||
for i in 0..photo.rating {
|
||||
paint(p, Rect::from_min_size(pos2(x + i as f32 * 13.0, bar.center().y - 6.0), vec2(12.0, 12.0)), Icon::StarFilled, t.star);
|
||||
}
|
||||
x += photo.rating as f32 * 13.0 + 4.0;
|
||||
match photo.flag {
|
||||
Flag::Pick => paint(p, Rect::from_min_size(pos2(x, bar.center().y - 7.0), vec2(14.0, 14.0)), Icon::FlagPick, t.pick),
|
||||
Flag::Reject => paint(p, Rect::from_min_size(pos2(x, bar.center().y - 7.0), vec2(14.0, 14.0)), Icon::FlagReject, t.reject),
|
||||
Flag::None => {}
|
||||
}
|
||||
if photo.is_edited() {
|
||||
paint(p, Rect::from_min_size(pos2(bar.right() - 20.0, bar.center().y - 7.0), vec2(14.0, 14.0)), Icon::Sliders, t.text_label);
|
||||
}
|
||||
}
|
||||
if photo.flag == Flag::Reject {
|
||||
p.rect_filled(img_rect, 0.0, Color32::from_black_alpha(110));
|
||||
}
|
||||
// interaction
|
||||
if resp.clicked() {
|
||||
let m = ui.input(|i| i.modifiers);
|
||||
let mode = if m.shift {
|
||||
"range"
|
||||
} else if m.command {
|
||||
"toggle"
|
||||
} else {
|
||||
"replace"
|
||||
};
|
||||
let _ = app.run("library.select", json!({"ids": [id.0], "mode": mode}));
|
||||
}
|
||||
if resp.double_clicked() {
|
||||
let _ = app.run("library.select", json!({"ids": [id.0]}));
|
||||
let _ = app.run("view.detail", json!({}));
|
||||
}
|
||||
resp.context_menu(|ui| context_menu(app, ui, id));
|
||||
}
|
||||
|
||||
pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
if !app.session.selection.contains(id) {
|
||||
let _ = app.run("library.select", json!({"ids": [id.0]}));
|
||||
}
|
||||
if ui.button("Open in Detail").clicked() {
|
||||
let _ = app.run("view.detail", json!({}));
|
||||
}
|
||||
ui.separator();
|
||||
ui.menu_button("Set Rating", |ui| {
|
||||
for r in 0..=5 {
|
||||
if ui.button(if r == 0 { "No Stars".to_string() } else { "★".repeat(r) }).clicked() {
|
||||
let _ = app.run("photo.rate", json!({"rating": r}));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.menu_button("Set Flag", |ui| {
|
||||
for (l, f) in [("Pick", "pick"), ("Reject", "reject"), ("Unflagged", "none")] {
|
||||
if ui.button(l).clicked() {
|
||||
let _ = app.run("photo.flag", json!({"flag": f}));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.menu_button("Add to Album", |ui| {
|
||||
let albums: Vec<_> = app.session.catalog.albums().filter(|a| !a.folder).map(|a| (a.id.0, a.name.clone())).collect();
|
||||
for (aid, name) in albums {
|
||||
if ui.button(name).clicked() {
|
||||
let _ = app.run("album.addPhotos", json!({"id": aid}));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
if ui.button("Copy Edit Settings").clicked() {
|
||||
let _ = app.run("develop.copy", json!({}));
|
||||
}
|
||||
if ui.add_enabled(app.session.clipboard.is_some(), egui::Button::new("Paste Edit Settings")).clicked() {
|
||||
let _ = app.run("develop.paste", json!({}));
|
||||
}
|
||||
if ui.button("Reset Edits").clicked() {
|
||||
let _ = app.run("develop.reset", json!({}));
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Rotate Left").clicked() {
|
||||
let _ = app.run("photo.rotateLeft", json!({}));
|
||||
}
|
||||
if ui.button("Rotate Right").clicked() {
|
||||
let _ = app.run("photo.rotateRight", json!({}));
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Delete Photo").clicked() {
|
||||
let _ = app.run("photo.delete", json!({}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//! The left "My Photos" panel: library sources, albums tree, and date groups.
|
||||
|
||||
use egui::{Align2, Rect, Sense, pos2, vec2};
|
||||
use lightcraft_catalog::{Album, AlbumId};
|
||||
use lightcraft_engine::LibrarySource;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::LightcraftApp;
|
||||
use crate::icons::{Icon, paint};
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{icon_button, register};
|
||||
|
||||
fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, icon: Icon, label: &str, count: Option<usize>, selected: bool, indent: f32) -> egui::Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 29.0), Sense::click());
|
||||
register(ui.ctx(), format!("source:{id}"), r);
|
||||
let inner = r.shrink2(vec2(8.0, 0.0));
|
||||
if selected {
|
||||
ui.painter().rect_filled(inner, 4.0, t.canvas);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(inner, 4.0, t.hover.gamma_multiply(0.6));
|
||||
}
|
||||
paint(ui.painter(), Rect::from_min_size(pos2(r.left() + 18.0 + indent, r.center().y - 8.0), vec2(16.0, 16.0)), icon, if selected { t.text } else { t.icon });
|
||||
ui.painter().text(pos2(r.left() + 42.0 + indent, r.center().y), Align2::LEFT_CENTER, label, t.font(13.5), if selected { t.text } else { t.text_label });
|
||||
if let Some(n) = count {
|
||||
ui.painter().text(pos2(r.right() - 18.0, r.center().y), Align2::RIGHT_CENTER, n.to_string(), t.font(12.5), t.text_dim);
|
||||
}
|
||||
let _ = app;
|
||||
resp
|
||||
}
|
||||
|
||||
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
egui::Panel::left("left_panel")
|
||||
.exact_size(t.left_w)
|
||||
.resizable(false)
|
||||
.frame(egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider)))
|
||||
.show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover());
|
||||
ui.painter().text(pos2(hr.left() + 18.0, hr.center().y), Align2::LEFT_CENTER, "My Photos", t.semibold(15.0), t.text);
|
||||
let stats: Vec<_> = app.session.catalog.photos().filter(|p| !p.deleted).map(|p| p.flag).collect();
|
||||
let total = stats.len();
|
||||
let picks = stats.iter().filter(|f| **f == lightcraft_catalog::Flag::Pick).count();
|
||||
let deleted = app.session.catalog.photos().filter(|p| p.deleted).count();
|
||||
egui::ScrollArea::vertical().id_salt("left-scroll").auto_shrink([false, false]).show(ui, |ui| {
|
||||
let src = app.session.source;
|
||||
for (id, icon, label, count, s) in [
|
||||
("all", Icon::Photos, "All Photos", Some(total), LibrarySource::All),
|
||||
("recentlyAdded", Icon::Clock, "Recently Added", None, LibrarySource::RecentlyAdded),
|
||||
("picks", Icon::FlagPick, "Picks", Some(picks), LibrarySource::Picks),
|
||||
] {
|
||||
if row(app, ui, id, icon, label, count, src == s, 0.0).clicked() {
|
||||
let _ = app.run("library.source", json!({"kind": id}));
|
||||
}
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
// Albums header
|
||||
let (ar, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover());
|
||||
ui.painter().text(pos2(ar.left() + 18.0, ar.center().y), Align2::LEFT_CENTER, "Albums", t.semibold(13.5), t.text_label);
|
||||
let mut hdr = ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_max(pos2(ar.right() - 50.0, ar.top()), ar.right_bottom())).layout(egui::Layout::right_to_left(egui::Align::Center)));
|
||||
let plus = icon_button(&mut hdr, "albumNew", Icon::Plus, vec2(26.0, 26.0), false, true, "Create Album");
|
||||
egui::Popup::menu(&plus).show(|ui| {
|
||||
if ui.button("Create Album…").clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: false });
|
||||
}
|
||||
if ui.button("Create Folder…").clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: true });
|
||||
}
|
||||
});
|
||||
let albums: Vec<Album> = app.session.catalog.albums().cloned().collect();
|
||||
albums_tree(app, ui, &albums, None, 0.0);
|
||||
ui.add_space(10.0);
|
||||
// By date
|
||||
let (dr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover());
|
||||
ui.painter().text(pos2(dr.left() + 18.0, dr.center().y), Align2::LEFT_CENTER, "By Date", t.semibold(13.5), t.text_label);
|
||||
for g in app.session.catalog.date_groups() {
|
||||
let sel = app.session.filter.date.as_deref() == Some(g.year.as_str());
|
||||
if row(app, ui, &format!("year:{}", g.year), Icon::Clock, &g.year, Some(g.count), sel, 0.0).clicked() {
|
||||
let v = if sel { serde_json::Value::Null } else { json!(g.year) };
|
||||
let _ = app.run("library.filter", json!({"date": v}));
|
||||
}
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
if row(app, ui, "recentlyDeleted", Icon::Trash, "Recently Deleted", Some(deleted), src == LibrarySource::RecentlyDeleted, 0.0).clicked() {
|
||||
let _ = app.run("library.source", json!({"kind": "recentlyDeleted"}));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &[Album], parent: Option<AlbumId>, indent: f32) {
|
||||
let mut kids: Vec<&Album> = all.iter().filter(|a| a.parent == parent).collect();
|
||||
kids.sort_by_key(|a| (!a.folder, a.name.to_lowercase()));
|
||||
for a in kids {
|
||||
if a.folder {
|
||||
let open_id = egui::Id::new(("folder-open", a.id.0));
|
||||
let open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(true);
|
||||
let resp = row(app, ui, &format!("folder:{}", a.id.0), Icon::Folder, &a.name, None, false, indent);
|
||||
if resp.clicked() {
|
||||
ui.data_mut(|d| d.insert_temp(open_id, !open));
|
||||
}
|
||||
folder_menu(app, &resp, a);
|
||||
if open {
|
||||
albums_tree(app, ui, all, Some(a.id), indent + 16.0);
|
||||
}
|
||||
} else {
|
||||
let sel = app.session.source == LibrarySource::Album(a.id);
|
||||
let resp = row(app, ui, &format!("album:{}", a.id.0), Icon::Album, &a.name, Some(a.photos.len()), sel, indent);
|
||||
if resp.clicked() {
|
||||
let _ = app.run("library.source", json!({"kind": "album", "id": a.id.0}));
|
||||
}
|
||||
folder_menu(app, &resp, a);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) {
|
||||
resp.context_menu(|ui| {
|
||||
if !a.folder && ui.button("Add Selected Photos").clicked() {
|
||||
let _ = app.run("album.addPhotos", json!({"id": a.id.0}));
|
||||
}
|
||||
if ui.button("Rename…").clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::RenameAlbum { id: a.id.0, name: a.name.clone() });
|
||||
}
|
||||
if ui.button("Delete").clicked() {
|
||||
let _ = app.run("album.delete", json!({"id": a.id.0}));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
//! The Masking panel: create masks, list them, edit components and local adjustments.
|
||||
|
||||
use egui::{Align2, Rect, Sense, Stroke, pos2, vec2};
|
||||
use lightcraft_catalog::PhotoId;
|
||||
use lightcraft_develop::{ControlSpec, LocalAdjustments, MaskShape, Section, Track};
|
||||
use serde_json::json;
|
||||
|
||||
use super::edit::apply_slider_out;
|
||||
use super::right::header;
|
||||
use crate::LightcraftApp;
|
||||
use crate::icons::{Icon, paint};
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{divider, icon_button, register, slider, text_button};
|
||||
|
||||
const fn spec(id: &'static str, label: &'static str, min: f64, max: f64, step: f64, decimals: u8, track: Track) -> ControlSpec {
|
||||
ControlSpec { id, label, section: Section::Light, min, max, default: 0.0, step, decimals, track }
|
||||
}
|
||||
|
||||
/// Local adjustment sliders (key = `LocalAdjustments` field name).
|
||||
pub const LOCAL: &[ControlSpec] = &[
|
||||
spec("temp", "Temp", -100.0, 100.0, 1.0, 0, Track::Temp),
|
||||
spec("tint", "Tint", -100.0, 100.0, 1.0, 0, Track::Tint),
|
||||
spec("exposure", "Exposure", -4.0, 4.0, 0.01, 2, Track::Centered),
|
||||
spec("contrast", "Contrast", -100.0, 100.0, 1.0, 0, Track::Centered),
|
||||
spec("highlights", "Highlights", -100.0, 100.0, 1.0, 0, Track::Centered),
|
||||
spec("shadows", "Shadows", -100.0, 100.0, 1.0, 0, Track::Centered),
|
||||
spec("whites", "Whites", -100.0, 100.0, 1.0, 0, Track::Centered),
|
||||
spec("blacks", "Blacks", -100.0, 100.0, 1.0, 0, Track::Centered),
|
||||
spec("texture", "Texture", -100.0, 100.0, 1.0, 0, Track::Centered),
|
||||
spec("clarity", "Clarity", -100.0, 100.0, 1.0, 0, Track::Centered),
|
||||
spec("dehaze", "Dehaze", -100.0, 100.0, 1.0, 0, Track::Centered),
|
||||
spec("hue", "Hue", -100.0, 100.0, 1.0, 0, Track::Rainbow),
|
||||
spec("saturation", "Saturation", -100.0, 100.0, 1.0, 0, Track::Gradient { from: "#7a7a7a", to: "#e04a3a" }),
|
||||
spec("sharpness", "Sharpness", -100.0, 100.0, 1.0, 0, Track::Centered),
|
||||
spec("noise", "Noise", -100.0, 100.0, 1.0, 0, Track::Centered),
|
||||
];
|
||||
|
||||
pub fn local_get(a: &LocalAdjustments, key: &str) -> f64 {
|
||||
serde_json::to_value(a).ok().and_then(|v| v.get(key).and_then(|x| x.as_f64())).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
fn kind_label(s: &MaskShape) -> (&'static str, Icon) {
|
||||
match s {
|
||||
MaskShape::Brush { .. } => ("Brush", Icon::Brush),
|
||||
MaskShape::Linear { .. } => ("Linear Gradient", Icon::Linear),
|
||||
MaskShape::Radial { .. } => ("Radial Gradient", Icon::Radial),
|
||||
MaskShape::ColorRange { .. } => ("Color Range", Icon::Picker),
|
||||
MaskShape::LuminanceRange { .. } => ("Luminance Range", Icon::Sliders),
|
||||
MaskShape::DepthRange { .. } => ("Depth Range", Icon::Sliders),
|
||||
MaskShape::Subject => ("Subject", Icon::Subject),
|
||||
MaskShape::Sky => ("Sky", Icon::Sky),
|
||||
MaskShape::Background => ("Background", Icon::Subject),
|
||||
MaskShape::Object { .. } => ("Object", Icon::Subject),
|
||||
MaskShape::People { .. } => ("People", Icon::Subject),
|
||||
MaskShape::Landscape { .. } => ("Landscape", Icon::Sky),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let d = app.session.develop_of(id).unwrap_or_default();
|
||||
header(ui, "Masking");
|
||||
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 0, bottom: 10 }).show(ui, |ui| {
|
||||
ui.label(egui::RichText::new("Create New Mask").color(t.text_dim));
|
||||
ui.add_space(6.0);
|
||||
let tiles: [(&str, &str, Icon); 8] = [
|
||||
("subject", "Subject", Icon::Subject),
|
||||
("sky", "Sky", Icon::Sky),
|
||||
("background", "Background", Icon::Subject),
|
||||
("brush", "Brush", Icon::Brush),
|
||||
("linear", "Linear", Icon::Linear),
|
||||
("radial", "Radial", Icon::Radial),
|
||||
("luminanceRange", "Luminance", Icon::Sliders),
|
||||
("colorRange", "Color", Icon::Picker),
|
||||
];
|
||||
egui::Grid::new("mask-tiles").spacing(vec2(6.0, 6.0)).show(ui, |ui| {
|
||||
for (i, (kind, label, icon)) in tiles.iter().enumerate() {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(52.0, 52.0), Sense::click());
|
||||
register(ui.ctx(), format!("maskNew:{kind}"), r);
|
||||
ui.painter().rect_filled(r, 4.0, if resp.hovered() { t.hover } else { t.inset });
|
||||
paint(ui.painter(), Rect::from_center_size(r.center() - vec2(0.0, 7.0), vec2(20.0, 20.0)), *icon, t.text_label);
|
||||
ui.painter().text(pos2(r.center().x, r.bottom() - 9.0), Align2::CENTER_CENTER, *label, t.font(10.5), t.text_dim);
|
||||
if resp.clicked() {
|
||||
match *kind {
|
||||
"brush" | "linear" | "radial" => {
|
||||
app.ui.tool = kind.to_string();
|
||||
if *kind != "brush" {
|
||||
let _ = app.run("mask.add", json!({"kind": kind}));
|
||||
}
|
||||
}
|
||||
k => {
|
||||
let _ = app.run("mask.add", json!({"kind": k}));
|
||||
}
|
||||
}
|
||||
}
|
||||
if i % 4 == 3 {
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
divider(ui);
|
||||
// mask list
|
||||
egui::Frame::NONE.inner_margin(egui::Margin { left: 16, right: 16, top: 8, bottom: 8 }).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new("Masks").font(t.semibold(13.0)).color(t.text_label));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let on = app.ui.mask_overlay;
|
||||
if icon_button(ui, "maskOverlay", Icon::Eye, vec2(24.0, 24.0), on, true, "Show overlay (O)").clicked() {
|
||||
app.ui.mask_overlay = !on;
|
||||
}
|
||||
});
|
||||
});
|
||||
if d.masks.is_empty() {
|
||||
ui.label(egui::RichText::new("No masks yet. Choose a mask type above.").color(t.text_dim));
|
||||
}
|
||||
for m in &d.masks {
|
||||
let sel = app.session.active_mask == Some(m.id);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click());
|
||||
register(ui.ctx(), format!("mask:{}", m.id), r);
|
||||
ui.painter().rect_filled(r, 4.0, if sel { t.tool_active } else if resp.hovered() { t.hover.gamma_multiply(0.7) } else { t.chrome });
|
||||
let icon = m.components.first().map(|c| kind_label(&c.shape).1).unwrap_or(Icon::Mask);
|
||||
paint(ui.painter(), Rect::from_min_size(r.min + vec2(8.0, 7.0), vec2(16.0, 16.0)), icon, t.text_label);
|
||||
ui.painter().text(pos2(r.left() + 32.0, r.center().y), Align2::LEFT_CENTER, &m.name, t.font(13.0), if m.visible { t.text } else { t.text_disabled });
|
||||
if resp.clicked() {
|
||||
let _ = app.run("mask.select", json!({"id": m.id}));
|
||||
}
|
||||
}
|
||||
});
|
||||
let Some(mid) = app.session.active_mask else { return };
|
||||
let Some(m) = d.masks.iter().find(|m| m.id == mid).cloned() else { return };
|
||||
divider(ui);
|
||||
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 8, bottom: 8 }).show(ui, |ui| {
|
||||
for (i, c) in m.components.iter().enumerate() {
|
||||
let (label, icon) = kind_label(&c.shape);
|
||||
ui.horizontal(|ui| {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover());
|
||||
paint(ui.painter(), r, icon, t.text_label);
|
||||
let op = match c.op {
|
||||
lightcraft_develop::MaskOp::Add => "",
|
||||
lightcraft_develop::MaskOp::Subtract => "− ",
|
||||
lightcraft_develop::MaskOp::Intersect => "∩ ",
|
||||
};
|
||||
ui.label(format!("{op}{label}{}", if c.invert { " (inverted)" } else { "" }));
|
||||
let _ = i;
|
||||
});
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
let add = text_button(ui, "maskAddComp", "Add", false);
|
||||
egui::Popup::menu(&add).show(|ui| component_menu(app, ui, "add"));
|
||||
let sub = text_button(ui, "maskSubComp", "Subtract", false);
|
||||
egui::Popup::menu(&sub).show(|ui| component_menu(app, ui, "subtract"));
|
||||
if text_button(ui, "maskInvert", "Invert", m.invert).clicked() {
|
||||
let _ = app.run("mask.invert", json!({}));
|
||||
}
|
||||
if icon_button(ui, "maskDelete", Icon::Trash, vec2(26.0, 24.0), false, true, "Delete mask").clicked() {
|
||||
let _ = app.run("mask.delete", json!({}));
|
||||
}
|
||||
});
|
||||
});
|
||||
if app.ui.tool == "brush" {
|
||||
divider(ui);
|
||||
brush_settings(app, ui);
|
||||
}
|
||||
divider(ui);
|
||||
for s in LOCAL {
|
||||
let v = local_get(&m.adjust, s.id);
|
||||
let out = slider(ui, s, v, true, None);
|
||||
apply_slider_out(app, s, out, |app, v| app.run("mask.adjust", json!({"values": {s.id: v}})));
|
||||
}
|
||||
let amt = ControlSpec { id: "amount", label: "Amount", section: Section::Light, min: 0.0, max: 200.0, default: 100.0, step: 1.0, decimals: 0, track: Track::Plain };
|
||||
let out = slider(ui, &amt, m.adjust.amount, true, None);
|
||||
apply_slider_out(app, &amt, out, |app, v| app.run("mask.adjust", json!({"values": {"amount": v}})));
|
||||
ui.add_space(30.0);
|
||||
let _ = Stroke::NONE;
|
||||
}
|
||||
|
||||
fn component_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, op: &str) {
|
||||
for (kind, label) in [("brush", "Brush"), ("linear", "Linear Gradient"), ("radial", "Radial Gradient"), ("sky", "Sky"), ("subject", "Subject"), ("luminanceRange", "Luminance Range")] {
|
||||
if ui.button(label).clicked() {
|
||||
if kind == "brush" {
|
||||
app.ui.tool = "brush".into();
|
||||
app.ui.brush_erase = op == "subtract";
|
||||
} else {
|
||||
let _ = app.run("mask.addComponent", json!({"op": op, "kind": kind}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn brush_settings(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 6, bottom: 0 }).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new("Brush").font(t.semibold(13.0)));
|
||||
if text_button(ui, "brushAdd", "Add", !app.ui.brush_erase).clicked() {
|
||||
app.ui.brush_erase = false;
|
||||
}
|
||||
if text_button(ui, "brushErase", "Erase", app.ui.brush_erase).clicked() {
|
||||
app.ui.brush_erase = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
for (id, label, min, max, get) in [
|
||||
("ui.brushSize", "Size", 1.0, 100.0, (app.ui.brush_size * 400.0) as f64),
|
||||
("ui.brushFeather", "Feather", 0.0, 100.0, app.ui.brush_feather as f64),
|
||||
("ui.brushFlow", "Flow", 1.0, 100.0, app.ui.brush_flow as f64),
|
||||
] {
|
||||
let s = ControlSpec { id, label, section: Section::Light, min, max, default: min, step: 1.0, decimals: 0, track: Track::Plain };
|
||||
let out = slider(ui, &s, get, true, None);
|
||||
if let Some(v) = out.value {
|
||||
match id {
|
||||
"ui.brushSize" => app.ui.brush_size = (v / 400.0) as f32,
|
||||
"ui.brushFeather" => app.ui.brush_feather = v as f32,
|
||||
_ => app.ui.brush_flow = v as f32,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! Window regions and panels.
|
||||
|
||||
pub mod bottombar;
|
||||
pub mod detail;
|
||||
pub mod dialogs;
|
||||
pub mod edit;
|
||||
pub mod grid;
|
||||
pub mod left;
|
||||
pub mod masking;
|
||||
pub mod presets;
|
||||
pub mod right;
|
||||
pub mod strip;
|
||||
pub mod topbar;
|
||||
|
||||
use egui::{Align2, Rect, pos2, vec2};
|
||||
|
||||
use crate::LightcraftApp;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// 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 Some((text, until)) = app.ui.toast.clone() else { return };
|
||||
if now > until {
|
||||
app.ui.toast = None;
|
||||
return;
|
||||
}
|
||||
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 galley = painter.layout_no_wrap(text, t.font(14.0), t.text.gamma_multiply(fade));
|
||||
let size = vec2(galley.size().x + 40.0, 40.0);
|
||||
let r = Rect::from_center_size(pos2(canvas.center().x, canvas.bottom() - 60.0), size);
|
||||
painter.rect_filled(r, 6.0, egui::Color32::from_black_alpha((200.0 * fade) as u8));
|
||||
painter.galley(r.center() - galley.size() / 2.0, galley, t.text);
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(30));
|
||||
}
|
||||
|
||||
/// Paint a centred, dimmed message (empty states).
|
||||
pub fn empty_message(ui: &egui::Ui, rect: Rect, title: &str, body: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let p = ui.painter();
|
||||
p.text(rect.center() - vec2(0.0, 12.0), Align2::CENTER_CENTER, title, t.semibold(18.0), t.text_label);
|
||||
p.text(rect.center() + vec2(0.0, 14.0), Align2::CENTER_CENTER, body, t.font(13.0), t.text_dim);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//! The Presets column (opens to the left of the Edit panel): grouped presets with hover preview,
|
||||
//! amount slider, create/favourite.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use egui::{Align2, Rect, Sense, pos2, vec2};
|
||||
use lightcraft_develop::{ControlSpec, Section, Track};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::LightcraftApp;
|
||||
use crate::icons::{Icon, paint};
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{divider, icon_button, register, slider};
|
||||
|
||||
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
egui::Panel::right("presets_panel")
|
||||
.exact_size(t.panel_w)
|
||||
.resizable(false)
|
||||
.frame(egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider)))
|
||||
.show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::hover());
|
||||
ui.painter().text(pos2(hr.left() + 24.0, hr.center().y + 2.0), Align2::LEFT_CENTER, "Presets", t.semibold(15.0), t.text);
|
||||
let mut hdr = ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_max(pos2(hr.right() - 80.0, hr.top()), hr.right_bottom())).layout(egui::Layout::right_to_left(egui::Align::Center)));
|
||||
if icon_button(&mut hdr, "presetCreate", Icon::Plus, vec2(28.0, 28.0), false, app.session.active().is_some(), "Create Preset…").clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::CreatePreset { name: String::new(), group: "User Presets".into() });
|
||||
}
|
||||
divider(ui);
|
||||
// amount slider (applies to the last applied preset)
|
||||
let amt_id = egui::Id::new("preset-amount");
|
||||
let last: Option<(String, f64)> = ui.data(|d| d.get_temp(amt_id));
|
||||
if let Some((pid, amount)) = last.clone() {
|
||||
let spec = ControlSpec { id: "presetAmount", label: "Amount", section: Section::Profile, min: 0.0, max: 200.0, default: 100.0, step: 1.0, decimals: 0, track: Track::Plain };
|
||||
let out = slider(ui, &spec, amount, true, None);
|
||||
if let Some(v) = out.value {
|
||||
// re-apply from the pre-preset state: undo last preset step then apply with the new amount
|
||||
let _ = app.run("edit.undo", json!({}));
|
||||
let _ = app.run("preset.apply", json!({"id": pid, "amount": v}));
|
||||
ui.data_mut(|d| d.insert_temp(amt_id, (pid, v)));
|
||||
}
|
||||
divider(ui);
|
||||
}
|
||||
egui::ScrollArea::vertical().id_salt("presets-scroll").auto_shrink([false, false]).show(ui, |ui| {
|
||||
let mut groups: BTreeMap<String, Vec<(String, String, bool)>> = BTreeMap::new();
|
||||
for p in &app.session.presets {
|
||||
groups.entry(p.group.clone()).or_default().push((p.id.clone(), p.name.clone(), p.favorite));
|
||||
}
|
||||
for (g, items) in groups {
|
||||
let open_id = egui::Id::new(("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());
|
||||
register(ui.ctx(), format!("presetGroup:{g}"), r);
|
||||
paint(ui.painter(), Rect::from_center_size(pos2(r.left() + 22.0, r.center().y), vec2(12.0, 12.0)), if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_label);
|
||||
ui.painter().text(pos2(r.left() + 36.0, r.center().y), Align2::LEFT_CENTER, &g, t.semibold(13.0), t.text_label);
|
||||
if resp.clicked() {
|
||||
ui.data_mut(|d| d.insert_temp(open_id, !open));
|
||||
}
|
||||
if !open {
|
||||
continue;
|
||||
}
|
||||
for (pid, name, fav) in items {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click());
|
||||
register(ui.ctx(), format!("preset:{pid}"), r);
|
||||
let active = last.as_ref().is_some_and(|(l, _)| *l == pid);
|
||||
if active {
|
||||
ui.painter().rect_filled(r, 0.0, t.tool_active);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.7));
|
||||
}
|
||||
ui.painter().text(pos2(r.left() + 40.0, r.center().y), Align2::LEFT_CENTER, &name, t.font(13.0), t.text_label);
|
||||
if fav {
|
||||
paint(ui.painter(), Rect::from_center_size(pos2(r.right() - 20.0, r.center().y), vec2(12.0, 12.0)), Icon::StarFilled, t.star);
|
||||
}
|
||||
if resp.clicked() {
|
||||
let _ = app.run("preset.apply", json!({"id": pid, "amount": 100}));
|
||||
ui.data_mut(|d| d.insert_temp(amt_id, (pid.clone(), 100.0)));
|
||||
app.toast(ui.ctx(), format!("Preset: {name}"));
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
if ui.button(if fav { "Remove from Favorites" } else { "Add to Favorites" }).clicked() {
|
||||
let _ = app.run("preset.favorite", json!({"id": pid}));
|
||||
}
|
||||
if ui.button("Delete Preset").clicked() {
|
||||
let _ = app.run("preset.delete", json!({"id": pid}));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
ui.add_space(30.0);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
//! The right-hand panel next to the tool strip: Edit, Crop, Remove, Masking, Red Eye, Info,
|
||||
//! Keywords, Versions, Activity.
|
||||
|
||||
use egui::{Align2, Rect, Sense, pos2, vec2};
|
||||
use lightcraft_catalog::PhotoId;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::LightcraftApp;
|
||||
use crate::icons::{Icon, paint};
|
||||
use crate::state::RightPanel;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{divider, register, slider, text_button};
|
||||
|
||||
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
egui::Panel::right("right_panel")
|
||||
.exact_size(t.panel_w)
|
||||
.resizable(false)
|
||||
.frame(egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider)))
|
||||
.show(ui, |ui| {
|
||||
let Some(id) = app.session.active() else {
|
||||
let r = ui.max_rect();
|
||||
super::empty_message(ui, r, "No photo selected", "Select a photo to edit");
|
||||
return;
|
||||
};
|
||||
egui::ScrollArea::vertical().id_salt("right-scroll").auto_shrink([false, false]).show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
match app.ui.right {
|
||||
RightPanel::Edit => super::edit::show(app, ui, id),
|
||||
RightPanel::Crop => crop(app, ui, id),
|
||||
RightPanel::Remove => remove(app, ui, id),
|
||||
RightPanel::Masking => super::masking::show(app, ui, id),
|
||||
RightPanel::RedEye => red_eye(app, ui),
|
||||
RightPanel::Info => info(app, ui, id),
|
||||
RightPanel::Keywords => keywords(app, ui, id),
|
||||
RightPanel::Versions => versions(app, ui, id),
|
||||
RightPanel::Activity => activity(app, ui, id),
|
||||
RightPanel::None => {}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
pub fn header(ui: &mut egui::Ui, title: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::hover());
|
||||
ui.painter().text(pos2(r.left() + 24.0, r.center().y + 2.0), Align2::LEFT_CENTER, title, t.semibold(15.0), t.text);
|
||||
}
|
||||
|
||||
pub fn label_row(ui: &mut egui::Ui, label: &str, value: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::hover());
|
||||
ui.painter().text(pos2(r.left() + 24.0, r.center().y), Align2::LEFT_CENTER, label, t.font(12.5), t.text_dim);
|
||||
ui.painter().text(pos2(r.left() + 110.0, r.center().y), Align2::LEFT_CENTER, value, t.font(12.5), t.text_label);
|
||||
}
|
||||
|
||||
fn padded(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui)) {
|
||||
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 6, bottom: 6 }).show(ui, add);
|
||||
}
|
||||
|
||||
fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
let d = app.session.develop_of(id).unwrap_or_default();
|
||||
header(ui, "Crop");
|
||||
padded(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Aspect Ratio");
|
||||
let cur = d.crop.aspect.map(|(w, h)| format!("{} × {}", w as f64 / 100.0, h as f64 / 100.0)).unwrap_or_else(|| "Free".into());
|
||||
let r = ui.add(egui::Button::new(cur).frame(false));
|
||||
register(ui.ctx(), "button:cropAspect", r.rect);
|
||||
egui::Popup::menu(&r).show(|ui| {
|
||||
for (label, a) in [
|
||||
("Free", "free"),
|
||||
("Original", "original"),
|
||||
("1 × 1", "1x1"),
|
||||
("4 × 5 / 8 × 10", "4x5"),
|
||||
("8.5 × 11", "8.5x11"),
|
||||
("5 × 7", "5x7"),
|
||||
("2 × 3 / 4 × 6", "2x3"),
|
||||
("4 × 3", "4x3"),
|
||||
("16 × 9", "16x9"),
|
||||
("16 × 10", "16x10"),
|
||||
] {
|
||||
if ui.button(label).clicked() {
|
||||
let _ = app.run("crop.aspect", json!({"aspect": a}));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
if text_button(ui, "cropRotateLeft", "⟲ 90°", false).clicked() {
|
||||
let _ = app.run("photo.rotateLeft", json!({}));
|
||||
}
|
||||
if text_button(ui, "cropRotateRight", "⟳ 90°", false).clicked() {
|
||||
let _ = app.run("photo.rotateRight", json!({}));
|
||||
}
|
||||
if text_button(ui, "cropFlipH", "Flip H", d.crop.flip_h).clicked() {
|
||||
let _ = app.run("photo.flipHorizontal", json!({}));
|
||||
}
|
||||
if text_button(ui, "cropFlipV", "Flip V", d.crop.flip_v).clicked() {
|
||||
let _ = app.run("photo.flipVertical", json!({}));
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
if text_button(ui, "cropSwapAspect", "Swap Aspect (X)", false).clicked() {
|
||||
let _ = app.run("crop.rotateAspect", json!({}));
|
||||
}
|
||||
if text_button(ui, "cropReset", "Reset", false).clicked() {
|
||||
let _ = app.run("crop.reset", json!({}));
|
||||
}
|
||||
});
|
||||
});
|
||||
let ang = lightcraft_develop::controls::find("crop.angle").copied();
|
||||
if let Some(spec) = ang {
|
||||
let out = slider(ui, &spec, d.crop.geometry.angle, true, Some("Straighten"));
|
||||
super::edit::apply_slider_out(app, &spec, out, |app, v| app.run("crop.straighten", json!({"angle": v})));
|
||||
}
|
||||
padded(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Overlay");
|
||||
for (label, o) in [("Thirds", crate::state::CropOverlay::Thirds), ("Grid", crate::state::CropOverlay::Grid), ("Golden", crate::state::CropOverlay::Golden), ("None", crate::state::CropOverlay::None)] {
|
||||
if ui.selectable_label(app.ui.crop_overlay == o, label).clicked() {
|
||||
app.ui.crop_overlay = o;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
divider(ui);
|
||||
header(ui, "Geometry");
|
||||
for spec in lightcraft_develop::controls::in_section(lightcraft_develop::Section::Geometry).filter(|c| c.id != "crop.angle") {
|
||||
let v = lightcraft_develop::controls::get(&d, spec.id).unwrap_or(spec.default);
|
||||
let out = slider(ui, spec, v, true, None);
|
||||
super::edit::apply_slider_out(app, spec, out, |app, v| app.run("develop.set", json!({"control": spec.id, "value": v})));
|
||||
}
|
||||
}
|
||||
|
||||
fn remove(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
let d = app.session.develop_of(id).unwrap_or_default();
|
||||
header(ui, "Remove");
|
||||
padded(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
for (label, tool) in [("Content-Aware Remove", "remove"), ("Heal", "heal"), ("Clone", "clone")] {
|
||||
if text_button(ui, &format!("removeMode-{tool}"), label, app.ui.tool == tool).clicked() {
|
||||
app.ui.tool = tool.into();
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.label(format!("{} spot(s) on this photo", d.spots.len()));
|
||||
ui.add_space(4.0);
|
||||
ui.label(egui::RichText::new("Paint over a distraction on the photo to remove it.").color(Tokens::get(ui.ctx()).text_dim));
|
||||
});
|
||||
let spec = lightcraft_develop::ControlSpec {
|
||||
id: "ui.removeSize",
|
||||
label: "Size",
|
||||
section: lightcraft_develop::Section::Detail,
|
||||
min: 1.0,
|
||||
max: 100.0,
|
||||
default: 20.0,
|
||||
step: 1.0,
|
||||
decimals: 0,
|
||||
track: lightcraft_develop::Track::Plain,
|
||||
};
|
||||
let out = slider(ui, &spec, (app.ui.remove_size * 1000.0) as f64, true, None);
|
||||
if let Some(v) = out.value {
|
||||
app.ui.remove_size = (v / 1000.0) as f32;
|
||||
}
|
||||
padded(ui, |ui| {
|
||||
if !d.spots.is_empty() && text_button(ui, "removeClear", "Delete all spots", false).clicked() {
|
||||
for i in (0..d.spots.len()).rev() {
|
||||
let _ = app.run("spot.delete", json!({"index": i}));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn red_eye(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
header(ui, "Red Eye");
|
||||
padded(ui, |ui| {
|
||||
ui.label("Click and drag over an eye to correct it.");
|
||||
if text_button(ui, "redeyeAuto", "Auto Correct", false).clicked() {
|
||||
app.ui.status = "Red eye correction arrives in M8".into();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
let Some(p) = app.session.catalog.photo(id).cloned() else { return };
|
||||
header(ui, "Info");
|
||||
let t = Tokens::get(ui.ctx());
|
||||
padded(ui, |ui| {
|
||||
ui.label(egui::RichText::new(&p.file_name).font(t.semibold(14.0)).color(t.text));
|
||||
ui.label(egui::RichText::new(format!("{} × {} · {}", p.width, p.height, p.format)).color(t.text_dim));
|
||||
ui.add_space(8.0);
|
||||
if let Some(r) = crate::widgets::stars(ui, "info", p.rating, 20.0) {
|
||||
let _ = app.run("photo.rate", json!({"rating": r}));
|
||||
}
|
||||
});
|
||||
divider(ui);
|
||||
let mut title = p.meta.title.clone();
|
||||
let mut caption = p.meta.caption.clone();
|
||||
let mut copyright = p.meta.copyright.clone();
|
||||
padded(ui, |ui| {
|
||||
for (label, val, key) in [("Title", &mut title, "title"), ("Caption", &mut caption, "caption"), ("Copyright", &mut copyright, "copyright")] {
|
||||
ui.label(egui::RichText::new(label).color(t.text_dim));
|
||||
let r = ui.add(egui::TextEdit::singleline(val).desired_width(f32::INFINITY));
|
||||
register(ui.ctx(), format!("field:{key}"), r.rect);
|
||||
if r.lost_focus() {
|
||||
let _ = app.run("photo.setMeta", json!({key: val.clone()}));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
});
|
||||
divider(ui);
|
||||
header(ui, "Camera");
|
||||
let m = &p.meta;
|
||||
label_row(ui, "Captured", p.captured.as_deref().unwrap_or("—"));
|
||||
label_row(ui, "Camera", &m.camera);
|
||||
label_row(ui, "Lens", &m.lens);
|
||||
label_row(ui, "Focal", &m.focal_mm.map(|f| format!("{f:.0} mm")).unwrap_or_default());
|
||||
label_row(ui, "Aperture", &m.aperture.map(|f| format!("f/{f:.1}")).unwrap_or_default());
|
||||
label_row(ui, "Shutter", &m.shutter);
|
||||
label_row(ui, "ISO", &m.iso.map(|f| f.to_string()).unwrap_or_default());
|
||||
label_row(ui, "Location", &m.location);
|
||||
label_row(ui, "File size", &format!("{:.1} MB", p.file_size as f64 / 1e6));
|
||||
}
|
||||
|
||||
fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
let Some(p) = app.session.catalog.photo(id).cloned() else { return };
|
||||
header(ui, "Keywords");
|
||||
let t = Tokens::get(ui.ctx());
|
||||
padded(ui, |ui| {
|
||||
let kid = egui::Id::new("kw-input");
|
||||
let mut text = ui.data_mut(|d| d.get_temp::<String>(kid).unwrap_or_default());
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut text).hint_text("Add keyword").desired_width(f32::INFINITY));
|
||||
register(ui.ctx(), "field:keyword", r.rect);
|
||||
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && !text.trim().is_empty() {
|
||||
let kws: Vec<String> = text.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
|
||||
let _ = app.run("photo.setMeta", json!({"addKeywords": kws}));
|
||||
text.clear();
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(kid, text));
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for k in &p.meta.keywords {
|
||||
let r = ui.add(egui::Button::new(egui::RichText::new(format!("{k} ✕")).color(t.text_label)).corner_radius(10.0));
|
||||
if r.clicked() {
|
||||
let _ = app.run("photo.setMeta", json!({"removeKeywords": [k]}));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
ui.label(egui::RichText::new("Keywords in library").color(t.text_dim));
|
||||
let all = app.session.catalog.keywords();
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (k, n) in all {
|
||||
if !p.meta.keywords.contains(&k) && ui.add(egui::Button::new(egui::RichText::new(format!("{k} {n}")).color(t.text_dim)).frame(false)).clicked() {
|
||||
let _ = app.run("photo.setMeta", json!({"addKeywords": [k]}));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
let Some(p) = app.session.catalog.photo(id).cloned() else { return };
|
||||
header(ui, "Versions");
|
||||
padded(ui, |ui| {
|
||||
if text_button(ui, "versionCreate", "Create Version", false).clicked() {
|
||||
let _ = app.run("version.create", json!({}));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
for (i, v) in p.versions.iter().enumerate() {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button(&v.name).on_hover_text(&v.created).clicked() {
|
||||
let _ = app.run("version.restore", json!({"index": i}));
|
||||
}
|
||||
if ui.small_button("✕").clicked() {
|
||||
let _ = app.run("version.delete", json!({"index": i}));
|
||||
}
|
||||
});
|
||||
}
|
||||
if p.versions.is_empty() {
|
||||
ui.label("No versions yet.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn activity(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
let Some(p) = app.session.catalog.photo(id).cloned() else { return };
|
||||
header(ui, "History");
|
||||
let t = Tokens::get(ui.ctx());
|
||||
padded(ui, |ui| {
|
||||
if p.history.is_empty() {
|
||||
ui.label(egui::RichText::new("No edits yet.").color(t.text_dim));
|
||||
}
|
||||
for (i, h) in p.history.iter().enumerate().rev() {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click());
|
||||
register(ui.ctx(), format!("history:{i}"), r);
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 3.0, t.hover);
|
||||
}
|
||||
paint(ui.painter(), Rect::from_min_size(r.min + vec2(0.0, 4.0), vec2(16.0, 16.0)), Icon::Clock, t.icon);
|
||||
ui.painter().text(pos2(r.left() + 24.0, r.center().y), Align2::LEFT_CENTER, &h.label, t.font(12.5), t.text_label);
|
||||
if resp.clicked() {
|
||||
let _ = app.run("history.restore", json!({"index": i}));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//! The vertical tool strip at the far right (Presets, Edit, Crop, Remove, Masking, Red Eye …).
|
||||
|
||||
use egui::vec2;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::LightcraftApp;
|
||||
use crate::icons::Icon;
|
||||
use crate::state::RightPanel;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::icon_button;
|
||||
|
||||
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
egui::Panel::right("tool_strip")
|
||||
.exact_size(t.strip_w)
|
||||
.resizable(false)
|
||||
.frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin { left: 0, right: 0, top: 8, bottom: 8 }).stroke(egui::Stroke::new(1.0, t.divider)))
|
||||
.show(ui, |ui| {
|
||||
let has_photo = app.session.active().is_some();
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 6.0;
|
||||
let sz = vec2(t.strip_w, 40.0);
|
||||
if icon_button(ui, "presets", Icon::Presets, sz, app.ui.presets, has_photo, "Presets (Shift+P)").clicked() {
|
||||
let _ = app.run("panel.presets", json!({}));
|
||||
}
|
||||
for (id, icon, panel, tip) in [
|
||||
("edit", Icon::Sliders, RightPanel::Edit, "Edit (E)"),
|
||||
("crop", Icon::Crop, RightPanel::Crop, "Crop & Rotate (C)"),
|
||||
("remove", Icon::Eraser, RightPanel::Remove, "Remove (H)"),
|
||||
("masking", Icon::Mask, RightPanel::Masking, "Masking (M)"),
|
||||
("redeye", Icon::Eye, RightPanel::RedEye, "Red Eye"),
|
||||
] {
|
||||
if icon_button(ui, id, icon, sz, app.ui.right == panel, has_photo, tip).clicked() {
|
||||
let _ = app.run(&format!("panel.{id}"), json!({}));
|
||||
}
|
||||
if id == "edit" {
|
||||
separator(ui, &t);
|
||||
}
|
||||
}
|
||||
separator(ui, &t);
|
||||
if icon_button(ui, "versions", Icon::Versions, sz, app.ui.right == RightPanel::Versions, has_photo, "Versions (Shift+V)").clicked() {
|
||||
let _ = app.run("panel.versions", json!({}));
|
||||
}
|
||||
if icon_button(ui, "activity", Icon::Activity, sz, app.ui.right == RightPanel::Activity, true, "History & Activity (Y)").clicked() {
|
||||
let _ = app.run("panel.activity", json!({}));
|
||||
}
|
||||
if icon_button(ui, "more", Icon::More, sz, false, true, "More").clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::About);
|
||||
}
|
||||
});
|
||||
// bottom icons
|
||||
let bottom = ui.max_rect().bottom();
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(egui::Rect::from_min_max(egui::pos2(ui.max_rect().left(), bottom - 96.0), ui.max_rect().right_bottom())));
|
||||
child.vertical_centered(|ui| {
|
||||
let sz = vec2(t.strip_w, 40.0);
|
||||
if icon_button(ui, "keywords", Icon::Tag, sz, app.ui.right == RightPanel::Keywords, has_photo, "Keywords (K)").clicked() {
|
||||
let _ = app.run("panel.keywords", json!({}));
|
||||
}
|
||||
if icon_button(ui, "info", Icon::Info, sz, app.ui.right == RightPanel::Info, has_photo, "Info (I)").clicked() {
|
||||
let _ = app.run("panel.info", json!({}));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn separator(ui: &mut egui::Ui, t: &Tokens) {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(t.strip_w - 16.0, 1.0), egui::Sense::hover());
|
||||
ui.painter().rect_filled(r, 0.0, t.button_border.gamma_multiply(0.7));
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//! The top bar: sidebar toggle, back/forward, search, filter, and the right-hand icons.
|
||||
|
||||
use egui::{Align2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::LightcraftApp;
|
||||
use crate::icons::{Icon, paint};
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{icon_button, register};
|
||||
|
||||
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let left = if app.integrated_titlebar { 78 } else { 10 };
|
||||
egui::Panel::top("top_bar")
|
||||
.exact_size(t.top_bar_h)
|
||||
.frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin { left, right: 12, top: 0, bottom: 0 }))
|
||||
.show(ui, |ui| {
|
||||
let full = ui.max_rect();
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
if icon_button(ui, "sidebar", Icon::Sidebar, vec2(30.0, 30.0), app.ui.left_panel, true, "Show/hide My Photos panel").clicked() {
|
||||
let _ = app.run("view.leftPanel", json!({}));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
if icon_button(ui, "back", Icon::Back, vec2(30.0, 30.0), false, true, "Back").clicked() {
|
||||
let _ = app.run("view.back", json!({}));
|
||||
}
|
||||
icon_button(ui, "forward", Icon::Forward, vec2(30.0, 30.0), false, false, "Forward");
|
||||
});
|
||||
// search field (centred on the window)
|
||||
let sw = 640.0f32.min(full.width() - 460.0).max(200.0);
|
||||
let sr = Rect::from_center_size(pos2(full.center().x, full.center().y), vec2(sw, 28.0));
|
||||
let id = egui::Id::new("search-field");
|
||||
let focused = ui.memory(|m| m.has_focus(id));
|
||||
ui.painter().rect(sr, 4.0, if focused { t.canvas } else { t.field }, Stroke::new(1.0, if focused { t.accent } else { t.field_border }), StrokeKind::Inside);
|
||||
register(ui.ctx(), "field:search", sr);
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(sr.shrink2(vec2(10.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center)));
|
||||
let empty = app.ui.search.is_empty();
|
||||
if empty && !focused {
|
||||
let g = child.painter().layout_no_wrap("Search Photos".into(), t.font(13.5), t.text_dim);
|
||||
let w = g.size().x + 24.0;
|
||||
let x0 = sr.center().x - w / 2.0;
|
||||
paint(child.painter(), Rect::from_min_size(pos2(x0, sr.center().y - 8.0), vec2(16.0, 16.0)), Icon::Search, t.text_dim);
|
||||
child.painter().galley(pos2(x0 + 24.0, sr.center().y - g.size().y / 2.0), g, t.text_dim);
|
||||
}
|
||||
let resp = child.add(
|
||||
egui::TextEdit::singleline(&mut app.ui.search).id(id).frame(egui::Frame::NONE).desired_width(sr.width() - 20.0).font(t.font(13.5)).text_color(t.text),
|
||||
);
|
||||
if resp.changed() {
|
||||
let q = app.ui.search.clone();
|
||||
let _ = app.run("library.filter", json!({"text": q}));
|
||||
}
|
||||
// filter icon right of the search field
|
||||
let fr = Rect::from_center_size(pos2(sr.right() + 22.0, sr.center().y), vec2(28.0, 28.0));
|
||||
let fresp = ui.interact(fr, egui::Id::new("filter-btn"), Sense::click());
|
||||
register(ui.ctx(), "icon:filter", fr);
|
||||
let filtering = app.session.filter != Default::default();
|
||||
paint(ui.painter(), fr.shrink(6.0), Icon::Filter, if filtering { t.accent } else if fresp.hovered() { t.text } else { t.icon });
|
||||
if fresp.clicked() {
|
||||
let _ = app.run("view.filterBar", json!({}));
|
||||
}
|
||||
// right icons
|
||||
let mut x = full.right() - 18.0;
|
||||
for (id, icon, tip, cmd) in [
|
||||
("cloud", Icon::Cloud, "Local library — no cloud account needed", ""),
|
||||
("help", Icon::Help, "Keyboard shortcuts", "app.shortcuts"),
|
||||
("share", Icon::Share, "Export", "dialog.export"),
|
||||
("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(tip);
|
||||
register(ui.ctx(), format!("icon:{id}"), r);
|
||||
paint(ui.painter(), r.shrink(5.0), icon, if resp.hovered() { t.text } else { t.icon });
|
||||
if resp.clicked() && !cmd.is_empty() {
|
||||
let _ = app.run(cmd, json!({}));
|
||||
}
|
||||
x -= 40.0;
|
||||
}
|
||||
let _ = Align2::CENTER_CENTER;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
//! Background rendering: a small worker pool runs engine [`RenderJob`]s; results become textures.
|
||||
//!
|
||||
//! Each visible thing that needs pixels is a *slot* (a grid thumbnail, the loupe, the "before"
|
||||
//! image…). Requests are deduplicated per slot (a newer request replaces a queued older one) and
|
||||
//! prioritised (loupe first, then visible thumbnails). On wasm the jobs run inline, one per frame.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
|
||||
use lightcraft_catalog::PhotoId;
|
||||
use lightcraft_engine::Session;
|
||||
use lightcraft_engine::media::{RenderJob, RenderResult};
|
||||
use lightcraft_raster::Histogram;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Slot {
|
||||
Thumb(PhotoId),
|
||||
Main,
|
||||
Before,
|
||||
Compare(u8),
|
||||
}
|
||||
|
||||
pub struct Tex {
|
||||
pub key: u64,
|
||||
pub photo: PhotoId,
|
||||
pub tex: egui::TextureHandle,
|
||||
pub size: [usize; 2],
|
||||
pub histogram: Option<Histogram>,
|
||||
pub ms: f64,
|
||||
}
|
||||
|
||||
type Queue = Arc<(Mutex<Vec<(Slot, u32, RenderJob)>>, Condvar)>;
|
||||
|
||||
pub struct Renderer {
|
||||
queue: Queue,
|
||||
results_tx: Sender<(Slot, RenderResult, f64)>,
|
||||
results: Receiver<(Slot, RenderResult, f64)>,
|
||||
pending: HashMap<Slot, u64>,
|
||||
pub textures: HashMap<Slot, Tex>,
|
||||
started: bool,
|
||||
pub last_main_ms: f64,
|
||||
/// Jobs finished since start (for inspect/perf).
|
||||
pub completed: u64,
|
||||
}
|
||||
|
||||
impl Default for Renderer {
|
||||
fn default() -> Self {
|
||||
let (tx, rx) = channel();
|
||||
Renderer {
|
||||
queue: Arc::new((Mutex::new(Vec::new()), Condvar::new())),
|
||||
results_tx: tx,
|
||||
results: rx,
|
||||
pending: HashMap::new(),
|
||||
textures: HashMap::new(),
|
||||
started: false,
|
||||
last_main_ms: 0.0,
|
||||
completed: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn now() -> f64 {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
fn start(&mut self) {
|
||||
if self.started {
|
||||
return;
|
||||
}
|
||||
self.started = true;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let n = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 6);
|
||||
for i in 0..n {
|
||||
let q = self.queue.clone();
|
||||
let tx = self.results_tx.clone();
|
||||
let _ = std::thread::Builder::new().name(format!("lc-render-{i}")).spawn(move || {
|
||||
loop {
|
||||
let job = {
|
||||
let (lock, cv) = &*q;
|
||||
let mut g = lock.lock().unwrap_or_else(|e| e.into_inner());
|
||||
while g.is_empty() {
|
||||
g = cv.wait(g).unwrap_or_else(|e| e.into_inner());
|
||||
}
|
||||
// highest priority, newest first
|
||||
let best = g.iter().enumerate().max_by_key(|(i, (_, p, _))| (*p, *i)).map(|(i, _)| i).unwrap_or(0);
|
||||
g.remove(best)
|
||||
};
|
||||
let t0 = now();
|
||||
let (slot, _, job) = job;
|
||||
let r = job.run();
|
||||
if tx.send((slot, r, now() - t0)).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the slot's current texture (or pending request) already for `key`?
|
||||
pub fn is_current(&self, slot: Slot, key: u64) -> bool {
|
||||
self.textures.get(&slot).is_some_and(|t| t.key == key) || self.pending.get(&slot) == Some(&key)
|
||||
}
|
||||
|
||||
/// Request a render for `slot` (no-op if already current or pending).
|
||||
pub fn request(&mut self, slot: Slot, job: RenderJob, priority: u32) {
|
||||
if self.is_current(slot, job.key) {
|
||||
return;
|
||||
}
|
||||
self.start();
|
||||
self.pending.insert(slot, job.key);
|
||||
let (lock, cv) = &*self.queue;
|
||||
let mut g = lock.lock().unwrap_or_else(|e| e.into_inner());
|
||||
g.retain(|(s, _, _)| *s != slot);
|
||||
g.push((slot, priority, job));
|
||||
cv.notify_one();
|
||||
}
|
||||
|
||||
pub fn queued(&self) -> usize {
|
||||
self.queue.0.lock().map(|g| g.len()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Collect finished jobs into textures. Returns true if anything changed.
|
||||
pub fn poll(&mut self, ctx: &egui::Context, session: &mut Session) -> bool {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
// inline: run one job per frame
|
||||
let job = {
|
||||
let mut g = self.queue.0.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let best = g.iter().enumerate().max_by_key(|(i, (_, p, _))| (*p, *i)).map(|(i, _)| i);
|
||||
best.map(|b| g.remove(b))
|
||||
};
|
||||
if let Some((slot, _, job)) = job {
|
||||
let r = job.run();
|
||||
let _ = self.results_tx.send((slot, r, 0.0));
|
||||
}
|
||||
}
|
||||
let mut changed = false;
|
||||
while let Ok((slot, r, ms)) = self.results.try_recv() {
|
||||
session.accept(&r);
|
||||
self.completed += 1;
|
||||
if self.pending.get(&slot) == Some(&r.key) {
|
||||
self.pending.remove(&slot);
|
||||
}
|
||||
let Ok(rendered) = r.rendered else {
|
||||
continue;
|
||||
};
|
||||
let img = &rendered.image;
|
||||
let color = egui::ColorImage::from_rgba_unmultiplied([img.width, img.height], &img.as_bytes());
|
||||
let name = format!("{slot:?}");
|
||||
match self.textures.get_mut(&slot) {
|
||||
Some(t) => {
|
||||
t.tex.set(color, egui::TextureOptions::LINEAR);
|
||||
t.key = r.key;
|
||||
t.photo = r.photo;
|
||||
t.size = [img.width, img.height];
|
||||
t.histogram = Some(rendered.histogram);
|
||||
t.ms = ms;
|
||||
}
|
||||
None => {
|
||||
let tex = ctx.load_texture(name, color, egui::TextureOptions::LINEAR);
|
||||
self.textures.insert(slot, Tex { key: r.key, photo: r.photo, tex, size: [img.width, img.height], histogram: Some(rendered.histogram), ms });
|
||||
}
|
||||
}
|
||||
if slot == Slot::Main {
|
||||
self.last_main_ms = ms;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
if !self.pending.is_empty() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(16));
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Drop thumbnails that aren't in `keep` (bounded memory for huge libraries).
|
||||
pub fn evict_thumbs(&mut self, keep: &std::collections::HashSet<PhotoId>, max: usize) {
|
||||
let thumbs = self.textures.keys().filter(|s| matches!(s, Slot::Thumb(_))).count();
|
||||
if thumbs <= max {
|
||||
return;
|
||||
}
|
||||
self.textures.retain(|s, _| !matches!(s, Slot::Thumb(id) if !keep.contains(id)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
//! UI state (serde: saved as preferences, readable/settable through the control channel).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ViewMode {
|
||||
/// Justified rows ("Photo Grid").
|
||||
#[default]
|
||||
PhotoGrid,
|
||||
SquareGrid,
|
||||
Detail,
|
||||
Compare,
|
||||
}
|
||||
|
||||
/// The right-hand tool/panel shown next to the tool strip.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum RightPanel {
|
||||
#[default]
|
||||
None,
|
||||
Edit,
|
||||
Crop,
|
||||
Remove,
|
||||
Masking,
|
||||
RedEye,
|
||||
Versions,
|
||||
Activity,
|
||||
Keywords,
|
||||
Info,
|
||||
}
|
||||
|
||||
impl RightPanel {
|
||||
pub fn is_edit_tool(self) -> bool {
|
||||
matches!(self, RightPanel::Edit | RightPanel::Crop | RightPanel::Remove | RightPanel::Masking | RightPanel::RedEye)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Zoom {
|
||||
#[default]
|
||||
Fit,
|
||||
Fill,
|
||||
/// 100 % = one image pixel per physical screen pixel.
|
||||
Percent(u32),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum BeforeAfter {
|
||||
#[default]
|
||||
Off,
|
||||
/// Hold `\`: show the original.
|
||||
Original,
|
||||
SideBySide,
|
||||
Split,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum CropOverlay {
|
||||
#[default]
|
||||
Thirds,
|
||||
Grid,
|
||||
Golden,
|
||||
Diagonal,
|
||||
None,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct UiState {
|
||||
pub view: ViewMode,
|
||||
pub left_panel: bool,
|
||||
pub right: RightPanel,
|
||||
/// Presets column open (opens to the left of the Edit panel).
|
||||
pub presets: bool,
|
||||
pub filmstrip: bool,
|
||||
pub zoom: Zoom,
|
||||
/// Pan offset of the loupe when zoomed (image-normalized centre).
|
||||
pub pan: (f32, f32),
|
||||
pub before_after: BeforeAfter,
|
||||
pub thumb_size: f32,
|
||||
/// Open Edit sections by id.
|
||||
pub open_sections: Vec<String>,
|
||||
/// Open flyouts (curve, mixer, grading…).
|
||||
pub open_flyouts: Vec<String>,
|
||||
/// Single-panel mode: opening one section closes the others.
|
||||
pub single_panel: bool,
|
||||
pub show_clipping: bool,
|
||||
pub histogram: bool,
|
||||
pub mask_overlay: bool,
|
||||
pub crop_overlay: CropOverlay,
|
||||
pub show_filenames: bool,
|
||||
pub search: String,
|
||||
/// Selected curve channel in the Curve flyout.
|
||||
pub curve_channel: String,
|
||||
/// Selected mixer mode: "hue" | "saturation" | "luminance" | "all".
|
||||
pub mixer_mode: String,
|
||||
/// Selected colour grading wheel: "3way" | "shadows" | "midtones" | "highlights" | "global".
|
||||
pub grading_mode: String,
|
||||
/// Active on-canvas tool: "", "brush", "linear", "radial", "wbPicker", "straighten", "remove".
|
||||
pub tool: String,
|
||||
pub brush_size: f32,
|
||||
pub brush_feather: f32,
|
||||
pub brush_flow: f32,
|
||||
pub brush_erase: bool,
|
||||
pub remove_size: f32,
|
||||
/// Transient toast text and its expiry (seconds of app time).
|
||||
#[serde(skip)]
|
||||
pub toast: Option<(String, f64)>,
|
||||
#[serde(skip)]
|
||||
pub status: String,
|
||||
#[serde(skip)]
|
||||
pub dialog: Option<Dialog>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum Dialog {
|
||||
NewAlbum { name: String, folder: bool },
|
||||
RenameAlbum { id: u64, name: String },
|
||||
CreatePreset { name: String, group: String },
|
||||
CopySettings { groups: Vec<String> },
|
||||
Export { format: String, quality: u32, long_edge: u32 },
|
||||
About,
|
||||
Shortcuts,
|
||||
}
|
||||
|
||||
impl Default for UiState {
|
||||
fn default() -> Self {
|
||||
UiState {
|
||||
view: ViewMode::Detail,
|
||||
left_panel: false,
|
||||
right: RightPanel::Edit,
|
||||
presets: false,
|
||||
filmstrip: true,
|
||||
zoom: Zoom::Fit,
|
||||
pan: (0.5, 0.5),
|
||||
before_after: BeforeAfter::Off,
|
||||
thumb_size: 220.0,
|
||||
open_sections: vec!["light".into()],
|
||||
open_flyouts: vec![],
|
||||
single_panel: false,
|
||||
show_clipping: false,
|
||||
histogram: true,
|
||||
mask_overlay: true,
|
||||
crop_overlay: CropOverlay::Thirds,
|
||||
show_filenames: true,
|
||||
search: String::new(),
|
||||
curve_channel: "parametric".into(),
|
||||
mixer_mode: "hue".into(),
|
||||
grading_mode: "3way".into(),
|
||||
tool: String::new(),
|
||||
brush_size: 0.04,
|
||||
brush_feather: 50.0,
|
||||
brush_flow: 60.0,
|
||||
brush_erase: false,
|
||||
remove_size: 0.02,
|
||||
toast: None,
|
||||
status: String::new(),
|
||||
dialog: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UiState {
|
||||
pub fn section_open(&self, id: &str) -> bool {
|
||||
self.open_sections.iter().any(|s| s == id)
|
||||
}
|
||||
pub fn toggle_section(&mut self, id: &str) {
|
||||
if self.section_open(id) {
|
||||
self.open_sections.retain(|s| s != id);
|
||||
} else {
|
||||
if self.single_panel {
|
||||
self.open_sections.clear();
|
||||
}
|
||||
self.open_sections.push(id.to_string());
|
||||
}
|
||||
}
|
||||
pub fn flyout_open(&self, id: &str) -> bool {
|
||||
self.open_flyouts.iter().any(|s| s == id)
|
||||
}
|
||||
pub fn toggle_flyout(&mut self, id: &str) {
|
||||
if self.flyout_open(id) {
|
||||
self.open_flyouts.retain(|s| s != id);
|
||||
} else {
|
||||
self.open_flyouts.push(id.to_string());
|
||||
}
|
||||
}
|
||||
/// Clamp values restored from disk.
|
||||
pub fn sanitized(mut self) -> Self {
|
||||
self.thumb_size = self.thumb_size.clamp(90.0, 480.0);
|
||||
self.brush_size = self.brush_size.clamp(0.002, 0.5);
|
||||
self.dialog = None;
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Design tokens (colours, sizes, fonts). Values measured from black-box observation of the
|
||||
//! reference app's dark theme (see plan/lightroom/10-observed-ui.md); everything is our own code.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::{Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, Stroke, Visuals};
|
||||
|
||||
pub const FONT_SEMIBOLD: &str = "semibold";
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Tokens {
|
||||
/// Top bar, side panels, bottom bar, tool strip.
|
||||
pub chrome: Color32,
|
||||
/// Photo canvas (detail view) and filmstrip.
|
||||
pub canvas: Color32,
|
||||
/// Grid background behind the cells.
|
||||
pub grid_bg: Color32,
|
||||
pub cell: Color32,
|
||||
pub cell_selected: Color32,
|
||||
pub divider: Color32,
|
||||
pub inset: Color32,
|
||||
pub field: Color32,
|
||||
pub field_border: Color32,
|
||||
pub button: Color32,
|
||||
pub button_border: Color32,
|
||||
pub hover: Color32,
|
||||
pub pressed: Color32,
|
||||
pub tool_active: Color32,
|
||||
pub text: Color32,
|
||||
pub text_label: Color32,
|
||||
pub text_dim: Color32,
|
||||
pub text_disabled: Color32,
|
||||
/// Icon glyphs in the chrome.
|
||||
pub icon: Color32,
|
||||
pub track: Color32,
|
||||
pub thumb: Color32,
|
||||
pub thumb_hover: Color32,
|
||||
pub accent: Color32,
|
||||
pub star: Color32,
|
||||
pub pick: Color32,
|
||||
pub reject: Color32,
|
||||
pub mask_overlay: Color32,
|
||||
// metrics (points)
|
||||
pub top_bar_h: f32,
|
||||
pub bottom_bar_h: f32,
|
||||
pub panel_w: f32,
|
||||
pub strip_w: f32,
|
||||
pub left_w: f32,
|
||||
pub slider_row_h: f32,
|
||||
pub section_h: f32,
|
||||
pub film_h: f32,
|
||||
}
|
||||
|
||||
impl Default for Tokens {
|
||||
fn default() -> Self {
|
||||
Tokens {
|
||||
chrome: Color32::from_rgb(0x2d, 0x2d, 0x2d),
|
||||
canvas: Color32::from_rgb(0x1c, 0x1c, 0x1c),
|
||||
grid_bg: Color32::from_rgb(0x0f, 0x0f, 0x0f),
|
||||
cell: Color32::from_rgb(0x1c, 0x1c, 0x1c),
|
||||
cell_selected: Color32::from_rgb(0x2d, 0x2d, 0x2d),
|
||||
divider: Color32::from_rgb(0x1c, 0x1c, 0x1c),
|
||||
inset: Color32::from_rgb(0x23, 0x23, 0x23),
|
||||
field: Color32::from_rgb(0x22, 0x22, 0x22),
|
||||
field_border: Color32::from_rgb(0x3c, 0x3c, 0x3c),
|
||||
button: Color32::from_rgb(0x24, 0x24, 0x24),
|
||||
button_border: Color32::from_rgb(0x4c, 0x4c, 0x4c),
|
||||
hover: Color32::from_rgb(0x3a, 0x3a, 0x3a),
|
||||
pressed: Color32::from_rgb(0x3f, 0x3f, 0x3f),
|
||||
tool_active: Color32::from_rgb(0x3f, 0x3f, 0x3f),
|
||||
text: Color32::from_rgb(0xe2, 0xe2, 0xe2),
|
||||
text_label: Color32::from_rgb(0xbc, 0xbc, 0xbc),
|
||||
text_dim: Color32::from_rgb(0x8e, 0x8e, 0x8e),
|
||||
text_disabled: Color32::from_rgb(0x5c, 0x5c, 0x5c),
|
||||
icon: Color32::from_rgb(0x9a, 0x9a, 0x9a),
|
||||
track: Color32::from_rgb(0x5a, 0x5a, 0x5a),
|
||||
thumb: Color32::from_rgb(0xa0, 0xa0, 0xa0),
|
||||
thumb_hover: Color32::from_rgb(0xe0, 0xe0, 0xe0),
|
||||
accent: Color32::from_rgb(0x01, 0x65, 0xdd),
|
||||
star: Color32::from_rgb(0xd8, 0xd8, 0xd8),
|
||||
pick: Color32::from_rgb(0xf0, 0xf0, 0xf0),
|
||||
reject: Color32::from_rgb(0xe0, 0x4a, 0x4a),
|
||||
mask_overlay: Color32::from_rgba_unmultiplied(0xe0, 0x20, 0x30, 110),
|
||||
top_bar_h: 42.0,
|
||||
bottom_bar_h: 48.0,
|
||||
panel_w: 270.0,
|
||||
strip_w: 48.0,
|
||||
left_w: 268.0,
|
||||
slider_row_h: 45.0,
|
||||
section_h: 57.0,
|
||||
film_h: 142.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Tokens {
|
||||
pub fn get(ctx: &egui::Context) -> Tokens {
|
||||
ctx.data(|d| d.get_temp::<Tokens>(egui::Id::NULL)).unwrap_or_default()
|
||||
}
|
||||
pub fn font(&self, size: f32) -> FontId {
|
||||
FontId::proportional(size)
|
||||
}
|
||||
pub fn semibold(&self, size: f32) -> FontId {
|
||||
FontId::new(size, FontFamily::Name(FONT_SEMIBOLD.into()))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn install_fonts(ctx: &egui::Context) {
|
||||
let mut fonts = FontDefinitions::default();
|
||||
fonts.font_data.insert("SourceSans3".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"))));
|
||||
fonts.font_data.insert("SourceSans3-Semibold".into(), Arc::new(FontData::from_static(include_bytes!("../../../assets/fonts/SourceSans3-Semibold.ttf"))));
|
||||
let fallback: Vec<String> = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default();
|
||||
let mut prop = vec!["SourceSans3".to_string()];
|
||||
prop.extend(fallback.clone());
|
||||
fonts.families.insert(FontFamily::Proportional, prop);
|
||||
let mut semi = vec!["SourceSans3-Semibold".to_string()];
|
||||
semi.extend(fallback);
|
||||
fonts.families.insert(FontFamily::Name(FONT_SEMIBOLD.into()), semi);
|
||||
ctx.set_fonts(fonts);
|
||||
}
|
||||
|
||||
pub fn apply(ctx: &egui::Context) {
|
||||
let t = Tokens::default();
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::NULL, t));
|
||||
let mut v = Visuals::dark();
|
||||
v.panel_fill = t.chrome;
|
||||
v.window_fill = t.chrome;
|
||||
v.extreme_bg_color = t.field;
|
||||
v.faint_bg_color = t.inset;
|
||||
v.window_stroke = Stroke::new(1.0, t.button_border);
|
||||
v.window_corner_radius = CornerRadius::same(6);
|
||||
v.menu_corner_radius = CornerRadius::same(6);
|
||||
v.selection.bg_fill = t.accent.gamma_multiply(0.6);
|
||||
v.selection.stroke = Stroke::new(1.0, t.accent);
|
||||
v.override_text_color = Some(t.text_label);
|
||||
v.popup_shadow = egui::epaint::Shadow { offset: [0, 4], blur: 16, spread: 0, color: Color32::from_black_alpha(140) };
|
||||
v.window_shadow = egui::epaint::Shadow { offset: [0, 8], blur: 30, spread: 0, color: Color32::from_black_alpha(160) };
|
||||
let w = &mut v.widgets;
|
||||
for (wv, fill) in [(&mut w.noninteractive, t.chrome), (&mut w.inactive, t.button), (&mut w.hovered, t.hover), (&mut w.active, t.pressed), (&mut w.open, t.hover)] {
|
||||
wv.bg_fill = fill;
|
||||
wv.weak_bg_fill = fill;
|
||||
wv.corner_radius = CornerRadius::same(4);
|
||||
wv.fg_stroke = Stroke::new(1.0, t.text_label);
|
||||
}
|
||||
w.noninteractive.bg_stroke = Stroke::new(1.0, t.divider);
|
||||
w.inactive.bg_stroke = Stroke::new(1.0, t.button_border);
|
||||
ctx.set_visuals(v);
|
||||
ctx.global_style_mut(|s| {
|
||||
s.spacing.item_spacing = egui::vec2(6.0, 4.0);
|
||||
s.spacing.button_padding = egui::vec2(8.0, 3.0);
|
||||
s.spacing.interact_size.y = 22.0;
|
||||
s.text_styles.insert(egui::TextStyle::Body, FontId::proportional(13.0));
|
||||
s.text_styles.insert(egui::TextStyle::Button, FontId::proportional(13.0));
|
||||
s.text_styles.insert(egui::TextStyle::Small, FontId::proportional(11.0));
|
||||
s.text_styles.insert(egui::TextStyle::Heading, FontId::new(16.0, FontFamily::Name(FONT_SEMIBOLD.into())));
|
||||
s.animation_time = 0.08;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
//! Lightroom-style widgets: sliders with gradient tracks and hollow thumbs, section headers,
|
||||
//! flyout rows, bordered buttons. Every interactive widget registers an automation id + rect
|
||||
//! ([`register`]) so the control channel can find and click it by name.
|
||||
|
||||
use egui::{Align2, Color32, CornerRadius, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use lightcraft_develop::{ControlSpec, Track};
|
||||
|
||||
use crate::icons::{Icon, paint};
|
||||
use crate::theme::Tokens;
|
||||
|
||||
// ------------------------------------------------------------------ automation registry
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
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)));
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ colour helpers
|
||||
|
||||
pub fn hex(s: &str) -> Color32 {
|
||||
let v = u32::from_str_radix(s.trim_start_matches('#'), 16).unwrap_or(0x808080);
|
||||
Color32::from_rgb((v >> 16) as u8, (v >> 8) as u8, v as u8)
|
||||
}
|
||||
|
||||
pub fn lerp(a: Color32, b: Color32, t: f32) -> Color32 {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
let f = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * t).round() as u8;
|
||||
Color32::from_rgb(f(a.r(), b.r()), f(a.g(), b.g()), f(a.b(), b.b()))
|
||||
}
|
||||
|
||||
/// Band colours for the 8 colour-mixer bands (swatches and tracks).
|
||||
pub const BAND_COLORS: [&str; 8] = ["#aa0000", "#bb6600", "#aaaa00", "#009900", "#00aaaa", "#0044cc", "#7722aa", "#aa0077"];
|
||||
|
||||
fn track_stops(track: &Track) -> Option<Vec<Color32>> {
|
||||
Some(match track {
|
||||
Track::Plain | Track::Centered => return None,
|
||||
Track::Temp => ["#193961", "#47566a", "#60666d", "#70705b", "#737339", "#75742a"].iter().map(|h| hex(h)).collect(),
|
||||
Track::Tint => ["#246123", "#2c552c", "#394739", "#4a3d4c", "#6b336b", "#872c87"].iter().map(|h| hex(h)).collect(),
|
||||
Track::Gradient { from, to } => vec![hex(from), hex(to)],
|
||||
Track::Hue { band } => {
|
||||
let i = *band as usize;
|
||||
let prev = hex(BAND_COLORS[(i + 7) % 8]);
|
||||
let next = hex(BAND_COLORS[(i + 1) % 8]);
|
||||
let me = hex(BAND_COLORS[i]);
|
||||
vec![prev.gamma_multiply(0.7), lerp(prev, me, 0.5).gamma_multiply(0.7), me.gamma_multiply(0.7), lerp(me, next, 0.5).gamma_multiply(0.7), next.gamma_multiply(0.7)]
|
||||
}
|
||||
Track::Sat { band } => vec![hex("#454343"), lerp(hex("#454343"), hex(BAND_COLORS[*band as usize]), 0.6), hex(BAND_COLORS[*band as usize])],
|
||||
Track::Lum { band } => {
|
||||
let c = hex(BAND_COLORS[*band as usize]);
|
||||
vec![c.gamma_multiply(0.2), c.gamma_multiply(0.55), lerp(c, Color32::WHITE, 0.35), lerp(c, Color32::WHITE, 0.6)]
|
||||
}
|
||||
Track::Rainbow => ["#aa0000", "#aaaa00", "#009900", "#00aaaa", "#0044cc", "#aa0077", "#aa0000"].iter().map(|h| hex(h)).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
fn paint_track(ui: &Ui, rect: Rect, track: &Track, t: &Tokens, thumb_x: f32, ring: f32) {
|
||||
let p = ui.painter();
|
||||
let y = rect.center().y;
|
||||
let h = 1.0;
|
||||
if let Some(stops) = track_stops(track) {
|
||||
// gradient: segments between stops
|
||||
let n = stops.len().max(2) - 1;
|
||||
let steps = 48;
|
||||
for i in 0..steps {
|
||||
let (a, b) = (i as f32 / steps as f32, (i + 1) as f32 / steps as f32);
|
||||
let seg = (a * n as f32).floor() as usize;
|
||||
let local = a * n as f32 - seg as f32;
|
||||
let c = lerp(stops[seg.min(n)], stops[(seg + 1).min(n)], local);
|
||||
let x0 = rect.left() + rect.width() * a;
|
||||
let x1 = rect.left() + rect.width() * b;
|
||||
p.rect_filled(Rect::from_min_max(pos2(x0, y - h - 0.5), pos2(x1 + 0.5, y + h + 0.5)), 0.0, c);
|
||||
}
|
||||
} else {
|
||||
p.rect_filled(Rect::from_min_max(pos2(rect.left(), y - h), pos2(rect.right(), y + h)), 1.0, t.track);
|
||||
}
|
||||
// gap around the hollow thumb
|
||||
p.rect_filled(Rect::from_center_size(pos2(thumb_x, y), vec2(ring * 2.0 + 4.0, 6.0)), 0.0, t.chrome);
|
||||
}
|
||||
|
||||
/// Result of a slider interaction this frame.
|
||||
#[derive(Default, Debug, Clone, Copy)]
|
||||
pub struct SliderOut {
|
||||
pub value: Option<f64>,
|
||||
pub drag_started: bool,
|
||||
pub drag_stopped: bool,
|
||||
pub reset: bool,
|
||||
}
|
||||
|
||||
/// A Lightroom slider row (label + value above a track with a hollow ring thumb).
|
||||
/// Double-click the label or thumb to reset. Shift-drag = fine adjustment.
|
||||
pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_override: Option<&str>) -> SliderOut {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let w = ui.available_width();
|
||||
let (row, _) = ui.allocate_exact_size(vec2(w, t.slider_row_h), Sense::hover());
|
||||
let pad_l = 24.0;
|
||||
let pad_r = 22.0;
|
||||
let label_rect = Rect::from_min_size(pos2(row.left() + pad_l, row.top() + 4.0), vec2(w - pad_l - pad_r, 18.0));
|
||||
let track_rect = Rect::from_min_max(pos2(row.left() + pad_l, row.top() + 22.0), pos2(row.right() - pad_r, row.top() + 40.0));
|
||||
let id = ui.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() });
|
||||
let label_resp = ui.interact(label_rect, id.with("label"), Sense::click());
|
||||
register(ui.ctx(), format!("slider:{}", spec.id), track_rect);
|
||||
let mut out = SliderOut::default();
|
||||
let span = (spec.max - spec.min).max(1e-9);
|
||||
let to_x = |v: f64| track_rect.left() + ((v - spec.min) / span).clamp(0.0, 1.0) as f32 * track_rect.width();
|
||||
let from_x = |x: f32| spec.min + ((x - track_rect.left()) / track_rect.width()).clamp(0.0, 1.0) as f64 * span;
|
||||
let mut v = value;
|
||||
if resp.double_clicked() || label_resp.double_clicked() {
|
||||
out.reset = true;
|
||||
out.value = Some(spec.default);
|
||||
v = spec.default;
|
||||
} else {
|
||||
if resp.drag_started() {
|
||||
out.drag_started = true;
|
||||
}
|
||||
if (resp.dragged() || resp.drag_started())
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
{
|
||||
let fine = ui.input(|i| i.modifiers.shift);
|
||||
let nv = if fine {
|
||||
let dx = ui.input(|i| i.pointer.delta().x);
|
||||
value + dx as f64 * span / track_rect.width() as f64 * 0.1
|
||||
} else {
|
||||
from_x(p.x)
|
||||
};
|
||||
let step = spec.step.max(1e-9);
|
||||
let nv = ((nv / step).round() * step).clamp(spec.min, spec.max);
|
||||
if (nv - value).abs() > 1e-12 {
|
||||
out.value = Some(nv);
|
||||
v = nv;
|
||||
}
|
||||
} else if resp.clicked()
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
{
|
||||
let step = spec.step.max(1e-9);
|
||||
let nv = ((from_x(p.x) / step).round() * step).clamp(spec.min, spec.max);
|
||||
out.value = Some(nv);
|
||||
v = nv;
|
||||
out.drag_started = true;
|
||||
out.drag_stopped = true;
|
||||
}
|
||||
if resp.drag_stopped() {
|
||||
out.drag_stopped = true;
|
||||
}
|
||||
}
|
||||
// paint
|
||||
let hovered = resp.hovered() || resp.dragged();
|
||||
let text_c = if enabled { t.text_label } else { t.text_disabled };
|
||||
let p = ui.painter();
|
||||
p.text(label_rect.left_center(), Align2::LEFT_CENTER, label_override.unwrap_or(spec.label), t.font(12.5), text_c);
|
||||
let shown = if spec.id == "wb.temp" { format!("{v:.0}") } else { spec.format(v).replace("+0.00", "0").replace("-0.00", "0") };
|
||||
let shown = if shown == "+0" || shown == "-0" { "0".to_string() } else { shown };
|
||||
p.text(label_rect.right_center(), Align2::RIGHT_CENTER, shown, t.font(12.5), text_c);
|
||||
let ring = 7.0;
|
||||
let tx = to_x(v);
|
||||
paint_track(ui, track_rect, &spec.track, &t, tx, ring);
|
||||
let p = ui.painter();
|
||||
let ring_c = if !enabled {
|
||||
t.text_disabled
|
||||
} else if hovered {
|
||||
t.thumb_hover
|
||||
} else {
|
||||
t.thumb
|
||||
};
|
||||
p.circle_filled(pos2(tx, track_rect.center().y), ring, t.chrome);
|
||||
p.circle_stroke(pos2(tx, track_rect.center().y), ring, Stroke::new(2.0, ring_c));
|
||||
if hovered {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 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 t = Tokens::get(ui.ctx());
|
||||
let w = ui.available_width();
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, 52.0), Sense::click());
|
||||
register(ui.ctx(), format!("section:{id}"), r);
|
||||
let p = ui.painter();
|
||||
if resp.hovered() {
|
||||
p.rect_filled(r, 0.0, t.chrome.gamma_multiply(1.06));
|
||||
}
|
||||
let chev = Rect::from_center_size(pos2(r.left() + 30.0, r.center().y), vec2(14.0, 14.0));
|
||||
paint(p, chev, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_label);
|
||||
p.text(pos2(r.left() + 44.0, r.center().y), Align2::LEFT_CENTER, title, t.semibold(14.0), t.text);
|
||||
let mut toggled = None;
|
||||
if let Some(on) = enabled
|
||||
&& (resp.hovered() || !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());
|
||||
register(ui.ctx(), format!("sectionEye:{id}"), eye);
|
||||
paint(ui.painter(), eye, Icon::Eye, if on { t.icon } else { t.text_disabled });
|
||||
if !on {
|
||||
ui.painter().line_segment([eye.left_bottom(), eye.right_top()], Stroke::new(1.5, t.text_disabled));
|
||||
}
|
||||
if er.clicked() {
|
||||
toggled = Some(!on);
|
||||
}
|
||||
}
|
||||
(resp, toggled)
|
||||
}
|
||||
|
||||
/// Thin divider between groups.
|
||||
pub fn divider(ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let w = ui.available_width();
|
||||
let (r, _) = ui.allocate_exact_size(vec2(w, 1.0), Sense::hover());
|
||||
ui.painter().rect_filled(r, 0.0, t.divider);
|
||||
}
|
||||
|
||||
/// A flyout row inside a section (Curve, Color Mixer, Color Grading…): inset dark box with icon tile.
|
||||
pub fn flyout_row(ui: &mut Ui, id: &str, title: &str, icon: Icon, open: bool) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
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());
|
||||
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 });
|
||||
let tile = Rect::from_min_size(pos2(r.left() + 4.0, r.top() + 4.0), vec2(r.height() - 8.0, r.height() - 8.0));
|
||||
p.rect_filled(tile, 3.0, t.chrome.gamma_multiply(1.15));
|
||||
paint(p, tile.shrink(3.0), icon, t.text_label);
|
||||
p.text(pos2(tile.right() + 10.0, r.center().y), Align2::LEFT_CENTER, title, t.semibold(13.0), t.text_label);
|
||||
paint(p, Rect::from_center_size(pos2(r.right() - 14.0, r.center().y), vec2(12.0, 12.0)), if open { Icon::ChevronDown } else { Icon::TriangleLeft }, t.text_label);
|
||||
resp
|
||||
}
|
||||
|
||||
/// A small bordered text button (Auto, B&W, HDR…).
|
||||
pub fn text_button(ui: &mut Ui, id: &str, label: &str, active: bool) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let font = t.semibold(11.5);
|
||||
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());
|
||||
register(ui.ctx(), format!("button:{id}"), r);
|
||||
let fill = if active {
|
||||
t.pressed
|
||||
} else if resp.hovered() {
|
||||
t.hover
|
||||
} else {
|
||||
t.button
|
||||
};
|
||||
let p = ui.painter();
|
||||
p.rect(r, CornerRadius::same(4), fill, Stroke::new(1.0, t.button_border), StrokeKind::Inside);
|
||||
p.galley(r.center() - galley.size() / 2.0, galley, t.text);
|
||||
resp
|
||||
}
|
||||
|
||||
/// An icon-only button. `active` draws the selected background (tool strip).
|
||||
pub fn icon_button(ui: &mut Ui, id: &str, icon: Icon, size: egui::Vec2, active: bool, enabled: bool, tooltip: &str) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, resp) = ui.allocate_exact_size(size, if enabled { Sense::click() } else { Sense::hover() });
|
||||
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)));
|
||||
if active {
|
||||
p.rect_filled(sq, 4.0, t.tool_active);
|
||||
} else if resp.hovered() && enabled {
|
||||
p.rect_filled(sq, 4.0, t.hover.gamma_multiply(0.7));
|
||||
}
|
||||
let c = if !enabled {
|
||||
t.text_disabled
|
||||
} else if active {
|
||||
t.pick
|
||||
} else if resp.hovered() {
|
||||
t.text
|
||||
} else {
|
||||
t.icon
|
||||
};
|
||||
let glyph = (size.x.min(size.y) * 0.62).min(22.0);
|
||||
paint(p, Rect::from_center_size(r.center(), vec2(glyph, glyph)), icon, c);
|
||||
if !tooltip.is_empty() { resp.on_hover_text(tooltip) } else { resp }
|
||||
}
|
||||
|
||||
/// Star rating row (5 stars); returns a new rating when clicked (clicking the current rating clears).
|
||||
pub fn stars(ui: &mut Ui, id: &str, rating: u8, size: f32) -> Option<u8> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mut out = None;
|
||||
let hover_n = {
|
||||
let mut h = None;
|
||||
for i in 0..5u8 {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(size, size), Sense::click());
|
||||
register(ui.ctx(), format!("star:{id}:{}", i + 1), r);
|
||||
if resp.hovered() {
|
||||
h = Some(i + 1);
|
||||
}
|
||||
if resp.clicked() {
|
||||
out = Some(if rating == i + 1 { 0 } else { i + 1 });
|
||||
}
|
||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, "", t.font(1.0), t.star);
|
||||
let filled = i < rating;
|
||||
paint(ui.painter(), r.shrink(size * 0.12), if filled { Icon::StarFilled } else { Icon::Star }, if filled { t.star } else { t.icon });
|
||||
}
|
||||
h
|
||||
};
|
||||
let _ = hover_n;
|
||||
out
|
||||
}
|
||||
Reference in New Issue
Block a user