mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-13 23:08:09 +00:00
Type Color Picker opens the full Color Picker; screen eyedropper with a pixel loupe (#1232)
The Type options bar and Character panel swatches open the full Color Picker (Text Color), applying to the selected characters or the whole layer via type.setStyle; Cancel is inert. A shared "Pick screen color" action in the full dialog and compact popups samples the desktop: X11/Windows/macOS snapshots with an 11x11 magnifying loupe (4x-24x), the XDG PickColor portal on Wayland, the EyeDropper API on the web. Captures stay in memory, are bounded at 64 MP, and are ICC-converted to sRGB where the display publishes a profile. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8d95a564e3
commit
0256eaa4e0
37 files changed
+2164
-44
No files matched your search
Generated
+640
-16
@@ -216,6 +216,16 @@ version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
|
||||
|
||||
[[package]]
|
||||
name = "annotate-snippets"
|
||||
version = "0.11.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "710e8eae58854cdc1790fcb56cca04d712a17be849eeb81da2a724bf4bae2bc4"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.14"
|
||||
@@ -511,7 +521,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"arrayvec",
|
||||
"log",
|
||||
"nom",
|
||||
"nom 8.0.0",
|
||||
"num-rational",
|
||||
"v_frame",
|
||||
]
|
||||
@@ -531,6 +541,25 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bindgen"
|
||||
version = "0.72.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895"
|
||||
dependencies = [
|
||||
"annotate-snippets",
|
||||
"bitflags 2.13.2",
|
||||
"cexpr",
|
||||
"clang-sys",
|
||||
"itertools 0.10.5",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"regex",
|
||||
"rustc-hash 2.1.3",
|
||||
"shlex 1.3.0",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
@@ -604,6 +633,12 @@ dependencies = [
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.5.1"
|
||||
@@ -775,7 +810,26 @@ dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
"shlex 2.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cexpr"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
|
||||
dependencies = [
|
||||
"nom 7.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-expr"
|
||||
version = "0.20.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ba9e9ec16c447027685b1f897b720e18e9a8afd00bd7332c483537e38086c9f"
|
||||
dependencies = [
|
||||
"smallvec",
|
||||
"target-lexicon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -799,6 +853,17 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
@@ -838,6 +903,17 @@ dependencies = [
|
||||
"half",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clang-sys"
|
||||
version = "1.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a"
|
||||
dependencies = [
|
||||
"glob",
|
||||
"libc",
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.7"
|
||||
@@ -933,6 +1009,12 @@ version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
||||
|
||||
[[package]]
|
||||
name = "cookie-factory"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
@@ -943,6 +1025,16 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
@@ -956,7 +1048,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"core-foundation",
|
||||
"core-foundation 0.9.4",
|
||||
"core-graphics-types",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
@@ -969,7 +1061,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"core-foundation",
|
||||
"core-foundation 0.9.4",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-graphics2"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b65858d95344d2711161cd92196732f67d6c5fea238cf12f994584828731a5c"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"block",
|
||||
"cfg-if",
|
||||
"core-foundation 0.10.1",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -1192,6 +1297,61 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
||||
|
||||
[[package]]
|
||||
name = "drm"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "80bc8c5c6c2941f70a55c15f8d9f00f9710ebda3ffda98075f996a0e6c92756f"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"bytemuck",
|
||||
"drm-ffi",
|
||||
"drm-fourcc",
|
||||
"libc",
|
||||
"rustix 0.38.44",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "drm"
|
||||
version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a41816e58f47f49acfd956651055ddcf137c4882c2098c30c448817af21183a"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"bytemuck",
|
||||
"bytemuck_derive",
|
||||
"drm-ffi",
|
||||
"drm-fourcc",
|
||||
"libc",
|
||||
"rustix 1.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "drm-ffi"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51a91c9b32ac4e8105dec255e849e0d66e27d7c34d184364fb93e469db08f690"
|
||||
dependencies = [
|
||||
"drm-sys",
|
||||
"rustix 1.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "drm-fourcc"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0aafbcdb8afc29c1a7ee5fbe53b5d62f4565b35a042a662ca9fecd0b54dae6f4"
|
||||
|
||||
[[package]]
|
||||
name = "drm-sys"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ecc8e1361066d91f5ffccff060a3c3be9c3ecde15be2959c1937595f7a82a9f8"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"linux-raw-sys 0.9.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dyn-clone"
|
||||
version = "1.0.20"
|
||||
@@ -1725,6 +1885,15 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs-set-times"
|
||||
version = "0.20.3"
|
||||
@@ -1837,6 +2006,30 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gbm"
|
||||
version = "0.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce852e998d3ca5e4a97014fb31c940dc5ef344ec7d364984525fd11e8a547e6a"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"drm 0.14.1",
|
||||
"drm-fourcc",
|
||||
"gbm-sys",
|
||||
"libc",
|
||||
"wayland-backend",
|
||||
"wayland-server",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gbm-sys"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c13a5f2acc785d8fb6bf6b7ab6bfb0ef5dad4f4d97e8e70bb8e470722312f76f"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gethostname"
|
||||
version = "1.1.0"
|
||||
@@ -1877,6 +2070,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1899,6 +2093,15 @@ dependencies = [
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gl"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a94edab108827d67608095e269cf862e60d920f144a5026d3dbcfd8b877fb404"
|
||||
dependencies = [
|
||||
"gl_generator",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gl_generator"
|
||||
version = "0.14.0"
|
||||
@@ -1926,6 +2129,12 @@ dependencies = [
|
||||
"vello_common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glob"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
|
||||
|
||||
[[package]]
|
||||
name = "glow"
|
||||
version = "0.17.0"
|
||||
@@ -2080,6 +2289,12 @@ dependencies = [
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "heic-rs"
|
||||
version = "0.1.1"
|
||||
@@ -2259,6 +2474,27 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
|
||||
dependencies = [
|
||||
"idna_adapter",
|
||||
"smallvec",
|
||||
"utf8_iter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna_adapter"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
|
||||
dependencies = [
|
||||
"icu_normalizer",
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.10"
|
||||
@@ -2623,6 +2859,54 @@ dependencies = [
|
||||
"redox_syscall 0.9.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libspa"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "882f7427e7989dcc9d388b7f05c4630390a1d7696f9ffa469cd4a7a48f0b4c40"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"cc",
|
||||
"cookie-factory",
|
||||
"libc",
|
||||
"libspa-sys",
|
||||
"nom 8.0.0",
|
||||
"rustix 1.1.5",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libspa-sys"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b6e17bdaf63ed0d5e4144022624032b41fd9733112e8c74ac26fc9bf1291924"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"cc",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libwayshot-xcap"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea8a46e4d016ef464e386e4970f5415fe0939c2d63abe0c2f539c05ef88c5fe5"
|
||||
dependencies = [
|
||||
"drm 0.15.0",
|
||||
"gbm",
|
||||
"gl",
|
||||
"image",
|
||||
"khronos-egl",
|
||||
"memmap2",
|
||||
"rustix 1.1.5",
|
||||
"thiserror 2.0.21",
|
||||
"tracing",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-protocols-wlr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linebender_resource_handle"
|
||||
version = "0.1.1"
|
||||
@@ -2635,6 +2919,12 @@ version = "0.4.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
@@ -2726,6 +3016,12 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -2874,6 +3170,16 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "8.0.0"
|
||||
@@ -3007,8 +3313,8 @@ dependencies = [
|
||||
"block2 0.5.1",
|
||||
"libc",
|
||||
"objc2 0.5.2",
|
||||
"objc2-core-data",
|
||||
"objc2-core-image",
|
||||
"objc2-core-data 0.2.2",
|
||||
"objc2-core-image 0.2.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
"objc2-quartz-core 0.2.2",
|
||||
]
|
||||
@@ -3021,9 +3327,48 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"block2 0.6.2",
|
||||
"libc",
|
||||
"objc2 0.6.4",
|
||||
"objc2-cloud-kit 0.3.2",
|
||||
"objc2-core-data 0.3.2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-core-image 0.3.2",
|
||||
"objc2-core-text",
|
||||
"objc2-core-video",
|
||||
"objc2-foundation 0.3.2",
|
||||
"objc2-quartz-core 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-av-foundation"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "478ae33fcac9df0a18db8302387c666b8ef08a3e2d62b510ca4fc278a384b6c0"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"block2 0.6.2",
|
||||
"dispatch2",
|
||||
"objc2 0.6.4",
|
||||
"objc2-avf-audio",
|
||||
"objc2-core-audio-types",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-core-image 0.3.2",
|
||||
"objc2-core-video",
|
||||
"objc2-foundation 0.3.2",
|
||||
"objc2-image-io",
|
||||
"objc2-media-toolbox",
|
||||
"objc2-quartz-core 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-avf-audio"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be"
|
||||
dependencies = [
|
||||
"objc2 0.6.4",
|
||||
"objc2-foundation 0.3.2",
|
||||
]
|
||||
|
||||
@@ -3040,6 +3385,17 @@ dependencies = [
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-cloud-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"objc2 0.6.4",
|
||||
"objc2-foundation 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-contacts"
|
||||
version = "0.2.2"
|
||||
@@ -3051,6 +3407,28 @@ dependencies = [
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-audio"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2"
|
||||
dependencies = [
|
||||
"dispatch2",
|
||||
"objc2 0.6.4",
|
||||
"objc2-core-audio-types",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-audio-types"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"objc2 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-data"
|
||||
version = "0.2.2"
|
||||
@@ -3063,6 +3441,17 @@ dependencies = [
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-data"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"objc2 0.6.4",
|
||||
"objc2-foundation 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-foundation"
|
||||
version = "0.3.2"
|
||||
@@ -3070,7 +3459,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"block2 0.6.2",
|
||||
"dispatch2",
|
||||
"libc",
|
||||
"objc2 0.6.4",
|
||||
]
|
||||
|
||||
@@ -3081,10 +3472,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"block2 0.6.2",
|
||||
"dispatch2",
|
||||
"libc",
|
||||
"objc2 0.6.4",
|
||||
"objc2-core-foundation",
|
||||
"objc2-io-surface",
|
||||
"objc2-metal 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3099,6 +3493,16 @@ dependencies = [
|
||||
"objc2-metal 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-image"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006"
|
||||
dependencies = [
|
||||
"objc2 0.6.4",
|
||||
"objc2-foundation 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-location"
|
||||
version = "0.2.2"
|
||||
@@ -3111,6 +3515,49 @@ dependencies = [
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-media"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05ec576860167a15dd9fce7fbee7512beb4e31f532159d3482d1f9c6caedf31d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"block2 0.6.2",
|
||||
"dispatch2",
|
||||
"objc2 0.6.4",
|
||||
"objc2-core-audio",
|
||||
"objc2-core-audio-types",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-video",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-text"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"objc2 0.6.4",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-video"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"block2 0.6.2",
|
||||
"objc2 0.6.4",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-io-surface",
|
||||
"objc2-metal 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-encode"
|
||||
version = "4.1.0"
|
||||
@@ -3138,10 +3585,22 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"block2 0.6.2",
|
||||
"libc",
|
||||
"objc2 0.6.4",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-image-io"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32b0446e98cf4a784cc7a0177715ff317eeaa8463841c616cfc78aa4f953c4ea"
|
||||
dependencies = [
|
||||
"objc2 0.6.4",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-io-surface"
|
||||
version = "0.3.2"
|
||||
@@ -3165,6 +3624,18 @@ dependencies = [
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-media-toolbox"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edd9fdde720df3da7046bb9097811000c1e7ab5cd579fa89d96b27d56781fb30"
|
||||
dependencies = [
|
||||
"objc2 0.6.4",
|
||||
"objc2-core-audio-types",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-media",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-metal"
|
||||
version = "0.2.2"
|
||||
@@ -3235,9 +3706,9 @@ dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"block2 0.5.1",
|
||||
"objc2 0.5.2",
|
||||
"objc2-cloud-kit",
|
||||
"objc2-core-data",
|
||||
"objc2-core-image",
|
||||
"objc2-cloud-kit 0.2.2",
|
||||
"objc2-core-data 0.2.2",
|
||||
"objc2-core-image 0.2.2",
|
||||
"objc2-core-location",
|
||||
"objc2-foundation 0.2.2",
|
||||
"objc2-link-presentation",
|
||||
@@ -3502,9 +3973,12 @@ name = "photocraft"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"async-io",
|
||||
"core-graphics2",
|
||||
"eframe",
|
||||
"egui",
|
||||
"fmv-macos-events",
|
||||
"futures-lite",
|
||||
"log",
|
||||
"muda",
|
||||
"objc2 0.6.4",
|
||||
@@ -3530,6 +4004,8 @@ dependencies = [
|
||||
"winresource",
|
||||
"winsafe",
|
||||
"x11rb",
|
||||
"xcap",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3956,6 +4432,31 @@ dependencies = [
|
||||
"futures-io",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pipewire"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bde71084c4e25959d68f1ea54daa75e5ecdb338e5caf0b5510143b79baa32d5c"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"libc",
|
||||
"libspa",
|
||||
"libspa-sys",
|
||||
"pipewire-sys",
|
||||
"rustix 1.1.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pipewire-sys"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ce653f53e63e5b93853218092ee9a8906a5d082c92f3f1db26316955dd63ce0"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"libspa-sys",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.34"
|
||||
@@ -4126,7 +4627,7 @@ dependencies = [
|
||||
"bit-vec 0.8.0",
|
||||
"bitflags 2.13.2",
|
||||
"num-traits",
|
||||
"rand",
|
||||
"rand 0.9.5",
|
||||
"rand_chacha",
|
||||
"rand_xorshift",
|
||||
"regex-syntax",
|
||||
@@ -4222,7 +4723,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4232,7 +4744,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4244,13 +4756,19 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_xorshift"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4286,7 +4804,7 @@ dependencies = [
|
||||
"num-traits",
|
||||
"paste",
|
||||
"profiling",
|
||||
"rand",
|
||||
"rand 0.9.5",
|
||||
"rand_chacha",
|
||||
"simd_helpers",
|
||||
"thiserror 2.0.21",
|
||||
@@ -4812,6 +5330,12 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
@@ -5100,6 +5624,25 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "7.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7"
|
||||
dependencies = [
|
||||
"cfg-expr",
|
||||
"heck",
|
||||
"pkg-config",
|
||||
"toml",
|
||||
"version-compare",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.13.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -5380,7 +5923,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"nom",
|
||||
"nom 8.0.0",
|
||||
"petgraph",
|
||||
]
|
||||
|
||||
@@ -5491,6 +6034,18 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"idna",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "usvg"
|
||||
version = "0.45.1"
|
||||
@@ -5574,6 +6129,12 @@ dependencies = [
|
||||
"vello_common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "version-compare"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
@@ -5886,6 +6447,19 @@ dependencies = [
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-server"
|
||||
version = "0.31.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0dde9c29be0f723a573977de51ee455bf3dfa03652730a74f9dd3b337e374d75"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"downcast-rs",
|
||||
"rustix 1.1.5",
|
||||
"wayland-backend",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-sys"
|
||||
version = "0.31.11"
|
||||
@@ -5893,7 +6467,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
|
||||
dependencies = [
|
||||
"dlib",
|
||||
"libc",
|
||||
"log",
|
||||
"memoffset",
|
||||
"once_cell",
|
||||
"pkg-config",
|
||||
]
|
||||
@@ -6107,6 +6683,12 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "widestring"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
@@ -6406,7 +6988,7 @@ dependencies = [
|
||||
"calloop 0.13.0",
|
||||
"cfg_aliases",
|
||||
"concurrent-queue",
|
||||
"core-foundation",
|
||||
"core-foundation 0.9.4",
|
||||
"core-graphics",
|
||||
"cursor-icon",
|
||||
"dpi",
|
||||
@@ -6539,6 +7121,48 @@ version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
||||
|
||||
[[package]]
|
||||
name = "xcap"
|
||||
version = "0.9.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da270fd7c8581d43d731cb690bcc791fc0a7b3bc96e131a4b6552d0379640a9e"
|
||||
dependencies = [
|
||||
"dispatch2",
|
||||
"image",
|
||||
"libwayshot-xcap",
|
||||
"log",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-av-foundation",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-core-media",
|
||||
"objc2-core-video",
|
||||
"objc2-foundation 0.3.2",
|
||||
"percent-encoding",
|
||||
"pipewire",
|
||||
"rand 0.10.3",
|
||||
"scopeguard",
|
||||
"serde",
|
||||
"thiserror 2.0.21",
|
||||
"url",
|
||||
"widestring",
|
||||
"windows",
|
||||
"xcb",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xcb"
|
||||
version = "1.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6c2ad15e0e922856ee89afe862b8992334bbe7953adad56cd1199358cb30566"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"libc",
|
||||
"quick-xml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xcursor"
|
||||
version = "0.3.11"
|
||||
|
||||
@@ -122,7 +122,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
|
||||
<br>
|
||||
<sub>A headline edited in place, with a byline and a paragraph of body text.<br><i>Among the Sierra Nevada, California</i>, Albert Bierstadt, 1868</sub>
|
||||
<h3>Type that sets beautifully</h3>
|
||||
Point and paragraph text, edited right on the canvas, with full Character and Paragraph controls: font, weight, size, leading, tracking, alignment and colour.
|
||||
Point and paragraph text, edited right on the canvas, with full Character and Paragraph controls: font, weight, size, leading, tracking, alignment and colour. The <a href="docs/color-picker.md">full Type Color Picker</a> also supports screen sampling with a pixel loupe.
|
||||
<br><br>
|
||||
Type layers stay editable, take layer styles, and round-trip through PSD.
|
||||
</td>
|
||||
|
||||
@@ -34,4 +34,4 @@ rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Storage", "Event", "EventTarget", "MouseEvent", "PointerEvent", "BeforeUnloadEvent", "AbortController", "AbortSignal", "DomException"] }
|
||||
@@ -161,6 +161,7 @@ impl eframe::App for WebShell {
|
||||
|
||||
fn services(inbox: Inbox) -> Services {
|
||||
Services {
|
||||
screen_pick: screen_color_service(),
|
||||
import: Some(Box::new(|name: &str, bytes: &[u8]| photocraft_io::import(name, bytes).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string()))),
|
||||
export: Some(Box::new(|doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings| {
|
||||
let mut opts = photocraft_io::ExportOptions::default();
|
||||
@@ -248,3 +249,72 @@ fn mime_for(name: &str) -> &'static str {
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
/// Rust bindings to the browser's user-activated EyeDropper API; unsupported browsers keep
|
||||
/// the document picker and its normal canvas zoom. The browser owns its screen magnifier.
|
||||
fn screen_color_service() -> Option<photocraft_ui_egui::screen_picker::Service> {
|
||||
use js_sys::{Function, Reflect};
|
||||
use photocraft_ui_egui::screen_picker::{Capture, Pending};
|
||||
use wasm_bindgen::{JsValue, closure::Closure};
|
||||
let window = web_sys::window()?;
|
||||
let constructor = Reflect::get(&window, &JsValue::from_str("EyeDropper")).ok()?.dyn_into::<Function>().ok()?;
|
||||
Some(Box::new(move |ctx| {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let started = (|| -> Result<_, String> {
|
||||
let eye = Reflect::construct(&constructor, &js_sys::Array::new()).map_err(|e| format!("Could not start browser eyedropper: {e:?}"))?;
|
||||
let controller = web_sys::AbortController::new().map_err(|e| format!("Could not create eyedropper cancellation: {e:?}"))?;
|
||||
let options = js_sys::Object::new();
|
||||
Reflect::set(&options, &JsValue::from_str("signal"), &controller.signal()).map_err(|e| format!("Could not set eyedropper options: {e:?}"))?;
|
||||
let open = Reflect::get(&eye, &JsValue::from_str("open"))
|
||||
.map_err(|e| format!("Browser eyedropper has no open method: {e:?}"))?
|
||||
.dyn_into::<Function>()
|
||||
.map_err(|_| "Invalid browser eyedropper method")?;
|
||||
let promise = open
|
||||
.call1(&eye, &options)
|
||||
.map_err(|e| format!("Could not open browser eyedropper: {e:?}"))?
|
||||
.dyn_into::<js_sys::Promise>()
|
||||
.map_err(|_| "Browser eyedropper returned no promise")?;
|
||||
let stop = cancelled.clone();
|
||||
let abort = controller.clone();
|
||||
let timer = Closure::<dyn FnMut()>::new(move || {
|
||||
if stop.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
abort.abort();
|
||||
}
|
||||
});
|
||||
let interval = window.set_interval_with_callback_and_timeout_and_arguments_0(timer.as_ref().unchecked_ref(), 50).map_err(|e| {
|
||||
controller.abort();
|
||||
format!("Could not watch eyedropper cancellation: {e:?}")
|
||||
})?;
|
||||
Ok((promise, timer, interval))
|
||||
})();
|
||||
match started {
|
||||
Err(e) => {
|
||||
let _ = tx.send(Err(e));
|
||||
}
|
||||
Ok((promise, timer, interval)) => {
|
||||
let wake = ctx.clone();
|
||||
let window = window.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let result = match wasm_bindgen_futures::JsFuture::from(promise).await {
|
||||
Ok(value) => Reflect::get(&value, &JsValue::from_str("sRGBHex"))
|
||||
.ok()
|
||||
.and_then(|v| v.as_string())
|
||||
.and_then(|h| photocraft_ui_egui::color_picker_ui::parse_hex(&h))
|
||||
.map(|rgb| Capture::Color(Some(rgb)))
|
||||
.ok_or_else(|| "Browser eyedropper returned an invalid color".into()),
|
||||
Err(e) if Reflect::get(&e, &JsValue::from_str("name")).ok().and_then(|v| v.as_string()).as_deref() == Some("AbortError") => {
|
||||
Ok(Capture::Color(None))
|
||||
}
|
||||
Err(e) => Err(format!("Browser screen color selection failed: {e:?}")),
|
||||
};
|
||||
window.clear_interval_with_handle(interval);
|
||||
drop(timer);
|
||||
let _ = tx.send(result);
|
||||
wake.request_repaint();
|
||||
});
|
||||
}
|
||||
}
|
||||
Pending { receiver: rx, cancelled }
|
||||
}))
|
||||
}
|
||||
@@ -42,6 +42,8 @@ open = "5"
|
||||
ron = "0.12"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# Safe CoreGraphics bindings, compatible with the existing macOS 11 deployment target.
|
||||
core-graphics2 = { version = "=0.6.1", default-features = false, features = ["display", "window", "link"] }
|
||||
# Finder double-click / Open With / Dock drops arrive as Apple events; this small audited crate
|
||||
# registers the handler (Objective-C interop needs unsafe, which our workspace forbids) behind a
|
||||
# safe API. Pinned exactly: review the diff before bumping.
|
||||
@@ -58,7 +60,12 @@ muda = { version = "=0.21.1", default-features = false }
|
||||
|
||||
# Drop position on X11 (`cursor.rs`): pure-Rust X client, already in Cargo.lock; pinned exactly.
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
x11rb = { version = "=0.13.2", default-features = false }
|
||||
x11rb = { version = "=0.13.2", default-features = false, features = ["image", "randr"] }
|
||||
# Wayland desktop color selection through the existing XDG portal transport.
|
||||
zbus = "=5.19.0"
|
||||
# Already used by the portal stack; bounded waits let cancelling close the OS request.
|
||||
futures-lite = "=2.6.1"
|
||||
async-io = "=2.6.0"
|
||||
# Open the window through Xwayland for a pen on Wayland (`tablet.rs`, #639): eframe doesn't
|
||||
# re-export winit's `EventLoopBuilderExtX11`. Already in Cargo.lock (eframe); pinned exactly.
|
||||
winit = { version = "=0.30.13", default-features = false, features = ["x11"] }
|
||||
@@ -66,6 +73,8 @@ winit = { version = "=0.30.13", default-features = false, features = ["x11"] }
|
||||
# Drop position on Windows (`cursor.rs`): safe Win32 wrappers (GetCursorPos), no dependencies of
|
||||
# its own. Pinned exactly: review the diff before bumping.
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
# Safe Windows screen-capture bindings; Linux uses the pure-Rust X11 client/portal.
|
||||
xcap = "=0.9.8"
|
||||
winsafe = { version = "=0.0.29", default-features = false, features = ["user"] }
|
||||
|
||||
# tests/mac_menu_appkit.rs: reads AppKit's menu bar back and sends it real key events.
|
||||
|
||||
@@ -37,6 +37,7 @@ mod mac_window;
|
||||
mod linux_libs;
|
||||
mod logging;
|
||||
mod monitor_profile;
|
||||
mod screen_color;
|
||||
mod services;
|
||||
// Windows gets pen pressure from winit (WM_POINTER); the web runner has its own listener.
|
||||
#[cfg(any(target_os = "macos", target_os = "linux", test))]
|
||||
@@ -427,6 +428,7 @@ fn main() -> eframe::Result {
|
||||
let _ = in_window_menus;
|
||||
// Where file drags and drops are (winit 0.30 doesn't say).
|
||||
app.services.cursor_pos = cursor::service(cc);
|
||||
app.services.screen_pick = screen_color::service(cc, app.services.is_wayland);
|
||||
// Tablet pressure/tilt/eraser (winit drops them): the macOS monitor and the X11 reader
|
||||
// write into the stylus feed. The monitor goes in here, not before the event loop:
|
||||
// AppKit's shared application only exists once winit created it (#759).
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
//! Desktop colour capture starts only on an explicit UI request. No snapshots are written to
|
||||
//! disk, transmitted or retained after a pick. Wayland delegates to its permission portal.
|
||||
use photocraft_ui_egui::screen_picker::{Capture, MAX_PIXELS, Pending, ScreenImage, Service};
|
||||
|
||||
pub fn service(cc: &eframe::CreationContext<'_>, wayland: bool) -> Option<Service> {
|
||||
if !cfg!(any(target_os = "linux", target_os = "macos", target_os = "windows")) {
|
||||
return None;
|
||||
}
|
||||
let window = cc.winit_window()?.clone();
|
||||
Some(Box::new(move |ctx| {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let fallback = tx.clone();
|
||||
let placements: Vec<Placement> = window
|
||||
.available_monitors()
|
||||
.enumerate()
|
||||
.map(|(index, m)| {
|
||||
let p = m.position();
|
||||
let size = m.size();
|
||||
Placement { index, x: p.x, y: p.y, width: size.width, height: size.height }
|
||||
})
|
||||
.collect();
|
||||
let cancelled = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let stop = cancelled.clone();
|
||||
let scale = ctx.native_pixels_per_point().unwrap_or(1.0);
|
||||
let wake = ctx.clone();
|
||||
if let Err(e) = std::thread::Builder::new().name("screen-color-picker".into()).spawn(move || {
|
||||
let result = capture(wayland, scale, &stop, &placements);
|
||||
let _ = tx.send(result);
|
||||
wake.request_repaint();
|
||||
}) {
|
||||
let _ = fallback.send(Err(format!("Could not start screen color picker: {e}")));
|
||||
}
|
||||
Pending { receiver: rx, cancelled }
|
||||
}))
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
struct Placement {
|
||||
index: usize,
|
||||
x: i32,
|
||||
y: i32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
fn find_monitor(placements: &[Placement], x: i32, y: i32, w: u32, h: u32) -> Option<usize> {
|
||||
placements.iter().find(|p| p.x == x && p.y == y && p.width == w && p.height == h).map(|p| p.index)
|
||||
}
|
||||
fn count(width: usize, height: usize) -> Result<usize, String> {
|
||||
width.checked_mul(height).filter(|&n| n > 0 && n <= MAX_PIXELS).ok_or_else(|| "Screen capture exceeds the 64 MP memory budget".into())
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
fn capture(wayland: bool, scale: f32, stop: &std::sync::atomic::AtomicBool, placements: &[Placement]) -> Result<Capture, String> {
|
||||
if wayland {
|
||||
return portal(stop);
|
||||
}
|
||||
use x11rb::{
|
||||
connection::Connection,
|
||||
image::{Image, PixelLayout},
|
||||
protocol::{
|
||||
randr::ConnectionExt as _,
|
||||
xproto::{AtomEnum, ConnectionExt, VisualClass},
|
||||
},
|
||||
};
|
||||
let (conn, index) = x11rb::connect(None).map_err(|e| e.to_string())?;
|
||||
let screen = conn.setup().roots.get(index).ok_or("X11 screen is missing")?;
|
||||
let (w, h) = (screen.width_in_pixels, screen.height_in_pixels);
|
||||
count(usize::from(w), usize::from(h))?;
|
||||
let mut monitors = conn.randr_get_monitors(screen.root, true).ok().and_then(|c| c.reply().ok()).map(|r| r.monitors).unwrap_or_default();
|
||||
monitors.sort_by_key(|m| !m.primary);
|
||||
if !monitors.iter().any(|m| m.primary)
|
||||
&& let Some(first) = monitors.first_mut()
|
||||
{
|
||||
first.primary = true;
|
||||
}
|
||||
let regions: Vec<(i16, i16, u16, u16, bool)> =
|
||||
if monitors.is_empty() { vec![(0, 0, w, h, true)] } else { monitors.iter().map(|m| (m.x, m.y, m.width, m.height, m.primary)).collect() };
|
||||
if regions.len() > 16 {
|
||||
return Err("Too many screens for the color picker".into());
|
||||
}
|
||||
let mut total = 0usize;
|
||||
for &(x, y, mw, mh, _) in ®ions {
|
||||
if x < 0 || y < 0 || u32::from(x as u16) + u32::from(mw) > u32::from(w) || u32::from(y as u16) + u32::from(mh) > u32::from(h) {
|
||||
return Err("Invalid X11 display geometry".into());
|
||||
}
|
||||
total = total
|
||||
.checked_add(count(usize::from(mw), usize::from(mh))?)
|
||||
.filter(|&n| n <= MAX_PIXELS)
|
||||
.ok_or("Combined displays exceed the screen picker memory budget")?;
|
||||
}
|
||||
let (image, visual_id) = Image::get(&conn, screen.root, 0, 0, w, h).map_err(|e| format!("Screen capture failed: {e}"))?;
|
||||
let visual = screen.allowed_depths.iter().flat_map(|d| &d.visuals).find(|v| v.visual_id == visual_id).ok_or("Unsupported X11 screen visual")?;
|
||||
if visual.class != VisualClass::TRUE_COLOR {
|
||||
return Err("Screen picking requires an X11 TrueColor visual".into());
|
||||
}
|
||||
let layout = PixelLayout::from_visual_type(*visual).map_err(|e| e.to_string())?;
|
||||
let atom = conn.intern_atom(true, b"_ICC_PROFILE").map_err(|e| e.to_string())?.reply().map_err(|e| e.to_string())?.atom;
|
||||
let primary_profile = if atom == 0 {
|
||||
None
|
||||
} else {
|
||||
let reply = conn.get_property(false, screen.root, atom, AtomEnum::ANY, 0, 256 * 1024).map_err(|e| e.to_string())?.reply().map_err(|e| e.to_string())?;
|
||||
(reply.bytes_after == 0 && reply.format == 8 && !reply.value.is_empty()).then_some(reply.value)
|
||||
};
|
||||
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
|
||||
let byte = |v: u16| ((u32::from(v) + 128) / 257) as u8;
|
||||
let mut images = Vec::new();
|
||||
for (x, y, mw, mh, primary) in regions {
|
||||
if stop.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
return Ok(Capture::Color(None));
|
||||
}
|
||||
let mut rgba = Vec::with_capacity(count(usize::from(mw), usize::from(mh))?.saturating_mul(4));
|
||||
for row in 0..mh {
|
||||
for col in 0..mw {
|
||||
let (r, g, b) = layout.decode(image.get_pixel((x as u16) + col, (y as u16) + row));
|
||||
rgba.extend_from_slice(&[byte(r), byte(g), byte(b), 255]);
|
||||
}
|
||||
}
|
||||
images.push(ScreenImage {
|
||||
monitor: find_monitor(placements, i32::from(x), i32::from(y), u32::from(mw), u32::from(mh)),
|
||||
position: egui::pos2(f32::from(x), f32::from(y)) / scale,
|
||||
size: egui::vec2(f32::from(mw), f32::from(mh)) / scale,
|
||||
width: usize::from(mw),
|
||||
height: usize::from(mh),
|
||||
rgba,
|
||||
profile: if primary { primary_profile.clone() } else { None },
|
||||
});
|
||||
}
|
||||
Ok(Capture::Images(images))
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
fn portal(stop: &std::sync::atomic::AtomicBool) -> Result<Capture, String> {
|
||||
use std::collections::HashMap;
|
||||
use zbus::{
|
||||
blocking::Proxy,
|
||||
zvariant::{OwnedObjectPath, OwnedValue, Value},
|
||||
};
|
||||
let conn = zbus::blocking::connection::Builder::session()
|
||||
.map_err(|e| e.to_string())?
|
||||
.method_timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let unique = conn.unique_name().ok_or("Portal connection has no unique name")?.as_str().trim_start_matches(':').replace('.', "_");
|
||||
let token = format!("photocraft_{}", std::process::id());
|
||||
let path = format!("/org/freedesktop/portal/desktop/request/{unique}/{token}");
|
||||
let request = Proxy::new(&conn, "org.freedesktop.portal.Desktop", path.as_str(), "org.freedesktop.portal.Request").map_err(|e| e.to_string())?;
|
||||
use futures_lite::StreamExt;
|
||||
let mut replies = futures_lite::future::block_on(request.inner().receive_signal("Response")).map_err(|e| e.to_string())?;
|
||||
let screenshot = Proxy::new(&conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Screenshot")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let options: HashMap<&str, Value<'_>> = HashMap::from([("handle_token", Value::from(token.as_str()))]);
|
||||
let _handle: OwnedObjectPath =
|
||||
screenshot.call("PickColor", &("", options)).map_err(|e| format!("The desktop portal does not support color picking: {e}"))?;
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120);
|
||||
let reply = loop {
|
||||
if stop.load(std::sync::atomic::Ordering::Relaxed) || std::time::Instant::now() >= deadline {
|
||||
let _: Result<(), _> = request.call("Close", &());
|
||||
return Ok(Capture::Color(None));
|
||||
}
|
||||
let next = futures_lite::future::block_on(futures_lite::future::race(async { Some(replies.next().await) }, async {
|
||||
async_io::Timer::after(std::time::Duration::from_millis(50)).await;
|
||||
None
|
||||
}));
|
||||
if let Some(message) = next {
|
||||
break message.ok_or("The desktop color picker closed without a result")?;
|
||||
}
|
||||
};
|
||||
let (code, values): (u32, HashMap<String, OwnedValue>) = reply.body().deserialize().map_err(|e| e.to_string())?;
|
||||
if code == 1 {
|
||||
return Ok(Capture::Color(None));
|
||||
}
|
||||
if code != 0 {
|
||||
return Err("The desktop color picker could not sample this screen".into());
|
||||
}
|
||||
let value = values.get("color").ok_or("Desktop picker returned no color")?;
|
||||
let (r, g, b): (f64, f64, f64) = value.try_clone().map_err(|e| e.to_string())?.try_into().map_err(|e: zbus::zvariant::Error| e.to_string())?;
|
||||
if [r, g, b].iter().any(|v| !v.is_finite() || !(0.0..=1.0).contains(v)) {
|
||||
return Err("Desktop picker returned an invalid color".into());
|
||||
}
|
||||
Ok(Capture::Color(Some([r as f32, g as f32, b as f32])))
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
fn capture(_wayland: bool, _scale: f32, stop: &std::sync::atomic::AtomicBool, placements: &[Placement]) -> Result<Capture, String> {
|
||||
let monitors = xcap::Monitor::all().map_err(|e| e.to_string())?;
|
||||
if monitors.is_empty() || monitors.len() > 16 {
|
||||
return Err("No supported screen is available".into());
|
||||
}
|
||||
let mut budget = 0usize;
|
||||
for m in &monitors {
|
||||
budget = budget
|
||||
.checked_add(count(m.width().map_err(|e| e.to_string())? as usize, m.height().map_err(|e| e.to_string())? as usize)?)
|
||||
.filter(|&n| n <= MAX_PIXELS)
|
||||
.ok_or("Combined displays exceed the screen picker memory budget")?;
|
||||
}
|
||||
let mut total = 0usize;
|
||||
let mut images = Vec::new();
|
||||
for m in monitors {
|
||||
if stop.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
return Ok(Capture::Color(None));
|
||||
}
|
||||
let (w, h) = (m.width().map_err(|e| e.to_string())?, m.height().map_err(|e| e.to_string())?);
|
||||
let scale = m.scale_factor().map_err(|e| e.to_string())?;
|
||||
if !scale.is_finite() || scale <= 0.0 || scale > 16.0 {
|
||||
return Err("Invalid display scale".into());
|
||||
}
|
||||
let captured = m.capture_image().map_err(|e| format!("Screen capture failed: {e}"))?;
|
||||
let (width, height) = (captured.width() as usize, captured.height() as usize);
|
||||
total = total.checked_add(count(width, height)?).filter(|&n| n <= MAX_PIXELS).ok_or("Combined displays exceed the screen picker memory budget")?;
|
||||
let (x, y) = (m.x().map_err(|e| e.to_string())?, m.y().map_err(|e| e.to_string())?);
|
||||
images.push(ScreenImage {
|
||||
monitor: find_monitor(placements, x, y, w, h),
|
||||
position: egui::pos2(x as f32, y as f32) / scale,
|
||||
size: egui::vec2(w as f32, h as f32) / scale,
|
||||
width,
|
||||
height,
|
||||
rgba: captured.into_raw(),
|
||||
profile: None,
|
||||
});
|
||||
}
|
||||
Ok(Capture::Images(images))
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
fn capture(_wayland: bool, _scale: f32, stop: &std::sync::atomic::AtomicBool, placements: &[Placement]) -> Result<Capture, String> {
|
||||
use core_graphics2::{display::get_active_display_list, image::CGImageAlphaInfo as Alpha, window};
|
||||
if !window::preflight_screen_capture_access() && !window::request_screen_capture_access() {
|
||||
return Err("Allow PhotoCraft in System Settings > Privacy & Security > Screen Recording, then reopen PhotoCraft".into());
|
||||
}
|
||||
let displays = get_active_display_list(17).ok_or("Could not enumerate displays")?;
|
||||
if displays.is_empty() || displays.len() > 16 {
|
||||
return Err("No supported screen is available".into());
|
||||
}
|
||||
let mut budget = 0usize;
|
||||
for display in &displays {
|
||||
let mode = display.copy_display_mode().ok_or("Display mode is unavailable")?;
|
||||
budget = budget
|
||||
.checked_add(count(mode.pixel_width(), mode.pixel_height())?)
|
||||
.filter(|&n| n <= MAX_PIXELS)
|
||||
.ok_or("Combined displays exceed the screen picker memory budget")?;
|
||||
}
|
||||
let mut images = Vec::new();
|
||||
let mut total = 0usize;
|
||||
for display in displays {
|
||||
if stop.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
return Ok(Capture::Color(None));
|
||||
}
|
||||
// Capture a composed monitor at native resolution through safe bindings. Keeping the
|
||||
// CoreGraphics seam preserves PhotoCraft's existing macOS 11 deployment target.
|
||||
let bounds = display.bounds();
|
||||
let image = window::new_image(
|
||||
bounds,
|
||||
window::CGWindowListOption::OnScreenOnly,
|
||||
window::kCGNullWindowID,
|
||||
window::CGWindowImageOption::BestResolution | window::CGWindowImageOption::ShouldBeOpaque,
|
||||
)
|
||||
.ok_or("Screen capture failed; check Screen Recording permission")?;
|
||||
let (width, height) = (image.width(), image.height());
|
||||
total = total.checked_add(count(width, height)?).filter(|&n| n <= MAX_PIXELS).ok_or("Combined displays exceed the screen picker memory budget")?;
|
||||
let profile = image.color_space().and_then(|space| space.copy_icc_data());
|
||||
if profile.as_ref().is_some_and(|p| p.len() > 1024 * 1024) {
|
||||
return Err("Screen profile exceeds the memory budget".into());
|
||||
}
|
||||
let info = image.bitmap_info().bits();
|
||||
if image.bits_per_component() != 8 || image.bits_per_pixel() != 32 || info & (1 << 8) != 0 {
|
||||
return Err("Unsupported screen capture pixel format".into());
|
||||
}
|
||||
let (first, premultiplied, opaque) = match image.alpha_info() {
|
||||
Alpha::AlphaPremultipliedFirst => (true, true, false),
|
||||
Alpha::AlphaPremultipliedLast => (false, true, false),
|
||||
Alpha::AlphaFirst => (true, false, false),
|
||||
Alpha::AlphaLast => (false, false, false),
|
||||
Alpha::AlphaNoneSkipFirst => (true, false, true),
|
||||
Alpha::AlphaNoneSkipLast => (false, false, true),
|
||||
_ => return Err("Unsupported screen capture alpha layout".into()),
|
||||
};
|
||||
let little = match info & 0x7000 {
|
||||
0 => cfg!(target_endian = "little"),
|
||||
0x2000 => true,
|
||||
0x4000 => false,
|
||||
_ => return Err("Unsupported screen capture byte order".into()),
|
||||
};
|
||||
let order = match (first, little) {
|
||||
(true, true) => [2, 1, 0, 3],
|
||||
(true, false) => [1, 2, 3, 0],
|
||||
(false, true) => [3, 2, 1, 0],
|
||||
(false, false) => [0, 1, 2, 3],
|
||||
};
|
||||
let stride = image.bytes_per_row();
|
||||
checked_stride(width, height, stride)?;
|
||||
let bytes = image.data_provider().and_then(|p| p.copy_data()).ok_or("Screen image has no pixels")?;
|
||||
let rgba = unpack_rgba(bytes.bytes(), width, height, stride, order, premultiplied, opaque)?;
|
||||
let (w, h) = (bounds.size.width as f32, bounds.size.height as f32);
|
||||
let (x, y) = (bounds.origin.x as f32, bounds.origin.y as f32);
|
||||
if ![w, h, x, y].iter().all(|v| v.is_finite()) || w <= 0.0 || h <= 0.0 {
|
||||
return Err("Invalid display geometry".into());
|
||||
}
|
||||
let scale = width as f32 / w;
|
||||
images.push(ScreenImage {
|
||||
monitor: find_monitor(placements, (x * scale).round() as i32, (y * scale).round() as i32, width as u32, height as u32),
|
||||
position: egui::pos2(x, y),
|
||||
size: egui::vec2(w, h),
|
||||
width,
|
||||
height,
|
||||
rgba,
|
||||
profile: profile.map(|p| p.bytes().to_vec()),
|
||||
});
|
||||
}
|
||||
Ok(Capture::Images(images))
|
||||
}
|
||||
// CoreGraphics rows can be padded and their byte order/alpha vary. Kept platform independent
|
||||
// so Linux CI also tests the macOS decoding path against synthetic pixels.
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn checked_stride(width: usize, height: usize, stride: usize) -> Result<usize, String> {
|
||||
count(width, height)?;
|
||||
let row = width.checked_mul(4).ok_or("Invalid screen row")?;
|
||||
if stride < row || stride > row.saturating_add(4096) {
|
||||
return Err("Invalid screen row stride".into());
|
||||
}
|
||||
stride.checked_mul(height).filter(|&n| n <= MAX_PIXELS.saturating_mul(4)).ok_or_else(|| "Screen buffer exceeds the memory budget".into())
|
||||
}
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn unpack_rgba(bytes: &[u8], width: usize, height: usize, stride: usize, order: [usize; 4], premultiplied: bool, opaque: bool) -> Result<Vec<u8>, String> {
|
||||
let length = checked_stride(width, height, stride)?;
|
||||
if bytes.len() < length || order.iter().any(|&i| i > 3) {
|
||||
return Err("Invalid screen pixel buffer".into());
|
||||
}
|
||||
let mut out = Vec::with_capacity(count(width, height)?.saturating_mul(4));
|
||||
for row in bytes.chunks_exact(stride).take(height) {
|
||||
for p in row[..width * 4].as_chunks::<4>().0 {
|
||||
let a = if opaque { 255 } else { p[order[3]] };
|
||||
for &i in &order[..3] {
|
||||
let v = u32::from(p[i]);
|
||||
out.push(if premultiplied && a != 255 {
|
||||
if a == 0 { 0 } else { ((v * 255 + u32::from(a) / 2) / u32::from(a)).min(255) as u8 }
|
||||
} else {
|
||||
v as u8
|
||||
});
|
||||
}
|
||||
out.push(255);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn screen_capture_decodes_padded_bgra_and_premultiplied_argb() {
|
||||
assert_eq!(
|
||||
unpack_rgba(&[10, 20, 30, 0, 99, 99, 99, 99, 40, 50, 60, 0, 99, 99, 99, 99], 1, 2, 8, [2, 1, 0, 3], false, true).unwrap(),
|
||||
[30, 20, 10, 255, 60, 50, 40, 255]
|
||||
);
|
||||
assert_eq!(unpack_rgba(&[128, 64, 32, 16], 1, 1, 4, [1, 2, 3, 0], true, false).unwrap(), [128, 64, 32, 255]);
|
||||
assert_eq!(unpack_rgba(&[0, 0, 0, 0], 1, 1, 4, [0, 1, 2, 3], true, false).unwrap(), [0, 0, 0, 255]);
|
||||
}
|
||||
#[test]
|
||||
fn screen_capture_rejects_unbounded_or_truncated_buffers() {
|
||||
assert!(count(usize::MAX, 2).is_err());
|
||||
assert!(checked_stride(1, 1, usize::MAX).is_err());
|
||||
assert!(checked_stride(1, MAX_PIXELS, 4096).is_err());
|
||||
assert!(unpack_rgba(&[0; 3], 1, 1, 4, [0, 1, 2, 3], false, true).is_err());
|
||||
assert!(unpack_rgba(&[0; 4], 1, 1, 4, [4, 1, 2, 3], false, true).is_err());
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
#[ignore = "requires a live X11 display; draws only a tiny synthetic fixture"]
|
||||
fn screen_capture_reads_a_known_x11_fixture_pixel() {
|
||||
use x11rb::{
|
||||
COPY_DEPTH_FROM_PARENT,
|
||||
connection::Connection,
|
||||
protocol::xproto::{ConfigureWindowAux, ConnectionExt, CreateWindowAux, StackMode, WindowClass},
|
||||
};
|
||||
let (conn, index) = x11rb::connect(None).unwrap();
|
||||
let screen = &conn.setup().roots[index];
|
||||
let window = conn.generate_id().unwrap();
|
||||
conn.create_window(
|
||||
COPY_DEPTH_FROM_PARENT,
|
||||
window,
|
||||
screen.root,
|
||||
200,
|
||||
200,
|
||||
8,
|
||||
8,
|
||||
0,
|
||||
WindowClass::INPUT_OUTPUT,
|
||||
0,
|
||||
&CreateWindowAux::new().override_redirect(1).background_pixel(0xff8040),
|
||||
)
|
||||
.unwrap()
|
||||
.check()
|
||||
.unwrap();
|
||||
conn.map_window(window).unwrap().check().unwrap();
|
||||
conn.configure_window(window, &ConfigureWindowAux::new().stack_mode(StackMode::ABOVE)).unwrap().check().unwrap();
|
||||
conn.flush().unwrap();
|
||||
// X11 request completion precedes the compositor presenting the mapped fixture.
|
||||
std::thread::sleep(std::time::Duration::from_millis(150));
|
||||
let images = capture(false, 1.0, &std::sync::atomic::AtomicBool::new(false), &[]);
|
||||
conn.destroy_window(window).unwrap().check().unwrap();
|
||||
conn.flush().unwrap();
|
||||
let Capture::Images(images) = images.unwrap() else { panic!("not an image capture") };
|
||||
let first = images.iter().find(|m| m.position.x <= 200.0 && m.position.y <= 200.0).unwrap();
|
||||
let x = (200.0 - first.position.x) as usize;
|
||||
let y = (200.0 - first.position.y) as usize;
|
||||
let offset = (y * first.width + x) * 4;
|
||||
assert_eq!(&first.rgba[offset..offset + 4], &[255, 128, 64, 255]);
|
||||
}
|
||||
#[test]
|
||||
fn screen_capture_matches_negative_and_hidpi_monitor_geometry() {
|
||||
let m = [Placement { index: 2, x: -3840, y: 0, width: 3840, height: 2160 }];
|
||||
assert_eq!(find_monitor(&m, -3840, 0, 3840, 2160), Some(2));
|
||||
assert_eq!(find_monitor(&m, 0, 0, 1920, 1080), None);
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
||||
fn capture(_wayland: bool, _scale: f32, _stop: &std::sync::atomic::AtomicBool, _placements: &[Placement]) -> Result<Capture, String> {
|
||||
Err("Screen color picking is unavailable on this platform".into())
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//! Render synthetic color-picker evidence without capturing the user's desktop.
|
||||
//! cargo run -p photocraft-ui-egui --example color_picker_demo -- /tmp/color-picker
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
use photocraft_ui_egui::{
|
||||
PhotocraftApp, Services,
|
||||
screen_picker::{self, Capture, Pending, ScreenImage},
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
fn main() {
|
||||
let output = std::env::args().nth(1).unwrap_or_else(|| "/tmp/photocraft-color-picker".into());
|
||||
std::fs::create_dir_all(&output).expect("output directory");
|
||||
let mut session = photocraft_engine::Session::new();
|
||||
session.execute("file.new", json!({"width":800,"height":500,"background":"white"})).unwrap();
|
||||
session.execute("type.create", json!({"text":"Color from anywhere","color":"#2070c0","size":48,"x":90,"y":200})).unwrap();
|
||||
let mut text = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_pixels_per_point(1.0).with_max_steps(64).wgpu().build_eframe(move |cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
let services = Services {
|
||||
screen_pick: Some(Box::new(|_| {
|
||||
let (tx, receiver) = std::sync::mpsc::channel();
|
||||
tx.send(Ok(Capture::Color(Some([1.0, 136.0 / 255.0, 0.0])))).unwrap();
|
||||
Pending { receiver, cancelled: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)) }
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = PhotocraftApp::new(session, services);
|
||||
app.ui.tool = photocraft_ui_egui::state::Tool::Type;
|
||||
app
|
||||
});
|
||||
text.run_steps(8);
|
||||
text.get_by_label("Set the text color").click();
|
||||
text.run_steps(6);
|
||||
text.render().unwrap().save(format!("{output}/text-color-picker.png")).unwrap();
|
||||
text.get_by_label("Pick screen color").click();
|
||||
text.run_steps(6);
|
||||
text.render().unwrap().save(format!("{output}/text-color-picked.png")).unwrap();
|
||||
|
||||
let services = Services {
|
||||
screen_pick: Some(Box::new(|_| {
|
||||
let mut rgba = Vec::with_capacity(800 * 600 * 4);
|
||||
for y in 0..600 {
|
||||
for x in 0..800 {
|
||||
rgba.extend_from_slice(&[(x * 255 / 799) as u8, (y * 255 / 599) as u8, 128, 255]);
|
||||
}
|
||||
}
|
||||
let (tx, receiver) = std::sync::mpsc::channel();
|
||||
tx.send(Ok(Capture::Images(vec![ScreenImage {
|
||||
monitor: None,
|
||||
position: egui::Pos2::ZERO,
|
||||
size: egui::vec2(800.0, 600.0),
|
||||
width: 800,
|
||||
height: 600,
|
||||
rgba,
|
||||
profile: None,
|
||||
}])))
|
||||
.unwrap();
|
||||
Pending { receiver, cancelled: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)) }
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let mut loupe = Harness::builder().with_size(egui::vec2(800.0, 600.0)).with_pixels_per_point(1.0).wgpu().build_ui_state(
|
||||
|ui, app: &mut PhotocraftApp| {
|
||||
screen_picker::tick(app, ui.ctx());
|
||||
if screen_picker::showing(ui.ctx()) {
|
||||
screen_picker::show(ui.ctx());
|
||||
} else {
|
||||
screen_picker::button(ui, egui::Id::new("demo"));
|
||||
}
|
||||
},
|
||||
PhotocraftApp::new(photocraft_engine::Session::new(), services),
|
||||
);
|
||||
loupe.run_steps(2);
|
||||
loupe.get_by_label("Pick screen color").click();
|
||||
loupe.run_steps(3);
|
||||
loupe.input_mut().events.push(egui::Event::PointerMoved(egui::pos2(360.0, 220.0)));
|
||||
loupe.run_steps(2);
|
||||
loupe.render().unwrap().save(format!("{output}/screen-color-loupe.png")).unwrap();
|
||||
screen_picker::cancel(&loupe.ctx);
|
||||
loupe.run_steps(1);
|
||||
}
|
||||
@@ -169,7 +169,7 @@ fn color32(c: [f32; 3]) -> Color32 {
|
||||
/// A colour swatch button (egui's picker); returns the edit.
|
||||
fn color_button(ui: &mut egui::Ui, c: &mut [f32; 3]) -> Edit {
|
||||
let mut srgb = [c[0], c[1], c[2]].map(|x| (x.clamp(0.0, 1.0) * 255.0).round() as u8);
|
||||
let r = ui.color_edit_button_srgb(&mut srgb);
|
||||
let r = crate::widgets::color_edit_button_srgb(ui, &mut srgb);
|
||||
if r.changed() {
|
||||
*c = srgb.map(|x| f32::from(x) / 255.0);
|
||||
}
|
||||
|
||||
@@ -457,7 +457,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo
|
||||
let _ = app.run("count.setGroup", json!({"group": gi, "visible": !g.visible}));
|
||||
}
|
||||
let mut rgb = g.color.to_rgb();
|
||||
if ui.color_edit_button_rgb(&mut rgb).changed() {
|
||||
if crate::widgets::color_edit_button_rgb(ui, &mut rgb).changed() {
|
||||
let _ = app.run("count.setGroup", json!({"group": gi, "color": [rgb[0], rgb[1], rgb[2]], "coalesce": "count-color"}));
|
||||
}
|
||||
if crate::icons::button(ui, "trash", 22.0, false, tl!("Delete count group")).clicked() {
|
||||
@@ -488,7 +488,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo
|
||||
ui.label(RichText::new(tl!("Author:")).color(t.text_dim).size(11.0));
|
||||
ui.add(egui::TextEdit::singleline(&mut app.ui.analysis.note_author).desired_width(120.0));
|
||||
ui.label(RichText::new(tl!("Color:")).color(t.text_dim).size(11.0));
|
||||
ui.color_edit_button_rgb(&mut app.ui.analysis.note_color);
|
||||
crate::widgets::color_edit_button_rgb(ui, &mut app.ui.analysis.note_color);
|
||||
if ui.add_enabled_ui(!doc.notes.is_empty(), |ui| crate::widgets::secondary_button(ui, tl!("Clear All"), 0.0)).inner.clicked() {
|
||||
let _ = app.run("notes.delete", json!({"all": true}));
|
||||
app.ui.analysis.note_selected = None;
|
||||
|
||||
@@ -134,7 +134,7 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) {
|
||||
if let ArtboardBackground::Custom(c) = a.background {
|
||||
let [r8, g8, b8, _] = c.to_rgba8();
|
||||
let mut rgb = [r8, g8, b8];
|
||||
if ui.color_edit_button_srgb(&mut rgb).changed() {
|
||||
if crate::widgets::color_edit_button_srgb(ui, &mut rgb).changed() {
|
||||
edit = Some(json!({"layer": layer.id.0, "background": "custom", "color": format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]), "coalesce": key("color")}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,6 +255,12 @@ pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) -> Option<bool> {
|
||||
f.insert("__webOnly".into(), json!(web));
|
||||
set_rgb(f, current(f).rgb, Keep::Nothing);
|
||||
}
|
||||
// Under the colour field, not in the action column: the dialog must still fit a
|
||||
// 760 x 480 window.
|
||||
ui.add_space(6.0);
|
||||
if let Some(rgb) = crate::screen_picker::button(ui, ui.id().with("screen-color")) {
|
||||
set_rgb(f, rgb, Keep::Nothing);
|
||||
}
|
||||
});
|
||||
ui.vertical(|ui| {
|
||||
ui.horizontal_top(|ui| {
|
||||
@@ -718,3 +724,48 @@ mod tests {
|
||||
assert_eq!(dialog_color(&app, id), "#00ff00");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod screen_integration_tests {
|
||||
use super::*;
|
||||
use crate::screen_picker::{Capture, Pending};
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
#[test]
|
||||
fn text_swatch_opens_full_dialog_and_screen_pick_waits_for_ok() {
|
||||
let mut session = photocraft_engine::Session::new();
|
||||
session.execute("file.new", json!({"width":160,"height":120})).unwrap();
|
||||
let id = session.execute("type.create", json!({"text":"Hello world","color":"#000000","size":24,"x":10,"y":60})).unwrap()["layer"].as_u64().unwrap();
|
||||
let services = crate::Services {
|
||||
screen_pick: Some(Box::new(|_| {
|
||||
let (tx, receiver) = std::sync::mpsc::channel();
|
||||
tx.send(Ok(Capture::Color(Some([1.0, 136.0 / 255.0, 0.0])))).unwrap();
|
||||
Pending { receiver, cancelled: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)) }
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(move |cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
let mut app = PhotocraftApp::new(session, services);
|
||||
app.ui.tool = crate::state::Tool::Type;
|
||||
app
|
||||
});
|
||||
h.run_steps(8);
|
||||
let before = h.state().session.active().unwrap().history.entries().len();
|
||||
let fg = h.state().session.tools.foreground;
|
||||
h.get_by_label("Set the text color").click();
|
||||
h.run_steps(6);
|
||||
assert_eq!(h.state().ui.dialogs.last().unwrap().fields["__label"], "Color Picker (Text Color)");
|
||||
h.get_by_label("Pick screen color").click();
|
||||
h.run_steps(6);
|
||||
assert_eq!(h.state().ui.dialogs.last().unwrap().fields["color"], "#ff8800");
|
||||
assert_eq!(h.state().session.active().unwrap().history.entries().len(), before, "sampling is only a dialog field change");
|
||||
assert_eq!(h.state().session.tools.foreground, fg);
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(4);
|
||||
let doc = &h.state().session.active().unwrap().doc;
|
||||
let layer = doc.layer(photocraft_doc::LayerId(id)).unwrap();
|
||||
let photocraft_doc::LayerContent::Text(text) = &layer.content else { panic!("not text") };
|
||||
assert_eq!(text.color.to_rgba8(), [255, 136, 0, 255]);
|
||||
assert_eq!(h.state().session.active().unwrap().history.entries().len(), before + 1);
|
||||
}
|
||||
}
|
||||
@@ -146,7 +146,7 @@ pub fn body(app: &PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Value>)
|
||||
ui.horizontal(|ui| {
|
||||
label(ui, tl!("Color:"));
|
||||
let mut rgb = parse_hex(&get_str(f, "color", "#000000"));
|
||||
if egui::color_picker::color_edit_button_srgb(ui, &mut rgb).changed() {
|
||||
if crate::widgets::color_edit_button_srgb(ui, &mut rgb).changed() {
|
||||
f.insert("color".into(), json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2])));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -513,7 +513,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
let mut rgb = c.map_or([neon[0], neon[1], neon[2]], |v| [v[0], v[1], v[2]]);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Glow Color")).color(t.text_dim));
|
||||
ui.color_edit_button_rgb(&mut rgb);
|
||||
crate::widgets::color_edit_button_rgb(ui, &mut rgb);
|
||||
});
|
||||
e.params.insert("glowColor".into(), json!([rgb[0], rgb[1], rgb[2], 1.0]));
|
||||
}
|
||||
|
||||
@@ -2167,6 +2167,14 @@ cameraRaw Color Barvy
|
||||
Channels: Kanály:
|
||||
Knockout Vyřazení
|
||||
Apply At Canvas Bounds Použít efekt na hranicích plátna
|
||||
|
||||
# Shared screen eyedropper and text Color Picker (#1128).
|
||||
Color Picker (Text Color) Výběr barvy (Barva textu)
|
||||
Pick screen color Vybrat barvu obrazovky
|
||||
Sample a pixel anywhere on the screen Vzorkovat pixel kdekoli na obrazovce
|
||||
Click to pick · Esc to cancel Kliknutí vybere · Esc zruší
|
||||
Pick a screen pixel or press Esc to cancel Vyberte pixel obrazovky nebo stiskněte Esc pro zrušení
|
||||
Scroll to zoom Rolováním přibližujte
|
||||
Show/Hide All Panels Zobrazit/skrýt všechny panely
|
||||
Show/Hide Panels Zobrazit/skrýt panely
|
||||
Could not open Nelze otevřít
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2207,6 +2207,14 @@
|
||||
Channels: Kanäle:
|
||||
Knockout Aussparung
|
||||
Apply At Canvas Bounds Effekt an Arbeitsflächenbegrenzungen anwenden
|
||||
|
||||
# Shared screen eyedropper and text Color Picker (#1128).
|
||||
Color Picker (Text Color) Farbwähler (Textfarbe)
|
||||
Pick screen color Bildschirmfarbe auswählen
|
||||
Sample a pixel anywhere on the screen Ein Pixel an beliebiger Stelle auf dem Bildschirm auswählen
|
||||
Click to pick · Esc to cancel Klicken zum Auswählen · Esc zum Abbrechen
|
||||
Pick a screen pixel or press Esc to cancel Ein Bildschirmpixel auswählen oder mit Esc abbrechen
|
||||
Scroll to zoom Zum Zoomen scrollen
|
||||
Show/Hide All Panels Alle Bedienfelder ein-/ausblenden
|
||||
Show/Hide Panels Bedienfelder ein-/ausblenden
|
||||
Could not open Öffnen nicht möglich
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2238,6 +2238,14 @@ cameraRaw Amount Cantidad
|
||||
Channels: Canales:
|
||||
Knockout Cobertura
|
||||
Apply At Canvas Bounds Aplicar efecto en los límites del lienzo
|
||||
|
||||
# Shared screen eyedropper and text Color Picker (#1128).
|
||||
Color Picker (Text Color) Selector de color (Color del texto)
|
||||
Pick screen color Elegir color de pantalla
|
||||
Sample a pixel anywhere on the screen Tomar un píxel en cualquier lugar de la pantalla
|
||||
Click to pick · Esc to cancel Clic para elegir · Esc para cancelar
|
||||
Pick a screen pixel or press Esc to cancel Elige un píxel de pantalla o pulsa Esc para cancelar
|
||||
Scroll to zoom Desplaza para ampliar
|
||||
Show/Hide All Panels Mostrar/ocultar todos los paneles
|
||||
Show/Hide Panels Mostrar/ocultar paneles
|
||||
Could not open No se pudo abrir
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -2598,6 +2598,14 @@ cameraRaw Blending Fusion
|
||||
Channels: Couches :
|
||||
Knockout Masquage
|
||||
Apply At Canvas Bounds Appliquer l’effet aux limites de la zone de travail
|
||||
|
||||
# Shared screen eyedropper and text Color Picker (#1128).
|
||||
Color Picker (Text Color) Sélecteur de couleurs (Couleur du texte)
|
||||
Pick screen color Choisir une couleur à l’écran
|
||||
Sample a pixel anywhere on the screen Prélever un pixel n’importe où sur l’écran
|
||||
Click to pick · Esc to cancel Cliquer pour choisir · Échap pour annuler
|
||||
Pick a screen pixel or press Esc to cancel Choisissez un pixel à l’écran ou appuyez sur Échap pour annuler
|
||||
Scroll to zoom Défiler pour zoomer
|
||||
Show/Hide All Panels Afficher/masquer tous les panneaux
|
||||
Show/Hide Panels Afficher/masquer les panneaux
|
||||
Could not open Impossible d’ouvrir
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2585,6 +2585,14 @@ cameraRaw Blending Pembauran
|
||||
Channels: Channel:
|
||||
Knockout Knockout
|
||||
Apply At Canvas Bounds Terapkan efek di batas kanvas
|
||||
|
||||
# Shared screen eyedropper and text Color Picker (#1128).
|
||||
Color Picker (Text Color) Pemilih Warna (Warna Teks)
|
||||
Pick screen color Pilih warna layar
|
||||
Sample a pixel anywhere on the screen Ambil sampel piksel di mana saja pada layar
|
||||
Click to pick · Esc to cancel Klik untuk memilih · Esc untuk membatalkan
|
||||
Pick a screen pixel or press Esc to cancel Pilih piksel layar atau tekan Esc untuk membatalkan
|
||||
Scroll to zoom Gulir untuk memperbesar
|
||||
Show/Hide All Panels Tampilkan/Sembunyikan Semua Panel
|
||||
Show/Hide Panels Tampilkan/Sembunyikan Panel
|
||||
Could not open Tidak dapat membuka
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -2241,6 +2241,14 @@ cameraRaw Amount Quantità
|
||||
Add to Swatches Aggiungi ai campioni
|
||||
Convert to Layers Converti in livelli
|
||||
Apply At Canvas Bounds Applica effetto ai limiti dell’area di lavoro
|
||||
|
||||
# Shared screen eyedropper and text Color Picker (#1128).
|
||||
Color Picker (Text Color) Selettore colore (Colore del testo)
|
||||
Pick screen color Scegli colore dallo schermo
|
||||
Sample a pixel anywhere on the screen Campiona un pixel in qualsiasi punto dello schermo
|
||||
Click to pick · Esc to cancel Clic per scegliere · Esc per annullare
|
||||
Pick a screen pixel or press Esc to cancel Scegli un pixel dello schermo o premi Esc per annullare
|
||||
Scroll to zoom Scorri per ingrandire
|
||||
Show/Hide All Panels Mostra/nascondi tutti i pannelli
|
||||
Show/Hide Panels Mostra/nascondi pannelli
|
||||
Could not open Impossibile aprire
|
||||
|
||||
|
@@ -2150,6 +2150,14 @@ cameraRaw Color カラー
|
||||
Channels: チャンネル:
|
||||
Knockout ノックアウト
|
||||
Apply At Canvas Bounds カンバスの境界に効果を適用
|
||||
|
||||
# Shared screen eyedropper and text Color Picker (#1128).
|
||||
Color Picker (Text Color) カラーピッカー(文字色)
|
||||
Pick screen color 画面の色を選択
|
||||
Sample a pixel anywhere on the screen 画面上の任意のピクセルから色を取得
|
||||
Click to pick · Esc to cancel クリックで選択 · Escでキャンセル
|
||||
Pick a screen pixel or press Esc to cancel 画面のピクセルを選択するか、Escでキャンセル
|
||||
Scroll to zoom スクロールしてズーム
|
||||
Show/Hide All Panels すべてのパネルを表示/非表示
|
||||
Show/Hide Panels パネルを表示/非表示
|
||||
Could not open 開けませんでした
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2753,6 +2753,14 @@ cameraRaw Color 색상
|
||||
Channels: 채널:
|
||||
Knockout 녹아웃
|
||||
Apply At Canvas Bounds 캔버스 경계에 효과 적용
|
||||
|
||||
# Shared screen eyedropper and text Color Picker (#1128).
|
||||
Color Picker (Text Color) 색상 선택기 (텍스트 색상)
|
||||
Pick screen color 화면 색상 선택
|
||||
Sample a pixel anywhere on the screen 화면의 아무 픽셀에서 색상 추출
|
||||
Click to pick · Esc to cancel 클릭하여 선택 · Esc로 취소
|
||||
Pick a screen pixel or press Esc to cancel 화면 픽셀을 선택하거나 Esc를 눌러 취소
|
||||
Scroll to zoom 스크롤하여 확대/축소
|
||||
Show/Hide All Panels 모든 패널 표시/숨기기
|
||||
Show/Hide Panels 패널 표시/숨기기
|
||||
Could not open 열 수 없음
|
||||
|
||||
|
@@ -3189,6 +3189,14 @@ cameraRaw Roughness Chropowatość
|
||||
% of all commits % wszystkich commitów
|
||||
Lines +/− Wiersze +/−
|
||||
@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{binAdded} / −{binDeleted} binary assets, {first} – {last} @{login}: PR: {prs}, commity: {commits}, wiersze +{added} / −{deleted} (Δ {delta}), pliki binarne +{binAdded} / −{binDeleted}, {first} – {last}
|
||||
|
||||
# Text Color Picker and screen sampling
|
||||
Color Picker (Text Color) Próbnik kolorów (Kolor tekstu)
|
||||
Pick screen color Wybierz kolor z ekranu
|
||||
Sample a pixel anywhere on the screen Pobierz kolor piksela w dowolnym miejscu ekranu
|
||||
Click to pick · Esc to cancel Kliknij, aby wybrać · Esc, aby anulować
|
||||
Pick a screen pixel or press Esc to cancel Wybierz piksel ekranu lub naciśnij Esc, aby anulować
|
||||
Scroll to zoom Przewijaj, aby zmieniać powiększenie
|
||||
Open in camera raw Otwórz w Camera Raw
|
||||
System title bar Systemowy pasek tytułu
|
||||
More documents Więcej dokumentów
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2605,6 +2605,14 @@ cameraRaw Whites Brancos
|
||||
Channels: Canais:
|
||||
Knockout Vazado
|
||||
Apply At Canvas Bounds Aplicar efeito nos limites da tela
|
||||
|
||||
# Shared screen eyedropper and text Color Picker (#1128).
|
||||
Color Picker (Text Color) Seletor de cores (Cor do texto)
|
||||
Pick screen color Escolher cor da tela
|
||||
Sample a pixel anywhere on the screen Amostrar um pixel em qualquer lugar da tela
|
||||
Click to pick · Esc to cancel Clique para escolher · Esc para cancelar
|
||||
Pick a screen pixel or press Esc to cancel Escolha um pixel da tela ou pressione Esc para cancelar
|
||||
Scroll to zoom Role para ampliar
|
||||
Show/Hide All Panels Mostrar/ocultar todos os painéis
|
||||
Show/Hide Panels Mostrar/ocultar painéis
|
||||
Could not open Não foi possível abrir
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2444,6 +2444,14 @@ cameraRaw Roughness Неровность
|
||||
Channels: Каналы:
|
||||
Knockout Просвечивание
|
||||
Apply At Canvas Bounds Применить эффект на границах холста
|
||||
|
||||
# Shared screen eyedropper and text Color Picker (#1128).
|
||||
Color Picker (Text Color) Палитра цветов (Цвет текста)
|
||||
Pick screen color Выбрать цвет на экране
|
||||
Sample a pixel anywhere on the screen Выберите пиксель в любом месте экрана
|
||||
Click to pick · Esc to cancel Щелчок — выбрать · Esc — отменить
|
||||
Pick a screen pixel or press Esc to cancel Выберите пиксель на экране или нажмите Esc для отмены
|
||||
Scroll to zoom Колесо — изменить масштаб
|
||||
Show/Hide All Panels Показать/скрыть все панели
|
||||
Show/Hide Panels Показать/скрыть панели
|
||||
Could not open Не удалось открыть
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2175,6 +2175,14 @@ cameraRaw Blending 混合
|
||||
Channels: 通道:
|
||||
Knockout 挖空
|
||||
Apply At Canvas Bounds 应用画布边界的效果
|
||||
|
||||
# Shared screen eyedropper and text Color Picker (#1128).
|
||||
Color Picker (Text Color) 拾色器(文字颜色)
|
||||
Pick screen color 选取屏幕颜色
|
||||
Sample a pixel anywhere on the screen 在屏幕任意位置采样像素
|
||||
Click to pick · Esc to cancel 单击选取 · Esc 取消
|
||||
Pick a screen pixel or press Esc to cancel 选取屏幕像素或按 Esc 取消
|
||||
Scroll to zoom 滚动以缩放
|
||||
Show/Hide All Panels 显示/隐藏所有面板
|
||||
Show/Hide Panels 显示/隐藏面板
|
||||
Could not open 无法打开
|
||||
|
||||
|
@@ -2144,6 +2144,14 @@ cameraRaw Blending 混合
|
||||
Channels: 色版:
|
||||
Knockout 去底色
|
||||
Apply At Canvas Bounds 套用效果至版面邊界
|
||||
|
||||
# Shared screen eyedropper and text Color Picker (#1128).
|
||||
Color Picker (Text Color) 檢色器(文字顏色)
|
||||
Pick screen color 選取螢幕顏色
|
||||
Sample a pixel anywhere on the screen 在螢幕任意位置取樣像素
|
||||
Click to pick · Esc to cancel 按一下選取 · Esc 取消
|
||||
Pick a screen pixel or press Esc to cancel 選取螢幕像素或按 Esc 取消
|
||||
Scroll to zoom 捲動以縮放
|
||||
Show/Hide All Panels 顯示/隱藏所有面板
|
||||
Show/Hide Panels 顯示/隱藏面板
|
||||
Could not open 無法開啟
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -912,7 +912,7 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
|
||||
ui.label(RichText::new(tl!(&label)).color(t.text_dim));
|
||||
let hexs = disp.get(key).and_then(Value::as_str).unwrap_or("#000000").to_string();
|
||||
let mut c = parse_hex(&hexs);
|
||||
if ui.color_edit_button_srgba(&mut c).changed() {
|
||||
if crate::widgets::color_edit_button_srgba(ui, &mut c).changed() {
|
||||
let value = json!(format!("#{r:02x}{g:02x}{b:02x}", r = c.r(), g = c.g(), b = c.b()));
|
||||
disp[key] = value.clone();
|
||||
set_param(f, &selected, key, value);
|
||||
|
||||
@@ -112,6 +112,7 @@ pub mod rasterize_prompt;
|
||||
pub mod retouch_ui;
|
||||
mod rgb_histogram;
|
||||
pub mod rulers;
|
||||
pub mod screen_picker;
|
||||
pub mod scrollbars;
|
||||
pub mod shortcut_dispatch;
|
||||
pub mod shortcuts;
|
||||
@@ -236,6 +237,8 @@ pub type CursorPosFn = Box<dyn FnMut(&egui::Context) -> Option<egui::Pos2>>;
|
||||
/// I/O dependencies.
|
||||
#[derive(Default)]
|
||||
pub struct Services {
|
||||
/// User-initiated desktop/browser pixel sampling.
|
||||
pub screen_pick: Option<screen_picker::Service>,
|
||||
/// Decode a file's bytes into a document (PSD, PNG, JPEG, …).
|
||||
pub import: Option<ImportFn>,
|
||||
/// Encode a document for a file name (format chosen by extension).
|
||||
@@ -1052,8 +1055,10 @@ impl eframe::App for PhotocraftApp {
|
||||
discard_ui::guard_window_close(self, ctx);
|
||||
// Background jobs: apply finished ones, keep frames coming, Esc cancels (before the
|
||||
// shortcuts see Esc).
|
||||
jobs_ui::tick(self, ctx);
|
||||
shortcuts::handle(self, ctx);
|
||||
if !screen_picker::tick(self, ctx) {
|
||||
jobs_ui::tick(self, ctx);
|
||||
shortcuts::handle(self, ctx);
|
||||
}
|
||||
let arrived: Vec<(String, Vec<u8>)> =
|
||||
self.services.inbox.as_ref().map(|q| std::mem::take(&mut *q.lock().unwrap_or_else(|e| e.into_inner()))).unwrap_or_default();
|
||||
for (name, bytes) in arrived {
|
||||
@@ -1106,6 +1111,22 @@ impl eframe::App for PhotocraftApp {
|
||||
}
|
||||
let t0 = gpu_canvas::now_ms();
|
||||
// View › Screen Mode › Full Screen Mode: only the image, on black (F or Esc returns).
|
||||
screen_picker::show(&ctx);
|
||||
if screen_picker::busy(&ctx) && screen_picker::showing(&ctx) {
|
||||
egui::Modal::new(egui::Id::new("screen-color-wait")).show(&ctx, |ui| {
|
||||
ui.spinner();
|
||||
ui.label(tl!("Pick a screen pixel or press Esc to cancel"));
|
||||
if ui.button(tl!("Cancel")).clicked() {
|
||||
screen_picker::cancel(&ctx);
|
||||
}
|
||||
});
|
||||
self.automation_input = false;
|
||||
return;
|
||||
}
|
||||
if screen_picker::busy(&ctx) {
|
||||
ui.disable();
|
||||
ui.set_opacity(1.0);
|
||||
}
|
||||
let chrome = !self.ui.view.hides_chrome();
|
||||
if !chrome && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
|
||||
let _ = menus::invoke(self, &ctx, "view.screenMode.standard", serde_json::json!({}));
|
||||
@@ -1155,6 +1176,11 @@ impl eframe::App for PhotocraftApp {
|
||||
gpu_status::check(self, &ctx);
|
||||
self.automation_input = false;
|
||||
native_menu::sync(self, &ctx);
|
||||
if screen_picker::busy(&ctx) {
|
||||
// Block input without a backdrop: the worker may still be capturing the screen.
|
||||
egui::Modal::new(egui::Id::new("screen-color-wait")).frame(egui::Frame::NONE).backdrop_color(egui::Color32::TRANSPARENT).show(&ctx, |_| {});
|
||||
ctx.set_cursor_icon(egui::CursorIcon::Wait);
|
||||
}
|
||||
self.perf.frame(gpu_canvas::now_ms() - t0);
|
||||
// Synthetic input is injected one press/release step per frame: keep frames coming until
|
||||
// the queue is empty, then release control replies waiting on it.
|
||||
|
||||
@@ -1014,7 +1014,7 @@ fn section_fields(ui: &mut egui::Ui, section: &str, obj: &mut Map<String, Value>
|
||||
let c = prefs::parse_hex(s).unwrap_or([128, 128, 128]);
|
||||
let mut rgb = c;
|
||||
ui.horizontal(|ui| {
|
||||
ui.color_edit_button_srgb(&mut rgb);
|
||||
crate::widgets::color_edit_button_srgb(ui, &mut rgb);
|
||||
hex_field(ui, &path, &mut rgb);
|
||||
});
|
||||
obj.insert(k, json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2])));
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
//! Explicit, one-shot desktop sampling. Snapshots remain in memory until selection/cancellation.
|
||||
use crate::PhotocraftApp;
|
||||
use egui::{Color32, Context, Id, Pos2, Rect, Sense, Stroke, StrokeKind, TextureOptions, vec2};
|
||||
use std::sync::{
|
||||
Arc, Mutex,
|
||||
mpsc::{Receiver, TryRecvError},
|
||||
};
|
||||
|
||||
/// Display-encoded RGBA8 pixels, native window points (before UI zoom), and optional source ICC.
|
||||
#[derive(Clone)]
|
||||
pub struct ScreenImage {
|
||||
/// Native monitor index (winit order); places the picker on that exact output.
|
||||
pub monitor: Option<usize>,
|
||||
pub position: Pos2,
|
||||
pub size: egui::Vec2,
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
pub rgba: Vec<u8>,
|
||||
pub profile: Option<Vec<u8>>,
|
||||
}
|
||||
pub enum Capture {
|
||||
Images(Vec<ScreenImage>),
|
||||
/// Native portal/browser pickers return sRGB; None means cancellation.
|
||||
Color(Option<[f32; 3]>),
|
||||
}
|
||||
/// Dropping a pending request also cancels portal/browser work.
|
||||
pub struct Pending {
|
||||
pub receiver: Receiver<Result<Capture, String>>,
|
||||
pub cancelled: Arc<std::sync::atomic::AtomicBool>,
|
||||
}
|
||||
impl Drop for Pending {
|
||||
fn drop(&mut self) {
|
||||
self.cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
pub type Service = Box<dyn FnMut(&Context) -> Pending>;
|
||||
pub const MAX_PIXELS: usize = 64 * 1024 * 1024;
|
||||
impl ScreenImage {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
let count = self.width.checked_mul(self.height).filter(|&n| n > 0 && n <= MAX_PIXELS).ok_or("Screen image is too large or empty")?;
|
||||
if count.checked_mul(4) != Some(self.rgba.len())
|
||||
|| !self.position.is_finite()
|
||||
|| !self.size.is_finite()
|
||||
|| self.size.min_elem() <= 0.0
|
||||
|| self.size.max_elem() > 65535.0
|
||||
|| self.position.to_vec2().abs().max_elem() > 1_000_000.0
|
||||
|| self.profile.as_ref().is_some_and(|p| p.len() > 1024 * 1024)
|
||||
{
|
||||
return Err("Invalid screen image geometry".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn pixel(&self, x: usize, y: usize) -> Option<Color32> {
|
||||
if x >= self.width || y >= self.height {
|
||||
return None;
|
||||
}
|
||||
let start = y.checked_mul(self.width)?.checked_add(x)?.checked_mul(4)?;
|
||||
let p = self.rgba.get(start..start.checked_add(4)?)?;
|
||||
Some(Color32::from_rgb(*p.first()?, *p.get(1)?, *p.get(2)?))
|
||||
}
|
||||
fn at(&self, rect: Rect, pos: Pos2) -> Option<(usize, usize)> {
|
||||
if !rect.contains(pos) || !pos.is_finite() || rect.size().min_elem() <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let p = (pos - rect.min) / rect.size();
|
||||
Some((((p.x * self.width as f32) as usize).min(self.width.saturating_sub(1)), ((p.y * self.height as f32) as usize).min(self.height.saturating_sub(1))))
|
||||
}
|
||||
fn conversion(&self) -> Result<Option<Arc<photocraft_cms::Transform>>, String> {
|
||||
let Some(bytes) = &self.profile else { return Ok(None) };
|
||||
let source = photocraft_cms::Profile::parse(bytes).map_err(|e| e.to_string())?;
|
||||
if source.color_space != photocraft_cms::ColorSpace::Rgb {
|
||||
return Err("Screen profile must describe RGB pixels".into());
|
||||
}
|
||||
photocraft_cms::Transform::new(&source, photocraft_cms::Builtin::Srgb.profile(), photocraft_cms::Intent::RelativeColorimetric, true)
|
||||
.map(|t| Some(Arc::new(t)))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
fn sample(&self, x: usize, y: usize, conversion: Option<&photocraft_cms::Transform>) -> Result<[f32; 3], String> {
|
||||
let p = self.pixel(x, y).ok_or("Screen pixel is out of bounds")?;
|
||||
let mut rgb = [f32::from(p.r()) / 255.0, f32::from(p.g()) / 255.0, f32::from(p.b()) / 255.0];
|
||||
if let Some(t) = conversion {
|
||||
t.apply(&mut rgb, 3);
|
||||
}
|
||||
Ok(rgb.map(|v| v.clamp(0.0, 1.0)))
|
||||
}
|
||||
}
|
||||
#[derive(Clone)]
|
||||
struct Snapshot {
|
||||
image: Arc<ScreenImage>,
|
||||
texture: egui::TextureHandle,
|
||||
conversion: Option<Arc<photocraft_cms::Transform>>,
|
||||
}
|
||||
type Target = (Option<photocraft_doc::DocId>, Option<photocraft_doc::LayerId>, Option<u64>);
|
||||
#[derive(Default)]
|
||||
struct Runtime {
|
||||
request: Option<Id>,
|
||||
owner: Option<Id>,
|
||||
pending: Option<Pending>,
|
||||
images: Vec<Snapshot>,
|
||||
answer: Option<Result<Option<[f32; 3]>, String>>,
|
||||
completed: Option<(Id, [f32; 3])>,
|
||||
zoom: usize,
|
||||
generation: u64,
|
||||
target: Option<Target>,
|
||||
}
|
||||
type Shared = Arc<Mutex<Runtime>>;
|
||||
fn runtime(ctx: &Context) -> Shared {
|
||||
let id = Id::new("screen-color-picker-runtime");
|
||||
ctx.data_mut(|d| {
|
||||
if let Some(r) = d.get_temp::<Shared>(id) {
|
||||
return r;
|
||||
}
|
||||
let r = Arc::new(Mutex::new(Runtime::default()));
|
||||
d.insert_temp(id, r.clone());
|
||||
r
|
||||
})
|
||||
}
|
||||
fn lock(r: &Shared) -> std::sync::MutexGuard<'_, Runtime> {
|
||||
r.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
/// Consume a result only at its initiating widget, including when its compact popup closed.
|
||||
pub fn take(ctx: &Context, id: Id) -> Option<[f32; 3]> {
|
||||
let r = runtime(ctx);
|
||||
let mut s = lock(&r);
|
||||
if s.completed.as_ref().is_some_and(|(owner, _)| *owner == id) { s.completed.take().map(|(_, rgb)| rgb) } else { None }
|
||||
}
|
||||
/// Same action for full dialogs and compact swatches. Cancellation returns no change.
|
||||
pub fn button(ui: &mut egui::Ui, id: Id) -> Option<[f32; 3]> {
|
||||
let r = runtime(ui.ctx());
|
||||
let available = ui.ctx().data(|d| d.get_temp::<bool>(Id::new("screen-color-picker-available"))).unwrap_or(false);
|
||||
let mut state = lock(&r);
|
||||
let result = if state.completed.as_ref().is_some_and(|(owner, _)| *owner == id) { state.completed.take().map(|(_, rgb)| rgb) } else { None };
|
||||
let busy = state.owner.is_some() || state.request.is_some();
|
||||
if ui.add_enabled(available && !busy, egui::Button::new(tl!("Pick screen color"))).on_hover_text(tl!("Sample a pixel anywhere on the screen")).clicked() {
|
||||
state.request = Some(id);
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
result
|
||||
}
|
||||
fn target(app: &PhotocraftApp) -> Target {
|
||||
(app.session.active().map(|s| s.doc.id), app.session.active().and_then(|s| s.active_layer), app.ui.dialogs.last().map(|d| d.id))
|
||||
}
|
||||
/// Poll before shortcuts. A changed editing target invalidates a pending pick.
|
||||
pub fn tick(app: &mut PhotocraftApp, ctx: &Context) -> bool {
|
||||
ctx.data_mut(|d| d.insert_temp(Id::new("screen-color-picker-available"), app.services.screen_pick.is_some()));
|
||||
let r = runtime(ctx);
|
||||
let mut s = lock(&r);
|
||||
if s.completed.is_some() && s.target != Some(target(app)) {
|
||||
s.completed = None;
|
||||
}
|
||||
if let Some(id) = s.request.take() {
|
||||
s.completed = None;
|
||||
if let Some(service) = &mut app.services.screen_pick {
|
||||
s.pending = Some(service(ctx));
|
||||
s.owner = Some(id);
|
||||
s.target = Some(target(app));
|
||||
s.zoom = 12;
|
||||
s.generation = s.generation.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
if s.owner.is_some() {
|
||||
if s.target != Some(target(app))
|
||||
|| ctx.input(|i| i.viewport().close_requested())
|
||||
|| ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape))
|
||||
{
|
||||
s.answer = Some(Ok(None));
|
||||
}
|
||||
if let Some(pending) = s.pending.as_ref().filter(|_| s.answer.is_none()) {
|
||||
match pending.receiver.try_recv() {
|
||||
Ok(Ok(Capture::Color(c))) => {
|
||||
s.pending = None;
|
||||
s.answer = Some(Ok(c));
|
||||
}
|
||||
Ok(Ok(Capture::Images(images))) => {
|
||||
s.pending = None;
|
||||
let total = images.iter().try_fold(0usize, |n, i| n.checked_add(i.width.checked_mul(i.height)?));
|
||||
let valid =
|
||||
!images.is_empty() && images.len() <= 16 && total.is_some_and(|n| n <= MAX_PIXELS) && images.iter().all(|i| i.validate().is_ok());
|
||||
if valid {
|
||||
let max = ctx.input(|i| i.max_texture_side);
|
||||
if images.iter().any(|i| i.width > max || i.height > max) {
|
||||
s.answer = Some(Err("This display exceeds the GPU's screen picker texture limit".into()));
|
||||
} else {
|
||||
s.images = images
|
||||
.into_iter()
|
||||
.map(|i| {
|
||||
let conversion = i.conversion()?;
|
||||
let texture = ctx.load_texture(
|
||||
"screen-color-snapshot",
|
||||
egui::ColorImage::from_rgba_unmultiplied([i.width, i.height], &i.rgba),
|
||||
TextureOptions::NEAREST,
|
||||
);
|
||||
Ok(Snapshot { image: Arc::new(i), texture, conversion })
|
||||
})
|
||||
.collect::<Result<Vec<_>, String>>()
|
||||
.unwrap_or_else(|e| {
|
||||
s.answer = Some(Err(e));
|
||||
Vec::new()
|
||||
});
|
||||
}
|
||||
} else {
|
||||
s.answer = Some(Err("Invalid or oversized screen capture".into()));
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
s.pending = None;
|
||||
s.answer = Some(Err(e));
|
||||
}
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
s.pending = None;
|
||||
s.answer = Some(Err("Screen color picker disconnected".into()));
|
||||
}
|
||||
Err(TryRecvError::Empty) => {}
|
||||
}
|
||||
}
|
||||
if let Some(answer) = s.answer.take() {
|
||||
let owner = s.owner.take();
|
||||
s.pending = None;
|
||||
for n in 0..s.images.len() {
|
||||
ctx.send_viewport_cmd_to(egui::ViewportId::from_hash_of(("screen-color", s.generation, n)), egui::ViewportCommand::Close);
|
||||
}
|
||||
s.images.clear();
|
||||
match answer {
|
||||
Ok(Some(rgb)) if rgb.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v)) => s.completed = owner.map(|id| (id, rgb)),
|
||||
Err(e) => {
|
||||
app.ui.status = e;
|
||||
app.ui.status_error = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let busy = s.owner.is_some();
|
||||
if busy {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(16));
|
||||
}
|
||||
busy
|
||||
}
|
||||
pub fn showing(ctx: &Context) -> bool {
|
||||
let r = runtime(ctx);
|
||||
!lock(&r).images.is_empty()
|
||||
}
|
||||
pub fn busy(ctx: &Context) -> bool {
|
||||
let r = runtime(ctx);
|
||||
let s = lock(&r);
|
||||
s.owner.is_some() || s.request.is_some()
|
||||
}
|
||||
pub fn cancel(ctx: &Context) {
|
||||
let r = runtime(ctx);
|
||||
lock(&r).answer = Some(Ok(None));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
pub fn show(ctx: &Context) {
|
||||
let r = runtime(ctx);
|
||||
let s = lock(&r);
|
||||
let images = s.images.clone();
|
||||
let generation = s.generation;
|
||||
drop(s);
|
||||
for (n, snapshot) in images.into_iter().enumerate() {
|
||||
let Snapshot { image, texture, conversion } = snapshot;
|
||||
let shared = r.clone();
|
||||
let vid = egui::ViewportId::from_hash_of(("screen-color", generation, n));
|
||||
let mut builder = egui::ViewportBuilder::default()
|
||||
.with_title("PhotoCraft — Screen Color Picker")
|
||||
.with_decorations(false)
|
||||
.with_resizable(false)
|
||||
.with_position(image.position / ctx.zoom_factor())
|
||||
.with_inner_size(image.size / ctx.zoom_factor())
|
||||
.with_window_level(egui::WindowLevel::AlwaysOnTop)
|
||||
.with_clamp_size_to_monitor_size(false);
|
||||
if let Some(monitor) = image.monitor {
|
||||
builder = builder.with_monitor(monitor);
|
||||
}
|
||||
ctx.show_viewport_deferred(vid, builder, move |ui, _| {
|
||||
{
|
||||
let s = lock(&shared);
|
||||
if s.generation != generation || s.owner.is_none() {
|
||||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if ui.ctx().input(|i| i.viewport().close_requested() || i.key_pressed(egui::Key::Escape)) {
|
||||
lock(&shared).answer = Some(Ok(None));
|
||||
ui.ctx().request_repaint_of(egui::ViewportId::ROOT);
|
||||
return;
|
||||
}
|
||||
egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| {
|
||||
let rect = ui.max_rect();
|
||||
ui.painter().image(texture.id(), rect, Rect::from_min_max(Pos2::ZERO, egui::pos2(1.0, 1.0)), Color32::WHITE);
|
||||
let response = ui.interact(rect, ui.id().with("screen-sample"), Sense::click());
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
let Some(pos) = ui.ctx().pointer_hover_pos() else {
|
||||
return;
|
||||
};
|
||||
let Some((x, y)) = image.at(rect, pos) else {
|
||||
return;
|
||||
};
|
||||
let zoom = {
|
||||
let delta = ui.input(|i| {
|
||||
i.raw
|
||||
.events
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
egui::Event::MouseWheel { delta, .. } => Some(delta.y),
|
||||
_ => None,
|
||||
})
|
||||
.sum::<f32>()
|
||||
});
|
||||
let mut s = lock(&shared);
|
||||
if delta > 0.0 {
|
||||
s.zoom = (s.zoom + 2).min(24);
|
||||
}
|
||||
if delta < 0.0 {
|
||||
s.zoom = s.zoom.saturating_sub(2).max(4);
|
||||
}
|
||||
s.zoom
|
||||
};
|
||||
let picked = image.sample(x, y, conversion.as_deref());
|
||||
loupe(ui, &image, rect, pos, (x, y), zoom, picked.as_ref().ok().copied());
|
||||
if response.clicked() {
|
||||
lock(&shared).answer = Some(picked.map(Some));
|
||||
ui.ctx().request_repaint_of(egui::ViewportId::ROOT);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
fn loupe(ui: &egui::Ui, image: &ScreenImage, screen: Rect, pos: Pos2, pixel: (usize, usize), zoom: usize, rgb: Option<[f32; 3]>) {
|
||||
let (x, y) = pixel;
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
// The label describes physical pixel magnification, including Retina and UI zoom.
|
||||
let cell = zoom as f32 / ui.ctx().pixels_per_point();
|
||||
let p = ui.painter();
|
||||
let pixel = image.pixel(x, y).unwrap_or(Color32::BLACK);
|
||||
let color = rgb.map(crate::color_picker_ui::hex).unwrap_or_else(|| format!("#{:02x}{:02x}{:02x}", pixel.r(), pixel.g(), pixel.b()));
|
||||
let label = p.layout_no_wrap(format!("{} {}×", color.to_uppercase(), zoom), egui::FontId::monospace(12.0), t.text);
|
||||
let hint = p.layout_no_wrap(tl!("Click to pick · Esc to cancel").to_owned(), egui::FontId::proportional(11.0), t.text);
|
||||
let wheel = p.layout_no_wrap(tl!("Scroll to zoom").to_owned(), egui::FontId::proportional(11.0), t.text);
|
||||
let width = (11.0 * cell + 12.0).max(label.size().x + 16.0).max(hint.size().x + 16.0).max(wheel.size().x + 16.0);
|
||||
let size = vec2(width, 11.0 * cell + 76.0);
|
||||
let desired = pos + vec2(24.0, 24.0);
|
||||
let at = egui::pos2(desired.x.min(screen.right() - size.x).max(screen.left()), desired.y.min(screen.bottom() - size.y).max(screen.top()));
|
||||
let area = Rect::from_min_size(at, size);
|
||||
p.rect_filled(area, t.radius, t.card);
|
||||
p.rect_stroke(area, t.radius, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
|
||||
let origin = at + vec2((width - 11.0 * cell) / 2.0, 6.0);
|
||||
for row in 0..11usize {
|
||||
for col in 0..11usize {
|
||||
let px = x.checked_add(col).and_then(|v| v.checked_sub(5));
|
||||
let py = y.checked_add(row).and_then(|v| v.checked_sub(5));
|
||||
let color = px.zip(py).and_then(|(x, y)| image.pixel(x, y)).unwrap_or(t.canvas);
|
||||
p.rect_filled(Rect::from_min_size(origin + vec2(col as f32 * cell, row as f32 * cell), vec2(cell, cell)), 0.0, color);
|
||||
}
|
||||
}
|
||||
let center = Rect::from_min_size(origin + vec2(5.0 * cell, 5.0 * cell), vec2(cell, cell));
|
||||
p.rect_stroke(center.expand(1.0), 0.0, Stroke::new(1.0, Color32::BLACK), StrokeKind::Inside);
|
||||
p.rect_stroke(center, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Inside);
|
||||
let footer = at + vec2(8.0, 11.0 * cell + 14.0);
|
||||
p.galley(footer, label, t.text);
|
||||
p.galley(footer + vec2(0.0, 20.0), hint, t.text);
|
||||
p.galley(footer + vec2(0.0, 36.0), wheel, t.text);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn image() -> ScreenImage {
|
||||
ScreenImage {
|
||||
monitor: None,
|
||||
position: egui::pos2(-100.0, 0.0),
|
||||
size: vec2(2.0, 1.0),
|
||||
width: 2,
|
||||
height: 1,
|
||||
rgba: vec![255, 0, 0, 255, 0, 128, 255, 255],
|
||||
profile: None,
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn pixel_mapping_handles_display_edges_negative_origins_and_hidpi() {
|
||||
let i = image();
|
||||
let rect = Rect::from_min_size(i.position, i.size);
|
||||
assert_eq!(i.at(rect, egui::pos2(-100.0, 0.0)), Some((0, 0)));
|
||||
assert_eq!(i.at(rect, egui::pos2(-98.0, 1.0)), Some((1, 0)));
|
||||
assert_eq!(i.at(rect, egui::pos2(-97.0, 0.0)), None);
|
||||
assert_eq!(i.at(rect, egui::pos2(f32::NAN, 0.0)), None);
|
||||
let scaled = Rect::from_min_size(Pos2::ZERO, vec2(4.0, 2.0));
|
||||
assert_eq!(i.at(scaled, egui::pos2(2.5, 1.0)), Some((1, 0)));
|
||||
assert_eq!(i.pixel(2, 0), None);
|
||||
assert_eq!(i.sample(1, 0, i.conversion().unwrap().as_deref()).unwrap(), [0.0, 128.0 / 255.0, 1.0]);
|
||||
}
|
||||
#[test]
|
||||
fn malformed_images_and_non_rgb_profiles_fail_before_sampling() {
|
||||
let mut i = image();
|
||||
assert!(i.validate().is_ok());
|
||||
i.rgba.pop();
|
||||
assert!(i.validate().is_err());
|
||||
i.width = usize::MAX;
|
||||
i.height = 2;
|
||||
assert!(i.validate().is_err());
|
||||
let mut i = image();
|
||||
i.size.x = f32::NAN;
|
||||
assert!(i.validate().is_err());
|
||||
let mut i = image();
|
||||
i.profile = Some(photocraft_cms::Builtin::LabD50.profile().to_bytes().as_ref().clone());
|
||||
assert!(i.conversion().is_err());
|
||||
}
|
||||
fn app() -> PhotocraftApp {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", serde_json::json!({"width":8,"height":8})).unwrap();
|
||||
app
|
||||
}
|
||||
fn queued(app: &mut PhotocraftApp, ctx: &Context) -> (Id, std::sync::mpsc::Sender<Result<Capture, String>>, Arc<std::sync::atomic::AtomicBool>) {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let stop = cancelled.clone();
|
||||
let mut receiver = Some(rx);
|
||||
app.services.screen_pick = Some(Box::new(move |_| Pending { receiver: receiver.take().unwrap(), cancelled: stop.clone() }));
|
||||
let owner = Id::new("test-destination");
|
||||
lock(&runtime(ctx)).request = Some(owner);
|
||||
assert!(tick(app, ctx));
|
||||
(owner, tx, cancelled)
|
||||
}
|
||||
#[test]
|
||||
fn cancellation_wins_over_a_simultaneously_completed_pick() {
|
||||
let mut app = app();
|
||||
let ctx = Context::default();
|
||||
let (id, tx, cancelled) = queued(&mut app, &ctx);
|
||||
tx.send(Ok(Capture::Color(Some([1.0, 0.0, 0.0])))).unwrap();
|
||||
cancel(&ctx);
|
||||
assert!(!tick(&mut app, &ctx));
|
||||
assert_eq!(take(&ctx, id), None);
|
||||
assert!(cancelled.load(std::sync::atomic::Ordering::Relaxed));
|
||||
}
|
||||
#[test]
|
||||
fn changed_document_discards_the_pending_result_and_replies_route_once() {
|
||||
let mut app = app();
|
||||
let ctx = Context::default();
|
||||
let (id, tx, _) = queued(&mut app, &ctx);
|
||||
tx.send(Ok(Capture::Color(Some([1.0, 0.0, 0.0])))).unwrap();
|
||||
app.run("file.new", serde_json::json!({"width":8,"height":8})).unwrap();
|
||||
assert!(!tick(&mut app, &ctx));
|
||||
assert_eq!(take(&ctx, id), None);
|
||||
let (id, tx, _) = queued(&mut app, &ctx);
|
||||
tx.send(Ok(Capture::Color(Some([0.25, 0.5, 1.0])))).unwrap();
|
||||
assert!(!tick(&mut app, &ctx));
|
||||
assert_eq!(take(&ctx, Id::new("different-widget")), None);
|
||||
assert_eq!(take(&ctx, id), Some([0.25, 0.5, 1.0]));
|
||||
assert_eq!(take(&ctx, id), None);
|
||||
}
|
||||
#[test]
|
||||
fn disconnected_service_and_invalid_portal_colors_leave_the_document_unchanged() {
|
||||
let mut app = app();
|
||||
let ctx = Context::default();
|
||||
let foreground = app.session.tools.foreground;
|
||||
let (id, tx, _) = queued(&mut app, &ctx);
|
||||
drop(tx);
|
||||
assert!(!tick(&mut app, &ctx));
|
||||
assert!(app.ui.status_error);
|
||||
assert_eq!(take(&ctx, id), None);
|
||||
let (id, tx, _) = queued(&mut app, &ctx);
|
||||
tx.send(Ok(Capture::Color(Some([f32::NAN, 0.0, 1.0])))).unwrap();
|
||||
assert!(!tick(&mut app, &ctx));
|
||||
assert_eq!(take(&ctx, id), None);
|
||||
assert_eq!(app.session.tools.foreground, foreground);
|
||||
}
|
||||
}
|
||||
@@ -411,7 +411,7 @@ fn options_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, paragraph: bool, s
|
||||
// Style colours are stored encoded (like the Color panel's hex), not linear.
|
||||
let h = |f: f32| (f.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
let mut rgb = c.map(h);
|
||||
if ui.color_edit_button_srgb(&mut rgb).changed() {
|
||||
if crate::widgets::color_edit_button_srgb(ui, &mut rgb).changed() {
|
||||
set = Some(json!({ "color": format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]) }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -830,6 +830,23 @@ fn apply(app: &mut PhotocraftApp, ctx: &egui::Context, props: serde_json::Value)
|
||||
let _ = app.run("type.setStyle", p);
|
||||
}
|
||||
|
||||
/// Open the shared Color Picker with a snapshot of the text target. Sampling or cancelling
|
||||
/// the dialog never changes the text selection, tool colours, or document history.
|
||||
pub fn open_color_picker(app: &mut PhotocraftApp, rgb: [f32; 3]) -> u64 {
|
||||
if let Some((layer, range)) = target(app) {
|
||||
let mut params = json!({"layer": layer});
|
||||
if let Some(range) = range {
|
||||
params["range"] = json!(range);
|
||||
}
|
||||
if let Some(ed) = &app.ui.text_edit {
|
||||
params["coalesce"] = json!(ed.session);
|
||||
}
|
||||
crate::color_picker_ui::open_for_command(app, "Color Picker (Text Color)", rgb, "type.setStyle", params)
|
||||
} else {
|
||||
crate::color_picker_ui::open(app, "foreground")
|
||||
}
|
||||
}
|
||||
|
||||
/// While characters are selected in a type layer, a new foreground colour (Color and Swatches
|
||||
/// panels, the Color Picker) recolours them, in the editing session's history step.
|
||||
pub fn foreground_changed(app: &mut PhotocraftApp) {
|
||||
@@ -849,7 +866,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
let run = tl.char_runs().into_iter().next().map(|r| r.style);
|
||||
// The size at the selection (else the first run), as the layer shows it (#124).
|
||||
let size = styles_at(app).map_or(tl.size_pt, |(c, _)| c.size_pt) * shown_scale(app);
|
||||
Some((tl.font_family.clone(), run.as_ref().map(|s| s.font_style.clone()).unwrap_or_default(), size, tl.color))
|
||||
Some((tl.font_family.clone(), run.as_ref().map(|s| s.font_style.clone()).unwrap_or_default(), size, styles_at(app).map_or(tl.color, |(c, _)| c.color)))
|
||||
});
|
||||
let o = app.ui.tool_options.clone();
|
||||
let (mut fam, mut style, mut size) = match &shown {
|
||||
@@ -929,12 +946,10 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
ui.painter().rect_filled(rect, 2.0, Color32::from_rgb(q(c[0]), q(c[1]), q(c[2])));
|
||||
ui.painter().rect_stroke(rect, 2.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Outside);
|
||||
let resp = resp.on_hover_text(tl!("Set the text color"));
|
||||
crate::widgets::swatch_popup(&resp).show(|ui| {
|
||||
let mut col = Color32::from_rgb(q(c[0]), q(c[1]), q(c[2]));
|
||||
if egui::color_picker::color_picker_color32(ui, &mut col, egui::color_picker::Alpha::Opaque) {
|
||||
apply(app, ui.ctx(), json!({"color": format!("#{:02x}{:02x}{:02x}", col.r(), col.g(), col.b())}));
|
||||
}
|
||||
});
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Set the text color")));
|
||||
if resp.clicked() {
|
||||
open_color_picker(app, [c[0], c[1], c[2]]);
|
||||
}
|
||||
if app.ui.text_edit.is_some() {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.add_space(8.0);
|
||||
@@ -1234,12 +1249,10 @@ fn type_sections(app: &mut PhotocraftApp, ui: &mut egui::Ui, character: bool, pa
|
||||
ui.painter().rect_filled(rect, t.radius_sm, Color32::from_rgb(q(rgb[0]), q(rgb[1]), q(rgb[2])));
|
||||
ui.painter().rect_stroke(rect, t.radius_sm, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside);
|
||||
let resp = resp.on_hover_text(tl!("Text color"));
|
||||
crate::widgets::swatch_popup(&resp).show(|ui| {
|
||||
let mut col = Color32::from_rgb(q(rgb[0]), q(rgb[1]), q(rgb[2]));
|
||||
if egui::color_picker::color_picker_color32(ui, &mut col, egui::color_picker::Alpha::Opaque) {
|
||||
apply(app, ui.ctx(), json!({"color": format!("#{:02x}{:02x}{:02x}", col.r(), col.g(), col.b())}));
|
||||
}
|
||||
});
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Text color")));
|
||||
if resp.clicked() {
|
||||
open_color_picker(app, rgb);
|
||||
}
|
||||
});
|
||||
});
|
||||
// Faux styles: T (bold) T (italic) TT Tᴛ T̲ T̶, sharing the row evenly.
|
||||
|
||||
@@ -459,6 +459,57 @@ fn command_t_while_typing_toggles_the_character_panel() {
|
||||
assert_ne!(crate::view_cmds::checked(h.state(), "window.panel.character"), before, "the Character panel toggled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_color_dialog_edits_only_the_selected_range_and_cancel_is_inert() {
|
||||
let mut app = new_app();
|
||||
let id = LayerId(app.run("type.create", json!({"text":"Hello world","size":40,"color":"#000000"})).unwrap()["layer"].as_u64().unwrap());
|
||||
app.ui.text_edit = Some(crate::state::TextEdit {
|
||||
layer: id.0,
|
||||
caret: 11,
|
||||
anchor: 6,
|
||||
session: "text-color-dialog-test".into(),
|
||||
created: false,
|
||||
dragging: false,
|
||||
resize: None,
|
||||
preedit: None,
|
||||
});
|
||||
let foreground = app.session.tools.foreground;
|
||||
let before = app.session.active().unwrap().history.entries().len();
|
||||
let selection = app.ui.text_edit.clone();
|
||||
let dialog = super::open_color_picker(&mut app, [0.0; 3]);
|
||||
app.ui.dialogs.last_mut().unwrap().fields.insert("color".into(), json!("#ff0000"));
|
||||
app.ui.close_dialog(dialog).unwrap();
|
||||
assert_eq!(rgb_at(&app, id, 6), [0, 0, 0, 255]);
|
||||
assert_eq!(app.session.active().unwrap().history.entries().len(), before);
|
||||
assert_eq!(app.ui.text_edit, selection);
|
||||
let dialog = super::open_color_picker(&mut app, [0.0; 3]);
|
||||
assert_eq!(app.ui.dialogs.last().unwrap().fields["__label"], "Color Picker (Text Color)");
|
||||
app.ui.dialogs.last_mut().unwrap().fields.insert("color".into(), json!("#00ff00"));
|
||||
crate::dialogs::confirm(&mut app, dialog).unwrap();
|
||||
assert_eq!(rgb_at(&app, id, 0), [0, 0, 0, 255]);
|
||||
assert_eq!(rgb_at(&app, id, 5), [0, 0, 0, 255]);
|
||||
assert_eq!(rgb_at(&app, id, 6), [0, 255, 0, 255]);
|
||||
assert_eq!(rgb_at(&app, id, 10), [0, 255, 0, 255]);
|
||||
assert_eq!(app.session.tools.foreground, foreground, "text-only picker leaves the foreground alone");
|
||||
assert_eq!(app.ui.text_edit, selection, "dialog preserves the caret and selection");
|
||||
assert_eq!(app.session.active().unwrap().history.entries().len(), before + 1);
|
||||
let dialog = super::open_color_picker(&mut app, [0.0, 1.0, 0.0]);
|
||||
app.ui.dialogs.last_mut().unwrap().fields.insert("color".into(), json!("#0000ff"));
|
||||
crate::dialogs::confirm(&mut app, dialog).unwrap();
|
||||
assert_eq!(app.session.active().unwrap().history.entries().len(), before + 1, "one editing-session undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_color_dialog_without_a_selection_edits_the_whole_layer() {
|
||||
let mut app = new_app();
|
||||
let id = LayerId(app.run("type.create", json!({"text":"Hello","color":"#000000"})).unwrap()["layer"].as_u64().unwrap());
|
||||
let dialog = super::open_color_picker(&mut app, [0.0; 3]);
|
||||
app.ui.dialogs.last_mut().unwrap().fields.insert("color".into(), json!("#ff8800"));
|
||||
crate::dialogs::confirm(&mut app, dialog).unwrap();
|
||||
assert_eq!(rgb_at(&app, id, 0), [255, 136, 0, 255]);
|
||||
assert_eq!(rgb_at(&app, id, 4), [255, 136, 0, 255]);
|
||||
}
|
||||
|
||||
/// #1381: on Wayland the input method sends an empty preedit (and sometimes an empty commit)
|
||||
/// whenever the caret rectangle moves, e.g. while ⌘A, ⇧-arrows or a drag select text. With
|
||||
/// nothing being composed that must leave the text and the selection alone; a real composition
|
||||
|
||||
@@ -589,12 +589,16 @@ fn swatch(ui: &mut egui::Ui, fill: Option<&photocraft_doc::Fill>, tip: &str) ->
|
||||
};
|
||||
ui.painter().rect_stroke(r, 2.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Outside);
|
||||
let resp = resp.on_hover_text(tip);
|
||||
let mut out = None;
|
||||
let screen_id = resp.id.with("screen-color");
|
||||
let mut out = crate::screen_picker::take(ui.ctx(), screen_id).map(crate::color_picker_ui::hex);
|
||||
crate::widgets::swatch_popup(&resp).show(|ui| {
|
||||
let mut c = current.unwrap_or(Color32::BLACK);
|
||||
if egui::color_picker::color_picker_color32(ui, &mut c, egui::color_picker::Alpha::Opaque) {
|
||||
out = Some(format!("#{:02x}{:02x}{:02x}", c.r(), c.g(), c.b()));
|
||||
}
|
||||
if let Some(rgb) = crate::screen_picker::button(ui, screen_id) {
|
||||
out = Some(crate::color_picker_ui::hex(rgb));
|
||||
}
|
||||
if fill.is_some() && ui.button(tl!("No Color")).clicked() {
|
||||
out = Some("none".into());
|
||||
}
|
||||
|
||||
@@ -1145,3 +1145,165 @@ mod tests {
|
||||
assert_eq!(*h.state(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// A compact colour popup with the shared desktop eyedropper. Keep egui's colour cache so hue
|
||||
/// survives black/white and alpha edits, and keep linear and encoded call sites distinct.
|
||||
pub fn color_edit_button_srgba(ui: &mut Ui, color: &mut Color32) -> Response {
|
||||
color_edit_button(ui, color, egui::color_picker::Alpha::BlendOrAdditive)
|
||||
}
|
||||
fn color_swatch(ui: &mut Ui, color: Color32) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, response) = ui.allocate_exact_size(ui.spacing().interact_size, Sense::click());
|
||||
response.widget_info(|| egui::WidgetInfo::new(egui::WidgetType::ColorButton));
|
||||
checker(ui.painter(), rect, 4.0);
|
||||
ui.painter().rect_filled(rect.shrink(1.0), t.radius_sm, color);
|
||||
ui.painter().rect_stroke(rect, t.radius_sm, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
|
||||
response
|
||||
}
|
||||
fn color_edit_button(ui: &mut Ui, color: &mut Color32, alpha: egui::color_picker::Alpha) -> Response {
|
||||
let mut response = color_swatch(ui, *color);
|
||||
let id = response.id.with("screen-color");
|
||||
if let Some(rgb) = crate::screen_picker::take(ui.ctx(), id) {
|
||||
*color = sampled_color(rgb, *color, alpha);
|
||||
response.mark_changed();
|
||||
}
|
||||
swatch_popup(&response).show(|ui| {
|
||||
if egui::color_picker::color_picker_color32(ui, color, alpha) {
|
||||
response.mark_changed();
|
||||
}
|
||||
if let Some(rgb) = crate::screen_picker::button(ui, id) {
|
||||
*color = sampled_color(rgb, *color, alpha);
|
||||
response.mark_changed();
|
||||
}
|
||||
});
|
||||
response
|
||||
}
|
||||
fn sampled_color(rgb: [f32; 3], original: Color32, alpha: egui::color_picker::Alpha) -> Color32 {
|
||||
let byte = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
if matches!(alpha, egui::color_picker::Alpha::BlendOrAdditive) && original.is_additive() {
|
||||
return Color32::from_rgb_additive(byte(rgb[0]), byte(rgb[1]), byte(rgb[2]));
|
||||
}
|
||||
Color32::from_rgba_unmultiplied(
|
||||
byte(rgb[0]),
|
||||
byte(rgb[1]),
|
||||
byte(rgb[2]),
|
||||
if matches!(alpha, egui::color_picker::Alpha::Opaque) { 255 } else { original.a() },
|
||||
)
|
||||
}
|
||||
pub fn color_edit_button_srgb(ui: &mut Ui, rgb: &mut [u8; 3]) -> Response {
|
||||
let mut color = Color32::from_rgb(rgb[0], rgb[1], rgb[2]);
|
||||
let response = color_edit_button(ui, &mut color, egui::color_picker::Alpha::Opaque);
|
||||
if response.changed() {
|
||||
*rgb = [color.r(), color.g(), color.b()];
|
||||
}
|
||||
response
|
||||
}
|
||||
/// Like egui's float RGB widget, this entry point stores linear RGB (not encoded hex values).
|
||||
pub fn color_edit_button_rgb(ui: &mut Ui, rgb: &mut [f32; 3]) -> Response {
|
||||
let mut response = color_swatch(ui, Color32::from(egui::Rgba::from_rgb(rgb[0], rgb[1], rgb[2])));
|
||||
let id = response.id.with("screen-color");
|
||||
let to_linear = |color: [f32; 3]| color.map(egui::ecolor::linear_from_gamma);
|
||||
if let Some(color) = crate::screen_picker::take(ui.ctx(), id) {
|
||||
*rgb = to_linear(color);
|
||||
response.mark_changed();
|
||||
}
|
||||
let rgba = egui::Rgba::from_rgb(rgb[0], rgb[1], rgb[2]);
|
||||
let cache = response.id.with("linear-hsva");
|
||||
let mut hsva = ui
|
||||
.ctx()
|
||||
.data(|d| d.get_temp::<(egui::Rgba, egui::ecolor::Hsva)>(cache))
|
||||
.filter(|(previous, _)| *previous == rgba)
|
||||
.map(|(_, hsva)| hsva)
|
||||
.unwrap_or_else(|| egui::ecolor::Hsva::from(rgba));
|
||||
swatch_popup(&response).show(|ui| {
|
||||
if egui::color_picker::color_picker_hsva_2d(ui, &mut hsva, egui::color_picker::Alpha::Opaque) {
|
||||
let color = egui::Rgba::from(hsva);
|
||||
*rgb = [color.r(), color.g(), color.b()];
|
||||
response.mark_changed();
|
||||
}
|
||||
if let Some(color) = crate::screen_picker::button(ui, id) {
|
||||
*rgb = to_linear(color);
|
||||
hsva = egui::ecolor::Hsva::from(egui::Rgba::from_rgb(rgb[0], rgb[1], rgb[2]));
|
||||
response.mark_changed();
|
||||
}
|
||||
});
|
||||
ui.ctx().data_mut(|d| d.insert_temp(cache, (egui::Rgba::from_rgb(rgb[0], rgb[1], rgb[2]), hsva)));
|
||||
response
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod screen_color_tests {
|
||||
use super::*;
|
||||
use crate::screen_picker::{Capture, Pending};
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
#[test]
|
||||
fn float_picker_keeps_precision_and_converts_screen_srgb_to_linear() {
|
||||
let services = crate::Services {
|
||||
screen_pick: Some(Box::new(|_| {
|
||||
let (tx, receiver) = std::sync::mpsc::channel();
|
||||
tx.send(Ok(Capture::Color(Some([0.5, 0.25, 1.0])))).unwrap();
|
||||
Pending { receiver, cancelled: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)) }
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let original = [0.123456, 0.234567, 0.345678];
|
||||
let app = crate::PhotocraftApp::new(photocraft_engine::Session::new(), services);
|
||||
let mut h = Harness::builder().with_size(vec2(600.0, 500.0)).build_ui_state(
|
||||
|ui, state: &mut (crate::PhotocraftApp, [f32; 3])| {
|
||||
crate::screen_picker::tick(&mut state.0, ui.ctx());
|
||||
color_edit_button_rgb(ui, &mut state.1);
|
||||
},
|
||||
(app, original),
|
||||
);
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().1, original);
|
||||
h.get_by_role(egui::accesskit::Role::ColorWell).click();
|
||||
h.run_steps(3);
|
||||
for (actual, expected) in h.state().1.iter().zip(original) {
|
||||
assert!((actual - expected).abs() < 0.000001, "opening the float popup must not quantize to 8 bits");
|
||||
}
|
||||
h.get_by_label("Pick screen color").click();
|
||||
h.run_steps(3);
|
||||
for (actual, expected) in h.state().1.iter().zip([0.21404114, 0.05087609, 1.0]) {
|
||||
assert!((actual - expected).abs() < 0.000001);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn compact_picker_delivers_screen_color_after_popup_closes_and_keeps_alpha() {
|
||||
for (original, expected) in [
|
||||
(Color32::from_rgba_unmultiplied(40, 50, 60, 128), Color32::from_rgba_unmultiplied(255, 0, 128, 128)),
|
||||
(Color32::from_rgb_additive(40, 50, 60), Color32::from_rgb_additive(255, 0, 128)),
|
||||
] {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let mut receiver = Some(rx);
|
||||
let services = crate::Services {
|
||||
screen_pick: Some(Box::new(move |_| Pending {
|
||||
receiver: receiver.take().unwrap(),
|
||||
cancelled: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let app = crate::PhotocraftApp::new(photocraft_engine::Session::new(), services);
|
||||
let mut h = Harness::builder().with_size(vec2(600.0, 500.0)).build_ui_state(
|
||||
|ui, state: &mut (crate::PhotocraftApp, Color32, bool)| {
|
||||
crate::screen_picker::tick(&mut state.0, ui.ctx());
|
||||
state.2 = color_edit_button_srgba(ui, &mut state.1).changed();
|
||||
},
|
||||
(app, original, false),
|
||||
);
|
||||
h.get_by_role(egui::accesskit::Role::ColorWell).click();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Pick screen color").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().1, original);
|
||||
egui::Popup::close_all(&h.ctx);
|
||||
tx.send(Ok(Capture::Color(Some([1.0, 0.0, 0.5])))).unwrap();
|
||||
h.run_steps(1);
|
||||
assert!(h.state().2);
|
||||
assert_eq!(h.state().1.a(), original.a());
|
||||
assert_eq!(h.state().1, expected);
|
||||
h.run_steps(1);
|
||||
assert!(!h.state().2, "the pick commits once");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
# Color selection and screen sampling
|
||||
|
||||
Click the color swatch in the Type options bar or Character panel to open the full Color Picker. It offers the color field, component strip, HSB/RGB/Lab/CMYK and hex fields, and current/new swatches. Selecting characters targets that range; with no character selection, the active text layer is targeted. **OK** applies through `type.setStyle`; **Cancel** leaves the text and history unchanged. The foreground color is independent of this dialog.
|
||||
|
||||
The existing canvas eyedropper continues to sample the document while the Color Picker is open, including canvas zoom and pan. This works without operating-system screen-capture permission.
|
||||
|
||||
## Pick a color outside PhotoCraft
|
||||
|
||||
**Pick screen color** is available in full Color Pickers and compact color-selection popups, including fills, shapes, gradients, layer effects, guides and preferences.
|
||||
|
||||
On X11, Windows and macOS, clicking it takes one snapshot of the connected screens and opens a sampling viewport on each monitor. Move the crosshair over a pixel, use the wheel to change the 11 × 11 pixel loupe from 4× to 24×, then click to select. **Esc** cancels. The center pixel has a black/white outline and its sampled hex value is displayed below the loupe.
|
||||
|
||||
A full dialog receives the new color for inspection; **OK** is still required to apply it. Compact popups retain their normal immediate-edit behavior. Existing alpha and additive blending are preserved; float RGB widgets retain their linear RGB storage.
|
||||
|
||||
Screen snapshots are held in memory, are never written or transmitted by the picker, and are released after selection, cancellation, or a change of document, layer or dialog. The combined capture budget is 64 × 1024 × 1024 pixels (about 67 MP) across at most 16 displays, subject to the GPU's texture-size limit. These limits affect screen sampling only, not document dimensions.
|
||||
|
||||
## Platform behavior
|
||||
|
||||
- **Linux/X11:** a native Rust X11 client captures the composed desktop. An advertised primary-display ICC profile is converted to sRGB through PhotoCraft's color-management library. Outputs with no published profile use an sRGB fallback.
|
||||
- **Linux/Wayland:** the desktop's XDG `PickColor` portal owns selection and permission UI and returns sRGB. Magnification and zoom controls depend on the portal implementation; PhotoCraft cannot capture other applications directly under Wayland.
|
||||
- **Windows:** native screen capture with monitor-specific fullscreen viewports. Captured pixels currently use an sRGB fallback; HDR and arbitrary monitor-profile accuracy are not guaranteed.
|
||||
- **macOS:** safe CoreGraphics bindings capture the composed screen and use the snapshot's own ICC profile when available. Screen Recording permission may be required. The existing macOS 11 deployment target is preserved. The window-list capture API is deprecated by Apple, so current macOS releases require explicit manual validation; migration to modern native sampling must also preserve older-app compatibility.
|
||||
- **Web:** browsers exposing the standard EyeDropper API use the browser's native sampling/magnification UI. Browser support and zoom controls vary. Unsupported browsers retain document sampling and color fields; external screen sampling is unavailable.
|
||||
|
||||
The implementation follows publicly documented Photoshop behavior for the full Type Color Picker and outside-image sampling. PhotoCraft uses an explicit screen-pick action and frozen snapshots rather than Photoshop's press-and-drag gesture.
|
||||
|
||||
## Verification
|
||||
|
||||
Unit and UI integration tests cover selected text versus whole-layer color changes, Cancel/OK and undo, routing asynchronous picks to their initiating popup, alpha preservation, malformed capture bounds, negative display origins, HiDPI mapping and cancellation races. The native capture tests also cover padded pixel layouts and memory budgets.
|
||||
|
||||
Generate screenshots using synthetic artwork without exposing your desktop:
|
||||
|
||||
```sh
|
||||
cargo run -p photocraft-ui-egui --example color_picker_demo -- /tmp/photocraft-color-picker
|
||||
```
|
||||
|
||||
For reproducible native X11 capture verification, the explicitly ignored test displays an 8 × 8 synthetic swatch in an isolated Xvfb display, captures it in memory and checks the known pixel:
|
||||
|
||||
```sh
|
||||
xvfb-run -a cargo test -p photocraft screen_capture_reads_a_known_x11_fixture_pixel -- --ignored --test-threads=1
|
||||
```
|
||||
|
||||
Windows, macOS, Wayland portals, browser activation and mixed-DPI monitor arrangements additionally need manual validation on those environments; mock UI tests do not substitute for it.
|
||||
|
||||
References: [Adobe color selection](https://helpx.adobe.com/photoshop/desktop/adjust-color/choose-colors/set-foreground-and-background-colors.html), [XDG color portal](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Screenshot.html), [Apple image color space](https://developer.apple.com/documentation/coregraphics/cgimage/colorspace), [EyeDropper specification](https://wicg.github.io/eyedropper-api/).
|
||||
Reference in new issue
Block a user