diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b83d5a8f6..7b3b6bf5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: uses: storytold/craft-common-actions/.github/workflows/gate.yml@main with: # Same craft-fonts commit as release.yml. - craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 timeout-minutes: 90 packages: >- pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 0f91ed6d0..007fcfec0 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -29,5 +29,5 @@ jobs: uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main with: require-craft-fonts: true - craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 run-package-check: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5fd996c43..d1677e538 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,7 +68,7 @@ jobs: with: app-name: vectorcraft app-title: VectorCraft - craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 web-craft-fonts: true version: ${{ inputs.version }} secrets: diff --git a/ASSETS.md b/ASSETS.md index 8e096c1c3..2d50ab95b 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -12,6 +12,8 @@ Generated-in-code art is original and has no file to list. This covers the defau | Asset | Author | Source | Licence | Notes | |---|---|---|---|---| +| `epaint_default_fonts/fonts/egui-icons.ttf` (egui dependency) | John Slegers (emoji-icon-font) | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full text in NOTICE) | Small special-icon subset embedded by default_fonts | +| `epaint_default_fonts/fonts/emoji-icon-font.ttf` (egui dependency) | John Slegers | https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/emoji-icon-font-mit-license.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full text in NOTICE) | Parent face embedded by monochrome_emoji_fonts | | `docs/brand/artcraft-logo-white.png` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app | | `docs/brand/artcraft-logo-white.svg` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app | | `docs/brand/artcraft-logo.png` | ArtCraft team (project owner) | https://getartcraft.com/ | ArtCraft trademark, see docs/brand/LICENSE-brand.txt (not open source) | Used in README only; not shipped in the app | diff --git a/Cargo.lock b/Cargo.lock index 8193d018b..062fa86ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,66 +4,60 @@ version = 4 [[package]] name = "accesskit" -version = "0.24.1" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932" dependencies = [ "uuid", ] [[package]] name = "accesskit_atspi_common" -version = "0.18.1" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +checksum = "52c182f9c282ac9c5638d876d551d15e5f7d397ec263349a0c6a2b61595dd5e4" dependencies = [ "accesskit", - "accesskit_consumer 0.36.0", + "accesskit_consumer", "atspi-common", - "phf", + "phf 0.14.0", "serde", "zvariant", ] [[package]] name = "accesskit_consumer" -version = "0.35.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344" dependencies = [ "accesskit", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] -name = "accesskit_consumer" -version = "0.36.0" +name = "accesskit_ios" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937" dependencies = [ "accesskit", - "hashbrown 0.16.1", -] - -[[package]] -name = "accesskit_consumer" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" -dependencies = [ - "accesskit", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", ] [[package]] name = "accesskit_macos" -version = "0.26.3" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4" dependencies = [ "accesskit", - "accesskit_consumer 0.38.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "objc2 0.5.3", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -71,9 +65,9 @@ dependencies = [ [[package]] name = "accesskit_unix" -version = "0.21.1" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +checksum = "202f24df034a7476d07b7f74284de84f6d62aabd858dbe7ee9cad3b7ad6f8f9d" dependencies = [ "accesskit", "accesskit_atspi_common", @@ -89,13 +83,13 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.32.1" +version = "0.35.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +checksum = "ce63f35d6bdcf59f26b76b3379063f738e6412cef46999cc772d46aa3de35adb" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "static_assertions", "windows", "windows-core", @@ -103,11 +97,12 @@ dependencies = [ [[package]] name = "accesskit_winit" -version = "0.32.2" +version = "0.34.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +checksum = "ee96dd150375c05912d8a473f98c2fb155fb3234fac88292d396548a2460fa77" dependencies = [ "accesskit", + "accesskit_ios", "accesskit_macos", "accesskit_unix", "accesskit_windows", @@ -222,7 +217,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.61.2", + "windows-sys 0.59.0", "wl-clipboard-rs", "x11rb", ] @@ -437,6 +432,30 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "base64" version = "0.22.1" @@ -449,6 +468,12 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.10.0" @@ -709,7 +734,7 @@ checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures 0.3.1", - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -718,7 +743,12 @@ version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ + "iana-time-zone", + "js-sys", "num-traits", + "serde", + "wasm-bindgen", + "windows-link", ] [[package]] @@ -740,6 +770,39 @@ dependencies = [ "error-code", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmpv2" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "961b955a666e25ee5a1091d219128d6e6401e3dab84efb1a2bf6b4035d797b39" +dependencies = [ + "crmf", + "der", + "spki", + "x509-cert", +] + +[[package]] +name = "cms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" +dependencies = [ + "const-oid", + "der", + "spki", + "x509-cert", +] + [[package]] name = "codespan-reporting" version = "0.13.1" @@ -795,6 +858,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "core-foundation" version = "0.9.4" @@ -805,6 +874,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" @@ -818,7 +897,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", @@ -831,7 +910,7 @@ 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", ] @@ -876,7 +955,7 @@ source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09 [[package]] name = "craft-keymap" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=2eca9b3a53a98ac52efed2a27353c6a6927ead10#2eca9b3a53a98ac52efed2a27353c6a6927ead10" +source = "git+https://github.com/storytold/craft-libs?rev=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f" dependencies = [ "egui", "serde", @@ -897,15 +976,20 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=d991bf6792f8ade99c51358bf3c1d902c8cd96b5#d991bf6792f8ade99c51358bf3c1d902c8cd96b5" +source = "git+https://github.com/storytold/craft-libs?rev=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f" dependencies = [ + "craft-winprint-gdi", + "miniz_oxide 0.8.9", + "roxmltree 0.20.0", "tempfile", + "winprint", + "winsafe", ] [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675" +source = "git+https://github.com/storytold/craft-libs?rev=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f" dependencies = [ "egui", "serde", @@ -914,12 +998,20 @@ dependencies = [ [[package]] name = "craft-window" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=1f805378d454dac48b5c214029d254055fdf961b#1f805378d454dac48b5c214029d254055fdf961b" +source = "git+https://github.com/storytold/craft-libs?rev=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f" dependencies = [ "egui", "wayland-client", ] +[[package]] +name = "craft-winprint-gdi" +version = "0.1.0" +source = "git+https://github.com/storytold/winprint.rs?rev=417ac3c4d14d3da5485a99c7d5ce8f05664ec312#417ac3c4d14d3da5485a99c7d5ce8f05664ec312" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "crc32fast" version = "1.5.3" @@ -929,6 +1021,18 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crmf" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36fe21b96d5b87f5de4b5b7202ec41c00110ac817ce6728fe75fb2fe5962ed92" +dependencies = [ + "cms", + "der", + "spki", + "x509-cert", +] + [[package]] name = "crossbeam-channel" version = "0.5.17" @@ -991,6 +1095,30 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "der_derive", + "flagset", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "dify" version = "0.8.0" @@ -1074,10 +1202,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] -name = "ecolor" -version = "0.36.2" +name = "dunce" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ecolor" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "emath", @@ -1085,9 +1218,8 @@ dependencies = [ [[package]] name = "eframe" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1110,6 +1242,7 @@ dependencies = [ "pollster 1.0.1", "profiling", "raw-window-handle", + "send_wrapper", "static_assertions", "wasm-bindgen", "web-sys", @@ -1121,9 +1254,8 @@ dependencies = [ [[package]] name = "egui" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit", "ahash", @@ -1141,9 +1273,8 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1161,30 +1292,33 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit_winit", "arboard", + "block2 0.6.2", "bytemuck", "egui", + "image", "log", "objc2 0.6.5", + "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", + "parking_lot", "profiling", "raw-window-handle", "smithay-clipboard", "web-time", + "windows-sys 0.61.2", "winit", ] [[package]] name = "egui_extras" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05cbdc785020861553f230f23d330ba1fb72613b9d8e0e457f3bdc8ba6db6dcf" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "egui", @@ -1197,9 +1331,8 @@ dependencies = [ [[package]] name = "egui_glow" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "egui", @@ -1211,9 +1344,8 @@ dependencies = [ [[package]] name = "egui_kittest" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "dify", "eframe", @@ -1238,13 +1370,26 @@ checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" [[package]] name = "emath" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", ] +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + [[package]] name = "endi" version = "1.1.1" @@ -1253,22 +1398,22 @@ checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" [[package]] name = "enum-map" -version = "2.7.3" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" +checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06" dependencies = [ "enum-map-derive", ] [[package]] name = "enum-map-derive" -version = "0.17.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" +checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] @@ -1294,9 +1439,8 @@ dependencies = [ [[package]] name = "epaint" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1312,16 +1456,17 @@ dependencies = [ "self_cell", "skrifa 0.44.0", "smallvec", + "unicode-bidi", "unicode-general-category", + "unicode-properties", "unicode-segmentation", - "vello_cpu 0.1.0", + "vello_cpu", ] [[package]] name = "epaint_default_fonts" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" [[package]] name = "equivalent" @@ -1336,7 +1481,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1401,6 +1546,16 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.15" @@ -1413,6 +1568,12 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "flate2" version = "1.1.10" @@ -1433,6 +1594,28 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fmt-derive" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cb4a4b45c26455885f8b462db12978e51f60cc914337bccb9a36faf93410169" +dependencies = [ + "fmt-derive-proc", +] + +[[package]] +name = "fmt-derive-proc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7704da0760734344ce8753f15b21e97c6be85dc5a8f2c3a963fbd042b3380d23" +dependencies = [ + "proc-macro-crate", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1516,6 +1699,22 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + [[package]] name = "futures-core" version = "0.3.34" @@ -1552,6 +1751,12 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + [[package]] name = "futures-task" version = "0.3.34" @@ -1565,8 +1770,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", + "futures-io", "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -1600,6 +1808,19 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -1619,9 +1840,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", - "rand_core", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -1645,22 +1868,6 @@ dependencies = [ "xml-rs", ] -[[package]] -name = "glifo" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" -dependencies = [ - "bytemuck", - "foldhash 0.2.0", - "hashbrown 0.17.1", - "log", - "peniko", - "skrifa 0.44.0", - "smallvec", - "vello_common 0.1.0", -] - [[package]] name = "glifo" version = "0.3.0" @@ -1675,7 +1882,7 @@ dependencies = [ "png 0.18.1", "skrifa 0.44.0", "smallvec", - "vello_common 0.2.0", + "vello_common", ] [[package]] @@ -1779,6 +1986,25 @@ dependencies = [ "euclid", ] +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.7.1" @@ -1870,7 +2096,7 @@ dependencies = [ "hayro-syntax", "kurbo 0.13.1", "moxcms", - "phf", + "phf 0.13.1", "rustc-hash 2.1.3", "siphasher", "skrifa 0.47.0", @@ -1917,6 +2143,12 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.5.3" @@ -1929,6 +2161,131 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.3.0" @@ -2145,6 +2502,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + [[package]] name = "is-docker" version = "0.2.0" @@ -2293,12 +2656,12 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kittest" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" +checksum = "0bb81a721ba76aa095b7f45deb180504f786904e56ca4b8e141f66eb89a78b75" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", + "accesskit_consumer", ] [[package]] @@ -2456,6 +2819,12 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + [[package]] name = "md-5" version = "0.10.6" @@ -2490,6 +2859,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2510,6 +2885,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -2538,6 +2924,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "naga" version = "30.0.1" @@ -2989,6 +3381,12 @@ dependencies = [ "libc", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "orbclient" version = "0.3.55" @@ -3034,7 +3432,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3078,6 +3476,25 @@ dependencies = [ "ryu", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "peniko" version = "0.6.1" @@ -3114,8 +3531,18 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_macros", - "phf_shared", + "phf_macros 0.13.1", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_macros 0.14.0", + "phf_shared 0.14.0", "serde", ] @@ -3126,7 +3553,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" +dependencies = [ + "fastrand", + "phf_shared 0.14.0", ] [[package]] @@ -3135,8 +3572,21 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_macros" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" +dependencies = [ + "phf_generator 0.14.0", + "phf_shared 0.14.0", "proc-macro2", "quote", "syn 2.0.119", @@ -3151,6 +3601,15 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher", +] + [[package]] name = "pico-args" version = "0.5.0" @@ -3308,6 +3767,30 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -3379,6 +3862,63 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.3", + "rustls", + "socket2", + "thiserror 2.0.21", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash 2.1.3", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.21", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.59.0", +] + [[package]] name = "quote" version = "1.0.47" @@ -3406,23 +3946,39 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ + "chacha20", "getrandom 0.4.3", - "rand_core", + "rand_core 0.10.1", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "rand_core" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rand_xorshift" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" dependencies = [ - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -3551,6 +4107,49 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "resvg" version = "0.45.1" @@ -3601,6 +4200,20 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + [[package]] name = "roxmltree" version = "0.20.0" @@ -3660,7 +4273,82 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", ] [[package]] @@ -3714,6 +4402,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "ryu-js" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" + [[package]] name = "same-file" version = "1.0.6" @@ -3723,6 +4417,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -3746,6 +4449,29 @@ dependencies = [ "tiny-skia", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "self_cell" version = "1.3.0" @@ -3758,6 +4484,12 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + [[package]] name = "serde" version = "1.0.229" @@ -3801,6 +4533,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_json_canonicalizer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe52319a927259afbfa5180c5157cd8167edfd3e8c254f9558c7fef44c5649f2" +dependencies = [ + "ryu-js", + "serde", + "serde_json", +] + [[package]] name = "serde_repr" version = "0.1.21" @@ -3821,6 +4564,29 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3848,6 +4614,175 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "sigstore-bundle" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7dc53c8a941858d01479622dbb7650aaa2ee0ca1fb58fb6cdddbfc3064abbb" +dependencies = [ + "base64 0.22.1", + "hex", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-rekor", + "sigstore-tsa", + "sigstore-types", + "thiserror 2.0.21", +] + +[[package]] +name = "sigstore-crypto" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da90c3d9af638898a5fdc347479b18f950bb0dde11ecf2244f94cd28135da53b" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "const-oid", + "der", + "digest", + "pem", + "rand_core 0.10.1", + "sha2", + "signature", + "sigstore-types", + "spki", + "thiserror 2.0.21", + "tracing", + "x509-cert", +] + +[[package]] +name = "sigstore-merkle" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c0d53558825c77716855c13794306fff766854c1bf92948e77d7890da3f2455" +dependencies = [ + "base64 0.22.1", + "hex", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.21", +] + +[[package]] +name = "sigstore-rekor" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "641fdaaa40d0cd6e249cf1671c2240beff1a3ece578062cf43e750779e8db2b3" +dependencies = [ + "base64 0.22.1", + "hex", + "reqwest", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-types", + "thiserror 2.0.21", + "url", +] + +[[package]] +name = "sigstore-trust-root" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6b0d2550f05c096400066e858db13d91f9de3b750a7541fb1291c1dc80ca290" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "rustls-pki-types", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.21", + "x509-cert", +] + +[[package]] +name = "sigstore-tsa" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bc86c8abc1ece6832236e7e34e9baebb5d4d40490c0332b814a0d8624ca7255" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "chrono", + "cmpv2", + "cms", + "const-oid", + "der", + "hex", + "rand", + "reqwest", + "rustls-pki-types", + "rustls-webpki", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.21", + "tracing", + "x509-cert", + "x509-tsp", +] + +[[package]] +name = "sigstore-types" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8d870c9bcfdf83396ac2bd2d6a23c5d09ec02cc9fda50fa1cbb3dd71a9bee53" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "pem", + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "sigstore-verify" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d30aff69036e4579b416956e9ba68cec45b793116f78f10842d04c7f8b924bab" +dependencies = [ + "base64 0.22.1", + "chrono", + "cms", + "const-oid", + "hex", + "pem", + "rustls-pki-types", + "rustls-webpki", + "serde", + "serde_json", + "serde_json_canonicalizer", + "sigstore-bundle", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-rekor", + "sigstore-trust-root", + "sigstore-tsa", + "sigstore-types", + "thiserror 2.0.21", + "tls_codec", + "tracing", + "x509-cert", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -4036,6 +4971,16 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4078,6 +5023,27 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "subsetter" version = "0.2.6" @@ -4090,6 +5056,12 @@ dependencies = [ "write-fonts", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "svgtypes" version = "0.15.3" @@ -4110,6 +5082,16 @@ dependencies = [ "siphasher", ] +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.119" @@ -4132,6 +5114,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.14.0" @@ -4143,6 +5134,38 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -4153,7 +5176,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4282,13 +5305,63 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tokio" version = "1.54.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc" dependencies = [ + "bytes", + "libc", + "mio", "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", ] [[package]] @@ -4342,6 +5415,51 @@ version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -4385,6 +5503,12 @@ dependencies = [ "petgraph", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "ttf-parser" version = "0.25.1" @@ -4501,6 +5625,18 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -4929,8 +6065,8 @@ dependencies = [ "vectorcraft-plugins", "vectorcraft-testkit", "vectorcraft-text", - "vello_common 0.2.0", - "vello_cpu 0.2.0", + "vello_common", + "vello_cpu", "wat", "weezl", ] @@ -5001,7 +6137,7 @@ dependencies = [ "unicode-segmentation", "vectorcraft-doc", "vectorcraft-geom", - "vello_cpu 0.2.0", + "vello_cpu", "windows-registry", "write-fonts", ] @@ -5098,21 +6234,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "vello_common" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" -dependencies = [ - "bytemuck", - "fearless_simd", - "guillotiere", - "log", - "peniko", - "smallvec", - "thiserror 2.0.21", -] - [[package]] name = "vello_common" version = "0.2.0" @@ -5129,18 +6250,6 @@ dependencies = [ "thiserror 2.0.21", ] -[[package]] -name = "vello_cpu" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" -dependencies = [ - "bytemuck", - "glifo 0.2.0", - "hashbrown 0.17.1", - "vello_common 0.1.0", -] - [[package]] name = "vello_cpu" version = "0.2.0" @@ -5149,13 +6258,13 @@ checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" dependencies = [ "bytemuck", "crossbeam-channel", - "glifo 0.3.0", + "glifo", "hashbrown 0.17.1", "ordered-channel", "png 0.18.1", "rayon", "thread_local", - "vello_common 0.2.0", + "vello_common", ] [[package]] @@ -5183,6 +6292,21 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" @@ -5512,6 +6636,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -5707,7 +6840,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5925,8 +7058,7 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winit" version = "0.30.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +source = "git+https://github.com/storytold/winit.git?rev=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc" dependencies = [ "ahash", "android-activity", @@ -5937,7 +7069,7 @@ dependencies = [ "calloop 0.13.0", "cfg_aliases", "concurrent-queue", - "core-foundation", + "core-foundation 0.9.4", "core-graphics", "cursor-icon", "dpi", @@ -5983,6 +7115,27 @@ dependencies = [ "memchr", ] +[[package]] +name = "winprint" +version = "0.2.1" +source = "git+https://github.com/storytold/winprint.rs?rev=5f80b9baf6b58937c401656634e42d33f00ddf49#5f80b9baf6b58937c401656634e42d33f00ddf49" +dependencies = [ + "flate2", + "fmt-derive", + "reqwest", + "scopeguard", + "sigstore-trust-root", + "sigstore-types", + "sigstore-verify", + "strum", + "tar", + "thiserror 2.0.21", + "windows", + "windows-core", + "windows-implement", + "xml", +] + [[package]] name = "winresource" version = "0.1.34" @@ -6074,6 +7227,41 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid", + "der", + "sha1", + "signature", + "spki", + "tls_codec", +] + +[[package]] +name = "x509-tsp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5ceece934a21607055b7ac5c25adb56a2ff559804b10705dc674d1d838c15e1" +dependencies = [ + "cmpv2", + "cms", + "der", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.5", +] + [[package]] name = "xcursor" version = "0.3.11" @@ -6099,6 +7287,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" +[[package]] +name = "xml" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f45bb2c13fec6a6cb4c0f76a7e94839e110a14ec803ec2940777a94c347bc52" + [[package]] name = "xml-rs" version = "0.8.29" @@ -6294,6 +7488,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerotrie" version = "0.2.5" @@ -6396,3 +7610,18 @@ dependencies = [ "syn 3.0.9", "winnow", ] + +[[patch.unused]] +name = "egui_demo_lib" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_inspection" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_system_font_provider" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" diff --git a/Cargo.toml b/Cargo.toml index 46a94c5c8..6e3d2fff8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,13 +12,21 @@ repository = "https://github.com/storytold/vectorcraft" [workspace.dependencies] craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10", default-features = false, features = ["interpret", "write"] } -craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" } +# Kept at the egui-0.37 port revision on purpose: 25da contains 4f85efb's bounded print +# parsing and 2bbef53's printer names; d991bf67 (VectorCraft main's pin) lacks both. This is a +# targeted security/UI bump, not an all-libs flatten. Re-check if main backports them. +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f" } craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } -craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10" } +# egui 0.37 port. 25da has craft_ui::capture and the old docking surface +# (DockContent/show_customized; Floating {panels, active, rect}) that VectorCraft's +# panel_docking.rs uses. Do not bump this to the new-docking L revision without porting that file. +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f", features = ["serde"] } +# The egui 0.37 port of craft-keymap. 25da's core keymap API matches 2eca9b3a; only its +# `ui` feature and tests differ, and VectorCraft enables only `egui`. +craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f" } # Decides who draws the window frame (desktop vs. app) so the suite shares one native titlebar per # desktop. The `egui` feature adds `craft_window::decorate`. (craft-libs#217.) -craft-window = { git = "https://github.com/storytold/craft-libs", rev = "1f805378d454dac48b5c214029d254055fdf961b", features = ["egui"] } +craft-window = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f", features = ["egui"] } # internal vectorcraft-geom = { path = "crates/geom" } vectorcraft-color = { path = "crates/color" } @@ -102,10 +110,10 @@ tiff = "0.11" zune-jpeg = "0.5" # ui -egui = "0.36" -egui_extras = { version = "0.36", default-features = false, features = ["svg"] } -eframe = { version = "0.36", default-features = false } -egui_kittest = "0.36" +egui = "0.37" +egui_extras = { version = "0.37", default-features = false, features = ["svg"] } +eframe = { version = "0.37", default-features = false } +egui_kittest = "0.37" rfd = "0.17" arboard = { version = "3.6", default-features = false } clipboard-win = "5.4" @@ -146,3 +154,18 @@ hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10" } hayro-postscript = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10" } krilla = { git = "https://github.com/storytold/craft-libs", rev = "2eca9b3a53a98ac52efed2a27353c6a6927ead10" } +ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" } diff --git a/NOTICE b/NOTICE index 0d6f9dd09..18bf7bf6f 100644 --- a/NOTICE +++ b/NOTICE @@ -123,3 +123,21 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Bundled egui font icons + +The pinned egui dependency includes egui-icons.ttf, a small subset of John Slegers' +emoji-icon-font, through default_fonts. The parent emoji-icon-font.ttf face is +included through monochrome_emoji_fonts. Their upstream project is +https://github.com/jslegers/emoji-icon-font; both use the same exact copyright +and permission notice below. + +MIT License + +Copyright (c) 2014 John Slegers + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/apps/vectorcraft-web/src/web.rs b/apps/vectorcraft-web/src/web.rs index c30799475..796680854 100644 --- a/apps/vectorcraft-web/src/web.rs +++ b/apps/vectorcraft-web/src/web.rs @@ -106,6 +106,7 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option { - match target { - DropTarget::Place(d) => { - place_inbox.lock().unwrap_or_else(|e| e.into_inner()).push(PlaceArrival { name, bytes, drop: Some(d) }) - } - DropTarget::Open => inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes)), - } - ctx.request_repaint(); - } - Err(e) => log::error!("couldn't read dropped file {name}: {e}"), - } - }); - } - } - app.logic(ctx); + app.tick(ctx); crate::api::poll_replies(); + app.finish_ime_logic(ctx); } - fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { + fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { raw.events.extend(self.host.pointers.borrow_mut().take()); if let Some(app) = &mut self.app { - app.raw_input_hook(raw); + app.raw_input_hook(ctx, raw); } } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + let ctx = ui.ctx().clone(); if let Some(app) = &mut self.app { + // DOM paste and drop are host input: read them in the UI phase, once per pass, not in + // `logic` where a hidden tab replays the last shown frame's input. + let pasted = self.host.pasted.borrow_mut().flavour.take(); + if let Some((f, held)) = pasted { + app.paste_from_host(&ctx, f, held); + } + let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)); + if !dropped.is_empty() { + let at = self.host.drag.take(); + let z = ctx.zoom_factor(); + let (pos, shift) = (at.map(|(x, y, _)| egui::pos2(x / z, y / z)), at.is_some_and(|a| a.2)); + for f in dropped { + let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into()); + let target = app.drop_target(&name, pos, shift); + let (inbox, place_inbox, repaint) = (self.host.inbox.clone(), self.host.place_inbox.clone(), ctx.clone()); + wasm_bindgen_futures::spawn_local(async move { + match f.bytes_async().await { + Ok(bytes) => { + match target { + DropTarget::Place(d) => { + place_inbox.lock().unwrap_or_else(|e| e.into_inner()).push(PlaceArrival { name, bytes, drop: Some(d) }) + } + DropTarget::Open => inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes)), + } + repaint.request_repaint(); + } + Err(e) => log::error!("couldn't read dropped file {name}: {e}"), + } + }); + } + } app.ui(ui); } crate::api::poll_replies(); diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index fedc4acbe..77b73a15a 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -23,7 +23,7 @@ vectorcraft-text = { workspace = true } # Shared across the Craft suite: decides who draws the window frame per desktop (compositor vs app) # so VectorCraft matches the rest on Linux. `egui` adds `craft_window::decorate`. See #217. craft-window = { workspace = true } -eframe = { workspace = true, features = ["default_fonts", "wayland", "x11"] } +eframe = { workspace = true, features = ["clipboard", "default_fonts", "wayland", "x11"] } egui = { workspace = true } serde_json = { workspace = true } log = { workspace = true } diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index fc3f0dea4..2a5eaf557 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -89,8 +89,9 @@ impl eframe::App for App { // (`VectorcraftApp::with_automation_roots`), which are in force elsewhere in the process. fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { file_access::unconfined(|| self.logic_frame(ctx, frame)); + self.app.finish_ime_logic(ctx); } - fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { + fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { // winit gives the window its caption back when it changes the window's flags (maximize, // restore, …): take it off again before the frame is laid out (#1758). if let Some(window_frame) = self.window_frame.as_mut() { @@ -103,7 +104,7 @@ impl eframe::App for App { // A pen contact with the barrel button held presses the secondary button (Windows Ink). #[cfg(all(windows, not(target_vendor = "win7")))] windows_stylus::raw_input_hook(raw); - file_access::unconfined(|| self.app.raw_input_hook(raw)); + file_access::unconfined(|| self.app.raw_input_hook(ctx, raw)); } #[cfg_attr(not(target_os = "macos"), allow(unused_variables))] fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { @@ -164,7 +165,7 @@ impl App { } open_files(&mut self.app, files); } - self.app.logic(ctx); + self.app.tick(ctx); if self.pending_maximize { if let Some(w) = &self.winit_window { self.pending_maximize = window::apply_pending_maximize(w, true, &mut self.maximize_tries); @@ -1014,6 +1015,7 @@ fn main() -> std::process::ExitCode { run( options, Box::new(move |cc| { + VectorcraftApp::install_ime_reset_hooks(&cc.egui_ctx); let mut session = Session::new(); // The profiles loaded before come back before the preferences name any of them. prefs_dir::attach_profile_store(&mut session); diff --git a/crates/engine/src/tests_emptytype.rs b/crates/engine/src/tests_emptytype.rs index 37c2f212c..47e8b0310 100644 --- a/crates/engine/src/tests_emptytype.rs +++ b/crates/engine/src/tests_emptytype.rs @@ -3,7 +3,7 @@ use serde_json::json; use vectorcraft_doc::{NodeId, NodeKind, TextKind}; -use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind, SurroundingUnit, ToolKey}; use super::*; @@ -184,3 +184,137 @@ fn discard_empty_command() { assert_eq!(undo_labels(&s), ["Type", "Rectangle"]); assert_eq!(texts(&s).len(), 1); } + +/// An input method that replaces text around its composition (macOS press-and-hold/dictation, +/// Wayland text-input-v3): `DeleteSurroundingBytes` counts exact UTF-8 bytes and shares the +/// commit's typing session, so the replacement is one undo step. Never splits a UTF-8 sequence. +#[test] +fn ime_delete_surrounding_bytes_replaces_text_in_one_undo_step() { + let mut s = session(); + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + click(&mut s, 100.0, 100.0); + s.tool_text("Héllo", v).unwrap(); + let steps = undo_labels(&s).len(); + // "llo" is three UTF-8 bytes before the caret; the commit inserts y in the same session. + s.tool_ime_delete_surrounding(3, 0, SurroundingUnit::Bytes, v).unwrap(); + s.tool_text("y", v).unwrap(); + key(&mut s, ToolKey::Escape); + assert_eq!(texts(&s).iter().map(|t| t.2.as_str()).collect::>(), ["Héy"]); + assert_eq!(undo_labels(&s).len(), steps + 1, "delete + commit is one typing step"); +} + +/// Character counts (upstream `DeleteSurrounding`) delete whole scalar values, and a byte count +/// that lands inside a multi-byte character deletes fewer bytes rather than splitting it. +#[test] +fn ime_delete_surrounding_counts_characters_and_respects_utf8_boundaries() { + let mut s = session(); + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + click(&mut s, 100.0, 100.0); + s.tool_text("aé", v).unwrap(); + // A byte count starting inside é (byte 2 of the text) moves forward to the boundary: nothing + // is deleted rather than splitting the character, and the commit still goes in. + s.tool_ime_delete_surrounding(1, 0, SurroundingUnit::Bytes, v).unwrap(); + s.tool_text("!", v).unwrap(); + assert_eq!(texts(&s).iter().map(|t| t.2.as_str()).collect::>(), ["aé!"]); + // One character before the caret removes the whole "!"; é itself is one character too. + s.tool_ime_delete_surrounding(1, 0, SurroundingUnit::Chars, v).unwrap(); + s.tool_text("z", v).unwrap(); + assert_eq!(texts(&s).iter().map(|t| t.2.as_str()).collect::>(), ["aéz"]); +} + +/// A selection is what an IME's surrounding bytes replace, and bytes after it count too; the +/// commit shares the one typing step. +#[test] +fn ime_delete_surrounding_replaces_a_selection_and_after_bytes() { + let mut s = session(); + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + click(&mut s, 100.0, 100.0); + s.tool_text("Hello", v).unwrap(); + // Caret before "lo"; Shift+Left selects the first "l". + key(&mut s, ToolKey::Left); + key(&mut s, ToolKey::Left); + s.tool_key(ToolKey::Left, Mods { shift: true, ..Mods::default() }, v).unwrap(); + let steps = undo_labels(&s).len(); + // One byte before the selection ("e") and one after it ("o") go with it. + s.tool_ime_delete_surrounding(1, 1, SurroundingUnit::Bytes, v).unwrap(); + s.tool_text("y", v).unwrap(); + key(&mut s, ToolKey::Escape); + assert_eq!(texts(&s).iter().map(|t| t.2.as_str()).collect::>(), ["Hyo"]); + assert_eq!(undo_labels(&s).len(), steps + 1, "delete + commit is one typing step"); +} + +/// A pre-edit in the way is replaced by the IME's surrounding deletion, then the commit lands in +/// the same typing step. +#[test] +fn ime_delete_surrounding_replaces_the_preedit_and_commit_in_one_step() { + let mut s = session(); + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + click(&mut s, 100.0, 100.0); + s.tool_text("ab", v).unwrap(); + s.tool_preedit("xy", None, v).unwrap(); + let steps = undo_labels(&s).len(); + // Two bytes before the pre-edit: "ab" and the marked "xy" all go, then é is committed. + s.tool_ime_delete_surrounding(2, 0, SurroundingUnit::Bytes, v).unwrap(); + s.tool_text("é", v).unwrap(); + key(&mut s, ToolKey::Escape); + assert_eq!(texts(&s).iter().map(|t| t.2.as_str()).collect::>(), ["é"]); + assert_eq!(undo_labels(&s).len(), steps + 1, "delete + commit is one typing step"); +} + +/// RTL text follows logical UTF-8 byte offsets too: the byte count before the pre-edit deletes +/// the whole preceding Hebrew character (two bytes), never splitting it. +#[test] +fn ime_delete_surrounding_uses_logical_bytes_for_rtl_text() { + let mut s = session(); + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + click(&mut s, 100.0, 100.0); + s.tool_text("שלום", v).unwrap(); + s.tool_preedit("xy", None, v).unwrap(); + s.tool_ime_delete_surrounding(2, 0, SurroundingUnit::Bytes, v).unwrap(); + s.tool_text("א", v).unwrap(); + key(&mut s, ToolKey::Escape); + assert_eq!(texts(&s).iter().map(|t| t.2.as_str()).collect::>(), ["שלוא"]); +} + +/// The Type tool's IME owner is stable across ordinary edits in one document and changes with the +/// document (a new tab/file has a new process-unique uid). +#[test] +fn ime_target_survives_edits_and_changes_with_the_document() { + let mut s = session(); + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + click(&mut s, 100.0, 100.0); + let first = s.tool_ime_target(v).expect("editing has a target"); + s.tool_text("A", v).unwrap(); + assert_eq!(s.tool_ime_target(v), Some(first), "ordinary typing keeps the owner"); + key(&mut s, ToolKey::Left); + assert_eq!(s.tool_ime_target(v), Some(first), "caret moves keep the owner"); + key(&mut s, ToolKey::Escape); + + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + s.select_tool("type", v).unwrap(); + click(&mut s, 100.0, 100.0); + let second = s.tool_ime_target(v).expect("the new document's type has a target"); + assert_ne!(first.0, second.0, "a document replacement is a new IME owner"); +} + +/// The outgoing surrounding-text source excludes the pre-edit and reports UTF-8 byte offsets. +#[test] +fn ime_surrounding_excludes_the_preedit_and_remaps_offsets() { + let mut s = session(); + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + click(&mut s, 100.0, 100.0); + s.tool_text("Hé", v).unwrap(); + s.tool_preedit("xy", None, v).unwrap(); + let vectorcraft_tools::ImeSurrounding::Available { text, cursor, anchor } = s.tool_ime_surrounding(4000, v) else { + panic!("type editing has a context"); + }; + assert_eq!(text, "Hé"); + assert_eq!((cursor, anchor), (3, 3), "the caret sits after the committed text, before the pre-edit"); +} diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index c29ca1a26..7b6065013 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -5,7 +5,7 @@ use vectorcraft_doc::Selection; use vectorcraft_geom::Point; use vectorcraft_tools::distort::perspective::widget::WidgetPlace; use vectorcraft_tools::draw2::FEEDBACK; -use vectorcraft_tools::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, settings}; +use vectorcraft_tools::{Action, Cursor, ImeSurrounding, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, settings}; use crate::{EngineError, Prefs, Result, Session}; @@ -429,6 +429,20 @@ impl Session { self.apply_actions(acts) } + /// Would [`Session::tool_text`] insert `text` unchanged (no control-character filtering, + /// newline normalization or document formatting such as Typographer's Quotes)? An IME commit + /// the application rewrites is an application edit, not an accepted IME insertion. + pub fn tool_text_is_verbatim(&mut self, text: &str, view: ViewInfo) -> bool { + self.with_tool_cx(view, |t, cx| t.text_input_is_verbatim(cx, text)) + } + + /// Does the active tool's open typing session still match the document? An IME commit after + /// an external edit starts a fresh session (an application-side reset), so its receipt must + /// not be accepted as an unchanged IME insertion. + pub fn tool_ime_session_matches(&mut self, view: ViewInfo) -> bool { + self.with_tool_cx(view, |t, cx| t.ime_session_matches_document(cx)) + } + pub fn tool_wants_text(&self) -> bool { self.tool.wants_text() } @@ -441,6 +455,37 @@ impl Session { self.apply_actions(acts) } + /// An IME replaces text around its composition before committing: delete `before`/`after` + /// units around the selection or pre-edit. The selection/pre-edit itself is preserved (the + /// following commit replaces it), and the commit shares the typing session, so the replacement + /// is one undo step. + pub fn tool_ime_delete_surrounding( + &mut self, + before: usize, + after: usize, + unit: vectorcraft_tools::SurroundingUnit, + view: ViewInfo, + ) -> Result> { + let acts = self.with_tool_cx(view, |t, cx| t.ime_delete_surrounding(cx, before, after, unit)); + self.take_tool_panic()?; + self.apply_actions(acts) + } + + /// The active tool's stable IME owner `(document uid, target id, replacement epoch)`: the + /// document uid changes on document replacement, the epoch when the same target is started + /// afresh. Ordinary edits and caret moves leave both unchanged. + pub fn tool_ime_target(&mut self, view: ViewInfo) -> Option<(u64, u64, u64)> { + self.with_tool_cx(view, |t, cx| t.ime_target(cx)) + } + + /// The committed logical text around the IME cursor (UTF-8 byte offsets), with any pre-edit + /// removed and at most `max_bytes` bytes copied: the app's outgoing surrounding-text producer + /// (egui E21b) reads it. An explicit [`ImeSurrounding::Unavailable`] carries the narrow reason + /// (no editor, invalid offsets, or a selection wider than the limit). + pub fn tool_ime_surrounding(&mut self, max_bytes: usize, view: ViewInfo) -> ImeSurrounding { + self.with_tool_cx(view, |t, cx| t.ime_surrounding(cx, max_bytes)) + } + /// Is the active tool showing uncommitted IME text? pub fn tool_composing(&self) -> bool { self.tool.composing() diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index e6eeaa09f..29b77f38e 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -770,6 +770,31 @@ impl Cursor { } } +/// What an IME's "delete the text around the cursor" counts: UTF-8 bytes (winit's raw +/// `DeleteSurroundingBytes`) or Unicode scalar values (egui's `DeleteSurrounding`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SurroundingUnit { + Bytes, + Chars, +} + +/// The committed logical text around an IME cursor, or the documented reason it is unavailable. +/// +/// This type stays free of any UI dependency: offsets are UTF-8 bytes into `text`, `cursor` is the +/// primary selection end and `anchor` the other (so `cursor > anchor` is a reversed selection). +/// `text` is already bounded to the caller's request and has the pre-edit removed. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ImeSurrounding { + Available { text: String, cursor: usize, anchor: usize }, + Unavailable(&'static str), +} + +impl Default for ImeSurrounding { + fn default() -> Self { + Self::Unavailable("no document is active") + } +} + /// A tool state machine. pub trait Tool: Send { fn id(&self) -> &'static str; @@ -809,6 +834,19 @@ pub trait Tool: Send { fn text_input(&mut self, _cx: &ToolContext, _s: &str) -> Vec { vec![] } + /// Would [`Tool::text_input`] insert `text` exactly as given? False when it filters control + /// characters, normalizes newlines, applies Typographer's Quotes or otherwise rewrites the + /// insertion. An IME commit the application rewrites is an application edit: its receipt must + /// be `Other`, never `InputMethod`. + fn text_input_is_verbatim(&self, _cx: &ToolContext, _text: &str) -> bool { + true + } + /// Does the tool's open typing session still match the document? False when an external edit + /// replaced the text under it: the next IME commit starts a fresh session, which is an + /// application-side reset, not an accepted IME insertion. + fn ime_session_matches_document(&self, _cx: &ToolContext) -> bool { + true + } /// IME composition (marked text) for the tool that wants text: `text` replaces the previous /// marked text (or the selection); an empty `text` ends the composition. `active_chars` is the /// clause being converted, in characters of `text`. The committed result arrives through @@ -816,6 +854,27 @@ pub trait Tool: Send { fn ime_preedit(&mut self, _cx: &ToolContext, _text: &str, _active_chars: Option>) -> Vec { vec![] } + /// An input method replaces text around its composition: delete `before` and `after` units + /// (bytes or characters, per [`SurroundingUnit`]) around the selection or pre-edit, in the + /// same typing session as the commit that follows. The selection/pre-edit itself is preserved + /// (the following commit replaces it), its direction is kept, and a unit count ending inside a + /// UTF-8 scalar leaves that scalar whole. + fn ime_delete_surrounding(&mut self, _cx: &ToolContext, _before: usize, _after: usize, _unit: SurroundingUnit) -> Vec { + vec![] + } + /// Stable IME owner data for the tool's current edit target: `(document uid, target id, + /// replacement epoch)`. The owner survives ordinary edits and caret moves; the replacement + /// epoch changes when the same target is started afresh (a new edit session). + fn ime_target(&self, _cx: &ToolContext) -> Option<(u64, u64, u64)> { + None + } + /// The committed logical text around the IME cursor, with UTF-8 byte offsets (`cursor` is the + /// primary end, `anchor` the other): the source for an outgoing surrounding-text payload + /// (egui's E21b). Pre-edit text is excluded and the offsets are remapped into the remaining + /// text; at most `max_bytes` bytes are copied (a wider selection is explicitly unavailable). + fn ime_surrounding(&self, _cx: &ToolContext, _max_bytes: usize) -> ImeSurrounding { + ImeSurrounding::Unavailable("the active tool has no IME text context") + } /// Is uncommitted IME text being shown? Keys, shortcuts and Undo wait while it is. fn composing(&self) -> bool { false diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index 16418ae8f..f982add4a 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -21,7 +21,7 @@ //! underlined, until the IME commits it (it then goes through [`Tool::text_input`]) or clears it. use std::ops::Range; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, PoisonError}; use serde_json::{Value, json}; use vectorcraft_doc::hit::hit_test; @@ -30,7 +30,7 @@ use vectorcraft_geom::{BezPath, Point, Rect, Shape}; use vectorcraft_text::{FontDb, TextLayout, edit}; use crate::guides::DrawSnap; -use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; +use crate::{Action, Cursor, ImeSurrounding, Mods, Overlay, PointerEvent, PointerKind, SurroundingUnit, Tool, ToolContext, ToolKey}; /// Which of the Type tools this is. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -63,6 +63,23 @@ struct Preedit { /// The edited text's layout, keyed by the font database's generation and the text. type CachedLayout = ((u64, TextObject), Arc); +/// Process-global Type-tool edit-session counter: the IME target's replacement epoch. +/// +/// A per-tool counter restarts at zero when the tool is recreated (a document switch, File › +/// Revert), while the document keeps its uid and node ids. The same `(document, node)` would then +/// receive an epoch a still-cached native IME context already used, violating the replacement +/// contract that an old context can never edit the new owner. Sessions are therefore numbered for +/// the process lifetime; the counter only stops changing after 2^64 sessions (unreachable). +/// A mutex (not an `AtomicU64`) keeps this available on wasm targets without 64-bit atomics. +static NEXT_EDIT_SESSION: Mutex = Mutex::new(0); + +fn next_edit_session() -> u64 { + let mut next = NEXT_EDIT_SESSION.lock().unwrap_or_else(PoisonError::into_inner); + let session = next.checked_add(1).unwrap_or(u64::MAX); + *next = session; + session +} + #[derive(Default)] pub struct TypeTool { vertical: bool, @@ -71,6 +88,11 @@ pub struct TypeTool { prev_tool: Option, /// Text object being edited. editing: Option, + /// Monotonic identity of this edit session (the IME target's replacement epoch): it changes + /// whenever editing starts afresh, including on the same text object, and never on ordinary + /// edits or caret moves. Numbered process-wide ([`NEXT_EDIT_SESSION`]), so recreating the tool + /// (File › Revert, a document switch) cannot reuse an epoch of the same document and node. + edit_session: u64, /// The point type a click just placed (edited now): discarded if empty when editing ends. fresh: Option, /// Caret and selection anchor (byte offsets into the plain text). @@ -80,6 +102,10 @@ pub struct TypeTool { goal_x: Option, typing: Option, preedit: Option, + /// A command from outside the tool (`settle_interaction`, an external edit while typing) ended + /// the typing session, while the text stays open for editing. A commit over that edit is + /// application-origin unless an IME deletion has already begun a fresh typing session. + typing_ended: bool, /// Where the button went down, and where new type goes from there (snapped to Smart Guides). press: Option<(Point, Point)>, /// The other corner of an area dragged out (snapped). @@ -129,16 +155,74 @@ impl TypeTool { } fn plain(&self, cx: &ToolContext) -> String { + let document = self.editing.and_then(|id| Self::text(cx, id)); match &self.typing { + // A session the document changed under is not the edited text any more: use the + // document's own runs (word/paragraph selection must not index stale text). + Some(ty) if document.is_some_and(|t| ty.cur != t.runs) => document.map(|t| t.plain_text()).unwrap_or_default(), Some(ty) => ty.cur.iter().map(|r| r.text.as_str()).collect(), - None => self.editing.and_then(|id| Self::text(cx, id)).map(|t| t.plain_text()).unwrap_or_default(), + None => document.map(|t| t.plain_text()).unwrap_or_default(), } } - /// The edited object as it currently is (with the typing session's runs). + /// The edited plain text as borrowed run slices: the IME query copies only the bounded window + /// it needs, never the whole document text. A typing session whose runs no longer match the + /// document (an external edit) is not used; the document's runs are. + fn plain_segments<'a>(&'a self, cx: &'a ToolContext) -> Option> { + let id = self.editing?; + let t = Self::text(cx, id)?; + match &self.typing { + Some(ty) if ty.cur == t.runs => Some(ty.cur.iter().map(|r| r.text.as_str()).collect()), + _ => Some(t.runs.iter().map(|r| r.text.as_str()).collect()), + } + } + + /// The exact text [`Tool::text_input`] would insert for `s`: control characters removed, `\r` + /// normalized to `\n`, and Typographer's Quotes applied to a straight quote. `None` when + /// nothing would be inserted. Shared with [`Tool::text_input_is_verbatim`] so the IME receipt + /// and the insertion cannot disagree about what was transformed. + fn input_text(&self, cx: &ToolContext, s: &str) -> Option { + let s: String = s.chars().filter(|c| !c.is_control() || matches!(c, '\n' | '\t')).map(|c| if c == '\r' { '\n' } else { c }).collect(); + if s.is_empty() { + return None; + } + // Use Typographer's Quotes (Document Setup): a typed straight quote becomes the document's + // opening or closing quote, by the character before it. + let setup = &cx.doc.setup; + if setup.typographers_quotes && matches!(s.as_str(), "\"" | "'") { + let mut prev = self.char_before(cx, self.caret.min(self.anchor)); + return Some(setup.quotes.apply(&s, &mut prev)); + } + Some(s) + } + + /// The character just before byte `at` in the edited plain text, walked run by run (the quote + /// direction needs one character, not a copy of the whole text). + fn char_before(&self, cx: &ToolContext, at: usize) -> Option { + let segments = self.plain_segments(cx)?; + let mut acc = 0usize; + let mut prev = None; + for segment in &segments { + let end = acc + segment.len(); + if at <= acc { + break; + } + let local = at.min(end).saturating_sub(acc); + if let Some((_, c)) = segment.char_indices().take_while(|(i, _)| *i < local).last() { + prev = Some(c); + } + acc = end; + } + prev + } + + /// The edited object as it currently is (with the typing session's runs). A session the + /// document changed under is dropped: the document's own runs win. fn current(&self, cx: &ToolContext) -> Option { let mut t = Self::text(cx, self.editing?)?.clone(); - if let Some(ty) = &self.typing { + if let Some(ty) = &self.typing + && ty.cur == t.runs + { t.runs = ty.cur.clone(); } Some(t) @@ -183,9 +267,11 @@ impl TypeTool { if self.typing.as_ref().is_some_and(|ty| ty.cur != t.runs) { out.push(Action::Commit); self.typing = None; + self.preedit = None; } if self.typing.is_none() { out.push(Action::Begin("Typing".into())); + self.typing_ended = false; } let ty = self.typing.get_or_insert_with(|| Typing { base: t.runs.clone(), cur: t.runs.clone(), lo: usize::MAX }); let len = edit::runs_len(&ty.cur); @@ -219,6 +305,7 @@ impl TypeTool { } fn start_editing(&mut self, id: NodeId, caret: usize) { + self.edit_session = next_edit_session(); self.editing = Some(id); self.fresh = None; self.caret = caret; @@ -226,6 +313,7 @@ impl TypeTool { self.goal_x = None; self.typing = None; self.preedit = None; + self.typing_ended = false; } /// Byte under `p` in the edited text if `p` is inside it (frame, path band or layout bounds). @@ -326,6 +414,68 @@ impl TypeTool { out.push(Action::Notify("text.editNew".into())); out } + + /// Delete plain-text byte ranges in one typing session, shifting the caret, anchor and any + /// pre-edit range so the selection's direction and the marked text are preserved. + fn delete_ranges(&mut self, cx: &ToolContext, ranges: &[(usize, usize)]) -> Vec { + let Some(id) = self.editing else { return vec![] }; + let Some(t) = Self::text(cx, id) else { + self.editing = None; + return vec![]; + }; + let mut out = vec![]; + // The document changed under us (undo, a panel edit): start a new session. + if self.typing.as_ref().is_some_and(|ty| ty.cur != t.runs) { + out.push(Action::Commit); + self.typing = None; + // The marked text belonged to the replaced session; it must not be sliced out of the + // new document text by a stale range. + self.preedit = None; + } + if self.typing.is_none() { + out.push(Action::Begin("Typing".into())); + self.typing_ended = false; + } + let len = match &self.typing { + Some(ty) => edit::runs_len(&ty.cur), + None => edit::runs_len(&t.runs), + }; + let mut sorted: Vec<(usize, usize)> = ranges.iter().map(|&(a, b)| (a.min(b).min(len), a.max(b).min(len))).filter(|(a, b)| a < b).collect(); + sorted.sort_unstable(); + if sorted.is_empty() { + return out; + } + // Shift the selection ends and the pre-edit by the bytes removed before them. The ranges + // lie outside the selection/pre-edit, so both ends move by the same preceding amount and + // a reversed selection stays reversed. + let shift = |at: usize| -> usize { + let removed: usize = sorted.iter().map(|&(a, b)| if at >= b { b - a } else { at.saturating_sub(a) }).sum(); + at.saturating_sub(removed) + }; + let (caret, anchor) = (self.caret.min(len), self.anchor.min(len)); + self.caret = shift(caret); + self.anchor = shift(anchor); + if let Some(p) = &mut self.preedit { + p.range.start = shift(p.range.start.min(len)); + p.range.end = shift(p.range.end.min(len)); + } + self.goal_x = None; + let ty = self.typing.get_or_insert_with(|| Typing { base: t.runs.clone(), cur: t.runs.clone(), lo: usize::MAX }); + // Delete from the end so earlier offsets stay valid; each call floors to a char boundary + // and the one Preview below still makes the whole session one undo step. + for &(a, b) in sorted.iter().rev() { + ty.lo = ty.lo.min(a); + edit::replace_range(&mut ty.cur, a, b, ""); + } + // One preview for the whole session, exactly as `replace` does. + let base: String = ty.base.iter().map(|r| r.text.as_str()).collect(); + let cur: String = ty.cur.iter().map(|r| r.text.as_str()).collect(); + let (p, s) = common_affixes(&base, &cur, ty.lo); + let runs = edit::slice_runs(&ty.cur, p, cur.len() - s); + let runs = if runs.is_empty() { json!([]) } else { serde_json::to_value(&runs).unwrap_or(json!([])) }; + out.push(Action::Preview("text.editRange".into(), json!({"id": id.0, "start": p, "end": base.len() - s, "runs": runs}))); + out + } } /// How far (screen px) the ports of area type sit from the frame's corners, along its sides: the @@ -555,6 +705,115 @@ fn common_affixes(a: &str, b: &str, max_prefix: usize) -> (usize, usize) { (p, s) } +/// Byte length of the borrowed plain-text segments. +fn plain_len(segments: &[&str]) -> usize { + segments.iter().map(|s| s.len()).sum() +} + +/// Is `byte` a UTF-8 boundary in the concatenation of `segments`? Run edges are boundaries. +fn plain_is_boundary(segments: &[&str], byte: usize) -> bool { + let mut at = 0usize; + for segment in segments { + if byte == at { + return true; + } + if byte < at + segment.len() { + return segment.is_char_boundary(byte - at); + } + at += segment.len(); + } + byte == at +} + +/// Append the bytes `start..end` of the concatenation of `segments` to `out`. +fn copy_plain_range(segments: &[&str], start: usize, end: usize, out: &mut String) { + let mut at = 0usize; + for segment in segments { + let segment_end = at + segment.len(); + let from = start.max(at); + let to = end.min(segment_end); + if from < to { + let (a, b) = (from - at, to - at); + if segment.is_char_boundary(a) && segment.is_char_boundary(b) { + out.push_str(&segment[a..b]); + } + } + if segment_end >= end { + break; + } + at = segment_end; + } +} + +/// Move `i` back to a character boundary in the concatenation of `segments` (deleting fewer +/// bytes rather than splitting UTF-8). +fn plain_floor_boundary(segments: &[&str], mut i: usize) -> usize { + i = i.min(plain_len(segments)); + while i > 0 && !plain_is_boundary(segments, i) { + i -= 1; + } + i +} + +/// Move `i` forward to a character boundary in the concatenation of `segments`. +fn plain_ceil_boundary(segments: &[&str], mut i: usize) -> usize { + let len = plain_len(segments); + i = i.min(len); + while i < len && !plain_is_boundary(segments, i) { + i += 1; + } + i +} + +/// The byte offset `n` characters before `at` (a boundary) in `segments`, stopping at the start of +/// the text. Walks the runs, so a large IME deletion count never copies the whole text. +fn plain_chars_before(segments: &[&str], at: usize, n: usize) -> usize { + if n == 0 { + return at; + } + let mut remaining = n; + let mut end = plain_len(segments); + for segment in segments.iter().rev() { + let start = end - segment.len(); + let local_end = at.min(end).saturating_sub(start); + if local_end > 0 && segment.is_char_boundary(local_end) { + let before = &segment[..local_end]; + let count = before.chars().count(); + if count >= remaining { + return start + before.char_indices().rev().nth(remaining.saturating_sub(1)).map_or(0, |(i, _)| i); + } + remaining -= count; + } + end = start; + } + 0 +} + +/// The byte offset `n` characters after `at` (a boundary) in `segments`, stopping at the end of +/// the text. +fn plain_chars_after(segments: &[&str], at: usize, n: usize) -> usize { + if n == 0 { + return at; + } + let len = plain_len(segments); + let mut remaining = n; + let mut acc = 0usize; + for segment in segments { + let end = acc + segment.len(); + let local_start = at.saturating_sub(acc); + if end > at && segment.is_char_boundary(local_start) { + let after = &segment[local_start..]; + let count = after.chars().count(); + if count >= remaining { + return acc + local_start + after.char_indices().nth(remaining).map_or(after.len(), |(i, _)| i); + } + remaining -= count; + } + acc = end; + } + len +} + impl Tool for TypeTool { fn id(&self) -> &'static str { match (self.mode, self.vertical) { @@ -643,7 +902,6 @@ impl Tool for TypeTool { if self.editing.is_none() { return vec![]; } - let s: String = s.chars().filter(|c| !c.is_control() || matches!(c, '\n' | '\t')).map(|c| if c == '\r' { '\n' } else { c }).collect(); // An IME commit replaces its marked text (an empty commit just removes it). let committing = self.preedit.is_some(); if committing && s.is_empty() { @@ -653,19 +911,7 @@ impl Tool for TypeTool { self.anchor = p.range.start; self.caret = p.range.end; } - if s.is_empty() { - return vec![]; - } - // Use Typographer's Quotes (Document Setup): a typed straight quote becomes the document's - // opening or closing quote, by the character before it. - let setup = &cx.doc.setup; - let s = if setup.typographers_quotes && matches!(s.as_str(), "\"" | "'") { - let text = self.plain(cx); - let mut prev = text.get(..self.caret.min(self.anchor)).and_then(|b| b.chars().next_back()); - setup.quotes.apply(&s, &mut prev) - } else { - s - }; + let Some(s) = self.input_text(cx, s) else { return vec![] }; self.clicks = (None, 0); // Pasting what we copied keeps its formatting (unless pasted text is kept plain). let clip: String = self.clipboard.iter().map(|r| r.text.as_str()).collect(); @@ -675,6 +921,40 @@ impl Tool for TypeTool { } self.replace(cx, &s, None) } + /// Would [`Tool::text_input`] change `s` before inserting it? Control characters are dropped, + /// `\r` becomes `\n`, Typographer's Quotes rewrites a straight quote, and copied text keeps + /// its run styles. An IME commit the application rewrites is an application edit, not an + /// accepted IME insertion (its receipt must stay `Other`). + fn text_input_is_verbatim(&self, cx: &ToolContext, s: &str) -> bool { + if self.editing.is_none() { + return true; + } + if s.is_empty() { + return true; + } + // A straight quote's direction depends on the character before the insertion. During a + // commit this check still sees the pre-edit at the caret, while `text_input` replaces it + // from its start; with a custom pair whose opening quote is straight, that can look + // verbatim here and then be rewritten. Never claim verbatim for one. + if cx.doc.setup.typographers_quotes && matches!(s, "\"" | "'") { + return false; + } + let Some(normalized) = self.input_text(cx, s) else { return false }; + if normalized != s { + return false; + } + let clip: String = self.clipboard.iter().map(|r| r.text.as_str()).collect(); + !(self.preedit.is_none() && normalized.len() > 1 && normalized == clip && !cx.paste_plain_text) + } + fn ime_session_matches_document(&self, cx: &ToolContext) -> bool { + match (self.editing, &self.typing) { + (Some(id), Some(ty)) => Self::text(cx, id).is_none_or(|t| ty.cur == t.runs), + // An outside command settled the session: the document may have changed under it, and + // the commit that starts a new session cannot claim the IME made that change. + (Some(_), None) => !self.typing_ended, + _ => true, + } + } fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec { // The IME owns the keys while it composes (the UI doesn't send them; this is a guard). if self.preedit.is_some() { @@ -841,6 +1121,102 @@ impl Tool for TypeTool { self.preedit = Some(Preedit { range: start..end, active: active_chars.and_then(|r| char_range_to_bytes(&text, r)) }); out } + fn ime_delete_surrounding(&mut self, cx: &ToolContext, before: usize, after: usize, unit: SurroundingUnit) -> Vec { + let Some(id) = self.editing else { return vec![] }; + // The document changed under the session (undo, a panel edit): the ranges below would index + // text that is no longer there. The commit that follows resets the session; do not delete + // from the wrong text in the meantime. + if let Some(t) = Self::text(cx, id) + && self.typing.as_ref().is_some_and(|ty| ty.cur != t.runs) + { + return vec![]; + } + // Walk the runs in place: joining the whole edited text here would copy a large document + // for every IME deletion event, though only the characters next to the selection matter. + let Some(segments) = self.plain_segments(cx) else { return vec![] }; + let len = plain_len(&segments); + // The text the IME's following commit replaces (the selection, or the marked text while + // composing). The surrounding deletion never removes it: it deletes only the bytes/characters + // before and after it, preserving the selection direction and the composition. + let (raw_start, raw_end) = match &self.preedit { + Some(p) => (p.range.start.min(len), p.range.end.min(len)), + None => (self.caret.min(self.anchor).min(len), self.caret.max(self.anchor).min(len)), + }; + let (before_start, start, end, after_end) = match unit { + SurroundingUnit::Bytes => { + // Inward rounding: a count ending inside a scalar leaves that scalar whole. The + // preceding side moves its start forward (ceil), the following side moves its end + // backward (floor). Do not reverse these: that would delete part of a character. + let start = plain_floor_boundary(&segments, raw_start); + let end = plain_ceil_boundary(&segments, raw_end); + let before_start = plain_ceil_boundary(&segments, start.saturating_sub(before)); + let after_end = plain_floor_boundary(&segments, end.saturating_add(after)); + (before_start, start, end, after_end) + } + SurroundingUnit::Chars => { + let start = plain_floor_boundary(&segments, raw_start); + let end = plain_ceil_boundary(&segments, raw_end); + let before_start = plain_chars_before(&segments, start, before); + let after_end = plain_chars_after(&segments, end, after); + (before_start, start, end, after_end) + } + }; + let mut ranges = Vec::with_capacity(2); + if before_start < start { + ranges.push((before_start, start)); + } + if end < after_end { + ranges.push((end, after_end)); + } + if ranges.is_empty() { + return vec![]; + } + self.delete_ranges(cx, &ranges) + } + fn ime_target(&self, cx: &ToolContext) -> Option<(u64, u64, u64)> { + let id = self.editing?; + Self::text(cx, id)?; + Some((cx.revision.0, id.0, self.edit_session)) + } + fn ime_surrounding(&self, cx: &ToolContext, max_bytes: usize) -> ImeSurrounding { + let Some(id) = self.editing else { + return ImeSurrounding::Unavailable("no text object is being edited"); + }; + let Some(current) = Self::text(cx, id) else { + return ImeSurrounding::Unavailable("the edited text object is gone"); + }; + // The document changed under the typing session (undo, a panel or control edit): the + // session's runs, marked-text range and caret offsets no longer describe the document. + // Publish the document's own text (never the stale session's, which the IME cannot see); + // the marked text has no range in it, so it is excluded from the logical view. The + // commit-time check (`ime_session_matches_document`) reports the reset as application + // origin instead of letting a receipt describe the wrong text. + let stale = self.typing.as_ref().is_some_and(|ty| ty.cur != current.runs); + if self.preedit.is_some() && self.typing.is_none() { + return ImeSurrounding::Unavailable("the IME pre-edit has no typing session"); + } + let Some(segments) = self.plain_segments(cx) else { + return ImeSurrounding::Unavailable("the edited text object is gone"); + }; + let len = plain_len(&segments); + let caret = self.caret.min(len); + let anchor = self.anchor.min(len); + if !plain_is_boundary(&segments, caret) || !plain_is_boundary(&segments, anchor) { + return ImeSurrounding::Unavailable("the caret or selection anchor is not a UTF-8 boundary"); + } + let preedit = match self.preedit.as_ref().filter(|_| !stale) { + Some(p) => { + let start = p.range.start.min(len); + let end = p.range.end.min(len); + if start > end || !plain_is_boundary(&segments, start) || !plain_is_boundary(&segments, end) { + return ImeSurrounding::Unavailable("the IME pre-edit is not on UTF-8 boundaries"); + } + Some(start..end) + } + None => None, + }; + ime_surrounding_window(&segments, preedit, len, caret, anchor, max_bytes) + } fn composing(&self) -> bool { self.preedit.is_some() } @@ -928,6 +1304,7 @@ impl Tool for TypeTool { "commitTyping" | "endTyping" => { self.typing = None; self.preedit = None; + self.typing_ended = true; } "selectAll" if self.editing.is_some() => { self.anchor = 0; @@ -1041,6 +1418,90 @@ impl Tool for TypeTool { } } +/// The logical IME surrounding window: the pre-edit is removed and the caret/anchor offsets +/// are remapped around it. `preedit` has already been validated against `segments`. +fn ime_surrounding_window( + segments: &[&str], + preedit: Option>, + len: usize, + caret: usize, + anchor: usize, + max_bytes: usize, +) -> ImeSurrounding { + let removed = preedit.as_ref().map_or(0, |r| r.end - r.start); + let logical_len = len - removed; + let to_logical = |byte: usize| -> usize { + match &preedit { + Some(r) if byte > r.start && byte < r.end => r.start, + Some(r) if byte >= r.end => byte - removed, + _ => byte, + } + }; + let cursor = to_logical(caret); + let anchor = to_logical(anchor); + let selection_start = cursor.min(anchor); + let selection_end = cursor.max(anchor); + let selection_len = selection_end - selection_start; + if selection_len > max_bytes { + return ImeSurrounding::Unavailable("the selection is wider than the IME surrounding-text limit"); + } + let budget = max_bytes - selection_len; + let before_available = selection_start; + let after_available = logical_len - selection_end; + let mut before = (budget / 2).min(before_available); + let mut after = (budget - before).min(after_available); + before = (budget - after).min(before_available); + after = (budget - before).min(after_available); + let mut window_start = selection_start - before; + let mut window_end = selection_end + after; + let logical_boundary = |byte: usize| -> bool { + match &preedit { + Some(r) if byte == r.start => true, + Some(r) if byte < r.start => plain_is_boundary(segments, byte), + Some(_) => plain_is_boundary(segments, byte + removed), + None => plain_is_boundary(segments, byte), + } + }; + while window_start < selection_start && !logical_boundary(window_start) { + window_start += 1; + } + while selection_end < window_end && !logical_boundary(window_end) { + window_end -= 1; + } + let to_original = |byte: usize| -> usize { + match &preedit { + Some(r) if byte > r.start => byte + removed, + _ => byte, + } + }; + let original_start = to_original(window_start); + let original_end = to_original(window_end); + let mut text = String::with_capacity(window_end - window_start); + match &preedit { + Some(r) => { + if window_start < r.start { + let first_end = original_end.min(r.start); + if original_start < first_end { + copy_plain_range(segments, original_start, first_end, &mut text); + } + } + if r.start < window_end { + let second_start = r.end.max(original_start); + if second_start < original_end { + copy_plain_range(segments, second_start, original_end, &mut text); + } + } + } + None => { + if original_start < original_end { + copy_plain_range(segments, original_start, original_end, &mut text); + } + } + } + debug_assert_eq!(text.len(), window_end - window_start, "removing the pre-edit must preserve the logical window length"); + ImeSurrounding::Available { text, cursor: cursor - window_start, anchor: anchor - window_start } +} + #[cfg(test)] #[path = "text_tests.rs"] mod tests; diff --git a/crates/tools/src/text_tests.rs b/crates/tools/src/text_tests.rs index f96d391f2..f5a454d09 100644 --- a/crates/tools/src/text_tests.rs +++ b/crates/tools/src/text_tests.rs @@ -2,6 +2,7 @@ //! `tests_textedit`). use super::*; +use crate::ImeSurrounding; use crate::testutil::{cx, paint}; use vectorcraft_doc::{CharStyle, Document, Node, NodeKind, Selection, TextObject}; use vectorcraft_text::FontDb; @@ -469,6 +470,29 @@ fn ime_ranges_convert_characters_to_bytes() { assert_eq!(char_range_to_bytes("ががく", backwards), None, "backwards"); } +/// Outgoing surrounding text excludes the pre-edit and remaps a selection that crosses it, not +/// only a caret at its end. +#[test] +fn ime_surrounding_remaps_a_selection_across_the_preedit() { + let (mut d, _id, mut tool) = editing("abcd"); + let snap = d.clone(); + let (sel, p) = (Selection::default(), paint()); + tool.caret = 2; + tool.anchor = 2; + let acts = tool.ime_preedit(&cx(&d, &sel, &p), "xy", None); + apply(&mut d, &snap, &acts); + assert_eq!(tool.preedit.as_ref().map(|p| p.range.clone()), Some(2..4)); + // A selection from before the marked text to after it: the IME receives the committed text + // with both offsets folded across the removed pre-edit. + tool.anchor = 1; + tool.caret = 6; + let ImeSurrounding::Available { text, cursor, anchor } = tool.ime_surrounding(&cx(&d, &sel, &p), 4000) else { + panic!("the pre-edit has committed text around it"); + }; + assert_eq!(text, "abcd"); + assert_eq!((cursor, anchor), (4, 1)); +} + #[test] fn marked_text_is_underlined_with_the_converting_clause_thick() { let (mut d, id, mut tool) = editing("曲"); @@ -878,3 +902,200 @@ fn type_on_a_path_has_its_ports_beyond_its_brackets() { assert!(near(port_centre(t, Port::Out, 1.0), Point::new(400.0 + PORT_RISE_PX, 300.0)), "{:?}", port_centre(t, Port::Out, 1.0)); assert!(near(port_centre(t, Port::In, 0.5), Point::new(160.0 - PORT_RISE_PX / 2.0, 300.0)), "screen pixels at 200%"); } + +/// The outgoing query copies only the 4 KiB window around the selection, not the whole document, +/// and reports offsets into that window. +#[test] +fn ime_surrounding_bounds_a_long_document() { + let source: String = (0..10_000).map(|i| char::from(b'a' + (i % 26) as u8)).collect(); + let (d, _id, mut tool) = editing(&source); + tool.caret = 5000; + tool.anchor = 5000; + let ImeSurrounding::Available { text, cursor, anchor } = tool.ime_surrounding(&cx(&d, &Selection::default(), &paint()), 4000) else { + panic!("a caret in a long document is available"); + }; + assert_eq!(text.len(), 4000, "only the bounded window is copied"); + assert_eq!(text.as_str(), &source[3000..7000]); + assert_eq!((cursor, anchor), (2000, 2000), "offsets are relative to the returned window"); + + tool.anchor = 0; + let ImeSurrounding::Unavailable(reason) = tool.ime_surrounding(&cx(&d, &Selection::default(), &paint()), 4000) else { + panic!("a selection wider than the limit must be explicit"); + }; + assert!(reason.contains("selection"), "{reason}"); +} + +/// Unicode offsets are UTF-8 bytes and a reversed selection stays reversed through the query. +#[test] +fn ime_surrounding_preserves_reversed_unicode_selection_and_empty_text() { + let (d, _id, mut tool) = editing("aé😀z"); + tool.caret = 1; + tool.anchor = 7; + let ImeSurrounding::Available { text, cursor, anchor } = tool.ime_surrounding(&cx(&d, &Selection::default(), &paint()), 4000) else { + panic!("the short text is available"); + }; + assert_eq!(text, "aé😀z"); + assert_eq!((cursor, anchor), (1, 7), "cursor is the primary end, anchor the other"); + + let (d, _id, tool) = editing(""); + let ImeSurrounding::Available { text, cursor, anchor } = tool.ime_surrounding(&cx(&d, &Selection::default(), &paint()), 4000) else { + panic!("an empty document is available, not unavailable"); + }; + assert_eq!((text.as_str(), cursor, anchor), ("", 0, 0)); +} + +/// Deleting around a selection removes only the surrounding text, keeps the reversed selection +/// direction, and leaves it for the following commit to replace. +#[test] +fn ime_delete_surrounding_preserves_a_reversed_selection() { + let (mut d, _id, mut tool) = editing("abcd"); + let snap = d.clone(); + let (sel, p) = (Selection::default(), paint()); + tool.anchor = 3; // "bc", selected backwards + tool.caret = 1; + let acts = tool.ime_delete_surrounding(&cx(&d, &sel, &p), 1, 1, SurroundingUnit::Bytes); + apply(&mut d, &snap, &acts); + let NodeKind::Text(t) = &d.node(_id).unwrap().kind else { panic!() }; + assert_eq!(t.plain_text(), "bc", "the selection itself is preserved"); + assert_eq!((tool.caret, tool.anchor), (0, 2), "direction is kept, shifted left by the preceding deletion"); + let ImeSurrounding::Available { text, cursor, anchor } = tool.ime_surrounding(&cx(&d, &sel, &p), 4000) else { + panic!("the selection is still an available context"); + }; + assert_eq!((text.as_str(), cursor, anchor), ("bc", 0, 2)); +} + +/// Deleting around a composition removes only the surrounding committed text, keeps the pre-edit +/// in place for the commit to replace, and remaps the composition range. +#[test] +fn ime_delete_surrounding_preserves_the_preedit() { + let (mut d, id, mut tool) = editing("abcd"); + let snap = d.clone(); + let (sel, p) = (Selection::default(), paint()); + tool.caret = 2; + tool.anchor = 2; + let acts = tool.ime_preedit(&cx(&d, &sel, &p), "xy", None); + apply(&mut d, &snap, &acts); + assert_eq!(tool.preedit.as_ref().map(|p| p.range.clone()), Some(2..4)); + let acts = tool.ime_delete_surrounding(&cx(&d, &sel, &p), 1, 0, SurroundingUnit::Bytes); + apply(&mut d, &snap, &acts); + let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!() }; + assert_eq!(t.plain_text(), "axycd", "only the byte before the composition is deleted"); + assert_eq!(tool.preedit.as_ref().map(|p| p.range.clone()), Some(1..3), "the composition stayed and moved left"); + let ImeSurrounding::Available { text, cursor, anchor } = tool.ime_surrounding(&cx(&d, &sel, &p), 4000) else { + panic!("the composition has committed text around it"); + }; + assert_eq!((text.as_str(), cursor, anchor), ("acd", 1, 1), "the pre-edit is excluded and remapped"); +} + +/// Edit-session identity changes only when editing starts afresh, not on ordinary edits or caret +/// moves; the owner keeps the document uid and target id. +#[test] +fn ime_target_is_stable_within_an_edit_session() { + let (d, id, mut tool) = editing("abc"); + let (sel, p) = (Selection::default(), paint()); + let first = tool.ime_target(&cx(&d, &sel, &p)).expect("editing has an owner"); + assert_eq!((first.0, first.1), (0, id.0)); + tool.caret = 1; + tool.anchor = 3; + assert_eq!(tool.ime_target(&cx(&d, &sel, &p)), Some(first), "caret and selection moves keep the context"); + tool.start_editing(id, 0); + let second = tool.ime_target(&cx(&d, &sel, &p)).expect("re-entering has an owner"); + assert_eq!((second.0, second.1), (first.0, first.1)); + assert_ne!(second.2, first.2, "a new edit session is a replacement epoch"); +} + +/// The bounded query walks the styled runs without joining them, and its byte offsets still refer +/// to the concatenated logical text. +#[test] +fn ime_surrounding_maps_across_styled_runs() { + use vectorcraft_doc::TextRun; + let (mut d, id, mut tool) = editing("aé😀z"); + let NodeKind::Text(t) = &mut d.node_mut(id).unwrap().kind else { panic!() }; + t.runs = + vec![TextRun::new("aé", CharStyle { size: 20.0, ..Default::default() }), TextRun::new("😀z", CharStyle { size: 24.0, ..Default::default() })]; + tool.caret = 1; + tool.anchor = 7; + let ImeSurrounding::Available { text, cursor, anchor } = tool.ime_surrounding(&cx(&d, &Selection::default(), &paint()), 4000) else { + panic!("the short text is available"); + }; + assert_eq!((text.as_str(), cursor, anchor), ("aé😀z", 1, 7)); +} + +/// Deletion counts start at the caret, including when it is in a later styled run. Character +/// units count Unicode scalars; byte units round inward instead of cutting a multibyte scalar. +#[test] +fn ime_character_deletion_walks_styled_runs_backwards_from_the_caret() { + use vectorcraft_doc::TextRun; + for (caret, before, after, unit, expected, shifted, committed) in [ + (7, 1, 0, SurroundingUnit::Chars, "aéz!x", 3, "aéQz!x"), + (8, 3, 0, SurroundingUnit::Chars, "a!x", 1, "aQ!x"), + (7, 3, 0, SurroundingUnit::Bytes, "aé😀z!x", 7, "aé😀Qz!x"), + (7, 1, 1, SurroundingUnit::Chars, "aé!x", 3, "aéQ!x"), + (7, 4, 1, SurroundingUnit::Bytes, "aé!x", 3, "aéQ!x"), + (9, 1, 1, SurroundingUnit::Chars, "aé😀z", 8, "aé😀zQ"), + ] { + let (mut d, id, mut tool) = editing("aé😀z!x"); + let NodeKind::Text(t) = &mut d.node_mut(id).unwrap().kind else { panic!() }; + t.runs = vec![ + TextRun::new("aé", CharStyle { size: 20.0, ..Default::default() }), + TextRun::new("😀z", CharStyle { size: 24.0, ..Default::default() }), + TextRun::new("!x", CharStyle { size: 28.0, ..Default::default() }), + ]; + tool.caret = caret; + tool.anchor = caret; + let snap = d.clone(); + let (sel, p) = (Selection::default(), paint()); + let owner = tool.ime_target(&cx(&d, &sel, &p)); + let acts = tool.ime_delete_surrounding(&cx(&d, &sel, &p), before, after, unit); + apply(&mut d, &snap, &acts); + let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!() }; + assert_eq!(t.plain_text(), expected, "caret={caret} before={before} after={after} unit={unit:?}"); + assert_eq!((tool.caret, tool.anchor), (shifted, shifted)); + assert_eq!(tool.ime_target(&cx(&d, &sel, &p)), owner, "deletion preserves the immutable edit target"); + let acts = tool.text_input(&cx(&d, &sel, &p), "Q"); + apply(&mut d, &snap, &acts); + let NodeKind::Text(t) = &d.node(id).unwrap().kind else { panic!() }; + assert_eq!(t.plain_text(), committed, "the following commit uses the shifted caret"); + } +} + +/// A recreated Type tool (a document switch, File › Revert) must not reuse the replacement epoch +/// of the same document and text node: a still-cached native IME context would look current. +#[test] +fn a_recreated_type_tool_gets_a_new_replacement_epoch() { + let (d, id) = doc_with_text("Same"); + let (sel, p) = (Selection::default(), paint()); + let mut first = TypeTool::new("type"); + first.start_editing(id, 0); + let a = first.ime_target(&cx(&d, &sel, &p)).expect("the first tool edits"); + let mut second = TypeTool::new("type"); + second.start_editing(id, 0); + let b = second.ime_target(&cx(&d, &sel, &p)).expect("the recreated tool edits"); + assert_eq!((a.0, a.1), (b.0, b.1), "the owner key is the document and text node"); + assert_ne!(a.2, b.2, "edit sessions are numbered process-wide, not per tool"); +} + +/// The surrounding query never publishes a typing session's text after the document changed +/// outside it: it reports the document's own text (and drops the stale marked-text range), and the +/// commit-time session check keeps the reset from earning an IME receipt. +#[test] +fn ime_surrounding_reports_the_document_when_it_changed_under_the_session() { + let (mut d, id, mut tool) = editing("abc"); + let (sel, p) = (Selection::default(), paint()); + tool.caret = 3; + tool.anchor = 3; + let snap = d.clone(); + let acts = tool.text_input(&cx(&d, &sel, &p), "x"); + apply(&mut d, &snap, &acts); + let ImeSurrounding::Available { text, .. } = tool.ime_surrounding(&cx(&d, &sel, &p), 4000) else { + panic!("the session matches the document"); + }; + assert_eq!(text, "abcx"); + // A panel, menu or control edit replaces the text while the typing session is open. + let NodeKind::Text(t) = &mut d.node_mut(id).unwrap().kind else { panic!() }; + let len = edit::runs_len(&t.runs); + let _ = edit::replace_range(&mut t.runs, 0, len, "XYZ"); + let stale = tool.ime_surrounding(&cx(&d, &sel, &p), 4000); + assert_eq!(stale, ImeSurrounding::Available { text: "XYZ".into(), cursor: 3, anchor: 3 }); + assert!(!tool.ime_session_matches_document(&cx(&d, &sel, &p)), "the commit starts a fresh session"); +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index e480a95c4..77a09e60c 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -38,8 +38,11 @@ vectorcraft-pdf = { workspace = true } vectorcraft-cad = { workspace = true } vectorcraft-eps = { workspace = true } vectorcraft-format = { workspace = true } -egui = { workspace = true, features = ["bytemuck"] } +egui = { workspace = true, features = ["bytemuck", "default_fonts", "monochrome_emoji_fonts"] } # With egui's `bytemuck`: copies rendered rasters into egui images whole (`canvas::color_image`). +# `default_fonts`: egui 0.37 makes its bundled faces opt-in, and a dependency's dev-features do not +# reach our tests; `theme::install_fonts` starts from `FontDefinitions::default()`, which is empty +# without them (production would lay out tofu, and the no-interface-fonts tests would bind nothing). bytemuck = "1" egui_extras = { workspace = true } image = { workspace = true } diff --git a/crates/ui-egui/src/brand.rs b/crates/ui-egui/src/brand.rs index 461af137f..a1ecd3c15 100644 --- a/crates/ui-egui/src/brand.rs +++ b/crates/ui-egui/src/brand.rs @@ -20,7 +20,7 @@ fn decode() -> egui::ColorImage { /// The icon texture, uploaded on first use and kept in the context. fn texture(ctx: &Context) -> TextureHandle { - let id = Id::new("vectorcraft-brand-mark"); + let id = Id::unique("vectorcraft-brand-mark"); if let Some(tex) = ctx.data(|d| d.get_temp::(id)) { return tex; } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index fae470cb5..c8bbb7f83 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -132,15 +132,15 @@ pub(crate) fn end_document_drag(ctx: &egui::Context) { } fn drag_id() -> egui::Id { - egui::Id::new("canvas-drag") + egui::Id::unique("canvas-drag") } /// Where the pointer was last seen during a press on the canvas. fn drag_pos_id() -> egui::Id { - egui::Id::new("canvas-drag-pos") + egui::Id::unique("canvas-drag-pos") } /// The layer the canvas was last drawn on: a release over another layer is over a panel (#2826). fn drag_layer_id() -> egui::Id { - egui::Id::new("canvas-drag-layer") + egui::Id::unique("canvas-drag-layer") } /// Whether `p` is over the canvas itself, not a panel or a floating window over it. fn on_canvas(ui: &Ui, resp: &egui::Response, p: Pos2) -> bool { @@ -149,11 +149,11 @@ fn on_canvas(ui: &Ui, resp: &egui::Response, p: Pos2) -> bool { /// The document point last sent to the tool as a drag during [`Drag::Tool`]; none until the press /// has dragged. fn tool_drag_doc_id() -> egui::Id { - egui::Id::new("canvas-tool-drag-doc") + egui::Id::unique("canvas-tool-drag-doc") } /// The document point of the press that began [`Drag::Tool`], sent to the tool as its Down. fn tool_press_doc_id() -> egui::Id { - egui::Id::new("canvas-tool-press-doc") + egui::Id::unique("canvas-tool-press-doc") } /// Whether the tool should get another drag event, and if so records `p` as the last position /// sent: the pointer moved this frame, or its document position changed without a delta (#834). @@ -180,7 +180,7 @@ fn tool_drag_moved(ui: &Ui, xf: &Xf, p: Pos2, pointer: &egui::PointerState) -> b /// until a pen reports it). A mouse presses fully (1). fn pen_pressure(ui: &Ui, app: &VectorcraftApp, pressed: bool) -> f32 { let stylus = app.services.stylus.as_ref(); - let id = egui::Id::new("canvas-pressure"); + let id = egui::Id::unique("canvas-pressure"); let force = ui.input(|i| { i.events.iter().rev().find_map(|e| match e { egui::Event::Touch { force: Some(f), .. } if f.is_finite() => Some(f.clamp(0.0, 1.0)), @@ -277,11 +277,16 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // that does not draw the overlay drops them, for example with the option off, with the edges // hidden, on the Home screen or with no document open. let hidden_chars = std::mem::take(&mut app.canvas.hidden_chars); + // The IME geometry this pass, if the Type tool gets one; `ime::end_of_pass` consumes it after + // the dialogs and panels draw. + app.ime_caret_rect = None; // A document that opened, closed or became active since Home was chosen replaces it. if app.ui.home.is_some_and(|k| k != crate::menus::home_key(app)) { app.ui.home = None; } if crate::menus::home_showing(app) { + // No IME owner this pass (`ime_caret_rect` stays None): the end-of-pass observation marks + // the context absent and keeps any marked text as typed. home(app, ui, full); return; } @@ -305,7 +310,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if bars { scroll_bars(app, ui, rect, outer, &t); } - let resp = ui.interact(rect, egui::Id::new("canvas"), Sense::click_and_drag()); + let resp = ui.interact(rect, egui::Id::unique("canvas"), Sense::click_and_drag()); // A press on the canvas while the context menu is open only closes it (a drag too, which egui // alone would leave open); the tool doesn't get it. if app.ui.dialog.as_ref().is_some_and(crate::dialogs::revolve_gizmo::active) { @@ -682,7 +687,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if !revolve_edit { shaper_widget_overlay(app, &painter, &xf); } - ime_output(app, ui.ctx(), &xf); + ime_geometry(app, ui.ctx(), &xf); crate::place::paint_drop_highlight(app, ui.ctx(), &painter, rect); // A Rotate View drag (the tool, or Space+Shift with any tool) shows the view's angle on a dial // at the canvas center, the point the view turns about. @@ -742,7 +747,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // In Presentation Mode the pointer is the default one, with no tool cursor. let tool_cursor = (app.ui.screen_mode < 3).then(|| view_cursor.or_else(|| hover_cursor(app, mods(m, space), view_info, hover_still))); let cur = if let Some(c) = tool_cursor.flatten() { - let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("tool-cursor"))); + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("tool-cursor"))); // The loaded place cursor carries the file's thumbnail. if app.session.tool_id() == "place" && let Some(hp) = ui.input(|i| i.pointer.hover_pos()) @@ -936,7 +941,7 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon { fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: egui::Rect) { let line = ui.ctx().options(|o| o.input_options.line_scroll_speed); let wheel_zooms = app.session.prefs.zoom_with_mouse_wheel; - let turn_id = egui::Id::new("canvas-wheel-turn"); + let turn_id = egui::Id::unique("canvas-wheel-turn"); let turn_before: WheelTurn = ui.data(|d| d.get_temp(turn_id)).unwrap_or_default(); let mut turn = turn_before; let (pointer, m, space, (factor, scroll)) = @@ -987,7 +992,7 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: // A modifier pressed or released over the canvas re-hovers the tool, so what it changes shows // without moving the mouse (Alt switches the Shape Builder to erase mode). let mods_changed = ui.data_mut(|d| { - let id = egui::Id::new("canvas-mods"); + let id = egui::Id::unique("canvas-mods"); let prev = d.get_temp::(id); d.insert_temp(id, m); prev.is_some_and(|prev| prev != m) @@ -998,7 +1003,7 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: // Whether the tool hovered last frame: coming onto the canvas re-hovers it, even after a single // jump egui doesn't count as moving, and leaving it ends the tool's hover (below). let was_over = ui.data_mut(|d| { - let id = egui::Id::new("canvas-hovered"); + let id = egui::Id::unique("canvas-hovered"); let prev = d.get_temp::(id).unwrap_or(false); d.insert_temp(id, hover_tool.is_some()); prev @@ -1017,7 +1022,7 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: doc: app.session.active().map(|st| (st.uid, st.revision)), }; let (tool_switched, context_changed) = ui.data_mut(|d| { - let id = egui::Id::new("canvas-hover-context"); + let id = egui::Id::unique("canvas-hover-context"); let prev = d.get_temp::(id); d.insert_temp(id, context); prev.map_or((false, false), |prev| (prev.tool != context.tool, prev != context)) @@ -1399,7 +1404,7 @@ fn trackpad_turn(ui: &Ui, on: bool) -> Option { if deg == 0.0 || !deg.is_finite() { return None; } - let id = egui::Id::new("canvas-trackpad-turn"); + let id = egui::Id::unique("canvas-trackpad-turn"); let kept: Option = ui.data(|d| d.get_temp(id)); let mut t = kept.filter(|t| now - t.last <= ROTATE_GESTURE_END).unwrap_or(TrackpadTurn { total: 0.0, last: now, turning: false }); t.total += deg; @@ -1485,7 +1490,7 @@ fn scroll_bars(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, outer: egui: let len = if horizontal { track.width() } else { track.height() }; let (at, size) = span.thumb(len); let on_thumb = |p: Pos2| (at..=at + size).contains(&along(p)); - let id = egui::Id::new(("canvas-scroll-bar", k)); + let id = egui::Id::unique(("canvas-scroll-bar", k)); let resp = ui.interact(track, id, Sense::click_and_drag()); if resp.drag_started() && let Some(p) = ui.input(|i| i.pointer.press_origin()) @@ -1641,7 +1646,7 @@ fn touch_gestures(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, view: vec if touches.is_empty() { return; } - let key = egui::Id::new("canvas-touch"); + let key = egui::Id::unique("canvas-touch"); let mut taps: crate::touch::Taps = ui.data(|d| d.get_temp(key)).unwrap_or_default(); let mut tap = None; for (id, phase, pos) in touches { @@ -1775,7 +1780,7 @@ pub fn apply_requests(app: &mut VectorcraftApp, r: vectorcraft_engine::Result (egui::TextureHandle, f32) { let colors = setup.grid_colors.map(|c| crate::panels::c32(&c)); - let id = egui::Id::new("transparency-grid-tile"); + let id = egui::Id::unique("transparency-grid-tile"); let cached = ctx.data(|d| d.get_temp::<([Color32; 2], egui::TextureHandle)>(id)).filter(|(c, _)| *c == colors); let tex = cached.map(|(_, t)| t).unwrap_or_else(|| { let [a, b] = colors; @@ -1952,7 +1957,7 @@ fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, canvas: egui let mut unit_clicked = None; for (r, vertical, id) in [(top, false, "ruler-top"), (left, true, "ruler-left")] { // Click-and-drag so the same widget drags out guides (left) and opens the unit menu (right). - let resp = ui.interact(r, egui::Id::new(id), Sense::click_and_drag()); + let resp = ui.interact(r, egui::Id::unique(id), Sense::click_and_drag()); resp.context_menu(|ui| crate::menus::ruler_menu_body(app, ui, &mut unit_clicked)); let kind = if resp.drag_stopped() { PointerKind::Up @@ -1972,7 +1977,7 @@ fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, canvas: egui app.status(e.to_string()); } } - let corner = ui.interact(corner, egui::Id::new("ruler-corner"), Sense::click_and_drag()); + let corner = ui.interact(corner, egui::Id::unique("ruler-corner"), Sense::click_and_drag()); corner.context_menu(|ui| crate::menus::ruler_menu_body(app, ui, &mut unit_clicked)); if let Some((id, p)) = unit_clicked { crate::menus::invoke(app, &id, p); @@ -2016,7 +2021,7 @@ fn ruler_input(app: &mut VectorcraftApp, ui: &Ui, full: egui::Rect, canvas: egui /// The key of the ruler origin drag's state in egui's temporary data. fn ruler_origin_drag_id() -> egui::Id { - egui::Id::new("ruler-origin-drag") + egui::Id::unique("ruler-origin-drag") } /// Is a drag from the rulers' corner in progress? It stays true after Escape cancels the move, until @@ -3017,26 +3022,21 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf, curve } } -/// While the Type tool edits, let the system IME compose (egui-winit allows it only in frames that -/// set `ime`) and keep its candidate window under the caret. -fn ime_output(app: &mut VectorcraftApp, ctx: &egui::Context, xf: &Xf) { - let ours = app.session.tool_wants_text() && !ctx.egui_wants_keyboard_input() && app.ui.dialog.is_none() && !app.ui.palette_open; - let caret = if ours { app.session.tool_ime_caret(app.view_info()) } else { None }; - let Some((a, b)) = caret else { - // The IME goes away with its marked text: keep that text as typed, like a click away. - crate::shortcuts::keep_marked_text(app); +/// Record where the Type tool's IME candidate window goes this pass, if it has an editor. +/// +/// The descriptor itself is observed once at the end of the pass (`crate::ime::end_of_pass`), +/// after dialogs, floating panels, progress windows and the palette draw: any of them can open +/// over the canvas, take the keyboard or run a command that changes the edited text, so a +/// descriptor written here would carry a stale owner, surrounding text or cause. +fn ime_geometry(app: &mut VectorcraftApp, ctx: &egui::Context, xf: &Xf) { + app.ime_caret_rect = None; + // A hidden viewport cannot compose; the end-of-pass observation clears the owner. + if !app.session.tool_wants_text() || app.viewport_hidden(ctx) { return; - }; - let r = egui::Rect::from_two_pos(xf.to_screen(a), xf.to_screen(b)).expand2(vec2(1.0, 0.0)); - // The tool ended the composition itself: the IME must drop what it still has marked. - let interrupt = app.ime_marked.is_some() && !app.session.tool_composing(); - if interrupt { - app.ime_marked = None; - app.ime_discard = true; } - ctx.output_mut(|o| { - o.ime = Some(egui::output::IMEOutput { purpose: egui::IMEPurpose::Normal, rect: r, cursor_rect: r, should_interrupt_composition: interrupt }); - }); + let Some((a, b)) = app.session.tool_ime_caret(app.view_info()) else { return }; + let r = egui::Rect::from_two_pos(xf.to_screen(a), xf.to_screen(b)).expand2(vec2(1.0, 0.0)); + app.ime_caret_rect = Some((r, r)); } /// The width an artboard needs on screen for its name to show (px): narrower, the name is left @@ -3044,11 +3044,11 @@ fn ime_output(app: &mut VectorcraftApp, ctx: &egui::Context, xf: &Xf) { const MIN_LABELLED_ARTBOARD: f32 = 24.0; fn name_press_id() -> egui::Id { - egui::Id::new("canvas-artboard-name-press") + egui::Id::unique("canvas-artboard-name-press") } fn name_edit_id() -> egui::Id { - egui::Id::new("canvas-artboard-name-edit") + egui::Id::unique("canvas-artboard-name-edit") } /// A name being typed above an artboard. `fresh`: this frame opened it, so a focus loss from the @@ -3203,7 +3203,14 @@ fn artboard_name_editor(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { }; let fresh = edit.fresh; edit.fresh = false; - let te = ui.put(rect, egui::TextEdit::singleline(&mut edit.text).id(name_edit_id().with("field")).font(egui::FontId::proportional(11.0))); + let te = ui.put( + rect, + egui::TextEdit::singleline(&mut edit.text) + .id(name_edit_id().with("field")) + .font(egui::FontId::proportional(11.0)) + // Keep the original inset so theme button padding cannot move the canvas label. + .margin(egui::Margin::symmetric(4, 2)), + ); if fresh || (!te.has_focus() && !te.lost_focus()) { te.request_focus(); } @@ -3449,7 +3456,7 @@ fn recent_row(ui: &mut Ui, path: &str) -> egui::Response { ui.painter().galley(egui::pos2(folder_x, rect.center().y - folder_galley.size().y / 2.0), folder_galley, t.text_dim); } } - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &name)); resp.on_hover_text(path).on_hover_cursor(egui::CursorIcon::PointingHand) } @@ -3545,7 +3552,7 @@ fn pixel_region(xf: &Xf) -> Option<[i64; 4]> { /// The Contextual Task Bar's area. fn task_bar_id() -> egui::Id { - egui::Id::new("task-bar") + egui::Id::unique("task-bar") } /// Where the Contextual Task Bar was last drawn (none while it isn't shown). @@ -4602,6 +4609,70 @@ mod tests { assert!(app.ui.dialog.is_none(), "Artboard Options stays closed"); } + /// The canvas artboard name field keeps its own inset: egui 0.37 insets a text field by the theme's button + /// padding unless told otherwise, but this field sits over the canvas label, so its glyphs must not move when + /// that padding changes (an ordinary themed field's do, as the probe shows). + #[test] + fn artboard_name_field_glyphs_stay_put_when_button_padding_changes() { + /// Where the frame paints `text` (a field's text starts at its galley's top left). + fn text_at(shapes: &[Shape], text: &str) -> Pos2 { + shapes + .iter() + .find_map(|s| match s { + Shape::Text(t) if t.galley.text() == text => Some(t.pos), + _ => None, + }) + .unwrap_or_else(|| panic!("no {text:?} text")) + } + fn raw() -> egui::RawInput { + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() } + } + /// The name field's text origin relative to its rectangle with the theme's button padding `padding`: the + /// field opened as a double click opens it, measured in the steady frame after it takes focus. + fn editor_inset(padding: Vec2) -> Vec2 { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("artboard.setProps", &json!({"index": 0, "name": "Inset"})).unwrap(); + app.select_tool("artboard"); + app.ui.task_bar = false; + app.ui.view.rulers = false; + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx, crate::theme::Brightness::default()); + ctx.global_style_mut(|s| s.spacing.button_padding = padding); + ctx.run_ui(raw(), |ui| begin_artboard_name_edit(ui, &app, 0)).textures_delta.clear(); + // The first frame opens and focuses the field; the next one is its steady state. + frame(&mut app, &ctx, vec![]); + let (shapes, mut delta) = frame_output(&mut app, &ctx); + delta.clear(); + let origin = text_at(&shapes, "Inset"); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let mut field = None; + ctx.run_ui(raw(), |ui| field = artboard_name_field_rect(&app, ui, &xf, 0).map(|r| r.min)).textures_delta.clear(); + origin - field.unwrap() + } + /// An ordinary themed field's text origin relative to its rectangle: the padding moves it. + fn plain_inset(padding: Vec2) -> Vec2 { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx, crate::theme::Brightness::default()); + ctx.global_style_mut(|s| s.spacing.button_padding = padding); + let mut text = String::from("Probe"); + let rect = egui::Rect::from_min_size(pos2(40.0, 40.0), vec2(140.0, 18.0)); + let mut out = ctx.run_ui(raw(), |ui| { + ui.put(rect, egui::TextEdit::singleline(&mut text).id(egui::Id::unique("inset-probe")).font(egui::FontId::proportional(11.0))); + }); + out.textures_delta.clear(); + let shapes: Vec = out.shapes.into_iter().map(|c| c.shape).collect(); + text_at(&shapes, "Probe") - rect.min + } + let (old, themed) = (vec2(4.0, 2.0), vec2(6.0, 2.0)); + let (editor_old, editor_themed) = (editor_inset(old), editor_inset(themed)); + let (plain_old, plain_themed) = (plain_inset(old), plain_inset(themed)); + assert!((plain_themed.x - plain_old.x).abs() > 1.0, "the probe field follows the padding: {plain_old:?} → {plain_themed:?}"); + assert!((editor_themed - editor_old).length() < 0.01, "the artboard name moves with the button padding: {editor_old:?} → {editor_themed:?}"); + } + /// #949: an artboard's name stays within the artboard's width on screen, ending in "…" when /// longer, and an artboard too narrow shows none. #[test] @@ -6586,6 +6657,7 @@ mod tests { delta: vec2(0.0, dy), phase: egui::TouchPhase::Move, modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, }; for k in 0..12 { let before = app.view().unwrap().center; @@ -7092,7 +7164,7 @@ mod tests { // A canvas frame through the host's input hook (it feeds the control channel's input). let run = |app: &mut VectorcraftApp| { let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; - app.raw_input_hook(&mut raw); + app.raw_input_hook(&ctx, &mut raw); let mut out = ctx.run_ui(raw, |ui| show(app, ui)); out.textures_delta.clear(); }; @@ -7146,7 +7218,7 @@ mod tests { let ctx = egui::Context::default(); let run = |app: &mut VectorcraftApp| { let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; - app.raw_input_hook(&mut raw); + app.raw_input_hook(&ctx, &mut raw); let mut out = ctx.run_ui(raw, |ui| show(app, ui)); out.textures_delta.clear(); }; @@ -7191,6 +7263,7 @@ mod tests { delta: vec2(0.0, -60.0), phase: egui::TouchPhase::Move, modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, }; let mut events = vec![wheel]; let mut moved = 0; @@ -7257,6 +7330,7 @@ mod tests { delta: vec2(0.0, -40.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::SHIFT, + source: egui::MouseWheelSource::Unknown, }; frame(&mut app, &ctx, vec![wheel]); let after = *app.view().unwrap(); @@ -8297,12 +8371,17 @@ mod tests { assert_eq!(st.history.undo.len(), undo + 1, "one move, one undo step"); } - /// One frame of keyboard handling and canvas, as the app runs them; returns the IME output. + /// One frame of keyboard handling, canvas and the end-of-pass IME observation, as the app runs + /// them; returns the IME output. fn typing_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Option { + VectorcraftApp::install_ime_reset_hooks(ctx); let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() }; let mut out = ctx.run_ui(raw, |ui| { + crate::ime::begin_ui(app, ui.ctx()); crate::shortcuts::handle(app, ui.ctx()); show(app, ui); + crate::ime::end_of_pass(app, ui.ctx()); + crate::ime::finish_ui(app, ui.ctx()); }); out.textures_delta.clear(); out.platform_output.ime @@ -8522,7 +8601,9 @@ mod tests { assert_eq!(plain(&app, id), "しょうこ"); assert!(!app.session.tool_composing()); assert_eq!(app.session.active().unwrap().selection.objects, vec![vectorcraft_doc::NodeId(other)]); - assert!(ime.expect("editing the other text").should_interrupt_composition); + let ime = ime.expect("editing the other text"); + assert!(ime.should_interrupt_composition); + let request_id = ime.reset_request.expect("the final destination publishes its reset request"); assert!(app.ime_marked.is_none()); assert!(app.take_ime_discard(), "the host tells the system IME to drop its marked text"); typing_frame(&mut app, &ctx, vec![click(b, false)]); @@ -8532,7 +8613,7 @@ mod tests { // A click on blank canvas ends the editing (#2871): the marked text stays, the frame asks // for no IME, and the host tells the system IME to drop its composition. let blank = xf.to_screen(Point::new(150.0, 200.0)); - typing_frame(&mut app, &ctx, vec![preedit("が", 1)]); + typing_frame(&mut app, &ctx, vec![egui::Event::ImeResetCompleted { request_id }, preedit("が", 1)]); typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(blank), click(blank, true)]); let ime = typing_frame(&mut app, &ctx, vec![click(blank, false)]); assert!(plain(&app, other).contains('が'), "{}", plain(&app, other)); @@ -8578,6 +8659,240 @@ mod tests { assert_eq!(plain(&app, id), "雅がくらくか"); } + fn type_at(app: &mut VectorcraftApp, ctx: &egui::Context) -> egui::Pos2 { + app.session.prefs.placeholder_text = false; + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + typing_frame(app, ctx, vec![]); + app.select_tool("type"); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let at = xf.to_screen(Point::new(100.0, 100.0)); + let click = + |p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + typing_frame(app, ctx, vec![egui::Event::PointerMoved(at), click(at, true)]); + typing_frame(app, ctx, vec![click(at, false)]); + at + } + + /// The outgoing descriptor carries the committed logical text and UTF-8 offsets, keeps a + /// reversed selection reversed, and labels only accepted IME mutations `InputMethod`. + #[test] + fn ime_output_carries_text_offsets_and_explicit_cause() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let ctx = egui::Context::default(); + type_at(&mut app, &ctx); + let empty = typing_frame(&mut app, &ctx, vec![]).expect("the Type tool owns the IME"); + let payload = empty.surrounding_text.available().expect("an empty document is available"); + assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("", 0, 0)); + assert_eq!(empty.change_cause, egui::output::ImeChangeCause::Other); + + let typed = typing_frame(&mut app, &ctx, vec![egui::Event::Text("Héllo".into())]).unwrap(); + let payload = typed.surrounding_text.available().unwrap(); + assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("Héllo", 6, 6)); + assert_eq!(typed.change_cause, egui::output::ImeChangeCause::Other, "ordinary typing is application-origin"); + + let shift_left = + || egui::Event::Key { key: egui::Key::ArrowLeft, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::SHIFT }; + // "Héllo" has five characters: four Shift+Lefts stop at the start of its two-byte é, + // leaving cursor 1 < anchor 6. + let mut reversed = None; + for _ in 0..4 { + reversed = typing_frame(&mut app, &ctx, vec![shift_left()]); + } + let reversed = reversed.unwrap(); + let payload = reversed.surrounding_text.available().unwrap(); + assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("Héllo", 1, 6), "cursor > anchor stays reversed"); + + // One accepted IME deletion (one byte before the selection) then the commit: both receipts + // chain, and the commit still replaces the preserved selection ("éllo"). + let ime = typing_frame( + &mut app, + &ctx, + vec![ + egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 0 }), + egui::Event::Ime(egui::ImeEvent::Commit("Z".into())), + ], + ) + .unwrap(); + let payload = ime.surrounding_text.available().unwrap(); + assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("Z", 1, 1)); + assert_eq!(ime.change_cause, egui::output::ImeChangeCause::InputMethod); + + let ordinary = typing_frame(&mut app, &ctx, vec![egui::Event::Text("!".into())]).unwrap(); + assert_eq!(ordinary.change_cause, egui::output::ImeChangeCause::Other, "typing after a commit is Other"); + } + + /// A pre-edit is excluded from the outgoing text; committing the composition is the accepted + /// IME mutation that earns `InputMethod`. + #[test] + fn ime_output_excludes_the_preedit_and_marks_the_commit() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let ctx = egui::Context::default(); + type_at(&mut app, &ctx); + typing_frame(&mut app, &ctx, vec![egui::Event::Text("ab".into())]); + let marked = typing_frame(&mut app, &ctx, vec![preedit("xy", 2)]).unwrap(); + let payload = marked.surrounding_text.available().unwrap(); + assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("ab", 2, 2), "the marked text is not committed text"); + let committed = typing_frame(&mut app, &ctx, vec![preedit("", 0), egui::Event::Ime(egui::ImeEvent::Commit("xy".into()))]).unwrap(); + let payload = committed.surrounding_text.available().unwrap(); + assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("abxy", 4, 4)); + assert_eq!(committed.change_cause, egui::output::ImeChangeCause::InputMethod); + } + + /// An application edit anywhere in the frame dominates an IME receipt in it. + #[test] + fn ime_output_marks_mixed_application_and_ime_mutations_other() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let ctx = egui::Context::default(); + type_at(&mut app, &ctx); + typing_frame(&mut app, &ctx, vec![egui::Event::Text("ab".into())]); + let ime = typing_frame( + &mut app, + &ctx, + vec![ + egui::Event::Text("x".into()), + egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 0 }), + egui::Event::Ime(egui::ImeEvent::Commit("y".into())), + ], + ) + .unwrap(); + let payload = ime.surrounding_text.available().unwrap(); + assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("aby", 3, 3)); + assert_eq!(ime.change_cause, egui::output::ImeChangeCause::Other, "application edit before the IME dominates"); + + let pasted = typing_frame(&mut app, &ctx, vec![egui::Event::Paste("c".into())]).unwrap(); + let payload = pasted.surrounding_text.available().unwrap(); + assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("abyc", 4, 4)); + assert_eq!(pasted.change_cause, egui::output::ImeChangeCause::Other, "paste is application-origin"); + + // The other order: an accepted IME commit followed by an application edit in the same + // frame is still `Other`. + let ime_then_text = + typing_frame(&mut app, &ctx, vec![egui::Event::Ime(egui::ImeEvent::Commit("y".into())), egui::Event::Text("x".into())]).unwrap(); + let payload = ime_then_text.surrounding_text.available().unwrap(); + assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("abycyx", 6, 6)); + assert_eq!(ime_then_text.change_cause, egui::output::ImeChangeCause::Other, "application edit after the IME dominates"); + } + + /// Leaving and re-entering the same text object keeps the owner but starts a new generation. + #[test] + fn ime_owner_replacement_starts_a_new_generation() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.prefs.placeholder_text = false; + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = app.session.execute("text.create", &json!({"x": 100, "y": 100, "text": "Same", "size": 24})).unwrap()["id"].as_u64().unwrap(); + let ctx = egui::Context::default(); + typing_frame(&mut app, &ctx, vec![]); + app.select_tool("type"); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let center = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap().center(); + let at = xf.to_screen(center); + let click = + |p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(at, true)]); + typing_frame(&mut app, &ctx, vec![click(at, false)]); + let first = typing_frame(&mut app, &ctx, vec![]).expect("the text is being edited"); + assert_eq!(first.owner, egui::Id::unique(("vectorcraft-type-ime", app.session.active().unwrap().uid, id))); + let escape = |pressed| egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; + typing_frame(&mut app, &ctx, vec![escape(true)]); + typing_frame(&mut app, &ctx, vec![escape(false)]); + assert!(typing_frame(&mut app, &ctx, vec![]).is_none(), "Escape ends the edit context"); + app.select_tool("type"); + typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(at, true)]); + typing_frame(&mut app, &ctx, vec![click(at, false)]); + let second = typing_frame(&mut app, &ctx, vec![]).expect("re-entering starts another context"); + assert_eq!(second.owner, first.owner, "the owner key is the document and text object"); + assert_ne!(second.context_generation, first.context_generation, "a replacement epoch resets once"); + } + + /// One tracker serves several native viewports: each gets its own generation for the same + /// editor, and an unchanged second pass in either keeps it. + #[test] + fn ime_context_is_scoped_per_viewport() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let ctx = egui::Context::default(); + type_at(&mut app, &ctx); + let aux = egui::ViewportId::from_hash_of("vectorcraft-ime-aux"); + let frame = |app: &mut VectorcraftApp, viewport: egui::ViewportId| { + let mut raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), + viewport_id: viewport, + ..Default::default() + }; + raw.viewports.entry(viewport).or_default(); + let mut out = ctx.run_ui(raw, |ui| { + crate::shortcuts::handle(app, ui.ctx()); + show(app, ui); + crate::ime::end_of_pass(app, ui.ctx()); + }); + out.textures_delta.clear(); + out.platform_output.ime.expect("the Type tool owns the IME in both viewports") + }; + let root = egui::ViewportId::ROOT; + let root_first = frame(&mut app, root); + let aux_first = frame(&mut app, aux); + assert_ne!(root_first.context_generation, aux_first.context_generation, "viewport state must not collide"); + let root_second = frame(&mut app, root); + let aux_second = frame(&mut app, aux); + assert_eq!(root_second.context_generation, root_first.context_generation); + assert_eq!(aux_second.context_generation, aux_first.context_generation); + } + + /// A minimized/occluded viewport has no IME owner: the descriptor disappears, and the next + /// visible pass starts a new generation instead of inheriting the old context. + #[test] + fn hidden_viewport_observes_no_ime_owner() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let ctx = egui::Context::default(); + type_at(&mut app, &ctx); + let root = egui::ViewportId::ROOT; + let visible = typing_frame(&mut app, &ctx, vec![]).unwrap(); + let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; + raw.viewports.entry(root).or_default().minimized = Some(true); + let mut out = ctx.run_ui(raw, |ui| { + crate::shortcuts::handle(&mut app, ui.ctx()); + show(&mut app, ui); + crate::ime::end_of_pass(&mut app, ui.ctx()); + }); + out.textures_delta.clear(); + assert!(out.platform_output.ime.is_none(), "a hidden viewport has no IME owner"); + let again = typing_frame(&mut app, &ctx, vec![]).expect("the visible editor comes back"); + assert_ne!(again.context_generation, visible.context_generation, "refocus starts a new generation"); + } + + /// A frame that is discarded and drawn again sees the mutation in both passes, and its + /// unchanged second pass keeps the first pass's `InputMethod` cause and generation. + #[test] + fn ime_observation_runs_in_every_pass_of_a_discarded_frame() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let ctx = egui::Context::default(); + type_at(&mut app, &ctx); + let mut first = true; + let mut seen = vec![]; + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + if first { + first = false; + let view = app.view_info(); + let target = crate::ime::target(&mut app.session, view); + let before = crate::ime::stamp(&mut app.session, view); + app.session.tool_text("x", view).unwrap(); + crate::ime::note_ime_apply(&mut app, ui.ctx(), view, target, before); + ui.ctx().request_discard("ime observation second pass"); + } + crate::shortcuts::handle(&mut app, ui.ctx()); + show(&mut app, ui); + crate::ime::end_of_pass(&mut app, ui.ctx()); + seen.push(ui.ctx().output(|o| o.ime.clone()).expect("IME output in this pass")); + }); + out.textures_delta.clear(); + assert_eq!(seen.len(), 2, "the frame really ran two passes"); + assert_eq!(seen[0].context_generation, seen[1].context_generation, "the discarded pass is not a new context"); + assert_eq!(seen[0].change_cause, egui::output::ImeChangeCause::InputMethod); + assert_eq!(seen[1].change_cause, egui::output::ImeChangeCause::InputMethod, "an unchanged pass retains the frame cause"); + let payload = seen[1].surrounding_text.available().unwrap(); + assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("x", 1, 1)); + } + #[test] fn tool_drag_moved_without_pointer_delta() { let ctx = egui::Context::default(); @@ -9230,9 +9545,10 @@ mod tests { } /// The labels the Contextual Task Bar draws in a headless frame (after one to lay it out), with - /// their rects; none while it isn't shown. Icons, which draw as ⚠ without an image loader, are - /// left out. + /// their rects; none while it isn't shown. The SVG loaders are installed so the icons draw as + /// images instead of egui's ⚠ placeholder, which the label list would otherwise pick up. fn task_bar_labels(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec<(String, egui::Rect)> { + egui_extras::install_image_loaders(ctx); frame(app, ctx, vec![]); let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; let mut out = ctx.run_ui(raw, |ui| show(app, ui)); @@ -9241,7 +9557,7 @@ mod tests { out.shapes .iter() .filter_map(|c| match &c.shape { - egui::Shape::Text(t) if bar.contains(t.visual_bounding_rect().center()) && t.galley.text() != "⚠" => { + egui::Shape::Text(t) if bar.contains(t.visual_bounding_rect().center()) => { Some((t.galley.text().to_string(), t.visual_bounding_rect())) } _ => None, @@ -9711,12 +10027,12 @@ mod tests { show(app, ui); match cover { Some(Cover::Panel(r)) => { - egui::Area::new(egui::Id::new("test-floating-panel")).order(egui::Order::Foreground).fixed_pos(r.min).show(ui.ctx(), |ui| { + egui::Area::new(egui::Id::unique("test-floating-panel")).order(egui::Order::Foreground).fixed_pos(r.min).show(ui.ctx(), |ui| { ui.allocate_space(r.size()); }); } Some(Cover::Dialog) => { - crate::dialogs::modal::show(ui.ctx(), "Test", egui::Id::new("test-dialog"), 0.0, 8, |ui| ui.label("A dialog")); + crate::dialogs::modal::show(ui.ctx(), "Test", egui::Id::unique("test-dialog"), 0.0, 8, |ui| ui.label("A dialog")); } None => {} } @@ -9995,6 +10311,7 @@ mod tests { delta: vec2(0.0, -30.0), phase: egui::TouchPhase::Move, modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, }; frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]); frame(&mut app, &ctx, vec![wheel]); diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 1f4550473..98ce51408 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -265,7 +265,7 @@ fn isolation_button(app: &mut VectorcraftApp, ui: &mut Ui) { return; }; let (rect, _) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::hover()); - let resp = ui.interact(rect, egui::Id::new(ISOLATION_BUTTON), Sense::click()); + let resp = ui.interact(rect, egui::Id::unique(ISOLATION_BUTTON), Sense::click()); if widgets::paint_icon_button(ui, resp, icon, tip, false).clicked() { crate::menus::invoke(app, id, json!({})); } @@ -489,7 +489,7 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) { } let is_active = Some(i) == active; let r = egui::Rect::from_min_size(egui::pos2(x, strip.top()), vec2(*w, strip.height() - 1.0)); - let resp = ui.interact(r, ui.id().with(("tab", i)), Sense::click()); + let resp = ui.interact(r, ui.scope_id().with(("tab", i)), Sense::click()); if is_active { ui.painter().rect_filled(r, 0.0, t.panel); } else if resp.hovered() { @@ -500,7 +500,7 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) { // (#673). let (x_center, title_left) = if CLOSE_BEFORE_TITLE { (r.left() + 16.0, r.left() + 32.0) } else { (r.right() - 16.0, r.left() + 14.0) }; let xr = egui::Rect::from_center_size(egui::pos2(x_center, r.center().y), vec2(12.0, 12.0)); - let xresp = ui.interact(xr.expand(3.0), ui.id().with(("tabx", i)), Sense::click()); + let xresp = ui.interact(xr.expand(3.0), ui.scope_id().with(("tabx", i)), Sense::click()); icons::paint(ui, "x", xr, if xresp.hovered() { t.text_strong } else { t.text }); ui.painter().galley(egui::pos2(title_left, r.center().y - galley.size().y / 2.0), galley.clone(), t.text); hovering_art |= crate::document_drag::hover_tab(app, &resp, i, xr.expand(3.0)); @@ -515,7 +515,7 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) { // The » button: every open document, the active one checked. if overflow { let more = egui::Rect::from_min_max(egui::pos2(strip.right() - MORE, strip.top()), egui::pos2(strip.right(), strip.bottom() - 1.0)); - let resp = ui.interact(more, ui.id().with("tabs-more"), Sense::click()); + let resp = ui.interact(more, ui.scope_id().with("tabs-more"), Sense::click()); ui.painter().rect_filled(more, 0.0, if resp.hovered() { t.hover.gamma_multiply(0.4) } else { t.tab_strip }); icons::paint(ui, "chevrons-right", egui::Rect::from_center_size(more.center(), vec2(14.0, 14.0)), t.text); let resp = resp.on_hover_text(tl!("Show all open documents")); @@ -563,10 +563,10 @@ pub fn isolation_bar(app: &mut VectorcraftApp, ui: &mut Ui, area: egui::Rect) { crumbs.push(pn.clone()); } let bar = egui::Rect::from_min_size(area.min, vec2(area.width(), 24.0)); - ui.interact(bar, ui.id().with("iso-bar"), Sense::click_and_drag()); + ui.interact(bar, ui.scope_id().with("iso-bar"), Sense::click_and_drag()); ui.painter().rect_filled(bar, 0.0, t.panel); let back = egui::Rect::from_min_size(bar.min + vec2(6.0, 3.0), vec2(18.0, 18.0)); - let bresp = ui.interact(back, ui.id().with("iso-back"), Sense::click()); + let bresp = ui.interact(back, ui.scope_id().with("iso-back"), Sense::click()); icons::paint(ui, "chevron-left", back, if bresp.hovered() { t.text } else { t.icon }); ui.painter().text( bar.left_center() + vec2(30.0, 0.0), @@ -582,7 +582,7 @@ pub fn isolation_bar(app: &mut VectorcraftApp, ui: &mut Ui, area: egui::Rect) { let label = tl!(label); let w = 12.0 + 7.0 * label.len() as f32; let r = egui::Rect::from_min_max(egui::pos2(x - w, bar.top() + 3.0), egui::pos2(x, bar.bottom() - 3.0)); - let resp = ui.interact(r, ui.id().with(("pat-bar", cmd)), Sense::click()); + let resp = ui.interact(r, ui.scope_id().with(("pat-bar", cmd)), Sense::click()); ui.painter().rect_filled(r, 3.0, if resp.hovered() { t.hover } else { t.panel }); ui.painter().rect_stroke(r, 3.0, Stroke::new(1.0, t.button_border), egui::StrokeKind::Inside); ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, label, egui::FontId::proportional(12.0), t.text); @@ -621,70 +621,70 @@ fn artboard_list(app: &VectorcraftApp, ui: &mut Ui, cur: usize) -> Option let boards = app.session.active().map(|d| d.doc.artboards.as_slice()).unwrap_or_default(); let shown = boards.get(cur).map_or_else(|| "–".to_string(), |a| format!("{} {}", cur + 1, a.name)); // Under `drawn`, egui's temp data stores the pass the list was last drawn in. - let drawn = ui.id().with("artboard-list"); + let drawn = ui.scope_id().with("artboard-list"); let height = ROW_H * ROWS; ui.add_enabled_ui(!boards.is_empty(), |ui| { ui.set_max_width(FIELD_W); - egui::ComboBox::from_id_salt("artboard-list") - .selected_text(egui::RichText::new(shown).size(11.5)) - .width(FIELD_W) - .wrap_mode(egui::TextWrapMode::Truncate) - .height(height) - .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) - .show_ui(ui, |ui| { - let pass = ui.ctx().cumulative_pass_nr(); - let last = ui.data(|d| d.get_temp::(drawn)).unwrap_or_default(); - ui.data_mut(|d| d.insert_temp(drawn, pass)); - let mut area = egui::ScrollArea::vertical().max_height(height); - // As the list opens, it scrolls the active row as close to the middle as the list allows. - if last + 1 < pass || last == 0 { - area = area.vertical_scroll_offset((cur as f32 * ROW_H - (height - ROW_H) * 0.5).max(0.0)); - } - // Every number ends where the widest one ends. - let numbers = ui.painter().layout_no_wrap(boards.len().to_string(), egui::FontId::proportional(12.0), t.text_dim).size().x; - let width = ui.available_width().max(LIST_W); - let mut chosen = None; - ui.spacing_mut().item_spacing.y = 0.0; - area.show_rows(ui, ROW_H, boards.len(), |ui, range| { - for i in range { - let Some(board) = boards.get(i) else { continue }; - let (r, resp) = ui.allocate_exact_size(vec2(width, ROW_H), Sense::click()); - if i == cur { - ui.painter().rect_filled(r, 0.0, t.row_selected); - } else if resp.hovered() { - ui.painter().rect_filled(r, 0.0, t.hover); - } - let number_end = r.left() + 8.0 + numbers; - ui.painter().text( - egui::pos2(number_end, r.center().y), - egui::Align2::RIGHT_CENTER, - (i + 1).to_string(), - egui::FontId::proportional(12.0), - t.text_dim, - ); - let name_start = number_end + 10.0; - let name = egui::WidgetText::from(egui::RichText::new(&board.name).size(12.5)).into_galley( - ui, - Some(egui::TextWrapMode::Truncate), - r.right() - 8.0 - name_start, - egui::TextStyle::Body, - ); - ui.painter().galley(egui::pos2(name_start, r.center().y - name.size().y / 2.0), name, t.text); - resp.widget_info(|| { - egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, i == cur, format!("{} {}", i + 1, board.name)) - }); - if resp.clicked() { - chosen = Some(i); - } + ui.name_inputs(tl!("Artboard"), |ui| { + egui::ComboBox::from_id_salt("artboard-list") + .selected_text(egui::RichText::new(shown).size(11.5)) + .width(FIELD_W) + .wrap_mode(egui::TextWrapMode::Truncate) + .height(height) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .show_ui(ui, |ui| { + let pass = ui.ctx().cumulative_pass_nr(); + let last = ui.data(|d| d.get_temp::(drawn)).unwrap_or_default(); + ui.data_mut(|d| d.insert_temp(drawn, pass)); + let mut area = egui::ScrollArea::vertical().max_height(height); + // As the list opens, it scrolls the active row as close to the middle as the list allows. + if last + 1 < pass || last == 0 { + area = area.vertical_scroll_offset((cur as f32 * ROW_H - (height - ROW_H) * 0.5).max(0.0)); } - }); - if chosen.is_some() { - ui.close(); - } - chosen - }) - .inner - .flatten() + // Every number ends where the widest one ends. + let numbers = ui.painter().layout_no_wrap(boards.len().to_string(), egui::FontId::proportional(12.0), t.text_dim).size().x; + let width = ui.available_width().max(LIST_W); + let mut chosen = None; + ui.spacing_mut().item_spacing.y = 0.0; + area.show_rows(ui, ROW_H, boards.len(), |ui, range| { + for i in range { + let Some(board) = boards.get(i) else { continue }; + let (r, resp) = ui.allocate_exact_size(vec2(width, ROW_H), Sense::click()); + if i == cur { + ui.painter().rect_filled(r, 0.0, t.row_selected); + } else if resp.hovered() { + ui.painter().rect_filled(r, 0.0, t.hover); + } + let number_end = r.left() + 8.0 + numbers; + ui.painter().text( + egui::pos2(number_end, r.center().y), + egui::Align2::RIGHT_CENTER, + (i + 1).to_string(), + egui::FontId::proportional(12.0), + t.text_dim, + ); + let name_start = number_end + 10.0; + let name = egui::WidgetText::from(egui::RichText::new(&board.name).size(12.5)).into_galley( + ui, + Some(egui::TextWrapMode::Truncate), + r.right() - 8.0 - name_start, + egui::TextStyle::Body, + ); + ui.painter().galley(egui::pos2(name_start, r.center().y - name.size().y / 2.0), name, t.text); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, i == cur, format!("{} {}", i + 1, board.name))); + if resp.clicked() { + chosen = Some(i); + } + } + }); + if chosen.is_some() { + ui.close(); + } + chosen + }) + .inner + .flatten() + }) }) .inner } @@ -700,38 +700,42 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) { let zoom = app.view().map(|v| v.zoom).unwrap_or(1.0); let print = app.session.prefs.display_print_size; let shown = crate::state::zoom_display_percent(zoom, print); - egui::ComboBox::from_id_salt("zoom-combo") - .selected_text(egui::RichText::new(zoom_label(zoom, print)).size(11.5)) - .width(76.0) - .show_ui(ui, |ui| { - for z in ZOOM_STOPS.iter().rev() { - if ui - .selectable_label((z - shown).abs() < 0.01, zoom_label(crate::state::zoom_from_display_percent(*z, print), print)) - .clicked() - { - app.run("view.setZoom", json!({"zoom": z})).ok(); + ui.name_inputs(tl!("Zoom"), |ui| { + egui::ComboBox::from_id_salt("zoom-combo") + .selected_text(egui::RichText::new(zoom_label(zoom, print)).size(11.5)) + .width(76.0) + .show_ui(ui, |ui| { + for z in ZOOM_STOPS.iter().rev() { + if ui + .selectable_label((z - shown).abs() < 0.01, zoom_label(crate::state::zoom_from_display_percent(*z, print), print)) + .clicked() + { + app.run("view.setZoom", json!({"zoom": z})).ok(); + } } - } - ui.separator(); - if ui.selectable_label(false, tl!("Fit on Screen")).clicked() { - app.run("view.fitArtboard", json!({})).ok(); - } - if ui.selectable_label(false, tl!("Fit All")).clicked() { - app.run("view.fitAll", json!({})).ok(); - } - }); + ui.separator(); + if ui.selectable_label(false, tl!("Fit on Screen")).clicked() { + app.run("view.fitArtboard", json!({})).ok(); + } + if ui.selectable_label(false, tl!("Fit All")).clicked() { + app.run("view.fitAll", json!({})).ok(); + } + }); + }); let rot = app.view().map(|v| v.rotation).unwrap_or(0.0); - egui::ComboBox::from_id_salt("rot-combo").selected_text(egui::RichText::new(format!("{rot:.0}°")).size(11.5)).width(52.0).show_ui( - ui, - |ui| { - for (label, a) in ROTATE_VIEW_ANGLES { - if ui.selectable_label((rot - a).abs() < 1e-9, label).clicked() { - // It fails only without a document, when there is no view to turn. - app.run("view.rotate", json!({"angle": a})).ok(); + ui.name_inputs(tl!("Rotation"), |ui| { + egui::ComboBox::from_id_salt("rot-combo") + .selected_text(egui::RichText::new(format!("{rot:.0}°")).size(11.5)) + .width(52.0) + .show_ui(ui, |ui| { + for (label, a) in ROTATE_VIEW_ANGLES { + if ui.selectable_label((rot - a).abs() < 1e-9, label).clicked() { + // It fails only without a document, when there is no view to turn. + app.run("view.rotate", json!({"angle": a})).ok(); + } } - } - }, - ); + }); + }); ui.separator(); // The artboard navigator has First and Previous, a field with the current artboard's // number and name and a list of every artboard, then Next and Last. @@ -779,7 +783,7 @@ struct StatusReadoutWidths { /// Reuse sample-string measurements until language, document units or display scale changes. /// Keep a single entry per UI so repeated DPI/zoom changes cannot grow the cache indefinitely. fn status_readout_widths(ui: &Ui, lang: crate::i18n::Lang, unit: vectorcraft_doc::Unit, t: &Tokens) -> Arc { - let id = ui.id().with("status-readout-widths"); + let id = ui.scope_id().with("status-readout-widths"); let key = (lang, unit, ui.ctx().pixels_per_point()); if let Some(cached) = ui.data(|data| data.get_temp::>(id)) && cached.key == key @@ -819,7 +823,7 @@ fn status_readouts(app: &VectorcraftApp, ui: &mut Ui, t: &Tokens) { let [render_width, ui_width, fps_width, axis_width] = widths.values; let coord_width = 2.0 * axis_width; let perf_width = render_width + ui_width + fps_width + 12.0; - let point_id = ui.id().with(("status-hover-point", app.session.active().map(|st| st.uid))); + let point_id = ui.scope_id().with(("status-hover-point", app.session.active().map(|st| st.uid))); if let Some(p) = app.hover_doc { ui.data_mut(|data| data.insert_temp(point_id, p)); } @@ -903,8 +907,8 @@ fn status_field(ui: &mut Ui, id: &str, rect: egui::Rect, text: String, font: egu let at = egui::pos2(rect.left(), rect.center().y - galley.size().y / 2.0); let elided = galley.elided; ui.painter().with_clip_rect(ui.clip_rect().intersect(rect)).galley(at, galley, color); - let response = ui.interact(rect, ui.id().with(("status-field", id)), Sense::hover()); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, &text)); + let response = ui.interact(rect, ui.scope_id().with(("status-field", id)), Sense::hover()); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, &text)); if elided { response.on_hover_text(text); } @@ -1267,7 +1271,7 @@ mod tests { let mut output = ctx.run_ui(input, |ui| { let t = Tokens::get(ui.ctx()); status_readouts(app, ui, &t); - cache = ui.data(|data| data.get_temp::>(ui.id().with("status-readout-widths"))); + cache = ui.data(|data| data.get_temp::>(ui.scope_id().with("status-readout-widths"))); }); output.textures_delta.clear(); cache.expect("production status readouts populated their cache") @@ -1833,6 +1837,7 @@ mod tests { delta: egui::vec2(0.0, dy), phase, modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, }; status_texts( &mut app, @@ -2030,7 +2035,7 @@ mod tests { let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 300.0)); let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| super::control_bar(app, ui)); out.textures_delta.clear(); - ctx.viewport(|vp| vp.prev_pass.widgets.get(egui::Id::new(super::ISOLATION_BUTTON)).map(|w| w.rect)) + ctx.viewport(|vp| vp.prev_pass.widgets.get(egui::Id::unique(super::ISOLATION_BUTTON)).map(|w| w.rect)) } /// Entering isolation mode leaves the canvas where it was: the breadcrumb bar is drawn over the @@ -2591,7 +2596,7 @@ mod visual_regressions { harness.render().unwrap().save(directory.join(format!("{stem}.png"))).unwrap(); let mut regions = serde_json::Map::new(); for id in ["app_bar", "status_bar"] { - let rect = egui::containers::panel::PanelState::load(&harness.ctx, egui::Id::new(id)).unwrap().outer_rect; + let rect = egui::containers::panel::PanelState::load(&harness.ctx, egui::Id::unique(id)).unwrap().outer_rect; regions.insert(id.into(), json!([rect.left(), rect.top(), rect.width(), rect.height()])); } let dynamic: Vec<_> = harness diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 1b9536997..1f5422593 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -337,7 +337,13 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques // Notches, not a flood: a turn stays within what a real wheel sends. let delta = egui::vec2(f("dx"), f("dy")).clamp(egui::Vec2::splat(-100.0), egui::Vec2::splat(100.0)); app.synthetic.push(egui::Event::PointerMoved(egui::pos2(f("x"), f("y")))); - app.synthetic.push(egui::Event::MouseWheel { unit, delta, phase: egui::TouchPhase::Move, modifiers: modifiers(p) }); + app.synthetic.push(egui::Event::MouseWheel { + unit, + delta, + phase: egui::TouchPhase::Move, + modifiers: modifiers(p), + source: egui::MouseWheelSource::Unknown, + }); ctx.request_repaint(); ok(Value::Null) } @@ -497,20 +503,59 @@ fn with_data(mut result: Value, png: Option<&[u8]>) -> Value { result } +/// The largest image whose PNG goes back in a `ui.screenshot` reply (`data: true`): the reply +/// makes a straight-alpha copy, the encoded PNG and, for `data`, a base64 string (~1.33× the +/// PNG), so this bounds that working set before any of it is allocated. A path-only capture keeps +/// the shared 128 MiB image budget. +pub(crate) const MAX_SCREENSHOT_PNG_REPLY_BYTES: usize = 64 * 1024 * 1024; + +/// The checked RGBA size of `w × h`, when it fits. +fn screenshot_rgba_bytes(w: usize, h: usize) -> Option { + w.checked_mul(h).and_then(|n| n.checked_mul(4)) +} + /// The reply to `ui.screenshot`: the window capture `image` written to `path`, and sent back as /// `pngBase64` with `data`. pub fn save_screenshot(app: &mut VectorcraftApp, image: &egui::ColorImage, path: Option<&str>, data: bool) -> Value { + save_screenshot_with_limit(app, image, path, data, MAX_SCREENSHOT_PNG_REPLY_BYTES) +} + +/// [`save_screenshot`] with the inline-PNG encoding limit supplied, so a small image can exercise +/// the over-limit path without allocating a multi-megapixel frame. The public function always uses +/// [`MAX_SCREENSHOT_PNG_REPLY_BYTES`]. +pub(crate) fn save_screenshot_with_limit( + app: &mut VectorcraftApp, + image: &egui::ColorImage, + path: Option<&str>, + data: bool, + max_reply_bytes: usize, +) -> Value { let [w, h] = image.size; if path.is_none() && !data { return json!({"ok": true, "result": {"width": w, "height": h}}); } + // A data-only reply has no second way to deliver the image, so refuse an oversized one before + // allocating its working set. A request that named a path still writes that file: only the + // inline copy is downgraded. + if data && path.is_none() && !screenshot_rgba_bytes(w, h).is_some_and(|bytes| bytes <= max_reply_bytes) { + return json!({"ok": false, "error": format!( + "a PNG reply of this {w}×{h} screenshot would exceed the {max_reply_bytes}-byte reply-encoding limit; write it to a path instead" + )}); + } let rgba: Vec = image.pixels.iter().flat_map(|c| c.to_array()).collect(); let img = vectorcraft_render::Rendered { width: w as u32, height: h as u32, pixels: rgba }; let png = match img.to_png() { Ok(png) => png, Err(e) => return json!({"ok": false, "error": e}), }; - let data = data.then_some(png.as_slice()); + let reply_png = data && png.len() <= max_reply_bytes; + if data && !reply_png && path.is_none() { + return json!({"ok": false, "error": format!( + "this screenshot's PNG is {} bytes, over the {max_reply_bytes}-byte reply-encoding limit; write it to a path instead", + png.len() + )}); + } + let data = reply_png.then_some(png.as_slice()); let Some(path) = path else { return json!({"ok": true, "result": with_data(json!({"width": w, "height": h}), data)}); }; diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index 104a642d1..3936a805a 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -226,7 +226,7 @@ impl Default for View { /// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table. pub fn contributors_ui(app: &mut VectorcraftApp, ui: &mut egui::Ui) { - let id = egui::Id::new("credits_view"); + let id = egui::Id::unique("credits_view"); let mut v = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_default(); ui.horizontal_wrapped(|ui| { ui.label(tl!("Show")); @@ -237,13 +237,15 @@ pub fn contributors_ui(app: &mut VectorcraftApp, ui: &mut egui::Ui) { } ui.separator(); ui.label(tl!("Sort")); - egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| { - for k in SortKey::ALL { - if ui.selectable_label(v.key == k, k.label().0).clicked() { - v.key = k; - v.ascending = k.default_ascending(); + ui.name_inputs(tl!("Sort"), |ui| { + egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| { + for k in SortKey::ALL { + if ui.selectable_label(v.key == k, k.label().0).clicked() { + v.key = k; + v.ascending = k.default_ascending(); + } } - } + }); }); if ui.button(if v.ascending { "▲" } else { "▼" }).on_hover_text(tl!("Reverse the order")).clicked() { v.ascending = !v.ascending; diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/cursors.rs index b5fd56316..399e96c06 100644 --- a/crates/ui-egui/src/cursors.rs +++ b/crates/ui-egui/src/cursors.rs @@ -1156,7 +1156,7 @@ mod tests { let painted = |scale: f32| { let ctx = egui::Context::default(); let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { - let layer = egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("probe")); + let layer = egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("probe")); assert!(paint(&ui.ctx().layer_painter(layer), Cursor::Arrow, pos2(100.0, 100.0), scale)); }); out.textures_delta.clear(); diff --git a/crates/ui-egui/src/dialogs/about.rs b/crates/ui-egui/src/dialogs/about.rs index af248d16a..e24587eae 100644 --- a/crates/ui-egui/src/dialogs/about.rs +++ b/crates/ui-egui/src/dialogs/about.rs @@ -18,7 +18,7 @@ pub(super) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { .frame(egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::same(18))) .show(ctx, |ui| { // Tabs: About · Contributors · Models (the credits are compiled in, see `credits`). - let tab_id = egui::Id::new("about_tab"); + let tab_id = egui::Id::unique("about_tab"); let mut tab = ui.data_mut(|d| d.get_temp::(tab_id)).unwrap_or(0); ui.horizontal(|ui| { for (i, label) in [tl!("About"), tl!("Contributors"), tl!("Models")].into_iter().enumerate() { diff --git a/crates/ui-egui/src/dialogs/artboard_options.rs b/crates/ui-egui/src/dialogs/artboard_options.rs index e599e174d..233d9648b 100644 --- a/crates/ui-egui/src/dialogs/artboard_options.rs +++ b/crates/ui-egui/src/dialogs/artboard_options.rs @@ -86,15 +86,20 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let fills = fill_colors(app); let mut set = None; ui.horizontal(|ui| { - form::row_label(ui, tl!("Background Color:")); - if let Some(k) = crate::widgets::dropdown(ui, "ab-options-fill", fill_name(app, color), &FILLS, 110.0) { - set = fills.get(k).copied(); - } - let [r, g, b, _] = color.unwrap_or(Color::WHITE).to_rgba8(1.0); - let mut rgb = [r, g, b]; - if ui.color_edit_button_srgb(&mut rgb).changed() { - set = Some(Some(Color::rgb(f32::from(rgb[0]) / 255.0, f32::from(rgb[1]) / 255.0, f32::from(rgb[2]) / 255.0))); - } + let row = form::row_label(ui, tl!("Background Color:")); + ui.label_inputs_by(row.id, |ui| { + if let Some(k) = + crate::widgets::dropdown_named(ui, "ab-options-fill", Some(tl!("Background Color:")), fill_name(app, color), &FILLS, 110.0) + { + set = fills.get(k).copied(); + } + let [r, g, b, _] = color.unwrap_or(Color::WHITE).to_rgba8(1.0); + let mut rgb = [r, g, b]; + let swatch_name = format!("{} {}", tl!("Background Color:"), tl!("Swatches")); + if ui.name_inputs(swatch_name, |ui| ui.color_edit_button_srgb(&mut rgb)).changed() { + set = Some(Some(Color::rgb(f32::from(rgb[0]) / 255.0, f32::from(rgb[1]) / 255.0, f32::from(rgb[2]) / 255.0))); + } + }); }); if let Some(c) = set { d.fields.insert("color".into(), c.as_ref().map_or(Value::Null, crate::panels::color_json)); @@ -104,12 +109,14 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { form::check(ui, d, "showCrossHairs", tl!("Show Cross Hairs")); form::check(ui, d, "showVideoSafeAreas", tl!("Show Video Safe Areas")); ui.horizontal(|ui| { - form::row_label(ui, tl!("Video Ruler Pixel Aspect Ratio:")); - let range = vectorcraft_doc::VIDEO_PIXEL_ASPECT_RANGE; - let now = d.f64(PIXEL_ASPECT, 1.0); - if let Some(v) = crate::widgets::range_field(ui, ("dlg-num", PIXEL_ASPECT), now, range, "", 4, 60.0) { - d.fields.insert(PIXEL_ASPECT.into(), json!(v)); - } + let row = form::row_label(ui, tl!("Video Ruler Pixel Aspect Ratio:")); + ui.label_inputs_by(row.id, |ui| { + let range = vectorcraft_doc::VIDEO_PIXEL_ASPECT_RANGE; + let now = d.f64(PIXEL_ASPECT, 1.0); + if let Some(v) = crate::widgets::range_field(ui, ("dlg-num", PIXEL_ASPECT), now, range, "", 4, 60.0) { + d.fields.insert(PIXEL_ASPECT.into(), json!(v)); + } + }); }); false } @@ -159,7 +166,7 @@ fn fit_bounds(app: &mut VectorcraftApp, ctx: &egui::Context, selection: bool) -> } let st = app.session.active()?; let key = (st.uid, st.revision); - let id = egui::Id::new("artboard-options-artwork-fit-bounds"); + let id = egui::Id::unique("artboard-options-artwork-fit-bounds"); if let Some(cached) = ctx.data_mut(|data| data.get_temp::(id)) && cached.key == key { @@ -178,18 +185,21 @@ fn position_rows(ui: &mut egui::Ui, d: &mut Dialog, refi: usize, unit: Unit, ori let rp = vectorcraft_geom::reference_point(r, refi); let mut indent = 0.0; for (key, label, at, zero) in [("x", "X:", rp.x, origin.x), ("y", "Y:", rp.y, origin.y)] { - ui.horizontal(|ui| { - // Y's label lines up with X's, after the locator. - if key == "x" { - let left = ui.cursor().min.x; - reference_locator(ui, refi); - indent = ui.min_rect().right() - left; - } else { - ui.add_space(indent); - } - form::row_label(ui, label); - }); - if let Some(v) = crate::widgets::num_field(ui, ("dlg-ab-xy", key), Some(at - zero), unit, form::FIELD_W) { + let row = ui + .horizontal(|ui| { + // Y's label lines up with X's, after the locator. + if key == "x" { + let left = ui.cursor().min.x; + reference_locator(ui, refi); + indent = ui.min_rect().right() - left; + } else { + ui.add_space(indent); + } + form::row_label(ui, label) + }) + .inner; + let value = ui.label_inputs_by(row.id, |ui| crate::widgets::num_field(ui, ("dlg-ab-xy", key), Some(at - zero), unit, form::FIELD_W)); + if let Some(v) = value { let n = if key == "x" { artboard_rect(r, refi, Some(v + zero), None, None, None, false).x0 } else { diff --git a/crates/ui-egui/src/dialogs/blend_options.rs b/crates/ui-egui/src/dialogs/blend_options.rs index 9ab6e3249..f988d702b 100644 --- a/crates/ui-egui/src/dialogs/blend_options.rs +++ b/crates/ui-egui/src/dialogs/blend_options.rs @@ -125,7 +125,7 @@ pub(crate) fn orientation_button(ui: &mut egui::Ui, along: bool, selected: bool, let n = if along { vec2(-12.0 * x / 9.0, -1.0).normalized() } else { vec2(0.0, -1.0) }; ui.painter().line_segment([p, p + n * 7.0], Stroke::new(2.0, color)); } - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), selected, tip)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), selected, tip)); resp.on_hover_text(tip).clicked() } diff --git a/crates/ui-egui/src/dialogs/brush_art_options.rs b/crates/ui-egui/src/dialogs/brush_art_options.rs index 29a664679..e3e364284 100644 --- a/crates/ui-egui/src/dialogs/brush_art_options.rs +++ b/crates/ui-egui/src/dialogs/brush_art_options.rs @@ -439,7 +439,7 @@ fn tip_sample(ui: &mut egui::Ui, def: &Value, ty: &str, method: Colorization, na ui.painter().image(tex.id(), r, egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Image, true, name)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Image, true, name)); } /// A document of `w` × `h` showing brush definition `def` (of type `ty`) with colorization diff --git a/crates/ui-egui/src/dialogs/color_picker.rs b/crates/ui-egui/src/dialogs/color_picker.rs index b5ceaa709..7e9d8e637 100644 --- a/crates/ui-egui/src/dialogs/color_picker.rs +++ b/crates/ui-egui/src/dialogs/color_picker.rs @@ -480,8 +480,8 @@ fn new_original_chips(ui: &mut Ui, new: &Color, original: &Color) -> bool { widgets::paint_chip(ui, top, &Paint::solid(*new)); widgets::paint_chip(ui, bottom, &Paint::solid(*original)); ui.painter().rect_stroke(r, 0.0, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Outside); - ui.interact(top, ui.id().with("cp-new"), Sense::hover()).on_hover_text(tl!("New")); - ui.interact(bottom, ui.id().with("cp-original"), Sense::click()).on_hover_text(tl!("Original: click to restore")).clicked() + ui.interact(top, ui.scope_id().with("cp-new"), Sense::hover()).on_hover_text(tl!("New")); + ui.interact(bottom, ui.scope_id().with("cp-original"), Sense::click()).on_hover_text(tl!("Original: click to restore")).clicked() } /// The HSB and RGB fields with the channel radios, hex, Lab and CMYK. Returns the edited colour @@ -518,7 +518,7 @@ fn value_fields(ui: &mut Ui, p: &Picked, channel: Channel) -> (Option, O 1 | 2 => (p.hsb[i] * 100.0, "%"), _ => (rgb[i - 3] * 255.0, ""), }; - if let Some(nv) = widgets::plain_field(ui, ("cp-left", i), v as f64, suffix, 0, VALUE_W) { + if let Some(nv) = widgets::plain_field_named(ui, ("cp-left", i), Some(ch.label()), v as f64, suffix, 0, VALUE_W) { let nv = nv as f32; edit = Some(match i { 0..3 => { @@ -533,7 +533,9 @@ fn value_fields(ui: &mut Ui, p: &Picked, channel: Channel) -> (Option, O } }); } - } else if let Some(c) = widgets::hex_field(ui, "cp-hex", &hex_digits(&p.color)).as_deref().and_then(parse_hex) { + } else if let Some(c) = + widgets::hex_field_named(ui, "cp-hex", Some(tl!("Color")), &hex_digits(&p.color)).as_deref().and_then(parse_hex) + { edit = Some(Picked::of(c)); } ui.end_row(); @@ -561,7 +563,7 @@ fn value_fields(ui: &mut Ui, p: &Picked, channel: Channel) -> (Option, O ui.add_space(12.0); ui.label(egui::RichText::new(label).size(12.5).color(t.text)); }); - if let Some(nv) = widgets::plain_field(ui, ("cp-right", i), v as f64, suffix, 0, VALUE_W) { + if let Some(nv) = widgets::plain_field_named(ui, ("cp-right", i), Some(label), v as f64, suffix, 0, VALUE_W) { let nv = nv as f32; edit = Some(Picked::of(if i < 3 { let mut l = [lab.l, lab.a, lab.b]; @@ -944,7 +946,8 @@ mod tests { assert_eq!(names[names.len() - 2..], [Some("Y"), Some("K")], "one Tab takes Y to K"); let found: Vec<&(String, Rect)> = stops.iter().flatten().collect(); for (left, right) in found[..7].iter().zip(&found[7..]) { - assert_eq!(left.1.center().y, right.1.center().y, "{} and {} are level", left.0, right.0); + // Two grids: their rows land on the same subpixel-rounded line, not necessarily bit-equal. + assert!((left.1.center().y - right.1.center().y).abs() <= 0.5, "{} and {} are level", left.0, right.0); } } @@ -1128,7 +1131,7 @@ mod tests { assert!((last_input.bottom() - field.bottom()).abs() < 2.0, "{last_input:?} vs {field:?}"); // Fonts and translated actions may leave one normal gutter, but never an unused column. assert!((last_input.right() - ok.right()).abs() < COLUMN_GAP, "no unused column beside the inputs: {last_input:?} vs {ok:?}"); - let id = egui::Id::new(("dialog", KIND)); + let id = egui::Id::unique(("dialog", KIND)); let before = ctx.memory(|m| m.area_rect(id)).unwrap(); assert!(drawn.boxes.iter().all(|r| r.left() >= before.left() - 1.0 && r.right() <= before.right() + 1.0), "controls fit horizontally"); let title = drawn.text(tl!("Color Picker")); @@ -1185,7 +1188,7 @@ mod tests { let (ctx, mut app) = (context(), app()); app.run("ui.colorPicker", json!({"color":"#ffffff"})).unwrap(); settled(&ctx, &mut app, screen()); - let id = egui::Id::new(("dialog", KIND)); + let id = egui::Id::unique(("dialog", KIND)); let before = ctx.memory(|m| m.area_rect(id)).unwrap(); app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("0000FE")); let drawn = settled(&ctx, &mut app, screen()); @@ -1217,7 +1220,7 @@ mod tests { app.run("ui.colorPicker", json!({})).unwrap(); let drawn = settled(&ctx, &mut app, screen()); let title = drawn.text(tl!("Color Picker")).center(); - let id = egui::Id::new(("dialog", KIND)); + let id = egui::Id::unique(("dialog", KIND)); let before = ctx.memory(|m| m.area_rect(id)).unwrap(); let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; draw(&ctx, &mut app, screen(), vec![egui::Event::PointerMoved(title), pointer(title, true)]); @@ -1273,7 +1276,7 @@ mod tests { let (ctx, mut app) = (context(), app()); app.run("ui.colorPicker", json!({"color": "#0000ff"})).unwrap(); let drawn = settled(&ctx, &mut app, size); - let outer = ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", KIND)))).unwrap(); + let outer = ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", KIND)))).unwrap(); assert!(outer.width() <= size.x && outer.height() <= size.y, "{outer:?} in {size:?}"); let field = drawn.fields.iter().max_by(|a, b| a.area().total_cmp(&b.area())).unwrap(); assert!((MIN_FIELD..=FIELD).contains(&field.width())); @@ -1295,6 +1298,7 @@ mod tests { delta: vec2(0.0, -20.0), phase: egui::TouchPhase::Move, modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, }, ], ); diff --git a/crates/ui-egui/src/dialogs/dimension.rs b/crates/ui-egui/src/dialogs/dimension.rs index 30afcbf8f..d79aea04e 100644 --- a/crates/ui-egui/src/dialogs/dimension.rs +++ b/crates/ui-egui/src/dialogs/dimension.rs @@ -100,10 +100,12 @@ fn precision(ui: &mut Ui, fields: &mut serde_json::Map, id: &str) let before = fields.get("precision").and_then(Value::as_u64).unwrap_or(2) as u32; let mut value = before; ui.label(tl!("Precision:")); - egui::ComboBox::from_id_salt(id).selected_text(value.to_string()).width(55.0).show_ui(ui, |ui| { - for n in 0..=6 { - ui.selectable_value(&mut value, n, n.to_string()); - } + ui.name_inputs(tl!("Precision"), |ui| { + egui::ComboBox::from_id_salt(id).selected_text(value.to_string()).width(55.0).show_ui(ui, |ui| { + for n in 0..=6 { + ui.selectable_value(&mut value, n, n.to_string()); + } + }); }); fields.insert("precision".into(), json!(value)); value != before @@ -129,15 +131,18 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui.horizontal(|ui| { widgets::label_row(ui, tl!("Scale:"), 96.0, |ui| { let mut scale = f.get("scale").and_then(Value::as_str).unwrap_or_default().to_owned(); - if ui.add(egui::TextEdit::singleline(&mut scale).desired_width(72.0)).changed() { + let changed = ui.name_inputs(tl!("Scale"), |ui| ui.add(egui::TextEdit::singleline(&mut scale).desired_width(72.0))).changed(); + if changed { f.insert("scale".into(), json!(scale)); } - egui::ComboBox::from_id_salt("dimension-scales").selected_text(tl!("Presets")).width(76.0).show_ui(ui, |ui| { - for scale in ["1:1", "1:2", "1:5", "1:10", "1:20", "1:50", "1:100", "1:200", "2:1", "5:1", "10:1"] { - if ui.selectable_label(false, scale).clicked() { - f.insert("scale".into(), json!(scale)); + ui.name_inputs(tl!("Scale Presets"), |ui| { + egui::ComboBox::from_id_salt("dimension-scales").selected_text(tl!("Presets")).width(76.0).show_ui(ui, |ui| { + for scale in ["1:1", "1:2", "1:5", "1:10", "1:20", "1:50", "1:100", "1:200", "2:1", "5:1", "10:1"] { + if ui.selectable_label(false, scale).clicked() { + f.insert("scale".into(), json!(scale)); + } } - } + }); }); }); check(ui, f, "hideUnits", "Hide Units"); @@ -251,7 +256,7 @@ pub fn floating(app: &mut VectorcraftApp, ctx: &egui::Context) { return; } egui::Window::new(tl!("Dimension Tool")) - .id(egui::Id::new("dimension-strip")) + .id(egui::Id::unique("dimension-strip")) .default_pos(egui::pos2(90.0, 120.0)) .resizable(false) .collapsible(false) diff --git a/crates/ui-egui/src/dialogs/document_setup.rs b/crates/ui-egui/src/dialogs/document_setup.rs index 4182be5de..070d93594 100644 --- a/crates/ui-egui/src/dialogs/document_setup.rs +++ b/crates/ui-egui/src/dialogs/document_setup.rs @@ -133,7 +133,8 @@ fn transparency(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.painter().rect_filled(chip, 2.0, crate::panels::c32(&c)); } ui.painter().rect_stroke(chip, 2.0, egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside); - if let Some(c) = widgets::hex_field(ui, ("grid-color", i), hex).and_then(|h| vectorcraft_color::Color::from_hex(&h)) { + let name = format!("{} {}", tl!("Grid Colors:"), i + 1); + if let Some(c) = widgets::hex_field_named(ui, ("grid-color", i), Some(&name), hex).and_then(|h| vectorcraft_color::Color::from_hex(&h)) { let mut v = colors.clone(); v.resize(2, "#ffffff".into()); v[i] = c.to_hex(); diff --git a/crates/ui-egui/src/dialogs/dxf_import.rs b/crates/ui-egui/src/dialogs/dxf_import.rs index 6bbf0d1d3..64a6cac5b 100644 --- a/crates/ui-egui/src/dialogs/dxf_import.rs +++ b/crates/ui-egui/src/dialogs/dxf_import.rs @@ -163,7 +163,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { fn preview(app: &mut VectorcraftApp, ctx: &egui::Context, d: &Dialog) -> Option { let file = app.ui.dialog_file.clone()?; let layout = d.str("layout"); - let key = egui::Id::new(("dxf-preview", file.token, layout.as_str())); + let key = egui::Id::unique(("dxf-preview", file.token, layout.as_str())); let o = vectorcraft_cad::ImportOptions { layout: Some(layout.clone()), fit: true, ..Default::default() }; // A layout that can't be read shows an empty preview (and isn't read again). preview_texture(app, ctx, key, "dxf-preview", PREVIEW, || vectorcraft_cad::import(&file.bytes, &o).ok().map(|r| r.document)) diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index a4b84e075..ddd462830 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -611,7 +611,7 @@ fn curve_graph(ui: &mut egui::Ui, d: &mut Dialog) { let (rect, resp) = ui.allocate_exact_size(egui::vec2(SIZE, SIZE), egui::Sense::click_and_drag()); let screen = |(x, y): (f32, f32)| egui::pos2(rect.left() + x * SIZE, rect.bottom() - y * SIZE); let unit = |p: egui::Pos2| (((p.x - rect.left()) / SIZE).clamp(0.0, 1.0), ((rect.bottom() - p.y) / SIZE).clamp(0.0, 1.0)); - let held = ui.id().with("curve-point"); + let held = ui.scope_id().with("curve-point"); let mut grabbed: Option = ui.data(|m| m.get_temp(held)).flatten(); let mut changed = false; if let Some(p) = resp.interact_pointer_pos() diff --git a/crates/ui-egui/src/dialogs/export_for_screens.rs b/crates/ui-egui/src/dialogs/export_for_screens.rs index 932eb3166..7bc4ca8b7 100644 --- a/crates/ui-egui/src/dialogs/export_for_screens.rs +++ b/crates/ui-egui/src/dialogs/export_for_screens.rs @@ -467,7 +467,7 @@ fn artboards(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { set_select(d, "range"); } ui.add_enabled_ui(select == "range", |ui| { - if form::text(ui, d, "range", 90.0) + if form::text_named(ui, d, "range", tl!("Range:"), 90.0) && let Ok(Some(chosen)) = (ArtboardPick { range: Some(d.str("range")), ..Default::default() }).resolve(n) { boards = (0..n).map(|i| chosen.contains(&i)).collect(); @@ -705,7 +705,9 @@ fn naming(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { if form::text(ui, d, "name", 170.0) { d.fields.insert("exportPreset".into(), json!("")); } - if let Some((_, token)) = widgets::dropdown(ui, "efs-name-token", tl!("Insert"), &TOKEN_LABELS, 90.0).and_then(|i| TOKENS.get(i)) { + if let Some((_, token)) = + widgets::dropdown_named(ui, "efs-name-token", Some(tl!("Insert")), tl!("Insert"), &TOKEN_LABELS, 90.0).and_then(|i| TOKENS.get(i)) + { let name = d.str("name"); d.fields.insert("name".into(), json!(format!("{name}{token}"))); } @@ -817,7 +819,9 @@ pub(crate) fn formats(ui: &mut egui::Ui, d: &mut Dialog) { let preset = d.str("preset"); let labels: Vec<&str> = std::iter::once("Custom").chain(SCREEN_PRESETS.iter().map(|(_, l)| *l)).collect(); let at = SCREEN_PRESETS.iter().position(|(id, _)| *id == preset).map_or(0, |i| i + 1); - if let Some(i) = widgets::dropdown(ui, "efs-preset", labels[at], &labels, (ui.available_width() - 8.0).clamp(90.0, 180.0)) { + if let Some(i) = + widgets::dropdown_named(ui, "efs-preset", Some(tl!("Preset:")), labels[at], &labels, (ui.available_width() - 8.0).clamp(90.0, 180.0)) + { let id = i.checked_sub(1).and_then(|i| SCREEN_PRESETS.get(i)).map_or("", |(id, _)| *id); if let Some(preset_rows) = fileio::screen_preset_rows(id) { rows = preset_rows; @@ -843,7 +847,7 @@ pub(crate) fn formats(ui: &mut egui::Ui, d: &mut Dialog) { let raster = row["format"].as_str().and_then(fileio::format).is_none_or(|f| f.raster); let scale = row["scale"].as_str().unwrap_or("1x").to_string(); ui.add_enabled_ui(raster, |ui| { - if let Some(s) = widgets::text_presets(ui, ("efs-scale", i), &scale, &SIZES, scale_w) { + if let Some(s) = widgets::text_presets_named(ui, ("efs-scale", i), Some(tl!("Scale")), &scale, &SIZES, scale_w) { // The suffix follows the size while it is the automatic one. let auto = |text: &str| ScreenSize::parse(text).map(ScreenSize::suffix); if row["suffix"].as_str().is_none_or(|x| Some(x.to_string()) == auto(&scale)) @@ -855,7 +859,7 @@ pub(crate) fn formats(ui: &mut egui::Ui, d: &mut Dialog) { edited = true; } }); - if let Some(s) = widgets::text_field(ui, ("efs-suffix", i), row["suffix"].as_str(), suffix_w, 1) { + if let Some(s) = widgets::text_field_named(ui, ("efs-suffix", i), Some(tl!("Suffix")), row["suffix"].as_str(), suffix_w, 1) { row["suffix"] = json!(s); edited = true; } @@ -864,7 +868,9 @@ pub(crate) fn formats(ui: &mut egui::Ui, d: &mut Dialog) { let current = FORMATS.iter().position(|(_, f, q, print)| *f == format && *print == marked && q.is_none_or(|q| Some(u64::from(q)) == quality)); let label = current.map_or_else(|| format.to_uppercase(), |i| FORMAT_LABELS[i].to_string()); - if let Some((_, f, q, print)) = widgets::dropdown(ui, ("efs-fmt", i), &label, &FORMAT_LABELS, format_w).and_then(|i| FORMATS.get(i)) { + if let Some((_, f, q, print)) = + widgets::dropdown_named(ui, ("efs-fmt", i), Some(tl!("Format")), &label, &FORMAT_LABELS, format_w).and_then(|i| FORMATS.get(i)) + { row["format"] = json!(f); if let Some(o) = row.as_object_mut() { match q { @@ -921,7 +927,9 @@ fn format_settings(ui: &mut egui::Ui, d: &mut Dialog, id: &str) { widgets::subheader(ui, tl!("Format Settings")); ui.add_space(6.0); let at = SETTINGS.iter().position(|(f, _)| *f == id).unwrap_or(0); - if let Some((f, _)) = widgets::dropdown(ui, "efs-settings-format", SETTINGS_LABELS[at], &SETTINGS_LABELS, 150.0).and_then(|i| SETTINGS.get(i)) { + if let Some((f, _)) = widgets::dropdown_named(ui, "efs-settings-format", Some(tl!("Format:")), SETTINGS_LABELS[at], &SETTINGS_LABELS, 150.0) + .and_then(|i| SETTINGS.get(i)) + { d.fields.insert("__settings".into(), json!(f)); } ui.add_space(8.0); @@ -958,7 +966,7 @@ fn format_settings(ui: &mut egui::Ui, d: &mut Dialog, id: &str) { /// A small cached rendering of artboard `i` (keyed by document revision). pub(super) fn artboard_thumb(app: &mut VectorcraftApp, ctx: &egui::Context, i: usize) -> Option { let st = app.session.active()?; - let key = egui::Id::new(("ab-thumb", i, st.revision)); + let key = egui::Id::unique(("ab-thumb", i, st.revision)); if let Some(t) = ctx.data(|d| d.get_temp::(key)) { return Some(t); } diff --git a/crates/ui-egui/src/dialogs/file_info.rs b/crates/ui-egui/src/dialogs/file_info.rs index b0d8185bb..3f3cbae3f 100644 --- a/crates/ui-egui/src/dialogs/file_info.rs +++ b/crates/ui-egui/src/dialogs/file_info.rs @@ -174,7 +174,7 @@ fn chips(ui: &mut egui::Ui, d: &mut Dialog) { ui.painter().galley(egui::pos2(rect.left() + 9.0, rect.center().y - galley.size().y / 2.0), galley, t.text_strong); let x = egui::Rect::from_center_size(egui::pos2(rect.right() - 11.0, rect.center().y), egui::vec2(14.0, 14.0)); let resp = ui - .interact(x, ui.id().with(("keyword-remove", i)), egui::Sense::click()) + .interact(x, ui.scope_id().with(("keyword-remove", i)), egui::Sense::click()) .on_hover_text(crate::i18n::fmt(tl!("Remove “{keyword}”"), &[("keyword", k.as_str())])); icons::paint(ui, "x", x.shrink(2.0), if resp.hovered() { t.text_strong } else { t.text_dim }); if resp.clicked() { diff --git a/crates/ui-egui/src/dialogs/flatten.rs b/crates/ui-egui/src/dialogs/flatten.rs index fbef5dbee..b4c54ed2f 100644 --- a/crates/ui-egui/src/dialogs/flatten.rs +++ b/crates/ui-egui/src/dialogs/flatten.rs @@ -205,7 +205,9 @@ pub(crate) fn options_editor(ui: &mut egui::Ui, id: &str, o: &mut FlattenOptions o.balance = b; } dim(ui, tl!("Vectors")); - if let Some(v) = widgets::range_field(ui, (id, "balance"), o.balance, 0.0..=100.0, "", 0, FIELD - 12.0) { + if let Some(v) = + widgets::range_field_named(ui, (id, "balance"), Some(tl!("Raster/Vector Balance:")), o.balance, 0.0..=100.0, "", 0, FIELD - 12.0) + { o.balance = v; } }); @@ -215,7 +217,7 @@ pub(crate) fn options_editor(ui: &mut egui::Ui, id: &str, o: &mut FlattenOptions widgets::dim_label(ui, label); ui.horizontal(|ui| { let v = if key == 0 { &mut o.line_art_ppi } else { &mut o.gradient_ppi }; - if let Some(n) = widgets::range_field(ui, (id, key), *v, 1.0..=2400.0, "", 0, FIELD) { + if let Some(n) = widgets::range_field_named(ui, (id, key), Some(label), *v, 1.0..=2400.0, "", 0, FIELD) { *v = n; } dim(ui, "ppi"); diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index b715ff98a..1e5514df6 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -18,21 +18,25 @@ pub(super) fn field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { /// [`field`] `width` points wide. pub(super) fn field_w(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, width: f32) { - row_label(ui, label); - text(ui, d, key, width); + let row = row_label(ui, label); + // The value widget must be named by the real Label already drawn beside it (egui 0.37's + // kittest fails an unnamed input). + ui.label_inputs_by(row.id, |ui| text(ui, d, key, width)); ui.end_row(); } -/// The label cell of a grid row. -pub(super) fn row_label(ui: &mut egui::Ui, label: &str) { +/// The label cell of a grid row (its `Response`, so the value can be named by it). +pub(super) fn row_label(ui: &mut egui::Ui, label: &str) -> egui::Response { let t = Tokens::get(ui.ctx()); - ui.label(egui::RichText::new(tl!(label)).color(t.text_dim)); + ui.label(egui::RichText::new(tl!(label)).color(t.text_dim)) } /// A labelled [`length`] field (one grid row). pub(super) fn length_field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, unit: Unit) { - row_label(ui, label); - length(ui, d, key, unit, FIELD_W); + let row = row_label(ui, label); + ui.label_inputs_by(row.id, |ui| { + length(ui, d, key, unit, FIELD_W); + }); ui.end_row(); } @@ -137,18 +141,38 @@ pub(super) fn text(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32) -> text_edit(ui, d, key, width).changed() } +/// [`text`] for a row whose visible meaning is not the immediately preceding label widget +/// (Export for Screens' Range field follows its radio buttons): screen readers name the field +/// `name`, already translated. The pending label is still consumed, so it cannot name a later +/// field. +pub(super) fn text_named(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, width: f32) -> bool { + text_edit_named(ui, d, key, name, width).changed() +} + /// [`text`] returning the field's response (focus, Enter). pub(super) fn text_edit(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32) -> egui::Response { text_edit_limited(ui, d, key, width, usize::MAX) } +/// [`text_edit`] with an explicit translated name (see [`text_named`]). +pub(super) fn text_edit_named(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, width: f32) -> egui::Response { + text_edit_limited_name(ui, d, key, width, usize::MAX, Some(name)) +} + /// [`text_edit`] that takes at most `max_chars` characters: typing and pasting stop at the limit, /// and a longer value already in the field is kept, with nothing added to it until it has fewer /// than `max_chars` characters. pub(super) fn text_edit_limited(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32, max_chars: usize) -> egui::Response { + text_edit_limited_name(ui, d, key, width, max_chars, None) +} + +/// The text field shared by [`text_edit_limited`] and [`text_edit_named`]: `name` wins over the +/// adjacent label, and either way the pending label is taken so it can't leak to a later field. +fn text_edit_limited_name(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32, max_chars: usize, name: Option<&str>) -> egui::Response { let t = Tokens::get(ui.ctx()); + let label = crate::widgets::take_previous_label(ui); let mut s = d.str(key); - let id = ui.id().with(("dlg-text", key)); + let id = ui.scope_id().with(("dlg-text", key)); crate::widgets::take_dialog_focus(ui, id, &s); let r = egui::Frame::NONE .fill(t.input) @@ -162,13 +186,23 @@ pub(super) fn text_edit_limited(ui: &mut egui::Ui, d: &mut Dialog, key: &str, wi if r.inner.changed() { d.fields.insert(key.into(), Value::String(s)); } - r.inner + match name { + Some(name) => r.inner.accessible_name(name), + None => crate::widgets::label_field(r.inner, label), + } } /// A text box `rows` lines tall and `width` wide bound to `d.fields[key]`. Enter adds a line (it /// doesn't press OK while the box has focus). pub(super) fn text_area(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32, rows: usize) -> egui::Response { + text_area_named(ui, d, key, None, width, rows) +} + +/// [`text_area`] with an optional explicit translated name; the pending adjacent label is consumed +/// either way. +pub(super) fn text_area_named(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: Option<&str>, width: f32, rows: usize) -> egui::Response { let t = Tokens::get(ui.ctx()); + let label = crate::widgets::take_previous_label(ui); let mut s = d.str(key); let r = egui::Frame::NONE .fill(t.input) @@ -185,7 +219,10 @@ pub(super) fn text_area(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32 if r.inner.has_focus() { ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); } - r.inner + match name { + Some(name) => r.inner.accessible_name(name), + None => crate::widgets::label_field(r.inner, label), + } } /// A checkbox bound to `d.fields[key]` (its box shows while unchecked too). @@ -246,10 +283,12 @@ pub(super) fn grid_then(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit, more: imp if lengths.contains(&k.as_str()) { length_field(ui, d, &k, &humanized(&k), unit); } else if let Some(suffix) = suffix(&d.kind, &k) { - row_label(ui, &humanized(&k)); - if let Some(v) = crate::widgets::plain_field(ui, ("dlg-num", &k), d.f64(&k, 0.0), suffix, 3, FIELD_W) { - d.fields.insert(k, json!(v)); - } + let row = row_label(ui, &humanized(&k)); + ui.label_inputs_by(row.id, |ui| { + if let Some(v) = crate::widgets::plain_field(ui, ("dlg-num", &k), d.f64(&k, 0.0), suffix, 3, FIELD_W) { + d.fields.insert(k, json!(v)); + } + }); ui.end_row(); } else if let Some(ends) = skew_ends(&d.kind, &k) { let max = vectorcraft_geom::shapes::GRID_SKEW_MAX; @@ -269,8 +308,8 @@ pub(super) fn grid_then(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit, more: imp }; field(ui, d, &k, &label); } else { - row_label(ui, &humanized(&k)); - dropdown(ui, d, &k, FIELD_W, &options, true); + let row = row_label(ui, &humanized(&k)); + ui.label_inputs_by(row.id, |ui| dropdown(ui, d, &k, FIELD_W, &options, true)); ui.end_row(); } } @@ -307,71 +346,74 @@ pub(super) fn param_fields( for (k, v) in keys { // A size's Relative / Absolute pair (Roughen, Zig Zag, Tweak) is its own label. let relative = k == "relative" && v.is_boolean(); - ui.label(egui::RichText::new(if relative { String::new() } else { humanized(&k) }).color(t.text)); - if let Some(cur) = crate::widgets::blend_param(&k, &v) { - if let Some(m) = crate::widgets::blend_param_dropdown(ui, ("fx-blend", &k), cur) { - d.fields.insert(k, m); - changed = true; - } - ui.end_row(); - continue; - } - if is_length(&k) { - changed |= length(ui, d, &k, unit, 140.0); - ui.end_row(); - continue; - } - if let Some(options) = choices(&k) { - let cur = v.as_str().unwrap_or_default(); - let label = options.iter().find(|(_, value)| *value == cur).map_or(cur, |(l, _)| choice_label(l)); - let labels: Vec<&str> = options.iter().map(|(l, _)| choice_label(l)).collect(); - if let Some((_, value)) = crate::widgets::dropdown_names(ui, ("fx-choice", &k), label, &labels, 140.0).and_then(|i| options.get(i)) { - d.fields.insert(k, json!(value)); - changed = true; - } - ui.end_row(); - continue; - } - match v { - Value::Number(n) => { - if let Some(x) = crate::widgets::plain_field(ui, ("fx-num", &k), n.as_f64().unwrap_or(0.0), "", 3, 140.0) { - d.fields.insert(k, typed_number(x)); + let row = ui.label(egui::RichText::new(if relative { String::new() } else { humanized(&k) }).color(t.text)); + // The row's actual label node names every input beside it, including plain + // `TextEdit`s and the numeric field beside a slider (egui 0.37's strict a11y gate). + ui.label_inputs_by(row.id, |ui| { + if let Some(cur) = crate::widgets::blend_param(&k, &v) { + if let Some(m) = crate::widgets::blend_param_dropdown(ui, ("fx-blend", &k), cur) { + d.fields.insert(k.clone(), m); changed = true; } + return; } - Value::Bool(b) if relative => { - ui.horizontal(|ui| { - for (label, on) in [(tl!("Relative"), true), (tl!("Absolute"), false)] { - if crate::widgets::radio(ui, label, b == on, true) && b != on { - d.fields.insert(k.clone(), json!(on)); - changed = true; - } + if is_length(&k) { + changed |= length(ui, d, &k, unit, 140.0); + return; + } + if let Some(options) = choices(&k) { + let cur = v.as_str().unwrap_or_default(); + let label = options.iter().find(|(_, value)| *value == cur).map_or(cur, |(l, _)| choice_label(l)); + let labels: Vec<&str> = options.iter().map(|(l, _)| choice_label(l)).collect(); + if let Some((_, value)) = + crate::widgets::dropdown_names(ui, ("fx-choice", &k), label, &labels, 140.0).and_then(|i| options.get(i)) + { + d.fields.insert(k.clone(), json!(value)); + changed = true; + } + return; + } + match v { + Value::Number(n) => { + if let Some(x) = crate::widgets::plain_field(ui, ("fx-num", &k), n.as_f64().unwrap_or(0.0), "", 3, 140.0) { + d.fields.insert(k.clone(), typed_number(x)); + changed = true; } - }); - } - Value::Bool(b) => { - if crate::widgets::check_box(ui, &humanized(&k), b, true) { - d.fields.insert(k, json!(!b)); - changed = true; + } + Value::Bool(b) if relative => { + ui.horizontal(|ui| { + for (label, on) in [(tl!("Relative"), true), (tl!("Absolute"), false)] { + if crate::widgets::radio(ui, label, b == on, true) && b != on { + d.fields.insert(k.clone(), json!(on)); + changed = true; + } + } + }); + } + Value::Bool(b) => { + if crate::widgets::check_box(ui, &humanized(&k), b, true) { + d.fields.insert(k.clone(), json!(!b)); + changed = true; + } + } + Value::String(mut s) if s.contains('\n') => { + // Multi-line values (Graph Data CSV) get a text area. + if ui.add(egui::TextEdit::multiline(&mut s).desired_width(260.0).desired_rows(8).font(egui::TextStyle::Monospace)).changed() { + d.fields.insert(k.clone(), json!(s)); + changed = true; + } + } + Value::String(mut s) => { + if ui.add(egui::TextEdit::singleline(&mut s).desired_width(140.0)).changed() { + d.fields.insert(k.clone(), json!(s)); + changed = true; + } + } + other => { + ui.label(egui::RichText::new(other.to_string()).color(t.text_dim).size(11.0)); } } - Value::String(mut s) if s.contains('\n') => { - // Multi-line values (Graph Data CSV) get a text area. - if ui.add(egui::TextEdit::multiline(&mut s).desired_width(260.0).desired_rows(8).font(egui::TextStyle::Monospace)).changed() { - d.fields.insert(k, json!(s)); - changed = true; - } - } - Value::String(mut s) => { - if ui.add(egui::TextEdit::singleline(&mut s).desired_width(140.0)).changed() { - d.fields.insert(k, json!(s)); - changed = true; - } - } - other => { - ui.label(egui::RichText::new(other.to_string()).color(t.text_dim).size(11.0)); - } - } + }); ui.end_row(); } }); @@ -462,16 +504,16 @@ fn param_row(ui: &mut egui::Ui, d: &mut Dialog, k: &str, p: &Param) -> (bool, Op let t = Tokens::get(ui.ctx()); let label = param_label(k, p); let shown_label = ui.label(egui::RichText::new(&label).color(t.text)); - if let Some(help) = &p.ui.help { - shown_label.on_hover_text(help); - } + let shown_label = if let Some(help) = &p.ui.help { shown_label.on_hover_text(help) } else { shown_label }; let cur = d.fields.get(k).cloned().unwrap_or_else(|| p.spec.default_value()); let unit = p.ui.unit.as_deref().unwrap_or_default(); let suffix = unit_suffix(unit); let mut opened = None; let new = ui .horizontal(|ui| { - let (new, in_field) = value_field(ui, k, &label, &p.spec, &cur, &suffix); + // The row's painted label names every input in its value column (egui 0.37's strict + // accessibility gate), not only the first one. + let (new, in_field) = ui.label_inputs_by(shown_label.id, |ui| value_field(ui, k, &label, &p.spec, &cur, &suffix)); if !in_field && !unit.is_empty() { ui.label(egui::RichText::new(unit).color(t.text_dim)); } @@ -494,7 +536,7 @@ fn value_field(ui: &mut egui::Ui, k: &str, label: &str, spec: &ParamSpec, cur: & match spec { ParamSpec::Number { min, max, .. } => { let x = cur.as_f64().unwrap_or(*min); - (crate::widgets::range_field(ui, ("plugin-num", k), x, *min..=*max, suffix, 2, 100.0).map(|x| json!(x)), true) + (crate::widgets::range_field_named(ui, ("plugin-num", k), Some(label), x, *min..=*max, suffix, 2, 100.0).map(|x| json!(x)), true) } ParamSpec::Int { min, max, .. } => { let x = cur.as_i64().unwrap_or(*min).clamp(*min, *max); @@ -502,12 +544,13 @@ fn value_field(ui: &mut egui::Ui, k: &str, label: &str, spec: &ParamSpec, cur: & // Past it, the number typed is read as an integer. const EXACT: i64 = 1 << 53; if -EXACT <= *min && *max <= EXACT { - let field = crate::widgets::range_field(ui, ("plugin-int", k), x as f64, *min as f64..=*max as f64, suffix, 0, 100.0); + let field = + crate::widgets::range_field_named(ui, ("plugin-int", k), Some(label), x as f64, *min as f64..=*max as f64, suffix, 0, 100.0); (field.map(|x| json!(x as i64)), true) } else { let shown = x.to_string(); - crate::widgets::take_dialog_focus(ui, ui.id().with(("plugin-int", k)), &shown); - let typed = crate::widgets::text_field(ui, ("plugin-int", k), Some(&shown), 100.0, 1); + crate::widgets::take_dialog_focus(ui, ui.scope_id().with(("plugin-int", k)), &shown); + let typed = crate::widgets::text_field_named(ui, ("plugin-int", k), Some(label), Some(&shown), 100.0, 1); (typed.and_then(|text| text.parse::().ok()).map(|x| json!(x.clamp(*min, *max))), false) } } @@ -517,15 +560,16 @@ fn value_field(ui: &mut egui::Ui, k: &str, label: &str, spec: &ParamSpec, cur: & } ParamSpec::Choice { options, .. } => { let labels: Vec<&str> = options.iter().map(String::as_str).collect(); - let chosen = crate::widgets::dropdown_names(ui, ("plugin-choice", k), cur.as_str().unwrap_or_default(), &labels, 140.0); + let chosen = + crate::widgets::dropdown_names_named(ui, ("plugin-choice", k), Some(label), cur.as_str().unwrap_or_default(), &labels, 140.0); (chosen.and_then(|i| options.get(i)).map(|o| json!(o)), false) } ParamSpec::Text { max_length, .. } => { - let typed = crate::widgets::text_field(ui, ("plugin-text", k), cur.as_str(), 160.0, 1); + let typed = crate::widgets::text_field_named(ui, ("plugin-text", k), Some(label), cur.as_str(), 160.0, 1); (typed.map(|text| json!(text.chars().take(*max_length).collect::())), false) } ParamSpec::NumberList { .. } => { - let typed = crate::widgets::text_field(ui, ("plugin-list", k), Some(&number_list_text(cur)), 160.0, 1); + let typed = crate::widgets::text_field_named(ui, ("plugin-list", k), Some(label), Some(&number_list_text(cur)), 160.0, 1); (typed.and_then(|text| read_number_list(&text, spec)), false) } } @@ -656,7 +700,7 @@ pub(super) fn slider_field_named( let v = d.f64(key, 0.0).clamp(min, max); let shown = crate::widgets::SliderValue::field(v, range.clone(), suffix, 0); slider_rail_valued(ui, d, key, name, range, Some(shown), track); - if let Some(n) = crate::widgets::range_field(ui, ("dlg-field", key), v, min..=max, suffix, 0, 52.0).filter(|n| *n != v) { + if let Some(n) = crate::widgets::range_field_named(ui, ("dlg-field", key), Some(name), v, min..=max, suffix, 0, 52.0).filter(|n| *n != v) { d.fields.insert(key.into(), json!(n)); } } @@ -823,8 +867,9 @@ pub(super) fn bleed(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Un ui.spacing_mut().item_spacing.x = 6.0; for (i, side) in ["Top", "Bottom", "Left", "Right"].into_iter().enumerate() { ui.vertical(|ui| { - ui.label(egui::RichText::new(tl!(side)).size(11.0).color(t.text_dim)); - if let Some(v) = crate::widgets::num_field(ui, ("bleed", i), Some(b[i]), unit, width) { + let label = ui.label(egui::RichText::new(tl!(side)).size(11.0).color(t.text_dim)); + let v = ui.label_inputs_by(label.id, |ui| crate::widgets::num_field(ui, ("bleed", i), Some(b[i]), unit, width)); + if let Some(v) = v { changed = Some((i, v.clamp(0.0, vectorcraft_doc::setup::MAX_BLEED))); } }); @@ -914,7 +959,7 @@ mod selected_text_tests { let mut d = Dialog::new("test", json!({"name": "Artboard 1"})); let id = std::cell::Cell::new(egui::Id::NULL); let mut add = |ui: &mut egui::Ui| { - id.set(ui.id().with(("dlg-text", "name"))); + id.set(ui.scope_id().with(("dlg-text", "name"))); text_edit(ui, &mut d, "name", 120.0); }; frame(&ctx, 0.0, vec![], &mut add); diff --git a/crates/ui-egui/src/dialogs/free_distort.rs b/crates/ui-egui/src/dialogs/free_distort.rs index 5d29c0a8c..5eed4c8e3 100644 --- a/crates/ui-egui/src/dialogs/free_distort.rs +++ b/crates/ui-egui/src/dialogs/free_distort.rs @@ -87,10 +87,10 @@ pub(super) fn fields(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { // Per handle: whether it is held or hovered, and whether it has the keyboard focus. let mut states = [(false, false); 4]; for (i, corner) in c.iter_mut().enumerate() { - let id = ui.id().with(("free-distort-corner", i)); + let id = ui.scope_id().with(("free-distort-corner", i)); let at = pad.clamp(to_screen(*corner)); let resp = ui.interact(Rect::from_center_size(at, Vec2::splat(GRAB)), id, Sense::click_and_drag()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), names[i])); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), names[i])); if resp.drag_started() || resp.clicked() { resp.request_focus(); } @@ -132,7 +132,7 @@ pub(super) fn fields(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { let reset = ui .add_enabled_ui(c != IDENTITY, |ui| { let r = widgets::flat_button(ui, tl!("Reset"), 64.0); - r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Reset"))); + r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Reset"))); r }) .inner; diff --git a/crates/ui-egui/src/dialogs/gradient_stop.rs b/crates/ui-egui/src/dialogs/gradient_stop.rs index 22a0d3973..0c6f39b8b 100644 --- a/crates/ui-egui/src/dialogs/gradient_stop.rs +++ b/crates/ui-egui/src/dialogs/gradient_stop.rs @@ -90,7 +90,7 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let screen = d.fields.get("screen").and_then(Value::as_array).and_then(|a| Some(egui::pos2(a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32))); let chip = Point::new(d.f64("x", 0.0), d.f64("y", 0.0)); - let area = egui::Id::new("gradient-stop-popover"); + let area = egui::Id::unique("gradient-stop-popover"); let size = ctx.memory(|m| m.area_rect(area)).map_or(POPOVER_SIZE, |r| r.size()); let pos = screen.unwrap_or_else(|| match app.canvas_rect.zip(app.view().copied()) { Some((canvas, v)) => { diff --git a/crates/ui-egui/src/dialogs/graph_data.rs b/crates/ui-egui/src/dialogs/graph_data.rs index 92b7e0139..296f60812 100644 --- a/crates/ui-egui/src/dialogs/graph_data.rs +++ b/crates/ui-egui/src/dialogs/graph_data.rs @@ -337,7 +337,7 @@ fn import(app: &mut VectorcraftApp, d: &mut Dialog) -> Result<(), String> { } fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { - let entry_id = egui::Id::new("graph-data-entry"); + let entry_id = egui::Id::unique("graph-data-entry"); if ui.memory(|m| m.has_focus(entry_id)) { // Tab, Enter (Shift: the other way) and the arrow keys move the picked cell while the entry box // has the keys; the entry keeps them (egui would move the focus on with Tab). diff --git a/crates/ui-egui/src/dialogs/import_pdf.rs b/crates/ui-egui/src/dialogs/import_pdf.rs index b9488d607..45ec6850f 100644 --- a/crates/ui-egui/src/dialogs/import_pdf.rs +++ b/crates/ui-egui/src/dialogs/import_pdf.rs @@ -282,7 +282,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { if widgets::radio(ui, tl!("Range:"), !all, true) { d.fields.insert("allPages".into(), json!(false)); } - if super::form::text_edit(ui, d, "range", 90.0).gained_focus() { + if super::form::text_edit_named(ui, d, "range", tl!("Range:"), 90.0).gained_focus() { d.fields.insert("allPages".into(), json!(false)); } }); @@ -311,7 +311,7 @@ fn password_row(ui: &mut egui::Ui, d: &mut Dialog) { fn preview(app: &mut VectorcraftApp, ctx: &egui::Context, d: &Dialog, page: usize) -> Option { let file = app.ui.dialog_file.clone()?; let crop = crop(d); - let key = egui::Id::new(("pdf-preview", file.token, page, crop.id())); + let key = egui::Id::unique(("pdf-preview", file.token, page, crop.id())); let opts = fileio::LoadOptions { crop, password: password(d), ..Default::default() }; // A page that can't be read shows an empty preview (and isn't read again). preview_texture(app, ctx, key, &format!("pdf-preview-{page}"), PREVIEW, || { diff --git a/crates/ui-egui/src/dialogs/layer_options.rs b/crates/ui-egui/src/dialogs/layer_options.rs index 119d5e3d7..a8f8f9526 100644 --- a/crates/ui-egui/src/dialogs/layer_options.rs +++ b/crates/ui-egui/src/dialogs/layer_options.rs @@ -125,7 +125,7 @@ fn color_dropdown(ui: &mut egui::Ui, shown: &str) -> Option { widgets::combo(ui, "layer-color", tl!(shown), 140.0, false, |ui| { let mut chosen = None; for (i, (name, rgb)) in rows.enumerate() { - let chip = ui.id().with(("layer-color-chip", i)); + let chip = egui::IdSalt::new(("layer-color-chip", i)); let r = egui::Button::selectable(name == shown, (egui::Atom::custom(chip, CHIP), tl!(name))).atom_ui(ui); if let (Some(rect), Some([cr, cg, cb])) = (r.rect(chip), rgb) { ui.painter().rect_filled(rect, 1.0, egui::Color32::from_rgb(cr, cg, cb)); diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 827855d28..d0289780c 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -448,7 +448,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { confirm::show_waiting(app); // The kind of dialog shown last frame: a different one (or none) means this one just opened, and // its first field is to take the keyboard focus (`focus_id`, until a field takes it). - let (shown_id, focus_id) = (egui::Id::new("dialog-shown"), egui::Id::new("dialog-focus-pending")); + let (shown_id, focus_id) = (egui::Id::unique("dialog-shown"), egui::Id::unique("dialog-focus-pending")); let Some(mut d) = app.ui.dialog.clone() else { revolve_gizmo::clear(ctx); app.ui.dialog_file = None; @@ -494,7 +494,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { revolve_gizmo::show(app, ctx, &mut d); } let initial = if revolves { egui::vec2((ctx.content_rect().width() / 2.0 - 226.0).max(0.0), -40.0) } else { egui::vec2(0.0, -40.0) }; - let id = egui::Id::new(("dialog", d.kind.as_str())); + let id = egui::Id::unique(("dialog", d.kind.as_str())); let id = if revolves { id.with("revolve") } else { id }; modal::show_at(ctx, &heading, id, initial, MARGIN, |ui| { // Never wider than the window (a large UI scale in a small window): the text wraps. @@ -590,6 +590,8 @@ pub(crate) fn mixed_dropdown( #[cfg(test)] mod tests; #[cfg(test)] +mod tests_accessibility; +#[cfg(test)] mod tests_export; #[cfg(test)] mod tests_import_pdf; diff --git a/crates/ui-egui/src/dialogs/modal.rs b/crates/ui-egui/src/dialogs/modal.rs index 2addf6129..b585b2e14 100644 --- a/crates/ui-egui/src/dialogs/modal.rs +++ b/crates/ui-egui/src/dialogs/modal.rs @@ -39,7 +39,7 @@ pub(crate) fn show_at(ctx: &egui::Context, title: &str, id: Id, initial: Vec2 /// The layer of the backdrop behind a modal dialog window, which takes the clicks the rest of /// the app would get. pub(crate) fn backdrop() -> egui::LayerId { - egui::LayerId::new(egui::Order::Middle, Id::new("modal-dim")) + egui::LayerId::new(egui::Order::Middle, Id::unique("modal-dim")) } /// The dialog's heading, which also moves the window ([`drag_band`]). diff --git a/crates/ui-egui/src/dialogs/new_document.rs b/crates/ui-egui/src/dialogs/new_document.rs index 2a41fcb9e..f5e34f998 100644 --- a/crates/ui-egui/src/dialogs/new_document.rs +++ b/crates/ui-egui/src/dialogs/new_document.rs @@ -171,7 +171,7 @@ fn set_layout(d: &mut Dialog, key: &str, v: Value) { /// The New Document dialog window `id` in the shared modal frame. fn window(ctx: &egui::Context, id: &str, margin: i8, add: impl FnOnce(&mut egui::Ui)) { - super::modal::show(ctx, id, egui::Id::new(("dialog", id)), -20.0, margin, add); + super::modal::show(ctx, id, egui::Id::unique(("dialog", id)), -20.0, margin, add); } /// What the buttons of a window asked for. diff --git a/crates/ui-egui/src/dialogs/path_ops.rs b/crates/ui-egui/src/dialogs/path_ops.rs index d627405f1..102c87043 100644 --- a/crates/ui-egui/src/dialogs/path_ops.rs +++ b/crates/ui-egui/src/dialogs/path_ops.rs @@ -71,29 +71,36 @@ fn split_grid(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) { let gutter_label = form::humanized("gutter"); egui::Grid::new("dlg").num_columns(4).spacing([10.0, 8.0]).show(ui, |ui| { for (number, label, ..) in axes { - form::row_label(ui, label); - if let Some(v) = crate::widgets::range_field(ui, ("dlg-num", number), d.f64(number, 2.0), 1.0..=500.0, "", 0, WIDTH) { - d.fields.insert(number.into(), json!(v)); - } + let row = form::row_label(ui, label); + ui.label_inputs_by(row.id, |ui| { + if let Some(v) = crate::widgets::range_field(ui, ("dlg-num", number), d.f64(number, 2.0), 1.0..=500.0, "", 0, WIDTH) { + d.fields.insert(number.into(), json!(v)); + } + }); } ui.end_row(); for (number, _, label, gutter, total) in axes { - form::row_label(ui, label); + let row = form::row_label(ui, label); let n = d.f64(number, 2.0).max(1.0); let gaps = d.f64(gutter, 0.0) * (n - 1.0); - if let Some(v) = crate::widgets::num_field(ui, ("dlg-size", number), Some((d.f64(total, 0.0) - gaps) / n), unit, WIDTH) { - d.fields.insert(total.into(), json!(n * v + gaps)); - } + ui.label_inputs_by(row.id, |ui| { + if let Some(v) = crate::widgets::num_field(ui, ("dlg-size", number), Some((d.f64(total, 0.0) - gaps) / n), unit, WIDTH) { + d.fields.insert(total.into(), json!(n * v + gaps)); + } + }); } ui.end_row(); - for (.., gutter, _) in axes { - form::row_label(ui, &gutter_label); - form::length(ui, d, gutter, unit, WIDTH); + for (_, axis, _, gutter, _) in axes { + // The visible rows stay the existing translated "Gutter:" and "Total:" labels; the + // accessible names say which axis they belong to (two identical visible labels would + // otherwise name two different inputs the same). + let _ = form::row_label(ui, &gutter_label); + ui.name_inputs(format!("{axis} {gutter_label}"), |ui| form::length(ui, d, gutter, unit, WIDTH)); } ui.end_row(); - for (.., total) in axes { - form::row_label(ui, tl!("Total:")); - form::length(ui, d, total, unit, WIDTH); + for (_, axis, _, _, total) in axes { + let _ = form::row_label(ui, tl!("Total:")); + ui.name_inputs(format!("{axis} {}", tl!("Total:")), |ui| form::length(ui, d, total, unit, WIDTH)); } ui.end_row(); }); diff --git a/crates/ui-egui/src/dialogs/perspective_grid.rs b/crates/ui-egui/src/dialogs/perspective_grid.rs index 6ca855f76..eaeb3da74 100644 --- a/crates/ui-egui/src/dialogs/perspective_grid.rs +++ b/crates/ui-egui/src/dialogs/perspective_grid.rs @@ -178,7 +178,8 @@ fn third_vp(ui: &mut egui::Ui, d: &mut Dialog, i: usize, unit: Unit, enabled: bo v.resize(2, json!(0)); let pt = v.get(i).and_then(Value::as_f64).map(|x| unit.to_pt(x)); ui.label(if i == 0 { "X:" } else { "Y:" }); - if let Some(x) = ui.add_enabled_ui(enabled, |ui| widgets::num_field(ui, ("persp-vp3", i), pt, unit, 84.0)).inner { + let name = if i == 0 { crate::i18n::t("X:") } else { crate::i18n::t("Y:") }; + if let Some(x) = ui.add_enabled_ui(enabled, |ui| widgets::num_field_named(ui, ("persp-vp3", i), Some(name), pt, unit, 84.0)).inner { v[i] = json!(unit.from_pt(x)); d.fields.insert("thirdVp".into(), Value::Array(v)); } diff --git a/crates/ui-egui/src/dialogs/print.rs b/crates/ui-egui/src/dialogs/print.rs index eb163d8c1..b767f8dae 100644 --- a/crates/ui-egui/src/dialogs/print.rs +++ b/crates/ui-egui/src/dialogs/print.rs @@ -364,8 +364,8 @@ fn printer_row(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { /// document change. fn preview_of(app: &mut VectorcraftApp, ctx: &egui::Context, settings: &Value) -> Arc> { let doc = app.session.active().map_or((0, 0), |s| (s.uid, s.revision)); - let stamp = egui::Id::new((settings.to_string(), doc)); - let key = egui::Id::new("print-preview"); + let stamp = egui::Id::unique((settings.to_string(), doc)); + let key = egui::Id::unique("print-preview"); if let Some((s, v)) = ctx.data(|m| m.get_temp::<(egui::Id, Arc>)>(key)) && s == stamp { @@ -419,7 +419,7 @@ fn page_preview(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, pv: painter.rect_filled(page, 0.0, egui::Color32::WHITE); // Dragging moves the art (and its trim box) on the paper; the placement changes on release. let tiling = choice::(d, "scaling").is_some_and(PrintScaling::tiles); - let drag_id = egui::Id::new("print-preview-drag"); + let drag_id = egui::Id::unique("print-preview-drag"); let mut offset = ui.ctx().data(|m| m.get_temp::(drag_id)).unwrap_or_default(); if !tiling && resp.dragged() { offset += resp.drag_delta(); @@ -538,8 +538,8 @@ fn round2(v: f64) -> f64 { fn art_texture(app: &mut VectorcraftApp, ctx: &egui::Context, layers: PrintLayers, area: DRect, px: f64) -> Option { let st = app.session.active()?; let bits = [area.x0, area.y0, area.x1, area.y1].map(f64::to_bits); - let stamp = egui::Id::new((st.uid, st.revision, layers, bits, px.round() as u32)); - let key = egui::Id::new("print-preview-art"); + let stamp = egui::Id::unique((st.uid, st.revision, layers, bits, px.round() as u32)); + let key = egui::Id::unique("print-preview-art"); if let Some((s, tex)) = ctx.data(|m| m.get_temp::<(egui::Id, egui::TextureHandle)>(key)) && s == stamp { @@ -695,7 +695,9 @@ fn output(ui: &mut egui::Ui, d: &mut Dialog, pv: &Result) { ui.label(egui::RichText::new(format!("{name}{spot}")).color(t.text)); for (k, suffix) in [("frequency", " lpi"), ("angle", "°")] { let v = ink[k].as_f64().unwrap_or(0.0); - if let Some(x) = widgets::plain_field(ui, ("print-ink", name, k), v, suffix, 2, 72.0) { + let heading = if k == "frequency" { tl!("Frequency") } else { tl!("Angle") }; + let field_name = format!("{heading} {name}"); + if let Some(x) = widgets::plain_field_named(ui, ("print-ink", name, k), Some(&field_name), v, suffix, 2, 72.0) { set_ink(d, name, k, json!(x)); } } diff --git a/crates/ui-egui/src/dialogs/recolor.rs b/crates/ui-egui/src/dialogs/recolor.rs index 810d6881d..c1483349d 100644 --- a/crates/ui-egui/src/dialogs/recolor.rs +++ b/crates/ui-egui/src/dialogs/recolor.rs @@ -221,7 +221,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool { } } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if let Some(i) = widgets::dropdown(ui, "recolor-preset", PRESETS[preset_of(d)], &PRESETS, 130.0) { + // The label is drawn after the menu (right-to-left), so it cannot name it by adjacency. + if let Some(i) = widgets::dropdown_named(ui, "recolor-preset", Some(tl!("Preset:")), PRESETS[preset_of(d)], &PRESETS, 130.0) { set_preset(app, d, i); } ui.label(egui::RichText::new(tl!("Preset:")).color(t.text_dim)); @@ -242,7 +243,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool { if d.fields.get("group").is_some_and(Value::is_string) || d.bool("__newGroup") { ui.add_space(6.0); ui.horizontal(|ui| { - ui.label(egui::RichText::new(tl!("Color Group:")).color(t.text_dim)); + widgets::dim_label(ui, tl!("Color Group:")); form::text(ui, d, "groupName", 200.0); }); } @@ -322,7 +323,7 @@ fn assign_tab(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut let labels: Vec = std::iter::once(tl!("Auto").to_string()).chain((1..=total).map(|n| n.to_string())).collect(); let refs: Vec<&str> = labels.iter().map(String::as_str).collect(); let cur = d.fields.get("colors").and_then(Value::as_u64).map_or(tl!("Auto").to_string(), |n| n.to_string()); - if let Some(i) = widgets::dropdown(ui, "recolor-count", &cur, &refs, 70.0) { + if let Some(i) = widgets::dropdown_named(ui, "recolor-count", Some(tl!("Colors:")), &cur, &refs, 70.0) { d.fields.insert("colors".into(), if i == 0 { Value::Null } else { json!(i) }); } ui.add_space(18.0); @@ -391,7 +392,8 @@ fn assign_tab(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut } ui.add_space(6.0); let hex = crate::panels::color::hex_digits(&to); - if let Some(h) = widgets::hex_field(ui, ("recolor-hex", i), &hex) + let hex_name = format!("{} {}", tl!("Color"), i + 1); + if let Some(h) = widgets::hex_field_named(ui, ("recolor-hex", i), Some(&hex_name), &hex) && let Some(c) = crate::panels::color::parse_hex(&h) { row["to"] = key(&keep_model(to, c)); @@ -455,7 +457,8 @@ fn assign_tab(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut ui.label(egui::RichText::new(tl!("Method:")).color(t.text_dim)); let m = Method::parse(&d.str("method")).unwrap_or_default(); let labels: Vec<&str> = Method::ALL.iter().map(|m| m.label()).collect(); - if let Some(i) = widgets::dropdown(ui, "recolor-method", m.label(), &labels, 180.0) { + let chosen = ui.name_inputs(tl!("Method:"), |ui| widgets::dropdown(ui, "recolor-method", m.label(), &labels, 180.0)); + if let Some(i) = chosen { d.fields.insert("method".into(), json!(Method::ALL[i].id())); } ui.end_row(); @@ -471,7 +474,8 @@ fn assign_tab(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut let at = keys.iter().position(|k| *k == cur).unwrap_or(0); let current = shown.get(at).copied().unwrap_or_default(); ui.horizontal(|ui| { - if let Some(k) = widgets::dropdown_names(ui, "recolor-library", current, &shown, 180.0).and_then(|i| keys.get(i)) { + let library = ui.name_inputs(tl!("Limit to Library:"), |ui| widgets::dropdown_names(ui, "recolor-library", current, &shown, 180.0)); + if let Some(k) = library.and_then(|i| keys.get(i)) { d.fields.insert("limitTo".into(), json!(k)); } // Import a library (.ase, .acb, .gpl, .vcswatches or a document's swatches) and limit to it. @@ -578,8 +582,9 @@ fn edit_tab(ui: &mut Ui, d: &mut Dialog, rows: &mut [Value]) -> bool { ui.label(egui::RichText::new(tl!("Harmony Rules:")).color(t.text_dim)); let rule = Harmony::parse(&d.str("rule")).unwrap_or(Harmony::Complementary); let labels: Vec<&str> = Harmony::ALL.iter().map(|h| h.label()).collect(); - // Choosing a rule gives the new colours its colours, from the base colour. - if let Some(i) = widgets::dropdown(ui, "recolor-rule", rule.label(), &labels, 170.0) { + // Choosing a rule gives the new colours its colours, from the base colour. The heading is + // not a field label widget, so the menu carries its own accessible name. + if let Some(i) = widgets::dropdown_named(ui, "recolor-rule", Some(tl!("Harmony Rules:")), rule.label(), &labels, 170.0) { d.fields.insert("rule".into(), json!(Harmony::ALL[i].id())); if let Some((b, c)) = base.and_then(|b| Some((b, to_color(&rows[b])?))) { apply_rule(rows, Harmony::ALL[i], b, c, &active); diff --git a/crates/ui-egui/src/dialogs/revolve.rs b/crates/ui-egui/src/dialogs/revolve.rs index ca281d95d..e6f685f28 100644 --- a/crates/ui-egui/src/dialogs/revolve.rs +++ b/crates/ui-egui/src/dialogs/revolve.rs @@ -40,8 +40,8 @@ pub(super) fn body(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { m.iter().any(|(k, v)| v.as_f64().map_or_else(|| d.fields.get(k) != Some(v), |default| (d.f64(k, default) - default).abs() > 1e-6)) }); let (r, _) = ui.allocate_exact_size(vec2(88.0, 24.0), Sense::hover()); - let response = ui.interact(r, Id::new("revolve-reset-all"), Sense::click()); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Reset all"))); + let response = ui.interact(r, Id::unique("revolve-reset-all"), Sense::click()); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Reset all"))); if response.hovered() { ui.painter().rect_filled(r, 3.0, t.hover); } @@ -572,8 +572,8 @@ fn dot(ui: &egui::Ui, p: Pos2, changed: bool) { fn reset_dot(ui: &mut egui::Ui, key: &str, changed: bool, default: &str) -> bool { let (r, _) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::hover()); - let response = ui.interact(r, Id::new(("revolve-reset", key)), Sense::click()); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Reset to default"))); + let response = ui.interact(r, Id::unique(("revolve-reset", key)), Sense::click()); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Reset to default"))); if response.hovered() { ui.painter().rect_filled(r, 3.0, Tokens::get(ui.ctx()).hover); } diff --git a/crates/ui-egui/src/dialogs/revolve_gizmo.rs b/crates/ui-egui/src/dialogs/revolve_gizmo.rs index 6a1c58dd6..f882ddc4f 100644 --- a/crates/ui-egui/src/dialogs/revolve_gizmo.rs +++ b/crates/ui-egui/src/dialogs/revolve_gizmo.rs @@ -31,7 +31,7 @@ pub(crate) fn hint(d: &Dialog) -> &'static str { } fn drag_id() -> Id { - Id::new("revolve-gizmo-drag") + Id::unique("revolve-gizmo-drag") } pub(crate) fn dragging(ctx: &egui::Context) -> bool { @@ -201,7 +201,7 @@ pub(super) fn show(app: &VectorcraftApp, ctx: &egui::Context, d: &mut Dialog) { rect = rect.union(label.expand(2.0)); } } - egui::Area::new(Id::new("revolve-gizmo-area")).order(egui::Order::Foreground).fixed_pos(rect.min).movable(false).show(ctx, |ui| { + egui::Area::new(Id::unique("revolve-gizmo-area")).order(egui::Order::Foreground).fixed_pos(rect.min).movable(false).show(ctx, |ui| { ui.set_clip_rect(geometry.xf.rect); let response = ui.allocate_rect(rect.intersect(geometry.xf.rect), Sense::drag()); let options = super::revolve::light_options(d); diff --git a/crates/ui-egui/src/dialogs/save_for_web.rs b/crates/ui-egui/src/dialogs/save_for_web.rs index 50863b93f..21494d783 100644 --- a/crates/ui-egui/src/dialogs/save_for_web.rs +++ b/crates/ui-egui/src/dialogs/save_for_web.rs @@ -373,7 +373,7 @@ struct Cache { } fn cache_id() -> egui::Id { - egui::Id::new("save-for-web-previews") + egui::Id::unique("save-for-web-previews") } /// The texture of straight RGBA pixels. @@ -508,7 +508,7 @@ fn preview_area(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, set } ui.add_space(4.0); let (area, resp) = ui.allocate_exact_size(egui::vec2(PREVIEW_W, PREVIEW_H), egui::Sense::click_and_drag()); - let pan_id = egui::Id::new("save-for-web-pan"); + let pan_id = egui::Id::unique("save-for-web-pan"); let mut pan: egui::Vec2 = ui.ctx().data(|m| m.get_temp(pan_id)).unwrap_or_default(); if resp.dragged() { pan += resp.drag_delta(); @@ -554,14 +554,16 @@ fn preview_area(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, set let mut browser = false; ui.horizontal(|ui| { let z = ZOOMS.iter().position(|z| *z == d.str("__zoom")).unwrap_or(2); - if let Some(i) = widgets::dropdown(ui, "sfw-zoom", ZOOM_LABELS[z], &ZOOM_LABELS, 110.0) { + let zoom = ui.name_inputs(tl!("Zoom"), |ui| widgets::dropdown(ui, "sfw-zoom", ZOOM_LABELS[z], &ZOOM_LABELS, 110.0)); + if let Some(i) = zoom { d.fields.insert("__zoom".into(), json!(ZOOMS[i])); ui.ctx().data_mut(|m| m.insert_temp(pan_id, egui::Vec2::ZERO)); } let kbps = d.f64("__kbps", 56.6); let labels: Vec = SPEEDS.iter().map(|s| speed_label(*s)).collect(); let names: Vec<&str> = labels.iter().map(String::as_str).collect(); - if let Some(i) = widgets::dropdown(ui, "sfw-speed", &speed_label(kbps), &names, 110.0) + let speed = ui.name_inputs(tl!("Download:"), |ui| widgets::dropdown(ui, "sfw-speed", &speed_label(kbps), &names, 110.0)); + if let Some(i) = speed && let Some(s) = SPEEDS.get(i) { d.fields.insert("__kbps".into(), json!(s)); diff --git a/crates/ui-egui/src/dialogs/save_pdf.rs b/crates/ui-egui/src/dialogs/save_pdf.rs index 175943697..685255391 100644 --- a/crates/ui-egui/src/dialogs/save_pdf.rs +++ b/crates/ui-egui/src/dialogs/save_pdf.rs @@ -256,11 +256,13 @@ pub(super) fn row(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui pub(super) fn row_with(ui: &mut egui::Ui, label: &str, width: f32, add: impl FnOnce(&mut egui::Ui)) { let t = Tokens::get(ui.ctx()); ui.horizontal(|ui| { - ui.allocate_ui_with_layout(egui::vec2(width, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { - ui.set_min_width(width); - ui.label(egui::RichText::new(label).color(t.text)); - }); - add(ui); + let label_id = ui + .allocate_ui_with_layout(egui::vec2(width, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.set_min_width(width); + ui.label(egui::RichText::new(label).color(t.text)).id + }) + .inner; + ui.label_inputs_by(label_id, add); }); } @@ -477,7 +479,7 @@ fn general(ui: &mut egui::Ui, d: &mut Dialog, editor: bool) { flag(ui, d, "includeNonPrinting", tl!("Include non-printing layers"), true); if editor { heading(ui, tl!("Description")); - if let Some(text) = widgets::text_field(ui, "pdf-preset-description", Some(&d.str("description")), 460.0, 3) { + if let Some(text) = widgets::text_field_named(ui, "pdf-preset-description", Some(tl!("Description")), Some(&d.str("description")), 460.0, 3) { d.fields.insert("description".into(), json!(text)); } return; @@ -766,8 +768,8 @@ pub(super) fn option_label(option: &str) -> String { /// cached until a field or the document changes. fn summary_of(app: &mut VectorcraftApp, ctx: &egui::Context, p: &Value) -> Result { let revision = app.session.active().map_or(0, |s| s.revision); - let stamp = egui::Id::new((p.to_string(), revision)); - let key = egui::Id::new("save-pdf-summary"); + let stamp = egui::Id::unique((p.to_string(), revision)); + let key = egui::Id::unique("save-pdf-summary"); if let Some((s, v)) = ctx.data(|m| m.get_temp::<(egui::Id, Result)>(key)) && s == stamp { diff --git a/crates/ui-egui/src/dialogs/shortcut_import.rs b/crates/ui-egui/src/dialogs/shortcut_import.rs index a66184a99..60c002e56 100644 --- a/crates/ui-egui/src/dialogs/shortcut_import.rs +++ b/crates/ui-egui/src/dialogs/shortcut_import.rs @@ -654,7 +654,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let statuses = review.statuses(); // The Keys and Shortcuts lists get the window's height less the rest of the dialog, as the frame // before drew it. - let id = egui::Id::new("dialog-shortcut-import"); + let id = egui::Id::unique("dialog-shortcut-import"); let rest = ctx.data(|m| m.get_temp::(id.with("rest"))).unwrap_or(0.0); let (keys, mods) = review.asked(); let keys_open = d.bool("__keysOpen") && (!keys.is_empty() || !mods.is_empty()); @@ -928,7 +928,7 @@ fn rows_section(ui: &mut egui::Ui, d: &mut Dialog, review: &mut Review, statuses fn target_picker(ui: &mut egui::Ui, i: usize, r: &Row) -> Option { let current = target_label(r.target.as_deref()); widgets::combo(ui, ("shortcut-import-target", i), ¤t, TARGET_W, true, |ui| { - let field = ui.id().with("query"); + let field = ui.scope_id().with("query"); if ui.memory(|m| m.focused().is_none()) { ui.memory_mut(|m| m.request_focus(field.with("edit"))); } diff --git a/crates/ui-egui/src/dialogs/simplify_slider.rs b/crates/ui-egui/src/dialogs/simplify_slider.rs index 9387b8dbd..caf898fdd 100644 --- a/crates/ui-egui/src/dialogs/simplify_slider.rs +++ b/crates/ui-egui/src/dialogs/simplify_slider.rs @@ -125,7 +125,7 @@ enum Action { fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let Some(mut d) = app.ui.dialog.clone() else { return }; let t = Tokens::get(ctx); - let id = egui::Id::new(("dialog", KIND)); + let id = egui::Id::unique(("dialog", KIND)); let b = bounds(app, &mut d); let pos = place(app, ctx, id, b); // A click anywhere else keeps the result, and the rest of the app doesn't take it. diff --git a/crates/ui-egui/src/dialogs/svg_options.rs b/crates/ui-egui/src/dialogs/svg_options.rs index 611d1f8ff..363fa00cc 100644 --- a/crates/ui-egui/src/dialogs/svg_options.rs +++ b/crates/ui-egui/src/dialogs/svg_options.rs @@ -266,8 +266,8 @@ fn code_view(app: &VectorcraftApp, ui: &mut egui::Ui, d: &Dialog) { o.insert("artboard".into(), json!(first.map_or(0, |n| n.saturating_sub(1)))); } // One cached text, replaced when the document or the options change. - let key = egui::Id::new("svg-code"); - let sig = egui::Id::new((st.uid, st.revision, opts.to_string())).value(); + let key = egui::Id::unique("svg-code"); + let sig = egui::Id::unique((st.uid, st.revision, opts.to_string())).value(); let code: Arc = match ui.ctx().data(|m| m.get_temp::<(u64, Arc)>(key)).filter(|(s, _)| *s == sig) { Some((_, c)) => c, None => { diff --git a/crates/ui-egui/src/dialogs/swatch_options.rs b/crates/ui-egui/src/dialogs/swatch_options.rs index fdb2843fb..6d9aa6d59 100644 --- a/crates/ui-egui/src/dialogs/swatch_options.rs +++ b/crates/ui-egui/src/dialogs/swatch_options.rs @@ -252,7 +252,7 @@ fn sliders(ui: &mut egui::Ui, d: &mut Dialog) -> bool { /// A hex field showing `hex`; returns the colour typed into it once committed. fn hex_field(ui: &mut egui::Ui, hex: &str) -> Option { let t = Tokens::get(ui.ctx()); - let id = ui.id().with("swatch-hex"); + let id = ui.scope_id().with("swatch-hex"); let editing = ui.memory(|m| m.has_focus(id)); // As in `widgets::hex_field`: until the user types into it, the field shows `hex` as it is now // (#1692). diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 83534e998..ea925b342 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -358,7 +358,7 @@ fn dialogs_are_as_tall_as_their_content() { let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() }; ctx.run_ui(input, |ui| show(app, ui.ctx())).textures_delta.clear(); } - ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", kind)))).unwrap().height() + ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", kind)))).unwrap().height() }; let tall = height(&mut app, "command", json!({"__command": "object.move", "a": 1, "b": 2, "c": 3, "d": 4, "e": 5, "f": 6, "g": 7, "h": 8})); let prompt = height(&mut app, crate::unsaved::KIND, json!({"index": 0, "name": "Untitled-1", "then": "close"})); @@ -403,7 +403,7 @@ fn dialogs_do_not_stretch_to_the_screen() { let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() }; ctx.run_ui(input, |ui| show(&mut app, ui.ctx())).textures_delta.clear(); } - let h = ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", kind.as_str()))))?.height(); + let h = ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", kind.as_str()))))?.height(); Some((kind, h)) }; let mut checked = 0; @@ -426,7 +426,7 @@ fn dialog_rect(app: &mut VectorcraftApp, w: f32) -> egui::Rect { let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() }; ctx.run_ui(input, |ui| show(app, ui.ctx())).textures_delta.clear(); } - ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", kind.as_str())))).unwrap() + ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", kind.as_str())))).unwrap() } #[test] diff --git a/crates/ui-egui/src/dialogs/tests_accessibility.rs b/crates/ui-egui/src/dialogs/tests_accessibility.rs new file mode 100644 index 000000000..a86087b26 --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests_accessibility.rs @@ -0,0 +1,141 @@ +//! Strict accessibility checks on the real dialogs: kittest fails any input without a name, and +//! each test also asks for the names a screen reader must hear on the fields this migration +//! touched (form text fields, parameter fields, bleed, split-grid context). + +use egui::accesskit::Role; +use egui::vec2; +use egui_kittest::kittest::Queryable; +use serde_json::json; +use vectorcraft_engine::Session; + +use super::show; +use crate::VectorcraftApp; + +type Harness = egui_kittest::Harness<'static, VectorcraftApp>; + +fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app +} + +/// A kittest harness over the real dialog layer, with kittest's strict accessibility check on: +/// every visible input must have an accessible name. +fn strict(app: VectorcraftApp) -> Harness { + let mut fonts = false; + let mut h = egui_kittest::Harness::builder().with_size(vec2(1100.0, 900.0)).build_ui_state( + move |ui, app: &mut VectorcraftApp| { + if !std::mem::replace(&mut fonts, true) { + crate::theme::install_fonts(ui.ctx()); + return; + } + show(app, ui.ctx()); + }, + app, + ); + h.run_steps(5); + h.check_accessibility(); + h +} + +#[test] +fn export_for_screens_names_the_form_text_fields() { + let mut app = app(); + app.run("file.exportForScreens", json!({})).unwrap(); + let h = strict(app); + for name in ["File Name:", "Prefix:", "Start at:"] { + let _ = h.get_by_role_and_label(Role::TextInput, name); + } + assert!(h.query_by_role_and_label(Role::TextInput, "name").is_none(), "no internal widget id"); + assert!(h.query_by_role_and_label(Role::TextInput, "prefix").is_none(), "no internal widget id"); +} + +#[test] +fn flatten_transparency_names_its_preset_and_option_fields() { + let mut app = app(); + app.run("ui.flattenTransparencyDialog", json!({})).unwrap(); + { + let d = app.ui.dialog.as_mut().expect("Flatten Transparency is open"); + d.fields.insert("__saving".into(), json!(true)); + d.fields.insert("__presetName".into(), json!("Mine")); + } + let h = strict(app); + for name in ["Name:", "Raster/Vector Balance:", "Line Art and Text Resolution:", "Gradient and Mesh Resolution:"] { + let _ = h.get_by_role_and_label(Role::TextInput, name); + } + let _ = h.get_by_role_and_label(Role::Slider, "Raster/Vector Balance:"); +} + +#[test] +fn document_setup_names_each_bleed_side() { + let mut app = app(); + app.run("file.documentSetup", json!({})).unwrap(); + let h = strict(app); + for side in ["Top", "Bottom", "Left", "Right"] { + let _ = h.get_by_role_and_label(Role::TextInput, side); + } +} + +#[test] +fn split_into_grid_names_row_and_column_fields_with_context() { + let mut app = app(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 120, "height": 80})).unwrap(); + app.run("select.all", json!({})).unwrap(); + crate::menus::invoke(&mut app, "object.path.splitIntoGrid", json!({})); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("splitIntoGrid")); + let h = strict(app); + for name in ["Rows: Gutter:", "Columns: Gutter:", "Rows: Total:", "Columns: Total:"] { + let _ = h.get_by_role_and_label(Role::TextInput, name); + } + // The visible rows keep the existing translated labels; only their accessible names carry + // the axis. Two labels of each remain, so the two inputs would otherwise share a name. + assert_eq!(h.get_all_by_label("Gutter:").count(), 2, "the visible gutter labels are unchanged"); + assert_eq!(h.get_all_by_label("Total:").count(), 2, "the visible total labels are unchanged"); +} + +#[test] +fn artboard_options_names_the_video_pixel_aspect_ratio_field() { + let mut app = app(); + app.select_tool("artboard"); + app.run("ui.newArtboard", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("artboardOptions")); + let h = strict(app); + let _ = h.get_by_role_and_label(Role::TextInput, "Video Ruler Pixel Aspect Ratio:"); +} + +#[test] +fn generic_parameter_dialog_names_numbers_text_and_choices() { + let mut app = app(); + app.run( + "ui.paramDialog", + json!({ + "command": "type.pathOptions", + "label": "Type on a Path Options", + "params": { + "effect": "rainbow", + "alignToPath": "baseline", + "spacing": 10.0, + "flip": false, + "note": "Draft" + } + }), + ) + .unwrap(); + let h = strict(app); + for name in ["Spacing:", "Note:"] { + let _ = h.get_by_role_and_label(Role::TextInput, name); + } + let _ = h.get_by_role_and_label(Role::ComboBox, "Effect:"); + let _ = h.get_by_role_and_label(Role::ComboBox, "Align to Path:"); + let _ = h.get_by_role_and_label(Role::CheckBox, "Flip:"); +} + +#[test] +fn slice_options_names_its_text_area() { + let mut app = app(); + app.run("object.slice.create", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + app.run("object.slice.options", json!({})).unwrap(); + app.ui.dialog.as_mut().expect("Slice Options is open").fields.insert("kind".into(), json!("noImage")); + let h = strict(app); + let _ = h.get_by_role_and_label(Role::MultilineTextInput, "Text Displayed in Cell:"); +} diff --git a/crates/ui-egui/src/dialogs/tests_modal.rs b/crates/ui-egui/src/dialogs/tests_modal.rs index dd5d01b80..602afe09b 100644 --- a/crates/ui-egui/src/dialogs/tests_modal.rs +++ b/crates/ui-egui/src/dialogs/tests_modal.rs @@ -87,10 +87,10 @@ type Open = fn(&mut VectorcraftApp); fn dialogs_move_by_their_heading_stay_on_screen_and_where_they_were_moved() { let screen = Rect::from_min_size(Pos2::ZERO, SCREEN); let opens: [(&str, Open, egui::Id); 4] = [ - ("Offset Path", |app| crate::menus::invoke(app, "object.path.offsetPath", json!({})), egui::Id::new(("dialog", "offsetPath"))), - ("New Document", open_new_document, egui::Id::new(("dialog", "newDocument"))), - ("Preferences", |app| crate::prefs_dialog::open(app, None), egui::Id::new("dialog-preferences")), - ("Keyboard Shortcuts", crate::shortcut_editor::open, egui::Id::new("dialog-shortcuts")), + ("Offset Path", |app| crate::menus::invoke(app, "object.path.offsetPath", json!({})), egui::Id::unique(("dialog", "offsetPath"))), + ("New Document", open_new_document, egui::Id::unique(("dialog", "newDocument"))), + ("Preferences", |app| crate::prefs_dialog::open(app, None), egui::Id::unique("dialog-preferences")), + ("Keyboard Shortcuts", crate::shortcut_editor::open, egui::Id::unique("dialog-shortcuts")), ]; for (name, open, id) in opens { let (ctx, mut app) = (ctx(), app_with_selection()); @@ -134,7 +134,7 @@ fn check_plugin_dialog(kind: &str, count: usize, check: impl FnOnce(&egui::Conte }; app.run(command, params).unwrap(); settle(&ctx, &mut app); - check(&ctx, &mut app, egui::Id::new(("dialog", dialog)), &format!("Test {id}")); + check(&ctx, &mut app, egui::Id::unique(("dialog", dialog)), &format!("Test {id}")); app.run("plugin.remove", json!({"id": id})).unwrap(); } @@ -164,6 +164,7 @@ fn a_plug_in_dialog_of_many_settings_fits_the_window() { delta: vec2(0.0, -200.0), phase: egui::TouchPhase::Move, modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, }; frame(ctx, app, vec![Event::PointerMoved(over), wheel]); for _ in 0..30 { @@ -318,7 +319,7 @@ fn simplify_preview(app: &VectorcraftApp) -> serde_json::Value { /// The Simplify slider's strip, and the centers of its Auto-Simplify button, its rail and its More /// Options button. fn slider_strip(ctx: &egui::Context) -> (Rect, Pos2, Pos2, Pos2) { - let r = rect_of(ctx, egui::Id::new(("dialog", simplify_slider::KIND))); + let r = rect_of(ctx, egui::Id::unique(("dialog", simplify_slider::KIND))); let y = r.center().y; (r, Pos2::new(r.left() + 8.0 + 14.0, y), Pos2::new(r.left() + 8.0 + 28.0 + 6.0 + 90.0, y), Pos2::new(r.right() - 8.0 - 14.0, y)) } @@ -430,13 +431,13 @@ fn simplify_slider_stays_where_it_opened_while_the_preview_changes_the_bounds() let before = app.selection_bounds().unwrap(); crate::menus::invoke(&mut app, "object.path.simplify", json!({})); settle(&ctx, &mut app); - let opened = rect_of(&ctx, egui::Id::new(("dialog", simplify_slider::KIND))); + let opened = rect_of(&ctx, egui::Id::unique(("dialog", simplify_slider::KIND))); app.ui.dialog.as_mut().unwrap().fields.insert("level".into(), json!(0)); settle(&ctx, &mut app); assert_eq!(simplify_preview(&app), json!({"level": 0.0})); let after = app.selection_bounds().unwrap(); assert!(after.height() < before.height() - 0.5, "the preview changes the bounds: {before:?} → {after:?}"); - assert_eq!(rect_of(&ctx, egui::Id::new(("dialog", simplify_slider::KIND))), opened); + assert_eq!(rect_of(&ctx, egui::Id::unique(("dialog", simplify_slider::KIND))), opened); } /// Auto-Simplify moves the slider back to the level that keeps the path within 1/400 of its size, diff --git a/crates/ui-egui/src/dialogs/tests_revolve.rs b/crates/ui-egui/src/dialogs/tests_revolve.rs index 2396fdf42..b8dee81b9 100644 --- a/crates/ui-egui/src/dialogs/tests_revolve.rs +++ b/crates/ui-egui/src/dialogs/tests_revolve.rs @@ -57,7 +57,7 @@ impl Harness { } fn response(&self, id: impl std::hash::Hash + std::fmt::Debug) -> Rect { - self.ctx.read_response(Id::new(id)).unwrap().rect + self.ctx.read_response(Id::unique(id)).unwrap().rect } fn click(&mut self, p: Pos2) { @@ -90,7 +90,7 @@ fn values_labels_sliders_and_reset_dots_change_the_live_effect_in_one_undo_step( let before = h.app.session.doc().unwrap().doc.clone(); h.settle(); assert!(!h.rect("Advanced").is_negative(), "Advanced is visible without scrolling in a normal window"); - let panel = h.ctx.memory(|m| m.area_rect(Id::new(("dialog", "effect")).with("revolve"))).unwrap(); + let panel = h.ctx.memory(|m| m.area_rect(Id::unique(("dialog", "effect")).with("revolve"))).unwrap(); // Cap adds one 28-point row to the shared dialog's previous 540-point bound. assert!(panel.width() < 420.0 && panel.height() < 570.0, "compact controls with Cap: {panel:?}"); let label = h.rect("Rotation X"); @@ -156,7 +156,7 @@ fn lighting_tab_shows_light_fields_and_keeps_the_dialog_compact() { for label in ["Shading", "Color", "Intensity", "Height", "Softness", "Ambient Light", "Light behind the object"] { assert!(Rect::from_min_size(Pos2::ZERO, h.screen).contains_rect(h.rect(label))); } - let panel = h.ctx.memory(|m| m.area_rect(Id::new(("dialog", "effect")).with("revolve"))).unwrap(); + let panel = h.ctx.memory(|m| m.area_rect(Id::unique(("dialog", "effect")).with("revolve"))).unwrap(); assert!(panel.width() < 420.0 && panel.height() < 570.0, "compact lighting controls with light management: {panel:?}"); h.drag(h.rect("Height").center(), vec2(24.0, 0.0)); assert_ne!(h.value("lightHeight"), 30.0); @@ -220,7 +220,7 @@ fn advanced_visibility_option_is_saved_resettable_and_leaves_preview_geometry_co let mut h = Harness::new(vec2(1280.0, 1000.0)); h.settle(); h.click(h.rect("Advanced").center()); - let panel = h.ctx.memory(|m| m.area_rect(Id::new(("dialog", "effect")).with("revolve"))).unwrap(); + let panel = h.ctx.memory(|m| m.area_rect(Id::unique(("dialog", "effect")).with("revolve"))).unwrap(); h.frame(vec![ Event::PointerMoved(panel.center()), Event::MouseWheel { @@ -228,6 +228,7 @@ fn advanced_visibility_option_is_saved_resettable_and_leaves_preview_geometry_co delta: vec2(0.0, -220.0), phase: egui::TouchPhase::Move, modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, }, ]); h.settle(); @@ -300,10 +301,10 @@ fn extrude_bevel_options_enable_with_a_bevel_shape() { let mut h = Harness::with("M100 100 L200 100 L200 200 L100 200 Z", vectorcraft_effects::EXTRUDE, vec2(1400.0, 1000.0)); h.settle(); let width = h.response(("revolve-reset", "bevelWidth")); - assert!(!h.ctx.read_response(Id::new(("revolve-reset", "bevelWidth"))).unwrap().enabled(), "no bevel: its sizes are off"); + assert!(!h.ctx.read_response(Id::unique(("revolve-reset", "bevelWidth"))).unwrap().enabled(), "no bevel: its sizes are off"); h.app.ui.dialog.as_mut().unwrap().fields.insert("bevel".into(), json!("rounded")); h.settle(); - assert!(h.ctx.read_response(Id::new(("revolve-reset", "bevelWidth"))).unwrap().enabled()); + assert!(h.ctx.read_response(Id::unique(("revolve-reset", "bevelWidth"))).unwrap().enabled()); assert_eq!(h.response(("revolve-reset", "bevelWidth")), width); } diff --git a/crates/ui-egui/src/dialogs/tests_scale.rs b/crates/ui-egui/src/dialogs/tests_scale.rs index cf4705253..7c890e65c 100644 --- a/crates/ui-egui/src/dialogs/tests_scale.rs +++ b/crates/ui-egui/src/dialogs/tests_scale.rs @@ -15,7 +15,7 @@ fn prompt(app: &mut VectorcraftApp, ctx: &egui::Context, physical: egui::Vec2, z let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() }; ctx.run_ui(input, |ui| show(app, ui.ctx())).textures_delta.clear(); } - let rect = ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", crate::unsaved::KIND)))).unwrap(); + let rect = ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", crate::unsaved::KIND)))).unwrap(); (rect, screen) } diff --git a/crates/ui-egui/src/dialogs/tests_warp.rs b/crates/ui-egui/src/dialogs/tests_warp.rs index 184ca1c0b..55f2bde99 100644 --- a/crates/ui-egui/src/dialogs/tests_warp.rs +++ b/crates/ui-egui/src/dialogs/tests_warp.rs @@ -506,9 +506,9 @@ fn tab_to_warp_control(app: &mut VectorcraftApp, ctx: &egui::Context, label: &st if !events.iter().any(|event| { let egui::output::OutputEvent::FocusGained(info) = event else { return false }; info.enabled - && match info.typ { - egui::WidgetType::ComboBox => info.current_text_value.as_deref() == Some(label), - egui::WidgetType::Button => info.label.as_deref() == Some(label), + && match info.role { + egui::Role::ComboBox => info.current_text_value.as_deref() == Some(label), + egui::Role::Button => info.label.as_deref() == Some(label), _ => false, } }) { diff --git a/crates/ui-egui/src/dialogs/width_point.rs b/crates/ui-egui/src/dialogs/width_point.rs index 3366d4b72..66e47d76a 100644 --- a/crates/ui-egui/src/dialogs/width_point.rs +++ b/crates/ui-egui/src/dialogs/width_point.rs @@ -229,7 +229,7 @@ mod tests { let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() }; ctx.run_ui(input, |ui| super::super::show(&mut app, ui.ctx())).textures_delta.clear(); } - ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", KIND)))).unwrap().height() + ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", KIND)))).unwrap().height() }; let (short, tall) = (height(1000.0), height(2000.0)); assert!((short - tall).abs() < 1.0 && short < 400.0, "{short} vs {tall}"); diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index f2f7035a0..b80be48c4 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -31,30 +31,32 @@ pub(crate) fn clamp_shared_dock_width(width: f32) -> f32 { width.clamp(SHARED_DOCK_MIN, SHARED_DOCK_MAX) } -fn seed_dock_panel_width(ctx: &egui::Context, panel: &str, width: f32) { - let screen = ctx.content_rect(); +/// Seed the panel's public persisted state before it is laid out: `Panel` keys it by the parent +/// `Ui`'s scope id and its own salt in 0.37, not by a global `Id` (the old `Id::unique(panel)` key +/// silently stopped restoring the width). Keep this in one place so the key cannot drift. +fn seed_dock_panel_width(ui: &egui::Ui, panel: &str, width: f32) { + let screen = ui.ctx().content_rect(); let w = width.max(1.0); let outer_rect = egui::Rect::from_min_size(egui::pos2(screen.right() - w, screen.top()), egui::vec2(w, screen.height())); let state = egui::containers::panel::PanelState { outer_rect }; - ctx.data_mut(|data| data.insert_persisted(egui::Id::new(panel), state)); + ui.ctx().data_mut(|data| data.insert_persisted(ui.scope_id().with(panel), state)); } -pub(crate) fn apply_pending_dock_width(app: &mut VectorcraftApp, ctx: &egui::Context, panel: &str, clamp: fn(f32) -> f32) { +pub(crate) fn apply_pending_dock_width(app: &mut VectorcraftApp, ui: &egui::Ui, panel: &str, clamp: fn(f32) -> f32) { if !app.ui.dock_width_pending { return; } if let Some(width) = app.ui.dock_width.filter(|w| w.is_finite() && *w > 0.0) { - seed_dock_panel_width(ctx, panel, clamp(width)); + seed_dock_panel_width(ui, panel, clamp(width)); } app.ui.dock_width_pending = false; } -pub(crate) fn note_dock_width(app: &mut VectorcraftApp, ctx: &egui::Context, panel: &str) { - if let Some(state) = egui::containers::panel::PanelState::load(ctx, egui::Id::new(panel)) { - let width = state.size().x; - if width.is_finite() && width > 0.0 { - app.ui.dock_width = Some(width); - } +/// Remember the width the panel was actually laid out at (its `response.rect`), so a workspace +/// switch or the next session restores it without reaching into egui's private `PanelState`. +pub(crate) fn note_dock_width(app: &mut VectorcraftApp, width: f32) { + if width.is_finite() && width > 0.0 { + app.ui.dock_width = Some(width); } } @@ -70,13 +72,13 @@ const FLYOUT_TOP: f32 = 110.0; /// Where the icon column's left edge was drawn last (egui temp memory, one frame old). fn column_left_id() -> egui::Id { - egui::Id::new("dock-icon-column-left") + egui::Id::unique("dock-icon-column-left") } /// Where the dock's left edge was drawn last: the icon column's, or the leftmost dock column's /// (egui temp memory, one frame old). Popped-out panels open left of it. pub(crate) fn dock_left_id() -> egui::Id { - egui::Id::new("dock-left") + egui::Id::unique("dock-left") } /// Collapse the tabbed group to icons or expand it again (`window.collapseDock`). Expanding while @@ -118,8 +120,8 @@ fn panel_icon(app: &mut VectorcraftApp, ui: &mut Ui, id: &'static str, label: &s let open = app.ui.open_panel.as_deref() == Some(id); // Its own id: the icons below it move up when it floats, and mustn't take over its drag. let (_, rect) = ui.allocate_space(vec2(30.0, 30.0)); - let resp = widgets::paint_icon_button(ui, ui.interact(rect, ui.id().with(("panel-icon", id)), Sense::click_and_drag()), icon, label, open); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, ui.is_enabled(), open, tl!(label))); + let resp = widgets::paint_icon_button(ui, ui.interact(rect, ui.scope_id().with(("panel-icon", id)), Sense::click_and_drag()), icon, label, open); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), open, tl!(label))); if resp.clicked() { app.ui.open_panel = if open { None } else { Some(id.to_string()) }; } @@ -131,7 +133,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if crate::panel_docking::show(app, ui) { return; } - apply_pending_dock_width(app, ui.ctx(), LEGACY_DOCK_PANEL, clamp_legacy_dock_width); + apply_pending_dock_width(app, ui, LEGACY_DOCK_PANEL, clamp_legacy_dock_width); let t = Tokens::get(ui.ctx()); let shown = floating::shown_tab(&app.ui); ui.ctx().data_mut(|d| d.insert_temp(floating::root_groups_id(), Vec::::new())); @@ -178,7 +180,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { .response .rect }); - note_dock_width(app, ui.ctx(), LEGACY_DOCK_PANEL); + if let Some(rect) = group { + note_dock_width(app, rect.width()); + } // Collapsed icon-panel strip, left of the expanded panel group (like Illustrator's dock). A // collapsed group's panels head it, under the « that expands them again. Floating panels leave // it. @@ -277,7 +281,7 @@ fn tab_strip(app: &mut VectorcraftApp, ui: &mut Ui, active: DockTab) -> bool { } let rest = Rect::from_min_max(egui::pos2(out.right, strip.top()), egui::pos2(menu.left() - 4.0, strip.bottom())); if rest.width() > 0.0 { - let resp = ui.interact(rest, ui.id().with("dock-group-bar"), Sense::click_and_drag()); + let resp = ui.interact(rest, ui.scope_id().with("dock-group-bar"), Sense::click_and_drag()); if resp.double_clicked() { if let Err(error) = app.run("window.panel.collapse", json!({ "panel": active.info().0 })) { app.ui.status = error; @@ -369,7 +373,7 @@ pub(crate) fn panel_tabs( } else { galley.clone() }; - let resp = craft_ui::tabs::Tab::new(ui.id().with((id, key)), label, i == selected) + let resp = craft_ui::tabs::Tab::new(ui.scope_id().with((id, key)), label, i == selected) .sense(Sense::click_and_drag()) .focus_stroke(Stroke::new(1.0, t.accent)) .show_at(ui, rect, |ui, _| { @@ -384,7 +388,7 @@ pub(crate) fn panel_tabs( } if !fit.overflow.is_empty() && fit.overflow_width > 0.0 { let rect = Rect::from_min_size(egui::pos2(out.right, area.top()), vec2(fit.overflow_width, area.height())); - let resp = ui.interact(rect, ui.id().with((id, "overflow")), Sense::click()); + let resp = ui.interact(rect, ui.scope_id().with((id, "overflow")), Sense::click()); if resp.hovered() { ui.painter().rect_filled(rect, 0.0, t.hover); } @@ -392,7 +396,7 @@ pub(crate) fn panel_tabs( ui.painter().rect_stroke(rect.shrink(2.0), 0.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); } icons::paint(ui, "chevrons-right", Rect::from_center_size(rect.center(), vec2(12.0, 12.0)).intersect(rect), t.text); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Panels"))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Panels"))); let resp = resp.on_hover_text(tl!("Panels")); egui::Popup::menu(&resp).show(|ui| { widgets::menu_scroll(ui, |ui| { @@ -451,7 +455,7 @@ pub fn floating_panel(app: &mut VectorcraftApp, ctx: &egui::Context) { let tall = (screen.height() - FLYOUT_TOP - 26.0 - 20.0 - 40.0).clamp(160.0, 560.0); let mut open = true; let mut torn = None; - let area = egui::Area::new(egui::Id::new("icon-panel")).order(egui::Order::Foreground).pivot(egui::Align2::RIGHT_TOP); + let area = egui::Area::new(egui::Id::unique("icon-panel")).order(egui::Order::Foreground).pivot(egui::Align2::RIGHT_TOP); area.fixed_pos(egui::pos2(right, FLYOUT_TOP)).show(ctx, |ui| { egui::Frame::popup(ui.style()).fill(t.panel).corner_radius(CornerRadius::same(4)).inner_margin(egui::Margin::ZERO).show(ui, |ui| { ui.set_width(width); @@ -462,14 +466,14 @@ pub fn floating_panel(app: &mut VectorcraftApp, ctx: &egui::Context) { ui.painter().rect_filled(title, CornerRadius { nw: 4, ne: 0, sw: 0, se: 0 }, t.panel); ui.painter().text(title.left_center() + vec2(12.0, 0.0), egui::Align2::LEFT_CENTER, &label, theme::semibold(12.0), t.text); let close = Rect::from_center_size(strip.right_center() - vec2(13.0, 0.0), vec2(14.0, 14.0)); - let cr = ui.interact(close, ui.id().with("close-panel"), Sense::click()); + let cr = ui.interact(close, ui.scope_id().with("close-panel"), Sense::click()); icons::paint(ui, "chevrons-right", close, if cr.hovered() { t.text } else { t.text_dim }); if cr.clicked() { open = false; } let menu_r = Rect::from_center_size(strip.right_center() - vec2(34.0, 0.0), vec2(16.0, 16.0)); panels::panel_menu(app, ui, id, menu_r); - let drag = ui.interact(title, ui.id().with("title"), Sense::drag()).on_hover_cursor(egui::CursorIcon::Grab); + let drag = ui.interact(title, ui.scope_id().with("title"), Sense::drag()).on_hover_cursor(egui::CursorIcon::Grab); if drag.dragged() && ui.input(|i| i.pointer.is_decidedly_dragging()) { torn = Some(title.left()); } @@ -655,7 +659,7 @@ mod tests { let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() }; ctx.run_ui(input, |ui| floating_panel(&mut app, ui.ctx())).textures_delta.clear(); } - let rect = ctx.memory(|m| m.area_rect(egui::Id::new("icon-panel"))).unwrap(); + let rect = ctx.memory(|m| m.area_rect(egui::Id::unique("icon-panel"))).unwrap(); if rect.right() > column { too_wide.push(format!("{panel}: {:.0} past the column", rect.right() - column)); } @@ -871,7 +875,7 @@ mod tests { h.click(layers.center()); assert_eq!(h.app.ui.open_panel.as_deref(), Some("layers")); h.settle(); - let rect = h.ctx.memory(|m| m.area_rect(egui::Id::new("icon-panel"))).unwrap(); + let rect = h.ctx.memory(|m| m.area_rect(egui::Id::unique("icon-panel"))).unwrap(); let column = h.column_left(); assert!(rect.right() <= column, "the flyout {rect:?} stays left of the column at {column}"); assert!(rect.left() >= 0.0 && rect.bottom() <= SCREEN.y, "the flyout {rect:?} is on screen"); @@ -892,7 +896,7 @@ mod tests { h.app.run("window.collapseDock", json!({"collapsed": true})).unwrap(); h.app.run("window.panel", json!({"panel": "properties"})).unwrap(); h.settle(); - let rect = h.ctx.memory(|m| m.area_rect(egui::Id::new("icon-panel"))).unwrap(); + let rect = h.ctx.memory(|m| m.area_rect(egui::Id::unique("icon-panel"))).unwrap(); assert!(rect.width() >= DOCK_WIDTH && rect.right() <= h.column_left(), "Properties flyout {rect:?}"); // Taller than the 400 points egui sizes a new area at: its sections (Align, Quick // Actions) aren't cut off below Appearance. @@ -917,7 +921,7 @@ mod tests { assert_eq!(h.app.ui.open_panel, None, "on: a click elsewhere puts it away"); h.click(layers.center()); assert_eq!(h.app.ui.open_panel.as_deref(), Some("layers"), "its icon still pops it out"); - let rect = h.ctx.memory(|m| m.area_rect(egui::Id::new("icon-panel"))).unwrap(); + let rect = h.ctx.memory(|m| m.area_rect(egui::Id::unique("icon-panel"))).unwrap(); h.click(egui::pos2(rect.center().x, rect.top() + 13.0 + 26.0 + 10.0)); assert_eq!(h.app.ui.open_panel.as_deref(), Some("layers"), "a click inside keeps it"); // The icons still swap the flyout and put it away. @@ -929,7 +933,7 @@ mod tests { h.click(layers.center()); h.app.ui.dialog = Some(crate::state::Dialog::new("move", json!({"dx": "0 pt", "dy": "0 pt"}))); h.settle(); - let dialog = h.ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", "move")))).expect("the Move dialog"); + let dialog = h.ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", "move")))).expect("the Move dialog"); h.click(dialog.left_top() + vec2(20.0, 30.0)); assert_eq!(h.app.ui.open_panel.as_deref(), Some("layers"), "a click in the dialog keeps it"); h.click(egui::pos2(dialog.left() - 40.0, dialog.center().y)); diff --git a/crates/ui-egui/src/document_drag.rs b/crates/ui-egui/src/document_drag.rs index 2ca57ce48..72ea5a3c1 100644 --- a/crates/ui-egui/src/document_drag.rs +++ b/crates/ui-egui/src/document_drag.rs @@ -31,15 +31,15 @@ struct Hover { /// Key for the source document and original canvas grab point. fn origin_id() -> Id { - Id::new("document-drag-origin") + Id::unique("document-drag-origin") } /// Key for the tab identity and dwell start time of the current hover. fn hover_id() -> Id { - Id::new("document-drag-hover") + Id::unique("document-drag-hover") } /// Key for the pointer position latched after tab activation to prevent cascading switches. fn activated_at_id() -> Id { - Id::new("document-drag-activated-at") + Id::unique("document-drag-activated-at") } /// Record the Selection drag source and grab point, clearing any previous tab-hover timer. diff --git a/crates/ui-egui/src/edit_toolbar.rs b/crates/ui-egui/src/edit_toolbar.rs index 98e316f06..2a7989f68 100644 --- a/crates/ui-egui/src/edit_toolbar.rs +++ b/crates/ui-egui/src/edit_toolbar.rs @@ -330,16 +330,16 @@ pub(crate) fn toolbar_drop(app: &mut VectorcraftApp, ui: &Ui, slots: &[(usize, R /// Where the toolbar leaves its ••• button's rect and its own for the drawer, each frame. pub(crate) fn button_id() -> egui::Id { - egui::Id::new("edit-toolbar-button") + egui::Id::unique("edit-toolbar-button") } pub(crate) fn toolbar_id() -> egui::Id { - egui::Id::new("edit-toolbar-toolbar") + egui::Id::unique("edit-toolbar-toolbar") } /// The drawer's area. pub(crate) fn drawer_id() -> egui::Id { - egui::Id::new("tools-drawer") + egui::Id::unique("tools-drawer") } /// The heading a tool is listed under: its Basic toolbar group, else one for the tools only the @@ -436,7 +436,7 @@ pub(crate) fn drawer(app: &mut VectorcraftApp, ctx: &egui::Context) { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let (r, menu) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click()); icons::paint(ui, "menu", r.shrink(1.0), if menu.hovered() { t.text_strong } else { t.text_dim }); - menu.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Drawer menu"))); + menu.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Drawer menu"))); let menu = menu.on_hover_text(tl!("Drawer menu")); egui::Popup::menu(&menu).id(menu_id()).show(|ui| { ui.set_min_width(160.0); @@ -530,7 +530,7 @@ pub(crate) fn drawer(app: &mut VectorcraftApp, ctx: &egui::Context) { /// and Coloring are drawn as small copies of those controls, the modes with their buttons' icons. fn show_button(ui: &mut Ui, t: &Tokens, name: &str, label: &str, icon: &str, on: bool) -> bool { let (r, resp) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, on, tl!(label))); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, on, tl!(label))); let p = ui.painter(); if on { p.rect_filled(r, 3.0, t.tool_active); @@ -571,7 +571,7 @@ fn show_button(ui: &mut Ui, t: &Tokens, name: &str, label: &str, icon: &str, on: /// The drawer's flyout menu. fn menu_id() -> egui::Id { - egui::Id::new("tools-drawer-menu") + egui::Id::unique("tools-drawer-menu") } /// A tool's row in the drawer: icon, name and shortcut, dimmed when the tool is in the toolbar @@ -628,7 +628,7 @@ fn ghost(ctx: &egui::Context, t: &Tokens) { let Some(d) = DragAndDrop::payload::(ctx) else { return }; let (Some(at), Some(tool)) = (ctx.pointer_interact_pos(), tool_info(d.tool)) else { return }; let r = Rect::from_min_size(at + vec2(8.0, 8.0), vec2(GHOST, GHOST)); - egui::Area::new(egui::Id::new("tool-drag-ghost")).order(egui::Order::Tooltip).fixed_pos(r.min).interactable(false).show(ctx, |ui| { + egui::Area::new(egui::Id::unique("tool-drag-ghost")).order(egui::Order::Tooltip).fixed_pos(r.min).interactable(false).show(ctx, |ui| { let (r, _) = ui.allocate_exact_size(r.size(), Sense::hover()); ui.painter().rect(r, 3.0, t.panel, Stroke::new(1.0, t.accent), StrokeKind::Inside); icons::paint(ui, icons::tool_icon(tool.icon), Rect::from_center_size(r.center(), vec2(18.0, 18.0)), t.text_strong); diff --git a/crates/ui-egui/src/export_progress.rs b/crates/ui-egui/src/export_progress.rs index 0a406254f..1854ad734 100644 --- a/crates/ui-egui/src/export_progress.rs +++ b/crates/ui-egui/src/export_progress.rs @@ -20,7 +20,7 @@ pub const DELAY: f64 = 0.4; /// Where the job a frame first saw is remembered (its id, the time it was first seen). fn seen_id() -> egui::Id { - egui::Id::new("export-progress-seen") + egui::Id::unique("export-progress-seen") } /// What the dialog shows: (label, steps done, steps in all, share done, exports after this one, @@ -103,7 +103,7 @@ pub enum Pressed { /// ([`crate::plugin_runs`]). pub fn window(ctx: &egui::Context, id: &str, heading: &str, body: impl FnOnce(&mut egui::Ui)) -> Option { let mut pressed = None; - modal::show(ctx, heading, egui::Id::new(id), -40.0, 20, |ui| { + modal::show(ctx, heading, egui::Id::unique(id), -40.0, 20, |ui| { ui.set_width(360.0); modal::heading(ui, heading); ui.add_space(12.0); diff --git a/crates/ui-egui/src/find_font.rs b/crates/ui-egui/src/find_font.rs index 9524096db..23340af8c 100644 --- a/crates/ui-egui/src/find_font.rs +++ b/crates/ui-egui/src/find_font.rs @@ -54,7 +54,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let mut close = false; let mut find_in_folder = false; let mut act: Option<&str> = None; - crate::dialogs::modal::show(ctx, tl!("Find Font"), egui::Id::new("dialog-find-font"), -40.0, 22, |ui: &mut Ui| { + crate::dialogs::modal::show(ctx, tl!("Find Font"), egui::Id::unique("dialog-find-font"), -40.0, 22, |ui: &mut Ui| { ui.set_width(380.0); crate::dialogs::modal::heading(ui, tl!("Find Font")); ui.add_space(10.0); diff --git a/crates/ui-egui/src/floating.rs b/crates/ui-egui/src/floating.rs index 5701d5890..b6a489db6 100644 --- a/crates/ui-egui/src/floating.rs +++ b/crates/ui-egui/src/floating.rs @@ -122,11 +122,11 @@ pub(crate) struct RootGroup { /// The dock's groups as last drawn, top to bottom. pub(crate) fn root_groups_id() -> Id { - Id::new("floating-root-groups") + Id::unique("floating-root-groups") } fn move_id() -> Id { - Id::new("floating-move") + Id::unique("floating-move") } fn suppressed_id() -> Id { @@ -140,29 +140,29 @@ fn gesture_allowed(ctx: &egui::Context) -> bool { /// The dock (tabbed group and icon column) as last drawn: a group dropped there docks. pub(crate) fn dock_rect_id() -> Id { - Id::new("floating-dock-rect") + Id::unique("floating-dock-rect") } /// The strip along the window's left edge where the Tools panel docks, as last laid out. /// The icon column's bounds (egui temp memory, one frame old), where panels without a tab in the /// tabbed group go back to when docked. pub(crate) fn icons_rect_id() -> Id { - Id::new("floating-icons-rect") + Id::unique("floating-icons-rect") } pub(crate) fn tools_zone_id() -> Id { - Id::new("floating-tools-zone") + Id::unique("floating-tools-zone") } /// The area of the floating group whose first panel is `first` (of the set it heads). pub fn area_id(first: &str) -> Id { - Id::new(("floating-panels", first)) + Id::unique(("floating-panels", first)) } /// The floating group whose first panel is `first`, as last drawn (egui temp memory): in a set, /// from the bar above its tabs to its panel's bottom. pub(crate) fn group_rect_id(first: &str) -> Id { - Id::new(("floating-group-rect", first)) + Id::unique(("floating-group-rect", first)) } /// Panel `id` (`window.panel`) as a static string and its English label, if it is a panel (a @@ -441,7 +441,7 @@ pub(crate) fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Movin let Some((first, _)) = g.panels.first().and_then(|p| panel(p)).filter(|_| !moving.contains(&i)) else { continue }; let Some(mut r) = ctx .data(|d| d.get_temp::(group_rect_id(first))) - .or_else(|| ctx.memory(|m| m.area_rect(Id::new("vectorcraft-panel-docking").with(("floating", &first.to_string()))))) + .or_else(|| ctx.memory(|m| m.area_rect(Id::unique("vectorcraft-panel-docking").with(("floating", &first.to_string()))))) else { continue; }; @@ -450,7 +450,7 @@ pub(crate) fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Movin // available just outside the window. Dock columns reserve their own panel space. if g.docked.is_none() && r.contains(at) { let area = if app.ui.docking.is_some() && !crate::panel_docking::decorated(g) { - Id::new("vectorcraft-panel-docking").with(("floating", &first.to_string())) + Id::unique("vectorcraft-panel-docking").with(("floating", &first.to_string())) } else { let lead = set_of(&app.ui, i).first().and_then(|&index| app.ui.floating_panels.get(index)).and_then(|group| group.panels.first()); area_id(lead.map_or(first, String::as_str)) @@ -458,7 +458,7 @@ pub(crate) fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Movin let moving_areas: Vec = moving .iter() .filter_map(|&index| app.ui.floating_panels.get(index).and_then(|group| group.panels.first())) - .flat_map(|panel| [area_id(panel), Id::new("vectorcraft-panel-docking").with(("floating", panel))]) + .flat_map(|panel| [area_id(panel), Id::unique("vectorcraft-panel-docking").with(("floating", panel))]) .collect(); let owner = ctx.memory(|memory| { let layers: Vec<_> = memory.layer_ids().collect(); @@ -538,21 +538,21 @@ pub(crate) fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Movin /// Light up a drop zone: over the docked panels, under the floating ones (the moved one too). pub(crate) fn highlight(ctx: &egui::Context, zone: Rect) { let t = Tokens::get(ctx); - let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Background, Id::new("floating-drop-zone"))); + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Background, Id::unique("floating-drop-zone"))); painter.rect(zone.shrink(1.0), CornerRadius::same(2), t.accent.gamma_multiply(0.2), Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); } /// Light up the tabs a drop stacks the moved panels with, over the floating and docked groups. fn tab_zone(ctx: &egui::Context, tabs: Rect) { let t = Tokens::get(ctx); - let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, Id::new("floating-drop-tabs"))); + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, Id::unique("floating-drop-tabs"))); painter.rect(tabs.shrink(1.0), CornerRadius::same(2), t.accent.gamma_multiply(0.25), Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); } /// Show where a drop puts the moved groups in a set: a line over the floating groups. fn insertion_line(ctx: &egui::Context, line: Rect) { let t = Tokens::get(ctx); - let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, Id::new("floating-drop-line"))); + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, Id::unique("floating-drop-line"))); painter.rect_filled(line, CornerRadius::same(2), t.accent); } @@ -855,7 +855,7 @@ pub fn dock_column(ui: &mut UiState, set: &[usize], slot: u32) { /// The dock columns, as last drawn (egui temp memory), from the one next to the icon column /// leftwards. pub(crate) fn columns_id() -> Id { - Id::new("dock-columns") + Id::unique("dock-columns") } /// The sets docked as columns, by their column number (next to the icon column first). @@ -880,7 +880,7 @@ pub(crate) fn docked_columns(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> Vec let groups = shown_groups(&app.ui, &set); let Some(first) = groups.first().and_then(|g| g.ids.first().copied()) else { continue }; let natural = groups.iter().flat_map(|g| g.ids.iter()).map(|id| dock::panel_width(id)).fold(200.0, f32::max); - let panel = egui::Panel::right(Id::new(("dock-column", first))) + let panel = egui::Panel::right(("dock-column", first)) .resizable(true) .default_size(natural) .size_range(200.0..=520.0) @@ -891,7 +891,8 @@ pub(crate) fn docked_columns(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> Vec let (bar, _) = ui.allocate_exact_size(vec2(width, TITLE), Sense::hover()); ui.painter().rect_filled(bar, 0.0, t.tab_strip); let close = Rect::from_center_size(bar.right_center() - vec2(10.0, 0.0), vec2(TITLE, TITLE)); - let grip = ui.interact(Rect::from_min_max(bar.min, pos2(close.left(), bar.bottom())), ui.id().with("column-bar"), Sense::drag()); + let grip = + ui.interact(Rect::from_min_max(bar.min, pos2(close.left(), bar.bottom())), ui.scope_id().with("column-bar"), Sense::drag()); for k in 0..2 { let y = bar.center().y - 1.5 + k as f32 * 3.0; ui.painter().line_segment([pos2(bar.center().x - 9.0, y), pos2(bar.center().x + 9.0, y)], Stroke::new(0.6, t.text_disabled)); @@ -902,7 +903,7 @@ pub(crate) fn docked_columns(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> Vec action = Some(Action::Undock(gi, at - bar.min)); } grip.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tl!("Drag to float the panel group")); - let x = ui.interact(close, ui.id().with("column-close"), Sense::click()); + let x = ui.interact(close, ui.scope_id().with("column-close"), Sense::click()); icons::paint(ui, "x", close.shrink(3.0), if x.hovered() { t.text_strong } else { t.text_dim }); if x.on_hover_text(tl!("Put back in the dock")).clicked() { action = Some(Action::Close(set.clone())); @@ -971,7 +972,7 @@ fn show_set(app: &mut VectorcraftApp, ctx: &egui::Context, set: &[usize], action let (bar, _) = ui.allocate_exact_size(vec2(width, TITLE), Sense::hover()); ui.painter().rect_filled(bar, CornerRadius { nw: 4, ne: 4, sw: 0, se: 0 }, t.tab_strip); let close = Rect::from_center_size(bar.right_center() - vec2(10.0, 0.0), vec2(TITLE, TITLE)); - let grip = ui.interact(Rect::from_min_max(bar.min, pos2(close.left(), bar.bottom())), ui.id().with("title"), Sense::drag()); + let grip = ui.interact(Rect::from_min_max(bar.min, pos2(close.left(), bar.bottom())), ui.scope_id().with("title"), Sense::drag()); for k in 0..2 { let y = bar.center().y - 1.5 + k as f32 * 3.0; ui.painter().line_segment([pos2(bar.center().x - 9.0, y), pos2(bar.center().x + 9.0, y)], Stroke::new(0.6, t.text_disabled)); @@ -980,7 +981,7 @@ fn show_set(app: &mut VectorcraftApp, ctx: &egui::Context, set: &[usize], action *action = Some(Action::Move(lead_active, pos)); } grip.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tl!("Drag to move")); - let x = ui.interact(close, ui.id().with("close"), Sense::click()); + let x = ui.interact(close, ui.scope_id().with("close"), Sense::click()); icons::paint(ui, "x", close.shrink(3.0), if x.hovered() { t.text_strong } else { t.text_dim }); if x.on_hover_text(tl!("Put back in the dock")).clicked() { *action = Some(Action::Close(set.to_vec())); @@ -1052,7 +1053,7 @@ fn draw_groups( if rest.width() > 0.0 { // Click as well as drag, so a press there no longer starts the move at once and a // double-click can collapse the group. - let resp = ui.interact(rest, ui.id().with(("strip", g.gi)), Sense::click_and_drag()); + let resp = ui.interact(rest, ui.scope_id().with(("strip", g.gi)), Sense::click_and_drag()); if resp.double_clicked() { *action = Some(Action::Collapse(g.gi)); } else if resp.drag_started() { @@ -1327,7 +1328,7 @@ mod tests { fn group(&self, first: &str) -> Rect { let native = group_of(&self.app.ui, first).and_then(|i| self.app.ui.floating_panels.get(i)).is_some_and(crate::panel_docking::decorated); let id = if self.app.ui.docking.is_some() && !native { - Id::new("vectorcraft-panel-docking").with(("floating", &first.to_string())) + Id::unique("vectorcraft-panel-docking").with(("floating", &first.to_string())) } else { area_id(first) }; @@ -1355,7 +1356,7 @@ mod tests { } fn shared_tab(&self, panel: &str) -> Rect { - self.ctx.read_response(Id::new("vectorcraft-panel-docking").with(("tab", &panel.to_string()))).unwrap().rect + self.ctx.read_response(Id::unique("vectorcraft-panel-docking").with(("tab", &panel.to_string()))).unwrap().rect } /// The dock's tabs, left to right. @@ -1366,7 +1367,7 @@ mod tests { .panels() .into_iter() .filter_map(|panel| { - self.ctx.read_response(Id::new("vectorcraft-panel-docking").with(("tab", panel))).map(|response| response.rect) + self.ctx.read_response(Id::unique("vectorcraft-panel-docking").with(("tab", panel))).map(|response| response.rect) }) .collect(); } @@ -1534,7 +1535,7 @@ mod tests { let mut h = Harness::new(); h.app.run("window.panel", json!({"panel": "swatches"})).unwrap(); h.settle(); - let flyout = h.ctx.memory(|m| m.area_rect(Id::new("icon-panel"))).unwrap(); + let flyout = h.ctx.memory(|m| m.area_rect(Id::unique("icon-panel"))).unwrap(); let to = pos2(500.0, 400.0); h.drag(flyout.left_top() + vec2(20.0, 13.0), to); assert_eq!((h.groups(), h.app.ui.open_panel.as_deref()), (vec![vec!["swatches"]], None)); @@ -1551,7 +1552,7 @@ mod tests { h.app.run("window.panel", json!({"panel":"swatches"})).unwrap(); h.settle(); let original = h.app.ui.clone(); - let flyout = h.ctx.memory(|m| m.area_rect(Id::new("icon-panel"))).unwrap(); + let flyout = h.ctx.memory(|m| m.area_rect(Id::unique("icon-panel"))).unwrap(); let to = pos2(500.0, 400.0); h.hold(flyout.left_top() + vec2(20.0, 13.0), to); assert!(h.app.ui.floating_panels.iter().any(|g| g.panels.contains(&"swatches".into()))); @@ -1614,7 +1615,7 @@ mod tests { let to = pos2(400.0, 300.0); h.drag(title, to); let pos = h.app.ui.toolbar_pos.expect("the Tools panel floats"); - let float = h.ctx.memory(|m| m.area_rect(Id::new("toolbar-floating"))).unwrap(); + let float = h.ctx.memory(|m| m.area_rect(Id::unique("toolbar-floating"))).unwrap(); assert!(float.contains(to) && (pos2(pos[0], pos[1]) - float.min).length() < 1.0, "under the pointer: {float:?}"); // Back on the window's left edge. let edge = pos2(10.0, 400.0); diff --git a/crates/ui-egui/src/font_menu.rs b/crates/ui-egui/src/font_menu.rs index 9011b8fd6..5d77b9508 100644 --- a/crates/ui-egui/src/font_menu.rs +++ b/crates/ui-egui/src/font_menu.rs @@ -245,7 +245,7 @@ pub fn font_menu( sample: Option<&str>, look: MenuLook, ) -> Option { - let state_id = ui.id().with(&id).with("font-menu"); + let state_id = ui.scope_id().with(&id).with("font-menu"); let mut st: MenuState = ui.data(|d| d.get_temp(state_id)).unwrap_or_default(); let mut drawn = false; let picked = widgets::combo(ui, &id, current, width, true, |ui| { @@ -522,7 +522,7 @@ fn list(ui: &mut Ui, state_id: egui::Id, st: &mut MenuState, current: &str, samp } } }); - st.scrolled = shown.state.offset.y; + st.scrolled = shown.state.clamped_offset().y; if let Some(f) = toggled { if st.expanded.contains(&f) { st.expanded.retain(|e| *e != f); @@ -721,7 +721,7 @@ const PREVIEWING: &str = "font-previewing"; /// Where the open font menu was drawn last: (pass, its popup's layer). fn open_id() -> egui::Id { - egui::Id::new("font-menu-open") + egui::Id::unique("font-menu-open") } /// Undo a preview whose menu is gone (its panel wasn't drawn: the text was deselected, the panel @@ -885,7 +885,7 @@ mod tests { for _ in 0..3 { let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(area), ..Default::default() }, |ui| { let mut child = ui.new_child(egui::UiBuilder::new().max_rect(area)); - super::list(&mut child, egui::Id::new("fonts"), &mut st, "", None, look); + super::list(&mut child, egui::Id::unique("fonts"), &mut st, "", None, look); }); out.textures_delta.clear(); stars = out @@ -956,7 +956,7 @@ mod tests { let mut out = ctx.run_ui(input, |ui| { picked = super::list( ui, - egui::Id::new("recent-test"), + egui::Id::unique("recent-test"), &mut st, &names[2], None, @@ -973,7 +973,14 @@ mod tests { [true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Default::default() }).to_vec() }; assert_eq!(frame(key(egui::Key::ArrowDown), &recent).0, Some(super::FontPick::Preview(names[1].clone(), None))); - assert_eq!(frame(key(egui::Key::ArrowDown), &recent).0, Some(super::FontPick::Preview(names[0].clone(), None)), "skip Fonts heading"); + // Other tests add fonts to the process-wide database while this one runs, so the first + // family under the Fonts heading need not be the one this test's earlier snapshot named. + // Read it from the rows this frame built: the highlight must skip the heading and land on + // that family, not on a heading or a row from the recent section. + let (picked, rows) = frame(key(egui::Key::ArrowDown), &recent); + assert_eq!(rows.get(3), Some(&Row::Heading("Fonts")), "{rows:?}"); + let (first, style) = rows.get(4).and_then(Row::pick).expect("a family below the Fonts heading"); + assert_eq!(picked, Some(super::FontPick::Preview(first, style)), "skip Fonts heading"); assert_eq!(frame(key(egui::Key::ArrowUp), &recent).0, Some(super::FontPick::Preview(names[1].clone(), None))); // Updating recent history while the menu is open must not retarget the highlight. let updated = vec![names[1].clone()]; @@ -1003,7 +1010,7 @@ mod tests { |ui| { pick = super::list( ui, - egui::Id::new("filtered-recent"), + egui::Id::unique("filtered-recent"), &mut st, &names[2], None, @@ -1041,7 +1048,7 @@ mod tests { |ui| { super::list( ui, - egui::Id::new("recent-at-top"), + egui::Id::unique("recent-at-top"), &mut st, current, None, @@ -1110,7 +1117,7 @@ mod tests { |ui| { super::list( ui, - egui::Id::new("variable-filter"), + egui::Id::unique("variable-filter"), st, "Source Sans 3", None, diff --git a/crates/ui-egui/src/free_transform.rs b/crates/ui-egui/src/free_transform.rs index 56b3eb24b..dbebedffa 100644 --- a/crates/ui-egui/src/free_transform.rs +++ b/crates/ui-egui/src/free_transform.rs @@ -37,7 +37,7 @@ fn modes() -> [(&'static str, Glyph, &'static str); 3] { /// Where the widget was last drawn (none while it isn't shown). pub(crate) fn rect(ctx: &egui::Context) -> Option { - let id = egui::Id::new(TOOL); + let id = egui::Id::unique(TOOL); ctx.memory(|m| m.areas().is_visible(&egui::LayerId::new(egui::Order::Middle, id)).then(|| m.area_rect(id)).flatten()) } @@ -52,7 +52,7 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &Ui, canvas: Rect) { let mode = opts["mode"].as_str().unwrap_or("free").to_string(); let constrain = opts["constrain"].as_bool().unwrap_or(false); let mut set: Option<(&str, Value)> = None; - egui::Area::new(egui::Id::new(TOOL)).order(egui::Order::Middle).fixed_pos(canvas.min + vec2(10.0, 10.0)).show(ui.ctx(), |ui| { + egui::Area::new(egui::Id::unique(TOOL)).order(egui::Order::Middle).fixed_pos(canvas.min + vec2(10.0, 10.0)).show(ui.ctx(), |ui| { egui::Frame::NONE .fill(t.panel) .stroke(Stroke::new(1.0, t.button_border)) diff --git a/crates/ui-egui/src/graphics.rs b/crates/ui-egui/src/graphics.rs index 88c37fef9..db85972d2 100644 --- a/crates/ui-egui/src/graphics.rs +++ b/crates/ui-egui/src/graphics.rs @@ -42,7 +42,7 @@ impl GraphicsLoss { /// context's. pub(crate) fn context_tag(ctx: &egui::Context) -> u64 { static NEXT: AtomicU64 = AtomicU64::new(1); - let id = egui::Id::new("vectorcraft-context-tag"); + let id = egui::Id::unique("vectorcraft-context-tag"); if let Some(tag) = ctx.data(|d| d.get_temp::(id)) { return tag; } @@ -105,14 +105,22 @@ impl VectorcraftApp { /// theme, the canvas, the place cursor's thumbnails and every [`TexCache`]. pub(crate) fn adopt_context(&mut self, ctx: &egui::Context) { let tag = context_tag(ctx); + self.ui_ctx = Some(ctx.clone()); if self.context == tag { return; } if self.context != 0 { log::info!("vectorcraft: new graphics context: uploading the UI again"); forget_textures(); + // The IME tracker's pass numbers are per context. A new context starts at pass 0; only + // an explicit teardown guarantees it cannot look continuous with the old context's + // last observed pass and reuse its owner/baseline generation. + self.ime_context.end_viewport(ctx.viewport_id()); self.styled = false; self.fonts_ready = false; + // Shortcut input is keyed by context identity. Preserve a first pass already + // handled for this new context before UI setup, or toggle keys execute twice. + self.injected_settle_hook = false; self.ui_fonts = Default::default(); self.canvas.texture = None; self.canvas.key = None; diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 00d0b51de..0f59a0e3c 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5478,3 +5478,4 @@ axis Y Axis Y ос Rectangular Grid Tool Options Опции на инструмента „Правоъгълна мрежа“ Polar Grid Tool Options Опции на инструмента „Полярна мрежа“ Fill Line Запълване на линия + Scale Presets Пресети за мащаб diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 1b41d7545..8168e410f 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5550,3 +5550,6 @@ axis Y Axis osa Y Rectangular Grid Tool Options Možnosti nástroje Obdélníková mřížka Polar Grid Tool Options Možnosti nástroje Polární mřížka Fill Line Výplň úsečky + Scale Presets Přednastavení měřítka +@msg a {width}×{height} screenshot's byte count overflows počet bajtů snímku {width}×{height} přeteče +@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot okno {what} ({points} bodů × {scale} DPI) nemůže určit velikost snímku diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index bfd2f5dc9..9f885201e 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5494,3 +5494,4 @@ axis Y Axis Y-akse Rectangular Grid Tool Options Indstillinger for rektangulært gitterværktøj Polar Grid Tool Options Indstillinger for polært gitterværktøj Fill Line Udfyld linje + Scale Presets Skaleringsforudindstillinger diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index dc6d6cdea..ba2812480 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5132,3 +5132,6 @@ axis Y Axis Y-Achse Rectangular Grid Tool Options Optionen für das Rechteckiges-Raster-Werkzeug Polar Grid Tool Options Optionen für das Radiales-Raster-Werkzeug Fill Line Linie füllen + Scale Presets Skalierungsvorgaben +@msg a {width}×{height} screenshot's byte count overflows die Byteanzahl eines {width}×{height}-Screenshots läuft über +@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot die {what} des Fensters ({points} Punkte × {scale} DPI) kann keinen Screenshot bemessen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 514a12969..5f946b572 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5502,3 +5502,6 @@ axis Y Axis Άξονας Y Rectangular Grid Tool Options Επιλογές εργαλείου ορθογώνιου πλέγματος Polar Grid Tool Options Επιλογές εργαλείου πολικού πλέγματος Fill Line Γέμισμα γραμμής + Scale Presets Προεπιλογές κλίμακας +@msg a {width}×{height} screenshot's byte count overflows το πλήθος byte ενός στιγμιότυπου {width}×{height} υπερχειλίζει +@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot το {what} του παραθύρου ({points} σημεία × {scale} DPI) δεν μπορεί να καθορίσει μέγεθος στιγμιότυπου diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 5fdbfbb89..be9890c63 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5492,3 +5492,6 @@ axis Y Axis Eje Y Rectangular Grid Tool Options Opciones de la herramienta Cuadrícula rectangular Polar Grid Tool Options Opciones de la herramienta Cuadrícula polar Fill Line Rellenar línea + Scale Presets Ajustes predefinidos de escala +@msg a {width}×{height} screenshot's byte count overflows el número de bytes de una captura de {width}×{height} se desborda +@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot el {what} de la ventana ({points} puntos × {scale} DPI) no puede dimensionar una captura diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index f4d06dedf..b6126313e 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5581,3 +5581,6 @@ axis Y Axis Y-akseli Rectangular Grid Tool Options Suorakulmaisen ruudukon työkalun asetukset Polar Grid Tool Options Napakoordinaattiruudukon työkalun asetukset Fill Line Täytä viiva + Scale Presets Skaalauksen esiasetukset +@msg a {width}×{height} screenshot's byte count overflows {width}×{height} kuvakaappauksen tavumäärä ylittää rajan +@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot ikkunan {what} ({points} pistettä × {scale} DPI) ei voi määrittää kuvakaappauksen kokoa diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 8298a79c4..ee64c2cd1 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5549,3 +5549,6 @@ axis Y Axis Axe Y Rectangular Grid Tool Options Options de l’outil Grille rectangulaire Polar Grid Tool Options Options de l’outil Grille à coordonnées polaires Fill Line Remplir le trait + Scale Presets Préréglages de mise à l'échelle +@msg a {width}×{height} screenshot's byte count overflows le nombre d'octets d'une capture {width}×{height} dépasse la limite +@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot la {what} de la fenêtre ({points} points × {scale} DPI) ne permet pas de dimensionner une capture diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 1fdb2469a..d521a5acf 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5536,3 +5536,6 @@ axis Y Axis Y tengely Rectangular Grid Tool Options Téglalaprács beállításai Polar Grid Tool Options Polárrács beállításai Fill Line Szakasz kitöltése + Scale Presets Méretezési beállítások +@msg a {width}×{height} screenshot's byte count overflows a(z) {width}×{height} képernyőkép bájtszáma túlcsordul +@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot az ablak {what} ({points} pont × {scale} DPI) nem tud képernyőkép-méretet adni diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index c92507ee7..0eb270a2f 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5494,3 +5494,4 @@ axis Y Axis Sumbu Y Rectangular Grid Tool Options Opsi alat kisi persegi panjang Polar Grid Tool Options Opsi alat kisi polar Fill Line Isi garis + Scale Presets Preset skala diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index b64f45155..09e6b4593 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5166,3 +5166,6 @@ axis Y Axis Asse Y Rectangular Grid Tool Options Opzioni strumento Griglia rettangolare Polar Grid Tool Options Opzioni strumento Griglia polare Fill Line Riempi linea + Scale Presets Preimpostazioni di scala +@msg a {width}×{height} screenshot's byte count overflows il numero di byte di uno screenshot {width}×{height} va in overflow +@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot la {what} della finestra ({points} punti × {scale} DPI) non può dimensionare uno screenshot diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index e747d479d..2e5aca28a 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5228,3 +5228,6 @@ axis Y Axis Y 軸 Rectangular Grid Tool Options 長方形グリッドツールオプション Polar Grid Tool Options 同心円グリッドツールオプション Fill Line 線を塗りつぶす + Scale Presets 拡大縮小のプリセット +@msg a {width}×{height} screenshot's byte count overflows {width}×{height} のスクリーンショットのバイト数がオーバーフローします +@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot ウィンドウの {what}({points} ポイント × {scale} DPI)ではスクリーンショットのサイズを決められません diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index a2cbbb601..fb0a6e776 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5466,3 +5466,6 @@ axis Y Axis Y ღერძი Rectangular Grid Tool Options მართკუთხა ბადის ხელსაწყოს პარამეტრები Polar Grid Tool Options პოლარული ბადის ხელსაწყოს პარამეტრები Fill Line ხაზის შევსება + Scale Presets მასშტაბის წინასწარ დაყენებული მნიშვნელობები +@msg a {width}×{height} screenshot's byte count overflows {width}×{height} ეკრანის სურათის ბაიტების რაოდენობა გადაივსო +@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot ფანჯრის {what} ({points} პუნქტი × {scale} DPI) ეკრანის სურათის ზომას ვერ განსაზღვრავს diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 7a43a4118..40730e66b 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5129,3 +5129,6 @@ axis Y Axis Y축 Rectangular Grid Tool Options 사각형 격자 도구 옵션 Polar Grid Tool Options 극좌표 격자 도구 옵션 Fill Line 선 채우기 + Scale Presets 크기 조절 사전 설정 +@msg a {width}×{height} screenshot's byte count overflows {width}×{height} 스크린샷의 바이트 수가 넘칩니다 +@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot 창의 {what}({points}포인트 × {scale} DPI)로는 스크린샷 크기를 정할 수 없습니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 4818531d3..1433980f4 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5538,3 +5538,4 @@ axis Y Axis Y-as Rectangular Grid Tool Options Opties rechthoekig-rastergereedschap Polar Grid Tool Options Opties polair-rastergereedschap Fill Line Lijn vullen + Scale Presets Schaalvoorinstellingen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 2258fbc7b..41ea8aa50 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5402,3 +5402,6 @@ axis Y Axis Oś Y Rectangular Grid Tool Options Opcje narzędzia Siatka prostokątna Polar Grid Tool Options Opcje narzędzia Siatka biegunowa Fill Line Wypełnij odcinek + Scale Presets Ustawienia wstępne skali +@msg a {width}×{height} screenshot's byte count overflows liczba bajtów zrzutu ekranu {width}×{height} przekracza limit +@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot {what} okna ({points} punktów × {scale} DPI) nie pozwala ustalić rozmiaru zrzutu ekranu diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 1908d41c8..613451db9 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5467,3 +5467,6 @@ axis Y Axis Eixo Y Rectangular Grid Tool Options Opções da Ferramenta Grade Retangular Polar Grid Tool Options Opções da Ferramenta Grade Polar Fill Line Preencher linha + Scale Presets Predefinições de escala +@msg a {width}×{height} screenshot's byte count overflows a contagem de bytes de uma captura {width}×{height} transborda +@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot o {what} da janela ({points} pontos × {scale} DPI) não pode dimensionar uma captura diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 54b6501e6..e17934792 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5162,3 +5162,6 @@ axis Y Axis Ось Y Rectangular Grid Tool Options Параметры инструмента «Прямоугольная сетка» Polar Grid Tool Options Параметры инструмента «Полярная сетка» Fill Line Заливка отрезка + Scale Presets Наборы масштаба +@msg a {width}×{height} screenshot's byte count overflows количество байтов снимка {width}×{height} переполняется +@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot {what} окна ({points} пунктов × {scale} DPI) не позволяет задать размер снимка diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index d7a8b748b..9274bd2ea 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5574,3 +5574,6 @@ axis Y Axis Y-axel Rectangular Grid Tool Options Alternativ för verktyget för rektangulärt rutnät Polar Grid Tool Options Alternativ för verktyget för polärt rutnät Fill Line Fyll linje + Scale Presets Skalningsförval +@msg a {width}×{height} screenshot's byte count overflows byteantalet för en skärmbild på {width}×{height} överstiger gränsen +@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot fönstrets {what} ({points} punkter × {scale} DPI) kan inte bestämma en skärmbilds storlek diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 9a444f72d..c8c65ed17 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -1239,8 +1239,9 @@ fn latin_script_glyphs_are_available_without_system_fonts() { let families: Vec<_> = fonts.definitions().families.iter().map(|(f, stack)| (f.clone(), stack.first().cloned())).collect(); for (family, first) in families { let first = first.unwrap(); - let mut font = fonts.fonts.font(&family); - let chars = font.characters(); + // Public 0.37 view: the family's characters with the fonts that have each (the old + // `fonts.font(...).characters()` reached a now-private field). + let chars = fonts.characters(&family); for ch in "áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽ„“‚‘…–ñÑüÜ¿¡”àèìòùÀÈÌÒÙ«»’âêîôûçëïÿœæÂÊÎÔÛÇËÏŒÆäöÄÖßőűŐŰąćęłńśźżĄĆĘŁŃŚŹŻåÅ\u{a0}".chars() { assert!(chars.get(&ch).is_some_and(|fonts| fonts.contains(&first)), "{first} ({family:?}) has no {ch}"); @@ -1261,8 +1262,9 @@ fn cyrillic_glyphs_are_available_without_system_fonts() { let families: Vec<_> = fonts.definitions().families.iter().map(|(f, stack)| (f.clone(), stack.first().cloned())).collect(); for (family, first) in families { let first = first.unwrap(); - let mut font = fonts.fonts.font(&family); - let chars = font.characters(); + // Public 0.37 view: the family's characters with the fonts that have each (the old + // `fonts.font(...).characters()` reached a now-private field). + let chars = fonts.characters(&family); for ch in "абвгдеёжзийклмнопрстуфхцчшщъыьэюяАБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯґєіїҐЄІЇ«»„“…–’".chars() { assert!(chars.get(&ch).is_some_and(|fonts| fonts.contains(&first)), "{first} ({family:?}) has no {ch}"); diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 7221608a7..c67d07d0b 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5513,3 +5513,6 @@ axis Y Axis Y ekseni Rectangular Grid Tool Options Dikdörtgen Izgara Aracı Seçenekleri Polar Grid Tool Options Yuvarlak Izgara Aracı Seçenekleri Fill Line Çizgiyi doldur + Scale Presets Ölçek ön ayarları +@msg a {width}×{height} screenshot's byte count overflows {width}×{height} ekran görüntüsünün bayt sayısı taşıyor +@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot pencerenin {what} ({points} nokta × {scale} DPI) bir ekran görüntüsünü boyutlandıramaz diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 222cf693b..5fabc566e 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5146,3 +5146,6 @@ axis Y Axis Вісь Y Rectangular Grid Tool Options Параметри інструменту «Прямокутна сітка» Polar Grid Tool Options Параметри інструменту «Полярна сітка» Fill Line Заливка відрізка + Scale Presets Набори масштабу +@msg a {width}×{height} screenshot's byte count overflows кількість байтів знімка {width}×{height} переповнюється +@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot {what} вікна ({points} пунктів × {scale} DPI) не дозволяє задати розмір знімка diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 823fbd4d1..8b6db7cc5 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5146,3 +5146,6 @@ axis Y Axis Trục Y Rectangular Grid Tool Options Tùy chọn công cụ Lưới chữ nhật Polar Grid Tool Options Tùy chọn công cụ Lưới cực Fill Line Tô đoạn thẳng + Scale Presets Cài đặt sẵn tỉ lệ +@msg a {width}×{height} screenshot's byte count overflows số byte của ảnh chụp {width}×{height} bị tràn +@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot {what} của cửa sổ ({points} điểm × {scale} DPI) không thể định cỡ ảnh chụp diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index e3bcc5feb..d1c861d91 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5389,3 +5389,6 @@ axis Y Axis Y 轴 Rectangular Grid Tool Options 矩形网格工具选项 Polar Grid Tool Options 极坐标网格工具选项 Fill Line 填充线段 + Scale Presets 缩放预设 +@msg a {width}×{height} screenshot's byte count overflows {width}×{height} 截图的字节数溢出 +@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot 窗口的{what}({points} 点 × {scale} DPI)无法确定截图大小 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index d4d036eef..754c92837 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5185,3 +5185,6 @@ axis Y Axis Y 軸 Rectangular Grid Tool Options 矩形格線工具選項 Polar Grid Tool Options 極座標格點工具選項 Fill Line 填色線段 + Scale Presets 縮放預設集 +@msg a {width}×{height} screenshot's byte count overflows {width}×{height} 螢幕擷取畫面的位元組數溢出 +@msg the window's {what} ({points} points × {scale} DPI) cannot size a screenshot 視窗的{what}({points} 點 × {scale} DPI)無法決定螢幕擷取畫面的大小 diff --git a/crates/ui-egui/src/ime.rs b/crates/ui-egui/src/ime.rs new file mode 100644 index 000000000..54d9fffc1 --- /dev/null +++ b/crates/ui-egui/src/ime.rs @@ -0,0 +1,799 @@ +//! VectorCraft's outgoing IME producer: the Type tool's committed logical text, its stable owner +//! and context generation, and the explicit `InputMethod`/`Other` cause. +//! +//! The shared [`egui::ime_context`] helper does the lifecycle bookkeeping. This module is the thin +//! app adapter the helper asks for: it names the edit target from the engine's document uid and +//! TypeTool edit session, copies only a bounded committed window (never the whole document), and +//! turns accepted IME mutations into receipts around the exact engine call that applied them. + +use egui::ime_context::{CommittedText, ImeContextUpdate, ImeMutationStamp, ImeObservation, ImeTarget}; +use egui::output::{IMEOutput, MAX_IME_SURROUNDING_TEXT_BYTES}; +use egui::{Context, Id}; +use vectorcraft_engine::{Session, ViewInfo}; +use vectorcraft_tools::ImeSurrounding; + +use crate::VectorcraftApp; + +/// The same byte limit the fork's [`egui::output::IMESurroundingText::from_text`] enforces. Asking +/// the engine for exactly this window avoids cloning a large document just to discard most of it. +pub(crate) const SURROUNDING_LIMIT: usize = MAX_IME_SURROUNDING_TEXT_BYTES; + +/// Incoming selection provenance, never part of the immutable native target identity. Use the +/// tool's actual offsets: the outgoing logical snapshot collapses offsets inside marked text. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct IncomingSelection { + target: ImeTarget, + anchor: u64, + caret: u64, +} + +#[derive(Clone, Copy, Debug)] +pub(crate) enum IncomingComposition { + Active(IncomingSelection), + /// An empty preedit cleared the preview; its separate terminal mutation is still pending. + TerminalPending(IncomingSelection), + Stale, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct OutputContext { + owner: Id, + generation: u64, + purpose: egui::IMEPurpose, +} + +impl From<&IMEOutput> for OutputContext { + fn from(output: &IMEOutput) -> Self { + Self { owner: output.owner, generation: output.context_generation, purpose: output.purpose } + } +} + +/// A guard can remain blocked without an acceptable token (suspension/allocation failure). +/// The destination is separate from the old composition whose preview was settled. +#[derive(Clone, Debug)] +pub(crate) struct ResetGuard { + request: Option, + context: u64, + destination: Option, + committed: Option, + output: Option, + suspended: bool, + deferred: bool, + /// Settled before the UI ran: this pass's token is retired and only the post-UI finalizer, + /// after the descriptor is confirmed, may bind the replacement. + pending: bool, +} + +fn mode_id(viewport: egui::ViewportId) -> Id { + Id::unique(("vectorcraft.ime.in-ui-pass", viewport)) +} +fn output_id(viewport: egui::ViewportId) -> Id { + Id::unique(("vectorcraft.ime.pass-output", viewport)) +} + +/// Install before the host's first pass. Callbacks borrow their own Ui's Context, never capture it. +pub(crate) fn install_hooks(ctx: &Context) { + let installed = Id::unique("vectorcraft.ime.mode-hooks-installed"); + let first = ctx.data_mut(|data| { + if data.get_temp::(installed).unwrap_or(false) { + false + } else { + data.insert_temp(installed, true); + true + } + }); + if !first { + return; + } + ctx.on_begin_pass( + "VectorCraft IME pass begin", + std::sync::Arc::new(|ui| { + let ctx = ui.ctx(); + let id = mode_id(ctx.viewport_id()); + ctx.data_mut(|data| data.insert_temp(id, true)); + }), + ); + ctx.on_end_pass( + "VectorCraft IME pass end", + std::sync::Arc::new(|ui| { + let ctx = ui.ctx(); + let id = mode_id(ctx.viewport_id()); + ctx.data_mut(|data| data.insert_temp(id, false)); + }), + ); +} + +/// The base eligibility every custom-descriptor consumer shares: a hidden viewport, the Home +/// screen, a modal dialog, the palette, a file sheet, an unfocused window, another egui keyboard +/// owner or no Type-tool text edit all mean this pass publishes a stock descriptor or none. One +/// helper so the pre-UI route test, the post-UI finalizer and the end-of-pass observation cannot +/// drift apart; Home in particular is an ineligible route even though the Type tool keeps its +/// target across it. +fn pass_eligible(app: &VectorcraftApp, ctx: &Context) -> bool { + !app.viewport_hidden(ctx) + && !crate::menus::home_showing(app) + && app.ui.dialog.is_none() + && !app.ui.palette_open + && !app.file_sheet_open() + && ctx.input(|input| input.focused) + && !ctx.egui_wants_keyboard_input() + && app.session.tool_wants_text() +} + +/// The one route test every allocation goes through: a request exists only when this pass can +/// publish the custom descriptor that consumes it. egui keeps the latest request in `Memory` until +/// such a descriptor arrives, so allocating on a hidden, Home, stock or geometry-less route would +/// attach a foreign token to whatever stock field publishes next. An ineligible route retires the +/// local guard instead; a later eligible activation allocates a fresh request. +fn route_live(app: &mut VectorcraftApp, ctx: &Context) -> bool { + if !pass_eligible(app, ctx) { + return false; + } + let view = app.view_info(); + target(&mut app.session, view).is_some() && app.session.tool_ime_caret(view).is_some() +} + +fn request_guard(app: &mut VectorcraftApp, ctx: &Context, deferred: bool) -> bool { + if !route_live(app, ctx) { + suspend(app, ctx); + return false; + } + let viewport = ctx.viewport_id(); + let destination = incoming_selection(app); + let view = app.view_info(); + let committed = stamp(&mut app.session, view); + let output = app.ime_resets.get(&viewport).and_then(|guard| guard.output); + let request = ctx.request_ime_reset(viewport); + #[cfg(test)] + note_test_request(ctx); + app.ime_resets.insert( + viewport, + ResetGuard { + request, + context: crate::graphics::context_tag(ctx), + destination, + committed, + output, + suspended: false, + deferred, + pending: false, + }, + ); + app.ime_compositions.insert(viewport, IncomingComposition::Stale); + true +} + +#[cfg(test)] +fn request_count_id() -> Id { + Id::unique("vectorcraft.ime.test-request-count") +} + +#[cfg(test)] +fn note_test_request(ctx: &Context) { + ctx.data_mut(|data| { + let count = data.get_temp::(request_count_id()).unwrap_or(0); + data.insert_temp(request_count_id(), count.saturating_add(1)); + }); +} + +/// Tests read how many guards were requested since the last call. This makes an intermediate +/// pre-settlement request visible even when the final token is stable. +#[cfg(test)] +pub(crate) fn take_request_count(ctx: &Context) -> u64 { + ctx.data_mut(|data| data.remove_temp::(request_count_id()).unwrap_or(0)) +} + +/// True only while a pointer/navigation/command notice is waiting for its real later handler. +/// Destination-dependent raw input must not mutate the old caret until that handler ran. +pub(crate) fn deferred(app: &VectorcraftApp, ctx: &Context) -> bool { + app.ime_resets.get(&ctx.viewport_id()).is_some_and(|guard| guard.deferred) +} + +/// Retire a live guard when this pass cannot publish the custom editor at all (the route lost +/// its target or caret geometry, or the viewport is hidden). This runs before any reset marker is +/// considered, so a marker for the old request cannot complete a route that no longer exists. +pub(crate) fn suspend_if_route_lost(app: &mut VectorcraftApp, ctx: &Context) { + let viewport = ctx.viewport_id(); + if !app.ime_resets.get(&viewport).is_some_and(|guard| !guard.suspended) { + return; + } + if !route_live(app, ctx) { + suspend(app, ctx); + } +} + +pub(crate) fn blocked(app: &VectorcraftApp, ctx: &Context) -> bool { + app.ime_resets.contains_key(&ctx.viewport_id()) +} + +#[cfg(test)] +pub(crate) fn pending_request(app: &VectorcraftApp, ctx: &Context) -> Option { + app.ime_resets.get(&ctx.viewport_id()).and_then(|guard| guard.request) +} + +/// A notice precedes a later pointer/navigation/command handler. Retire the old request and keep +/// its suffix closed until that handler has established the real destination. No request is +/// allocated here: the final handler may still invalidate the route (a click that ends editing, a +/// modal, a stock field), and a token allocated now would survive in egui's memory for the next +/// descriptor. The eligible finalizer allocates for the confirmed destination instead. +pub(crate) fn defer_interrupt(app: &mut VectorcraftApp, ctx: &Context) -> bool { + let live = route_live(app, ctx); + let was_blocked = blocked(app, ctx); + // Settle with the old suffix still fenced off, but never allocate: the raw suffix after this + // notice belongs to the old caret whether or not the final route is still a custom editor. + let settled = interrupt_incoming_impl(app, ctx, Fence::Deferred); + if settled || was_blocked { + // The finalizer owns the token, but this pass must keep a guard so the old marker cannot + // complete and so a confirmed final descriptor requests its replacement. A new guard here + // has no request: `needs_replacement` sees `deferred` and allocates later. + place_deferred_guard(app, ctx); + } + if !live { + // No custom editor owns this pass. Keep the guard retired until an eligible activation, + // but leave its stale latch in place so no old commit/preedit is accepted meanwhile. + if let Some(guard) = app.ime_resets.get_mut(&ctx.viewport_id()) { + guard.request = None; + guard.deferred = true; + guard.suspended = true; + } + } + settled || blocked(app, ctx) +} + +/// Retire the current request but keep the guard awaiting the finalizer's confirmed destination. +fn place_deferred_guard(app: &mut VectorcraftApp, ctx: &Context) { + let viewport = ctx.viewport_id(); + let destination = incoming_selection(app); + let view = app.view_info(); + let committed = stamp(&mut app.session, view); + match app.ime_resets.get_mut(&viewport) { + Some(guard) => { + guard.request = None; + guard.deferred = true; + guard.pending = false; + } + None => { + let output = None; + app.ime_resets.insert( + viewport, + ResetGuard { + request: None, + context: crate::graphics::context_tag(ctx), + destination, + committed, + output, + suspended: false, + deferred: true, + pending: false, + }, + ); + } + } + app.ime_compositions.insert(viewport, IncomingComposition::Stale); +} + +/// Pre-UI settlement. The old token is retired and the ordered latch is kept, but nothing is +/// allocated: the pass's final descriptor does not exist yet, so a token bound here could outlive +/// the route (a later palette/dialog/Home) in egui's memory. The post-UI finalizer, which confirms +/// the descriptor, binds the replacement for `pending`. +fn mark_pending(app: &mut VectorcraftApp, ctx: &Context) { + let viewport = ctx.viewport_id(); + let destination = incoming_selection(app); + let view = app.view_info(); + let committed = stamp(&mut app.session, view); + match app.ime_resets.get_mut(&viewport) { + Some(guard) => { + guard.request = None; + guard.deferred = false; + guard.pending = true; + } + None => { + app.ime_resets.insert( + viewport, + ResetGuard { + request: None, + context: crate::graphics::context_tag(ctx), + destination, + committed, + output: None, + suspended: false, + deferred: false, + pending: true, + }, + ); + } + } + app.ime_compositions.insert(viewport, IncomingComposition::Stale); +} + +fn suspend(app: &mut VectorcraftApp, ctx: &Context) { + if let Some(guard) = app.ime_resets.get_mut(&ctx.viewport_id()) { + guard.request = None; + guard.suspended = true; + guard.deferred = false; + guard.pending = false; + } +} + +/// A close request ends this viewport's IME route without publishing a custom descriptor. Settle +/// any preview without a token (a later stock field must not inherit one) and retire the guard so +/// a cancelled close rearms on a fresh generation. +pub(crate) fn close_viewport(app: &mut VectorcraftApp, ctx: &Context) { + let viewport = ctx.viewport_id(); + interrupt_incoming_impl(app, ctx, Fence::Unbound); + app.ime_compositions.remove(&viewport); + suspend(app, ctx); +} + +/// Only this ordered raw control can lift an interrupted guard. Call-return and old terminals +/// do not acknowledge it. A missing, suspended, deferred or pending guard is inert: in particular +/// this must not synchronize an active composition into a fresh guard after `controls_without_editor`. +pub(crate) fn complete(app: &mut VectorcraftApp, ctx: &Context, request: egui::ImeResetId) -> bool { + let viewport = ctx.viewport_id(); + if !app.ime_resets.get(&viewport).is_some_and(|guard| !guard.suspended && !guard.deferred && !guard.pending) { + return false; + } + // This runs before the UI: a changed composition settles `pending` and its finalizer, after + // the descriptor is confirmed, binds any replacement token. + sync_incoming_impl(app, ctx, Fence::Pending); + let destination = incoming_selection(app); + if !app.ime_resets.get(&viewport).is_some_and(|guard| { + !guard.suspended + && !guard.deferred + && !guard.pending + && guard.request == Some(request) + && guard.context == crate::graphics::context_tag(ctx) + && request.viewport_id() == viewport + && guard.destination == destination + }) { + return false; + } + app.ime_resets.remove(&viewport); + app.ime_compositions.remove(&viewport); + app.ime_marked = None; + if app.ime_resets.is_empty() { + app.ime_discard = false; + } + true +} + +/// This path owns controls only when the custom editor cannot process ANY raw mutations. +/// Retire its activation before walking controls; leave the stock field's text/keys intact. +pub(crate) fn controls_without_editor(app: &mut VectorcraftApp, ctx: &Context) { + suspend(app, ctx); + let controls = ctx.input_mut(|input| { + let mut controls = Vec::new(); + input.events.retain(|event| { + if let egui::Event::ImeResetCompleted { request_id } = event { + controls.push(*request_id); + false + } else { + true + } + }); + controls + }); + for request in controls { + complete(app, ctx, request); + } +} + +pub(crate) fn begin_ui(app: &mut VectorcraftApp, ctx: &Context) { + app.ime_caret_rect = None; + let id = output_id(ctx.viewport_id()); + ctx.data_mut(|data| data.remove::<(u64, OutputContext)>(id)); +} + +/// Every UI exit, including discarded/stock/no-geometry output, observes this pass's descriptor. +/// Losing publication retires R1; reactivation allocates R2 even for the same model/caret. +pub(crate) fn finish_ui(app: &mut VectorcraftApp, ctx: &Context) { + let viewport = ctx.viewport_id(); + let custom = ctx + .data(|data| data.get_temp::<(u64, OutputContext)>(output_id(viewport))) + .filter(|(pass, _)| *pass == ctx.cumulative_pass_nr_for(viewport)) + .map(|(_, key)| key); + if !eligible(app, ctx, custom) { + suspend(app, ctx); + return; + } + // Only an eligible custom route may settle an active composition: doing this for a stock + // field could allocate a request that core would attach to that field's descriptor. + sync_incoming(app, ctx); + let actual = ctx.output(|output| output.ime.as_ref().map(OutputContext::from)); + if !eligible_with(app, ctx, custom, actual) { + suspend(app, ctx); + return; + } + let destination = incoming_selection(app); + let Some(custom) = custom else { + suspend(app, ctx); + return; + }; + let replacement = app.ime_resets.get(&viewport).is_some_and(|guard| needs_replacement(guard, destination, custom)); + if replacement && !request_guard(app, ctx, false) { + return; + } + if let Some(guard) = app.ime_resets.get_mut(&viewport) { + guard.output = Some(custom); + } +} + +fn eligible(app: &VectorcraftApp, ctx: &Context, custom: Option) -> bool { + let actual = ctx.output(|output| output.ime.as_ref().map(OutputContext::from)); + eligible_with(app, ctx, custom, actual) +} + +fn eligible_with(app: &VectorcraftApp, ctx: &Context, custom: Option, actual: Option) -> bool { + custom.is_some() && actual == custom && pass_eligible(app, ctx) +} + +fn needs_replacement(guard: &ResetGuard, destination: Option, custom: OutputContext) -> bool { + guard.suspended || guard.deferred || guard.pending || guard.destination != destination || guard.output.is_some_and(|previous| previous != custom) +} + +/// A full pass calls logic before UI; absence at that intermediate point is not publication +/// loss. Standalone run_logic has no fresh geometry and must retire a pending activation once. +pub(crate) fn finish_logic(app: &mut VectorcraftApp, ctx: &Context) { + let id = mode_id(ctx.viewport_id()); + if !ctx.data(|data| data.get_temp::(id).unwrap_or(false)) { + // A logic-only iteration has no fresh custom descriptor: settle a changed preview without + // ever allocating a request (in particular for a hidden window with an external edit). + sync_incoming_unbound(app, ctx); + suspend(app, ctx); + } +} + +fn incoming_selection(app: &mut VectorcraftApp) -> Option { + let view = app.view_info(); + let target = target(&mut app.session, view)?; + let options = app.session.tool_options(); + Some(IncomingSelection { target, anchor: options.get("anchor")?.as_u64()?, caret: options.get("caret")?.as_u64()? }) +} + +/// Record a successfully accepted preedit. Accepted IME deletes refresh this selection without +/// invalidating the commit that belongs to them. +pub(crate) fn remember_incoming(app: &mut VectorcraftApp, ctx: &Context, preedit_empty: Option) { + let viewport = ctx.viewport_id(); + let Some(selection) = incoming_selection(app) else { return }; + let next = match (preedit_empty, app.ime_compositions.get(&viewport)) { + (Some(false), _) | (None, Some(IncomingComposition::Active(_))) => IncomingComposition::Active(selection), + (Some(true), _) | (None, Some(IncomingComposition::TerminalPending(_))) => IncomingComposition::TerminalPending(selection), + _ => return, + }; + app.ime_compositions.insert(viewport, next); +} + +/// How an incoming-composition settlement relates to a reset request. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Fence { + /// Settle and allocate a request for this pass's custom descriptor. + Bound, + /// Settle and close the raw suffix, but leave the token to the finalizer that confirms the + /// destination after the real pointer/navigation/command handler ran. + Deferred, + /// Settle before the UI without allocating or claiming a deferred notice: the ordered latch + /// stays closed, text routing is unchanged, and the post-UI finalizer binds the replacement. + Pending, + /// Settle without a request: no custom editor owns this pass (hidden, stock, no geometry). + Unbound, +} + +/// Keep already-previewed marked text at its original position, without reinserting it through +/// `tool_text`: the caret or document may already have changed. Settle with no request on a route +/// that has no custom editor (a stock field, a hidden window); core could attach such a token to +/// the stock descriptor. +fn interrupt_incoming_unbound(app: &mut VectorcraftApp, ctx: &Context) { + interrupt_incoming_impl(app, ctx, Fence::Unbound); +} + +/// Returns whether an active/terminal preview was settled. +fn interrupt_incoming_impl(app: &mut VectorcraftApp, ctx: &Context, fence: Fence) -> bool { + let viewport = ctx.viewport_id(); + let previous = app.ime_compositions.get(&viewport).copied(); + let Some(IncomingComposition::Active(expected) | IncomingComposition::TerminalPending(expected)) = previous else { return false }; + if fence == Fence::Unbound { + // No fence will be bound on this route, so leave no Stale latch that only a marker could + // clear. The composition is settled and the adapter returns to Clear. + app.ime_compositions.remove(&viewport); + } else { + app.ime_compositions.insert(viewport, IncomingComposition::Stale); + } + app.ime_marked = None; + app.ime_discard = true; + let view = app.view_info(); + if app.session.tool_composing() && target(&mut app.session, view) == Some(expected.target) { + match app.session.commit_interaction() { + Ok(()) => app.session.set_tool_option("commitTyping", &serde_json::Value::Null), + Err(error) => app.status(error.to_string()), + } + } + match fence { + Fence::Bound => { + // Ask for the fence only after settlement: the guard's destination and committed stamp + // are the post-commit ones, so an eligible pass does not immediately supersede it. + // Refusal (the route became ineligible) retires the local guard and its latch. + if !request_guard(app, ctx, false) { + app.ime_compositions.remove(&viewport); + } + } + Fence::Deferred => { + if let Some(guard) = app.ime_resets.get_mut(&viewport) { + guard.request = None; + guard.deferred = true; + guard.pending = false; + } + } + Fence::Pending => mark_pending(app, ctx), + Fence::Unbound => {} + } + true +} + +/// The finalizer form: check application changes after the canvas/panels ran. A live preview +/// ending on its own is an interruption even if the caret happened to land at its old offset. No +/// prior composition means a direct commit remains legitimate. Only a post-UI call may bind. +pub(crate) fn sync_incoming(app: &mut VectorcraftApp, ctx: &Context) { + sync_incoming_impl(app, ctx, Fence::Bound); +} + +/// Pre-UI form: the pass's descriptor does not exist yet, so a changed composition or guard is +/// settled `pending` and the post-UI finalizer binds the replacement after confirming it. +pub(crate) fn sync_incoming_before_ui(app: &mut VectorcraftApp, ctx: &Context) { + sync_incoming_impl(app, ctx, Fence::Pending); +} + +/// Settlement for a pass that publishes no custom descriptor (standalone hidden logic). It settles +/// an interrupted preview without allocating a token that a later stock field could consume. +fn sync_incoming_unbound(app: &mut VectorcraftApp, ctx: &Context) { + sync_incoming_impl(app, ctx, Fence::Unbound); +} + +fn sync_incoming_impl(app: &mut VectorcraftApp, ctx: &Context, fence: Fence) { + let previous = app.ime_compositions.get(&ctx.viewport_id()).copied(); + let expected = match previous { + Some(IncomingComposition::Active(selection) | IncomingComposition::TerminalPending(selection)) => selection, + _ => { + if !blocked(app, ctx) { + return; + } + let selection = incoming_selection(app); + let view = app.view_info(); + let committed = stamp(&mut app.session, view); + if app.ime_resets.get(&ctx.viewport_id()).is_some_and(|guard| { + !guard.suspended + && !guard.deferred + && (guard.pending + || guard.destination != selection + || guard.committed != committed + || guard.context != crate::graphics::context_tag(ctx)) + }) { + match fence { + Fence::Bound => { + request_guard(app, ctx, false); + } + Fence::Pending => mark_pending(app, ctx), + Fence::Deferred | Fence::Unbound => suspend(app, ctx), + } + } + return; + } + }; + let view = app.view_info(); + if incoming_selection(app) != Some(expected) + || matches!(previous, Some(IncomingComposition::Active(_))) && !app.session.tool_composing() + || !app.session.tool_ime_session_matches(view) + { + interrupt_incoming_impl(app, ctx, fence); + } +} + +/// Stable identity of the Type tool's current edit target. +/// +/// The owner is the document uid plus the edited text node; the replacement epoch is the Type +/// tool's edit session, so leaving and re-entering the same text object starts a fresh IME +/// context while ordinary typing, selection and caret moves keep the old one. +pub(crate) fn target(session: &mut Session, view: ViewInfo) -> Option { + session.tool_ime_target(view).map(|(document, node, session)| ImeTarget::new(Id::unique(("vectorcraft-type-ime", document, node)), session)) +} + +/// The bounded committed text and UTF-8 offsets, or `None` for an explicit unavailability reason. +pub(crate) fn committed(session: &mut Session, view: ViewInfo) -> Option<(String, usize, usize)> { + match session.tool_ime_surrounding(SURROUNDING_LIMIT, view) { + ImeSurrounding::Available { text, cursor, anchor } => Some((text, cursor, anchor)), + ImeSurrounding::Unavailable(_) => None, + } +} + +/// A receipt stamp of the current committed context, for a successful mutation's boundary. +/// +/// `None` means there is no edit target at all. An explicit unavailable query (a selection wider +/// than the limit, invalid offsets, or a typing session the document changed under) is still a +/// stamp: [`note_ime_apply`] refuses to earn a receipt from an unavailable boundary, so the +/// mutation stays [`egui::output::ImeChangeCause::Other`]. +pub(crate) fn stamp(session: &mut Session, view: ViewInfo) -> Option { + target(session, view)?; + let revision = session.active().map_or(0, |document| document.revision); + Some(match committed(session, view) { + Some((text, cursor, anchor)) => ImeMutationStamp::from_committed(&text, cursor, anchor, None), + // Unavailable stamps need a producer revision to order two boundaries: without one, + // before and after compare equal and an application edit cannot be distinguished from + // an unchanged unavailable snapshot. + None => ImeMutationStamp::unavailable_at_revision(revision), + }) +} + +/// Record an accepted IME commit/delete after it succeeded, comparing the committed context +/// immediately before and after the engine call. +/// +/// A stale target, a no-op (`before == after`), or an unavailable receipt earns nothing: the +/// tracker then reports `Other` for any snapshot change it cannot prove came from the IME. +pub(crate) fn note_ime_apply( + app: &mut VectorcraftApp, + ctx: &Context, + view: ViewInfo, + target_before: Option, + before: Option, +) { + let (Some(target_before), Some(before)) = (target_before, before) else { return }; + if target(&mut app.session, view) != Some(target_before) { + return; + } + let Some(after) = stamp(&mut app.session, view) else { return }; + if before == after { + return; + } + // A receipt must describe the committed text on both sides of the mutation. An unavailable + // snapshot (an external edit left the typing session stale, or a selection is wider than the + // limit) cannot prove what the IME changed, so such a mutation stays `Other`. + if before.surrounding_text().available().is_none() || after.surrounding_text().available().is_none() { + return; + } + app.ime_context.note_input_method_apply(ctx.viewport_id(), ctx.cumulative_frame_nr_for(ctx.viewport_id()), target_before, before, after); +} + +/// Record an application-origin text mutation (typing, paste, Backspace/Delete, a menu edit, a +/// correction). `Other` then dominates the whole frame, including any IME receipt in it. +/// +/// `before == after` is a no-op and is not reported. A changed target (editing ended) is left to +/// the normal context-transition rule. +pub(crate) fn note_application_edit( + app: &mut VectorcraftApp, + ctx: &Context, + view: ViewInfo, + target_before: Option, + before: Option, +) { + let (Some(target_before), Some(before)) = (target_before, before) else { return }; + if target(&mut app.session, view) != Some(target_before) { + return; + } + let Some(after) = stamp(&mut app.session, view) else { return }; + if before == after { + return; + } + app.ime_context.note_application_change(ctx.viewport_id(), ctx.cumulative_frame_nr_for(ctx.viewport_id()), target_before); +} + +/// Observe the Type tool once, near the end of this viewport's pass. +/// +/// `focused` is the canvas's own decision (the Type tool wants text, no other egui field owns the +/// keyboard, and no dialog or palette covers it). Pass `false` for a hidden/absent owner. The +/// tracker gets `cumulative_frame_nr_for` and `cumulative_pass_nr_for` separately, so cause +/// accumulates across a frame's discarded passes while an unchanged second pass keeps its cause. +pub(crate) fn observe(app: &mut VectorcraftApp, ctx: &Context, focused: bool) -> Option { + let viewport = ctx.viewport_id(); + if !focused { + return app.ime_context.observe(viewport, None); + } + let view = app.view_info(); + let Some(target) = target(&mut app.session, view) else { + return app.ime_context.observe(viewport, None); + }; + let committed = committed(&mut app.session, view); + let revision = app.session.active().map_or(0, |document| document.revision); + let observation = ImeObservation::new(ctx.cumulative_frame_nr_for(viewport), ctx.cumulative_pass_nr_for(viewport), target) + .focused(true) + .purpose(egui::IMEPurpose::Normal) + .committed(committed.as_ref().map(|(text, cursor, anchor)| CommittedText::new(text, *cursor, *anchor))) + // The helper keeps this only for an unavailable snapshot; available text still compares + // its exact bounded contents and selection. + .unavailable_revision(revision); + app.ime_context.observe(viewport, Some(observation)) +} + +/// Mark this viewport's IME owner absent for a pass (no document, Home, a hidden canvas). +pub(crate) fn observe_none(app: &mut VectorcraftApp, ctx: &Context) { + app.ime_context.observe(ctx.viewport_id(), None); +} + +/// Observe and write the outgoing descriptor once, at the end of the pass. +/// +/// The canvas records only the caret geometry (`VectorcraftApp::ime_caret_rect`) while it draws. +/// By the time this runs, every dialog, floating panel, progress window and the palette has drawn +/// and ran its commands, so the owner, focused state, committed text and cause are the ones of the +/// latest completed pass. A dialog's own `TextEdit` descriptor is left alone: writing `None` here +/// would clobber it. +pub(crate) fn end_of_pass(app: &mut VectorcraftApp, ctx: &Context) { + let ours = app.ime_caret_rect.is_some() && pass_eligible(app, ctx); + if !ours { + // No custom editor owns the IME this pass (Home, no document, a dialog, a palette, a hidden + // window). Mark the context absent and let marked text stay as typed, as a click away does. + // Do not synchronize or allocate here: the stock descriptor must carry no reset token. + observe_none(app, ctx); + interrupt_incoming_unbound(app, ctx); + crate::shortcuts::keep_marked_text(app); + return; + } + sync_incoming(app, ctx); + let Some((rect, cursor_rect)) = app.ime_caret_rect else { return }; + // The tool ended the composition itself: the IME must drop what it still has marked. + let mut interrupt = app.ime_marked.is_some() && !app.session.tool_composing(); + if interrupt { + app.ime_marked = None; + app.ime_discard = true; + // The tool ended a composition that `sync_incoming` did not already settle (for example a + // pre-edit without recorded geometry). Publish a request-bound interruption like every + // other path, rather than an untokened `should_interrupt_composition`. If the route became + // ineligible after `ours` was computed, publish no interruption without its fence. + if !request_guard(app, ctx, false) { + interrupt = false; + } + } + let update = observe(app, ctx, true); + write_output(ctx, update, rect, cursor_rect, interrupt); +} + +/// Write every field of the descriptor the shared helper returned. Geometry stays the actual +/// caret rectangle; cause and surrounding text come from the tracker, never from a placeholder. +pub(crate) fn write_output(ctx: &Context, update: Option, rect: egui::Rect, cursor_rect: egui::Rect, interrupt: bool) { + let Some(update) = update else { + ctx.output_mut(|o| o.ime = None); + return; + }; + ctx.output_mut(|o| { + o.ime = Some(IMEOutput { + owner: update.owner, + context_generation: update.context_generation, + purpose: update.purpose, + rect, + cursor_rect, + should_interrupt_composition: interrupt, + reset_request: None, + change_cause: update.change_cause, + surrounding_text: update.surrounding_text, + }); + }); + if let Some(key) = ctx.output(|output| output.ime.as_ref().map(OutputContext::from)) { + let id = output_id(ctx.viewport_id()); + let pass = ctx.cumulative_pass_nr(); + ctx.data_mut(|data| data.insert_temp(id, (pass, key))); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn same_owner_new_generation_or_purpose_requires_a_replacement_request() { + let owner = Id::unique("vectorcraft-ime-output-context-test"); + let previous = OutputContext { owner, generation: 7, purpose: egui::IMEPurpose::Normal }; + let guard = ResetGuard { + request: None, + context: 1, + destination: None, + committed: None, + output: Some(previous), + suspended: false, + deferred: false, + pending: false, + }; + let newer_generation = OutputContext { generation: 8, ..previous }; + assert_ne!(previous, newer_generation, "the same owner in a new context generation is a new output"); + assert!(needs_replacement(&guard, None, newer_generation)); + let other_purpose = OutputContext { purpose: egui::IMEPurpose::Password, ..previous }; + assert_ne!(previous, other_purpose, "the same owner and generation with another purpose is a new output"); + assert!(needs_replacement(&guard, None, other_purpose)); + assert!(!needs_replacement(&guard, None, previous), "an unchanged output keeps its request"); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 93ae627b5..3dbfa4a1a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -35,6 +35,7 @@ pub mod graphics; pub mod i18n; pub mod icon_data; pub mod icons; +pub(crate) mod ime; pub mod io; pub mod keymap; pub mod menus; @@ -100,6 +101,8 @@ mod tests_home; #[cfg(test)] mod tests_labels; #[cfg(test)] +mod tests_lifecycle; +#[cfg(test)] mod tests_nativemenu; #[cfg(test)] mod tests_nativeoptions; @@ -146,6 +149,8 @@ mod tests_saveext; #[cfg(test)] mod tests_screenmode; #[cfg(test)] +mod tests_screenshots; +#[cfg(test)] mod tests_script_check; #[cfg(test)] mod tests_scrub; @@ -172,6 +177,7 @@ mod tests_transparencygrid; #[cfg(test)] mod tests_widthtool; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{Receiver, Sender}; use std::sync::{Arc, Mutex}; @@ -463,8 +469,11 @@ pub struct VectorcraftApp { control_rx: Option>, /// Window captures asked for and not answered yet. pending_screenshots: Vec, - queued_screenshots: Vec<(u64, f64, u32)>, + /// Admitted captures whose settled frame has not come yet (issued one at a time). + queued_screenshots: Vec, screenshot_token: u64, + /// Bytes reserved by admitted captures (checked `width * height * 4`). + screenshot_reserved: usize, /// Synthetic input events (from the control channel) injected one step per frame. pub synthetic: Vec, styled: bool, @@ -472,9 +481,11 @@ pub struct VectorcraftApp { /// The egui context the UI's textures were uploaded to (0: none yet; see /// [`Self::adopt_context`]). context: u64, + /// The current context, for the focus part of an injected batch's generation (set by + /// [`Self::adopt_context`]). + ui_ctx: Option, /// Installed fonts added to the UI's for characters its own fonts lack (CJK names…). ui_fonts: ui_fonts::UiFonts, - frame: u64, /// The status bar's frame rate ([`frame_rate`]). frame_rate: frame_rate::FrameRate, /// Canvas rect of the last frame (screen points), for control-channel coordinate mapping. @@ -506,6 +517,10 @@ pub struct VectorcraftApp { /// (SVG without a `system_clipboard`) while the internal clipboard is empty. It enables the /// Paste menu items. pub(crate) system_paste: bool, + /// The modifiers held when an egui `PasteImage` asked the rich system clipboard to be read. + /// Consumed once by the next `tick`; storing the paste-time chord keeps a later pass's stale + /// modifiers from choosing Paste in Place instead of Paste (or the reverse). + pub(crate) clipboard_image_request: Option, /// When `system_paste` was last checked in line (app time, s; at most once per frame). system_paste_at: f64, /// The thread that checks the system clipboard for `system_paste` instead @@ -543,6 +558,18 @@ pub struct VectorcraftApp { /// The marked text the system IME last sent (`None` once it commits or clears). When the Type /// tool stops composing on its own (a click, a tool switch), the IME is told to drop it. pub(crate) ime_marked: Option, + /// Owner/generation/change-cause bookkeeping for the outgoing IME surrounding-text producer + /// (one tracker serves every viewport; see the crate's IME producer module). + pub(crate) ime_context: egui::ime_context::ImeContextTracker, + /// Incoming composition ownership is separate from outgoing receipt/generation bookkeeping. + /// Interrupted input stays stale until the exact request-bound native fence arrives. + pub(crate) ime_compositions: egui::ViewportIdMap, + pub(crate) ime_resets: egui::ViewportIdMap, + /// The Type tool's IME caret geometry for this pass, recorded by the canvas. The descriptor + /// itself is written at the end of the pass ([`ime::end_of_pass`]), after dialogs, floating + /// panels and the palette, because any of them can move the owner, edit the text or cover the + /// canvas after the canvas drew. + pub(crate) ime_caret_rect: Option<(egui::Rect, egui::Rect)>, /// The IME must drop its marked text (see [`Self::take_ime_discard`]). pub(crate) ime_discard: bool, /// Preferences were confirmed (see [`Self::take_prefs_changed`]). @@ -564,20 +591,128 @@ pub struct VectorcraftApp { /// The pen end the canvas saw last and the tool its eraser end stands in for /// ([`stylus::sync_eraser_tool`]). pub(crate) pen_end: stylus::PenEnd, + /// Host iterations (full or logic-only), advanced once in [`Self::begin_host_iteration`]. + pub(crate) host_iteration: u64, + /// A test's clock for [`Self::tick`], replacing the monotonic [`now_secs`] so idle scheduling + /// can be driven one simulated frame at a time (production always leaves this `None`). + #[cfg(test)] + pub(crate) now_override: Option, + /// The host iteration [`Self::tick`] ran in (`None`: never): once per iteration, never per pass. + tick_iteration: Option, + /// The context, viewport and frame whose UI-phase input was already taken in this host + /// iteration: a discarded rerun and a second viewport pass must not replay a frame's events. + input_frames: Vec, + /// The context, viewport and frame `shortcuts::handle` already took in this host iteration. + /// Interleaving root and auxiliary viewport passes cannot make an older frame look new again. + pub(crate) shortcuts_frames: Vec, + /// A synthetic batch is in the host's raw input and no settled UI pass has taken it yet. + synthetic_injected: bool, + /// The exact events of [`Self::synthetic_injected`], to remove them if the state they belong + /// to changed before a UI pass could take them. + injected_events: Vec, + /// The document, dialog and focus an injected batch belongs to. + injected_generation: InputGeneration, + /// The end-of-pass observer was installed on the context. + injected_settle_hook: bool, + /// A settled UI pass consumed the injected batch (read by the next host iteration). + injected_settled: Arc, + /// A change of the window's content geometry (resize, DPI, zoom or surface), even back again. + geometry_epoch: u64, + /// The geometry stamp [`Self::geometry_epoch`] was last advanced for. + geometry_stamp: Option, +} + +/// One UI pass key: the context identity, the viewport and that viewport's cumulative frame. +pub(crate) type InputFrame = (u64, egui::ViewportId, u64); + +/// Record `frame` unless this host iteration already took it. A discarded rerun and any +/// interleaved pass order stay exact-once because the key, not the last-seen slot, is compared. +/// The list is cleared at every host-iteration boundary, so it stays as small as that iteration's +/// pass count. +pub(crate) fn take_frame_once(frames: &mut Vec, frame: InputFrame) -> bool { + if frames.contains(&frame) { + return false; + } + frames.push(frame); + true +} + +/// The document, dialog and focus an injected batch belongs to: if any changed before a UI pass +/// took the batch, the clicks or text no longer belong to the new state and are dropped. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct InputGeneration { + document: u64, + dialog: Option, + focus: Option, +} + +/// The window geometry a capture belongs to (logical and screen bounds, DPI and zoom). +#[derive(Clone, Copy, Debug, PartialEq)] +struct GeometryStamp { + viewport: egui::ViewportId, + content: egui::Rect, + inner: Option, + pixels_per_point: f32, + zoom: f32, +} + +/// What the issued capture must still match when it completes. +#[derive(Clone, Copy, Debug)] +struct CaptureEpoch { + geometry: u64, + viewport: egui::ViewportId, + /// The active document (uid, revision) at issuance: a screenshot never answers with pixels of + /// a document, undo state or selection that replaced it while the capture was in flight. + document: Option<(u64, u64)>, + /// The image size the issuing geometry predicts (`width * height`, checked). + width: usize, + height: usize, +} + +/// An admitted capture waiting for its settled frame (`frames`) and animation delay (`due_ms`). +struct QueuedScreenshot { + id: u64, + due_ms: f64, + frames: u32, } /// A window capture asked for through the control channel (`ui.screenshot`), answered once a /// frame is presented; a window that isn't presented never delivers one, hence the deadline. struct PendingScreenshot { - token: u64, + id: u64, path: Option, /// Send the PNG back as `pngBase64`. data: bool, reply: Sender, - /// When to give up (ms, as [`now_ms`]). - deadline: f64, + /// When to give up (ms, as [`now_ms`], monotonic). + deadline_ms: f64, + /// The checked bytes this request reserved before it was admitted. + reserved_bytes: usize, + /// The active document at admission: switching documents before issuance fails the request + /// rather than capturing a different document. + document: Option<(u64, u64)>, + /// The live one-shot completion slot while the command is outstanding. + slot: Option, + /// The geometry and expected image size at issuance. + issued: Option, + /// Fresh slots made after a geometry change (a resize while the capture was in flight). + attempts: u8, } +/// At most this many captures admitted (pending or queued) at once. +const MAX_PENDING_SCREENSHOTS: usize = 4; +/// The largest single capture admitted (128 MiB: a 4K viewport at 2x). +const MAX_SCREENSHOT_BYTES: usize = 128 * 1024 * 1024; +/// The aggregate budget of admitted captures (256 MiB). +const MAX_SCREENSHOT_TOTAL_BYTES: usize = 256 * 1024 * 1024; +/// Settle frames a capture waits for after a control request (and the animation time). +const SCREENSHOT_SETTLE_FRAMES: u32 = 3; +/// How long after settling an issued capture may take (ms). +const SCREENSHOT_TIMEOUT_MS: f64 = 8000.0; +/// The timeout's answer: no file, no second reply. +const SCREENSHOT_TIMEOUT_ERROR: &str = + "no frame was presented (screen locked, window minimized or fully covered); ui.render still renders the artboard"; + /// Seconds between two looks at the system clipboard for [`VectorcraftApp::system_paste`]. const SYSTEM_CLIPBOARD_POLL: f64 = 0.25; /// Seconds between looks for fonts a font manager activated while the app stays in front (#2576). @@ -640,12 +775,13 @@ impl VectorcraftApp { pending_screenshots: vec![], queued_screenshots: vec![], screenshot_token: 0, + screenshot_reserved: 0, synthetic: vec![], styled: false, fonts_ready: false, context: 0, + ui_ctx: None, ui_fonts: Default::default(), - frame: 0, frame_rate: Default::default(), canvas_rect: None, hover_doc: None, @@ -654,6 +790,7 @@ impl VectorcraftApp { graphics_adapter: None, place: Default::default(), system_paste: false, + clipboard_image_request: None, system_paste_at: f64::NEG_INFINITY, clipboard_probe: None, picks: picks::Picks::default(), @@ -672,6 +809,10 @@ impl VectorcraftApp { host_modifiers: Default::default(), synthetic_modifiers: false, ime_marked: None, + ime_context: Default::default(), + ime_compositions: Default::default(), + ime_resets: Default::default(), + ime_caret_rect: None, ime_discard: false, prefs_changed: false, touch_screen: false, @@ -680,6 +821,19 @@ impl VectorcraftApp { automation_roots: None, synthetic_frame: false, pen_end: Default::default(), + host_iteration: 0, + #[cfg(test)] + now_override: None, + tick_iteration: None, + input_frames: vec![], + shortcuts_frames: vec![], + synthetic_injected: false, + injected_events: vec![], + injected_generation: InputGeneration::default(), + injected_settle_hook: false, + injected_settled: Default::default(), + geometry_epoch: 0, + geometry_stamp: None, } } @@ -822,6 +976,12 @@ impl VectorcraftApp { } } + /// The host iteration count: one increment per `App::logic`/`App::ui` iteration (or a + /// logic-only one), for panel state that asks "was this drawn in the last iteration?". + pub(crate) fn frame_iteration(&self) -> u64 { + self.host_iteration + } + /// The canvas on screen in document coordinates (none before it is laid out). pub fn screen_frame(&self) -> Option { let (rect, view) = (self.canvas_rect?, self.view()?); @@ -908,7 +1068,19 @@ impl VectorcraftApp { if fileio::SaveMode::of(id).is_some() { io::remember_view(self); } + // A command that changes the edited text is an application edit (undo, a panel or menu + // edit, a control request). Capture the bounded committed context around it so `Other` + // dominates the frame even if an IME receipt follows. Selection/view commands that leave + // the committed text and caret unchanged record nothing. + let ime_view = self.view_info(); + let ime_target = crate::ime::target(&mut self.session, ime_view); + let ime_before = crate::ime::stamp(&mut self.session, ime_view); let r = self.session.execute(id, ¶ms).map_err(|e| e.to_string()); + if r.is_ok() + && let Some(ctx) = self.ui_ctx.clone() + { + crate::ime::note_application_edit(self, &ctx, ime_view, ime_target, ime_before); + } if r.is_ok() && matches!(id, "edit.copy" | "edit.cut") { self.publish_clipboard(); } @@ -1058,11 +1230,59 @@ impl VectorcraftApp { let _ = reply.send(v); } control::Outcome::Screenshot { path, data } => { - self.screenshot_token += 1; - let token = self.screenshot_token; - let settle = ctx.global_style().animation_time as f64 * 2000.0 + 80.0; - self.queued_screenshots.push((token, now_ms() + settle, 0)); - self.pending_screenshots.push(PendingScreenshot { token, path, data, reply, deadline: now_ms() + settle + 8000.0 }); + // Admit with the shared ceilings (4 requests, 128 MiB each, 256 MiB together) + // before issue, reserving the checked bytes this viewport would need. + match self.screenshot_pixels(ctx) { + Ok((width, height, bytes)) if bytes > MAX_SCREENSHOT_BYTES => { + let _ = reply.send(json!({"ok": false, "error": format!( + "screenshot would use {bytes} bytes ({width}×{height}), over the {MAX_SCREENSHOT_BYTES}-byte limit" + )})); + } + Ok((width, height, bytes)) if data && path.is_none() && bytes > control::MAX_SCREENSHOT_PNG_REPLY_BYTES => { + let _ = reply.send(json!({"ok": false, "error": format!( + "a PNG reply of this {width}×{height} screenshot ({bytes} bytes of pixels) would exceed the {}-byte reply-encoding limit; write it to a path instead", + control::MAX_SCREENSHOT_PNG_REPLY_BYTES + )})); + } + Ok((_, _, _)) if self.pending_screenshots.len() >= MAX_PENDING_SCREENSHOTS => { + let _ = reply.send(json!({"ok": false, "error": format!( + "already {MAX_PENDING_SCREENSHOTS} screenshots are waiting; wait for one to finish" + )})); + } + Ok((width, height, bytes)) if self.screenshot_reserved.saturating_add(bytes) > MAX_SCREENSHOT_TOTAL_BYTES => { + let _ = reply.send(json!({ + "ok": false, + "error": format!( + "screenshots already reserved {} bytes; {bytes} more ({width}×{height}) would pass the {MAX_SCREENSHOT_TOTAL_BYTES}-byte limit", + self.screenshot_reserved + ) + })); + } + Ok((_, _, bytes)) => { + self.screenshot_token += 1; + let id = self.screenshot_token; + let settle = ctx.global_style().animation_time as f64 * 2000.0 + 80.0; + let due_ms = now_ms() + settle; + self.screenshot_reserved = self.screenshot_reserved.saturating_add(bytes); + self.queued_screenshots.push(QueuedScreenshot { id, due_ms, frames: 0 }); + let document = self.document_identity(); + self.pending_screenshots.push(PendingScreenshot { + id, + path, + data, + reply, + deadline_ms: due_ms + SCREENSHOT_TIMEOUT_MS, + reserved_bytes: bytes, + document, + slot: None, + issued: None, + attempts: 0, + }); + } + Err(e) => { + let _ = reply.send(json!({"ok": false, "error": e})); + } + } } } } @@ -1073,57 +1293,181 @@ impl VectorcraftApp { self.control_rx = Some(rx); } - fn issue_screenshots(&mut self, ctx: &egui::Context) { - let now = now_ms(); - self.queued_screenshots.retain_mut(|(token, at, frames)| { - *frames += 1; - if now >= *at && *frames >= 3 { - ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(*token))); - false - } else { - true - } - }); - if !self.queued_screenshots.is_empty() || !self.pending_screenshots.is_empty() { - ctx.request_repaint_after(std::time::Duration::from_millis(16)); - } + /// The active document's identity: its process-unique uid and revision. A capture records it + /// at admission and issuance and refuses to answer with a different document or undo state. + fn document_identity(&self) -> Option<(u64, u64)> { + self.session.active().map(|document| (document.uid, document.revision)) } + /// The image size (pixels) and checked byte count a capture of the current viewport would + /// have: the window's content rect times the current DPI/zoom. The viewport's own `inner_rect` + /// is the fresh source; `content_rect` is only a fallback for hosts that do not report one + /// (Wayland, headless), and a logic-only pass leaves it from the last shown frame. Issuance in + /// a real UI pass recomputes this and re-checks the budget, so a stale admission size cannot + /// let an oversized image through. + fn screenshot_pixels(&self, ctx: &egui::Context) -> Result<(usize, usize, usize), String> { + let rect = ctx.input(|i| i.viewport().inner_rect).unwrap_or_else(|| ctx.content_rect()); + let scale = f64::from(ctx.pixels_per_point()); + let pixels = |points: f32, what: &str| -> Result { + let value = f64::from(points) * scale; + if !value.is_finite() || value < 1.0 || value > 100_000.0 { + return Err(format!("the window's {what} ({points} points × {scale} DPI) cannot size a screenshot")); + } + Ok(value.round() as usize) + }; + let width = pixels(rect.width(), "width")?; + let height = pixels(rect.height(), "height")?; + let bytes = width + .checked_mul(height) + .and_then(|n| n.checked_mul(4)) + .ok_or_else(|| format!("a {width}×{height} screenshot's byte count overflows"))?; + Ok((width, height, bytes)) + } + + /// Issue the first due capture of a settled UI pass, at most one in flight: the one-shot + /// callback makes correlation trivial, and serializing bounds readback memory. + fn issue_screenshots(&mut self, ctx: &egui::Context) { + if self.pending_screenshots.iter().any(|p| p.slot.is_some()) { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + return; + } + for q in &mut self.queued_screenshots { + q.frames = q.frames.saturating_add(1); + } + let now = now_ms(); + let Some(pos) = self.queued_screenshots.iter().position(|q| now >= q.due_ms && q.frames >= SCREENSHOT_SETTLE_FRAMES) else { + if !self.queued_screenshots.is_empty() { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } + return; + }; + let queued = self.queued_screenshots.remove(pos); + let Some(index) = self.pending_screenshots.iter().position(|p| p.id == queued.id) else { return }; + let document = self.document_identity(); + if self.pending_screenshots[index].document != document { + self.fail_screenshot(index, "the document changed while the screenshot was pending"); + return; + } + let (width, height, bytes) = match self.screenshot_pixels(ctx) { + Ok(v) => v, + Err(e) => { + self.fail_screenshot(index, &e); + return; + } + }; + let (data, path, previous) = + (self.pending_screenshots[index].data, self.pending_screenshots[index].path.is_some(), self.pending_screenshots[index].reserved_bytes); + if data && !path && bytes > control::MAX_SCREENSHOT_PNG_REPLY_BYTES { + let e = format!( + "a PNG reply of this {width}×{height} screenshot ({bytes} bytes of pixels) no longer fits the {}-byte reply-encoding limit; write it to a path instead", + control::MAX_SCREENSHOT_PNG_REPLY_BYTES + ); + self.fail_screenshot(index, &e); + return; + } + if bytes > MAX_SCREENSHOT_BYTES || self.screenshot_reserved.saturating_sub(previous).saturating_add(bytes) > MAX_SCREENSHOT_TOTAL_BYTES { + let e = format!("the window grew since the screenshot was admitted: {width}×{height} no longer fits the capture budget"); + self.fail_screenshot(index, &e); + return; + } + self.screenshot_reserved = self.screenshot_reserved.saturating_sub(previous).saturating_add(bytes); + let viewport = ctx.viewport_id(); + let (slot, callback) = craft_ui::capture::CaptureSlot::new(ctx, viewport, MAX_SCREENSHOT_BYTES); + { + let pending = &mut self.pending_screenshots[index]; + pending.reserved_bytes = bytes; + pending.issued = Some(CaptureEpoch { geometry: self.geometry_epoch, viewport, document, width, height }); + pending.slot = Some(slot); + } + // A pending `Screenshot` asks for the paint it needs, even while hidden. + ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(callback)); + ctx.request_repaint(); + } + + /// Answer a pending request with an error and release its reservation. + fn fail_screenshot(&mut self, index: usize, error: &str) { + if index >= self.pending_screenshots.len() { + return; + } + let pending = self.pending_screenshots.remove(index); + self.queued_screenshots.retain(|q| q.id != pending.id); + self.screenshot_reserved = self.screenshot_reserved.saturating_sub(pending.reserved_bytes); + if let Some(slot) = pending.slot { + slot.cancel(); + } + let _ = pending.reply.send(json!({"ok": false, "error": error})); + } + + /// Take completed captures on the UI thread only, verify they still match the document and + /// geometry they were issued for, and write them under the automation roots. A late completion + /// (after a timeout or close) has already been canceled with its slot, so it writes nothing. + /// + /// This runs in `tick`, as both hosts call logic and the UI on the same thread. `save_screenshot` + /// and `file_access::confine` here, and the `&mut self` write, must move with it if logic ever + /// runs off the UI thread. fn collect_screenshots(&mut self, ctx: &egui::Context) { if self.pending_screenshots.is_empty() { return; } - let events: Vec<_> = ctx.input(|i| { - i.raw - .events - .iter() - .filter_map(|e| match e { - egui::Event::Screenshot { user_data, image, .. } => { - let token = user_data.data.as_ref().and_then(|d| d.downcast_ref::()).copied()?; - Some((token, image.clone())) - } - _ => None, - }) - .collect() - }); - for (token, image) in events { - if let Some(i) = self.pending_screenshots.iter().position(|p| p.token == token) { - let p = self.pending_screenshots.remove(i); - let roots = self.automation_roots.clone(); - let _ = p.reply.send(file_access::confine(roots.as_ref(), || control::save_screenshot(self, &image, p.path.as_deref(), p.data))); + let mut completed: Vec<(usize, Result, craft_ui::capture::CaptureError>)> = vec![]; + for (index, pending) in self.pending_screenshots.iter().enumerate() { + if let Some(slot) = &pending.slot + && let Some(result) = slot.take() + { + completed.push((index, result)); } } let now = now_ms(); - self.pending_screenshots.retain(|p| { - if now < p.deadline { - return true; + let document = self.document_identity(); + for (index, result) in completed.into_iter().rev() { + let Some((id, issued, deadline)) = self.pending_screenshots.get(index).map(|p| (p.id, p.issued, p.deadline_ms)) else { continue }; + match result { + // The frame came only after the request's deadline: it is late even though the + // callback had stored it before this pass took it. Do not encode or write it. + _ if now >= deadline => { + self.fail_screenshot(index, SCREENSHOT_TIMEOUT_ERROR); + } + Ok(_) if issued.is_some_and(|e| e.document != document) => { + self.fail_screenshot(index, "the document changed while the screenshot was pending"); + } + Ok(image) => { + let size = (image.size[0], image.size[1]); + let stale = + issued.is_none_or(|e| e.geometry != self.geometry_epoch || e.viewport != ctx.viewport_id() || (e.width, e.height) != size); + if stale { + // Resize, DPI or zoom changed while the capture was in flight: a fresh slot + // and a fresh frame, not the old pixels. Attempts are bounded. + let retry = self.pending_screenshots[index].attempts == 0; + self.pending_screenshots[index].attempts = self.pending_screenshots[index].attempts.saturating_add(1); + self.pending_screenshots[index].slot = None; + self.pending_screenshots[index].issued = None; + if retry { + self.queued_screenshots.insert(0, QueuedScreenshot { id, due_ms: now_ms(), frames: 0 }); + } else { + self.fail_screenshot(index, "the window changed while the screenshot was in flight"); + } + continue; + } + let pending = self.pending_screenshots.remove(index); + self.screenshot_reserved = self.screenshot_reserved.saturating_sub(pending.reserved_bytes); + let roots = self.automation_roots.clone(); + let _ = pending + .reply + .send(file_access::confine(roots.as_ref(), || control::save_screenshot(self, &image, pending.path.as_deref(), pending.data))); + } + Err(e) => { + let error = format!("the screenshot could not be used: {e}"); + self.fail_screenshot(index, &error); + } } - let _ = p.reply.send(serde_json::json!({ - "ok": false, - "error": "no frame was presented (screen locked, window minimized or fully covered); ui.render still renders the artboard" - })); - false - }); + } + let timed_out: Vec = self.pending_screenshots.iter().enumerate().filter(|(_, p)| now >= p.deadline_ms).map(|(i, _)| i).collect(); + for index in timed_out.into_iter().rev() { + self.fail_screenshot(index, SCREENSHOT_TIMEOUT_ERROR); + } + if !self.pending_screenshots.is_empty() { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } } /// Did the Type tool end an IME composition on its own this frame? Interrupting the IME @@ -1163,7 +1507,7 @@ impl VectorcraftApp { Err(e) => self.status(e.to_string()), } } - let (last_key, focus_key) = (egui::Id::new("linkWatch.lastScan"), egui::Id::new("linkWatch.focused")); + let (last_key, focus_key) = (egui::Id::unique("linkWatch.lastScan"), egui::Id::unique("linkWatch.focused")); let focused = ctx.input(|i| i.focused); let was_focused: bool = ctx.data(|d| d.get_temp(focus_key)).unwrap_or(focused); ctx.data_mut(|d| d.insert_temp(focus_key, focused)); @@ -1172,7 +1516,7 @@ impl VectorcraftApp { } let last: f64 = ctx.data(|d| d.get_temp(last_key)).unwrap_or(f64::NEG_INFINITY); // The active document as the last look saw it: its uid and revision. - let (seen_key, doc) = (egui::Id::new("linkWatch.seen"), self.session.active().map(|d| (d.uid, d.revision))); + let (seen_key, doc) = (egui::Id::unique("linkWatch.seen"), self.session.active().map(|d| (d.uid, d.revision))); if now - last >= 2.0 || (focused && !was_focused) { // Walking the document's links costs a pass over it: only when a look is due. // `start_link_scan` starts none for a document without links, which then doesn't @@ -1181,13 +1525,13 @@ impl VectorcraftApp { self.session.start_link_scan(); let links = self.session.link_scan.is_some(); ctx.data_mut(|d| { - d.insert_temp(egui::Id::new("linkWatch.links"), links); + d.insert_temp(egui::Id::unique("linkWatch.links"), links); d.insert_temp(seen_key, doc); }); } if self.session.link_scan.is_some() { ctx.request_repaint_after(std::time::Duration::from_millis(100)); - } else if ctx.data(|d| d.get_temp::(egui::Id::new("linkWatch.links"))).unwrap_or(false) { + } else if ctx.data(|d| d.get_temp::(egui::Id::unique("linkWatch.links"))).unwrap_or(false) { ctx.request_repaint_after(std::time::Duration::from_millis(2000)); } else if ctx.data(|d| d.get_temp::>(seen_key)).is_some_and(|seen| seen != doc) { // The document changed (a file placed into one without links) or another came to the @@ -1227,23 +1571,32 @@ impl VectorcraftApp { } } -/// Returns the milliseconds since the Unix epoch, or in the web build the milliseconds since the -/// first call, read from the page's `performance.now()`. -pub fn now_ms() -> f64 { +/// Seconds since the process started, from a monotonic clock (`web_time::Instant`, which maps to +/// `performance.now()` in the browser and `std::time::Instant` natively). Background work, poll +/// pacing and deadlines use this, not egui's `InputState::time`: a logic-only pass replays the +/// last shown frame's time, and a wall clock jumps with the system's. +pub fn now_secs() -> f64 { #[cfg(not(target_arch = "wasm32"))] { - use std::time::{SystemTime, UNIX_EPOCH}; - SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0) + use std::sync::LazyLock; + use std::time::Instant; + static START: LazyLock = LazyLock::new(Instant::now); + START.elapsed().as_secs_f64() } #[cfg(target_arch = "wasm32")] { use std::sync::LazyLock; use web_time::Instant; static START: LazyLock = LazyLock::new(Instant::now); - START.elapsed().as_secs_f64() * 1000.0 + START.elapsed().as_secs_f64() } } +/// [`now_secs`] in milliseconds, for frame timing and capture deadlines. +pub fn now_ms() -> f64 { + now_secs() * 1000.0 +} + /// The most fonts Type › Recent Fonts lists (Preferences › Type › Number of Recent Fonts). pub const MAX_RECENT_FONTS: usize = 15; @@ -1319,37 +1672,56 @@ impl VectorcraftApp { i18n::Lang::from_pref(editing.unwrap_or(&self.session.prefs.interface_language)) } - /// Per-frame logic before layout (control channel, shortcuts, inbox). A bug that panics costs - /// one frame and shows an error, instead of closing the app with unsaved work. - pub fn logic(&mut self, ctx: &egui::Context) { + /// The host starts one iteration: called from both hosts' `raw_input_hook`, before the host + /// decides whether a UI pass follows. It counts the iteration; it is not a frame or pass count + /// and does not promise that `ui` will run (a hidden window gets `tick` only). + pub fn begin_host_iteration(&mut self) { + self.host_iteration = self.host_iteration.wrapping_add(1); + // Frame keys identify input already taken in this host iteration. Each iteration starts + // fresh; this is what makes a viewport's discarded pass a duplicate, while two viewports' + // frame 0 both count. + self.input_frames.clear(); + self.shortcuts_frames.clear(); + } + + /// Background work, once per host iteration (full or logic-only): control requests, background + /// saves/exports/opens, plug-ins, recovery, font and clipboard polls, capture completion. It + /// reads only viewport state from `ctx.input` — never events or `i.time`: a logic-only pass + /// leaves the last shown frame's input in place. + /// + /// A bug that panics costs one iteration and shows an error, instead of closing the app with + /// unsaved work. + pub fn tick(&mut self, ctx: &egui::Context) { let confined = self.synthetic_frame.then(|| self.automation_roots.clone()).flatten(); - if let Err(msg) = file_access::confine(confined.as_ref(), || vectorcraft_engine::guard::catch_panic(|| self.logic_frame(ctx))) { + if let Err(msg) = file_access::confine(confined.as_ref(), || vectorcraft_engine::guard::catch_panic(|| self.tick_frame(ctx))) { self.picks.recover(); self.status(format!("Internal error: {msg} (please report this bug)")); } } - /// [`Self::logic`]'s work, inside its guard: the UI language and styling, the clipboard and - /// font polls, the results of work done in the background (saves, exports, files being opened, - /// heavy plug-in filters) and the window's close button. - fn logic_frame(&mut self, ctx: &egui::Context) { + /// [`Self::tick`]'s work, once per host iteration: a second call in the same iteration (a + /// discarded rerun shares one hook call) does nothing. + fn tick_frame(&mut self, ctx: &egui::Context) { + if self.tick_iteration == Some(self.host_iteration) { + return; + } + self.tick_iteration = Some(self.host_iteration); + let dialog_was_open = self.ui.dialog.is_some(); let lang = self.ui_language(); i18n::set_current(lang); // The engine gives new type the Japanese defaults while the UI is in Japanese. if self.session.ui_language.as_deref() != Some(lang.code()) { self.session.ui_language = Some(lang.code().to_string()); } - self.adopt_context(ctx); - if !self.styled { - theme::install_fonts(ctx); - theme::apply(ctx, self.ui.brightness); - egui_extras::install_image_loaders(ctx); - self.styled = true; - } else { - self.fonts_ready = true; - } - self.frame += 1; - let now = ctx.input(|i| i.time); + self.install_injected_settle_hook(ctx); + self.advance_geometry_epoch(ctx); + // Monotonic host time, never egui's `InputState::time`: a logic-only (hidden) iteration + // replays the last shown pass's time, freezing recovery, clipboard polling and deadlines. + // Tests inject a clock to drive idle scheduling deterministically. + #[cfg(test)] + let now = self.now_override.unwrap_or_else(now_secs); + #[cfg(not(test))] + let now = now_secs(); self.perf.fps = self.frame_rate.frame(now, self.perf.frame_ms / 1000.0); self.sync_views(); // Read the system clipboard only when that alone decides whether Paste is enabled. A @@ -1375,7 +1747,9 @@ impl VectorcraftApp { } self.poll_font_check(ctx); picks::poll(self, ctx); - self.write_formats_before_text_input(ctx); + // A Paste the host read before this iteration's formats were written must still wait for + // them, even if the worker below finishes them during this same tick. + ctx.data_mut(|d| d.insert_temp(egui::Id::unique("clipboard.pending-at-tick"), self.clipboard_publishing.is_pending())); background::poll(self); screen_color::poll(self, ctx); opening::poll(self); @@ -1395,6 +1769,12 @@ impl VectorcraftApp { // once the saves running in the background are done. Not while a file dialog's sheet is on // the window: what asked for it runs again when it answers. if ctx.input(|i| i.viewport().close_requested()) { + // This native viewport is going away: release its IME context. No request is + // allocated here: the close prompt or file sheet publishes no custom descriptor, so a + // token would survive for a later stock field. A close the user then cancels starts a + // fresh generation on the next pass (never inherits a composition across the prompt). + ime::close_viewport(self, ctx); + self.ime_context.end_viewport(ctx.viewport_id()); background::wait_all(self); if self.file_sheet_open() { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); @@ -1420,27 +1800,147 @@ impl VectorcraftApp { // system title bar this is where the document name lives, as the in-app mark is hidden. crate::chrome::sync_window_title(self, ctx); self.drain_control(ctx); - if !self.synthetic.is_empty() { - ctx.request_repaint(); + // An egui `PasteImage` with a rich system clipboard: run the paste command now, once. It + // goes through `run`, whose `edit.paste` branch calls `adopt_system_clipboard` (which clears + // the request) to read the formats and then pastes them. A text paste in the same UI pass + // never sets the request, so the two cannot both paste. + if let Some(held) = self.clipboard_image_request.take() { + crate::menus::invoke(self, crate::shortcuts::paste_command(held), json!({})); } self.collect_screenshots(ctx); - self.issue_screenshots(ctx); if let Some(u) = self.pending_url.take() { ctx.open_url(egui::OpenUrl::new_tab(u)); } - if let Some(t) = self.clipboard_out.take() { - ctx.copy_text(t); - } + self.flush_clipboard_out(ctx); self.drain_inbox(); + if self.viewport_hidden(ctx) && (self.needs_hidden_paint() || (!dialog_was_open && self.ui.dialog.is_some())) { + // A plain repaint runs only logic in a hidden window: the ui that applies injected + // input, shows a queued dialog or issues a capture needs this command. egui-winit + // handles it; the 0.37 web backend does not (a pending fork fix), so a hidden browser + // tab cannot be claimed as qualified. + ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden); + } + if !self.synthetic.is_empty() || self.synthetic_injected || !self.pending_screenshots.is_empty() { + ctx.request_repaint(); + } + } + + /// The 0.36-style entry point, kept for the crate's tests, which call one `logic` inside a + /// `run_ui` pass and draw a panel afterwards (a UI phase). Production hosts call + /// `raw_input_hook`, then `tick` in `App::logic` and `ui` in `App::ui`; `tick` alone never + /// takes input, so a hidden logic-only iteration cannot replay the last shown frame's events. + /// + /// Outside tests this is just [`Self::tick`] with an iteration boundary. + pub fn logic(&mut self, ctx: &egui::Context) { + self.begin_host_iteration(); + self.tick(ctx); + #[cfg(test)] + { + let confined = self.synthetic_frame.then(|| self.automation_roots.clone()).flatten(); + let _ = file_access::confine(confined.as_ref(), || { + vectorcraft_engine::guard::catch_panic(|| { + self.ui_setup(ctx); + self.handle_input_once(ctx); + }) + }); + } + self.finish_ime_logic(ctx); + } + + /// Install once during host Context creation, before its first run_ui. + pub fn install_ime_reset_hooks(ctx: &egui::Context) { + ime::install_hooks(ctx); + } + + /// Host logic wrappers call this after their last application mutation. Full-pass logic + /// leaves final output observation to UI; standalone logic retires unavailable publication. + pub fn finish_ime_logic(&mut self, ctx: &egui::Context) { + ime::finish_logic(self, ctx); + } + + /// First UI-pass setup, in a real `run_ui` phase: adopt the context, install the interface + /// fonts and theme, and the image loaders. 0.37's `set_fonts` applies at once when no text has + /// been laid out, so the same pass can draw; a hidden logic-only iteration never installs them. + fn ui_setup(&mut self, ctx: &egui::Context) { + // Hosts install before their first pass. Installing here covers later passes only: egui + // has already snapshotted this pass's plugin list, so a direct harness that calls `logic` + // inside its first `run_ui` must call `install_ime_reset_hooks` before that `run_ui` + // (as `tests_lifecycle::ime_frame` does). This is not a first-pass bootstrap fallback. + ime::install_hooks(ctx); + self.adopt_context(ctx); + if !self.styled { + theme::apply(ctx, self.ui.brightness); + egui_extras::install_image_loaders(ctx); + self.styled = true; + } + if !self.fonts_ready { + // `set_fonts` applies at once only while no text was laid out in this pass; when it + // defers, the next pass applies it and this leaves input and drawing to that pass. + let fonts = theme::interface_fonts(); + ctx.set_fonts(fonts.clone()); + self.fonts_ready = ctx.fonts(|applied| applied.definitions() == &fonts); + } + } + + /// The first UI pass of a host iteration takes the host's input, once per context, viewport and + /// frame: clipboard formats, native-menu clicks, shortcuts and dropped files. A later pass + /// (`request_discard` rerun) or an interleaved second viewport pass with the same key draws + /// again but does not replay them; a logic-only iteration never reaches this. + fn handle_input_once(&mut self, ctx: &egui::Context) { + let frame = (graphics::context_tag(ctx), ctx.viewport_id(), ctx.cumulative_frame_nr_for(ctx.viewport_id())); + if !take_frame_once(&mut self.input_frames, frame) { + return; + } + self.write_formats_before_text_input(ctx); native_menu::run(self, ctx); if self.fonts_ready { shortcuts::handle(self, ctx); } + // A Copy taken in this UI phase (an egui copy event, or a command it runs) is published in + // this same pass: waiting for the next `tick` can leave the command unissued when an idle + // web host has no further frame to run it in. Native rich-format Copy does not use this + // path; it writes through `SystemClipboard` and leaves `clipboard_out` empty. + self.flush_clipboard_out(ctx); // Native only: the web host reads dropped files asynchronously and feeds the inboxes. #[cfg(not(target_arch = "wasm32"))] self.take_dropped_files(ctx); } + /// Hand a queued text copy to egui. Called after `tick`, after the UI phase's input and at the + /// end of the pass; the later calls are what an input- or menu-driven fallback Copy needs, and + /// `take` keeps it single-shot. + fn flush_clipboard_out(&mut self, ctx: &egui::Context) { + if let Some(t) = self.clipboard_out.take() { + ctx.copy_text(t); + } + } + + /// Is the window minimized or fully occluded (as far as the host reports)? + fn viewport_hidden(&self, ctx: &egui::Context) -> bool { + ctx.input(|i| { + let v = i.viewport(); + v.minimized == Some(true) || v.occluded == Some(true) + }) + } + + /// Is there work that only a UI pass can do, so a hidden window must be asked to paint? + fn needs_hidden_paint(&self) -> bool { + !self.synthetic.is_empty() + || self.synthetic_injected + || !self.queued_screenshots.is_empty() + || self.pending_screenshots.iter().any(|p| p.slot.is_none()) + } + + fn geometry_stamp(ctx: &egui::Context) -> GeometryStamp { + GeometryStamp { + viewport: ctx.viewport_id(), + content: ctx.content_rect(), + inner: ctx.input(|i| i.viewport().inner_rect), + pixels_per_point: ctx.pixels_per_point(), + zoom: ctx.zoom_factor(), + } + } + /// Files dropped on the window: documents, libraries, presets, plug-ins and shortcut sets /// opened, pictures and text placed on the canvas ([`Self::drop_target`]). #[cfg(not(target_arch = "wasm32"))] @@ -1518,10 +2018,97 @@ impl VectorcraftApp { } } - /// Inject synthetic events (one press/release step or wheel turn per frame). Handlers read the - /// modifiers egui holds (`i.modifiers`), so a synthetic key, button or wheel turn holds its own - /// for the frames it spans (a drag's moves included); the keyboard's come back after. - pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { + /// Advance the capture geometry epoch when any part of the window geometry changed, even back + /// to a previous value: a capture issued before the change must not be saved as if it showed + /// the new one. + fn advance_geometry_epoch(&mut self, ctx: &egui::Context) { + let stamp = Self::geometry_stamp(ctx); + if self.geometry_stamp != Some(stamp) { + self.geometry_stamp = Some(stamp); + self.geometry_epoch = self.geometry_epoch.wrapping_add(1); + } + } + + fn input_generation(&self) -> InputGeneration { + InputGeneration { + document: self.session.active().map_or(0, |document| document.uid), + dialog: self.ui.dialog.as_ref().map(|dialog| egui::Id::unique(&dialog.kind)), + focus: self.ui_ctx.as_ref().and_then(|ctx| ctx.memory(|memory| memory.focused())), + } + } + + /// Install the end-of-pass observer that tells the next host iteration a settled UI pass took + /// the injected batch (a font-gated or discarded pass does not). The shipped hosts have only + /// the root viewport; a multi-viewport host would need pending batches and settlement keyed + /// per viewport instead of this root-only acknowledgement. + fn install_injected_settle_hook(&mut self, ctx: &egui::Context) { + if self.injected_settle_hook { + return; + } + let settled = self.injected_settled.clone(); + ctx.on_end_pass( + "vectorcraft injected input settlement", + Arc::new(move |ui| { + if ui.ctx().viewport_id() == egui::ViewportId::ROOT && !ui.ctx().will_discard() { + settled.store(true, Ordering::Relaxed); + } + }), + ); + self.injected_settle_hook = true; + } + + /// The settled pass took the injected batch: allow the next step at the next host iteration. + fn acknowledge_injected_batch(&mut self) { + self.synthetic_injected = false; + self.injected_events.clear(); + self.synthetic_frame = false; + } + + /// The document, dialog or focus changed before a UI pass took the batch: remove exactly the + /// events we appended (they still live in eframe's pending raw input) so a forced hidden paint + /// cannot replay stale clicks or text into the new state. + fn discard_stale_injected_batch(&mut self, raw: &mut egui::RawInput) { + if !self.synthetic_injected || self.input_generation() == self.injected_generation { + return; + } + let events = std::mem::take(&mut self.injected_events); + let mut removed = false; + if !events.is_empty() + && let Some(start) = raw.events.windows(events.len()).rposition(|window| window == events.as_slice()) + { + raw.events.drain(start..start + events.len()); + removed = true; + } + if !events.is_empty() && !removed { + // The host's raw input no longer contains the batch as we injected it (another + // handler rewrote or interleaved events). We cannot tell stale synthetic input from + // the person's, so fail closed: deliver nothing this pass rather than replay a stale + // click or text into the state that replaced it, and cancel the rest of that + // synthetic sequence instead of injecting its next step after this one. + raw.events.clear(); + self.synthetic.clear(); + } + // The discarded batch may have carried the modifiers it set; the host's modifiers are + // what the next real event has, so do not leave a synthetic latch behind. + self.synthetic_modifiers = false; + self.synthetic_injected = false; + self.synthetic_frame = false; + } + + /// Inject synthetic events (one press/release step, wheel turn or short key sequence per host + /// iteration), carrying control provenance until a settled UI pass has taken the batch. The + /// batch belongs to the one root viewport the shipped hosts draw (see + /// `install_injected_settle_hook`); a multi-viewport host would key it by viewport. A + /// hidden logic-only iteration may see this hook run without a UI pass: nothing more is + /// injected until the batch was actually applied, so a burst of hidden ticks cannot queue + /// several steps of a drag or replay them together later. + pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + // Both hosts call this hook once per host iteration, before deciding whether a UI pass + // follows; it is the iteration boundary, not a UI-phase marker. + self.begin_host_iteration(); + // The observer lives on the context so it also settles a harness that consumes raw input + // without calling `VectorcraftApp::ui`. + self.install_injected_settle_hook(ctx); // The native menu's key equivalents become the input their keys make, ahead of what came // after them (see `native_menu`). let keys = self.services.native_menu.as_mut().map(native_menu::NativeMenu::take_keys).unwrap_or_default(); @@ -1539,7 +2126,16 @@ impl VectorcraftApp { _ => {} } } - self.synthetic_frame = !self.synthetic.is_empty(); + // The previous pass settled: the batch it took is done, so the next step may go. + if self.injected_settled.swap(false, Ordering::Relaxed) { + self.acknowledge_injected_batch(); + } + self.discard_stale_injected_batch(raw); + // While the previous batch is unapplied (it lives in eframe's pending raw input), inject + // nothing more: one step per settled UI pass, not per hidden logic tick. + if self.synthetic_injected { + return; + } let Some(first) = self.synthetic.first() else { if std::mem::take(&mut self.synthetic_modifiers) { raw.events.push(egui::Event::ModifiersChanged(self.host_modifiers)); @@ -1552,10 +2148,11 @@ impl VectorcraftApp { egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } | egui::Event::MouseWheel { .. } => 1, _ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1), }; - if let egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. } = first { - raw.events.push(egui::Event::PointerMoved(*p)); - } let (now, later) = self.synthetic.split_at(n.min(self.synthetic.len())); + let mut batch: Vec = Vec::with_capacity(n + 2); + if let egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. } = first { + batch.push(egui::Event::PointerMoved(*p)); + } // This frame's key or button, else the button a drag holds down (released later). let held = now .iter() @@ -1571,35 +2168,58 @@ impl VectorcraftApp { }); match held { Some(m) => { - raw.events.push(egui::Event::ModifiersChanged(m)); + batch.push(egui::Event::ModifiersChanged(m)); self.synthetic_modifiers = true; } - None if std::mem::take(&mut self.synthetic_modifiers) => raw.events.push(egui::Event::ModifiersChanged(self.host_modifiers)), + None if std::mem::take(&mut self.synthetic_modifiers) => batch.push(egui::Event::ModifiersChanged(self.host_modifiers)), None => {} } - raw.events.extend(self.synthetic.drain(..n)); + batch.extend(now.iter().cloned()); + // `batch` holds the n drained events plus the pointer/modifier companions that make the + // step read like real input. + self.synthetic.drain(..n); + self.injected_generation = self.input_generation(); + self.injected_events = batch.clone(); + self.synthetic_injected = true; + self.synthetic_frame = true; + raw.events.extend(batch); } - /// Lay out the whole window. + /// Lay out the whole window: the first UI pass of a host iteration sets the UI up (fonts, + /// theme, image loaders) and takes the input; later passes (a `request_discard` rerun) draw + /// only. pub fn ui(&mut self, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + ime::begin_ui(self, &ctx); let confined = self.synthetic_frame.then(|| self.automation_roots.clone()).flatten(); - if let Err(msg) = file_access::confine(confined.as_ref(), || vectorcraft_engine::guard::catch_panic(|| self.ui_frame(ui))) { + if let Err(msg) = file_access::confine(confined.as_ref(), || { + vectorcraft_engine::guard::catch_panic(|| { + self.ui_setup(&ctx); + self.handle_input_once(&ctx); + self.ui_frame(ui); + }) + }) { self.picks.recover(); self.status(format!("Internal error: {msg} (please report this bug)")); } + ime::finish_ui(self, &ctx); } /// [`Self::ui`]'s work, inside its guard: the bars, panels and canvas, then the dialogs and /// progress windows over them. fn ui_frame(&mut self, ui: &mut egui::Ui) { - crate::state::sync_zoom_stops_display_print_size(self.session.prefs.display_print_size); let ctx = ui.ctx().clone(); if !self.fonts_ready { + // Do not draw (or run the app's input handling) before the interface fonts are the + // context's: a deferred `set_fonts` applies at the start of the next pass. ctx.request_repaint(); return; } + crate::state::sync_zoom_stops_display_print_size(self.session.prefs.display_print_size); let t0 = now_ms(); widgets::set_dialog_open(&ctx, self.ui.dialog.is_some()); + // The canvas records this pass's IME caret geometry; a pass that draws no canvas has none. + self.ime_caret_rect = None; scrub::begin_frame(self, &ctx); font_menu::end_stale_preview(self, &ctx); floating::track(self, &ctx); @@ -1651,11 +2271,22 @@ impl VectorcraftApp { self.ui_fonts.frame(&ctx); scrub::end_frame(self, &ctx); native_menu::sync(self, &ctx); + // Observe once, after all of this pass's input, dialogs, floating panels, palette and + // commands: the latest completed pass is authoritative for owner, text and cause. + ime::end_of_pass(self, &ctx); self.drop_formats_after_egui_copy(&ctx); + // A command run by a menu, panel or dialog in this pass may have queued its text copy + // after the UI phase's input flush. Publish it now for the same reason as there. + self.flush_clipboard_out(&ctx); if opening::long_operation_busy(self) { ctx.set_cursor_icon(egui::CursorIcon::Progress); } - self.perf.frame_ms = now_ms() - t0; + // A pass egui will discard and rerun is not a presented frame: it must not become the + // frame-time sample, and it must not issue a capture of what it drew. + if !ctx.will_discard() { + self.perf.frame_ms = now_ms() - t0; + self.issue_screenshots(&ctx); + } let _ = json!(null); } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7e3f810bb..5d12a91a0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -3466,7 +3466,7 @@ pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 { // its text (#516), the frame after the click that chose them (that click takes the keyboard // from it again). type Field = (egui::Id, egui::text_edit::TextEditState); - let (field_key, chosen_key) = (egui::Id::new("menu-bar-field"), egui::Id::new("menu-bar-field-event")); + let (field_key, chosen_key) = (egui::Id::unique("menu-bar-field"), egui::Id::unique("menu-bar-field-event")); if let Some(((field, state), e)) = ctx.data(|d| d.get_temp::<(Field, egui::Event)>(chosen_key)) { ctx.data_mut(|d| d.remove::<(Field, egui::Event)>(chosen_key)); state.store(&ctx, field); @@ -3480,12 +3480,17 @@ pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 { // No application menu in the window (as in Illustrator): the bar starts at File on every // platform. The VectorCraft menu stays in the tree: the native Mac bar takes it from there. let tree: Vec<(&str, Vec)> = tree.into_iter().filter(|(title, _)| *title != "VectorCraft").collect(); + // A menu bar for its layout and menu-button style, but the titles use `Popup::menu` directly: + // egui 0.37's `MenuButton` opens the title under a resting pointer while another menu is open, + // and the app switches titles on movement only (`switch_on_hover`, the native behaviour). let (end, open) = egui::MenuBar::new() .ui(ui, |ui| { let mut titles = Vec::with_capacity(tree.len()); for (title, items) in tree.iter() { let text = egui::RichText::new(tl!(title)).size(13.0).color(t.text); - titles.push(ui.menu_button(text, |ui| menu_body(app, ui, items, &mut clicked)).response); + let resp = ui.add(egui::Button::new(text)); + egui::Popup::menu(&resp).show(|ui| menu_body(app, ui, items, &mut clicked)); + titles.push(resp); } (ui.cursor().min.x, switch_on_hover(ui.ctx(), &titles)) }) @@ -3590,7 +3595,7 @@ fn render_row(app: &VectorcraftApp, ui: &mut egui::Ui, row: &Row, checks: bool, let sampled = p.get("font").and_then(Value::as_str).filter(|_| id == "text.setStyle" && app.session.prefs.font_preview); let r = match sampled { Some(family) => { - let slot = ui.id().with(("font-sample", family)); + let slot = egui::IdSalt::new(("font-sample", family)); let button = egui::Button::new(text).right_text(egui::Atom::custom(slot, crate::font_menu::MENU_SAMPLE_SIZE)); let out = ui.add_enabled_ui(row.enabled, |ui| button.atom_ui(ui)).inner; if let Some(rect) = out.rect(slot) { @@ -4457,10 +4462,10 @@ mod tests { egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(300.0, 200.0))), ..Default::default() }; let mut out = ctx.run_ui(raw, |ui| { let ctx = ui.ctx().clone(); - let covered = ui.interact(egui::Rect::from_center_size(p, egui::vec2(80.0, 24.0)), egui::Id::new("covered"), egui::Sense::click()); + let covered = ui.interact(egui::Rect::from_center_size(p, egui::vec2(80.0, 24.0)), egui::Id::unique("covered"), egui::Sense::click()); let q = p + egui::vec2(0.0, 100.0); - let free = ui.interact(egui::Rect::from_center_size(q, egui::vec2(80.0, 24.0)), egui::Id::new("free"), egui::Sense::click()); - egui::Area::new(egui::Id::new("popup")).order(egui::Order::Foreground).fixed_pos(p - egui::vec2(10.0, 10.0)).show(&ctx, |ui| { + let free = ui.interact(egui::Rect::from_center_size(q, egui::vec2(80.0, 24.0)), egui::Id::unique("free"), egui::Sense::click()); + egui::Area::new(egui::Id::unique("popup")).order(egui::Order::Foreground).fixed_pos(p - egui::vec2(10.0, 10.0)).show(&ctx, |ui| { ui.allocate_space(egui::vec2(20.0, 20.0)); }); reaches = (pointer_reaches_title(&ctx, &covered, p), pointer_reaches_title(&ctx, &free, q)); @@ -4929,7 +4934,7 @@ mod tests { let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0)); let tree = menu_tree(); let (_, file) = tree.iter().find(|(t, _)| *t == "File").unwrap(); - let id = egui::Id::new("test-menu"); + let id = egui::Id::unique("test-menu"); for _ in 0..3 { let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() }; let mut out = ctx.run_ui(input, |ui| { @@ -4953,7 +4958,7 @@ mod tests { crate::theme::install_fonts(&ctx); let tree = menu_tree(); let (_, window) = tree.iter().find(|(t, _)| *t == "Window").unwrap(); - let id = egui::Id::new("test-long-menu"); + let id = egui::Id::unique("test-long-menu"); let rect = |height: f32| { let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, height)); for _ in 0..3 { diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs index ad4c02e86..09059c397 100644 --- a/crates/ui-egui/src/native_menu.rs +++ b/crates/ui-egui/src/native_menu.rs @@ -567,7 +567,7 @@ pub fn sync(app: &mut VectorcraftApp, ctx: &egui::Context) { let Some(menu) = app.services.native_menu.as_ref() else { return }; let input = ctx .input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Key { pressed: true, .. } | egui::Event::PointerButton { pressed: false, .. }))); - let state = state_hash(app, input.then_some(app.frame)); + let state = state_hash(app, input.then_some(app.host_iteration)); if menu.state == Some(state) && !menu.dirty { return; } diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs index e18084213..5fd8bb62e 100644 --- a/crates/ui-egui/src/palette.rs +++ b/crates/ui-egui/src/palette.rs @@ -53,7 +53,7 @@ fn cache_key(lang: crate::i18n::Lang) -> (&'static str, u64, u64) { fn entries(ctx: &egui::Context, lang: crate::i18n::Lang) -> Arc> { type Cached = ((&'static str, u64, u64), Arc>); let key = cache_key(lang); - let id = egui::Id::new("palette-entries"); + let id = egui::Id::unique("palette-entries"); if let Some((k, v)) = ctx.data(|d| d.get_temp::(id)) && k == key { @@ -84,7 +84,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let mut run: Option = None; // The highlighted result (#1757): Up and Down move it (wrapping), the pointer over a result // takes it, and a new query starts again from the first. - let sel_id = egui::Id::new("palette-selected"); + let sel_id = egui::Id::unique("palette-selected"); let (mut sel, last_q) = ctx.data(|d| d.get_temp::<(usize, String)>(sel_id)).unwrap_or_default(); if last_q != q { sel = 0; @@ -101,7 +101,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { } sel = sel.min(n - 1); } - egui::Area::new(egui::Id::new("palette")).order(egui::Order::Foreground).anchor(egui::Align2::CENTER_TOP, [0.0, 90.0]).show(ctx, |ui| { + egui::Area::new(egui::Id::unique("palette")).order(egui::Order::Foreground).anchor(egui::Align2::CENTER_TOP, [0.0, 90.0]).show(ctx, |ui| { egui::Frame::popup(ui.style()).fill(t.panel).inner_margin(egui::Margin::same(10)).show(ui, |ui| { ui.set_width(520.0); let r = ui.add(egui::TextEdit::singleline(&mut app.ui.palette_query).hint_text(tl!("Search commands and tools…")).desired_width(500.0)); @@ -205,7 +205,7 @@ mod tests { let key = |k| egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; let frame = |app: &mut VectorcraftApp, events: Vec| { ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui.ctx())).textures_delta.clear(); - ctx.data(|d| d.get_temp::<(usize, String)>(egui::Id::new("palette-selected"))).unwrap().0 + ctx.data(|d| d.get_temp::<(usize, String)>(egui::Id::unique("palette-selected"))).unwrap().0 }; assert_eq!(frame(&mut app, vec![]), 0); assert_eq!(frame(&mut app, vec![key(egui::Key::ArrowDown)]), 1); diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index 48426a45a..0f3bda216 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -26,7 +26,7 @@ struct RootGroupDrag { } fn cancel_legacy_drag(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool { - let key = egui::Id::new("vectorcraft-panel-docking").with("legacy-origin"); + let key = egui::Id::unique("vectorcraft-panel-docking").with("legacy-origin"); let previous = ctx.data_mut(|data| data.remove_temp::(key)); if let Some(previous) = previous { app.ui.open_panel = previous.open_panel; @@ -34,7 +34,7 @@ fn cancel_legacy_drag(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool { app.ui.docking = previous.layout; app.ui.docking_icons = previous.icons; app.ui.docking_collapsed = previous.collapsed; - craft_ui::docking::cancel_drag::(ctx, egui::Id::new("vectorcraft-panel-docking")); + craft_ui::docking::cancel_drag::(ctx, egui::Id::unique("vectorcraft-panel-docking")); true } else { false @@ -800,13 +800,13 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { } else { // Click as well as drag, so a double-click here collapses the group // (`tab_area_double_clicked`) rather than floating it on the first press. - let response = ui.interact(grip, ui.id().with("dock-group-bar"), egui::Sense::click_and_drag()); + let response = ui.interact(grip, ui.scope_id().with("dock-group-bar"), egui::Sense::click_and_drag()); if response.drag_started() && let Some(pos) = response.interact_pointer_pos() { ui.ctx().data_mut(|data| { data.insert_temp( - egui::Id::new("vectorcraft-root-group-drag"), + egui::Id::unique("vectorcraft-root-group-drag"), RootGroupDrag { panel: active.clone(), layout: self.app.ui.docking.clone().unwrap_or_else(|| legacy(self.app)), @@ -843,7 +843,7 @@ fn drop_command(panel: &str, target: crate::floating::Drop, whole_group: bool) - /// from where its tabs were drawn: a group's tabs start at its top-left corner, and the groups of /// a split meet where the second one's tabs start. fn root_groups(ctx: &egui::Context, layout: &Layout, content: egui::Rect, tab_height: f32) -> Vec { - let area = egui::Id::new("vectorcraft-panel-docking"); + let area = egui::Id::unique("vectorcraft-panel-docking"); let tab = |panel: &String| ctx.read_response(area.with(("tab", panel))).map(|response| response.rect).filter(|r| r.is_positive()); /// The top-left corner of a node's first group, from where its tabs were drawn. fn corner(node: &Node, tab: &dyn Fn(&String) -> Option) -> Option { @@ -913,7 +913,7 @@ fn tab_area_double_clicked( if tab_group_shortens(layout, group.shown) != Some(true) { return None; } - let area = egui::Id::new("vectorcraft-panel-docking"); + let area = egui::Id::unique("vectorcraft-panel-docking"); let right = |id: egui::Id| ctx.read_response(id).map(|r| r.rect.right()); let tabs = group .panels @@ -926,7 +926,7 @@ fn tab_area_double_clicked( } pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { - let root_key = egui::Id::new("vectorcraft-root-group-drag"); + let root_key = egui::Id::unique("vectorcraft-root-group-drag"); if let Some(drag) = ui.ctx().data(|data| data.get_temp::(root_key)) { if drag.generation != app.ui.docking_generation { ui.ctx().data_mut(|data| data.remove::(root_key)); @@ -960,7 +960,7 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { } } } - let area = egui::Id::new("vectorcraft-panel-docking"); + let area = egui::Id::unique("vectorcraft-panel-docking"); let changed_workspace = ui.ctx().data_mut(|data| { let previous = data.get_temp::(area.with("generation")); data.insert_temp(area.with("generation"), app.ui.docking_generation); @@ -1010,7 +1010,7 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { Some((layer, layout_rect, fill)) }) .collect(); - crate::dock::apply_pending_dock_width(app, ui.ctx(), crate::dock::SHARED_DOCK_PANEL, crate::dock::clamp_shared_dock_width); + crate::dock::apply_pending_dock_width(app, ui, crate::dock::SHARED_DOCK_PANEL, crate::dock::clamp_shared_dock_width); let shared_dock_default = crate::dock::shared_dock_default_width(&app.ui); // Splits sized to a collapsed group's tabs: their dividers don't resize the saved layout. let mut fixed: Vec> = Vec::new(); @@ -1133,7 +1133,7 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { let panel = if collapsed { panel.exact_size(38.0).resizable(false) } else { panel }; let shown = panel.show(ui, |ui| crate::theme::card(ui, &t, egui::Margin { left: 0, right: 6, top: 6, bottom: 6 }, &mut draw)); ui.ctx().data_mut(|data| data.insert_temp(crate::floating::dock_rect_id(), shown.response.rect)); - crate::dock::note_dock_width(app, ui.ctx(), crate::dock::SHARED_DOCK_PANEL); + crate::dock::note_dock_width(app, shown.response.rect.width()); shown.inner } else { draw(ui) @@ -1182,8 +1182,8 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { { return app.ui.docking.is_some(); } - craft_ui::docking::cancel_drag::(ui.ctx(), egui::Id::new("vectorcraft-panel-docking")); - ui.ctx().data_mut(|data| data.remove::(egui::Id::new("vectorcraft-panel-docking").with("legacy-origin"))); + craft_ui::docking::cancel_drag::(ui.ctx(), egui::Id::unique("vectorcraft-panel-docking")); + ui.ctx().data_mut(|data| data.remove::(egui::Id::unique("vectorcraft-panel-docking").with("legacy-origin"))); } for action in output.actions { if matches!(&action, Action::ResizeSplit { path, .. } if fixed.contains(path)) { @@ -1207,7 +1207,7 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { // Shared and native windows have already chosen their render ownership for this // frame. Switch representations only after both renderers finish, so a panel's // widgets never appear in two layers in the same frame. - ui.ctx().data_mut(|data| data.insert_temp(egui::Id::new("vectorcraft-native-panel-drop"), (command, params))); + ui.ctx().data_mut(|data| data.insert_temp(egui::Id::unique("vectorcraft-native-panel-drop"), (command, params))); } for group in &display.floating { if let Some(first) = group.panels.first() @@ -1230,7 +1230,7 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { /// The panel whose tab (of the shared dock, or of the legacy dock or icon column on the drag that /// began the shared layout) is being dragged. fn dragged_tab(ctx: &egui::Context, layout: &Layout) -> Option { - let area = egui::Id::new("vectorcraft-panel-docking"); + let area = egui::Id::unique("vectorcraft-panel-docking"); if let Some(drag) = ctx.data(|data| data.get_temp::(area.with("legacy-origin"))) { return Some(drag.panel); } @@ -1275,10 +1275,10 @@ fn icon_rail(app: &mut VectorcraftApp, ui: &mut egui::Ui) { continue; } let (_, rect) = ui.allocate_space(egui::vec2(30.0, 30.0)); - let response = ui.interact(rect, ui.id().with(("panel-icon", id)), egui::Sense::click_and_drag()); + let response = ui.interact(rect, ui.scope_id().with(("panel-icon", id)), egui::Sense::click_and_drag()); let open = app.ui.open_panel.as_deref() == Some(id); let response = crate::widgets::paint_icon_button(ui, response, icon, tl!(label), open); - response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, ui.is_enabled(), open, tl!(label))); + response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), open, tl!(label))); if response.clicked() { app.ui.open_panel = if app.ui.open_panel.as_deref() == Some(id) { None } else { Some(id.into()) }; } @@ -1297,7 +1297,7 @@ fn icon_rail(app: &mut VectorcraftApp, ui: &mut egui::Ui) { }; data.insert_temp(crate::floating::dock_rect_id(), dock.union(column)); data.insert_temp(crate::floating::icons_rect_id(), column); - data.insert_temp(egui::Id::new("dock-icon-column-left"), column.left()); + data.insert_temp(egui::Id::unique("dock-icon-column-left"), column.left()); }); } @@ -1346,7 +1346,7 @@ pub(crate) fn legacy_tab_in_strip( } }); let inside_strip = strip.is_some_and(|strip| ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| strip.expand(4.0).contains(p))); - let area = egui::Id::new("vectorcraft-panel-docking"); + let area = egui::Id::unique("vectorcraft-panel-docking"); let begun = ui.ctx().data(|d| d.get_temp::(area.with("legacy-origin")).is_some()); let begin = if strip.is_some() { !begun && (response.drag_started() || (!inside_strip && response.dragged())) } else { response.drag_started() }; if begin && let Some(id) = normalize(panel) { @@ -1357,11 +1357,11 @@ pub(crate) fn legacy_tab_in_strip( egui::vec2(bounds.width().clamp(240.0, 600.0), bounds.height().clamp(200.0, 600.0)), ); if ensure(&mut layout, id).is_ok() - && craft_ui::docking::begin_drag(ui.ctx(), egui::Id::new("vectorcraft-panel-docking"), id.to_string(), source) + && craft_ui::docking::begin_drag(ui.ctx(), egui::Id::unique("vectorcraft-panel-docking"), id.to_string(), source) { ui.ctx().data_mut(|data| { data.insert_temp( - egui::Id::new("vectorcraft-panel-docking").with("legacy-origin"), + egui::Id::unique("vectorcraft-panel-docking").with("legacy-origin"), LegacyDrag { panel: id.to_string(), layout: app.ui.docking.clone(), @@ -1505,7 +1505,7 @@ mod tests; /// Commit a pointer drop after shared and native panel windows have finished rendering. pub(crate) fn finish_native_drop(app: &mut VectorcraftApp, ctx: &egui::Context) { if let Some((id, params)) = - ctx.data_mut(|data| data.remove_temp::<(&'static str, serde_json::Value)>(egui::Id::new("vectorcraft-native-panel-drop"))) + ctx.data_mut(|data| data.remove_temp::<(&'static str, serde_json::Value)>(egui::Id::unique("vectorcraft-native-panel-drop"))) && let Some(result) = command(app, id, ¶ms) && let Err(error) = result { diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index e1b89c2b4..b05fe509f 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -91,8 +91,9 @@ fn ordinary_panel_tab_drag_enters_shared_docking_and_can_redock_into_another_tab drag(&mut h, source, egui::pos2(160.0, 140.0)); let layout = h.state().ui.docking.as_ref().expect("ordinary tab drag starts shared docking"); assert!(layout.floating.iter().any(|g| g.panels.contains(&"layers".into())), "{layout:?}"); - let source = h.ctx.read_response(egui::Id::new("vectorcraft-panel-docking").with(("tab", &"layers".to_string()))).unwrap().rect.center(); - let destination = h.ctx.read_response(egui::Id::new("vectorcraft-panel-docking").with(("tab", &"properties".to_string()))).unwrap().rect.center(); + let source = h.ctx.read_response(egui::Id::unique("vectorcraft-panel-docking").with(("tab", &"layers".to_string()))).unwrap().rect.center(); + let destination = + h.ctx.read_response(egui::Id::unique("vectorcraft-panel-docking").with(("tab", &"properties".to_string()))).unwrap().rect.center(); drag(&mut h, source, destination); let layout = h.state().ui.docking.as_ref().unwrap(); assert!(!layout.floating.iter().any(|g| g.panels.contains(&"layers".into()))); @@ -289,8 +290,8 @@ fn populated_canvas_keeps_a_real_stroke_edit_after_floating_and_redocking() { std::fs::create_dir_all(directory).unwrap(); h.render().unwrap().save(directory.join("vectorcraft-populated-stroke-floating.png")).unwrap(); } - let from = h.ctx.read_response(egui::Id::new("vectorcraft-panel-docking").with(("tab", &"stroke".to_string()))).unwrap().rect.center(); - let to = h.ctx.read_response(egui::Id::new("vectorcraft-panel-docking").with(("tab", &"properties".to_string()))).unwrap().rect.center(); + let from = h.ctx.read_response(egui::Id::unique("vectorcraft-panel-docking").with(("tab", &"stroke".to_string()))).unwrap().rect.center(); + let to = h.ctx.read_response(egui::Id::unique("vectorcraft-panel-docking").with(("tab", &"properties".to_string()))).unwrap().rect.center(); drag(&mut h, from, to); assert!(h.state().ui.docking.as_ref().unwrap().floating.is_empty()); assert_eq!(crate::panels::current_stroke(h.state()).unwrap().dash.unwrap(), dash); @@ -757,7 +758,7 @@ fn a_floating_group_dropped_on_the_lower_half_of_the_dock_splits_it() { h.state_mut().run("window.panel.float", json!({"panel":"align","x":300,"y":300})).unwrap(); h.run_steps(3); let title = - h.ctx.memory(|m| m.area_rect(egui::Id::new("vectorcraft-panel-docking").with(("floating", &"align".to_string())))).unwrap().left_top() + h.ctx.memory(|m| m.area_rect(egui::Id::unique("vectorcraft-panel-docking").with(("floating", &"align".to_string())))).unwrap().left_top() + vec2(6.0, 16.0); drag(&mut h, title, egui::pos2(lower.center().x, lower.top() + 50.0)); assert!(h.state().ui.docking.as_ref().unwrap().floating.is_empty()); @@ -773,7 +774,7 @@ fn a_floating_group_dropped_on_the_icon_column_becomes_icons() { h.state_mut().run("window.panel.float", json!({"panel":"layers","x":300,"y":300})).unwrap(); h.run_steps(3); let title = - h.ctx.memory(|m| m.area_rect(egui::Id::new("vectorcraft-panel-docking").with(("floating", &"layers".to_string())))).unwrap().left_top() + h.ctx.memory(|m| m.area_rect(egui::Id::unique("vectorcraft-panel-docking").with(("floating", &"layers".to_string())))).unwrap().left_top() + vec2(6.0, 16.0); let column = h.ctx.data(|d| d.get_temp::(crate::floating::icons_rect_id())).unwrap(); drag(&mut h, title, column.center()); @@ -829,7 +830,7 @@ fn a_dock_tab_context_menu_closes_its_panel_and_the_window_menu_brings_it_back() assert!(!layout.contains(&"libraries".to_string())); assert_eq!(root_group_of(h.state(), "layers"), ["properties", "layers"]); // In the shared dock the tabs' context menu closes them too. - let tab = h.ctx.read_response(egui::Id::new("vectorcraft-panel-docking").with(("tab", &"layers".to_string()))).unwrap().rect.center(); + let tab = h.ctx.read_response(egui::Id::unique("vectorcraft-panel-docking").with(("tab", &"layers".to_string()))).unwrap().rect.center(); h.event(Event::PointerMoved(tab)); h.event(Event::PointerButton { pos: tab, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE }); h.event(Event::PointerButton { pos: tab, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); @@ -908,9 +909,12 @@ fn a_panel_the_icon_column_has_no_place_for_still_shows_there_once_dropped_on_it assert!(!crate::state::ICON_PANEL_GROUPS.iter().any(|group| group.contains(&"charStyles"))); h.state_mut().run("window.panel.float", json!({"panel":"charStyles","x":300,"y":300})).unwrap(); h.run_steps(3); - let title = - h.ctx.memory(|m| m.area_rect(egui::Id::new("vectorcraft-panel-docking").with(("floating", &"charStyles".to_string())))).unwrap().left_top() - + vec2(6.0, 16.0); + let title = h + .ctx + .memory(|m| m.area_rect(egui::Id::unique("vectorcraft-panel-docking").with(("floating", &"charStyles".to_string())))) + .unwrap() + .left_top() + + vec2(6.0, 16.0); let column = h.ctx.data(|d| d.get_temp::(crate::floating::icons_rect_id())).unwrap(); drag(&mut h, title, column.center()); assert!(h.state().ui.docking_icons.contains("charStyles")); @@ -936,7 +940,7 @@ fn double_click(h: &mut Harness<'static, VectorcraftApp>, at: Pos2) { /// The Layers panel's body is drawn (its search field). fn layers_drawn(h: &Harness<'static, VectorcraftApp>) -> bool { - h.ctx.read_response(egui::Id::new("layers-search").with("edit")).is_some() + h.ctx.read_response(egui::Id::unique("layers-search").with("edit")).is_some() } /// Clicks at `at`. @@ -967,7 +971,7 @@ fn two_stacked_groups_both_collapsed_are_both_as_short_as_their_tabs() { } fn tab_center(h: &Harness<'static, VectorcraftApp>, panel: &str) -> Pos2 { - h.ctx.read_response(egui::Id::new("vectorcraft-panel-docking").with(("tab", &panel.to_string()))).unwrap().rect.center() + h.ctx.read_response(egui::Id::unique("vectorcraft-panel-docking").with(("tab", &panel.to_string()))).unwrap().rect.center() } /// A double-click on a tab of the shared dock collapses its group to its tabs; another tab expands it. @@ -1086,7 +1090,7 @@ fn a_double_click_on_the_shared_dock_strip_right_of_the_tabs_collapses_its_group let saved = app.ui.docking.clone(); let mut h = harness(app, vec2(1280.0, 800.0)); let strip = root_groups(&h)[0].strip; - let tab = h.ctx.read_response(egui::Id::new("vectorcraft-panel-docking").with(("tab", &"swatches".to_string()))).unwrap().rect; + let tab = h.ctx.read_response(egui::Id::unique("vectorcraft-panel-docking").with(("tab", &"swatches".to_string()))).unwrap().rect; // Between the tab and the group's header (80 wide), and on the header's grip. let gap = pos2(tab.right() + 10.0, strip.center().y); assert!(gap.x < strip.right() - 80.0, "{tab:?} {strip:?}"); @@ -1212,7 +1216,7 @@ fn a_double_click_on_the_strip_of_a_group_beside_another_does_nothing() { let mut h = harness(app, vec2(1280.0, 800.0)); let status = h.state().ui.status.clone(); let strip = root_groups(&h).into_iter().find(|g| g.shown == "swatches").unwrap().strip; - let tab = h.ctx.read_response(egui::Id::new("vectorcraft-panel-docking").with(("tab", &"swatches".to_string()))).unwrap().rect; + let tab = h.ctx.read_response(egui::Id::unique("vectorcraft-panel-docking").with(("tab", &"swatches".to_string()))).unwrap().rect; let gap = pos2(tab.right() + 4.0, strip.center().y); assert!(gap.x < strip.right() - 24.0, "{tab:?} {strip:?}"); double_click(&mut h, gap); @@ -1220,3 +1224,32 @@ fn a_double_click_on_the_strip_of_a_group_beside_another_does_nothing() { assert!(h.state().ui.docking_collapsed.is_empty()); assert_eq!(h.state().ui.docking, saved); } + +/// egui 0.37 keys a `Panel`'s persisted state by its parent `Ui` scope plus its own salt, not by +/// a global `Id`. Seeding the saved width must use the same key, and the width the panel was laid +/// out at must come back into `UiState` (`dock_width`), for a workspace switch and the next run. +#[test] +fn the_dock_width_seeds_and_returns_through_the_public_panel_state() { + let mut app = app(); + // The shared panel layout (not the legacy single dock), so `Panel::right`'s parent-scope key + // is the one under test. + app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"top"})).unwrap(); + app.ui.dock_width = Some(420.0); + app.ui.dock_width_pending = true; + let mut h = harness(app, vec2(1400.0, 900.0)); + let width = h.state().ui.dock_width.expect("the laid-out width is remembered"); + assert!((width - 420.0).abs() < 1.0, "the seeded width is restored, got {width}"); + assert!(!h.state().ui.dock_width_pending, "the pending seed is consumed"); + + // An in-session width change (a workspace switch) resizes the already-laid-out panel. + h.state_mut().ui.dock_width = Some(300.0); + h.state_mut().ui.dock_width_pending = true; + h.run_steps(2); + let width = h.state().ui.dock_width.expect("the new width is remembered"); + assert!((width - 300.0).abs() < 1.0, "the in-session width is applied, got {width}"); + + // The preference survives a serialization round trip, as the saved workspace does. + let saved = serde_json::to_vec(&h.state().ui).unwrap(); + let restored: crate::state::UiState = serde_json::from_slice(&saved).unwrap(); + assert_eq!(restored.dock_width.map(|w| w.round()), Some(300.0)); +} diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index 7d6a78ec1..44e92f0d8 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -194,7 +194,7 @@ fn row(ui: &mut Ui, selected: bool) -> (Rect, egui::Response) { fn eye(ui: &mut Ui, r: Rect, id: impl std::hash::Hash + std::fmt::Debug, on: bool, enabled: bool) -> bool { let t = Tokens::get(ui.ctx()); let er = Rect::from_center_size(pos2(r.left() + EYE_W / 2.0, r.center().y), vec2(16.0, 16.0)); - let resp = ui.interact(er, ui.id().with(id), if enabled { Sense::click() } else { Sense::hover() }); + let resp = ui.interact(er, ui.scope_id().with(id), if enabled { Sense::click() } else { Sense::hover() }); let col = if !enabled { t.text_disabled } else if resp.hovered() { @@ -209,7 +209,7 @@ fn eye(ui: &mut Ui, r: Rect, id: impl std::hash::Hash + std::fmt::Debug, on: boo fn chevron(ui: &mut Ui, r: Rect, id: impl std::hash::Hash + std::fmt::Debug, open: bool) -> bool { let t = Tokens::get(ui.ctx()); let cr = Rect::from_center_size(pos2(r.left() + EYE_W + 12.0, r.center().y), vec2(12.0, 12.0)); - let resp = ui.interact(cr, ui.id().with(id), Sense::click()); + let resp = ui.interact(cr, ui.scope_id().with(id), Sense::click()); icons::paint(ui, if open { "chevron-down" } else { "chevron-right" }, cr, if resp.hovered() { t.text_strong } else { t.icon }); resp.clicked() } @@ -232,7 +232,7 @@ fn link(ui: &mut Ui, pos: egui::Pos2, id: impl std::hash::Hash + std::fmt::Debug let t = Tokens::get(ui.ctx()); let galley = ui.painter().layout_no_wrap(s.to_string(), egui::FontId::proportional(12.5), t.text_strong); let r = Rect::from_min_size(pos2(pos.x, pos.y - galley.size().y / 2.0), galley.size()); - let resp = ui.interact(r, ui.id().with(id), Sense::click()); + let resp = ui.interact(r, ui.scope_id().with(id), Sense::click()); ui.painter().galley(r.min, galley, t.text_strong); let mut x = r.left(); while x < r.right() { @@ -292,7 +292,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { /// The object row's thumbnail drags this object's appearance onto art ([`PanelDrag::Appearance`]; /// a chip of its fill follows the pointer). fn thumbnail_drag(ui: &mut Ui, th: Rect, id: NodeId) { - let resp = ui.interact(th, ui.id().with("ap-thumb"), Sense::drag()).on_hover_text(tl!("Drag onto art to apply this appearance")); + let resp = ui.interact(th, ui.scope_id().with("ap-thumb"), Sense::drag()).on_hover_text(tl!("Drag onto art to apply this appearance")); widgets::drag_source(ui, &resp, || PanelDrag::Appearance(id)); if resp.dragged() { ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); @@ -441,7 +441,7 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel, object_row: let cr = Rect::from_min_size(pos2(r.left() + EYE_W + 76.0, r.center().y - 9.0), vec2(18.0, 18.0)); chip(ui, cr, paint); let cresp = ui - .interact(cr.expand(2.0), ui.id().with(("ap-chip", i)), Sense::click()) + .interact(cr.expand(2.0), ui.scope_id().with(("ap-chip", i)), Sense::click()) .on_hover_text(tl!("Click to choose a swatch, Shift-click to mix a colour")); let mixer = cresp.clicked() && ui.input(|inp| inp.modifiers.shift); if mixer { @@ -454,7 +454,8 @@ fn stack(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, sel: Sel, object_row: if let AppearanceItem::Stroke(st) = it { let fr = Rect::from_min_size(pos2(r.left() + EYE_W + 104.0, r.center().y - 12.0), vec2(56.0, 24.0)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(fr).layout(egui::Layout::left_to_right(egui::Align::Center))); - if let Some(w) = widgets::num_field(&mut child, ("ap-w", i), Some(st.width), app.session.stroke_unit(), 56.0) { + if let Some(w) = widgets::num_field_named(&mut child, ("ap-w", i), Some(tl!("Weight:")), Some(st.width), app.session.stroke_unit(), 56.0) + { app.run("appearance.setItem", json!({"index": i, "weight": w})).ok(); } stroke_notes(ui, Rect::from_min_max(pos2(fr.right() + 6.0, r.top()), pos2(r.right() - 4.0, r.bottom())), st); @@ -757,87 +758,90 @@ fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, k: let ui_keys = manifest.and_then(|m| m.param(key)).map(|p| &p.ui); ui.horizontal(|ui| { let label = ui_keys.and_then(|u| u.label.clone()).unwrap_or_else(|| param_label(key, built_in)); - let shown = ui.add_sized(vec2(84.0, 22.0), egui::Label::new(egui::RichText::new(label.as_str()).size(11.5).color(t.text)).truncate()); - if let Some(help) = ui_keys.and_then(|u| u.help.as_deref()) { - shown.on_hover_text(help); - } - if e.id == "stylize.dropShadow" && key == "shade" { - let cur = v.as_str().unwrap_or_default(); - for (label, value) in crate::dialogs::SHADES { - if widgets::radio(ui, label, cur.eq_ignore_ascii_case(value), true) && !cur.eq_ignore_ascii_case(value) { + let label_node = + ui.add_sized(vec2(84.0, 22.0), egui::Label::new(egui::RichText::new(label.as_str()).size(11.5).color(t.text)).truncate()); + let label_node = if let Some(help) = ui_keys.and_then(|u| u.help.as_deref()) { label_node.on_hover_text(help) } else { label_node }; + // The row's actual label node names its fields, including a plug-in's plain text + // and number fields (egui 0.37's strict a11y gate). + ui.label_inputs_by(label_node.id, |ui| { + if e.id == "stylize.dropShadow" && key == "shade" { + let cur = v.as_str().unwrap_or_default(); + for (label, value) in crate::dialogs::SHADES { + if widgets::radio(ui, label, cur.eq_ignore_ascii_case(value), true) && !cur.eq_ignore_ascii_case(value) { + change = Some((key.clone(), json!(value))); + } + } + return; + } + if let Some(options) = crate::dialogs::shown_choices(&e.id, key, ¶ms, cmyk) { + let cur = v.as_str().unwrap_or_default(); + let shown = options.iter().find(|(_, value)| value == cur).map_or(cur, |(label, _)| label.as_str()); + let labels: Vec<&str> = options.iter().map(|(label, _)| label.as_str()).collect(); + if let Some((_, value)) = widgets::dropdown_names(ui, ("fx-choice", item, k, key.as_str()), shown, &labels, 90.0) + .and_then(|i| options.get(i)) + .filter(|(_, value)| value != cur) + { change = Some((key.clone(), json!(value))); } + return; } - return; - } - if let Some(options) = crate::dialogs::shown_choices(&e.id, key, ¶ms, cmyk) { - let cur = v.as_str().unwrap_or_default(); - let shown = options.iter().find(|(_, value)| value == cur).map_or(cur, |(label, _)| label.as_str()); - let labels: Vec<&str> = options.iter().map(|(label, _)| label.as_str()).collect(); - if let Some((_, value)) = widgets::dropdown_names(ui, ("fx-choice", item, k, key.as_str()), shown, &labels, 90.0) - .and_then(|i| options.get(i)) - .filter(|(_, value)| value != cur) + // A plug-in's number list is typed as its dialog's is: "45, 135". + if let Some(spec) = manifest.and_then(|m| m.param(key)).map(|p| &p.spec) + && matches!(spec, vectorcraft_plugins::manifest::ParamSpec::NumberList { .. }) { - change = Some((key.clone(), json!(value))); + let typed = widgets::exact_text_field(ui, ("fx", item, k, key.as_str()), &crate::dialogs::number_list_text(v), 90.0); + if let Some(list) = typed.and_then(|text| crate::dialogs::read_number_list(&text, spec)) { + change = Some((key.clone(), list)); + } + if let Some(unit) = ui_keys.and_then(|u| u.unit.as_deref()) { + widgets::dim_label(ui, unit); + } + return; } - return; - } - // A plug-in's number list is typed as its dialog's is: "45, 135". - if let Some(spec) = manifest.and_then(|m| m.param(key)).map(|p| &p.spec) - && matches!(spec, vectorcraft_plugins::manifest::ParamSpec::NumberList { .. }) - { - let typed = widgets::exact_text_field(ui, ("fx", item, k, key.as_str()), &crate::dialogs::number_list_text(v), 90.0); - if let Some(list) = typed.and_then(|text| crate::dialogs::read_number_list(&text, spec)) { - change = Some((key.clone(), list)); + // Only our own effects show a `mode` parameter as the blend mode dropdown; a plug-in's `mode` stays a plain field. + if built_in && let Some(cur) = widgets::blend_param(key, v) { + if let Some(m) = widgets::blend_param_dropdown(ui, ("fx-blend", item, k, key.as_str()), cur) { + change = Some((key.clone(), m)); + } + return; } - if let Some(unit) = ui_keys.and_then(|u| u.unit.as_deref()) { - widgets::dim_label(ui, unit); - } - return; - } - // Only our own effects show a `mode` parameter as the blend mode dropdown; a plug-in's `mode` stays a plain field. - if built_in && let Some(cur) = widgets::blend_param(key, v) { - if let Some(m) = widgets::blend_param_dropdown(ui, ("fx-blend", item, k, key.as_str()), cur) { - change = Some((key.clone(), m)); - } - return; - } - match v { - Value::Bool(b) => { - if widgets::check_box(ui, &label, *b, true) { - change = Some((key.clone(), json!(!b))); + match v { + Value::Bool(b) => { + if widgets::check_box(ui, &label, *b, true) { + change = Some((key.clone(), json!(!b))); + } + } + Value::Number(n) => { + let (x, id) = (n.as_f64().unwrap_or(0.0), ("fx", item, k, key.as_str())); + let suffix = ui_keys.and_then(|u| u.unit.as_deref()).map(crate::dialogs::unit_suffix).unwrap_or_default(); + let nx = if vectorcraft_effects::is_length(&e.id, key, relative) { + widgets::num_field(ui, id, Some(x), unit, 70.0) + } else { + widgets::plain_field(ui, id, x, &suffix, 2, 70.0) + }; + if let Some(nx) = nx { + change = Some((key.clone(), json!(nx))); + } + } + // Glass's loaded texture shows its size; its dialog's Load Texture… replaces it. + Value::String(s) if key == vectorcraft_effects::pixel::TEXTURE_IMAGE => { + let size = vectorcraft_effects::pixel::texture(s).map(|t| t.size()); + widgets::dim_label(ui, &size.map_or_else(String::new, |(w, h)| format!("{w} × {h} px"))); + } + Value::String(s) => { + if let Some(typed) = widgets::exact_text_field(ui, ("fx", item, k, key.as_str()), s, 90.0) { + change = Some((key.clone(), json!(typed))); + } + } + // Free Distort's corners are dragged in its dialog. + _ if e.id == crate::dialogs::free_distort::FREE_DISTORT => { + edit |= widgets::flat_button(ui, tl!("Edit…"), 48.0).clicked(); + } + other => { + widgets::dim_label(ui, &other.to_string()); } } - Value::Number(n) => { - let (x, id) = (n.as_f64().unwrap_or(0.0), ("fx", item, k, key.as_str())); - let suffix = ui_keys.and_then(|u| u.unit.as_deref()).map(crate::dialogs::unit_suffix).unwrap_or_default(); - let nx = if vectorcraft_effects::is_length(&e.id, key, relative) { - widgets::num_field(ui, id, Some(x), unit, 70.0) - } else { - widgets::plain_field(ui, id, x, &suffix, 2, 70.0) - }; - if let Some(nx) = nx { - change = Some((key.clone(), json!(nx))); - } - } - // Glass's loaded texture shows its size; its dialog's Load Texture… replaces it. - Value::String(s) if key == vectorcraft_effects::pixel::TEXTURE_IMAGE => { - let size = vectorcraft_effects::pixel::texture(s).map(|t| t.size()); - widgets::dim_label(ui, &size.map_or_else(String::new, |(w, h)| format!("{w} × {h} px"))); - } - Value::String(s) => { - if let Some(typed) = widgets::exact_text_field(ui, ("fx", item, k, key.as_str()), s, 90.0) { - change = Some((key.clone(), json!(typed))); - } - } - // Free Distort's corners are dragged in its dialog. - _ if e.id == crate::dialogs::free_distort::FREE_DISTORT => { - edit |= widgets::flat_button(ui, tl!("Edit…"), 48.0).clicked(); - } - other => { - widgets::dim_label(ui, &other.to_string()); - } - } + }); }); } }); @@ -1532,4 +1536,29 @@ mod tests { assert!(shown.contains(&want), "{want} in {shown:?}"); } } + + #[test] + fn inline_stroke_weight_and_effect_fields_are_named() { + use egui_kittest::kittest::Queryable; + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap(); + app.run("appearance.addStroke", json!({})).unwrap(); + app.run("effect.apply", json!({"effect": "stylize.dropShadow"})).unwrap(); + let mut first = true; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(360.0, 760.0)).with_accessibility_check(false).build_ui_state( + move |ui, app: &mut VectorcraftApp| { + if std::mem::replace(&mut first, false) { + crate::theme::install_fonts(ui.ctx()); + set_pstate(ui.ctx(), "ap-fx-open-None-0", true); + } + show(app, ui); + }, + app, + ); + h.run_steps(4); + assert_eq!(h.get_all_by_role_and_label(egui::accesskit::Role::TextInput, "Weight:").count(), 2, "both stroke rows are named"); + let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Blur"); + let _ = h.get_by_role_and_label(egui::accesskit::Role::RadioButton, "Color"); + } } diff --git a/crates/ui-egui/src/panels/artboards.rs b/crates/ui-egui/src/panels/artboards.rs index a3aeaa5c0..2acd0d16d 100644 --- a/crates/ui-egui/src/panels/artboards.rs +++ b/crates/ui-egui/src/panels/artboards.rs @@ -76,7 +76,7 @@ pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) -> bool { // The field shows the name as it is (names are never translated). A new name is applied on Enter // or a click elsewhere, and an empty field or Escape keeps the old one, as in the Properties panel. ui.label(egui::RichText::new(tl!("Name:")).size(12.0).color(t.text)); - if let Some(name) = widgets::text_field(ui, ("cb-ab", "name"), Some(&ab.name), 120.0, 1).filter(|n| !n.is_empty()) { + if let Some(name) = widgets::text_field_named(ui, ("cb-ab", "name"), Some(tl!("Name:")), Some(&ab.name), 120.0, 1).filter(|n| !n.is_empty()) { app.run("artboard.setProps", json!({"index": i, "name": name})).ok(); } if widgets::icon_button(ui, "dc-artboard-options", tl!("Artboard Options"), false, 24.0).clicked() { @@ -317,7 +317,7 @@ fn toggle(app: &mut VectorcraftApp, i: usize, n: usize) { /// Whether the panel has the keyboard: one of its rows was clicked, and in every frame since then /// the panel was drawn and the pointer was not pressed outside its list. fn has_keyboard(app: &VectorcraftApp, ctx: &egui::Context) -> bool { - pstate::>(ctx, "ab-keys").is_some_and(|f| f + 1 >= app.frame) + pstate::>(ctx, "ab-keys").is_some_and(|f| f + 1 >= app.frame_iteration()) } /// Whether Select All's shortcut selects every artboard ([`select_all`]) instead of the art: the @@ -422,7 +422,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let opened_on: Vec = pstate(ui.ctx(), "ab-edit-all"); let targets = if opened_on.len() > 1 && opened_on.contains(&i) { opened_on } else { vec![i] }; let shown = if targets.len() > 1 { "" } else { name.as_str() }; - let id = ui.id().with(("ab-name", i)); + let id = ui.scope_id().with(("ab-name", i)); let mut buf: String = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_else(|| shown.to_string()); let kept = widgets::own_field_shift_enter(ui, id, &mut buf, shown); let te = ui.put(name_rect, egui::TextEdit::singleline(&mut buf).id(id).font(egui::FontId::proportional(12.0))); @@ -449,7 +449,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // (dimmed under the pointer while it isn't), and a click locks or unlocks it. let is_locked = locked.get(i).copied().unwrap_or(false); let lock = egui::Rect::from_center_size(r.right_center() - vec2(36.0, 0.0), vec2(14.0, 14.0)); - let lresp = ui.interact(lock, ui.id().with(("ab-lock", i)), Sense::click()); + let lresp = ui.interact(lock, ui.scope_id().with(("ab-lock", i)), Sense::click()); if is_locked { icons::paint(ui, "lock", egui::Rect::from_center_size(lock.center(), vec2(12.0, 12.0)), t.icon); } else if lresp.hovered() { @@ -459,7 +459,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { app.run("artboard.setProps", json!({"index": i, "locked": !is_locked})).ok(); } let opt = egui::Rect::from_center_size(r.right_center() - vec2(14.0, 0.0), vec2(14.0, 14.0)); - let oresp = ui.interact(opt, ui.id().with(("ab-opt", i)), Sense::click()); + let oresp = ui.interact(opt, ui.scope_id().with(("ab-opt", i)), Sense::click()); icons::paint(ui, "dc-artboard-options", opt, if oresp.hovered() { t.text_strong } else { t.icon }); let oresp = oresp.on_hover_text(tl!("Artboard Options")); // A click on the active artboard's icon opens its Artboard Options. A click on @@ -505,7 +505,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // pressed outside the list or the panel is not drawn (`ab-keys`: the frame it last had it in). let pressed_outside = ui.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| !list_rect.contains(p))); let keys = row_clicked || (has_keyboard(app, ui.ctx()) && !pressed_outside); - set_pstate(ui.ctx(), "ab-keys", keys.then_some(app.frame)); + set_pstate(ui.ctx(), "ab-keys", keys.then_some(app.frame_iteration())); let n = abs.len(); widgets::bottom_bar(ui, |ui| { if widgets::icon_button_enabled(ui, "dc-rearrange", tl!("Rearrange All Artboards"), false, n > 1, 24.0).clicked() { @@ -1435,7 +1435,7 @@ mod tests { .collect(); let top_left = points.iter().min_by(|a, b| (a.top(), a.left()).partial_cmp(&(b.top(), b.left())).unwrap()).unwrap(); bar_click(&mut app, &ctx, top_left.center()); - assert_eq!(ctx.data(|d| d.get_temp::(egui::Id::new("refpt"))), Some(0), "the shared reference point is the top-left one"); + assert_eq!(ctx.data(|d| d.get_temp::(egui::Id::unique("refpt"))), Some(0), "the shared reference point is the top-left one"); let w = text_rect(&bar_texts(&mut app, &ctx), "W:"); bar_type(&mut app, &ctx, egui::pos2(w.right() + 40.0, w.center().y), "300"); want[1] = vectorcraft_geom::Rect::new(b.x0, b.y0, b.x0 + 300.0, b.y1); diff --git a/crates/ui-egui/src/panels/asset_export.rs b/crates/ui-egui/src/panels/asset_export.rs index d723dffc3..737655540 100644 --- a/crates/ui-egui/src/panels/asset_export.rs +++ b/crates/ui-egui/src/panels/asset_export.rs @@ -108,7 +108,7 @@ struct Events { /// The asset tiles: thumbnail, name (a text field while renaming), selection. fn tiles(ui: &mut Ui, st: &DocState, sel: &[u64], ev: &mut Events) { let t = Tokens::get(ui.ctx()); - let rename_id = egui::Id::new("asset-export-rename"); + let rename_id = egui::Id::unique("asset-export-rename"); let renaming: Option<(u64, String)> = ui.data(|d| d.get_temp(rename_id)); ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing = vec2(4.0, 4.0); @@ -297,7 +297,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { tiles(ui, st, &sel, &mut ev); } }); - let zone = ui.interact(out.inner_rect, ui.id().with("asset-export-drop"), Sense::hover()); + let zone = ui.interact(out.inner_rect, ui.scope_id().with("asset-export-drop"), Sense::hover()); drop_zone(ui, &zone, &mut ev); }); ui.add_space(6.0); diff --git a/crates/ui-egui/src/panels/attributes.rs b/crates/ui-egui/src/panels/attributes.rs index ad3fdd7fa..d387735de 100644 --- a/crates/ui-egui/src/panels/attributes.rs +++ b/crates/ui-egui/src/panels/attributes.rs @@ -27,7 +27,7 @@ fn state(app: &VectorcraftApp, ctx: &egui::Context) -> (AttributesInfo, bool) { let Some(st) = app.session.active() else { return Default::default() }; let sel = &st.selection.objects; let key: Key = (st.uid, st.revision, sel.len(), sel.first().copied(), sel.last().copied()); - let id = egui::Id::new("attr-state"); + let id = egui::Id::unique("attr-state"); if let Some((k, v)) = ctx.data(|d| d.get_temp::<(Key, (AttributesInfo, bool))>(id)) && k == key { @@ -123,7 +123,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { widgets::divider(ui); widgets::subheader(ui, tl!("Note:")); ui.add_enabled_ui(has, |ui| { - if let Some(n) = widgets::text_field(ui, "attr-note", info.note.as_deref(), ui.available_width(), 3) { + if let Some(n) = widgets::text_field_named(ui, "attr-note", Some(tl!("Note:")), info.note.as_deref(), ui.available_width(), 3) { set(app, json!({ "note": n })); } }); diff --git a/crates/ui-egui/src/panels/brushes.rs b/crates/ui-egui/src/panels/brushes.rs index f02de35df..e85f603ec 100644 --- a/crates/ui-egui/src/panels/brushes.rs +++ b/crates/ui-egui/src/panels/brushes.rs @@ -248,7 +248,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }); // Art dragged off the canvas and dropped on the list opens New Brush for that art, as the New // Brush button opens it for the selection. - let zone = ui.interact(list_rect, ui.id().with("brushes-drop"), Sense::hover()); + let zone = ui.interact(list_rect, ui.scope_id().with("brushes-drop"), Sense::hover()); if let Some(ids) = widgets::art_drop(ui, &zone) && let Err(e) = app.run("ui.newBrush", json!({ "ids": ids })) { diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index e7986dd5e..7156d0003 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -355,8 +355,16 @@ fn text_copy(app: &mut VectorcraftApp) -> bool { fn text_key(app: &mut VectorcraftApp, k: ToolKey, m: Mods) { let v = app.view_info(); - if let Err(e) = app.session.tool_key(k, m, v) { - app.status(e.to_string()); + let target_before = crate::ime::target(&mut app.session, v); + let before = crate::ime::stamp(&mut app.session, v); + let r = app.session.tool_key(k, m, v); + match &r { + Err(e) => app.status(e.to_string()), + Ok(_) => { + if let Some(ctx) = app.ui_ctx.clone().or_else(|| CTX.get().cloned()) { + crate::ime::note_application_edit(app, &ctx, v, target_before, before); + } + } } } @@ -364,8 +372,16 @@ fn text_paste(app: &mut VectorcraftApp, s: Option) { let s = s.unwrap_or_else(|| CLIP.lock().unwrap_or_else(|e| e.into_inner()).clone()); if !s.is_empty() { let v = app.view_info(); - if let Err(e) = app.session.tool_text(&s, v) { - app.status(e.to_string()); + let target_before = crate::ime::target(&mut app.session, v); + let before = crate::ime::stamp(&mut app.session, v); + let r = app.session.tool_text(&s, v); + match &r { + Err(e) => app.status(e.to_string()), + Ok(_) => { + if let Some(ctx) = app.ui_ctx.clone().or_else(|| CTX.get().cloned()) { + crate::ime::note_application_edit(app, &ctx, v, target_before, before); + } + } } } } @@ -490,7 +506,9 @@ fn cell(ui: &mut Ui, label: &str, tip: &str, add: impl FnOnce(&mut Ui)) { let l = ui.add_sized(vec2(22.0, 24.0), egui::Label::new(egui::RichText::new(label).size(11.5).strong().color(t.text))).on_hover_text(tl!(tip)); crate::scrub::note_label(ui, l.rect); - add(ui); + // The visible label names the value field beside it, also in a grid cell that draws more + // than one widget (egui 0.37's strict accessibility check). + ui.label_inputs_by(l.id, add); }); } @@ -1104,7 +1122,7 @@ mod tests { let input = egui::RawInput { events, ..Default::default() }; ctx.run_ui(input, |ui| { axis_sliders(app, ui, &face); - slider = ctx.read_response(ui.id().with(("ch-axis", *b"wght"))).map(|r| r.rect); + slider = ctx.read_response(ui.scope_id().with(("ch-axis", *b"wght"))).map(|r| r.rect); }) .textures_delta .clear(); diff --git a/crates/ui-egui/src/panels/color.rs b/crates/ui-egui/src/panels/color.rs index 43f519611..379a3a025 100644 --- a/crates/ui-egui/src/panels/color.rs +++ b/crates/ui-egui/src/panels/color.rs @@ -501,7 +501,7 @@ fn tint_rows(ui: &mut Ui, tint: &Tint, slider_w: f32, field_w: f32) -> Option<(f let percent = (tint.tint * 100.0).round() as f64; let shown = widgets::SliderValue::field(percent, 0.0..=100.0, "%", 0); let (nv, phase) = widgets::color_slider_valued(ui, ("color-slider", "tint"), "T", Some(shown), tint.tint, slider_w, &track); - let field = widgets::plain_field(ui, ("color-field", "tint"), percent, "%", 0, field_w); + let field = widgets::plain_field_named(ui, ("color-field", "tint"), Some("T"), percent, "%", 0, field_w); match (nv, field) { (Some(v), _) => Some(((v * 100.0).round() / 100.0, phase)), (None, Some(v)) => Some(((v as f32 / 100.0).clamp(0.0, 1.0), Live::Released)), @@ -522,7 +522,7 @@ enum SpectrumHit { fn spectrum(ui: &mut Ui, mode: Mode, size: egui::Vec2) -> Option { let t = Tokens::get(ui.ctx()); let (r, _) = ui.allocate_exact_size(size, Sense::hover()); - let resp = ui.interact(r, ui.id().with("color-spectrum"), Sense::click_and_drag()); + let resp = ui.interact(r, ui.scope_id().with("color-spectrum"), Sense::click_and_drag()); ui.painter().add(widgets::color_mesh(r, (72, 12), &|x, y| to32(&spectrum_at(mode, x, y)))); ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); if let Some(p) = resp.hover_pos() { @@ -633,7 +633,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }; new = Some((from_components(mode, &c2), phase, c2)); } - if let Some(fv) = widgets::plain_field(ui, ("color-field", i), v as f64, mode.suffix(i), 0, field_w) + if let Some(fv) = widgets::plain_field_named(ui, ("color-field", i), Some(lbl), v as f64, mode.suffix(i), 0, field_w) && enabled { let mut c2 = comps.clone(); @@ -674,7 +674,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if matches!(mode, Mode::Rgb | Mode::WebSafe | Mode::Hsb) { ui.add_space((ui.available_width() - 96.0).max(4.0)); let hex = color.map(|c| hex_digits(&c)).unwrap_or_default(); - if let Some(c) = widgets::hex_field(ui, "hex", &hex).as_deref().and_then(parse_hex) { + if let Some(c) = widgets::hex_field_named(ui, "hex", Some(tl!("Color")), &hex).as_deref().and_then(parse_hex) { new = Some((c, Live::Released, components(mode, &c))); } } @@ -779,7 +779,7 @@ mod tests { let input = egui::RawInput { events, ..Default::default() }; let mut full = ctx.run_ui(input, |ui| { show(app, ui); - out = (ctx.read_response(ui.id().with(&id)).map(|r| r.rect), ui.min_rect().height()); + out = (ctx.read_response(ui.scope_id().with(&id)).map(|r| r.rect), ui.min_rect().height()); }); full.textures_delta.clear(); out @@ -1094,7 +1094,7 @@ mod tests { let mut rect = None; let mut out = ctx.run_ui(input, |ui| { crate::panels::proxy(app, ui, 40.0); - rect = ctx.read_response(ui.id().with("stroke-proxy")).map(|r| r.rect); + rect = ctx.read_response(ui.scope_id().with("stroke-proxy")).map(|r| r.rect); }); out.textures_delta.clear(); rect diff --git a/crates/ui-egui/src/panels/color_guide.rs b/crates/ui-egui/src/panels/color_guide.rs index 81be4659d..aa19103ce 100644 --- a/crates/ui-egui/src/panels/color_guide.rs +++ b/crates/ui-egui/src/panels/color_guide.rs @@ -156,7 +156,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let w = strip.width() / g.colors.len() as f32; for (i, c) in g.colors.iter().enumerate() { let r = Rect::from_min_size(strip.min + vec2(i as f32 * w, 0.0), vec2(w, strip.height())); - let resp = ui.interact(r, ui.id().with(("cg-h", i)), Sense::click()); + let resp = ui.interact(r, ui.scope_id().with(("cg-h", i)), Sense::click()); ui.painter().rect_filled(r, 0.0, c32(c)); if resp.on_hover_text(c.to_hex()).clicked() { chosen = Some(*c); @@ -176,7 +176,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let (rr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 18.0), Sense::hover()); for (ci, v) in row.iter().enumerate() { let r = Rect::from_min_size(rr.min + vec2(x(ci), 0.0), vec2(x(ci + 1) - x(ci) - gap, 17.0)); - let resp = ui.interact(r, ui.id().with(("cg-v", ri, ci)), Sense::click()); + let resp = ui.interact(r, ui.scope_id().with(("cg-v", ri, ci)), Sense::click()); ui.painter().rect_filled(r, 0.0, c32(v)); if sel.contains(&(ri, ci)) { ui.painter().rect_stroke(r.shrink(1.0), 0.0, egui::Stroke::new(1.0, egui::Color32::WHITE), egui::StrokeKind::Inside); diff --git a/crates/ui-egui/src/panels/color_themes.rs b/crates/ui-egui/src/panels/color_themes.rs index 570265998..d736fa415 100644 --- a/crates/ui-egui/src/panels/color_themes.rs +++ b/crates/ui-egui/src/panels/color_themes.rs @@ -176,7 +176,7 @@ fn create(app: &mut VectorcraftApp, ui: &mut Ui) { let cw = row.width() / THEME_SIZE as f32; for (i, c) in d.colors.iter().enumerate() { let r = Rect::from_min_size(row.min + vec2(i as f32 * cw, 0.0), vec2(cw - 3.0, row.height())); - let resp = ui.interact(r, ui.id().with(("ct-chip", i)), Sense::click()); + let resp = ui.interact(r, ui.scope_id().with(("ct-chip", i)), Sense::click()); ui.painter().rect_filled(r, 2.0, c32(c)); ui.painter().rect_stroke(r, 2.0, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Inside); if i == d.sel { @@ -189,7 +189,7 @@ fn create(app: &mut VectorcraftApp, ui: &mut Ui) { ui.add_space(6.0); ui.horizontal(|ui| { let hex = d.colors.get(d.sel).map(|c| c.to_hex().trim_start_matches('#').to_string()).unwrap_or_default(); - if let Some(c) = widgets::hex_field(ui, "ct-hex", &hex).and_then(|h| Color::from_hex(&h)) { + if let Some(c) = widgets::hex_field_named(ui, "ct-hex", Some(tl!("Color")), &hex).and_then(|h| Color::from_hex(&h)) { d.set_selected(c); } let current = active_paint(app).color(); @@ -202,7 +202,7 @@ fn create(app: &mut VectorcraftApp, ui: &mut Ui) { ui.add_space(4.0); ui.horizontal(|ui| { ui.label(egui::RichText::new(tl!("Name:")).color(t.text_dim)); - if let Some(n) = widgets::text_field(ui, "ct-name", Some(&d.name), ui.available_width() - 4.0, 1) { + if let Some(n) = widgets::text_field_named(ui, "ct-name", Some(tl!("Name:")), Some(&d.name), ui.available_width() - 4.0, 1) { d.name = n; } }); @@ -250,7 +250,7 @@ fn my_themes(app: &mut VectorcraftApp, ui: &mut Ui) { egui::ScrollArea::vertical().id_salt("ct-list").max_height(300.0).auto_shrink([false, true]).show(ui, |ui| { for th in &app.session.prefs.color_themes { let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover()); - let resp = ui.interact(r, ui.id().with(("ct-theme", &th.name)), Sense::click()); + let resp = ui.interact(r, ui.scope_id().with(("ct-theme", &th.name)), Sense::click()); let selected = pick.as_deref() == Some(th.name.as_str()); if selected { ui.painter().rect_filled(r, 0.0, t.row_selected); diff --git a/crates/ui-egui/src/panels/glyphs.rs b/crates/ui-egui/src/panels/glyphs.rs index a5675d7e0..ea7afa520 100644 --- a/crates/ui-egui/src/panels/glyphs.rs +++ b/crates/ui-egui/src/panels/glyphs.rs @@ -145,12 +145,16 @@ fn glyph_texture(ctx: &egui::Context, face: &vectorcraft_text::FontFace, gid: u3 } /// Insert `ch` at the Type tool's caret, else append it to the selected text. -fn insert(app: &mut VectorcraftApp, ch: char) { +fn insert(app: &mut VectorcraftApp, ctx: &egui::Context, ch: char) { let s = ch.to_string(); if app.session.tool_wants_text() { let v = app.view_info(); - if let Err(e) = app.session.tool_text(&s, v) { - app.status(e.to_string()); + let target_before = crate::ime::target(&mut app.session, v); + let before = crate::ime::stamp(&mut app.session, v); + let r = app.session.tool_text(&s, v); + match &r { + Err(e) => app.status(e.to_string()), + Ok(_) => crate::ime::note_application_edit(app, ctx, v, target_before, before), } app.sync_views(); } else if super::character::text_style(app).is_some() { @@ -188,7 +192,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let rows = chars.len().div_ceil(cols); let px = (cell * ui.ctx().pixels_per_point()).round().clamp(8.0, 256.0) as u32; let list_h = (ui.available_height() - 64.0).max(cell * 3.0); - let hover_id = egui::Id::new("gl-hover"); + let hover_id = egui::Id::unique("gl-hover"); egui::ScrollArea::vertical().max_height(list_h).auto_shrink([false, false]).show_rows(ui, cell, rows, |ui, range| { for r in range { ui.horizontal(|ui| { @@ -204,7 +208,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.painter().image(tex.id(), rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), t.text_strong); let resp = resp.on_hover_text(format!("U+{:04X} {c} (glyph {gid})\n{}", c as u32, tl!("Double-click to insert"))); if resp.double_clicked() { - insert(app, c); + insert(app, ui.ctx(), c); } } }); @@ -262,7 +266,7 @@ fn zoom_button(ui: &mut Ui, id: &str, label: &str, tip: &str, enabled: bool) -> let t = Tokens::get(ui.ctx()); let (rect, _) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::hover()); let sense = if enabled { Sense::click() } else { Sense::hover() }; - let resp = ui.interact(rect, ui.id().with(("gl-zoom", id)), sense); + let resp = ui.interact(rect, ui.scope_id().with(("gl-zoom", id)), sense); let bg = if enabled && resp.hovered() { t.hover } else { Color32::TRANSPARENT }; ui.painter().rect_filled(rect, 2, bg); let border = if enabled { t.button_border } else { t.border }; diff --git a/crates/ui-egui/src/panels/gradient.rs b/crates/ui-egui/src/panels/gradient.rs index 84c4931f0..b7c78f007 100644 --- a/crates/ui-egui/src/panels/gradient.rs +++ b/crates/ui-egui/src/panels/gradient.rs @@ -298,7 +298,18 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let angle = g.geom.map_or(g.angle, |x| x.angle_deg()); let set = ui .add_enabled_ui(placed, |ui| { - widgets::spin_plain(ui, "grad-angle", (angle * 10.0).round() / 10.0, "°", 1, 104.0, 1.0, -180.0, &ANGLE_PRESETS) + widgets::spin_plain_named( + ui, + "grad-angle", + Some(tl!("Angle")), + (angle * 10.0).round() / 10.0, + "°", + 1, + 104.0, + 1.0, + -180.0, + &ANGLE_PRESETS, + ) }) .inner; if let Some(a) = set { @@ -312,7 +323,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.horizontal(|ui| { icons::icon(ui, "scaling", 15.0, if radial { t.icon } else { t.text_disabled }).on_hover_text(tl!("Aspect Ratio")); let asp = g.geom.map_or(100.0, |x| (x.aspect * 1000.0).round() / 10.0); - let set = ui.add_enabled_ui(radial, |ui| widgets::spin_plain(ui, "grad-aspect", asp, "%", 1, 104.0, 1.0, 0.5, &ASPECT_PRESETS)).inner; + let set = ui + .add_enabled_ui(radial, |ui| { + widgets::spin_plain_named(ui, "grad-aspect", Some(tl!("Aspect Ratio")), asp, "%", 1, 104.0, 1.0, 0.5, &ASPECT_PRESETS) + }) + .inner; if let Some(a) = set { edit(app, json!({"aspect": a}), Live::Released); } @@ -436,7 +451,7 @@ fn thumbnail(app: &mut VectorcraftApp, ui: &mut Ui, g: &GradientPaint, is_grad: widgets::drag_source(ui, &resp, || PanelDrag::paint(Paint::Gradient(Box::new(g.clone())))); let dr = Rect::from_min_size(r.right_top() + vec2(1.0, 0.0), vec2(14.0, 40.0)); ui.advance_cursor_after_rect(dr); - let dresp = ui.interact(dr, ui.id().with("grad-swatches"), Sense::click()); + let dresp = ui.interact(dr, ui.scope_id().with("grad-swatches"), Sense::click()); icons::paint(ui, "chevron-down", Rect::from_center_size(dr.center(), vec2(12.0, 12.0)), if dresp.hovered() { t.text_strong } else { t.icon }); let dresp = dresp.on_hover_text(tl!("Gradient swatches")); egui::Popup::menu(&dresp).show(|ui| { @@ -609,8 +624,8 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) { // The ramp and the strip below it, under the stops and diamonds (interacted first): a click // below adds a stop, and without a gradient either applies one. let below = Rect::from_min_max(pos2(bar.left(), bar.bottom()), pos2(bar.right(), area.bottom())); - let add_resp = ui.interact(below, ui.id().with("grad-add"), Sense::click()); - let bar_resp = ui.interact(bar, ui.id().with("grad-bar"), Sense::click()); + let add_resp = ui.interact(below, ui.scope_id().with("grad-add"), Sense::click()); + let bar_resp = ui.interact(bar, ui.scope_id().with("grad-bar"), Sense::click()); if add_resp.hovered() && is_grad { ui.ctx().set_cursor_icon(egui::CursorIcon::Copy); } @@ -628,7 +643,7 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) { let Some(p) = midpoint_pos(stops, i) else { continue }; let c = pos2(x_of(p), bar.top() - 5.0); let rect = Rect::from_center_size(c, vec2(10.0, 10.0)); - let resp = ui.interact(rect, ui.id().with(("grad-mid", i)), Sense::click_and_drag()); + let resp = ui.interact(rect, ui.scope_id().with(("grad-mid", i)), Sense::click_and_drag()); let lit = drag == Drag::Mid(i) || mid_sel == Some(i) || resp.hovered(); let pts = vec![c + vec2(0.0, -4.0), c + vec2(4.0, 0.0), c + vec2(0.0, 4.0), c + vec2(-4.0, 0.0)]; ui.painter().add(egui::Shape::convex_polygon(pts, if lit { Color32::WHITE } else { t.icon }, Stroke::new(1.0, t.border))); @@ -659,7 +674,7 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) { let x = x_of(s.offset); let m = marker(s.offset); let top = m.top(); - let resp = ui.interact(m, ui.id().with(("grad-stop", i)), Sense::click_and_drag()); + let resp = ui.interact(m, ui.scope_id().with(("grad-stop", i)), Sense::click_and_drag()); let (dragging_this, copy) = match drag { Drag::Stop { index, copy } => (index == i, copy), _ => (false, false), @@ -896,10 +911,15 @@ mod tests { v } - /// The rect of the widget its parent ui keyed `key` (last frame). + /// The rect of the widget keyed `key` (last frame). egui 0.37's `WidgetRect.parent_id` is + /// the parent Ui's unstable `unique_id`, not the stable `scope_id` widgets are salted with; + /// debug builds remember the `with(key)` that made each id, so match that. fn widget(&self, key: impl std::hash::Hash + std::fmt::Debug + Copy) -> Rect { + let suffix = format!(".with({key:?})"); self.ctx - .viewport(|vp| vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).find(|w| w.id == w.parent_id.with(key)).map(|w| w.rect)) + .viewport(|vp| { + vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).find(|w| format!("{:?}", w.id).ends_with(&suffix)).map(|w| w.rect) + }) .expect("widget drawn") } @@ -1190,4 +1210,24 @@ mod tests { stop_eyedropper(&mut app); assert_eq!((app.session.tool_id(), app.session.tool_options()), ("eyedropper", json!({"stop": "gradient", "brushKeyColor": false}))); } + + #[test] + fn angle_and_aspect_fields_are_named() { + use egui_kittest::kittest::Queryable; + let app = app(json!({"gradient": {}})); + let mut fonts = false; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(280.0, 640.0)).with_accessibility_check(false).build_ui_state( + move |ui, app: &mut VectorcraftApp| { + if !std::mem::replace(&mut fonts, true) { + crate::theme::install_fonts(ui.ctx()); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Angle"); + let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Aspect Ratio"); + } } diff --git a/crates/ui-egui/src/panels/graphic_styles.rs b/crates/ui-egui/src/panels/graphic_styles.rs index 5aae7d397..1d355b11e 100644 --- a/crates/ui-egui/src/panels/graphic_styles.rs +++ b/crates/ui-egui/src/panels/graphic_styles.rs @@ -230,8 +230,8 @@ pub(super) fn outline(ui: &Ui, r: Rect, on: bool, hovered: bool) { fn object_preview(app: &VectorcraftApp, ctx: &egui::Context, g: &GraphicStyle, look: u64, px: u32) -> Option { let st = app.session.active()?; let n = st.doc.node(*st.selection.objects.first()?)?; - let slot = egui::Id::new("gs-large-object"); - let key = egui::Id::new((st.uid, st.revision, look, px, app.session.prefs.override_char_color)); + let slot = egui::Id::unique("gs-large-object"); + let key = egui::Id::unique((st.uid, st.revision, look, px, app.session.prefs.override_char_color)); if let Some((k, tex)) = ctx.data(|d| d.get_temp::<(egui::Id, TextureHandle)>(slot)) && k == key { @@ -253,7 +253,7 @@ fn large_preview(app: &VectorcraftApp, ctx: &egui::Context, s: &Shown, i: usize) let px = (LARGE * ctx.pixels_per_point()).round() as u32; let tex = object_preview(app, ctx, g, look, px).or_else(|| thumb(app, ctx, Paints::of(s.d), g, look, s.preview, LARGE)); let t = Tokens::get(ctx); - let area = egui::Area::new(egui::Id::new("gs-large")).order(egui::Order::Tooltip).fixed_pos(at + vec2(14.0, 14.0)); + let area = egui::Area::new(egui::Id::unique("gs-large")).order(egui::Order::Tooltip).fixed_pos(at + vec2(14.0, 14.0)); area.interactable(false).show(ctx, |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { let (r, _) = ui.allocate_exact_size(vec2(LARGE, LARGE), Sense::hover()); @@ -477,7 +477,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { None => tiles(app, ui, &shown, &sel, &mut ev), } }); - let zone = ui.interact(out.inner_rect, ui.id().with("gs-drop"), Sense::hover()); + let zone = ui.interact(out.inner_rect, ui.scope_id().with("gs-drop"), Sense::hover()); zone_input(ui, &zone, &doc, &mut ev); }); act(app, ui, &shown, ev, false); diff --git a/crates/ui-egui/src/panels/history.rs b/crates/ui-egui/src/panels/history.rs index 9339ad0f0..7c7acbae7 100644 --- a/crates/ui-egui/src/panels/history.rs +++ b/crates/ui-egui/src/panels/history.rs @@ -116,16 +116,17 @@ mod tests { assert_eq!(steps_to(3, 3), 0); } - /// The step names the panel paints, top to bottom. Without an image loader, each row's icon - /// paints a `⚠` instead, which is left out. + /// The step names the panel paints, top to bottom. The SVG loaders are installed so each row's + /// icon draws as an image instead of egui's ⚠ placeholder. fn listed(app: &mut VectorcraftApp) -> Vec { let ctx = egui::Context::default(); + egui_extras::install_image_loaders(&ctx); let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui)); out.textures_delta.clear(); out.shapes .iter() .filter_map(|c| match &c.shape { - egui::Shape::Text(t) if t.galley.text() != "⚠" => Some(t.galley.text().to_string()), + egui::Shape::Text(t) => Some(t.galley.text().to_string()), _ => None, }) .collect() diff --git a/crates/ui-egui/src/panels/image_trace.rs b/crates/ui-egui/src/panels/image_trace.rs index 939d48916..d7449bb06 100644 --- a/crates/ui-egui/src/panels/image_trace.rs +++ b/crates/ui-egui/src/panels/image_trace.rs @@ -266,7 +266,7 @@ fn slider(ui: &mut Ui, label: &str, v: &mut f64, range: std::ops::RangeInclusive ui.visuals_mut().selection.bg_fill = t.accent; let r = ui.add(egui::Slider::new(v, range).suffix(suffix).integer().trailing_fill(true)); if cfg!(test) { - ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new(("trace-slider", label)), r.rect)); + ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique(("trace-slider", label)), r.rect)); } out = (r.changed(), r.drag_stopped() || (r.changed() && !r.dragged())); }); @@ -324,7 +324,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let held = app.ui.trace_hold_source.is_some(); let r = widgets::icon_button_enabled(ui, "eye", tl!("Press and hold to view the source image"), held, is_trace, eye); if cfg!(test) { - ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("trace-hold-source"), r.rect)); + ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique("trace-hold-source"), r.rect)); } hold_source(app, ui.ctx(), is_trace && r.is_pointer_button_down_on()); }); @@ -679,7 +679,7 @@ mod tests { let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx); frame(&mut app, &ctx, vec![]); - let rail = ctx.data(|d| d.get_temp::(egui::Id::new(("trace-slider", "Threshold")))).unwrap(); + let rail = ctx.data(|d| d.get_temp::(egui::Id::unique(("trace-slider", "Threshold")))).unwrap(); let at = rail.left_center() + vec2(20.0, 0.0); assert_eq!(first_selected(&app).unwrap().children().unwrap().len(), 2, "source and ink path"); for pressed in [true, false] { @@ -710,7 +710,7 @@ mod tests { click(&mut app, &ctx, "Preview"); assert!(!app.ui.image_trace_preview); let steps = app.session.active().unwrap().history.undo.len(); - let rail = ctx.data(|d| d.get_temp::(egui::Id::new(("trace-slider", "Threshold")))).unwrap(); + let rail = ctx.data(|d| d.get_temp::(egui::Id::unique(("trace-slider", "Threshold")))).unwrap(); let at = rail.left_center() + vec2(20.0, 0.0); for pressed in [true, false] { frame( @@ -850,7 +850,7 @@ mod tests { crate::theme::install_fonts(&ctx); for (label, key) in [("Threshold", "threshold"), ("Paths", "paths"), ("Corners", "corners"), ("Noise", "noise")] { frame(&mut app, &ctx, vec![]); - let rail = ctx.data(|d| d.get_temp::(egui::Id::new(("trace-slider", label)))).unwrap(); + let rail = ctx.data(|d| d.get_temp::(egui::Id::unique(("trace-slider", label)))).unwrap(); let at = rail.left_center() + vec2(20.0, 0.0); let press = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; let before = target(&app).2.unwrap()["params"][key].clone(); @@ -891,7 +891,7 @@ mod tests { let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); let revision = |app: &VectorcraftApp| app.session.active().unwrap().revision; let (steps, rev) = (undo(&app), revision(&app)); - let at = ctx.data(|d| d.get_temp::(egui::Id::new("trace-hold-source"))).unwrap().center(); + let at = ctx.data(|d| d.get_temp::(egui::Id::unique("trace-hold-source"))).unwrap().center(); let press = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; frame(&mut app, &ctx, vec![Event::PointerMoved(at), press(true)]); assert_eq!(app.ui.trace_hold_source.as_deref(), Some(&vec![id]), "held: the object shows its source image"); diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index e634403cd..605201008 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -127,11 +127,11 @@ struct ColumnDrag { } fn key(name: &str) -> egui::Id { - egui::Id::new(("layers-panel", name)) + egui::Id::unique(("layers-panel", name)) } fn rename_id() -> egui::Id { - egui::Id::new("layers-rename") + egui::Id::unique("layers-rename") } /// Ghost rows are drawn at this opacity. @@ -526,7 +526,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let Some(st) = app.session.active() else { return }; // A rename belongs to the document it started in (node ids are per document): drop it when // another document shows, so it can't rename that document's node with the same id. - let shown_doc = egui::Id::new("layers-doc"); + let shown_doc = egui::Id::unique("layers-doc"); let before = ui.data(|d| d.get_temp::(shown_doc)); if before != Some(st.uid) { ui.data_mut(|d| { @@ -572,7 +572,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { .horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { filter_button(app, ui); - crate::widgets::search_field(ui, egui::Id::new("layers-search"), tl!("Search All")) + crate::widgets::search_field(ui, egui::Id::unique("layers-search"), tl!("Search All")) }) .inner }) @@ -647,7 +647,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let released = ui.input(|i| i.pointer.any_released()); // Layers dragged into the empty space under the last row go below the bottom layer (there is // no row there to take the drop). - let below = list_rect.top() - scrolled.inner.state.offset.y + list.len() as f32 * view.h; + let below = list_rect.top() - scrolled.inner.state.clamped_offset().y + list.len() as f32 * view.h; if query.is_empty() && !app.ui.layers_filter.is_on() && let Some(drag) = egui::DragAndDrop::payload::(ui.ctx()) @@ -683,7 +683,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { None => d.remove::(key("column")), }); if released { - ui.data_mut(|d| d.remove::(egui::Id::new("layers-drag"))); + ui.data_mut(|d| d.remove::(egui::Id::unique("layers-drag"))); } // Dragged rows show as a dimmed row under the pointer. if !released @@ -1006,7 +1006,7 @@ fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) { // Disclosure triangle; Alt opens or closes everything inside too. if has_children { let dr = egui::Rect::from_min_size(egui::pos2(x, r.center().y - 8.0), vec2(14.0, 16.0)); - let dresp = ui.interact(dr, ui.id().with(("disc", n.id.0)), Sense::click()); + let dresp = ui.interact(dr, ui.scope_id().with(("disc", n.id.0)), Sense::click()); icons::paint(ui, if open { "chevron-down" } else { "chevron-right" }, dr, t.text_dim); if dresp.clicked() { out.toggle.push((n.id, !open)); @@ -1092,7 +1092,7 @@ fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) { } else if child_sel { ui.painter().rect_filled(egui::Rect::from_center_size(sq, vec2(4.0, 4.0)), 0.0, color); } - let sq_resp = ui.interact(egui::Rect::from_center_size(sq, vec2(18.0, h)), ui.id().with(("selsq", n.id.0)), Sense::click_and_drag()); + let sq_resp = ui.interact(egui::Rect::from_center_size(sq, vec2(18.0, h)), ui.scope_id().with(("selsq", n.id.0)), Sense::click_and_drag()); // Dragging the selected-art square carries the selection to another row. if (is_sel || child_sel) && sq_resp.drag_started() { egui::DragAndDrop::set_payload(ui.ctx(), LayersDrag::Art); @@ -1173,7 +1173,7 @@ fn eye_and_lock(ui: &mut Ui, view: &View, n: &Node, r: egui::Rect, out: &mut Out ui.painter().line_segment([eye.right_top(), eye.right_bottom()], Stroke::new(1.0, t.input_border)); ui.painter().line_segment([lock.right_top(), lock.right_bottom()], Stroke::new(1.0, t.input_border)); let preview = !matches!(n.kind, NodeKind::Layer { preview: false, .. }); - let er = ui.interact(eye, ui.id().with(("eye", n.id.0)), Sense::click_and_drag()); + let er = ui.interact(eye, ui.scope_id().with(("eye", n.id.0)), Sense::click_and_drag()); if n.visible { let icon = egui::Rect::from_center_size(eye.center(), vec2(14.0, 14.0)); if n.is_template() { @@ -1187,7 +1187,7 @@ fn eye_and_lock(ui: &mut Ui, view: &View, n: &Node, r: egui::Rect, out: &mut Out ui.painter().circle_stroke(eye.center(), 7.5, Stroke::new(1.0, t.text_dim)); } } - let lr = ui.interact(lock, ui.id().with(("lock", n.id.0)), Sense::click_and_drag()); + let lr = ui.interact(lock, ui.scope_id().with(("lock", n.id.0)), Sense::click_and_drag()); if n.locked { icons::paint(ui, "lock", egui::Rect::from_center_size(lock.center(), vec2(12.0, 12.0)), t.icon); } else if lr.hovered() { @@ -1288,7 +1288,7 @@ fn drop_target(ui: &Ui, view: &View, n: &Node, r: egui::Rect, indent: f32, open: /// it carries `n`'s appearance ([`PanelDrag::Appearance`]) onto another row's circle (Alt /// copies it) or the trash. fn target_circle(ui: &mut Ui, n: &Node, c: egui::Pos2, h: f32, targeted: bool, actions: &mut Vec<(String, Value)>, t: &Tokens) -> egui::Response { - let resp = ui.interact(egui::Rect::from_center_size(c, vec2(16.0, h)), ui.id().with(("target", n.id.0)), Sense::click_and_drag()); + let resp = ui.interact(egui::Rect::from_center_size(c, vec2(16.0, h)), ui.scope_id().with(("target", n.id.0)), Sense::click_and_drag()); widgets::drag_source(ui, &resp, || PanelDrag::Appearance(n.id)); let held = resp.dnd_hover_payload::().is_some_and(|d| matches!(*d, PanelDrag::Appearance(id) if id != n.id)); if let Some(d) = resp.dnd_release_payload::() @@ -2092,7 +2092,7 @@ mod tests { egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }; // What a double-click on the row does: the name field opens with the name. let open = |app: &mut VectorcraftApp| { - ctx.data_mut(|d| d.insert_temp(egui::Id::new("layers-rename"), (layer.0, name(app)))); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique("layers-rename"), (layer.0, name(app)))); frame(app, &ctx, vec![], false); frame(app, &ctx, vec![], false); }; @@ -2101,7 +2101,7 @@ mod tests { frame(&mut app, &ctx, vec![key(egui::Key::Enter)], false); frame(&mut app, &ctx, vec![], false); assert_eq!(name(&app), "Sky", "Enter keeps the new name"); - assert!(ctx.data(|d| d.get_temp::<(u64, String)>(egui::Id::new("layers-rename"))).is_none(), "and closes the field"); + assert!(ctx.data(|d| d.get_temp::<(u64, String)>(egui::Id::unique("layers-rename"))).is_none(), "and closes the field"); open(&mut app); frame(&mut app, &ctx, vec![select_all, egui::Event::Text("Ground".into())], false); frame(&mut app, &ctx, vec![key(egui::Key::Escape)], false); @@ -2145,7 +2145,7 @@ mod tests { let layer = app.session.doc().unwrap().doc.layers[0].id; let ctx = egui::Context::default(); frame(&mut app, &ctx, vec![], false); - ctx.data_mut(|d| d.insert_temp(egui::Id::new("layers-rename"), (layer.0, "Renamed in the first".to_string()))); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique("layers-rename"), (layer.0, "Renamed in the first".to_string()))); frame(&mut app, &ctx, vec![], false); // A second document, whose first layer has the same node id, comes to the front. app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); @@ -2155,7 +2155,7 @@ mod tests { frame(&mut app, &ctx, vec![enter], false); frame(&mut app, &ctx, vec![], false); assert_eq!(app.session.doc().unwrap().doc.layers[0].display_name(), "Layer 1"); - assert!(ctx.data(|d| d.get_temp::<(u64, String)>(egui::Id::new("layers-rename"))).is_none()); + assert!(ctx.data(|d| d.get_temp::<(u64, String)>(egui::Id::unique("layers-rename"))).is_none()); } #[test] diff --git a/crates/ui-egui/src/panels/libraries.rs b/crates/ui-egui/src/panels/libraries.rs index 9687ae451..05c06f7bd 100644 --- a/crates/ui-egui/src/panels/libraries.rs +++ b/crates/ui-egui/src/panels/libraries.rs @@ -172,7 +172,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }); ui.add_space(4.0); let empty = lib.items().next().is_none() && lib.groups.is_empty(); - let query = if empty { String::new() } else { widgets::search_field(ui, egui::Id::new("library-search"), tl!("Search")) }; + let query = if empty { String::new() } else { widgets::search_field(ui, egui::Id::unique("library-search"), tl!("Search")) }; let query = query.trim().to_lowercase(); let searching = !query.is_empty(); let shown = |name: &str| !searching || name.to_lowercase().contains(&query); @@ -188,7 +188,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.add_space(4.0); // The list takes the panel's height down to the bottom bar (as tall as it was last frame), and // scrolls. - let bar_id = egui::Id::new("library-bottom-bar"); + let bar_id = egui::Id::unique("library-bottom-bar"); let bar: f32 = ui.data(|d| d.get_temp(bar_id)).unwrap_or(BAR); let list_h = ui.available_height() - bar; let list_h = if list_h.is_finite() { list_h.max(MIN_LIST) } else { MIN_LIST }; @@ -286,7 +286,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } // Art dragged off the canvas, or a swatch (not one of this library's colours), dropped on the // list is added to the library. - let zone = ui.interact(list_rect, ui.id().with("libraries-drop"), Sense::hover()); + let zone = ui.interact(list_rect, ui.scope_id().with("libraries-drop"), Sense::hover()); if let Some(ids) = widgets::art_drop(ui, &zone) { act = Some(Act::Run("library.add", json!({ "kind": "graphic", "ids": ids }))); } @@ -825,7 +825,7 @@ mod tests { fn texts(app: &mut VectorcraftApp, query: &str) -> Vec { let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx); - ctx.data_mut(|d| d.insert_temp(egui::Id::new("library-search"), query.to_string())); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique("library-search"), query.to_string())); let mut shapes = vec![]; let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui)); out.textures_delta.clear(); diff --git a/crates/ui-egui/src/panels/library_panel.rs b/crates/ui-egui/src/panels/library_panel.rs index b27d6ea25..4d27c9755 100644 --- a/crates/ui-egui/src/panels/library_panel.rs +++ b/crates/ui-egui/src/panels/library_panel.rs @@ -382,7 +382,7 @@ fn rename(ui: &mut UiState, from: &str, to: &str) -> Result<(), String> { /// The interaction id of the tile or row of `name` in panel `panel`. pub(crate) fn tile_id(panel: &str, name: &str) -> egui::Id { - egui::Id::new(("library-tile", panel, name)) + egui::Id::unique(("library-tile", panel, name)) } /// The state key `key` of panel `panel`. @@ -594,7 +594,7 @@ pub(crate) fn user_slot(id: &str, prefix: &str, libraries: Vec) -> #[cfg(test)] pub(crate) fn show_window(app: &mut VectorcraftApp, ctx: &egui::Context) { let Some(panel) = last_open(&app.ui).map(LibraryPanel::panel) else { return }; - let area = egui::Area::new(egui::Id::new("test-library-panel")).fixed_pos(egui::pos2(20.0, 20.0)); + let area = egui::Area::new(egui::Id::unique("test-library-panel")).fixed_pos(egui::pos2(20.0, 20.0)); area.show(ctx, |ui| crate::dock::panel_body(app, ui, &panel, 256.0, 400.0)); } @@ -612,7 +612,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { }; let panel = panel_id(K::KIND, id); let (view, hide_find) = settings(&app.ui, K::KIND, id).map_or((K::VIEW, false), |s| (s.view.unwrap_or(K::VIEW), s.hide_find)); - let query = if hide_find { String::new() } else { widgets::search_field(ui, egui::Id::new(key(&panel, "find")), tl!("Find")) }; + let query = if hide_find { String::new() } else { widgets::search_field(ui, egui::Id::unique(key(&panel, "find")), tl!("Find")) }; ui.add_space(4.0); let rows = K::rows(&lib, &query.trim().to_lowercase()); let selected = selection(ui.ctx(), &panel); @@ -939,7 +939,7 @@ mod tests { app.run("window.swatchLibrary", json!({"library": "web-safe-216"})).unwrap(); let panel = "library:swatches:web-safe-216"; app.run("ui.libraryPanel", json!({"panel": panel, "view": "smallList"})).unwrap(); - ctx.data_mut(|d| d.insert_temp(egui::Id::new(key(panel, "find")), "#FF00".to_string())); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique(key(panel, "find")), "#FF00".to_string())); frame(&mut app, &ctx, vec![], Modifiers::NONE, 0.0); frame(&mut app, &ctx, vec![], Modifiers::NONE, 0.5); assert!(ctx.read_response(tile_id(panel, "#FF0033")).is_some()); diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 582ebdcec..d2296d770 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -117,7 +117,7 @@ fn corner_fields(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, id: &str, widt egui::Grid::new((id, "corners")).num_columns(2).spacing(vec2(6.0, 4.0)).show(ui, |ui| { for (k, label) in rectangle_corners() { dim_label(ui, label); - if let Some(v) = crate::widgets::num_field(ui, (id, "corner", k), Some(lc.radius(k)), unit, width) { + if let Some(v) = crate::widgets::num_field_named(ui, (id, "corner", k), Some(label), Some(lc.radius(k)), unit, width) { app.run("object.setLiveShape", json!({"radius": v, "corners": [k]})).ok(); } ui.end_row(); @@ -238,9 +238,9 @@ pub fn panel_menu_items(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) -> bool /// The ≡ panel-menu button drawn into `rect` (the right end of a panel's title/tab strip). pub fn panel_menu(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, rect: Rect) { let t = Tokens::get(ui.ctx()); - let resp = ui.interact(rect, ui.id().with(("panel-menu", id)), Sense::click()); + let resp = ui.interact(rect, ui.scope_id().with(("panel-menu", id)), Sense::click()); icons::paint(ui, "menu", rect.shrink(1.0), if resp.hovered() { t.text_strong } else { t.text_dim }); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Panel menu"))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Panel menu"))); let resp = resp.on_hover_text(tl!("Panel menu")); egui::Popup::menu(&resp).show(|ui| { crate::widgets::menu_scroll(ui, |ui| { @@ -462,10 +462,10 @@ pub(crate) fn live_run(app: &mut VectorcraftApp, label: &str, cmd: &str, params: /// Per-panel UI state kept in egui memory (not document state). pub(crate) fn pstate(ctx: &egui::Context, key: &str) -> T { - ctx.data(|d| d.get_temp::(egui::Id::new(("panel-state", key)))).unwrap_or_default() + ctx.data(|d| d.get_temp::(egui::Id::unique(("panel-state", key)))).unwrap_or_default() } pub(crate) fn set_pstate(ctx: &egui::Context, key: &str, v: T) { - ctx.data_mut(|d| d.insert_temp(egui::Id::new(("panel-state", key)), v)); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique(("panel-state", key)), v)); } /// [`crate::i18n::label_or_name`] in the UI language. @@ -486,7 +486,7 @@ pub(crate) fn recent_colors_row(app: &VectorcraftApp, ui: &mut Ui) -> Option r.right() - 2.0 { break; } - let resp = ui.interact(cell, ui.id().with(("recent", i)), Sense::click()); + let resp = ui.interact(cell, ui.scope_id().with(("recent", i)), Sense::click()); crate::widgets::swatch_tile(ui, cell, &Paint::solid(*c), false, resp.hovered()); if resp.on_hover_text(c.to_hex()).clicked() { chosen = Some(*c); @@ -561,7 +561,7 @@ mod tests { let frame = |app: &mut crate::VectorcraftApp, events: Vec| { let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { proxy(app, ui, 36.0); - if let Some(c) = ctx.read_response(ui.id().with("stroke-proxy")).map(|r| r.rect.center()) { + if let Some(c) = ctx.read_response(ui.scope_id().with("stroke-proxy")).map(|r| r.rect.center()) { stroke_at.set(c); } }); diff --git a/crates/ui-egui/src/panels/paragraph.rs b/crates/ui-egui/src/panels/paragraph.rs index 3e81bc893..c575b3f25 100644 --- a/crates/ui-egui/src/panels/paragraph.rs +++ b/crates/ui-egui/src/panels/paragraph.rs @@ -374,7 +374,7 @@ pub fn bullets_button(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) { ui.painter().rect_filled(rect, egui::CornerRadius::same(4), t.hover); } crate::icons::paint(ui, "chevron-down", egui::Rect::from_center_size(rect.center(), vec2(10.0, 10.0)), t.text_dim); - arrow.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Bullets"))); + arrow.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Bullets"))); let arrow = arrow.on_hover_text(tl!("Bullets")); let presets = [ (tl!("None"), "none", None), @@ -421,7 +421,7 @@ fn bullet_row(ui: &mut Ui, name: &str, bullet: Option painter.rect_filled(egui::Rect::from_center_size(c, vec2(6.0, 6.0)), 0.0, t.text_strong), }; painter.text(rect.left_center() + vec2(42.0, 0.0), egui::Align2::LEFT_CENTER, name, font, t.text_strong); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, ui.is_enabled(), checked, name)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, ui.is_enabled(), checked, name)); resp.clicked() } @@ -441,7 +441,7 @@ pub fn numbers_button(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) { ui.painter().rect_filled(rect, egui::CornerRadius::same(4), t.hover); } crate::icons::paint(ui, "chevron-down", egui::Rect::from_center_size(rect.center(), vec2(10.0, 10.0)), t.text_dim); - arrow.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Numbered List"))); + arrow.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Numbered List"))); let arrow = arrow.on_hover_text(tl!("Numbered List")); let picked = egui::Popup::menu(&arrow).show(|ui| { let mut picked = None; @@ -475,7 +475,7 @@ fn number_row(ui: &mut Ui, sample: &str, checked: bool) -> bool { painter.text(rect.left_center() + vec2(6.0, 0.0), egui::Align2::LEFT_CENTER, "✓", font.clone(), t.text_strong); } painter.text(rect.left_center() + vec2(22.0, 0.0), egui::Align2::LEFT_CENTER, sample, font, t.text_strong); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, ui.is_enabled(), checked, sample)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, ui.is_enabled(), checked, sample)); resp.clicked() } @@ -526,27 +526,61 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }; egui::Grid::new("para-grid").num_columns(4).spacing([4.0, 4.0]).show(ui, |ui| { label(ui, "→|", tl!("Left Indent")); - if let Some(v) = widgets::spin_field(ui, "pa-li", shared_para_field(app, |p| p.left_indent), unit, fw, 1.0, -1296.0, &[]) { + if let Some(v) = + widgets::spin_field_named(ui, "pa-li", Some(tl!("Left Indent")), shared_para_field(app, |p| p.left_indent), unit, fw, 1.0, -1296.0, &[]) + { format(app, json!({"leftIndent": v})); } label(ui, "|←", tl!("Right Indent")); - if let Some(v) = widgets::spin_field(ui, "pa-ri", shared_para_field(app, |p| p.right_indent), unit, fw, 1.0, -1296.0, &[]) { + if let Some(v) = + widgets::spin_field_named(ui, "pa-ri", Some(tl!("Right Indent")), shared_para_field(app, |p| p.right_indent), unit, fw, 1.0, -1296.0, &[]) + { format(app, json!({"rightIndent": v})); } ui.end_row(); label(ui, "1→", tl!("First-line Left Indent")); - if let Some(v) = widgets::spin_field(ui, "pa-fi", shared_para_field(app, |p| p.first_line_indent), unit, fw, 1.0, -1296.0, &[]) { + if let Some(v) = widgets::spin_field_named( + ui, + "pa-fi", + Some(tl!("First-line Left Indent")), + shared_para_field(app, |p| p.first_line_indent), + unit, + fw, + 1.0, + -1296.0, + &[], + ) { format(app, json!({"firstLineIndent": v})); } ui.label(""); ui.label(""); ui.end_row(); label(ui, "↑¶", tl!("Space Before Paragraph")); - if let Some(v) = widgets::spin_field(ui, "pa-sb", shared_para_field(app, |p| p.space_before), unit, fw, 1.0, 0.0, &[]) { + if let Some(v) = widgets::spin_field_named( + ui, + "pa-sb", + Some(tl!("Space Before Paragraph")), + shared_para_field(app, |p| p.space_before), + unit, + fw, + 1.0, + 0.0, + &[], + ) { format(app, json!({"spaceBefore": v})); } label(ui, "↓¶", tl!("Space After Paragraph")); - if let Some(v) = widgets::spin_field(ui, "pa-sa", shared_para_field(app, |p| p.space_after), unit, fw, 1.0, 0.0, &[]) { + if let Some(v) = widgets::spin_field_named( + ui, + "pa-sa", + Some(tl!("Space After Paragraph")), + shared_para_field(app, |p| p.space_after), + unit, + fw, + 1.0, + 0.0, + &[], + ) { format(app, json!({"spaceAfter": v})); } ui.end_row(); diff --git a/crates/ui-egui/src/panels/pattern_options.rs b/crates/ui-egui/src/panels/pattern_options.rs index bde462c9d..9e4575d01 100644 --- a/crates/ui-egui/src/panels/pattern_options.rs +++ b/crates/ui-egui/src/panels/pattern_options.rs @@ -45,7 +45,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let name = def.name.clone(); // Name (commit on Enter / focus loss; Shift+Enter commits and keeps the focus). row(ui, tl!("Name:"), |ui| { - let id = ui.id().with("po-name"); + let id = ui.scope_id().with("po-name"); let mut buf: String = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_else(|| name.clone()); if !ui.memory(|m| m.has_focus(id)) { buf = name.clone(); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 14bcc9682..6224dfa37 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -380,7 +380,7 @@ fn artboard_sections(app: &mut VectorcraftApp, ui: &mut Ui) { // button does. ui.horizontal(|ui| { let w = (ui.available_width() - 26.0 - ui.spacing().item_spacing.x).max(80.0); - if let Some(name) = widgets::text_field(ui, ("ab", "name"), Some(&ab.name), w, 1).filter(|n| !n.is_empty()) { + if let Some(name) = widgets::text_field_named(ui, ("ab", "name"), Some(tl!("Name:")), Some(&ab.name), w, 1).filter(|n| !n.is_empty()) { app.run("artboard.setProps", json!({"index": i, "name": name})).ok(); } if widgets::icon_button(ui, "dc-artboard-options", tl!("Artboard Options"), false, 26.0).clicked() { @@ -400,7 +400,7 @@ fn artboard_sections(app: &mut VectorcraftApp, ui: &mut Ui) { "y" => origin.y, _ => 0.0, }; - if let Some(v) = widgets::num_field(ui, ("ab", key, row), Some(v - offset), units, fw) { + if let Some(v) = widgets::num_field_named(ui, ("ab", key, row), Some(tl!(label)), Some(v - offset), units, fw) { // A new X or Y moves the active artboard; a new W or H goes to every selected one. let indices = if matches!(key, "width" | "height") { super::artboards::resized_with(app, i) } else { vec![i] }; let common = json!({"scaleArt": scale_art, "moveArt": move_art}); @@ -575,7 +575,7 @@ fn artboard_fill_row(app: &mut VectorcraftApp, ui: &mut Ui, i: usize, color: Opt widgets::proxy_chip(ui, r, &paint, false, None); }); pick = chip.on_hover_text(tl!("Color Picker…")).clicked(); - match widgets::dropdown(ui, "ab-fill", shown, &options, 110.0) { + match widgets::dropdown_named(ui, "ab-fill", Some(tl!("Fill:")), shown, &options, 110.0) { Some(k) if k < fills.len() => set = Some(fills[k]), Some(_) => pick = true, None => {} @@ -687,33 +687,33 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) { return; }; let b = bx.rect; - let refi: usize = ui.data(|d| d.get_temp(egui::Id::new("refpt"))).unwrap_or(4); + let refi: usize = ui.data(|d| d.get_temp(egui::Id::unique("refpt"))).unwrap_or(4); let rp = bx.reference_point(refi); let copy = super::transform::alt_enter_copies(app, ui); section_header(ui, tl!("Transform")); let fw = super::field_width(ui); ui.horizontal(|ui| { if let Some(i) = widgets::reference_point(ui, refi) { - ui.data_mut(|d| d.insert_temp(egui::Id::new("refpt"), i)); + ui.data_mut(|d| d.insert_temp(egui::Id::unique("refpt"), i)); } ui.add_space(6.0); let link = super::transform::proportional(app, ui); egui::Grid::new("xf-grid").num_columns(4).spacing([4.0, 6.0]).min_col_width(0.0).show(ui, |ui| { dim_label(ui, "X:"); - if let Some(v) = widgets::num_field(ui, "tx", Some(rp.x - origin.x), units, fw) { + if let Some(v) = widgets::num_field_named(ui, "tx", Some(crate::i18n::t("X:")), Some(rp.x - origin.x), units, fw) { app.run("object.setBounds", json!({"x": v + origin.x, "reference": refi, "copy": copy})).ok(); } dim_label(ui, "W:"); - if let Some(v) = widgets::size_field(ui, "tw", Some(b.width()), units, fw) { + if let Some(v) = widgets::size_field_named(ui, "tw", Some(tl!("W:")), Some(b.width()), units, fw) { app.run("object.setBounds", json!({"width": v, "reference": refi, "proportional": link, "copy": copy})).ok(); } ui.end_row(); dim_label(ui, "Y:"); - if let Some(v) = widgets::num_field(ui, "ty", Some(rp.y - origin.y), units, fw) { + if let Some(v) = widgets::num_field_named(ui, "ty", Some(crate::i18n::t("Y:")), Some(rp.y - origin.y), units, fw) { app.run("object.setBounds", json!({"y": v + origin.y, "reference": refi, "copy": copy})).ok(); } dim_label(ui, "H:"); - if let Some(v) = widgets::size_field(ui, "th", Some(b.height()), units, fw) { + if let Some(v) = widgets::size_field_named(ui, "th", Some(tl!("H:")), Some(b.height()), units, fw) { app.run("object.setBounds", json!({"height": v, "reference": refi, "proportional": link, "copy": copy})).ok(); } ui.end_row(); @@ -725,7 +725,7 @@ pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) { let icon = icons::icon(ui, "rotate-ccw", 16.0, Tokens::get(ui.ctx()).icon); crate::scrub::note_label(ui, icon.rect); // The bounding box's angle: a new value turns the selection to it. - if let Some(a) = widgets::plain_field(ui, "rot", bx.angle, "°", 2, fw) { + if let Some(a) = widgets::plain_field_named(ui, "rot", Some(tl!("Rotation")), bx.angle, "°", 2, fw) { app.run("object.rotate", json!({"angle": a, "absolute": true, "copy": copy})).ok(); } ui.add_space(10.0); diff --git a/crates/ui-egui/src/panels/stroke.rs b/crates/ui-egui/src/panels/stroke.rs index b73a799e6..911f80e43 100644 --- a/crates/ui-egui/src/panels/stroke.rs +++ b/crates/ui-egui/src/panels/stroke.rs @@ -130,7 +130,7 @@ pub(crate) fn shown_weight(app: &VectorcraftApp, st: Option<&StrokeLayer>, mixed /// The weight spinner (Stroke panel, Control bar): Units > Stroke, presets, blank when mixed. pub(crate) fn weight_field(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, weight: Option, width: f32) { let unit = app.session.stroke_unit(); - if let Some(w) = widgets::spin_field(ui, id, weight, unit, width, 1.0, 0.0, &weight_presets(unit)) { + if let Some(w) = widgets::spin_field_named(ui, id, Some(tl!("Weight:")), weight, unit, width, 1.0, 0.0, &weight_presets(unit)) { set(app, json!({"weight": w})); } } @@ -246,7 +246,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let mut nf = shown; let mut changed = false; for (i, f) in shown.iter().enumerate() { - let r = ui.add_enabled_ui(dash.is_some(), |ui| widgets::opt_field(ui, ("dash", i), *f, unit, fw)).inner; + // The dash and gap names are in the row below the fields, so name each cell here: + // "dash 1", "gap 1", "dash 2"…. + let cell = if i % 2 == 0 { tl!("dash") } else { tl!("gap") }; + let name = format!("{} {}", cell, i / 2 + 1); + let r = ui.add_enabled_ui(dash.is_some(), |ui| widgets::opt_field_named(ui, ("dash", i), Some(&name), *f, unit, fw)).inner; if let Some(x) = r { nf[i] = x.map(|v| v.max(0.0)); changed = true; @@ -286,10 +290,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { row_label(ui, tl!("Scale:")); let mut ns = None; ui.add_enabled_ui(has_head, |ui| { - if let Some(v) = widgets::plain_field(ui, "arrow-scale-s", scale.0, "%", 0, 56.0) { + let start_name = format!("{} 1", tl!("Scale:")); + if let Some(v) = widgets::plain_field_named(ui, "arrow-scale-s", Some(&start_name), scale.0, "%", 0, 56.0) { ns = Some((v, if linked { v } else { scale.1 })); } - if let Some(v) = widgets::plain_field(ui, "arrow-scale-e", scale.1, "%", 0, 56.0) { + let end_name = format!("{} 2", tl!("Scale:")); + if let Some(v) = widgets::plain_field_named(ui, "arrow-scale-e", Some(&end_name), scale.1, "%", 0, 56.0) { ns = Some((if linked { v } else { scale.0 }, v)); } }); @@ -368,7 +374,7 @@ fn arrow_dropdown(ui: &mut Ui, id: &str, cur: Option, start: bool) -> icons::paint(ui, "chevron-down", Rect::from_center_size(pos2(r.right() - 8.0, r.center().y), vec2(10.0, 10.0)), t.icon); let resp = resp.on_hover_text(if start { tl!("Start arrowhead") } else { tl!("End arrowhead") }); let mut out = None; - egui::Popup::menu(&resp).id(egui::Id::new(("arrow-pop", id))).show(|ui| { + egui::Popup::menu(&resp).id(egui::Id::unique(("arrow-pop", id))).show(|ui| { ui.set_min_width(170.0); egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { for a in std::iter::once(None).chain(Arrowhead::ALL.map(Some)) { @@ -749,6 +755,7 @@ mod tests { delta: vec2(0.0, -1.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }; frame(&mut app, vec![Event::PointerMoved(at), notch]); } diff --git a/crates/ui-egui/src/panels/svg_interactivity.rs b/crates/ui-egui/src/panels/svg_interactivity.rs index 7761a9635..83e09a785 100644 --- a/crates/ui-egui/src/panels/svg_interactivity.rs +++ b/crates/ui-egui/src/panels/svg_interactivity.rs @@ -32,7 +32,7 @@ fn state(app: &mut VectorcraftApp, ctx: &egui::Context) -> Info { let Some(st) = app.session.active() else { return Info::default() }; let sel = &st.selection.objects; let key: Key = (st.uid, st.revision, sel.len(), sel.first().copied(), sel.last().copied()); - let id = egui::Id::new("svgi-state"); + let id = egui::Id::unique("svgi-state"); if let Some((k, v)) = ctx.data(|d| d.get_temp::<(Key, Info)>(id)) && k == key { diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 1083454ca..323bceebe 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -633,7 +633,7 @@ pub(crate) fn drag_preview(app: &VectorcraftApp, ctx: &egui::Context) { /// A 16-point chip `draw` paints, following the pointer. fn pointer_chip(ctx: &egui::Context, draw: impl FnOnce(&Ui, Rect)) { let Some(at) = ctx.pointer_hover_pos() else { return }; - let area = egui::Area::new(egui::Id::new("swatch-drag-preview")).order(egui::Order::Tooltip).fixed_pos(at + vec2(12.0, 12.0)); + let area = egui::Area::new(egui::Id::unique("swatch-drag-preview")).order(egui::Order::Tooltip).fixed_pos(at + vec2(12.0, 12.0)); area.interactable(false).show(ctx, |ui| { let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); draw(ui, r); @@ -642,7 +642,7 @@ fn pointer_chip(ctx: &egui::Context, draw: impl FnOnce(&Ui, Rect)) { /// The id of the find field's text (shown with the panel menu's Show Find Field). fn find_id() -> egui::Id { - egui::Id::new("swatch-find") + egui::Id::unique("swatch-find") } pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { @@ -741,7 +741,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, salt: &'static str) { } } }); - let zone = ui.interact(out.inner_rect, ui.id().with("swatch-drop"), Sense::hover()); + let zone = ui.interact(out.inner_rect, ui.scope_id().with("swatch-drop"), Sense::hover()); zone_input(ui, &zone, &mut ev); }); match ev.edit { @@ -905,7 +905,7 @@ pub(crate) fn folder_tile(ui: &Ui, cell: Rect, selected: bool) { /// The interaction id of a tile or list row (stable per swatch or group name and host `salt`). fn tile_id(salt: &'static str, e: &Entry) -> egui::Id { - egui::Id::new((salt, e.name())) + egui::Id::unique((salt, e.name())) } /// The selected swatches and groups that can be deleted (all but None and Registration). @@ -1332,14 +1332,14 @@ mod tests { /// Double-click the thumbnail of swatch `name` in the panel. fn double_click_tile(app: &mut VectorcraftApp, ctx: &egui::Context, name: &str) { frame(app, ctx, vec![], 0.0, show); - let tile = ctx.read_response(egui::Id::new(("swatch-tile", name))).unwrap_or_else(|| panic!("no tile for {name}")).rect; + let tile = ctx.read_response(egui::Id::unique(("swatch-tile", name))).unwrap_or_else(|| panic!("no tile for {name}")).rect; frame(app, ctx, double_click(tile.center()), 1.0, show); } /// Click the thumbnail of swatch or group `name` with `modifiers` held (frames at `time`, `time + 0.5`). fn click_tile(app: &mut VectorcraftApp, ctx: &egui::Context, name: &str, modifiers: Modifiers, time: f64) { frame(app, ctx, vec![], time, show); - let at = ctx.read_response(egui::Id::new(("swatch-tile", name))).unwrap_or_else(|| panic!("no tile for {name}")).rect.center(); + let at = ctx.read_response(egui::Id::unique(("swatch-tile", name))).unwrap_or_else(|| panic!("no tile for {name}")).rect.center(); let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers }; frame_with(app, ctx, vec![Event::PointerMoved(at), button(true), button(false)], modifiers, time + 0.5, show); } @@ -1652,7 +1652,7 @@ mod tests { /// The centre of the tile or row of `name` as laid out by the last frame. fn tile_center(ctx: &egui::Context, name: &str) -> Pos2 { - ctx.read_response(egui::Id::new(("swatch-tile", name))).unwrap_or_else(|| panic!("no tile for {name}")).rect.center() + ctx.read_response(egui::Id::unique(("swatch-tile", name))).unwrap_or_else(|| panic!("no tile for {name}")).rect.center() } /// Press at `from`, drag to `to` and release there, a frame per step from `time`. @@ -1685,7 +1685,7 @@ mod tests { set_pstate(&ctx, "swatch-show-find", true); ctx.data_mut(|d| d.insert_temp(find_id(), "BRIGHT".to_string())); frame(&mut app, &ctx, vec![], 0.0, show); - assert!(ctx.read_response(egui::Id::new(("swatch-tile", "Red"))).is_none(), "Red doesn't match"); + assert!(ctx.read_response(egui::Id::unique(("swatch-tile", "Red"))).is_none(), "Red doesn't match"); click_tile(&mut app, &ctx, "Bright Red", Modifiers::NONE, 1.0); click_tile(&mut app, &ctx, "Bright Blue", Modifiers::SHIFT, 2.0); assert_eq!(selected(&ctx), ["Bright Red", "Bright Yellow", "Bright Green", "Bright Blue"]); @@ -1693,8 +1693,8 @@ mod tests { ctx.data_mut(|d| d.insert_temp(find_id(), "grays".to_string())); frame(&mut app, &ctx, vec![], 3.0, show); frame(&mut app, &ctx, vec![], 3.5, show); - assert!(ctx.read_response(egui::Id::new(("swatch-tile", "K=50"))).is_some()); - assert!(ctx.read_response(egui::Id::new(("swatch-tile", "Bright Red"))).is_none()); + assert!(ctx.read_response(egui::Id::unique(("swatch-tile", "K=50"))).is_some()); + assert!(ctx.read_response(egui::Id::unique(("swatch-tile", "Bright Red"))).is_none()); } #[test] @@ -1912,7 +1912,7 @@ mod tests { app.run("paint.setFill", json!({"ids": [id], "swatch": "Last Ink"})).unwrap(); app.run("select.none", json!({})).unwrap(); let ctx = context(); - let tile = |ctx: &egui::Context| ctx.read_response(egui::Id::new(("swatch-tile", "Last Ink"))).unwrap().rect; + let tile = |ctx: &egui::Context| ctx.read_response(egui::Id::unique(("swatch-tile", "Last Ink"))).unwrap().rect; frame(&mut app, &ctx, vec![], 0.0, show); frame(&mut app, &ctx, vec![], 0.1, show); let far = tile(&ctx).top(); @@ -1925,7 +1925,7 @@ mod tests { assert!(near < far - 100.0, "scrolled to the swatch: {far} → {near}"); assert!(selected(&ctx).is_empty(), "the art's swatch is highlighted"); // Once: later frames leave the scroll where the user puts it. - assert_eq!(focus_swatch(&app, &egui::Ui::new(ctx.clone(), egui::Id::new("t"), egui::UiBuilder::new()), Some("Last Ink")), None); + assert_eq!(focus_swatch(&app, &egui::Ui::new(ctx.clone(), egui::Id::unique("t"), egui::UiBuilder::new()), Some("Last Ink")), None); } /// Deselecting art keeps its swatch highlighted (not the White new art gets) until the paints for @@ -1940,7 +1940,7 @@ mod tests { // New art's paints back to the defaults, as for art that came in with a file. app.run("paint.default", json!({})).unwrap(); let ctx = context(); - let ui = egui::Ui::new(ctx.clone(), egui::Id::new("t"), egui::UiBuilder::new()); + let ui = egui::Ui::new(ctx.clone(), egui::Id::unique("t"), egui::UiBuilder::new()); let shown = |app: &VectorcraftApp| kept_swatch(app, &ui, active_swatch(app)); assert_ne!(shown(&app).as_deref(), Some("Ink 185"), "nothing selected yet: new art's paint"); app.run("select.set", json!({"ids": [id]})).unwrap(); diff --git a/crates/ui-egui/src/panels/symbols.rs b/crates/ui-egui/src/panels/symbols.rs index 213004330..ba4a6b8d8 100644 --- a/crates/ui-egui/src/panels/symbols.rs +++ b/crates/ui-egui/src/panels/symbols.rs @@ -155,7 +155,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } ui.min_rect() }); - let zone = ui.interact(list_rect, ui.id().with("symbols-drop"), Sense::hover()); + let zone = ui.interact(list_rect, ui.scope_id().with("symbols-drop"), Sense::hover()); if let Some(ids) = widgets::art_drop(ui, &zone) && let Err(e) = app.run("symbol.new", json!({ "ids": ids })) { diff --git a/crates/ui-egui/src/panels/tabs.rs b/crates/ui-egui/src/panels/tabs.rs index c888cb46a..568da25b7 100644 --- a/crates/ui-egui/src/panels/tabs.rs +++ b/crates/ui-egui/src/panels/tabs.rs @@ -145,7 +145,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let on = stops.get(sel).map_or(align_idx == i, |s| s.align == *a); let tip = align_name(*a, vertical); let resp = ui.add(egui::Button::new(egui::RichText::new(*label).size(13.0)).selected(on).min_size(vec2(28.0, 24.0))); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), on, tip)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), on, tip)); if resp.on_hover_text(tip).clicked() { align_idx = i; set_pstate(ui.ctx(), "tabs-align", i); @@ -174,7 +174,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.label(egui::RichText::new(tl!("Leader:")).color(t.text)); // The fields keep what is typed until Enter or a click away commits it (#924). let leader = stops.get(sel).map(|s| s.leader.clone()).unwrap_or_default(); - let typed = ui.add_enabled_ui(stops.get(sel).is_some(), |ui| widgets::exact_text_field(ui, "tabs-leader", &leader, 90.0)).inner; + let typed = ui + .add_enabled_ui(stops.get(sel).is_some(), |ui| widgets::exact_text_field_named(ui, "tabs-leader", Some(tl!("Leader:")), &leader, 90.0)) + .inner; if let (Some(leader), Some(s)) = (typed, stops.get_mut(sel)) { // Up to eight characters repeat in the leader. s.leader = leader.chars().take(8).collect(); @@ -184,7 +186,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.label(egui::RichText::new(tl!("Align On:")).color(t.text)); let decimal = stops.get(sel).is_some_and(|s| s.align == TabAlign::Decimal); let on = stops.get(sel).map(|s| s.align_on.to_string()).unwrap_or_else(|| ".".into()); - let typed = ui.add_enabled_ui(decimal, |ui| widgets::exact_text_field(ui, "tabs-align-on", &on, 30.0)).inner; + let typed = ui.add_enabled_ui(decimal, |ui| widgets::exact_text_field_named(ui, "tabs-align-on", Some(tl!("Align On:")), &on, 30.0)).inner; if let (Some(c), Some(s)) = (typed.and_then(|t| t.chars().next()), stops.get_mut(sel)) { s.align_on = c; apply(app, &stops); diff --git a/crates/ui-egui/src/panels/tests_asset_export.rs b/crates/ui-egui/src/panels/tests_asset_export.rs index 51d4d9cc0..b3d29e2f4 100644 --- a/crates/ui-egui/src/panels/tests_asset_export.rs +++ b/crates/ui-egui/src/panels/tests_asset_export.rs @@ -154,7 +154,7 @@ fn renaming_an_asset_runs_assets_rename() { crate::theme::install_fonts(&ctx); frame(&ctx, &mut app, vec![], false); // Double-click starts editing the name (as typed here); Enter commits it. - ctx.data_mut(|d| d.insert_temp(egui::Id::new("asset-export-rename"), (id, "Logo".to_string()))); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique("asset-export-rename"), (id, "Logo".to_string()))); frame(&ctx, &mut app, vec![], false); let enter = egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; frame(&ctx, &mut app, vec![enter], false); @@ -169,7 +169,7 @@ fn shift_enter_renames_an_asset_and_keeps_the_field_open() { let id = app.run("assets.add", json!({"ids": [a]})).unwrap()["assets"][0].as_u64().unwrap(); let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx); - let rename_id = egui::Id::new("asset-export-rename"); + let rename_id = egui::Id::unique("asset-export-rename"); let field = rename_id.with("field"); let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; frame(&ctx, &mut app, vec![], false); diff --git a/crates/ui-egui/src/panels/tests_maskview.rs b/crates/ui-egui/src/panels/tests_maskview.rs index acea8f9f5..a0f24e1cd 100644 --- a/crates/ui-egui/src/panels/tests_maskview.rs +++ b/crates/ui-egui/src/panels/tests_maskview.rs @@ -16,7 +16,7 @@ fn frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec /// Click the mask thumbnail with `modifiers` held. fn click_mask(ctx: &egui::Context, app: &mut VectorcraftApp, modifiers: egui::Modifiers) { frame(ctx, app, vec![], egui::Modifiers::NONE); - let at = ctx.read_response(egui::Id::new(transparency::MASK_THUMB)).expect("the mask thumbnail is drawn").rect.center(); + let at = ctx.read_response(egui::Id::unique(transparency::MASK_THUMB)).expect("the mask thumbnail is drawn").rect.center(); let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers }; frame(ctx, app, vec![egui::Event::PointerMoved(at), button(true), button(false)], modifiers); frame(ctx, app, vec![], egui::Modifiers::NONE); diff --git a/crates/ui-egui/src/panels/transform.rs b/crates/ui-egui/src/panels/transform.rs index ac4598aca..a60b7f4b2 100644 --- a/crates/ui-egui/src/panels/transform.rs +++ b/crates/ui-egui/src/panels/transform.rs @@ -53,13 +53,13 @@ pub fn artboard_rect(r: Rect, refi: usize, x: Option, y: Option, w: Op /// The reference point the Transform panel and the Control bar share (0 to 8, the center by default). pub fn reference_index(ui: &Ui) -> usize { - ui.data(|d| d.get_temp(egui::Id::new("refpt"))).unwrap_or(4) + ui.data(|d| d.get_temp(egui::Id::unique("refpt"))).unwrap_or(4) } /// The shared reference point's locator; a click on it chooses another point. pub fn reference_locator(ui: &mut Ui, refi: usize) { if let Some(i) = widgets::reference_point(ui, refi) { - ui.data_mut(|d| d.insert_temp(egui::Id::new("refpt"), i)); + ui.data_mut(|d| d.insert_temp(egui::Id::unique("refpt"), i)); } } @@ -96,20 +96,20 @@ fn artboard_fields(app: &mut VectorcraftApp, ui: &mut Ui, index: usize, r: Rect) ui.add_space(4.0); egui::Grid::new("xfp-ab-grid").num_columns(4).spacing([4.0, 6.0]).min_col_width(0.0).show(ui, |ui| { widgets::dim_label(ui, "X:"); - if let Some(v) = widgets::num_field(ui, "xfp-ab-x", Some(rp.x - origin.x), units, 80.0) { + if let Some(v) = widgets::num_field_named(ui, "xfp-ab-x", Some(crate::i18n::t("X:")), Some(rp.x - origin.x), units, 80.0) { set(app, Some(v + origin.x), None, None, None); } widgets::dim_label(ui, "W:"); - if let Some(v) = widgets::size_field(ui, "xfp-ab-w", Some(r.width()), units, 80.0) { + if let Some(v) = widgets::size_field_named(ui, "xfp-ab-w", Some(tl!("W:")), Some(r.width()), units, 80.0) { set(app, None, None, Some(v), None); } ui.end_row(); widgets::dim_label(ui, "Y:"); - if let Some(v) = widgets::num_field(ui, "xfp-ab-y", Some(rp.y - origin.y), units, 80.0) { + if let Some(v) = widgets::num_field_named(ui, "xfp-ab-y", Some(crate::i18n::t("Y:")), Some(rp.y - origin.y), units, 80.0) { set(app, None, Some(v + origin.y), None, None); } widgets::dim_label(ui, "H:"); - if let Some(v) = widgets::size_field(ui, "xfp-ab-h", Some(r.height()), units, 80.0) { + if let Some(v) = widgets::size_field_named(ui, "xfp-ab-h", Some(tl!("H:")), Some(r.height()), units, 80.0) { set(app, None, None, None, Some(v)); } ui.end_row(); @@ -140,7 +140,7 @@ fn guide_fields(app: &mut VectorcraftApp, ui: &mut Ui, (x, y): (Option, Opt for (label, id, pos, origin, by) in [("X:", "xfp-guide-x", x, origin.x, "dx"), ("Y:", "xfp-guide-y", y, origin.y, "dy")] { widgets::dim_label(ui, label); ui.add_enabled_ui(pos.is_some(), |ui| { - if let (Some(v), Some(pos)) = (widgets::num_field(ui, id, pos.map(|p| p - origin), units, 80.0), pos) + if let (Some(v), Some(pos)) = (widgets::num_field_named(ui, id, Some(tl!(label)), pos.map(|p| p - origin), units, 80.0), pos) && let Err(e) = app.run("guide.move", json!({ by: v + origin - pos })) { app.status(e); @@ -171,33 +171,33 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // point on the page. let bx = app.selection_box(); let bounds = bx.map(|b| b.rect); - let refi: usize = ui.data(|d| d.get_temp(egui::Id::new("refpt"))).unwrap_or(4); + let refi: usize = ui.data(|d| d.get_temp(egui::Id::unique("refpt"))).unwrap_or(4); let link = proportional(app, ui); let copy = alt_enter_copies(app, ui); let has = bounds.is_some(); let rp = bx.map(|b| b.reference_point(refi)); ui.horizontal(|ui| { if let Some(i) = widgets::reference_point(ui, refi) { - ui.data_mut(|d| d.insert_temp(egui::Id::new("refpt"), i)); + ui.data_mut(|d| d.insert_temp(egui::Id::unique("refpt"), i)); } ui.add_space(4.0); ui.add_enabled_ui(has, |ui| { egui::Grid::new("xfp-grid").num_columns(4).spacing([4.0, 6.0]).min_col_width(0.0).show(ui, |ui| { widgets::dim_label(ui, "X:"); - if let Some(v) = widgets::num_field(ui, "xfp-x", rp.map(|p| p.x - origin.x), units, 80.0) { + if let Some(v) = widgets::num_field_named(ui, "xfp-x", Some(crate::i18n::t("X:")), rp.map(|p| p.x - origin.x), units, 80.0) { app.run("object.setBounds", json!({"x": v + origin.x, "reference": refi, "copy": copy})).ok(); } widgets::dim_label(ui, "W:"); - if let Some(v) = widgets::size_field(ui, "xfp-w", bounds.map(|b| b.width()), units, 80.0) { + if let Some(v) = widgets::size_field_named(ui, "xfp-w", Some(tl!("W:")), bounds.map(|b| b.width()), units, 80.0) { app.run("object.setBounds", json!({"width": v, "reference": refi, "proportional": link, "copy": copy})).ok(); } ui.end_row(); widgets::dim_label(ui, "Y:"); - if let Some(v) = widgets::num_field(ui, "xfp-y", rp.map(|p| p.y - origin.y), units, 80.0) { + if let Some(v) = widgets::num_field_named(ui, "xfp-y", Some(crate::i18n::t("Y:")), rp.map(|p| p.y - origin.y), units, 80.0) { app.run("object.setBounds", json!({"y": v + origin.y, "reference": refi, "copy": copy})).ok(); } widgets::dim_label(ui, "H:"); - if let Some(v) = widgets::size_field(ui, "xfp-h", bounds.map(|b| b.height()), units, 80.0) { + if let Some(v) = widgets::size_field_named(ui, "xfp-h", Some(tl!("H:")), bounds.map(|b| b.height()), units, 80.0) { app.run("object.setBounds", json!({"height": v, "reference": refi, "proportional": link, "copy": copy})).ok(); } ui.end_row(); @@ -213,7 +213,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { crate::scrub::note_label(ui, icon.rect); // The bounding box's angle: a new value turns the selection to it. let angle = bx.map_or(0.0, |b| b.angle); - if let Some(a) = widgets::spin_plain(ui, "xfp-rot", angle, "°", 2, 96.0, 15.0, -360.0, &ANGLE_PRESETS) + if let Some(a) = widgets::spin_plain_named(ui, "xfp-rot", Some(tl!("Rotate")), angle, "°", 2, 96.0, 15.0, -360.0, &ANGLE_PRESETS) && a != angle { app.run("object.rotate", json!({"angle": a, "absolute": true, "origin": origin, "copy": copy})).ok(); @@ -222,7 +222,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let icon = icons::icon(ui, "dc-shear", 16.0, t.icon).on_hover_text(tl!("Shear")); crate::scrub::note_label(ui, icon.rect); // Shear is relative: the field rests at 0° and a new value shears by it. - if let Some(a) = widgets::spin_plain(ui, "xfp-shear", 0.0, "°", 2, 96.0, 15.0, -89.0, &SHEAR_PRESETS) + if let Some(a) = widgets::spin_plain_named(ui, "xfp-shear", Some(tl!("Shear")), 0.0, "°", 2, 96.0, 15.0, -89.0, &SHEAR_PRESETS) && a != 0.0 && a.abs() < 90.0 { @@ -308,17 +308,19 @@ pub(crate) fn polygon_rows(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, live let mut set = None; egui::Grid::new((id, "grid")).num_columns(2).spacing([6.0, 6.0]).show(ui, |ui| { widgets::dim_label(ui, tl!("Sides:")); - if let Some(s) = widgets::range_field(ui, (id, "sides"), f64::from(*sides), 3.0..=f64::INFINITY, "", 0, fw) { + if let Some(s) = widgets::range_field_named(ui, (id, "sides"), Some(tl!("Sides:")), f64::from(*sides), 3.0..=f64::INFINITY, "", 0, fw) { set = Some(("sides", json!(s as u64))); } ui.end_row(); widgets::dim_label(ui, tl!("Angle:")); - if let Some(a) = widgets::plain_field(ui, (id, "angle"), live.polygon_angle().unwrap_or(0.0), "°", 1, fw) { + if let Some(a) = widgets::plain_field_named(ui, (id, "angle"), Some(tl!("Angle:")), live.polygon_angle().unwrap_or(0.0), "°", 1, fw) { set = Some(("polygonAngle", json!(a))); } ui.end_row(); widgets::dim_label(ui, tl!("Corner:")); - if let Some(k) = widgets::dropdown_names(ui, (id, "kind"), kind.map_or("", corner_kind), &kinds, fw).and_then(|i| CornerKind::ALL.get(i)) { + if let Some(k) = widgets::dropdown_names_named(ui, (id, "kind"), Some(tl!("Corner:")), kind.map_or("", corner_kind), &kinds, fw) + .and_then(|i| CornerKind::ALL.get(i)) + { set = Some(("kind", json!(k))); } ui.end_row(); @@ -326,12 +328,12 @@ pub(crate) fn polygon_rows(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, live super::corner_radius_field(app, ui, n, (id, "cornerRadius"), fw); ui.end_row(); widgets::dim_label(ui, tl!("Radius:")); - if let Some(r) = widgets::num_field(ui, (id, "radius"), radius, units, fw) { + if let Some(r) = widgets::num_field_named(ui, (id, "radius"), Some(tl!("Radius:")), radius, units, fw) { set = Some(("polygonRadius", json!(r))); } ui.end_row(); widgets::dim_label(ui, tl!("Side Length:")); - if let Some(l) = widgets::num_field(ui, (id, "side"), side, units, fw) { + if let Some(l) = widgets::num_field_named(ui, (id, "side"), Some(tl!("Side Length:")), side, units, fw) { set = Some(("sideLength", json!(l))); } ui.end_row(); @@ -389,7 +391,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { let hidden: bool = pstate(ui.ctx(), "xf-hide-options"); let has = super::selection_len(app) > 0; // The flips mirror across the panel's reference point, as its flip buttons do. - let refi: usize = ui.data(|d| d.get_temp(egui::Id::new("refpt"))).unwrap_or(4); + let refi: usize = ui.data(|d| d.get_temp(egui::Id::unique("refpt"))).unwrap_or(4); let origin = app.selection_box().map(|b| b.reference_point(refi)).map(|p| json!([p.x, p.y])); if menu_item(ui, if hidden { tl!("Show Options") } else { tl!("Hide Options") }, true, false) { set_pstate(ui.ctx(), "xf-hide-options", !hidden); @@ -594,7 +596,7 @@ mod tests { let ctx = egui::Context::default(); crate::theme::install_fonts(&ctx); // Top-right reference point: (120, 30). - ctx.data_mut(|d| d.insert_temp(egui::Id::new("refpt"), 2usize)); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique("refpt"), 2usize)); let flip = |app: &mut VectorcraftApp, item: &str| { let texts = frame_events(&ctx, app, vec![], menu); let r = texts.iter().find(|(t, _)| t.trim_start() == item).unwrap_or_else(|| panic!("{item} in {texts:?}")).1; @@ -623,4 +625,26 @@ mod tests { assert_eq!(constrained(100.0, 50.0, None, Some(25.0)), (50.0, 25.0)); assert_eq!(constrained(0.0, 50.0, Some(10.0), None), (10.0, 50.0)); } + + #[test] + fn rotate_and_shear_fields_are_named() { + use egui_kittest::kittest::Queryable; + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 100, "height": 50})).unwrap(); + let mut fonts = false; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(280.0, 500.0)).with_accessibility_check(false).build_ui_state( + move |ui, app: &mut VectorcraftApp| { + if !std::mem::replace(&mut fonts, true) { + crate::theme::install_fonts(ui.ctx()); + return; + } + show(app, ui); + }, + app, + ); + h.run_steps(3); + let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Rotate"); + let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Shear"); + } } diff --git a/crates/ui-egui/src/panels/transparency.rs b/crates/ui-egui/src/panels/transparency.rs index 06cc7a9bc..cbd6b6931 100644 --- a/crates/ui-egui/src/panels/transparency.rs +++ b/crates/ui-egui/src/panels/transparency.rs @@ -26,7 +26,7 @@ fn state(app: &VectorcraftApp, ctx: &egui::Context) -> (TransparencyInfo, Option let Some(st) = app.session.active() else { return Default::default() }; let sel = &st.selection.objects; let key: Key = (st.uid, st.revision, sel.len(), sel.first().copied(), sel.last().copied()); - let id = egui::Id::new("tr-state"); + let id = egui::Id::unique("tr-state"); if let Some((k, v)) = ctx.data(|d| d.get_temp::<(Key, (TransparencyInfo, Option))>(id)) && k == key { @@ -105,7 +105,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { app.run("transparency.setOpacityMask", json!({"linked": !m.linked})).ok(); } let (mr, _) = ui.allocate_exact_size(vec2(50.0, 50.0), Sense::hover()); - let mresp = ui.interact(mr, egui::Id::new(MASK_THUMB), Sense::click()); + let mresp = ui.interact(mr, egui::Id::unique(MASK_THUMB), Sense::click()); let bg = if m.clip != m.invert { [0, 0, 0, 255] } else { [255, 255, 255, 255] }; ui.painter().rect_filled(mr, 0.0, egui::Color32::from_rgb(bg[0], bg[1], bg[2])); node_thumb(app, ui, "tr-mask", &m.art, mr.shrink(2.0), Some(bg)); @@ -257,7 +257,7 @@ fn node_thumb(app: &VectorcraftApp, ui: &Ui, slot: &str, n: &vectorcraft_doc::No } let Some(st) = app.session.active() else { return false }; let px = (r.width().min(r.height()) * ui.ctx().pixels_per_point()).round().max(8.0) as u32; - let (slot_id, key) = (egui::Id::new(slot), egui::Id::new((st.uid, n.id, bg, px))); + let (slot_id, key) = (egui::Id::unique(slot), egui::Id::unique((st.uid, n.id, bg, px))); let busy = st.interaction.is_some() || app.session.tool_busy(); let cached: Option<(egui::Id, u64, egui::TextureHandle)> = ui.ctx().data(|d| d.get_temp(slot_id)); let tex = match cached { diff --git a/crates/ui-egui/src/plugin_runs.rs b/crates/ui-egui/src/plugin_runs.rs index a3d01c964..719eadc18 100644 --- a/crates/ui-egui/src/plugin_runs.rs +++ b/crates/ui-egui/src/plugin_runs.rs @@ -184,7 +184,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let Some(first) = app.plugin_runs.running.iter().find(|r| r.shown()) else { return }; let now = ctx.input(|i| i.time); let since = ctx.data_mut(|m| { - let seen = m.get_temp_mut_or_insert_with(egui::Id::new("plugin-run-seen"), || (first.id, now)); + let seen = m.get_temp_mut_or_insert_with(egui::Id::unique("plugin-run-seen"), || (first.id, now)); if seen.0 != first.id { *seen = (first.id, now); } diff --git a/crates/ui-egui/src/prefs_dialog.rs b/crates/ui-egui/src/prefs_dialog.rs index 9deee19c0..9057aeb13 100644 --- a/crates/ui-egui/src/prefs_dialog.rs +++ b/crates/ui-egui/src/prefs_dialog.rs @@ -125,7 +125,7 @@ pub fn apply_runtime(app: &mut VectorcraftApp, ctx: &egui::Context) { scrub: p.scrub_numeric_fields, bare_points: p.numbers_without_units_are_points, }; - let id = egui::Id::new("dc-applied-prefs"); + let id = egui::Id::unique("dc-applied-prefs"); let prev: Option = ctx.data(|d| d.get_temp::>(id)).flatten(); if prev != Some(want) { theme::apply_with(ctx, want.brightness, want.accent, want.scheme); @@ -166,7 +166,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let (mut ok, mut cancel, mut reset) = (false, false, false); let cat = d.str("__category"); let cat = PREF_CATEGORIES.iter().find(|c| **c == cat).copied().unwrap_or(PREF_CATEGORIES[0]); - crate::dialogs::modal::show(ctx, tl!("Preferences"), egui::Id::new("dialog-preferences"), -20.0, 20, |ui| { + crate::dialogs::modal::show(ctx, tl!("Preferences"), egui::Id::unique("dialog-preferences"), -20.0, 20, |ui| { ui.set_width(760.0); crate::dialogs::modal::heading(ui, tl!("Preferences")); ui.add_space(12.0); diff --git a/crates/ui-egui/src/scrub.rs b/crates/ui-egui/src/scrub.rs index 77df5782e..cfb8f6e35 100644 --- a/crates/ui-egui/src/scrub.rs +++ b/crates/ui-egui/src/scrub.rs @@ -22,16 +22,16 @@ const LABEL_GAP: f32 = 64.0; /// Where the scrub in progress is kept (egui memory). fn state_id() -> Id { - Id::new("numeric-field-scrub") + Id::unique("numeric-field-scrub") } /// Where the last label drawn waits for its field. fn label_id() -> Id { - Id::new("numeric-field-label") + Id::unique("numeric-field-label") } fn enabled_id() -> Id { - Id::new("numeric-field-scrub-enabled") + Id::unique("numeric-field-scrub-enabled") } /// Turn scrubbing on or off (General › Scrub Numeric Fields by Dragging; on until set). diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index 5d68d1d8e..ccfd76550 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -628,7 +628,7 @@ impl DialogView { /// The view of `d`'s working set, from the cache unless the set changed. The dialog's /// `overrides` field is left in its stored form. fn of(ctx: &egui::Context, d: &mut Dialog) -> Arc { - let id = egui::Id::new("dialog-shortcuts-view"); + let id = egui::Id::unique("dialog-shortcuts-view"); let source = d.fields.get("overrides").unwrap_or(&Value::Null); if let Some(v) = ctx.data(|m| m.get_temp::>(id)).filter(|v| v.source == *source) { return v; @@ -667,7 +667,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { } } } - crate::dialogs::modal::show(ctx, tl!("Keyboard Shortcuts"), egui::Id::new("dialog-shortcuts"), -20.0, 20, |ui| { + crate::dialogs::modal::show(ctx, tl!("Keyboard Shortcuts"), egui::Id::unique("dialog-shortcuts"), -20.0, 20, |ui| { ui.set_width(640.0); crate::dialogs::modal::heading(ui, tl!("Keyboard Shortcuts")); ui.add_space(10.0); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index f079c8c3f..0a1c3a111 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -6,7 +6,8 @@ use craft_keymap::{Active, CommandRef}; use egui::{Key, KeyboardShortcut, Modifiers}; use serde_json::json; use vectorcraft_engine::cmd::clipboard::{Flavour, TEXT}; -use vectorcraft_tools::{Mods, ToolKey}; +use vectorcraft_engine::{Result as EngineResult, UiRequest, ViewInfo}; +use vectorcraft_tools::{Mods, SurroundingUnit, ToolKey}; use crate::VectorcraftApp; @@ -219,8 +220,24 @@ fn is_shortcut_chord(m: egui::Modifiers) -> bool { (m.command || m.ctrl) && !m.alt } +enum Seq { + Text(String), + Preedit(String, Option>), + Commit(String), + Erase(ToolKey, Mods), + /// An input method replaces text around the composition (upstream's character counts or + /// the fork's exact UTF-8 byte counts), then commits. + DeleteSurrounding(usize, usize, SurroundingUnit), + // The actual pointer/key still belongs to its existing handler. Its position in this + // sequence must nevertheless interrupt an old composition before later terminal input. + InterruptPointer, + InterruptNavigation, + InterruptCommand, + ResetCompleted(egui::ImeResetId), +} + /// Typed text and IME events, in the order they came, to the Type tool. -fn type_text(app: &mut VectorcraftApp, ctx: &egui::Context) { +fn type_text(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool { let view = app.view_info(); // Switching apps mid-composition needs nothing: the macOS IME keeps its marked text and goes // on with it when the window comes back (measured with Kotoeri), as the tool does. @@ -233,80 +250,279 @@ fn type_text(app: &mut VectorcraftApp, ctx: &egui::Context) { // characters in one frame interleaves correctly. Taken as two passes (all text, then the keys) // the deletes land after the inserts and the diacritics are lost. Each handled event is removed // so `route_type_input` below does not process the same Backspace/Delete again. - enum Seq { - Text(String), - Preedit(String, Option>), - Commit(String), - Erase(ToolKey, Mods), + let seq = type_sequence(ctx); + let viewport = ctx.viewport_id(); + let mut old_composition_retired = false; + for e in seq { + // Before the UI, a changed composition or guard is settled `pending`: a token bound here + // could outlive a route this pass invalidates later (a palette/dialog/Home) and attach to + // the stock descriptor. The post-UI finalizer binds the confirmed replacement. + crate::ime::sync_incoming_before_ui(app, ctx); + if let Seq::ResetCompleted(request) = &e { + old_composition_retired |= crate::ime::complete(app, ctx, *request); + continue; + } + if matches!(app.ime_compositions.get(&viewport), Some(crate::ime::IncomingComposition::Stale)) { + match &e { + // Accepted old payloads precede the fence even when their terminal arrives. + // None of these events proves the local reset has reached its boundary. + Seq::Commit(_) | Seq::DeleteSurrounding(..) | Seq::Preedit(..) => continue, + _ => {} + } + } + // A pointer/navigation/command notice has not reached its real handler yet. Text and + // erases after it belong to the old caret, not to the destination it will establish. + if crate::ime::deferred(app, ctx) { + match &e { + Seq::Text(_) | Seq::Erase(..) => continue, + _ => {} + } + } + let preedit_empty = match &e { + Seq::Preedit(text, _) => Some(text.is_empty()), + _ => None, + }; + let terminal_commit = matches!(&e, Seq::Commit(_)); + let from_ime = matches!(&e, Seq::Preedit(..) | Seq::Commit(_) | Seq::DeleteSurrounding(..)); + // Errors are already reported by the engine's interaction (a failed preview keeps it). + let result = apply_type_event(app, ctx, view, e, shortcut_mods, was_composing, &mut old_composition_retired); + if result.is_ok() && from_ime { + if terminal_commit { + app.ime_compositions.remove(&viewport); + } else { + crate::ime::remember_incoming(app, ctx, preedit_empty); + } + } } + old_composition_retired +} + +/// Take this frame's typed text, IME events and plain Backspace/Delete, in the order they arrived. +fn type_sequence(ctx: &egui::Context) -> Vec { let mut seq = vec![]; ctx.input_mut(|i| { - i.events.retain(|e| match e { - egui::Event::Text(t) => { - seq.push(Seq::Text(t.clone())); - false - } - egui::Event::Ime(egui::ImeEvent::Preedit { text, active_range_chars }) => { - seq.push(Seq::Preedit(text.clone(), active_range_chars.clone())); - false - } - egui::Event::Ime(egui::ImeEvent::Commit(t)) => { - seq.push(Seq::Commit(t.clone())); - false - } - // Only unmodified Backspace/Delete interleave with injected text; word-delete and other - // modified forms stay with `route_type_input`. - egui::Event::Key { key: key @ (Key::Backspace | Key::Delete), pressed: true, modifiers: m, .. } if !m.command && !m.alt && !m.ctrl => { - let tk = if *key == Key::Backspace { ToolKey::Backspace } else { ToolKey::Delete }; - seq.push(Seq::Erase(tk, Mods { shift: m.shift, ..Mods::default() })); - false - } - _ => true, - }) - }); - for e in seq { - // Errors are already reported by the engine's interaction (a failed preview keeps it). - let _ = match e { - // Keys the IME passed through come as text only when nothing is marked. - Seq::Text(t) if !app.session.tool_composing() && !shortcut_mods => app.session.tool_text(&t, view), - Seq::Text(_) => Ok(vec![]), - Seq::Preedit(text, active_range_chars) => { - app.ime_marked = Some(text.clone()).filter(|t| !t.is_empty()); - app.session.tool_preedit(&text, active_range_chars, view) - } - // A bare line break confirms the composition (Enter): the marked text, not a newline. - Seq::Commit(t) if t == "\n" || t == "\r" => { - keep_marked_text(app); - Ok(vec![]) - } - Seq::Commit(t) => { - app.ime_marked = None; - app.session.tool_text(&t, view) - } - // While composing (or in a frame that began composing), editing keys belong to the IME. - Seq::Erase(tk, m) if !was_composing && !app.session.tool_composing() => { - // Mirror `route_type_input`/`text_key`: surface an erase error in the status bar - // instead of discarding it. - if let Err(e) = app.session.tool_key(tk, m, view) { - app.status(e.to_string()); + // Move the events out instead of cloning their strings: a keyboard hook that reinjects a + // batch keeps the batch in `seq` only, not in both the input list and a second copy. + let mut kept = Vec::with_capacity(i.events.len()); + // InputState's held flag describes the end of the batch. Recover its initial state from + // the first transition, so a move before a later press cannot cancel an earlier commit. + let mut primary_down = i + .events + .iter() + .find_map(|e| match e { + egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed, .. } => Some(!pressed), + _ => None, + }) + .unwrap_or_else(|| i.pointer.primary_down()); + for e in std::mem::take(&mut i.events) { + match e { + egui::Event::Text(t) => seq.push(Seq::Text(t)), + egui::Event::Ime(egui::ImeEvent::Preedit { text, active_range_chars }) => { + seq.push(Seq::Preedit(text, active_range_chars)); } - Ok(vec![]) + egui::Event::Ime(egui::ImeEvent::Commit(t)) => seq.push(Seq::Commit(t)), + egui::Event::Ime(egui::ImeEvent::DeleteSurrounding { before_chars, after_chars }) => { + seq.push(Seq::DeleteSurrounding(before_chars, after_chars, SurroundingUnit::Chars)); + } + egui::Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes, after_bytes }) => { + seq.push(Seq::DeleteSurrounding(before_bytes, after_bytes, SurroundingUnit::Bytes)); + } + egui::Event::ImeResetCompleted { request_id } => seq.push(Seq::ResetCompleted(request_id)), + // X11/Wayland PRIMARY middle-click: text at the pointer, inserted as typed text (never + // routed as a key or shortcut). + egui::Event::MiddleClickPaste { text, .. } => seq.push(Seq::Text(text)), + // Only unmodified Backspace/Delete interleave with injected text; word-delete and other + // modified forms stay with `route_type_input`. + egui::Event::Key { key: key @ (Key::Backspace | Key::Delete), pressed: true, modifiers: m, .. } + if !m.command && !m.alt && !m.ctrl => + { + let tk = if key == Key::Backspace { ToolKey::Backspace } else { ToolKey::Delete }; + seq.push(Seq::Erase(tk, Mods { shift: m.shift, ..Mods::default() })); + } + e @ egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed, .. } => { + primary_down = pressed; + if pressed { + seq.push(Seq::InterruptPointer); + } + kept.push(e); + } + e @ egui::Event::PointerMoved(_) => { + if primary_down { + seq.push(Seq::InterruptPointer); + } + kept.push(e); + } + e @ egui::Event::Key { key, pressed: true, modifiers, .. } + if matches!(key, Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown | Key::Home | Key::End) + || key == Key::A && modifiers.command => + { + seq.push(Seq::InterruptNavigation); + kept.push(e); + } + e @ egui::Event::Key { pressed: true, modifiers, .. } if is_shortcut_chord(modifiers) => { + seq.push(Seq::InterruptCommand); + kept.push(e); + } + e @ (egui::Event::Cut | egui::Event::Paste(_)) => { + seq.push(Seq::InterruptCommand); + kept.push(e); + } + other => kept.push(other), } - Seq::Erase(..) => Ok(vec![]), - }; + } + i.events = kept; + }); + seq +} + +/// Apply one sequence item to the active tool; the caller filters stale/deferred events and +/// turns a successful IME mutation into its receipt. +fn apply_type_event( + app: &mut VectorcraftApp, + ctx: &egui::Context, + view: ViewInfo, + e: Seq, + shortcut_mods: bool, + was_composing: bool, + old_composition_retired: &mut bool, +) -> EngineResult> { + match e { + Seq::InterruptPointer => { + *old_composition_retired |= crate::ime::defer_interrupt(app, ctx); + Ok(vec![]) + } + Seq::InterruptNavigation => { + // Active composition owns its navigation keys. Once cleared, application caret + // navigation interrupts its still-pending terminal event, even in this batch. + if !app.session.tool_composing() { + *old_composition_retired |= crate::ime::defer_interrupt(app, ctx); + } + Ok(vec![]) + } + Seq::InterruptCommand => { + if crate::ime::blocked(app, ctx) { + *old_composition_retired |= crate::ime::defer_interrupt(app, ctx); + } + Ok(vec![]) + } + Seq::ResetCompleted(_) => Ok(vec![]), + // Keys the IME passed through come as text only when nothing is marked. This is an + // application edit (also a hook IME's reinjected characters): `Other` dominates. + Seq::Text(t) if !app.session.tool_composing() && !shortcut_mods => { + let target_before = crate::ime::target(&mut app.session, view); + let before = crate::ime::stamp(&mut app.session, view); + let r = app.session.tool_text(&t, view); + if r.is_ok() { + crate::ime::note_application_edit(app, ctx, view, target_before, before); + } + r + } + Seq::Text(_) => Ok(vec![]), + // A pre-edit is not committed text: it changes neither the outgoing snapshot (the + // engine query removes it) nor the cause. + Seq::Preedit(text, active_range_chars) => { + app.ime_marked = Some(text.clone()).filter(|t| !t.is_empty()); + app.session.tool_preedit(&text, active_range_chars, view) + } + // A bare line break confirms the composition (Enter): the marked text, not a newline. + // It is an accepted IME commit of that marked text when the tool inserts the marked + // text unchanged; a commit the tool rewrites (a quoted or filtered marked text) is an + // application edit instead. + Seq::Commit(t) if t == "\n" || t == "\r" => { + let target_before = crate::ime::target(&mut app.session, view); + let before = crate::ime::stamp(&mut app.session, view); + let composing = app.session.tool_composing(); + let verbatim = app.session.tool_ime_session_matches(view) + && match app.ime_marked.as_deref() { + Some(m) => app.session.tool_text_is_verbatim(m, view), + None => true, + }; + keep_marked_text(app); + if composing { + if verbatim { + crate::ime::note_ime_apply(app, ctx, view, target_before, before); + } else { + crate::ime::note_application_edit(app, ctx, view, target_before, before); + } + } + Ok(vec![]) + } + // An accepted IME commit: stamp the bounded committed context immediately before and + // after, so only a real mutation (not a no-op, rejection or failure) earns the receipt. + // A commit the tool rewrites (Typographer's Quotes, a filtered control character) is an + // application edit and must never earn `InputMethod`. + Seq::Commit(t) => { + app.ime_marked = None; + let target_before = crate::ime::target(&mut app.session, view); + let before = crate::ime::stamp(&mut app.session, view); + // A document edit since the composition means this commit starts a fresh typing + // session: that reset is application-side, so it cannot earn `InputMethod`. + let verbatim = app.session.tool_ime_session_matches(view) && app.session.tool_text_is_verbatim(&t, view); + let r = app.session.tool_text(&t, view); + if r.is_ok() { + if verbatim { + crate::ime::note_ime_apply(app, ctx, view, target_before, before); + } else { + crate::ime::note_application_edit(app, ctx, view, target_before, before); + } + } + r + } + // While composing (or in a frame that began composing), editing keys belong to the IME. + Seq::Erase(tk, m) if !was_composing && !app.session.tool_composing() => { + // Mirror `route_type_input`/`text_key`: surface an erase error in the status bar + // instead of discarding it, and report the successful edit as application-origin. + let target_before = crate::ime::target(&mut app.session, view); + let before = crate::ime::stamp(&mut app.session, view); + let r = app.session.tool_key(tk, m, view); + match &r { + Err(e) => app.status(e.to_string()), + Ok(_) => crate::ime::note_application_edit(app, ctx, view, target_before, before), + } + r + } + Seq::Erase(..) => Ok(vec![]), + // Deleting around the composition starts the same typing session as the commit that + // follows, so the replacement is one undo step; an error reaches the status bar. The + // receipt covers only this accepted deletion. + Seq::DeleteSurrounding(before, after, unit) => { + let target_before = crate::ime::target(&mut app.session, view); + let stamp_before = crate::ime::stamp(&mut app.session, view); + let r = app.session.tool_ime_delete_surrounding(before, after, unit, view); + match &r { + Err(e) => app.status(e.to_string()), + Ok(_) => crate::ime::note_ime_apply(app, ctx, view, target_before, stamp_before), + } + r + } } } pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { + // One UI pass takes a frame's events. A discarded rerun and a test helper that also calls + // `logic` see the same events again; they must not fire a shortcut or tool key twice. The + // key includes the context (a restarted host starts at frame 0) and the viewport (frame + // numbers are per viewport), and remembers every key taken this host iteration: an + // interleaved root/auxiliary pass cannot make an earlier frame look new again. + let frame = (crate::graphics::context_tag(ctx), ctx.viewport_id(), ctx.cumulative_frame_nr_for(ctx.viewport_id())); + if !crate::take_frame_once(&mut app.shortcuts_frames, frame) { + return; + } + if !ctx.input(|input| input.focused) { + crate::ime::controls_without_editor(app, ctx); + return; + } // Taken before anything returns, so a press in a field or a dialog ends a pair of Escapes. let mut escape_pair = escape_press(ctx); // Followed before anything returns, so a key typed in a field isn't taken for a paste. let textless_paste = ctx.input(|i| app.paste_chord.textless_paste(&i.events)); // A numeric field being scrubbed has the keyboard: Escape cancels the drag. if crate::scrub::phase(ctx) != crate::scrub::Phase::Idle { + crate::ime::controls_without_editor(app, ctx); return; } if app.ui.dialog.is_some() || app.ui.palette_open { + crate::ime::controls_without_editor(app, ctx); if app.ui.dialog.as_ref().is_some_and(crate::dialogs::revolve_gizmo::active) && crate::dialogs::revolve_gizmo::dragging(ctx) { // Escape first restores this rotation gesture. The settings stay open. return; @@ -324,6 +540,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { return; } if app.ui.screen_mode == 3 { + crate::ime::controls_without_editor(app, ctx); presentation_keys(app, ctx, escape_pair); return; } @@ -393,6 +610,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { } } if typing { + crate::ime::controls_without_editor(app, ctx); return; } ctx.input_mut(unclip_chords); @@ -408,16 +626,35 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { } // Type tool editing: text, IME composition and editing keys go to the tool. if app.session.tool_wants_text() { - type_text(app, ctx); + // Route-level loss (no current target or caret geometry) retires a live guard before any + // marker in this pass can be considered. The finalizer may re-arm on a later activation. + crate::ime::suspend_if_route_lost(app, ctx); + let old_composition_retired = type_text(app, ctx); // Keys of a frame that began composing are the IME's too (a Backspace that empties the // marked text must not delete the committed character before it). - if composing || app.session.tool_composing() { + if composing && !old_composition_retired || app.session.tool_composing() { // The IME owns the keyboard until it commits: no editing keys, clipboard or shortcuts. ctx.input_mut(|i| { i.events.retain(|e| !matches!(e, egui::Event::Key { .. } | egui::Event::Copy | egui::Event::Cut | egui::Event::Paste(_))) }); return; } + // A deferred pointer/navigation notice has not established its destination yet. Its + // clipboard suffix must not cut or paste at the old caret; only the notice's own actual + // handler (kept in the raw vector) may run. + if crate::ime::deferred(app, ctx) { + ctx.input_mut(|input| { + input.events.retain(|event| match event { + egui::Event::Cut | egui::Event::Paste(_) => false, + egui::Event::Key { key: egui::Key::X | egui::Key::V, pressed: true, modifiers, .. } + if modifiers.command && !modifiers.shift && !modifiers.alt => + { + false + } + _ => true, + }) + }); + } // Editing keys with modifiers and the clipboard (Cmd+A is Select All's, below: the text). crate::panels::character::route_type_input(app, ctx); // Enter was already delivered above as ToolKey::Enter (newline). Only chords with the @@ -429,6 +666,57 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { settings_on_a_mac(app, ctx, mac(ctx)); return; } + crate::ime::controls_without_editor(app, ctx); + // A clipboard image (eframe's `clipboard` feature, 0.37): the host's text paste path never + // carries it. With the app's rich-format `SystemClipboard`, it is only the signal that the + // clipboard holds no text — the next tick invokes Paste, whose `adopt_system_clipboard` reads + // the formats once (never paste twice). Without one (the web/simple host), turn the pixels + // into a PNG flavour and paste that. + let image_paste = app.services.system_clipboard.is_none(); + let mut pasted_image: Option> = None; + let mut read_image_formats = false; + // `MiddleClickPaste` is deliberately left in `i.events`: the Type tool above already took it, + // and otherwise egui's text field under the pointer (focused or not) pastes it later in this + // pass. It is not a shortcut. + ctx.input_mut(|i| { + i.events.retain(|e| match e { + egui::Event::PasteImage(image) => { + if image_paste { + if pasted_image.is_none() { + let pixels: Vec = image.pixels.iter().flat_map(|c| c.to_array()).collect(); + let rendered = vectorcraft_render::Rendered { + width: u32::try_from(image.size[0]).unwrap_or(0), + height: u32::try_from(image.size[1]).unwrap_or(0), + pixels, + }; + // A renderer failure leaves the paste as the no-op an empty flavour gets. + pasted_image = rendered.to_png().ok(); + } + } else { + read_image_formats = true; + } + false + } + _ => true, + }) + }); + if read_image_formats { + // A text paste in the same frame wins, as it does for a clipboard with text and a picture: + // the paste chord path below reads the text. An image-only clipboard is read here. + if pasted_image.is_none() && textless_paste.is_none() && !ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Paste(_)))) { + // Remember the chord as it was when the image was pasted: the next `tick` runs in a + // later input pass whose modifiers need not describe this paste. The request also + // schedules that tick; nothing else may be coming to run it. + app.clipboard_image_request = Some(ctx.input(|i| i.modifiers)); + ctx.request_repaint(); + } + } + if let Some(png) = pasted_image { + let held = ctx.input(|i| i.modifiers); + app.clipboard_in = Some(Flavour { mime: vectorcraft_engine::cmd::clipboard::PNG, data: png }); + crate::menus::invoke(app, paste_command(held), json!({})); + return; + } // Clipboard keys arrive as events, not key presses; the chord held says which paste. let mut clip = vec![]; ctx.input_mut(|i| { @@ -735,7 +1023,7 @@ const ESCAPE_PAIR_SECONDS: f64 = 1.0; /// The key of the last counted Escape's time in egui's temporary data. fn escape_pair_id() -> egui::Id { - egui::Id::new("escape-pair") + egui::Id::unique("escape-pair") } /// Reads this frame's presses for Escape pressed twice. `None` when the frame has no Escape press, or @@ -798,7 +1086,9 @@ fn presentation_keys(app: &mut VectorcraftApp, ctx: &egui::Context, escape_pair: | egui::Event::Ime(_) | egui::Event::Copy | egui::Event::Cut - | egui::Event::Paste(_) => false, + | egui::Event::Paste(_) + | egui::Event::PasteImage(_) + | egui::Event::MiddleClickPaste { .. } => false, _ => true, }) }); @@ -863,8 +1153,12 @@ mod tests { fn frame(app: &mut VectorcraftApp, events: Vec) -> egui::FullOutput { let ctx = egui::Context::default(); let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { - handle(app, ui.ctx()); + // The production path: `logic` takes this host iteration's input once through + // `handle_input_once`, which runs the shortcut handler when the fonts are ready. A + // direct call covers the passes before those fonts are installed; the frame key makes + // it a no-op on the passes that already took this frame. app.logic(ui.ctx()); + handle(app, ui.ctx()); }); out.textures_delta.clear(); out @@ -1174,6 +1468,31 @@ mod tests { assert_eq!(doc["layers"][0]["children"][0]["name"], "a\tb"); } + /// egui counts frames per viewport: two viewports of one context can both be on frame 0. Each + /// one must take its shortcut, not lose the second to a dedupe key that omits the viewport. + #[test] + fn two_viewports_on_the_same_frame_number_each_take_their_shortcut() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let ctx = egui::Context::default(); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)); + let aux = egui::ViewportId::from_hash_of("vectorcraft-shortcuts-aux"); + let tab = egui::Event::Key { key: Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; + let mut frames = vec![]; + // The auxiliary viewport goes first so the root one has not advanced past frame 0. + for viewport in [aux, egui::ViewportId::ROOT] { + let mut raw = egui::RawInput { events: vec![tab.clone()], screen_rect: Some(screen), viewport_id: viewport, ..Default::default() }; + raw.viewports.entry(viewport).or_default(); + let mut out = ctx.run_ui(raw, |ui| { + frames.push(ui.ctx().cumulative_frame_nr_for(viewport)); + handle(&mut app, ui.ctx()); + }); + out.textures_delta.clear(); + } + assert_eq!(frames, [0, 0], "both viewports are on frame 0"); + assert!(app.ui.dock, "two Tabs in two viewports toggled the panels twice (the second was not a duplicate)"); + } + /// Shift+Tab shows and hides the panels and leaves the toolbar and the Control bar as they are, /// while Tab hides all three. Hiding the panels closes a panel popped out of the icon column. /// In a focused field neither key shows or hides anything. @@ -1323,6 +1642,9 @@ mod tests { }); let svg = copied.expect("copy publishes SVG"); assert!(svg.contains(" bool { + self.pending + } +} + /// The command (and its params) that loads `f` into the internal clipboard, centred on `center`. pub(crate) fn import_command(f: &Flavour, center: Option<[f64; 2]>) -> (&'static str, Value) { let b64 = || vectorcraft_format::base64_encode(&f.data); @@ -155,7 +161,11 @@ impl VectorcraftApp { } // A paste chord egui sent no Paste event for: the clipboard held no text when it came. let textless = ctx.input(|i| self.paste_chord.clone().textless_paste(&i.events).is_some()); - if !self.clipboard_publishing.pending { + // Formats that waited when this host iteration began still own a paste event read while + // they were pending, even if the worker wrote them during this iteration's tick. + let waited = ctx.data(|d| d.get_temp::(egui::Id::unique("clipboard.pending-at-tick"))).unwrap_or(false); + ctx.data_mut(|d| d.remove::(egui::Id::unique("clipboard.pending-at-tick"))); + if !(self.clipboard_publishing.pending || waited) { // The formats the clipboard still holds were written after the chord's key went down. if textless && self.services.system_clipboard.as_mut().is_some_and(|cb| cb.holds_ours()) @@ -197,6 +207,9 @@ impl VectorcraftApp { /// copy, and so does what we are still making the formats of. An error means nothing should be /// pasted. pub(crate) fn adopt_system_clipboard(&mut self) -> Result<(), String> { + // A paste command may run before the next tick consumes an image signal: this is that + // paste, so a queued `PasteImage` must not read the clipboard a second time. + self.clipboard_image_request = None; let pasted = self.clipboard_in.take(); let center = self.view().map(|v| [v.center.x, v.center.y]); let (cmd, params) = match (pasted, self.services.system_clipboard.as_mut()) { diff --git a/crates/ui-egui/src/tests_automation.rs b/crates/ui-egui/src/tests_automation.rs index 063d81188..137b36ca1 100644 --- a/crates/ui-egui/src/tests_automation.rs +++ b/crates/ui-egui/src/tests_automation.rs @@ -185,15 +185,87 @@ fn frames_with_injected_input_are_confined() { let (mut app, tx, base) = confined("synthetic"); let r = request(&mut app, &tx, "ui.key", json!({"key": "Escape"})); assert_eq!(r["ok"], true, "{r}"); + let ctx = egui::Context::default(); let mut raw = egui::RawInput::default(); - app.raw_input_hook(&mut raw); + app.raw_input_hook(&ctx, &mut raw); assert!(app.synthetic_frame, "the frame carrying the key"); + // The batch is acknowledged only after a settled UI pass took it (one step per pass). + let mut out = ctx.run_ui(raw, |_| {}); + out.textures_delta.clear(); let mut raw = egui::RawInput::default(); - app.raw_input_hook(&mut raw); + app.raw_input_hook(&ctx, &mut raw); assert!(!app.synthetic_frame, "the frames after it are the person's"); let _ = std::fs::remove_dir_all(base); } +/// Hidden logic-only ticks between the injection and its UI pass must not drain another step: +/// one step per settled pass, not per hidden tick. +#[test] +fn hidden_ticks_without_a_ui_pass_do_not_drain_more_injected_steps() { + let (mut app, tx, base) = confined("gate"); + let r = request(&mut app, &tx, "ui.key", json!({"key": "A", "text": "x"})); + assert_eq!(r["ok"], true, "{r}"); + let ctx = egui::Context::default(); + let mut raw = egui::RawInput::default(); + app.raw_input_hook(&ctx, &mut raw); + assert!(!raw.events.is_empty(), "the first step is in the host's input"); + let queued = app.synthetic.len(); + for _ in 0..3 { + let mut raw = egui::RawInput::default(); + app.raw_input_hook(&ctx, &mut raw); + assert!(raw.events.is_empty(), "no new step while the previous batch is unapplied"); + } + assert_eq!(app.synthetic.len(), queued, "the hidden ticks drained nothing"); + let _ = std::fs::remove_dir_all(base); +} + +/// A UI pass that changes the document before taking an injected batch drops exactly that batch, +/// including the synthetic modifier latch it set: a later real event keeps the host's modifiers. +#[test] +fn a_stale_injected_batch_is_dropped_with_its_modifier_latch() { + let (mut app, tx, base) = confined("discard"); + let r = request(&mut app, &tx, "ui.key", json!({"key": "A", "text": "x", "cmd": true})); + assert_eq!(r["ok"], true, "{r}"); + let ctx = egui::Context::default(); + let mut raw = egui::RawInput::default(); + app.raw_input_hook(&ctx, &mut raw); + assert!(app.synthetic_modifiers, "the batch carries Cmd"); + assert!(!raw.events.is_empty()); + // The document changed after injection (a control request or a click switching documents): + // the batch no longer belongs to what the UI pass would act on. + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.raw_input_hook(&ctx, &mut raw); + assert!(raw.events.is_empty(), "the stale events are removed from the host's input"); + assert!(!app.synthetic_frame && !app.synthetic_injected, "the batch is not applied"); + assert!(!app.synthetic_modifiers, "its modifier latch is cleared too"); + assert!(app.synthetic.is_empty(), "nothing further was queued"); + let _ = std::fs::remove_dir_all(base); +} + +/// If the host's raw input no longer contains the synthetic batch exactly (another handler +/// rewrote or interleaved events), the app cannot separate stale synthetic clicks/text from the +/// person's input. It delivers nothing that pass and cancels the rest of the sequence. +#[test] +fn an_unidentifiable_stale_injected_batch_fails_closed() { + let (mut app, tx, base) = confined("discard-unknown"); + let r = request(&mut app, &tx, "ui.key", json!({"key": "A", "text": "x", "cmd": true})); + assert_eq!(r["ok"], true, "{r}"); + let ctx = egui::Context::default(); + let mut raw = egui::RawInput::default(); + app.raw_input_hook(&ctx, &mut raw); + assert!(raw.events.len() > 1, "the batch has a modifier companion and the key"); + // Rewrite one event so the exact subsequence can no longer be found. + let last = raw.events.len() - 1; + raw.events[last] = egui::Event::Text("host".into()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.raw_input_hook(&ctx, &mut raw); + assert!(raw.events.is_empty(), "unidentifiable stale input is never delivered"); + assert!(!app.synthetic_frame && !app.synthetic_injected, "the stale batch is not applied"); + assert!(!app.synthetic_modifiers, "its modifier latch is cleared too"); + assert!(app.synthetic.is_empty(), "the rest of the stale synthetic sequence is cancelled"); + let _ = std::fs::remove_dir_all(base); +} + /// Edit Original and the Attributes panel's Browser button hand a file or URL to another app: a /// control request (or MCP, which comes through it) can't run them, roots or not. #[test] diff --git a/crates/ui-egui/src/tests_font_menu.rs b/crates/ui-egui/src/tests_font_menu.rs index efbee00ff..6b52792e5 100644 --- a/crates/ui-egui/src/tests_font_menu.rs +++ b/crates/ui-egui/src/tests_font_menu.rs @@ -386,6 +386,7 @@ fn the_search_field_stays_put_while_the_list_scrolls() { delta: egui::vec2(0.0, dy), modifiers: Default::default(), phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Unknown, }; frame(vec![egui::Event::PointerMoved(over_list), wheel]); for _ in 0..10 { diff --git a/crates/ui-egui/src/tests_home.rs b/crates/ui-egui/src/tests_home.rs index 86785557f..1b62ac94c 100644 --- a/crates/ui-egui/src/tests_home.rs +++ b/crates/ui-egui/src/tests_home.rs @@ -282,6 +282,7 @@ fn home_scrolls_in_a_short_window() { delta: egui::vec2(0.0, -5000.0), phase: egui::TouchPhase::Move, modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, }; visible_home_texts(&mut app, &ctx, size, 0.1, vec![egui::Event::PointerMoved(at), wheel]); let mut texts = vec![]; diff --git a/crates/ui-egui/src/tests_lifecycle.rs b/crates/ui-egui/src/tests_lifecycle.rs new file mode 100644 index 000000000..8dbdd503d --- /dev/null +++ b/crates/ui-egui/src/tests_lifecycle.rs @@ -0,0 +1,1541 @@ +//! The 0.37 lifecycle: `raw_input_hook` (one host iteration) → `tick` in `App::logic` → `ui` in +//! `App::ui`. Input is taken once, on the first UI pass; a discarded rerun draws again without +//! replaying it, and a logic-only iteration ticks without reading the stale events. + +use egui::Event; +use serde_json::json; +use vectorcraft_engine::Session; + +use crate::VectorcraftApp; + +fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 40, "height": 30})).unwrap(); + app.session.execute("select.all", &json!({})).unwrap(); + app +} + +fn objects(app: &VectorcraftApp) -> usize { + app.session.active().map_or(0, |st| st.doc.layers.iter().map(|l| l.children().map_or(0, |c| c.len())).sum()) +} + +fn undo(app: &VectorcraftApp) -> usize { + app.session.active().map_or(0, |st| st.history.undo.len()) +} + +/// A realistic window for headless frames: `RawInput::default()`'s viewport is huge enough for the +/// canvas texture to exceed egui's 2048-point limit. +fn screen() -> egui::Rect { + egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0)) +} + +/// Foreign SVG: a paste of it adds one object through the internal clipboard, unlike plain text +/// (which only pastes when the app copied text itself). +const PASTE_SVG: &str = r#""#; + +#[test] +fn a_discarded_rerun_ticks_once_and_runs_a_duplicate_once() { + let mut app = app(); + let ctx = egui::Context::default(); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let raw = egui::RawInput { events, screen_rect: Some(screen()), ..Default::default() }; + app.begin_host_iteration(); + let mut out = ctx.run_ui(raw, |ui| { + app.tick(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + out + }; + // Fonts install on the first pass and are ready on the second. + frame(&mut app, vec![]); + frame(&mut app, vec![]); + let (before_objects, before_undo, before_iteration) = (objects(&app), undo(&app), app.host_iteration); + // A paste adds one object and one undo step: a second handling of the same event would add + // two of each. + let paste = Event::Paste(PASTE_SVG.into()); + let mut passes = 0; + let raw = egui::RawInput { events: vec![paste], screen_rect: Some(screen()), ..Default::default() }; + app.begin_host_iteration(); + let mut out = ctx.run_ui(raw, |ui| { + passes += 1; + app.tick(ui.ctx()); + app.ui(ui); + if passes == 1 { + // Force the multi-pass path the new Grid widgets trigger in production. + ui.ctx().request_discard("lifecycle test"); + } + }); + out.textures_delta.clear(); + assert!(passes >= 2, "the frame reran after the discard, got {passes} passes"); + assert_eq!(app.host_iteration, before_iteration + 1, "one host iteration despite {passes} passes"); + assert_eq!(app.tick_iteration, Some(app.host_iteration), "tick once for the iteration"); + assert_eq!(objects(&app), before_objects + 1, "the duplicate ran exactly once"); + assert_eq!(undo(&app), before_undo + 1, "one undo step"); +} + +#[test] +fn logic_only_iterations_tick_without_replaying_input() { + let mut app = app(); + let ctx = egui::Context::default(); + // Two full frames: fonts, then a drawn frame. + for _ in 0..2 { + app.begin_host_iteration(); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen()), ..Default::default() }, |ui| { + app.tick(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + } + let (before_objects, before_undo) = (objects(&app), undo(&app)); + let iterations = app.host_iteration; + // The same duplicate event stays in this raw input, as eframe keeps the last shown frame's + // events for a hidden window. `tick` must not read them. + let stale = egui::RawInput { events: vec![Event::Paste(PASTE_SVG.into())], screen_rect: Some(screen()), ..Default::default() }; + for _ in 0..3 { + app.begin_host_iteration(); + let _ = ctx.run_logic(&stale, |ctx| app.tick(ctx)); + } + assert_eq!(app.host_iteration, iterations + 3); + assert_eq!(app.tick_iteration, Some(app.host_iteration), "the last hidden iteration ticked"); + assert_eq!(objects(&app), before_objects, "no stale input was replayed"); + assert_eq!(undo(&app), before_undo, "no command ran hidden"); +} + +#[test] +fn a_consumed_event_is_not_replayed_by_a_later_logic_iteration() { + let mut app = app(); + let ctx = egui::Context::default(); + for _ in 0..2 { + app.begin_host_iteration(); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen()), ..Default::default() }, |ui| { + app.tick(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + } + let before = undo(&app); + let raw = egui::RawInput { events: vec![Event::Paste(PASTE_SVG.into())], screen_rect: Some(screen()), ..Default::default() }; + app.begin_host_iteration(); + let mut out = ctx.run_ui(raw, |ui| { + app.tick(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + assert_eq!(undo(&app), before + 1, "the visible frame ran it"); + // eframe hands the same events to `run_logic` for a hidden window; tick ignores them. + let stale = egui::RawInput { events: vec![Event::Paste(PASTE_SVG.into())], screen_rect: Some(screen()), ..Default::default() }; + for _ in 0..2 { + app.begin_host_iteration(); + let _ = ctx.run_logic(&stale, |ctx| app.tick(ctx)); + } + assert_eq!(undo(&app), before + 1, "consumed input stays consumed"); +} + +/// One host iteration through the production lifecycle (`tick` then `ui`), with an optional +/// command applied between them (as a control request drained by `tick` does); returns the IME +/// descriptor of the last pass. +fn ime_frame( + app: &mut VectorcraftApp, + ctx: &egui::Context, + events: Vec, + between: impl FnOnce(&mut VectorcraftApp), +) -> Option { + VectorcraftApp::install_ime_reset_hooks(ctx); + let raw = egui::RawInput { events, screen_rect: Some(screen()), ..Default::default() }; + app.begin_host_iteration(); + let mut between = Some(between); + let mut out = ctx.run_ui(raw, |ui| { + app.tick(ui.ctx()); + if let Some(between) = between.take() { + between(app); + } + app.ui(ui); + }); + out.textures_delta.clear(); + out.platform_output.ime +} + +fn app_with_text(text: &str) -> (VectorcraftApp, u64) { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.prefs.placeholder_text = false; + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let id = app.run("text.create", json!({"x": 100, "y": 100, "text": text, "size": 24})).unwrap()["id"].as_u64().unwrap(); + (app, id) +} + +fn plain(app: &VectorcraftApp, id: u64) -> String { + match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind { + vectorcraft_doc::NodeKind::Text(t) => t.plain_text(), + _ => panic!("not text"), + } +} + +fn click(app: &mut VectorcraftApp, ctx: &egui::Context, at: egui::Pos2) { + let button = + |pos: egui::Pos2, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + ime_frame(app, ctx, vec![egui::Event::PointerMoved(at), button(at, true)], |_| {}); + ime_frame(app, ctx, vec![button(at, false)], |_| {}); +} + +/// Start editing `id` with the Type tool and leave the caret where a click into its bounds lands. +fn begin_typing(app: &mut VectorcraftApp, ctx: &egui::Context, id: u64) -> egui::Pos2 { + app.select_tool("type"); + ime_frame(app, ctx, vec![], |_| {}); + let center = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap().center(); + let xf = crate::canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let at = xf.to_screen(center); + click(app, ctx, at); + assert!(app.session.tool_wants_text(), "the click starts editing the text"); + at +} + +fn preedit(t: &str, chars: usize) -> Event { + Event::Ime(egui::ImeEvent::Preedit { text: t.into(), active_range_chars: Some(chars..chars) }) +} + +/// Aim a real pointer at the already-drawn caret1, then start the composition at caret4. +fn interruption_editor() -> (VectorcraftApp, egui::Context, u64, egui::Pos2) { + let (mut app, id) = app_with_text("abcd"); + let ctx = egui::Context::default(); + ime_frame(&mut app, &ctx, vec![], |_| {}); + ime_frame(&mut app, &ctx, vec![], |_| {}); + begin_typing(&mut app, &ctx, id); + app.session.set_tool_option("select", &json!({"start": 1, "end": 1})); + let point = ime_frame(&mut app, &ctx, vec![], |_| {}).expect("caret1 is drawn").cursor_rect.center(); + assert!(app.canvas_rect.unwrap().contains(point)); + app.session.set_tool_option("select", &json!({"start": 4, "end": 4})); + ime_frame(&mut app, &ctx, vec![], |_| {}); + (app, ctx, id, point) +} + +fn surrounding_delete(bytes: bool) -> Event { + Event::Ime(if bytes { + egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 0 } + } else { + egui::ImeEvent::DeleteSurrounding { before_chars: 1, after_chars: 0 } + }) +} + +fn reset_marker(app: &VectorcraftApp, ctx: &egui::Context) -> Event { + Event::ImeResetCompleted { request_id: crate::ime::pending_request(app, ctx).expect("a real interruption requested a fence") } +} + +fn pointer_reset(app: &mut VectorcraftApp, ctx: &egui::Context, point: egui::Pos2) -> egui::ImeResetId { + let press = Event::PointerButton { pos: point, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }; + let output = ime_frame(app, ctx, vec![Event::PointerMoved(point), press], |_| {}).expect("the destination still edits text"); + let request = output.reset_request.expect("the final destination publishes its real request"); + let release = Event::PointerButton { pos: point, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }; + assert!(ime_frame(app, ctx, vec![release], |_| {}).unwrap().reset_request.is_none(), "an unchanged pass does not request again"); + assert_eq!(crate::ime::pending_request(app, ctx), Some(request)); + request +} + +fn pressed(key: egui::Key, modifiers: egui::Modifiers) -> Event { + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers } +} + +#[test] +fn reset_fence_without_an_old_terminal_admits_the_immediate_fresh_suffix() { + for terminal_pending in [false, true] { + for next_batch in [false, true] { + let (mut app, ctx, id, point) = interruption_editor(); + ime_frame(&mut app, &ctx, vec![preedit("é", 1)], |_| {}); + if terminal_pending { + ime_frame(&mut app, &ctx, vec![preedit("", 0)], |_| {}); + } + let request = pointer_reset(&mut app, &ctx, point); + let marker = Event::ImeResetCompleted { request_id: request }; + let mut events = if next_batch { + ime_frame(&mut app, &ctx, vec![marker], |_| {}); + vec![] + } else { + vec![marker] + }; + events.extend([preedit("が", 1), Event::Ime(egui::ImeEvent::Commit("が".into()))]); + ime_frame(&mut app, &ctx, events, |_| {}); + assert_eq!(plain(&app, id), if terminal_pending { "aがbcd" } else { "aがbcdé" }); + assert!(!crate::ime::blocked(&app, &ctx)); + } + } +} + +#[test] +fn newest_full_reset_id_rejects_old_foreign_and_duplicate_controls() { + let (mut app, ctx, id, point) = interruption_editor(); + ime_frame(&mut app, &ctx, vec![preedit("é", 1)], |_| {}); + let first = pointer_reset(&mut app, &ctx, point); + app.session.set_tool_option("select", &json!({"start": 2, "end": 2})); + let second = ime_frame(&mut app, &ctx, vec![], |_| {}).unwrap().reset_request.expect("a second caret interruption supersedes R1"); + assert_ne!(first, second); + let foreign = ctx.request_ime_reset(egui::ViewportId::from_hash_of("foreign-reset-owner")).unwrap(); + let foreign_context = egui::Context::default().request_ime_reset(egui::ViewportId::ROOT).unwrap(); + let text = plain(&app, id); + let document = app.session.active().unwrap().doc.clone(); + let steps = undo(&app); + ime_frame( + &mut app, + &ctx, + vec![ + Event::ImeResetCompleted { request_id: first }, + Event::ImeResetCompleted { request_id: foreign }, + Event::ImeResetCompleted { request_id: foreign_context }, + preedit("late", 4), + preedit("", 0), + surrounding_delete(false), + surrounding_delete(true), + Event::Ime(egui::ImeEvent::Commit("X".into())), + ], + |_| {}, + ); + assert_eq!(crate::ime::pending_request(&app, &ctx), Some(second)); + assert_eq!(plain(&app, id), text); + assert!(std::sync::Arc::ptr_eq(&document, &app.session.active().unwrap().doc)); + assert_eq!(undo(&app), steps); + ime_frame( + &mut app, + &ctx, + vec![ + Event::ImeResetCompleted { request_id: second }, + preedit("Q", 1), + Event::ImeResetCompleted { request_id: second }, + preedit("QR", 2), + Event::Ime(egui::ImeEvent::Commit("QR".into())), + ], + |_| {}, + ); + assert_eq!(plain(&app, id), "abQRcdé", "a duplicate marker cannot retire the new live preview"); +} + +#[test] +fn pressed_navigation_retires_the_old_fence_until_the_real_destination_is_known() { + let (mut app, ctx, id, point) = interruption_editor(); + ime_frame(&mut app, &ctx, vec![preedit("é", 1)], |_| {}); + let first = pointer_reset(&mut app, &ctx, point); + let output = ime_frame( + &mut app, + &ctx, + vec![ + pressed(egui::Key::ArrowRight, egui::Modifiers::NONE), + Event::ImeResetCompleted { request_id: first }, + surrounding_delete(true), + Event::Ime(egui::ImeEvent::Commit("X".into())), + preedit("late", 4), + ], + |_| {}, + ) + .unwrap(); + assert_eq!(app.session.tool_options()["caret"], 2, "the actual pressed key moved the caret once"); + assert_eq!(plain(&app, id), "abcdé", "the deferred handler's raw suffix stays closed"); + let second = output.reset_request.expect("the final caret publishes a replacement fence"); + assert_ne!(first, second); + ime_frame(&mut app, &ctx, vec![Event::ImeResetCompleted { request_id: second }, preedit("が", 1)], |_| {}); + assert_eq!(plain(&app, id), "abがcdé"); +} + +#[test] +fn a_pressed_command_runs_after_composition_retirement_and_after_its_fence() { + let (mut app, ctx, id, point) = interruption_editor(); + ime_frame(&mut app, &ctx, vec![preedit("é", 1)], |_| {}); + let rulers = app.ui.view.rulers; + let press = Event::PointerButton { pos: point, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }; + ime_frame(&mut app, &ctx, vec![Event::PointerMoved(point), press, pressed(egui::Key::R, egui::Modifiers::COMMAND)], |_| {}); + assert_eq!(app.ui.view.rulers, !rulers, "the cached composing flag must not swallow a real permitted command press"); + assert_eq!(plain(&app, id), "abcdé"); + + // A separate unchanged-layout editor proves the command remains available after F too. + let (mut app, ctx, _, point) = interruption_editor(); + ime_frame(&mut app, &ctx, vec![preedit("é", 1)], |_| {}); + let request = pointer_reset(&mut app, &ctx, point); + let rulers = app.ui.view.rulers; + ime_frame(&mut app, &ctx, vec![Event::ImeResetCompleted { request_id: request }, pressed(egui::Key::R, egui::Modifiers::COMMAND)], |_| {}); + assert_eq!(app.ui.view.rulers, !rulers); +} + +#[test] +fn discarded_request_then_hidden_output_rearms_the_same_caret_once() { + let (mut app, ctx, id, point) = interruption_editor(); + ime_frame(&mut app, &ctx, vec![preedit("é", 1)], |_| {}); + let previous = pointer_reset(&mut app, &ctx, point); + app.session.set_tool_option("select", &json!({"start": 2, "end": 2})); + let selection = app.session.tool_options(); + let raw = egui::RawInput { screen_rect: Some(screen()), ..Default::default() }; + let mut passes = 0; + let mut discarded = None; + app.begin_host_iteration(); + let mut out = ctx.run_ui(raw, |ui| { + passes += 1; + if passes > 1 { + // A real reported occlusion removes this pass's custom producer without changing A. + ui.ctx().input_mut(|input| input.raw.viewports.entry(egui::ViewportId::ROOT).or_default().occluded = Some(true)); + } + app.tick(ui.ctx()); + app.ui(ui); + if passes == 1 { + // Core attaches the token after the callback. This is the adapter's final ID for + // the actual custom descriptor, after all deferred handlers and UI writers ran. + assert!(ui.ctx().output(|output| output.ime.is_some())); + discarded = crate::ime::pending_request(&app, ui.ctx()); + ui.ctx().request_discard("reset descriptor discarded before an unavailable final pass"); + } + }); + out.textures_delta.clear(); + assert!(passes >= 2); + let first = discarded.expect("the discarded pass had a new request for its final destination"); + assert_ne!(previous, first); + assert!(out.platform_output.ime.is_none()); + assert!(crate::ime::pending_request(&app, &ctx).is_none(), "R1 is retired, not left awaiting an impossible acknowledgement"); + assert!(crate::ime::blocked(&app, &ctx)); + assert_eq!(app.session.tool_options(), selection); + let second = ime_frame(&mut app, &ctx, vec![], |_| {}).unwrap().reset_request.expect("the same A/caret reactivates with R2"); + assert_ne!(first, second); + assert!(ime_frame(&mut app, &ctx, vec![], |_| {}).unwrap().reset_request.is_none()); + ime_frame(&mut app, &ctx, vec![Event::ImeResetCompleted { request_id: first }, preedit("late", 4)], |_| {}); + assert_eq!(plain(&app, id), "abcdé"); + ime_frame(&mut app, &ctx, vec![Event::ImeResetCompleted { request_id: second }, preedit("が", 1)], |_| {}); + assert_eq!(plain(&app, id), "abがcdé"); +} + +#[test] +fn stock_field_and_focus_loss_retire_custom_fences_without_consuming_stock_text() { + let (mut app, ctx, id, point) = interruption_editor(); + ime_frame(&mut app, &ctx, vec![preedit("é", 1)], |_| {}); + let first = pointer_reset(&mut app, &ctx, point); + app.ui.palette_open = true; + ime_frame(&mut app, &ctx, vec![], |_| {}); + let stock = ime_frame(&mut app, &ctx, vec![Event::ImeResetCompleted { request_id: first }, Event::Text("xyz".into())], |_| {}) + .expect("the palette's stock TextEdit publishes its descriptor"); + assert!(app.ui.palette_query.contains("xyz"), "controls-only routing leaves stock text intact"); + let view = app.view_info(); + assert_ne!(stock.owner, crate::ime::target(&mut app.session, view).unwrap().owner); + assert!(stock.reset_request.is_none(), "the stock descriptor never carries the custom fence"); + assert!(crate::ime::pending_request(&app, &ctx).is_none()); + assert_eq!(plain(&app, id), "abcdé"); + app.ui.palette_open = false; + ctx.memory_mut(|memory| { + if let Some(field) = memory.focused() { + memory.surrender_focus(field); + } + }); + let second = ime_frame(&mut app, &ctx, vec![], |_| {}).unwrap().reset_request.expect("returning from the stock field rearms"); + assert_ne!(first, second); + let raw = egui::RawInput { + focused: false, + screen_rect: Some(screen()), + events: vec![Event::ImeResetCompleted { request_id: second }], + ..Default::default() + }; + app.begin_host_iteration(); + let mut out = ctx.run_ui(raw, |ui| { + app.tick(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + assert!(crate::ime::pending_request(&app, &ctx).is_none()); + let third = ime_frame(&mut app, &ctx, vec![], |_| {}).unwrap().reset_request.expect("focus reactivation needs a new fence"); + assert_ne!(second, third); + ime_frame( + &mut app, + &ctx, + vec![Event::ImeResetCompleted { request_id: second }, preedit("late", 4), Event::ImeResetCompleted { request_id: third }, preedit("が", 1)], + |_| {}, + ); + assert_eq!(plain(&app, id), "aがbcdé"); +} + +#[test] +fn a_deferred_pointer_drops_text_and_clipboard_until_its_real_handler_and_fence() { + for paste in [false, true] { + let (mut app, ctx, id, point) = interruption_editor(); + ime_frame(&mut app, &ctx, vec![preedit("é", 1)], |_| {}); + let first = pointer_reset(&mut app, &ctx, point); + let text = plain(&app, id); + let steps = undo(&app); + let mut events = vec![Event::PointerMoved(point), primary_button(point, true)]; + if paste { + events.push(Event::Paste("P".into())); + } else { + events.push(Event::Text("x".into())); + events.push(Event::Cut); + } + // The old marker arrives as a suffix, after the application text/clipboard event. + events.push(Event::ImeResetCompleted { request_id: first }); + let output = ime_frame(&mut app, &ctx, events, |_| {}).expect("the pointer destination still edits text"); + assert_eq!(plain(&app, id), text, "the stale prefix mutates neither the old caret nor a new destination"); + assert_eq!(undo(&app), steps, "dropped text and clipboard events create no undo step"); + assert_eq!(app.session.tool_options()["caret"], 1, "the real pointer handler still moves the caret once"); + let second = output.reset_request.expect("the actual destination publishes its replacement fence"); + assert_ne!(first, second); + assert_eq!(crate::ime::pending_request(&app, &ctx), Some(second)); + // The retired marker and its suffix stay inert; the newest fence admits fresh input. + ime_frame(&mut app, &ctx, vec![Event::ImeResetCompleted { request_id: first }, preedit("late", 4)], |_| {}); + assert_eq!(plain(&app, id), text); + ime_frame(&mut app, &ctx, vec![Event::ImeResetCompleted { request_id: second }, preedit("が", 1)], |_| {}); + assert_eq!(plain(&app, id), "aがbcdé"); + } +} + +#[test] +fn hidden_pointer_and_marker_leave_no_token_for_a_later_stock_field() { + let (mut app, ctx, id, point) = interruption_editor(); + ime_frame(&mut app, &ctx, vec![preedit("é", 1)], |_| {}); + let first = pointer_reset(&mut app, &ctx, point); + let _ = crate::ime::take_request_count(&ctx); + // The route is already known ineligible: hidden and with an old marker followed by a pointer + // and navigation notice. No new request may be allocated for a descriptor this pass cannot + // publish. + let mut raw = egui::RawInput { + events: vec![ + Event::PointerMoved(point), + primary_button(point, true), + Event::Ime(egui::ImeEvent::Commit("X".into())), + Event::ImeResetCompleted { request_id: first }, + pressed(egui::Key::ArrowRight, egui::Modifiers::NONE), + ], + screen_rect: Some(screen()), + ..Default::default() + }; + raw.viewports.entry(egui::ViewportId::ROOT).or_default().occluded = Some(true); + app.begin_host_iteration(); + let mut out = ctx.run_ui(raw, |ui| { + app.tick(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + assert_eq!(crate::ime::take_request_count(&ctx), 0, "an ineligible route allocates no reset"); + assert!(crate::ime::pending_request(&app, &ctx).is_none(), "the old fence is retired, not rearmed"); + assert!(crate::ime::blocked(&app, &ctx), "the guard stays suspended until a real custom destination"); + assert_eq!(plain(&app, id), "abcdé", "the hidden marker and pointer suffix mutate nothing"); + app.ui.palette_open = true; + // A newly shown TextEdit requests focus after it has already sampled + // `owns_ime_events`; its stock descriptor is published on the next pass. + assert!(ime_frame(&mut app, &ctx, vec![], |_| {}).is_none(), "the palette's focus pass publishes no descriptor or reset token"); + let stock = ime_frame(&mut app, &ctx, vec![], |_| {}).expect("the palette's stock TextEdit publishes its descriptor"); + assert!(stock.reset_request.is_none(), "no hidden-route token reaches a stock field"); + assert!(crate::ime::pending_request(&app, &ctx).is_none()); +} + +#[test] +fn hidden_logic_with_a_changed_composition_leaves_no_token_for_a_later_stock_field() { + let (mut app, ctx, id, _) = interruption_editor(); + ime_frame(&mut app, &ctx, vec![preedit("é", 1)], |_| {}); + // The application changed the composition's destination while the window is hidden; a logic + // pass settles it, but has no custom descriptor to consume a request. + app.session.set_tool_option("select", &json!({"start": 1, "end": 1})); + let _ = crate::ime::take_request_count(&ctx); + let raw = egui::RawInput { screen_rect: Some(screen()), ..Default::default() }; + app.begin_host_iteration(); + let _ = ctx.run_logic(&raw, |ctx| { + app.tick(ctx); + app.finish_ime_logic(ctx); + }); + assert_eq!(crate::ime::take_request_count(&ctx), 0, "hidden logic allocates no reset"); + assert!(crate::ime::pending_request(&app, &ctx).is_none()); + assert!(!app.session.tool_composing(), "the changed preview settled at its original position"); + assert_eq!(plain(&app, id), "abcdé"); + app.ui.palette_open = true; + // A newly shown TextEdit requests focus after it has already sampled + // `owns_ime_events`; its stock descriptor is published on the next pass. + assert!(ime_frame(&mut app, &ctx, vec![], |_| {}).is_none(), "the palette's focus pass publishes no descriptor or reset token"); + let stock = ime_frame(&mut app, &ctx, vec![], |_| {}).expect("the palette's stock TextEdit publishes its descriptor"); + assert!(stock.reset_request.is_none(), "hidden logic leaves no token for the stock field"); +} + +#[test] +fn home_with_an_interrupted_commit_allocates_no_reset_and_leaves_no_stock_token() { + let (mut app, ctx, id, point) = interruption_editor(); + ime_frame(&mut app, &ctx, vec![preedit("é", 1)], |_| {}); + let first = pointer_reset(&mut app, &ctx, point); + // R1 is live and the incoming latch is Stale. A fresh preedit/commit here is not reachable: + // `shortcuts.rs` drops IME mutations while Stale. Application text still is: `Text` changes + // the committed stamp and caret without being an IME mutation. Home is already known at + // pass entry, so route suspension retires R1 before text/ArrowRight handling. No new reset + // may be allocated for this pass's non-custom route. + let _ = crate::ime::take_request_count(&ctx); + let output = ime_frame(&mut app, &ctx, vec![Event::Text("x".into()), pressed(egui::Key::ArrowRight, egui::Modifiers::NONE)], |app| { + // Chosen after `tick`, before this pass's UI/input handling: the same route ordering + // as the original regression, with Home already known when the notice is processed. + app.run("app.home", json!({})).unwrap(); + assert!(crate::menus::home_showing(app)); + }); + assert!(output.is_none(), "Home publishes no custom descriptor"); + assert_eq!(crate::ime::take_request_count(&ctx), 0, "Home suspends the old fence before input; no new reset is allocated"); + assert!(crate::ime::pending_request(&app, &ctx).is_none(), "the old fence is retired, not rearmed"); + assert_eq!(plain(&app, id), "axbcdé", "the application text lands at the fenced caret; Home changes no text"); + app.ui.palette_open = true; + // A newly shown TextEdit requests focus after it has already sampled + // `owns_ime_events`; its stock descriptor is published on the next pass. + assert!(ime_frame(&mut app, &ctx, vec![], |_| {}).is_none(), "the palette's focus pass publishes no descriptor or reset token"); + let stock = ime_frame(&mut app, &ctx, vec![Event::ImeResetCompleted { request_id: first }], |_| {}) + .expect("the palette's stock TextEdit publishes its descriptor"); + assert!(stock.reset_request.is_none(), "Home's route suspension leaves no token for the stock field"); + assert!(crate::ime::pending_request(&app, &ctx).is_none(), "the old marker cannot rearm a retired fence"); +} + +#[test] +fn command_palette_opened_by_a_commit_batch_gets_no_speculative_reset() { + let (mut app, ctx, id, point) = interruption_editor(); + ime_frame(&mut app, &ctx, vec![preedit("é", 1)], |_| {}); + let _ = pointer_reset(&mut app, &ctx, point); + // R1 is live and Stale. The application Text below is accepted while Stale and changes the + // committed context and caret. The following Cmd+Shift+/ is a command notice whose palette + // only opens later in this same pass: binding at the notice would outlive the palette's + // stock descriptor. + let _ = crate::ime::take_request_count(&ctx); + let output = ime_frame( + &mut app, + &ctx, + vec![Event::Text("x".into()), pressed(egui::Key::Questionmark, egui::Modifiers::COMMAND | egui::Modifiers::SHIFT)], + |_| {}, + ); + assert!(app.ui.palette_open, "the batch opened the command palette"); + assert_eq!(crate::ime::take_request_count(&ctx), 0, "the pre-UI command settlement allocates no reset"); + assert!(crate::ime::pending_request(&app, &ctx).is_none(), "no token awaits the palette"); + assert!(output.is_none(), "the opening batch only takes stock focus; the descriptor is next pass"); + let stock = ime_frame(&mut app, &ctx, vec![], |_| {}).expect("the palette's stock TextEdit publishes its descriptor"); + assert!(stock.reset_request.is_none(), "the palette's stock field gets no custom token"); + assert_eq!(plain(&app, id), "axbcdé", "the application text lands at the fenced caret"); +} + +#[test] +fn multiple_pointer_presses_ending_on_an_invalid_destination_leave_no_stock_token() { + let (mut app, ctx, id, point) = interruption_editor(); + ime_frame(&mut app, &ctx, vec![preedit("é", 1)], |_| {}); + let text = plain(&app, id); + // Clicking the edited caret, then clicking an empty part of the canvas, ends the editing + // session. A token allocated on the empty click's preflight notice would outlive the + // now-ineligible route, so the finalizer must be the only allocator. + let xf = crate::canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let empty = xf.to_screen(vectorcraft_geom::Point::new(300.0, 250.0)); + click(&mut app, &ctx, point); + assert!(app.session.tool_wants_text(), "the first press edits the caret"); + let _ = ime_frame(&mut app, &ctx, vec![Event::PointerMoved(empty), primary_button(empty, true)], |_| {}); + assert!(app.session.tool_wants_text(), "the empty press ends the edit only on release"); + // The release publishes no custom descriptor and must allocate nothing for the stock field. + let _ = crate::ime::take_request_count(&ctx); + let output = ime_frame(&mut app, &ctx, vec![primary_button(empty, false)], |_| {}); + assert!(output.is_none(), "the final release ended the custom editor"); + assert_eq!(crate::ime::take_request_count(&ctx), 0, "the invalidated final destination allocates no reset"); + assert!(crate::ime::pending_request(&app, &ctx).is_none()); + assert_eq!(plain(&app, id), text, "the presses mutate no text"); + app.ui.palette_open = true; + // A newly shown TextEdit requests focus after it has already sampled + // `owns_ime_events`; its stock descriptor is published on the next pass. + assert!(ime_frame(&mut app, &ctx, vec![], |_| {}).is_none(), "the palette's focus pass publishes no descriptor or reset token"); + let stock = ime_frame(&mut app, &ctx, vec![], |_| {}).expect("the palette's stock TextEdit publishes its descriptor"); + assert!(stock.reset_request.is_none(), "the invalidated final pointer leaves no stock token"); +} + +#[test] +fn deferred_command_key_forms_drop_cmd_x_and_cmd_v_until_the_fence() { + let (mut app, ctx, id, point) = interruption_editor(); + ime_frame(&mut app, &ctx, vec![preedit("é", 1)], |_| {}); + let first = pointer_reset(&mut app, &ctx, point); + app.session.set_tool_option("select", &json!({"start": 0, "end": 2})); + let text = plain(&app, id); + let steps = undo(&app); + // Cmd+R is a command notice that leaves the selection alone; the following key-form Cmd+X and + // Cmd+V must not cut or paste at the old caret before the final destination is known. + let output = ime_frame( + &mut app, + &ctx, + vec![ + pressed(egui::Key::R, egui::Modifiers::COMMAND), + pressed(egui::Key::X, egui::Modifiers::COMMAND), + pressed(egui::Key::V, egui::Modifiers::COMMAND), + Event::ImeResetCompleted { request_id: first }, + ], + |_| {}, + ) + .expect("the destination still edits text"); + assert_eq!(plain(&app, id), text, "deferred Cmd+X cuts nothing at the old caret"); + assert_eq!(undo(&app), steps, "deferred Cmd+X creates no undo step"); + let second = output.reset_request.expect("the actual destination publishes its replacement fence"); + assert_ne!(first, second); +} + +#[test] +fn a_stock_field_with_an_active_composition_but_no_guard_gets_no_reset_request() { + let (mut app, ctx, id, _) = interruption_editor(); + ime_frame(&mut app, &ctx, vec![preedit("é", 1)], |_| {}); + assert!(crate::ime::pending_request(&app, &ctx).is_none(), "a live composition alone requests nothing"); + app.session.set_tool_option("select", &json!({"start": 1, "end": 1})); + app.ui.palette_open = true; + let foreign = egui::Context::default().request_ime_reset(egui::ViewportId::ROOT).unwrap(); + // A newly shown TextEdit requests focus after it has already sampled + // `owns_ime_events`; its stock descriptor is published on the next pass. + assert!(ime_frame(&mut app, &ctx, vec![], |_| {}).is_none(), "the palette's focus pass publishes no descriptor or reset token"); + let stock = ime_frame(&mut app, &ctx, vec![Event::ImeResetCompleted { request_id: foreign }], |_| {}) + .expect("the palette's stock TextEdit publishes its descriptor"); + assert!(stock.reset_request.is_none(), "a control-only pass with no custom guard allocates no request"); + assert!(crate::ime::pending_request(&app, &ctx).is_none()); + assert_eq!(plain(&app, id), "abcdé", "the interrupted preview settles at its original position"); + app.ui.palette_open = false; + // The palette's stock TextEdit was focused last pass. It disappears this pass, but egui's + // focus dead-man switch only runs at end_pass, so surrender it before the Type tool can own + // the keyboard again. This fixture has no guard to acknowledge; the fresh preedit below is + // blocked only by that stale focus, not by a Stale latch. + ctx.memory_mut(|memory| { + if let Some(field) = memory.focused() { + memory.surrender_focus(field); + } + }); + let returned = ime_frame(&mut app, &ctx, vec![], |_| {}).expect("the Type tool owns the IME again"); + assert!(returned.reset_request.is_none(), "no guard existed to re-arm"); + ime_frame(&mut app, &ctx, vec![preedit("が", 1)], |_| {}); + assert_eq!(plain(&app, id), "aがbcdé", "a clear adapter accepts fresh composition without a stale latch"); +} + +#[test] +fn a_modal_dialog_suspends_a_live_fence_and_the_same_caret_rearms() { + let (mut app, ctx, id, point) = interruption_editor(); + ime_frame(&mut app, &ctx, vec![preedit("é", 1)], |_| {}); + let first = pointer_reset(&mut app, &ctx, point); + let caret = app.session.tool_options()["caret"].clone(); + let during = ime_frame(&mut app, &ctx, vec![], |app| { + app.ui.told.push(crate::state::Dialog::new(crate::dialogs::confirm::MESSAGE, json!({"message": "A message", "detail": ""}))); + }); + assert!(app.ui.dialog.is_some(), "the modal dialog opened after the canvas drew"); + assert!(during.is_none(), "a modal covering the canvas is not a custom IME owner"); + let returned = ime_frame(&mut app, &ctx, vec![], |app| { + app.ui.dialog = None; + }) + .expect("the Type tool owns the IME again"); + assert_eq!(app.session.tool_options()["caret"], caret, "returning to the same caret"); + let second = returned.reset_request.expect("the same caret re-arms after the modal"); + assert_ne!(first, second); + ime_frame(&mut app, &ctx, vec![Event::ImeResetCompleted { request_id: first }, preedit("late", 4)], |_| {}); + assert_eq!(plain(&app, id), "abcdé"); + ime_frame(&mut app, &ctx, vec![Event::ImeResetCompleted { request_id: second }, preedit("が", 1)], |_| {}); + assert_eq!(plain(&app, id), "aがbcdé"); +} + +#[test] +fn a_same_owner_absent_pass_gets_a_new_generation_and_replacement_fence() { + let (mut app, ctx, id, point) = interruption_editor(); + ime_frame(&mut app, &ctx, vec![preedit("é", 1)], |_| {}); + let first = pointer_reset(&mut app, &ctx, point); + let before = ime_frame(&mut app, &ctx, vec![], |_| {}).expect("the Type tool owns the IME"); + app.run("app.home", json!({})).unwrap(); + assert!(ime_frame(&mut app, &ctx, vec![], |_| {}).is_none(), "Home has no custom IME owner"); + app.ui.home = None; + let after = ime_frame(&mut app, &ctx, vec![], |_| {}).expect("the same Type tool owner returns"); + assert_eq!(after.owner, before.owner, "the model owner did not change"); + assert_ne!(after.context_generation, before.context_generation, "an absent pass starts a new context generation"); + let second = after.reset_request.expect("the new generation publishes a replacement fence"); + assert_ne!(first, second); + ime_frame(&mut app, &ctx, vec![Event::ImeResetCompleted { request_id: first }, preedit("late", 4)], |_| {}); + assert_eq!(plain(&app, id), "abcdé"); + ime_frame(&mut app, &ctx, vec![Event::ImeResetCompleted { request_id: second }, preedit("が", 1)], |_| {}); + assert_eq!(plain(&app, id), "aがbcdé"); +} + +#[test] +fn geometry_loss_before_a_marker_keeps_the_fence_suspended_until_reactivation() { + let (mut app, ctx, id, point) = interruption_editor(); + ime_frame(&mut app, &ctx, vec![preedit("é", 1)], |_| {}); + let first = pointer_reset(&mut app, &ctx, point); + let text = plain(&app, id); + // A hidden/occluded pass has no custom descriptor. Its marker must not complete R1 before the + // route has an editor again. + let mut raw = egui::RawInput { events: vec![Event::ImeResetCompleted { request_id: first }], screen_rect: Some(screen()), ..Default::default() }; + raw.viewports.entry(egui::ViewportId::ROOT).or_default().occluded = Some(true); + app.begin_host_iteration(); + let mut out = ctx.run_ui(raw, |ui| { + app.tick(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + assert!(crate::ime::pending_request(&app, &ctx).is_none(), "the occluded marker is inert"); + assert!(crate::ime::blocked(&app, &ctx), "the guard stays suspended, not completed"); + let second = ime_frame(&mut app, &ctx, vec![], |_| {}).unwrap().reset_request.expect("visible reactivation re-arms"); + assert_ne!(first, second); + ime_frame(&mut app, &ctx, vec![Event::ImeResetCompleted { request_id: first }, preedit("late", 4)], |_| {}); + assert_eq!(plain(&app, id), text); + ime_frame(&mut app, &ctx, vec![Event::ImeResetCompleted { request_id: second }, preedit("が", 1)], |_| {}); + assert_eq!(plain(&app, id), "aがbcdé"); +} + +#[test] +fn settling_a_preview_without_a_pointer_allocates_one_request_after_the_commit() { + let (mut app, ctx, id, _) = interruption_editor(); + ime_frame(&mut app, &ctx, vec![preedit("é", 1)], |_| {}); + let _ = crate::ime::take_request_count(&ctx); + let output = ime_frame(&mut app, &ctx, vec![], |app| { + // An application selection/caret change ends the live preview without a pointer or + // navigation event. The fence must describe the post-settlement destination directly. + app.session.set_tool_option("select", &json!({"start": 1, "end": 1})); + }) + .expect("the Type tool still owns the IME"); + assert_eq!(crate::ime::take_request_count(&ctx), 1, "settlement allocates one fence, not a pre-commit preflight"); + let request = output.reset_request.expect("the settled destination publishes its request"); + assert_eq!(crate::ime::pending_request(&app, &ctx), Some(request)); + let _ = crate::ime::take_request_count(&ctx); + assert!(ime_frame(&mut app, &ctx, vec![], |_| {}).unwrap().reset_request.is_none(), "an unchanged pass keeps the request"); + assert_eq!(crate::ime::take_request_count(&ctx), 0, "no replacement request on an unchanged frame"); + ime_frame(&mut app, &ctx, vec![Event::ImeResetCompleted { request_id: request }, preedit("が", 1)], |_| {}); + assert_eq!(plain(&app, id), "aがbcdé"); +} + +#[test] +fn an_untokened_marked_text_interruption_binds_a_request() { + let (mut app, ctx, id, _) = interruption_editor(); + // The defensive end-of-pass branch: marked text survives without a recorded composition or a + // live tool composition (for example a pre-edit accepted before geometry was observed). + app.ime_marked = Some("x".into()); + let output = ime_frame(&mut app, &ctx, vec![], |_| {}).expect("the Type tool owns the IME"); + assert!(output.should_interrupt_composition, "the tool ended the composition itself"); + let request = output.reset_request.expect("an interruption cannot publish without a request-bound fence"); + assert_eq!(crate::ime::pending_request(&app, &ctx), Some(request)); + assert!(app.ime_marked.is_none()); + ime_frame(&mut app, &ctx, vec![Event::ImeResetCompleted { request_id: request }, preedit("が", 1)], |_| {}); + assert_eq!(plain(&app, id), "abcdが"); +} + +#[test] +fn full_pass_logic_keeps_its_fence_but_standalone_logic_rearms_once() { + let (mut app, ctx, id, point) = interruption_editor(); + ime_frame(&mut app, &ctx, vec![preedit("é", 1)], |_| {}); + let first = pointer_reset(&mut app, &ctx, point); + let raw = egui::RawInput { screen_rect: Some(screen()), ..Default::default() }; + let mut out = ctx.run_ui(raw.clone(), |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + assert_eq!(crate::ime::pending_request(&app, &ctx), Some(first), "logic inside a full UI pass is not publication loss"); + assert!(out.platform_output.ime.unwrap().reset_request.is_none()); + for _ in 0..3 { + app.begin_host_iteration(); + let _ = ctx.run_logic(&raw, |ctx| { + app.tick(ctx); + app.finish_ime_logic(ctx); + }); + assert!(crate::ime::blocked(&app, &ctx)); + assert!(crate::ime::pending_request(&app, &ctx).is_none()); + } + let second = ime_frame(&mut app, &ctx, vec![], |_| {}).unwrap().reset_request.expect("the first returning UI pass rearms once"); + assert_ne!(first, second); + for _ in 0..3 { + assert!(ime_frame(&mut app, &ctx, vec![], |_| {}).unwrap().reset_request.is_none()); + } + ime_frame( + &mut app, + &ctx, + vec![ + Event::ImeResetCompleted { request_id: first }, + Event::Ime(egui::ImeEvent::Commit("X".into())), + Event::ImeResetCompleted { request_id: second }, + preedit("が", 1), + ], + |_| {}, + ); + assert_eq!(plain(&app, id), "aがbcdé"); +} + +#[test] +fn same_caret_external_edit_invalidates_a_fence_before_its_raw_suffix() { + let (mut app, ctx, id, point) = interruption_editor(); + ime_frame(&mut app, &ctx, vec![preedit("é", 1)], |_| {}); + let first = pointer_reset(&mut app, &ctx, point); + let caret = app.session.tool_options()["caret"].clone(); + let second = ime_frame( + &mut app, + &ctx, + vec![Event::ImeResetCompleted { request_id: first }, surrounding_delete(false), Event::Ime(egui::ImeEvent::Commit("X".into()))], + |app| { + app.run("text.editRange", json!({"id": id, "start": 0, "end": 1, "insert": "A"})).unwrap(); + }, + ) + .unwrap() + .reset_request + .expect("the changed committed context gets a new fence"); + assert_ne!(first, second); + assert_eq!(app.session.tool_options()["caret"], caret, "the caret itself did not change"); + assert_eq!(plain(&app, id), "Abcdé", "F1 cannot admit its suffix into a changed context"); + ime_frame(&mut app, &ctx, vec![Event::ImeResetCompleted { request_id: second }, preedit("が", 1)], |_| {}); + assert_eq!(plain(&app, id), "Aがbcdé"); +} + +#[test] +fn a_replaced_context_and_auxiliary_ui_cannot_reuse_the_root_fence() { + let (mut app, ctx, id, point) = interruption_editor(); + ime_frame(&mut app, &ctx, vec![preedit("é", 1)], |_| {}); + let first = pointer_reset(&mut app, &ctx, point); + let aux = egui::ViewportId::from_hash_of("vectorcraft-ime-mode-aux"); + let mut raw = egui::RawInput { viewport_id: aux, screen_rect: Some(screen()), ..Default::default() }; + raw.viewports.entry(aux).or_default(); + app.begin_host_iteration(); + let mut out = ctx.run_ui(raw, |ui| { + app.tick(ui.ctx()); + app.finish_ime_logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + assert_eq!(crate::ime::pending_request(&app, &ctx), Some(first), "auxiliary UI observes only its own viewport's mode and guard"); + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen()), ..Default::default() }, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + assert_eq!(crate::ime::pending_request(&app, &ctx), Some(first)); + + let replacement = egui::Context::default(); + // The real app adopts and restyles this Context. Old F must be rejected even before its + // first descriptor appears, and the final returned descriptor must carry a new request. + let first_output = ime_frame(&mut app, &replacement, vec![Event::ImeResetCompleted { request_id: first }, preedit("late", 4)], |_| {}); + assert_eq!(plain(&app, id), "abcdé"); + let second = first_output + .and_then(|output| output.reset_request) + .or_else(|| ime_frame(&mut app, &replacement, vec![], |_| {}).and_then(|output| output.reset_request)) + .expect("the restored custom editor publishes a request belonging to the replacement Context"); + assert_ne!(first, second); + ime_frame(&mut app, &replacement, vec![Event::ImeResetCompleted { request_id: second }, preedit("が", 1)], |_| {}); + assert_eq!(plain(&app, id), "aがbcdé"); +} + +#[test] +fn hidden_logic_preserves_an_uninterrupted_composition_and_no_ack_stays_closed() { + let (mut app, ctx, id, point) = interruption_editor(); + ime_frame(&mut app, &ctx, vec![preedit("é", 1)], |_| {}); + let raw = egui::RawInput { screen_rect: Some(screen()), ..Default::default() }; + app.begin_host_iteration(); + let _ = ctx.run_logic(&raw, |ctx| { + app.tick(ctx); + app.finish_ime_logic(ctx); + }); + assert!(app.session.tool_composing()); + assert!(!crate::ime::blocked(&app, &ctx), "a hidden iteration alone is not a new interruption"); + let request = pointer_reset(&mut app, &ctx, point); + for _ in 0..4 { + let output = + ime_frame(&mut app, &ctx, vec![preedit("late", 4), preedit("", 0), Event::Ime(egui::ImeEvent::Commit("X".into()))], |_| {}).unwrap(); + assert!(output.reset_request.is_none(), "unsupported/no-ack does not retry every idle frame"); + assert_eq!(crate::ime::pending_request(&app, &ctx), Some(request)); + assert_eq!(plain(&app, id), "abcdé", "old terminals and nonempty text never stand in for acknowledgement"); + } +} + +#[test] +fn pointer_interruption_rejects_next_batch_ime_mutations_and_allows_fresh_input() { + for bytes in [false, true] { + for terminal_pending in [false, true] { + let (mut app, ctx, id, point) = interruption_editor(); + let view = app.view_info(); + let owner = app.session.tool_ime_target(view); + ime_frame(&mut app, &ctx, vec![preedit("é", 1)], |_| {}); + if terminal_pending { + ime_frame(&mut app, &ctx, vec![preedit("", 0)], |_| {}); + } + click(&mut app, &ctx, point); + assert_eq!(app.session.tool_options()["caret"], 1, "the real pointer moved the caret"); + assert_eq!(app.session.tool_ime_target(view), owner, "ordinary caret moves preserve the immutable owner/session"); + let text = if terminal_pending { "abcd" } else { "abcdé" }; + assert_eq!(plain(&app, id), text, "interrupted preview remains at its original position"); + let document = app.session.active().unwrap().doc.clone(); + let steps = undo(&app); + ime_frame(&mut app, &ctx, vec![surrounding_delete(bytes), Event::Ime(egui::ImeEvent::Commit("X".into()))], |_| {}); + assert_eq!(plain(&app, id), text); + assert_eq!(app.session.tool_options()["caret"], 1); + assert!(std::sync::Arc::ptr_eq(&document, &app.session.active().unwrap().doc), "rejection cannot preview a mutation"); + assert_eq!(undo(&app), steps, "rejection creates no undo entry"); + let marker = reset_marker(&app, &ctx); + ime_frame(&mut app, &ctx, vec![marker, preedit("Q", 1), Event::Ime(egui::ImeEvent::Commit("Q".into()))], |_| {}); + assert_eq!(plain(&app, id), if terminal_pending { "aQbcd" } else { "aQbcdé" }); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(plain(&app, id), text, "only fresh input needs undo"); + } + } +} + +#[test] +fn target_switch_rejects_late_composition_input_before_mutating_the_new_text() { + for bytes in [false, true] { + for terminal_pending in [false, true] { + let (mut app, ctx, first, _) = interruption_editor(); + let second = app.run("text.create", json!({"x": 300, "y": 250, "text": "WXYZ", "size": 24})).unwrap()["id"].as_u64().unwrap(); + // Creating a text object selects it; return to the first through the real viewer. + begin_typing(&mut app, &ctx, first); + ime_frame(&mut app, &ctx, vec![preedit("é", 1)], |_| {}); + if terminal_pending { + ime_frame(&mut app, &ctx, vec![preedit("", 0)], |_| {}); + } + let old_text = plain(&app, first); + let bounds = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(second)).unwrap().geometric_bounds().unwrap(); + let xf = crate::canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + click(&mut app, &ctx, xf.to_screen(bounds.center())); + assert_eq!(app.session.tool_options()["editing"], second, "the pointer edits the replacement target"); + let caret = app.session.tool_options()["caret"].as_u64().unwrap() as usize; + let document = app.session.active().unwrap().doc.clone(); + let steps = undo(&app); + ime_frame(&mut app, &ctx, vec![surrounding_delete(bytes), Event::Ime(egui::ImeEvent::Commit("X".into()))], |_| {}); + assert_eq!(plain(&app, first), old_text); + assert_eq!(plain(&app, second), "WXYZ"); + assert!(std::sync::Arc::ptr_eq(&document, &app.session.active().unwrap().doc)); + assert_eq!(undo(&app), steps); + // The actual fence, rather than the dropped old terminal, admits the next commit. + let marker = reset_marker(&app, &ctx); + ime_frame(&mut app, &ctx, vec![marker, Event::Ime(egui::ImeEvent::Commit("Y".into()))], |_| {}); + let mut expected = "WXYZ".to_string(); + expected.insert(caret, 'Y'); + assert_eq!(plain(&app, second), expected); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(plain(&app, second), "WXYZ"); + } + } +} + +#[test] +fn valid_composition_delete_commit_and_noncomposing_pointer_commit_stay_accepted() { + for bytes in [false, true] { + for terminal_pending in [false, true] { + let (mut app, ctx, id, _) = interruption_editor(); + ime_frame(&mut app, &ctx, vec![preedit("é", 1)], |_| {}); + if terminal_pending { + ime_frame(&mut app, &ctx, vec![preedit("", 0)], |_| {}); + } + ime_frame(&mut app, &ctx, vec![surrounding_delete(bytes), Event::Ime(egui::ImeEvent::Commit("X".into()))], |_| {}); + assert_eq!(plain(&app, id), "abcX", "the IME's own delete does not interrupt its commit"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(plain(&app, id), "abcd", "valid replacement is one undo step"); + } + } + let (mut app, ctx, id, point) = interruption_editor(); + click(&mut app, &ctx, point); + assert_eq!(app.session.tool_options()["caret"], 1); + ime_frame(&mut app, &ctx, vec![Event::Ime(egui::ImeEvent::Commit("Y".into()))], |_| {}); + assert_eq!(plain(&app, id), "aYbcd", "no old composition means no stale guard"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(plain(&app, id), "abcd"); +} + +#[test] +fn a_stale_nonempty_preedit_stays_rejected_until_its_matching_fence() { + let (mut app, ctx, id, point) = interruption_editor(); + ime_frame(&mut app, &ctx, vec![preedit("é", 1)], |_| {}); + click(&mut app, &ctx, point); + let text = plain(&app, id); + let document = app.session.active().unwrap().doc.clone(); + let steps = undo(&app); + ime_frame(&mut app, &ctx, vec![preedit("late", 4)], |_| {}); + for _ in 0..3 { + ime_frame(&mut app, &ctx, vec![], |_| {}); + } + let marker = reset_marker(&app, &ctx); + ime_frame(&mut app, &ctx, vec![Event::Ime(egui::ImeEvent::Commit("X".into())), marker, preedit("Q", 1)], |_| {}); + // Old input did not change committed text; only Q after the explicit fence did. + assert_eq!(plain(&app, id), "aQbcdé"); + assert_eq!(undo(&app), steps); + assert!(!std::sync::Arc::ptr_eq(&document, &app.session.active().unwrap().doc), "the fresh preview is a real model change"); + ime_frame(&mut app, &ctx, vec![Event::Ime(egui::ImeEvent::Commit("Q".into()))], |_| {}); + assert_eq!(plain(&app, id), "aQbcdé"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(plain(&app, id), text); +} + +#[test] +fn an_external_caret_change_keeps_preview_at_its_old_position_and_rejects_old_input() { + let (mut app, ctx, id, _) = interruption_editor(); + ime_frame(&mut app, &ctx, vec![preedit("é", 1)], |_| {}); + app.session.set_tool_option("select", &json!({"start": 1, "end": 1})); + ime_frame(&mut app, &ctx, vec![], |_| {}); + assert_eq!(plain(&app, id), "abcdé", "settling the old preview must not reinsert it at caret1"); + assert_eq!(app.session.tool_options()["caret"], 1); + let document = app.session.active().unwrap().doc.clone(); + let steps = undo(&app); + ime_frame(&mut app, &ctx, vec![surrounding_delete(false), Event::Ime(egui::ImeEvent::Commit("X".into()))], |_| {}); + assert_eq!(plain(&app, id), "abcdé"); + assert_eq!(app.session.tool_options()["caret"], 1); + assert!(std::sync::Arc::ptr_eq(&document, &app.session.active().unwrap().doc)); + assert_eq!(undo(&app), steps); +} + +#[test] +fn external_edit_while_typing_is_other_and_ime_commit_uses_the_document_text() { + let (mut app, id) = app_with_text("Same"); + let ctx = egui::Context::default(); + ime_frame(&mut app, &ctx, vec![], |_| {}); + ime_frame(&mut app, &ctx, vec![], |_| {}); + begin_typing(&mut app, &ctx, id); + ime_frame(&mut app, &ctx, vec![Event::Text("x".into())], |_| {}); + let typed = plain(&app, id); + assert!(typed.contains('x'), "{typed}"); + + // A control/menu/panel edit between passes (here inside the pass, as `tick` drains it): the + // typing session's runs are now stale. The outgoing payload must describe the document, not + // the stale session. + let edited = ime_frame(&mut app, &ctx, vec![], |app| { + app.run("text.editRange", json!({"id": id, "start": 0, "end": typed.len(), "insert": "XYZ"})).unwrap(); + }) + .expect("the Type tool still owns the IME"); + assert_eq!(plain(&app, id), "XYZ"); + assert_eq!(edited.change_cause, egui::output::ImeChangeCause::Other); + let payload = edited.surrounding_text.available().expect("the document text is available"); + assert_eq!(payload.text.as_str(), "XYZ", "the stale session text is never published"); + + // The commit replaces the stale session's text as an application-side reset; its receipt + // cannot continue the observed baseline, so the change is `Other`, never `InputMethod`. + let committed = ime_frame(&mut app, &ctx, vec![Event::Ime(egui::ImeEvent::Commit("y".into()))], |_| {}).expect("still editing"); + assert_eq!(plain(&app, id), "XYZy"); + assert_eq!(committed.change_cause, egui::output::ImeChangeCause::Other); + let payload = committed.surrounding_text.available().expect("the committed text is available"); + assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("XYZy", 4, 4)); +} + +#[test] +fn a_dialog_opened_after_the_canvas_takes_the_ime_away_from_the_type_tool() { + let (mut app, id) = app_with_text("Same"); + let ctx = egui::Context::default(); + ime_frame(&mut app, &ctx, vec![], |_| {}); + ime_frame(&mut app, &ctx, vec![], |_| {}); + begin_typing(&mut app, &ctx, id); + let owner = egui::Id::unique(("vectorcraft-type-ime", app.session.active().unwrap().uid, id)); + let before = ime_frame(&mut app, &ctx, vec![], |_| {}).expect("the Type tool owns the IME"); + assert_eq!(before.owner, owner); + // A queued dialog opens in `dialogs::show`, after the canvas drew: a producer that observed + // during the canvas would still publish the Type tool as the owner, though the modal now + // covers it. + let during = ime_frame(&mut app, &ctx, vec![], |app| { + app.ui.told.push(crate::state::Dialog::new(crate::dialogs::confirm::MESSAGE, json!({"message": "A message", "detail": ""}))); + }); + assert!(app.ui.dialog.is_some(), "the dialog opened after the canvas drew"); + assert!(during.is_none(), "the canvas must not own the IME while a dialog opened after it: {during:?}"); +} + +#[test] +fn home_takes_down_a_live_composition() { + let (mut app, id) = app_with_text("Same"); + let ctx = egui::Context::default(); + ime_frame(&mut app, &ctx, vec![], |_| {}); + ime_frame(&mut app, &ctx, vec![], |_| {}); + begin_typing(&mut app, &ctx, id); + let composing = ime_frame(&mut app, &ctx, vec![preedit("が", 1)], |_| {}).expect("the composition has an owner"); + assert!(composing.surrounding_text.available().is_some()); + assert!(app.ime_marked.is_some()); + app.run("app.home", json!({})).unwrap(); + let home = ime_frame(&mut app, &ctx, vec![], |_| {}); + assert!(home.is_none(), "Home has no IME owner"); + assert!(app.ime_marked.is_none(), "Home takes the marked text down"); + assert!(app.take_ime_discard(), "the system IME is told to drop its marked text"); + assert!(!app.session.tool_composing(), "no stale composition survives Home"); +} + +#[test] +fn closing_the_document_takes_down_a_live_composition() { + let (mut app, id) = app_with_text("Same"); + let ctx = egui::Context::default(); + ime_frame(&mut app, &ctx, vec![], |_| {}); + ime_frame(&mut app, &ctx, vec![], |_| {}); + begin_typing(&mut app, &ctx, id); + ime_frame(&mut app, &ctx, vec![preedit("が", 1)], |_| {}); + assert!(app.ime_marked.is_some()); + assert!(app.session.close_document(0), "the document closes"); + let closed = ime_frame(&mut app, &ctx, vec![], |_| {}); + assert!(closed.is_none(), "no document, no IME owner"); + assert!(app.ime_marked.is_none(), "closing takes the marked text down"); + assert!(app.take_ime_discard(), "the system IME is told to drop its marked text"); +} + +#[test] +fn an_ime_commit_the_document_rewrites_is_other() { + let (mut app, id) = app_with_text("Same"); + let ctx = egui::Context::default(); + ime_frame(&mut app, &ctx, vec![], |_| {}); + ime_frame(&mut app, &ctx, vec![], |_| {}); + begin_typing(&mut app, &ctx, id); + // Typographer's Quotes is on by default: the straight quote the IME accepted is rewritten by + // the document, so the change is application-origin. + let quoted = ime_frame(&mut app, &ctx, vec![Event::Ime(egui::ImeEvent::Commit("\"".into()))], |_| {}).expect("still editing"); + let text = plain(&app, id); + assert!(text.contains('”'), "the document rewrote the quote: {text}"); + assert_eq!(quoted.change_cause, egui::output::ImeChangeCause::Other, "a rewritten commit is not InputMethod"); + + // A filtered control character is another application transformation of the same commit. + let filtered = ime_frame(&mut app, &ctx, vec![Event::Ime(egui::ImeEvent::Commit("a\u{7}b".into()))], |_| {}).expect("still editing"); + let text = plain(&app, id); + assert!(text.contains("ab") && !text.contains('\u{7}'), "the control character is filtered: {text}"); + assert_eq!(filtered.change_cause, egui::output::ImeChangeCause::Other, "filtering is not an IME insertion"); +} + +#[test] +fn an_ime_quote_commit_uses_the_character_before_its_pre_edit() { + let (mut app, id) = app_with_text("a"); + // The pre-edit ends in an opening quote, but replacement starts after the letter `a`. + app.run("document.setup", json!({"quotes": {"double": ["\"", "”"]}})).unwrap(); + let ctx = egui::Context::default(); + ime_frame(&mut app, &ctx, vec![], |_| {}); + ime_frame(&mut app, &ctx, vec![], |_| {}); + begin_typing(&mut app, &ctx, id); + app.session.set_tool_option("select", &json!({"start": 1, "end": 1})); + ime_frame(&mut app, &ctx, vec![], |_| {}); + ime_frame(&mut app, &ctx, vec![preedit("\"", 1)], |_| {}); + let committed = ime_frame(&mut app, &ctx, vec![Event::Ime(egui::ImeEvent::Commit("\"".into()))], |_| {}).expect("still editing"); + assert_eq!(plain(&app, id), "a”", "the document inserts its closing quote"); + assert_eq!(committed.change_cause, egui::output::ImeChangeCause::Other, "a rewritten quote is not an unchanged IME insertion"); +} + +/// An outside command can end the typing session (`endTyping`) while the Type tool still owns +/// the IME. The IME's following `DeleteSurrounding` starts a new typing session; that session +/// must clear the ended marker, so after its commit and a caret move the next ordinary IME +/// commit is an accepted `InputMethod` mutation, not an application edit. +#[test] +fn ime_delete_surrounding_after_end_typing_keeps_the_next_commit_input_method() { + let (mut app, id) = app_with_text("abc"); + let ctx = egui::Context::default(); + ime_frame(&mut app, &ctx, vec![], |_| {}); + ime_frame(&mut app, &ctx, vec![], |_| {}); + begin_typing(&mut app, &ctx, id); + app.session.set_tool_option("select", &json!({"start": 3, "end": 3})); + ime_frame(&mut app, &ctx, vec![], |_| {}); + // A real typing session, settled by an outside command as `settle_interaction` does. + ime_frame(&mut app, &ctx, vec![Event::Text("x".into())], |_| {}).expect("typing owns the IME"); + assert_eq!(plain(&app, id), "abcx"); + ime_frame(&mut app, &ctx, vec![], |app| { + app.session.commit_interaction().expect("the typing preview is committed"); + app.session.set_tool_option("endTyping", &json!(true)); + }); + // The IME deletes the typed character and commits its replacement in the new session. + let deleted = ime_frame( + &mut app, + &ctx, + vec![Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 0 }), Event::Ime(egui::ImeEvent::Commit("Z".into()))], + |_| {}, + ) + .expect("the Type tool still owns the IME"); + assert_eq!(plain(&app, id), "abcZ"); + assert_eq!(deleted.change_cause, egui::output::ImeChangeCause::InputMethod); + // A caret move ends the deletion's session. The following IME commit starts a fresh session; + // because no outside command ended that session, the commit is InputMethod. + let left = egui::Event::Key { key: egui::Key::ArrowLeft, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + ime_frame(&mut app, &ctx, vec![left], |_| {}); + let committed = + ime_frame(&mut app, &ctx, vec![Event::Ime(egui::ImeEvent::Commit("Q".into()))], |_| {}).expect("the Type tool still owns the IME"); + assert_eq!(plain(&app, id), "abcQZ"); + assert_eq!(committed.change_cause, egui::output::ImeChangeCause::InputMethod, "a caret move is not an application-side reset"); +} + +#[test] +fn a_reloaded_document_gets_a_new_ime_replacement_epoch() { + let (mut app, id) = app_with_text("Same"); + let ctx = egui::Context::default(); + ime_frame(&mut app, &ctx, vec![], |_| {}); + ime_frame(&mut app, &ctx, vec![], |_| {}); + begin_typing(&mut app, &ctx, id); + ime_frame(&mut app, &ctx, vec![], |_| {}); + let view = app.view_info(); + let first = app.session.tool_ime_target(view).expect("editing has a target"); + assert_eq!(first.0, app.session.active().unwrap().uid); + assert_eq!(first.1, id); + // File › Revert: the document is replaced in its tab, keeping its uid and node ids, and the + // Type tool is recreated. + let doc = app.session.active().unwrap().doc.clone(); + assert!(app.session.replace_document(0, (*doc).clone())); + ime_frame(&mut app, &ctx, vec![], |_| {}); + let center = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap().center(); + let xf = crate::canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + click(&mut app, &ctx, xf.to_screen(center)); + let second = app.session.tool_ime_target(view).expect("re-entering has a target"); + assert_eq!((second.0, second.1), (first.0, first.1), "the same document uid and text node"); + assert_ne!(second.2, first.2, "the replacement epoch must not be reusable after a reload"); +} + +/// Frame keys compare the whole (context, viewport, frame) set, so root frame 0 → auxiliary frame +/// 0 → root rerun frame 0 does not deliver the root frame twice. +#[test] +fn frame_keys_are_exact_once_across_interleaved_viewports() { + let aux = egui::ViewportId::from_hash_of("vectorcraft-lifecycle-aux"); + let mut seen = vec![]; + assert!(crate::take_frame_once(&mut seen, (1, egui::ViewportId::ROOT, 0))); + assert!(crate::take_frame_once(&mut seen, (1, aux, 0))); + assert!(!crate::take_frame_once(&mut seen, (1, egui::ViewportId::ROOT, 0)), "root's rerun is a duplicate"); + assert!(!crate::take_frame_once(&mut seen, (1, aux, 0)), "the auxiliary pass is a duplicate too"); +} + +/// The production `ui` path (not `shortcuts::handle` directly) takes input once for each viewport +/// in one host iteration: two Tabs from two viewports toggle the panels twice, and the root +/// viewport's discarded rerun toggles them no third time. +#[test] +fn two_viewports_take_their_input_once_through_the_ui_path() { + let mut app = app(); + let ctx = egui::Context::default(); + let aux = egui::ViewportId::from_hash_of("vectorcraft-lifecycle-input-aux"); + let screen = screen(); + let tab = Event::Key { key: egui::Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + let run = |app: &mut VectorcraftApp, viewport: egui::ViewportId, events: Vec, discard: bool| { + let mut raw = egui::RawInput { events, screen_rect: Some(screen), viewport_id: viewport, ..Default::default() }; + raw.viewports.entry(viewport).or_default(); + app.begin_host_iteration(); + let mut first = true; + let mut passes = 0; + let mut out = ctx.run_ui(raw, |ui| { + passes += 1; + app.tick(ui.ctx()); + app.ui(ui); + if discard && std::mem::replace(&mut first, false) { + ui.ctx().request_discard("interleaved viewports"); + } + }); + out.textures_delta.clear(); + passes + }; + // Fonts and a drawn frame for each viewport before the input frame. + for viewport in [aux, egui::ViewportId::ROOT] { + for _ in 0..2 { + run(&mut app, viewport, vec![], false); + } + } + let shown = app.ui.dock; + assert!(shown, "the panels start shown"); + // One host iteration: the auxiliary viewport first, then the root viewport with a discarded + // rerun. Both viewports must take their Tab; the rerun must not take the root's again. + let mut raw = egui::RawInput { events: vec![tab.clone()], screen_rect: Some(screen), viewport_id: aux, ..Default::default() }; + raw.viewports.entry(aux).or_default(); + app.begin_host_iteration(); + let mut out = ctx.run_ui(raw, |ui| { + app.tick(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + let mut raw = egui::RawInput { events: vec![tab], screen_rect: Some(screen), ..Default::default() }; + raw.viewports.entry(egui::ViewportId::ROOT).or_default(); + let mut first = true; + let mut passes = 0; + let mut out = ctx.run_ui(raw, |ui| { + passes += 1; + app.tick(ui.ctx()); + app.ui(ui); + if std::mem::replace(&mut first, false) { + ui.ctx().request_discard("interleaved viewports"); + } + }); + out.textures_delta.clear(); + assert!(passes >= 2, "the root reran after the discard, got {passes} passes"); + assert_eq!(app.ui.dock, shown, "two viewports took their Tab exactly once; the rerun took none"); +} + +/// A minimized window's `run_logic` sees egui's last shown `i.time` (the same value forever here). +/// Background polling must still advance on the monotonic host clock. +#[test] +fn a_hidden_tick_advances_on_monotonic_time_not_the_stale_pass_clock() { + let mut app = app(); + let ctx = egui::Context::default(); + let stale = || egui::RawInput { time: Some(0.0), screen_rect: Some(screen()), ..Default::default() }; + app.begin_host_iteration(); + let _ = ctx.run_logic(&stale(), |ctx| app.tick(ctx)); + let first = app.system_paste_at; + std::thread::sleep(std::time::Duration::from_millis(300)); + app.begin_host_iteration(); + let _ = ctx.run_logic(&stale(), |ctx| app.tick(ctx)); + assert!(app.system_paste_at > first, "the clipboard poll used the replayed i.time: {first} → {}", app.system_paste_at); +} + +/// A new graphics context starts a new IME context generation: the old native IME context must not +/// be treated as the new owner's. +#[test] +fn a_new_graphics_context_invalidates_the_ime_context() { + let (mut app, id) = app_with_text("Same"); + let first_ctx = egui::Context::default(); + ime_frame(&mut app, &first_ctx, vec![], |_| {}); + ime_frame(&mut app, &first_ctx, vec![], |_| {}); + begin_typing(&mut app, &first_ctx, id); + let before = ime_frame(&mut app, &first_ctx, vec![], |_| {}).expect("the Type tool owns the IME"); + let second_ctx = egui::Context::default(); + // The first pass installs the new context's fonts; the second draws and publishes the IME. + let _ = ime_frame(&mut app, &second_ctx, vec![], |_| {}); + let after = ime_frame(&mut app, &second_ctx, vec![], |_| {}).expect("the Type tool still owns the IME"); + assert_ne!(before.context_generation, after.context_generation, "a new context needs a new IME generation"); + assert_eq!(before.owner, after.owner, "the same Type tool owner, in a new context"); +} + +/// An unavailable surrounding-text query (a selection wider than the 4,000-byte limit) still carries +/// a document revision, so an application edit between two unavailable boundaries is detectable. +#[test] +fn an_unavailable_ime_boundary_carries_the_document_revision() { + let (mut app, id) = app_with_text("Same"); + let ctx = egui::Context::default(); + ime_frame(&mut app, &ctx, vec![], |_| {}); + ime_frame(&mut app, &ctx, vec![], |_| {}); + begin_typing(&mut app, &ctx, id); + // Grow the edited text past the 4,000-byte surrounding-text limit while keeping the typing + // session alive, then select all of it. + let long = "x".repeat(5_000); + app.run("text.editRange", json!({"id": id, "start": 0, "end": 4, "insert": long})).unwrap(); + app.run("tool.setOption", json!({"key": "selectAll", "value": true})).unwrap(); + let view = app.view_info(); + let before = crate::ime::stamp(&mut app.session, view).expect("the Type tool is editing"); + assert!(before.surrounding_text().available().is_none(), "the 5,000-byte selection is unavailable"); + let revision = before.unavailable_revision().expect("an unavailable stamp carries a revision"); + app.run("text.editRange", json!({"id": id, "start": 0, "end": 1, "insert": "y"})).unwrap(); + let after = crate::ime::stamp(&mut app.session, view).expect("still editing after the application edit"); + assert_ne!(before, after, "an application edit changes the unavailable boundary"); + assert!(after.unavailable_revision().is_some_and(|after| after > revision), "the document revision advanced"); +} + +fn primary_button(at: egui::Pos2, pressed: bool) -> Event { + Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() } +} + +fn left_key(pressed: bool) -> Event { + Event::Key { key: egui::Key::ArrowLeft, physical_key: Some(egui::Key::ArrowLeft), pressed, repeat: false, modifiers: Default::default() } +} + +/// The press and old terminal arrive in one raw batch, rather than finishing the pointer frame +/// first. The guard must run at the press's position before the queued old mutations execute. +#[test] +fn a_pointer_before_same_batch_terminal_input_preserves_the_interrupted_text() { + for bytes in [false, true] { + for terminal_pending in [false, true] { + let (mut app, ctx, id, point) = interruption_editor(); + ime_frame(&mut app, &ctx, vec![preedit("é", 1)], |_| {}); + if terminal_pending { + ime_frame(&mut app, &ctx, vec![preedit("", 0)], |_| {}); + } + let expected = if terminal_pending { "abcd" } else { "abcdé" }; + let document = app.session.active().unwrap().doc.clone(); + let steps = undo(&app); + let view = app.view_info(); + let owner = app.session.tool_ime_target(view); + let interrupted = ime_frame( + &mut app, + &ctx, + vec![ + Event::PointerMoved(point), + primary_button(point, true), + surrounding_delete(bytes), + Event::Ime(egui::ImeEvent::Commit("X".into())), + ], + |_| {}, + ) + .expect("the pointer destination still edits text"); + assert_eq!(plain(&app, id), expected, "old same-batch input cannot delete or insert at either caret"); + assert_eq!(app.session.tool_options()["caret"], 1, "the retained real pointer still moves the caret"); + assert_eq!(app.session.tool_ime_target(view), owner); + assert!(std::sync::Arc::ptr_eq(&document, &app.session.active().unwrap().doc), "reject before previewing an old mutation"); + assert_eq!(undo(&app), steps + usize::from(!terminal_pending), "only settling an existing live preview commits a step"); + let fence = interrupted.reset_request.expect("the retained pointer interruption publishes its replacement fence"); + assert_eq!(crate::ime::pending_request(&app, &ctx), Some(fence)); + ime_frame(&mut app, &ctx, vec![primary_button(point, false)], |_| {}); + // The guard must still fence fresh-looking input until the native reset completion. + ime_frame(&mut app, &ctx, vec![preedit("Q", 1), Event::Ime(egui::ImeEvent::Commit("Q".into()))], |_| {}); + assert_eq!(plain(&app, id), expected, "input before the reset ack stays stale"); + ime_frame( + &mut app, + &ctx, + vec![Event::ImeResetCompleted { request_id: fence }, preedit("Q", 1), Event::Ime(egui::ImeEvent::Commit("Q".into()))], + |_| {}, + ); + assert_eq!(plain(&app, id), if terminal_pending { "aQbcd" } else { "aQbcdé" }); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(plain(&app, id), expected, "fresh composition after rejection remains one undoable edit"); + } + } +} + +#[test] +fn application_navigation_before_same_batch_pending_terminal_rejects_its_mutations() { + for bytes in [false, true] { + let (mut app, ctx, id, _) = interruption_editor(); + ime_frame(&mut app, &ctx, vec![preedit("é", 1)], |_| {}); + ime_frame(&mut app, &ctx, vec![preedit("", 0)], |_| {}); + let document = app.session.active().unwrap().doc.clone(); + let steps = undo(&app); + ime_frame(&mut app, &ctx, vec![left_key(true), surrounding_delete(bytes), Event::Ime(egui::ImeEvent::Commit("X".into()))], |_| {}); + assert_eq!(plain(&app, id), "abcd"); + assert_eq!(app.session.tool_options()["caret"], 3, "the application navigation is not consumed by the marker"); + assert!(std::sync::Arc::ptr_eq(&document, &app.session.active().unwrap().doc)); + assert_eq!(undo(&app), steps, "rejected input cannot create an Undo edit"); + let marker = reset_marker(&app, &ctx); + ime_frame(&mut app, &ctx, vec![left_key(false), marker, Event::Ime(egui::ImeEvent::Commit("Y".into()))], |_| {}); + assert_eq!(plain(&app, id), "abcYd", "a later direct commit stays valid"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(plain(&app, id), "abcd"); + } +} + +/// A later press must not retroactively interrupt an earlier valid terminal. The initial move +/// also precedes the terminal while the final batch state is held: only the actual press starts +/// the pointer interruption, not InputState's final held flag. +#[test] +fn terminal_before_a_same_batch_pointer_is_accepted_in_both_composition_states() { + for bytes in [false, true] { + for terminal_pending in [false, true] { + let (mut app, ctx, id, point) = interruption_editor(); + ime_frame(&mut app, &ctx, vec![preedit("é", 1)], |_| {}); + if terminal_pending { + ime_frame(&mut app, &ctx, vec![preedit("", 0)], |_| {}); + } + ime_frame( + &mut app, + &ctx, + vec![ + Event::PointerMoved(point), + surrounding_delete(bytes), + Event::Ime(egui::ImeEvent::Commit("X".into())), + primary_button(point, true), + ], + |_| {}, + ); + assert_eq!(plain(&app, id), "abcX", "a later press cannot cancel an earlier terminal"); + assert_eq!(app.session.tool_options()["caret"], 1); + ime_frame(&mut app, &ctx, vec![primary_button(point, false)], |_| {}); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(plain(&app, id), "abcd", "the accepted replacement remains one Undo edit"); + } + } +} + +#[test] +fn terminal_before_navigation_is_accepted_and_active_ime_navigation_stays_owned() { + for bytes in [false, true] { + let (mut app, ctx, id, _) = interruption_editor(); + ime_frame(&mut app, &ctx, vec![preedit("é", 1)], |_| {}); + ime_frame(&mut app, &ctx, vec![preedit("", 0)], |_| {}); + ime_frame(&mut app, &ctx, vec![surrounding_delete(bytes), Event::Ime(egui::ImeEvent::Commit("X".into())), left_key(true)], |_| {}); + assert_eq!(plain(&app, id), "abcX"); + assert_eq!(app.session.tool_options()["caret"], 3, "a later application navigation still executes"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(plain(&app, id), "abcd"); + + let (mut app, ctx, id, _) = interruption_editor(); + ime_frame(&mut app, &ctx, vec![preedit("é", 1)], |_| {}); + ime_frame(&mut app, &ctx, vec![left_key(true), surrounding_delete(bytes), Event::Ime(egui::ImeEvent::Commit("X".into()))], |_| {}); + assert_eq!(plain(&app, id), "abcX", "an active IME owns its arrow; it does not interrupt the valid terminal"); + assert_eq!(app.session.tool_options()["caret"], 4); + } +} + +#[test] +fn a_pointer_marker_without_an_old_composition_does_not_reject_a_direct_commit() { + let (mut app, ctx, id, point) = interruption_editor(); + ime_frame(&mut app, &ctx, vec![Event::PointerMoved(point), primary_button(point, true), Event::Ime(egui::ImeEvent::Commit("Y".into()))], |_| {}); + // Keep the existing dispatch contract: terminal text executes before canvas pointer placement. + assert_eq!(plain(&app, id), "abcdY", "no observed old composition means no stale rejection"); + assert_eq!(app.session.tool_options()["caret"], 1); + ime_frame(&mut app, &ctx, vec![primary_button(point, false)], |_| {}); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(plain(&app, id), "abcd"); +} + +#[test] +fn a_held_pointer_move_before_same_batch_terminal_also_interrupts_the_old_composition() { + for bytes in [false, true] { + let (mut app, ctx, id, point) = interruption_editor(); + let start = ime_frame(&mut app, &ctx, vec![], |_| {}).expect("caret4 is drawn").cursor_rect.center(); + ime_frame(&mut app, &ctx, vec![Event::PointerMoved(start), primary_button(start, true)], |_| {}); + ime_frame(&mut app, &ctx, vec![preedit("é", 1)], |_| {}); + let document = app.session.active().unwrap().doc.clone(); + let steps = undo(&app); + ime_frame( + &mut app, + &ctx, + vec![Event::PointerMoved(point), surrounding_delete(bytes), Event::Ime(egui::ImeEvent::Commit("X".into()))], + |_| {}, + ); + assert_eq!(plain(&app, id), "abcdé", "a move in an already-held gesture precedes the old terminal"); + assert_eq!(app.session.tool_options()["caret"], 1, "the retained real move reaches the tool"); + assert!(std::sync::Arc::ptr_eq(&document, &app.session.active().unwrap().doc)); + assert_eq!(undo(&app), steps + 1, "only the interrupted live preview commits"); + ime_frame(&mut app, &ctx, vec![primary_button(point, false)], |_| {}); + } +} diff --git a/crates/ui-egui/src/tests_nativemenu.rs b/crates/ui-egui/src/tests_nativemenu.rs index 6f0bb7f48..77a83291d 100644 --- a/crates/ui-egui/src/tests_nativemenu.rs +++ b/crates/ui-egui/src/tests_nativemenu.rs @@ -312,7 +312,7 @@ impl Mac { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 850.0))), ..Default::default() }; - self.app.raw_input_hook(&mut raw); + self.app.raw_input_hook(&self.ctx, &mut raw); let app = &mut self.app; let mut out = self.ctx.run_ui(raw, |ui| { app.logic(ui.ctx()); @@ -664,7 +664,7 @@ fn the_menu_bar_is_inert_while_a_file_dialog_is_open() { k.modifiers.mac_cmd = k.modifiers.command; m.fake.report(native_menu::Event::Key(k)); let mut raw = egui::RawInput::default(); - m.app.raw_input_hook(&mut raw); + m.app.raw_input_hook(&m.ctx, &mut raw); assert!(!raw.events.iter().any(|e| matches!(e, Event::Key { .. })), "{:?}", raw.events); m.key_equivalent("Cmd+A"); assert_eq!(m.selected(), 0, "⌘A selected nothing"); diff --git a/crates/ui-egui/src/tests_numfields.rs b/crates/ui-egui/src/tests_numfields.rs index 81409786b..bad1fbebf 100644 --- a/crates/ui-egui/src/tests_numfields.rs +++ b/crates/ui-egui/src/tests_numfields.rs @@ -59,7 +59,7 @@ fn a_hex_field_passed_through_with_tab_commits_nothing() { let draw = |ui: &mut egui::Ui| { let [r, g, b] = rgb.get(); let red = widgets::plain_field(ui, "red", r, "", 0, 80.0); - hex_id.set(ui.id().with("hex")); + hex_id.set(ui.scope_id().with("hex")); hex.set(widgets::hex_field(ui, "hex", &format!("{:02X}{:02X}{:02X}", r as u8, g as u8, b as u8))); blue.set(widgets::plain_field(ui, "blue", b, "", 0, 80.0)); red @@ -247,7 +247,7 @@ impl Field { let input = egui::RawInput { events, time: Some(self.time), screen_rect, focused: self.focused, ..Default::default() }; let mut got = None; let mut out = self.ctx.run_ui(input, |ui| { - self.id.set(ui.id().with("f")); + self.id.set(ui.scope_id().with("f")); got = draw(ui); self.rect.set(ui.min_rect()); }); @@ -406,7 +406,7 @@ fn shift_enter_applies_the_value_and_keeps_the_field_active() { let color = RefCell::new(String::from("FF8000")); let draw = |ui: &mut egui::Ui| { text.set(widgets::text_field(ui, "f", Some("Name"), 120.0, 1).or(text.take())); - hex_id.set(ui.id().with("hex")); + hex_id.set(ui.scope_id().with("hex")); let shown = color.borrow().clone(); let applied = widgets::hex_field(ui, "hex", &shown); if let Some(h) = &applied { @@ -608,7 +608,10 @@ fn arrow_keys_step_plain_fields() { /// A wheel turn of `dy` (`unit`s, up positive) with `modifiers`, the pointer at `at`. fn wheel(at: Pos2, unit: egui::MouseWheelUnit, dy: f32, modifiers: Modifiers) -> Vec { - vec![Event::PointerMoved(at), Event::MouseWheel { unit, delta: vec2(0.0, dy), phase: egui::TouchPhase::Move, modifiers }] + vec![ + Event::PointerMoved(at), + Event::MouseWheel { unit, delta: vec2(0.0, dy), phase: egui::TouchPhase::Move, modifiers, source: egui::MouseWheelSource::Unknown }, + ] } /// The mouse wheel over a focused numeric field steps it as ↑/↓ do (#485): a notch a step, Shift @@ -661,7 +664,7 @@ fn the_wheel_scrolls_the_panel_unless_over_the_focused_field() { ui.add_space(600.0); v }); - offset.set(out.state.offset.y); + offset.set(out.state.clamped_offset().y); out.inner }; f.frame(vec![], &draw); diff --git a/crates/ui-egui/src/tests_paintchips.rs b/crates/ui-egui/src/tests_paintchips.rs index ca8e8f259..a236b3f41 100644 --- a/crates/ui-egui/src/tests_paintchips.rs +++ b/crates/ui-egui/src/tests_paintchips.rs @@ -105,7 +105,7 @@ fn a_popover_tile_click_sets_the_fill() { let (fill, stroke) = chips(&ctx); click(&mut app, &ctx, fill.center(), Modifiers::NONE, control_bar); assert!(app.session.fill_active, "the Fill chip brings its proxy forward"); - let tile = ctx.read_response(egui::Id::new(("swatch-pop-tile", "Red"))).expect("the popover shows the swatches").rect; + let tile = ctx.read_response(egui::Id::unique(("swatch-pop-tile", "Red"))).expect("the popover shows the swatches").rect; click(&mut app, &ctx, tile.center(), Modifiers::NONE, control_bar); assert_eq!(fill_hex(&app), "#ed1c24"); assert_eq!(app.session.doc().unwrap().history.undo.last().unwrap().label, "Fill Color", "through paint.setFill"); @@ -113,7 +113,7 @@ fn a_popover_tile_click_sets_the_fill() { click(&mut app, &ctx, Pos2::new(1300.0, 900.0), Modifiers::NONE, control_bar); click(&mut app, &ctx, stroke.center(), Modifiers::SHIFT, control_bar); assert!(!app.session.fill_active); - assert!(ctx.read_response(egui::Id::new(("swatch-pop-tile", "Red"))).is_none(), "no swatches in the mixer"); + assert!(ctx.read_response(egui::Id::unique(("swatch-pop-tile", "Red"))).is_none(), "no swatches in the mixer"); let text = { let mut t = String::new(); for s in frame(&mut app, &ctx, vec![], Modifiers::NONE, control_bar) { @@ -142,7 +142,7 @@ fn the_properties_panel_has_the_control_bars_fill_and_stroke() { let (fill, stroke) = chips(&ctx); assert!(stroke.top() > fill.bottom(), "Fill above Stroke"); click(&mut app, &ctx, fill.center(), Modifiers::NONE, props); - let tile = ctx.read_response(egui::Id::new(("swatch-pop-tile", "Red"))).expect("the popover shows the swatches").rect; + let tile = ctx.read_response(egui::Id::unique(("swatch-pop-tile", "Red"))).expect("the popover shows the swatches").rect; click(&mut app, &ctx, tile.center(), Modifiers::NONE, props); assert_eq!(fill_hex(&app), "#ed1c24", "the next object's fill"); app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); @@ -152,7 +152,7 @@ fn the_properties_panel_has_the_control_bars_fill_and_stroke() { frame(&mut app, &ctx, vec![], Modifiers::NONE, props); let (fill, _) = chips(&ctx); click(&mut app, &ctx, fill.center(), Modifiers::NONE, props); - let tile = ctx.read_response(egui::Id::new(("swatch-pop-tile", "Black"))).expect("the popover shows the swatches").rect; + let tile = ctx.read_response(egui::Id::unique(("swatch-pop-tile", "Black"))).expect("the popover shows the swatches").rect; click(&mut app, &ctx, tile.center(), Modifiers::NONE, props); assert_eq!(fill_hex(&app), "#000000"); assert_eq!(app.session.doc().unwrap().history.undo.last().unwrap().label, "Fill Color"); diff --git a/crates/ui-egui/src/tests_place.rs b/crates/ui-egui/src/tests_place.rs index 2ecb1c812..7c887e1fe 100644 --- a/crates/ui-egui/src/tests_place.rs +++ b/crates/ui-egui/src/tests_place.rs @@ -744,6 +744,9 @@ fn crop_and_rasterize_say_in_the_status_bar_when_an_image_could_not_be_decoded() /// One headless frame of the whole window at `time` → how long the app asked to wait before the /// next one. fn frame_at(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64) -> std::time::Duration { + // The app's background work (the link-watch look) runs on host time; drive it with this test's + // simulated seconds rather than the real monotonic clock, as the window loop would. + app.now_override = Some(time); let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), time: Some(time), ..Default::default() }; let mut out = ctx.run_ui(raw, |ui| { app.logic(ui.ctx()); diff --git a/crates/ui-egui/src/tests_recolor.rs b/crates/ui-egui/src/tests_recolor.rs index d6e3b63e7..8d38d8d2f 100644 --- a/crates/ui-egui/src/tests_recolor.rs +++ b/crates/ui-egui/src/tests_recolor.rs @@ -130,7 +130,7 @@ fn double_clicking_a_colour_group_opens_it_and_ok_rewrites_it() { let ctx = context(); let show: fn(&mut VectorcraftApp, &mut egui::Ui) = panels::swatches::show; frame(&mut app, &ctx, vec![], 0.0, show); - let folder = ctx.read_response(egui::Id::new(("swatch-tile", "Brights"))).expect("the Brights folder").rect.center(); + let folder = ctx.read_response(egui::Id::unique(("swatch-tile", "Brights"))).expect("the Brights folder").rect.center(); frame(&mut app, &ctx, press(folder, 2), 1.0, show); let d = app.ui.dialog.as_ref().expect("a dialog opened"); assert_eq!((d.kind.as_str(), d.str("group")), (dialogs::recolor::KIND, "Brights".to_string())); @@ -158,7 +158,7 @@ fn the_options_button_edits_a_selected_group_and_applies_it_to_the_art() { art(&mut app, &["#ff0000", "#00ff00", "#0000ff", "#ffff00", "#ff00ff"]); let show: fn(&mut VectorcraftApp, &mut egui::Ui) = panels::swatches::show; frame(&mut app, &ctx, vec![], 0.0, show); - let folder = ctx.read_response(egui::Id::new(("swatch-tile", "Brights"))).unwrap().rect.center(); + let folder = ctx.read_response(egui::Id::unique(("swatch-tile", "Brights"))).unwrap().rect.center(); frame(&mut app, &ctx, press(folder, 1), 1.0, show); frame(&mut app, &ctx, vec![], 2.0, show); // The bottom bar's third button: Swatch Options, or Edit or Apply Color Group for a group. @@ -226,6 +226,18 @@ fn edit_harness(colors: &[&str]) -> egui_kittest::Harness<'static, VectorcraftAp h } +/// The Assign tab's per-row hex fields are named by their row, not all "Color": a screen reader can +/// tell the second from the first. +#[test] +fn each_recolor_hex_field_has_its_row_number() { + let mut h = edit_harness(&["#ff0000", "#0000ff"]); + h.get_by_role_and_label(egui::accesskit::Role::Button, "Assign").click(); + h.run_steps(4); + let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Color 1"); + let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Color 2"); + assert!(h.query_by_role_and_label(egui::accesskit::Role::TextInput, "Color").is_none(), "no ambiguous Color field"); +} + /// The rect of the widget drawn `size` big (the wheel is 220 across, its Brightness slider 220 by 22). fn widget_of_size(h: &egui_kittest::Harness<'_, VectorcraftApp>, size: egui::Vec2) -> Rect { h.ctx @@ -382,7 +394,7 @@ fn many_edit_colors_keep_the_dialog_on_screen() { } let screen = h.ctx.content_rect(); assert_eq!(screen.size(), physical / zoom, "the requested effective screen size"); - let dialog = h.ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", "recolor")))).unwrap(); + let dialog = h.ctx.memory(|m| m.area_rect(egui::Id::unique(("dialog", "recolor")))).unwrap(); assert!(screen.contains_rect(dialog), "{count} colors, {physical:?} at {zoom}: dialog {dialog:?} in {screen:?}"); let wheel = widget_of_size(&h, vec2(220.0, 220.0)); assert!(screen.contains_rect(wheel), "the wheel is accessible"); @@ -433,6 +445,7 @@ fn the_last_edit_color_can_be_selected_edited_and_undone() { delta: vec2(0.0, -10000.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }, ]); h.run_steps(12); diff --git a/crates/ui-egui/src/tests_rotateview.rs b/crates/ui-egui/src/tests_rotateview.rs index b9685d127..541605a31 100644 --- a/crates/ui-egui/src/tests_rotateview.rs +++ b/crates/ui-egui/src/tests_rotateview.rs @@ -337,10 +337,10 @@ fn escape_after_ending_isolation_or_with_the_rotate_view_tool_resets_the_view() fn escape_after_closing_a_popup_or_during_a_ruler_origin_drag_keeps_the_rotation() { let mut w = Window::new(); // The menu's button has the id `ComboBox::from_id_salt("rot-combo")` gives it in the status bar's - // row, which hosts other widgets too. - let salt = egui::IdSalt::new("rot-combo"); + // row, which hosts other widgets too. egui 0.37 salts the Ui's stable `scope_id`, not the + // widget rect's unstable `parent_id`; in debug builds the id's debug source names its salt. let widgets: Vec = w.ctx.viewport(|vp| vp.prev_pass.widgets.layers().flat_map(|(_, ws)| ws.iter().copied()).collect()); - let combo = widgets.iter().find(|r| widgets.iter().any(|row| r.id == row.parent_id.with(salt))).copied(); + let combo = widgets.iter().find(|r| format!("{:?}", r.id).contains("rot-combo")).copied(); let combo = combo.expect("the status bar's angle menu"); w.click(0.0, egui::PointerButton::Primary, combo.rect.center()); w.after(0.0, vec![]); @@ -372,7 +372,7 @@ fn escape_after_closing_a_popup_or_during_a_ruler_origin_drag_keeps_the_rotation let mut w = Window::new(); w.app.ui.view.rulers = true; w.after(0.0, vec![]); - let corner = w.ctx.read_response(egui::Id::new("ruler-corner")).expect("the rulers' corner").rect.center(); + let corner = w.ctx.read_response(egui::Id::unique("ruler-corner")).expect("the rulers' corner").rect.center(); let to = w.app.canvas_rect.unwrap().center(); w.after(0.0, vec![egui::Event::PointerMoved(corner)]); let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; diff --git a/crates/ui-egui/src/tests_screenshots.rs b/crates/ui-egui/src/tests_screenshots.rs new file mode 100644 index 000000000..b6ac2a2ee --- /dev/null +++ b/crates/ui-egui/src/tests_screenshots.rs @@ -0,0 +1,370 @@ +//! `ui.screenshot` after egui 0.37 replaced `Event::Screenshot`/`UserData` with a one-shot +//! `ScreenshotCallback`: exactly one file and reply per request, a late completion writes +//! nothing, admission is bounded before issue, a changed document or window geometry never saves +//! stale pixels, and a hidden window asks for the paint that reaches issuance. + +use std::sync::Arc; + +use egui::{Event, Pos2, Rect, ViewportCommand, ViewportId, ViewportInfo, vec2}; +use serde_json::json; +use vectorcraft_engine::Session; + +use crate::control::ControlRequest; +use crate::{Services, VectorcraftApp}; + +const SCREEN: egui::Vec2 = vec2(320.0, 200.0); + +fn app(tag: &str) -> (VectorcraftApp, std::sync::mpsc::Sender, std::path::PathBuf) { + let dir = vectorcraft_testkit::temp_dir(tag); + let services = + Services { write: Some(Box::new(|path: &str, bytes: &[u8]| std::fs::write(path, bytes).map_err(|e| e.to_string()))), ..Default::default() }; + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = VectorcraftApp::new(Session::new(), services).with_control(rx); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + (app, tx, dir) +} + +fn request(tx: &std::sync::mpsc::Sender, params: serde_json::Value) -> std::sync::mpsc::Receiver { + let (req, reply) = ControlRequest::new("ui.screenshot", params); + tx.send(req).unwrap(); + reply +} + +fn frame_at(app: &mut VectorcraftApp, ctx: &egui::Context, size: egui::Vec2, events: Vec) -> egui::FullOutput { + let raw = egui::RawInput { events, screen_rect: Some(Rect::from_min_size(Pos2::ZERO, size)), ..Default::default() }; + app.begin_host_iteration(); + let mut out = ctx.run_ui(raw, |ui| { + app.tick(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + out +} + +fn frame(app: &mut VectorcraftApp, ctx: &egui::Context) -> egui::FullOutput { + frame_at(app, ctx, SCREEN, vec![]) +} + +/// The callback of the screenshot command this frame issued, if any. +fn issued(out: &egui::FullOutput) -> Option { + let viewport = out.viewport_output.get(&ViewportId::ROOT)?; + viewport.commands.iter().rev().find_map(|command| match command { + ViewportCommand::Screenshot(callback) => Some(callback.clone()), + _ => None, + }) +} + +/// Make an admitted capture due now: the settle policy measures wall-clock animation time, which +/// a test must not race. The frame count in `issue_screenshots` still proves the settle passes. +fn allow_due(app: &mut VectorcraftApp) { + for queued in &mut app.queued_screenshots { + queued.due_ms = f64::MIN; + } +} + +/// Run full frames until the capture is issued (bounded: a failure is an error, not a hang). +fn drive_until_issued(app: &mut VectorcraftApp, ctx: &egui::Context) -> egui::ScreenshotCallback { + for _ in 0..64 { + let out = frame(app, ctx); + if let Some(callback) = issued(&out) { + return callback; + } + } + panic!("the settled frame issues the capture"); +} + +/// Two full frames install and settle the interface fonts before the first request. +fn ready(app: &mut VectorcraftApp, ctx: &egui::Context) { + frame(app, ctx); + frame(app, ctx); +} + +/// Drain an admitted request and make it due: a frame takes it from the control channel. +fn admit(app: &mut VectorcraftApp, ctx: &egui::Context) { + frame(app, ctx); + allow_due(app); +} + +#[test] +fn a_request_saves_one_file_and_replies_once() { + let (mut app, tx, dir) = app("screenshot-one"); + let ctx = egui::Context::default(); + ready(&mut app, &ctx); + let path = dir.join("shot.png"); + let reply = request(&tx, json!({"path": path.to_string_lossy(), "data": true})); + assert!(reply.try_recv().is_err(), "no reply before a frame is presented"); + admit(&mut app, &ctx); + let callback = drive_until_issued(&mut app, &ctx); + assert!(!path.exists(), "the callback writes nothing itself"); + callback.complete(Arc::new(egui::ColorImage::filled([320, 200], egui::Color32::BLACK))); + app.begin_host_iteration(); + app.tick(&ctx); + let answer = reply.try_recv().expect("one reply"); + assert_eq!(answer["ok"], true, "{answer}"); + assert_eq!((answer["result"]["width"].as_u64(), answer["result"]["height"].as_u64()), (Some(320), Some(200))); + assert!(path.is_file()); + assert!(reply.try_recv().is_err(), "exactly one reply"); + assert_eq!(app.screenshot_reserved, 0, "the reservation is released"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn a_timed_out_request_writes_nothing_and_a_late_completion_is_inert() { + let (mut app, tx, dir) = app("screenshot-timeout"); + let ctx = egui::Context::default(); + ready(&mut app, &ctx); + let path = dir.join("late.png"); + let reply = request(&tx, json!({"path": path.to_string_lossy()})); + admit(&mut app, &ctx); + let callback = drive_until_issued(&mut app, &ctx); + // The frame never came: the deadline passes and the request answers once. + for pending in &mut app.pending_screenshots { + pending.deadline_ms = -1.0; + } + app.begin_host_iteration(); + app.tick(&ctx); + let answer = reply.try_recv().expect("the timeout reply"); + assert_eq!(answer["ok"], false, "{answer}"); + callback.complete(Arc::new(egui::ColorImage::filled([320, 200], egui::Color32::WHITE))); + app.begin_host_iteration(); + app.tick(&ctx); + assert!(!path.exists(), "a late completion writes nothing"); + assert!(reply.try_recv().is_err(), "no second reply"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn a_completion_that_arrives_after_the_deadline_is_not_saved() { + let (mut app, tx, dir) = app("screenshot-deadline"); + let ctx = egui::Context::default(); + ready(&mut app, &ctx); + let path = dir.join("too-late.png"); + let reply = request(&tx, json!({"path": path.to_string_lossy()})); + admit(&mut app, &ctx); + let callback = drive_until_issued(&mut app, &ctx); + // The callback stored the image, but the UI thread collects it after the deadline. + for pending in &mut app.pending_screenshots { + pending.deadline_ms = -1.0; + } + callback.complete(Arc::new(egui::ColorImage::filled([320, 200], egui::Color32::WHITE))); + app.begin_host_iteration(); + app.tick(&ctx); + let answer = reply.try_recv().expect("the timeout reply"); + assert_eq!(answer["ok"], false, "{answer}"); + assert!(answer["error"].as_str().unwrap_or_default().contains("no frame was presented"), "{answer}"); + assert!(!path.exists(), "an image collected after the deadline is not encoded or written"); + assert!(reply.try_recv().is_err(), "no second reply"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn admission_rejects_more_than_four_requests_without_evicting_one_and_releases_a_finished_one() { + let (mut app, tx, dir) = app("screenshot-admission"); + let ctx = egui::Context::default(); + ready(&mut app, &ctx); + let mut replies = vec![]; + for i in 0..5 { + replies.push(request(&tx, json!({"path": dir.join(format!("{i}.png")).to_string_lossy()}))); + } + frame(&mut app, &ctx); + let last = replies.pop().unwrap().try_recv().expect("the fifth is refused at once"); + assert_eq!(last["ok"], false, "{last}"); + assert!(last["error"].as_str().unwrap_or_default().contains("already 4"), "{last}"); + for reply in &replies { + assert!(reply.try_recv().is_err(), "the four admitted requests stay pending"); + } + assert_eq!(app.pending_screenshots.len(), 4); + // Complete the first: its reservation must come back, so a new request is admitted. + allow_due(&mut app); + let callback = drive_until_issued(&mut app, &ctx); + callback.complete(Arc::new(egui::ColorImage::filled([320, 200], egui::Color32::BLACK))); + app.begin_host_iteration(); + app.tick(&ctx); + assert_eq!(replies[0].try_recv().expect("the completed reply")["ok"], true); + let more = request(&tx, json!({"path": dir.join("more.png").to_string_lossy()})); + frame(&mut app, &ctx); + assert!(more.try_recv().is_err(), "a released reservation admits the next request"); + assert_eq!(app.pending_screenshots.len(), 4); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn an_image_of_another_size_is_stale_and_retried() { + let (mut app, tx, dir) = app("screenshot-size"); + let ctx = egui::Context::default(); + ready(&mut app, &ctx); + let path = dir.join("retry.png"); + let reply = request(&tx, json!({"path": path.to_string_lossy()})); + admit(&mut app, &ctx); + // An image of another size means the window changed under the capture: it is not saved. + drive_until_issued(&mut app, &ctx).complete(Arc::new(egui::ColorImage::filled([300, 200], egui::Color32::BLACK))); + app.begin_host_iteration(); + app.tick(&ctx); + assert!(reply.try_recv().is_err(), "no reply for the stale image"); + assert!(!path.exists()); + // The retry issues a fresh slot after the settle frames, and the matching image completes it. + let second = drive_until_issued(&mut app, &ctx); + second.complete(Arc::new(egui::ColorImage::filled([320, 200], egui::Color32::BLACK))); + app.begin_host_iteration(); + app.tick(&ctx); + let answer = reply.try_recv().expect("the retry's reply"); + assert_eq!(answer["ok"], true, "{answer}"); + assert!(path.is_file()); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn a_geometry_change_and_change_back_still_rejects_the_old_pixels() { + let (mut app, tx, dir) = app("screenshot-epoch"); + let ctx = egui::Context::default(); + ready(&mut app, &ctx); + let path = dir.join("epoch.png"); + let reply = request(&tx, json!({"path": path.to_string_lossy()})); + admit(&mut app, &ctx); + let callback = drive_until_issued(&mut app, &ctx); + // DPI changes and changes back before the callback arrives: the epoch, not value equality, + // decides that the pixels are stale. + ctx.set_pixels_per_point(2.0); + frame(&mut app, &ctx); + ctx.set_pixels_per_point(1.0); + frame(&mut app, &ctx); + callback.complete(Arc::new(egui::ColorImage::filled([320, 200], egui::Color32::BLACK))); + app.begin_host_iteration(); + app.tick(&ctx); + assert!(reply.try_recv().is_err(), "no reply for pixels taken before the change back"); + assert!(!path.exists()); + // The retry issues for the current geometry and completes once. + drive_until_issued(&mut app, &ctx).complete(Arc::new(egui::ColorImage::filled([320, 200], egui::Color32::BLACK))); + app.begin_host_iteration(); + app.tick(&ctx); + let answer = reply.try_recv().expect("the retry's reply"); + assert_eq!(answer["ok"], true, "{answer}"); + assert!(path.is_file()); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn a_document_change_while_a_capture_is_in_flight_fails_instead_of_saving_it() { + let (mut app, tx, dir) = app("screenshot-document"); + let ctx = egui::Context::default(); + ready(&mut app, &ctx); + let path = dir.join("other-document.png"); + let reply = request(&tx, json!({"path": path.to_string_lossy()})); + admit(&mut app, &ctx); + let callback = drive_until_issued(&mut app, &ctx); + // The user switches documents during the 240 ms settle/in-flight window: pixels of the new + // document must not be answered for the old request. + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + callback.complete(Arc::new(egui::ColorImage::filled([320, 200], egui::Color32::BLACK))); + app.begin_host_iteration(); + app.tick(&ctx); + let answer = reply.try_recv().expect("the identity reply"); + assert_eq!(answer["ok"], false, "{answer}"); + assert!(answer["error"].as_str().unwrap_or_default().contains("document changed"), "{answer}"); + assert!(!path.exists()); + assert!(reply.try_recv().is_err(), "no second reply"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn a_document_change_during_the_settle_fails_at_issuance_without_issuing_a_capture() { + let (mut app, tx, dir) = app("screenshot-document-settle"); + let ctx = egui::Context::default(); + ready(&mut app, &ctx); + let path = dir.join("settled-away.png"); + let reply = request(&tx, json!({"path": path.to_string_lossy()})); + admit(&mut app, &ctx); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + let mut answer = None; + for _ in 0..8 { + let out = frame(&mut app, &ctx); + assert!(issued(&out).is_none(), "a changed document is never captured"); + if let Ok(value) = reply.try_recv() { + answer = Some(value); + break; + } + } + let answer = answer.expect("the identity failure comes at issuance"); + assert_eq!(answer["ok"], false, "{answer}"); + assert!(answer["error"].as_str().unwrap_or_default().contains("document changed"), "{answer}"); + assert!(!path.exists()); + assert!(app.pending_screenshots.is_empty(), "the admitted reservation is released"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn a_second_stale_completion_replies_once_with_an_error_and_writes_nothing() { + let (mut app, tx, dir) = app("screenshot-stale-twice"); + let ctx = egui::Context::default(); + ready(&mut app, &ctx); + let path = dir.join("stale.png"); + let reply = request(&tx, json!({"path": path.to_string_lossy()})); + admit(&mut app, &ctx); + drive_until_issued(&mut app, &ctx).complete(Arc::new(egui::ColorImage::filled([300, 200], egui::Color32::BLACK))); + app.begin_host_iteration(); + app.tick(&ctx); + assert!(reply.try_recv().is_err(), "the first stale image is retried, not answered"); + drive_until_issued(&mut app, &ctx).complete(Arc::new(egui::ColorImage::filled([300, 200], egui::Color32::BLACK))); + app.begin_host_iteration(); + app.tick(&ctx); + let answer = reply.try_recv().expect("the second stale image fails the request"); + assert_eq!(answer["ok"], false, "{answer}"); + assert!(answer["error"].as_str().unwrap_or_default().contains("changed while the screenshot was in flight"), "{answer}"); + assert!(!path.exists()); + assert!(reply.try_recv().is_err(), "exactly one error reply"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn a_hidden_window_is_asked_to_paint_the_queued_capture() { + let (mut app, tx, dir) = app("screenshot-hidden"); + let ctx = egui::Context::default(); + ready(&mut app, &ctx); + let reply = request(&tx, json!({})); + let mut raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, SCREEN)), ..Default::default() }; + raw.viewports.insert(ViewportId::ROOT, ViewportInfo { minimized: Some(true), inner_rect: None, focused: Some(false), ..Default::default() }); + app.begin_host_iteration(); + let out = ctx.run_logic(&raw, |ctx| app.tick(ctx)); + let commands = out.viewport_commands.get(&ViewportId::ROOT).cloned().unwrap_or_default(); + assert!( + commands.iter().any(|c| matches!(c, ViewportCommand::RequestPaintWhileHidden)), + "a queued capture reaches issuance only through RequestPaintWhileHidden: {commands:?}" + ); + assert_eq!(app.pending_screenshots.len(), 1); + assert!(app.pending_screenshots.iter().all(|p| p.slot.is_none()), "logic alone never issues a capture"); + assert!(reply.try_recv().is_err(), "no reply before the paint pass"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn a_data_reply_over_the_encoding_budget_is_refused_before_encoding() { + let (mut app, _tx, _dir) = app("screenshot-encoding"); + // A huge claimed image with no pixels: the guard must look at the checked size before copying + // or encoding anything. (A real 100 000 × 100 000 ColorImage would already be impossible.) + let image = egui::ColorImage { size: [100_000, 100_000], source_size: egui::Vec2::ZERO, pixels: vec![] }; + let answer = crate::control::save_screenshot(&mut app, &image, None, true); + assert_eq!(answer["ok"], false, "{answer}"); + assert!(answer["error"].as_str().unwrap_or_default().contains("reply-encoding limit"), "{answer}"); +} + +#[test] +fn a_path_plus_data_capture_writes_the_file_and_omits_an_oversized_inline_png() { + let (mut app, _tx, dir) = app("screenshot-path-data"); + let image = egui::ColorImage::filled([2, 2], egui::Color32::RED); + let path = dir.join("shot.png"); + let path_text = path.to_string_lossy().into_owned(); + let answer = crate::control::save_screenshot_with_limit(&mut app, &image, Some(&path_text), true, 1); + assert_eq!(answer["ok"], true, "{answer}"); + assert_eq!(answer["result"]["path"], json!(path_text), "{answer}"); + assert_eq!(answer["result"]["width"], json!(2), "{answer}"); + assert_eq!(answer["result"]["height"], json!(2), "{answer}"); + assert!(answer["result"].get("pngBase64").is_none(), "the oversized inline copy is downgraded, not the file: {answer}"); + assert!(path.is_file(), "the named file is still written"); + + // Without a path there is no delivery channel left, so that request still fails before it + // copies or encodes the image. + let answer = crate::control::save_screenshot_with_limit(&mut app, &image, None, true, 1); + assert_eq!(answer["ok"], false, "{answer}"); + assert!(answer["error"].as_str().unwrap_or_default().contains("reply-encoding limit"), "{answer}"); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/ui-egui/src/tests_synthetic.rs b/crates/ui-egui/src/tests_synthetic.rs index 84962e40f..cb328e235 100644 --- a/crates/ui-egui/src/tests_synthetic.rs +++ b/crates/ui-egui/src/tests_synthetic.rs @@ -25,7 +25,7 @@ pub(crate) fn run_synthetic(app: &mut VectorcraftApp, ctx: &egui::Context, draw: loop { let last = app.synthetic.is_empty(); let mut raw = egui::RawInput::default(); - app.raw_input_hook(&mut raw); + app.raw_input_hook(ctx, &mut raw); let mut out = ctx.run_ui(raw, |ui| { held.push(ui.input(|i| i.modifiers)); draw(app, ui); diff --git a/crates/ui-egui/src/tests_sysclip.rs b/crates/ui-egui/src/tests_sysclip.rs index 5b58ec260..38e08fbf7 100644 --- a/crates/ui-egui/src/tests_sysclip.rs +++ b/crates/ui-egui/src/tests_sysclip.rs @@ -94,6 +94,54 @@ fn count(app: &VectorcraftApp) -> usize { app.session.active().unwrap().doc.layers[0].children().unwrap().len() } +/// An egui `PasteImage` from the host is the signal to read the rich system clipboard: it must +/// paste the bitmap even when no paste chord or text event came with it. +#[test] +fn an_image_only_paste_event_imports_the_system_image_once() { + let (mut app, board) = app(); + copy_elsewhere(&board, vec![(PNG, blue_png())]); + frame(&mut app, vec![egui::Event::PasteImage(Arc::new(egui::ColorImage::filled([4, 2], egui::Color32::BLUE)))]); + frame(&mut app, vec![]); + assert_eq!(count(&app), 1, "one image object, not zero or two"); + assert!(matches!(pasted(&app)[0].kind, NodeKind::Image { .. })); +} + +/// The image-only paste schedules the tick that reads the rich clipboard and remembers the chord +/// held when the image arrived, not the next pass's (possibly stale) modifiers. +#[test] +fn an_image_only_paste_requests_its_tick_and_keeps_the_paste_time_chord() { + let (mut app, board) = app(); + copy_elsewhere(&board, vec![(PNG, blue_png())]); + let held = egui::Modifiers::COMMAND | egui::Modifiers::SHIFT; + let out = frame( + &mut app, + vec![egui::Event::ModifiersChanged(held), egui::Event::PasteImage(Arc::new(egui::ColorImage::filled([4, 2], egui::Color32::BLUE)))], + ); + assert_eq!(app.clipboard_image_request, Some(held), "the paste-time chord is kept"); + let delay = out.viewport_output.get(&egui::ViewportId::ROOT).map_or(std::time::Duration::MAX, |v| v.repaint_delay); + assert_eq!(delay, std::time::Duration::ZERO, "the request asks for the tick that will consume it"); + // The next pass carries the keyboard's modifiers back as None; the stored chord still picks + // Paste in Place rather than Paste. + frame(&mut app, vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE)]); + assert_eq!(app.clipboard_image_request, None, "the one-shot request is consumed"); + assert_eq!(count(&app), 1, "the image pasted once"); +} + +/// A text paste alongside `PasteImage` takes the text path only: the image signal must not read +/// and paste the clipboard a second time. +#[test] +fn text_and_image_paste_events_paste_once_as_text() { + let (mut app, board) = app(); + copy_elsewhere(&board, vec![(TEXT, b"Words".to_vec()), (PNG, blue_png())]); + frame( + &mut app, + vec![egui::Event::Paste("Words".into()), egui::Event::PasteImage(Arc::new(egui::ColorImage::filled([4, 2], egui::Color32::BLUE)))], + ); + frame(&mut app, vec![]); + assert_eq!(count(&app), 1, "one paste, not text and picture"); + assert!(matches!(pasted(&app)[0].kind, NodeKind::Text(_)), "{:?}", pasted(&app)[0].kind); +} + /// A 4 × 2 px blue PNG. pub(super) fn blue_png() -> Vec { let img = image::RgbaImage::from_pixel(4, 2, image::Rgba([0, 0, 255, 255])); @@ -104,12 +152,14 @@ pub(super) fn blue_png() -> Vec { /// One headless frame of app logic (shortcuts too, from the second frame on) delivering `events`, /// in the test's one context (in a new one the app starts its fonts over). -fn frame(app: &mut VectorcraftApp, events: Vec) { +fn frame(app: &mut VectorcraftApp, events: Vec) -> egui::FullOutput { thread_local! { static CTX: egui::Context = egui::Context::default(); } let ctx = CTX.with(Clone::clone); - ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| app.logic(ui.ctx())).textures_delta.clear(); + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| app.logic(ui.ctx())); + out.textures_delta.clear(); + out } fn key(key: egui::Key, pressed: bool, modifiers: egui::Modifiers) -> egui::Event { @@ -301,7 +351,7 @@ fn the_menus_paste_into_a_field_waits_for_the_formats() { run(&mut app, "edit.copy", json!({})); let hold = hold_worker(&mut app, "Saving a.vectorcraft"); // A text field with the keyboard; in the second frame Paste is chosen from the menu. - let (ctx, id, mut field) = (egui::Context::default(), egui::Id::new("field"), String::new()); + let (ctx, id, mut field) = (egui::Context::default(), egui::Id::unique("field"), String::new()); for paste in [false, true] { let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { if paste { diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index f3559793a..e6c06e5dd 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -500,14 +500,23 @@ pub fn mono(size: f32) -> FontId { } /// Installs the interface's fonts and binds the families the panels and dialogs draw in -/// ([`FONT_UI`], [`FONT_UI_SEMIBOLD`], [`FONT_MONO`]). egui panics drawing text in a family no font -/// is bound to, so every context that shows them needs this once: [`crate::VectorcraftApp::logic`] -/// calls it on its first frame, and an embedder that draws the panels or dialogs without it calls -/// it when it makes the context. It replaces the context's font definitions: add other fonts after -/// it, or keep these families in definitions of one's own. Without the `interface-fonts` feature -/// the families are egui's built-in fonts (and craft-fonts' faces in a build with them), which lack -/// some symbols the panels show (→ ▾ ● ✓). +/// ([`FONT_UI`], [`FONT_UI_SEMIBOLD`], [`FONT_MONO`]). egui 0.37 only bundles its own faces when +/// its `default_fonts` feature is on (this crate enables it); without any face a family lays out +/// tofu, and the `egui_kittest` harness alone panics on a missing glyph. Every context that shows +/// these families needs this once: [`crate::VectorcraftApp::ui`] calls it on its first pass, and +/// an embedder that draws the panels or dialogs without it calls it when it makes the context. It +/// replaces the context's font definitions: add other fonts after it, or keep these families in +/// definitions of one's own. Without the `interface-fonts` feature the families are egui's built-in +/// fonts (and craft-fonts' faces in a build with them), which lack some symbols the panels show +/// (→ ▾ ● ✓). pub fn install_fonts(ctx: &egui::Context) { + ctx.set_fonts(interface_fonts()); +} + +/// The definitions [`install_fonts`] installs. [`crate::VectorcraftApp::ui`] keeps them to tell +/// whether egui applied them at once or deferred them to the next pass (it must not take input +/// or draw with fallback metrics while they are pending). +pub fn interface_fonts() -> FontDefinitions { let mut fonts = FontDefinitions::default(); let (ui, semi, mono) = interface_faces(&mut fonts); let stack = |fonts: &FontDefinitions, family: FontFamily| fonts.families.get(&family).cloned().unwrap_or_default(); @@ -521,7 +530,7 @@ pub fn install_fonts(ctx: &egui::Context) { fonts.families.insert(FontFamily::Name(FONT_UI.into()), family(ui, &fallback)); fonts.families.insert(FontFamily::Name(FONT_MONO.into()), family(mono, &mono_fallback)); add_craft_fonts(&mut fonts); - ctx.set_fonts(fonts); + fonts } /// The interface's own faces, added to `fonts`: the names of the regular, semibold and monospace diff --git a/crates/ui-egui/src/titlebar.rs b/crates/ui-egui/src/titlebar.rs index d359309da..8f4f4833b 100644 --- a/crates/ui-egui/src/titlebar.rs +++ b/crates/ui-egui/src/titlebar.rs @@ -30,7 +30,7 @@ const CAPTIONS: [Caption; 3] = [Caption::Minimize, Caption::Maximize, Caption::C /// Caption button ids (stable, so tests and agents can find them). fn caption_id(c: Caption) -> Id { - Id::new(("titlebar-caption", c as u8)) + Id::unique(("titlebar-caption", c as u8)) } fn maximized(ctx: &egui::Context) -> bool { @@ -45,7 +45,7 @@ fn toggle_maximize(ctx: &egui::Context) { /// maximize. Call it before laying out the bar so its widgets, registered later, take the clicks. pub fn drag_area(ui: &Ui, rect: Rect) { let ctx = ui.ctx(); - let resp = ui.interact(rect, Id::new("titlebar-drag"), Sense::click_and_drag()); + let resp = ui.interact(rect, Id::unique("titlebar-drag"), Sense::click_and_drag()); // egui hands a drag that starts on a click-only widget (a menu title) to the drag-sensing // background below it, so remember whether the press itself landed on bare background. if ctx.input(|i| i.pointer.any_pressed()) { @@ -159,10 +159,10 @@ pub fn resize_zones(ui: &mut Ui) { return; } let w = ctx.content_rect(); - let layer = LayerId::new(Order::Foreground, Id::new("titlebar-resize")); + let layer = LayerId::new(Order::Foreground, Id::unique("titlebar-resize")); let zui = ui.new_child(egui::UiBuilder::new().layer_id(layer).max_rect(w)); for (i, (r, dir, cursor)) in zones(w).into_iter().enumerate() { - let resp = zui.interact(r, Id::new(("titlebar-resize", i)), Sense::drag()).on_hover_cursor(cursor); + let resp = zui.interact(r, Id::unique(("titlebar-resize", i)), Sense::drag()).on_hover_cursor(cursor); if resp.drag_started_by(PointerButton::Primary) { ctx.send_viewport_cmd(ViewportCommand::BeginResize(dir)); handed_to_os(&ctx); @@ -253,7 +253,7 @@ mod tests { /// The bar's clickable widgets other than its background and the caption buttons. fn bar_widgets(&self) -> Vec { - let skip: Vec = std::iter::once(Id::new("titlebar-drag")).chain(CAPTIONS.map(caption_id)).collect(); + let skip: Vec = std::iter::once(Id::unique("titlebar-drag")).chain(CAPTIONS.map(caption_id)).collect(); self.ctx.viewport(|v| { v.prev_pass .widgets diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 449a11bdb..8c00d8027 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -114,7 +114,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { Some(pos) => { // Floating, kept inside the window, its tools scrolling when they don't fit under it. let ctx = ui.ctx().clone(); - let id = egui::Id::new("toolbar-floating"); + let id = egui::Id::unique("toolbar-floating"); let screen = ctx.content_rect(); // Kept on screen by its title bar and first tools: the rest scrolls. let size = ctx.memory(|m| m.area_rect(id)).map_or(vec2(w, 160.0), |r| vec2(r.width(), r.height().min(160.0))); @@ -161,7 +161,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, cols: usize, floating: Option(held_id)); } @@ -262,7 +262,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, cols: usize, floating: Option>()); + d.insert_temp(egui::Id::unique("flyout-anchor"), rect); + d.insert_temp(egui::Id::unique("flyout-tools"), slot.iter().map(|s| s.to_string()).collect::>()); }); } ui.add_space(8.0); @@ -479,7 +479,7 @@ fn bottom_controls(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, cols: usiz "paint.lastGradient" => widgets::gradient_chip(ui, r, &app.session.last_gradient), _ => widgets::paint_chip(ui, r, &Paint::None), } - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!(tip))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(tip))); if resp.on_hover_text(tl!(tip)).clicked() { clicked = Some(cmd); } @@ -560,7 +560,7 @@ fn is_floating(app: &VectorcraftApp, key: &str) -> bool { /// The area of the floating flyout of the group whose first tool is `key`. fn floating_area(key: &str) -> egui::Id { - egui::Id::new(("tool-floating", key)) + egui::Id::unique(("tool-floating", key)) } /// Bring a floating flyout to the front and flash its border. @@ -604,9 +604,9 @@ fn flyout_body(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String], floating ui.painter().rect_filled(bar, 0.0, t.tab_strip); let grip = if floating { let x = egui::Rect::from_min_size(bar.min, vec2(BAR, CLOSE)); - let xr = ui.interact(x, egui::Id::new(("tool-flyout-close", key)), Sense::click()); + let xr = ui.interact(x, egui::Id::unique(("tool-flyout-close", key)), Sense::click()); icons::paint(ui, "x", x.shrink2(vec2(1.0, 3.0)), if xr.hovered() { t.text_strong } else { t.text }); - xr.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Put back in the toolbar"))); + xr.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Put back in the toolbar"))); out.close = xr.on_hover_text(tl!("Put back in the toolbar")).clicked(); egui::Rect::from_min_max(bar.min + vec2(0.0, CLOSE), bar.max) } else { @@ -619,7 +619,7 @@ fn flyout_body(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String], floating ui.painter().line_segment([pos2(x, grip.center().y - half), pos2(x, grip.center().y + half)], Stroke::new(0.6, t.text_disabled)); } let tip = if floating { tl!("Drag to move") } else { tl!("Drag to float as a toolbar") }; - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tip)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tip)); let resp = if resp.dragged() { ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); resp @@ -655,7 +655,7 @@ fn tool_rows(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String]) -> Option< if let Some(sc) = shortcut { ui.painter().galley(pos2(r.right() - ROW_END - sc.size().x, r.center().y - sc.size().y / 2.0), sc, color); } - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), is_active, tip(tool))); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), is_active, tip(tool))); if resp.clicked() { chosen = Some(tool.id); } @@ -687,7 +687,7 @@ fn rows_width<'a>(rows: impl Iterator) -> f32 { /// The grab bar of the group whose first tool is `key` (one id, flyout or floating, so a drag /// that tears the flyout off carries on moving it). fn bar_id(key: &str) -> egui::Id { - egui::Id::new(("tool-flyout-bar", key)) + egui::Id::unique(("tool-flyout-bar", key)) } /// Where the pointer holds a floating flyout's bar, from the flyout's corner, while it's dragged. @@ -709,7 +709,7 @@ fn tool_strip(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String]) -> Option } let ir = egui::Rect::from_center_size(r.center(), vec2(18.0, 18.0)); icons::paint(ui, icons::tool_icon(tool.icon), ir, if active == tool.id { t.text_strong } else { t.icon }); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), active == tool.id, tip(tool))); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), active == tool.id, tip(tool))); if resp.on_hover_text(tip(tool)).clicked() { chosen = Some(tool.id); } @@ -731,17 +731,17 @@ fn flyout(app: &mut VectorcraftApp, ctx: &egui::Context) { if app.ui.flyout.is_none() { return; } - let tools: Vec = ctx.data(|d| d.get_temp(egui::Id::new("flyout-tools"))).unwrap_or_default(); + let tools: Vec = ctx.data(|d| d.get_temp(egui::Id::unique("flyout-tools"))).unwrap_or_default(); if tools.is_empty() { app.ui.flyout = None; return; } let anchor: egui::Rect = - ctx.data(|d| d.get_temp(egui::Id::new("flyout-anchor"))).unwrap_or(egui::Rect::from_min_size(pos2(40.0, 100.0), vec2(32.0, 32.0))); + ctx.data(|d| d.get_temp(egui::Id::unique("flyout-anchor"))).unwrap_or(egui::Rect::from_min_size(pos2(40.0, 100.0), vec2(32.0, 32.0))); let t = Tokens::get(ctx); let active = app.session.tool_id(); let mut input = FlyoutInput::default(); - let resp = egui::Area::new(egui::Id::new("tool-flyout")).order(egui::Order::Foreground).fixed_pos(anchor.right_top() + vec2(8.0, -1.0)).show( + let resp = egui::Area::new(egui::Id::unique("tool-flyout")).order(egui::Order::Foreground).fixed_pos(anchor.right_top() + vec2(8.0, -1.0)).show( ctx, |ui| { flyout_frame(&t, false).show(ui, |ui| input = flyout_body(ui, &t, active, &tools, false)); @@ -886,7 +886,16 @@ pub(crate) mod tests { /// A mouse wheel turn over `at`, scrolling the content up by `dy` points. pub(crate) fn wheel(at: Pos2, dy: f32) -> Vec { let unit = egui::MouseWheelUnit::Point; - vec![Event::PointerMoved(at), Event::MouseWheel { unit, delta: vec2(0.0, -dy), phase: egui::TouchPhase::Move, modifiers: Default::default() }] + vec![ + Event::PointerMoved(at), + Event::MouseWheel { + unit, + delta: vec2(0.0, -dy), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + source: egui::MouseWheelSource::Unknown, + }, + ] } #[test] @@ -1237,7 +1246,7 @@ pub(crate) mod tests { frame(&mut app, &ctx, 1.05, vec![b(false)]); frame(&mut app, &ctx, 1.1, vec![]); assert!(app.ui.flyout.is_some(), "the right-click opens the flyout and the same click doesn't close it"); - let menu = ctx.memory(|m| m.area_rect(egui::Id::new("tool-flyout"))).expect("the flyout is shown"); + let menu = ctx.memory(|m| m.area_rect(egui::Id::unique("tool-flyout"))).expect("the flyout is shown"); assert_eq!(app.session.tool_id(), "selection", "a right-click only opens the flyout"); // Its second row is Rounded Rectangle (30 pt rows). let row = egui::pos2(menu.left() + 60.0, menu.top() + 45.0); @@ -1352,9 +1361,9 @@ pub(crate) mod tests { frame_in(&mut app, &ctx, 1.4, vec![pointer(at, false)], 2000.0); frame_in(&mut app, &ctx, 1.45, vec![], 2000.0); assert_eq!(app.session.tool_id(), "selection"); - let tools: Vec = ctx.data(|d| d.get_temp(egui::Id::new("flyout-tools"))).unwrap(); + let tools: Vec = ctx.data(|d| d.get_temp(egui::Id::unique("flyout-tools"))).unwrap(); let row_index = tools.iter().position(|id| id == "verticalType").unwrap(); - let menu = ctx.memory(|m| m.area_rect(egui::Id::new("tool-flyout"))).unwrap(); + let menu = ctx.memory(|m| m.area_rect(egui::Id::unique("tool-flyout"))).unwrap(); let row = egui::pos2(menu.left() + 60.0, menu.top() + 15.0 + row_index as f32 * 30.0); frame_in(&mut app, &ctx, 2.0, vec![Event::PointerMoved(row), pointer(row, true)], 2000.0); frame_in(&mut app, &ctx, 2.05, vec![pointer(row, false)], 2000.0); @@ -1486,7 +1495,7 @@ pub(crate) mod tests { let ctx = egui::Context::default(); let at = shapes_button(&mut app, &ctx).center(); click(&mut app, &ctx, 1.0, at, PointerButton::Secondary); - let menu = ctx.memory(|m| m.area_rect(egui::Id::new("tool-flyout"))).expect("the flyout is shown"); + let menu = ctx.memory(|m| m.area_rect(egui::Id::unique("tool-flyout"))).expect("the flyout is shown"); // Its grab bar runs down its right side: dragging it tears the flyout off. let bar = pos2(menu.right() - BAR / 2.0, menu.center().y); drag(&mut app, &ctx, 2.0, bar, &[bar + vec2(20.0, 0.0), bar + vec2(60.0, 40.0)]); @@ -1531,7 +1540,7 @@ pub(crate) mod tests { let ctx = egui::Context::default(); let at = shapes_button(&mut app, &ctx).center(); click(&mut app, &ctx, 1.0, at, PointerButton::Secondary); - let menu = ctx.memory(|m| m.area_rect(egui::Id::new("tool-flyout"))).unwrap(); + let menu = ctx.memory(|m| m.area_rect(egui::Id::unique("tool-flyout"))).unwrap(); let bar = pos2(menu.right() - BAR / 2.0, menu.center().y); click(&mut app, &ctx, 2.0, bar, PointerButton::Primary); frame(&mut app, &ctx, 2.2, vec![]); @@ -1551,7 +1560,7 @@ pub(crate) mod tests { frame(&mut app, &ctx, 1.0, vec![Event::PointerMoved(at), b(at, true)]); frame(&mut app, &ctx, 1.0 + LONG_PRESS + 0.01, vec![]); frame(&mut app, &ctx, 1.45, vec![]); - let menu = ctx.memory(|m| m.area_rect(egui::Id::new("tool-flyout"))).expect("the long press opens the flyout"); + let menu = ctx.memory(|m| m.area_rect(egui::Id::unique("tool-flyout"))).expect("the long press opens the flyout"); let bar = pos2(menu.right() - BAR / 2.0, menu.center().y); frame(&mut app, &ctx, 1.5, vec![Event::PointerMoved(bar)]); frame(&mut app, &ctx, 1.55, vec![b(bar, false)]); @@ -1722,7 +1731,7 @@ pub(crate) mod tests { let group: Vec = ["rectangle", "roundedRectangle", "ellipse"].map(String::from).to_vec(); let flyout = tree(|ui| { app.ui.flyout = Some(0); - ui.data_mut(|d| d.insert_temp(egui::Id::new("flyout-tools"), group.clone())); + ui.data_mut(|d| d.insert_temp(egui::Id::unique("flyout-tools"), group.clone())); show(&mut app, ui); }); assert_eq!(role_and_state(&flyout, &name("roundedRectangle")), (Role::Button, Some(Toggled::False))); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 73f5a61e8..5b1a20e57 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -9,6 +9,29 @@ use vectorcraft_doc::Unit; use crate::theme::{self, Tokens}; use crate::{icons, scrub}; +/// A widget id salt that needs only [`std::hash::Hash`]. egui 0.37's [`egui::AsIdSalt`] also +/// requires `Debug` for its debug-only id-source registry; VectorCraft's public field helpers do +/// not need a `Debug` bound merely to salt a widget id, and never make an accessible name from it. +#[derive(Clone, Copy)] +struct HashOnly(T); + +impl std::hash::Hash for HashOnly { + fn hash(&self, state: &mut H) { + self.0.hash(state); + } +} + +impl std::fmt::Debug for HashOnly { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("widget id") + } +} + +/// `parent.with(salt)` for a salt that needs only `Hash`. +fn id_with(parent: egui::Id, salt: impl std::hash::Hash) -> egui::Id { + parent.with(HashOnly(salt)) +} + /// Square icon button; `selected` draws the pressed well. pub fn icon_button(ui: &mut Ui, icon: &str, tip: &str, selected: bool, size: f32) -> Response { let (_, resp) = ui.allocate_exact_size(Vec2::splat(size), Sense::click()); @@ -42,7 +65,7 @@ pub fn flat_button(ui: &mut Ui, text: &str, width: f32) -> Response { ui.painter().rect_filled(rect, CornerRadius::same(5), t.button); flat_fill(ui, rect, &resp); ui.painter().with_clip_rect(rect).text(rect.center(), egui::Align2::CENTER_CENTER, tl!(text), egui::FontId::proportional(12.5), t.text_strong); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!(text))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(text))); resp } @@ -56,7 +79,7 @@ pub fn flat_button_fit(ui: &mut Ui, text: &str, min_width: f32) -> Response { ui.painter().rect_filled(rect, CornerRadius::same(5), t.button); flat_fill(ui, rect, &resp); ui.painter().with_clip_rect(rect).text(rect.center(), egui::Align2::CENTER_CENTER, tl!(text), egui::FontId::proportional(12.5), t.text_strong); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!(text))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(text))); resp } @@ -78,8 +101,8 @@ pub fn split_button_named(ui: &mut Ui, text: &str, arrow_tip: &str, width: f32) let split = rect.right() - SPLIT_ARROW; let main_rect = Rect::from_min_max(rect.min, pos2(split, rect.bottom())); let arrow_rect = Rect::from_min_max(pos2(split, rect.top()), rect.max); - let main = ui.interact(main_rect, ui.id().with((text, "split")), Sense::click()); - let arrow = ui.interact(arrow_rect, ui.id().with((text, "split-arrow")), Sense::click()); + let main = ui.interact(main_rect, ui.scope_id().with((text, "split")), Sense::click()); + let arrow = ui.interact(arrow_rect, ui.scope_id().with((text, "split-arrow")), Sense::click()); ui.painter().rect_filled(rect, CornerRadius::same(5), t.button); flat_fill(ui, main_rect, &main); flat_fill(ui, arrow_rect, &arrow); @@ -92,11 +115,11 @@ pub fn split_button_named(ui: &mut Ui, text: &str, arrow_tip: &str, width: f32) t.text_strong, ); icons::paint(ui, "chevron-down", Rect::from_center_size(arrow_rect.center(), vec2(12.0, 12.0)), t.text_dim); - main.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!(text))); + main.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(text))); if arrow_tip.is_empty() { return (main, arrow); } - arrow.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!(arrow_tip))); + arrow.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(arrow_tip))); (main, arrow.on_hover_text(tl!(arrow_tip))) } @@ -143,7 +166,7 @@ fn primary_button_sized(ui: &mut Ui, text: &str, enabled: bool, min_width: f32) ui.painter().rect_stroke(rect, CornerRadius::same(6), egui::Stroke::new(1.0, t.button_border), egui::StrokeKind::Inside); } ui.painter().galley(rect.center() - galley.size() / 2.0, galley, fg); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled && ui.is_enabled(), tl!(text))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled && ui.is_enabled(), tl!(text))); resp } @@ -160,7 +183,7 @@ pub(crate) fn secondary_button_width(ui: &mut Ui, text: &str, min_width: f32) -> let (rect, resp) = ui.allocate_exact_size(vec2(w.max(min_width), 28.0), Sense::click()); ui.painter().rect_filled(rect, CornerRadius::same(6), if resp.hovered() { t.hover } else { t.button }); ui.painter().galley(rect.center() - galley.size() / 2.0, galley, t.text); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!(text))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(text))); resp } @@ -189,7 +212,7 @@ pub fn section_toggle_name(ui: &mut Ui, text: &str, open: bool) -> Response { let chevron = Rect::from_center_size(pos2(rect.left() + 8.0, rect.center().y), vec2(12.0, 12.0)); icons::paint(ui, if open { "chevron-down" } else { "chevron-right" }, chevron, if resp.hovered() { t.text_strong } else { t.icon }); ui.painter().text(pos2(rect.left() + 18.0, rect.center().y), egui::Align2::LEFT_CENTER, text, theme::semibold(12.5), t.text); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::CollapsingHeader, true, open, text)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::DisclosureTriangle, true, open, text)); resp.on_hover_cursor(egui::CursorIcon::PointingHand) } @@ -202,11 +225,86 @@ pub fn divider(ui: &mut Ui) { ui.add_space(6.0); } +/// The `Id` under which [`dim_label`]/[`field_label`] leave the label a following field should be +/// named by. egui 0.37's kittest fails an input with no accessible name, and VectorCraft paints +/// most labels beside, not inside, their fields. +fn next_label_id() -> egui::Id { + egui::Id::unique("vectorcraft-next-input-label") +} + +/// The label a field may be named by, and the widget id the field must be drawn under for the +/// label to belong to it. egui allocates a fresh auto id for every widget, but not for moving +/// between grid cells: the next field in a grid still has the id the label recorded, while a +/// checkbox or another widget between the label and a field has taken it. A label therefore names +/// the very next field, also in a grid, but not a later one after an unrelated widget. +#[derive(Clone, Copy)] +struct NextLabel { + id: egui::Id, + viewport: egui::ViewportId, + pass: u64, + at: Pos2, + next_auto_id: egui::Id, +} + +fn remember_next_label(ui: &Ui, id: egui::Id) { + let label = NextLabel { + id, + viewport: ui.ctx().viewport_id(), + pass: ui.ctx().cumulative_pass_nr(), + at: ui.next_widget_position(), + next_auto_id: ui.next_auto_id(), + }; + ui.ctx().data_mut(|d| d.insert_temp(next_label_id(), label)); +} + +/// A label taken for the next field, and whether no widget came between the two. +pub(crate) struct PreviousLabel { + id: egui::Id, + at: Pos2, + immediate: bool, +} + +/// The label drawn immediately before a field in this pass, if no other widget came between them +/// (an app-local adapter around [`egui::Response::labelled_by`], not a general compatibility +/// layer). Takes the record, so one label names one field. +pub(crate) fn take_previous_label(ui: &Ui) -> Option { + let label = ui.ctx().data_mut(|d| { + let label = d.get_temp::(next_label_id()); + d.remove::(next_label_id()); + label + })?; + // Frame and pass numbers are per viewport; a label left in one viewport must never name a + // field in another. The shipped hosts draw one root viewport, so this scoping is defensive. + (label.viewport == ui.ctx().viewport_id() && label.pass == ui.ctx().cumulative_pass_nr()).then(|| PreviousLabel { + id: label.id, + at: label.at, + immediate: ui.next_auto_id() == label.next_auto_id, + }) +} + +/// Name `resp` by the field's real adjacent label (see [`take_previous_label`]). +pub(crate) fn label_field(resp: Response, label: Option) -> Response { + let Some(label) = label else { return resp }; + let adjacent = label.immediate || resp.rect.min.distance(label.at) <= 16.0 || resp.rect.contains(label.at); + if adjacent { resp.labelled_by(label.id) } else { resp } +} + +/// Give `resp` the explicit semantic name of a field whose visible label is not a widget beside +/// it (a label below a grid of dash cells, a heading over a row of fields). The name is already +/// translated at the call site. An empty name leaves the field unnamed rather than inventing one. +fn name_field(resp: Response, name: Option<&str>) -> Response { + match name { + Some(name) if !name.trim().is_empty() => resp.accessible_name(name), + _ => resp, + } +} + /// A label in the panels' text colour; before a numeric field it scrubs the field /// ([`scrub`]). pub fn dim_label(ui: &mut Ui, text: &str) -> Response { let resp = dim_name(ui, tl!(text)); scrub::note_label(ui, resp.rect); + remember_next_label(ui, resp.id); resp } @@ -215,6 +313,7 @@ pub fn dim_label(ui: &mut Ui, text: &str) -> Response { pub fn field_label(ui: &mut Ui, text: impl Into) -> Response { let resp = ui.label(text); scrub::note_label(ui, resp.rect); + remember_next_label(ui, resp.id); resp } @@ -228,14 +327,14 @@ pub fn dim_name(ui: &mut Ui, text: &str) -> Response { /// Preferences › Units › Numbers Without Units Are Points (#394; on until set): the app sets it /// when the preference changes (`prefs_dialog::apply_runtime`), the length fields read it. pub(crate) fn set_bare_numbers_are_points(ctx: &egui::Context, on: bool) { - ctx.data_mut(|d| d.insert_temp(egui::Id::new("bare_numbers_are_points"), on)); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique("bare_numbers_are_points"), on)); } /// The unit a number typed with no unit into a field in `unit` is read in: `unit`, or for a field /// in picas points with Numbers Without Units Are Points on (where `12` could be either). A typed /// unit (`12 mm`, `2p6`, `1in`) always wins. pub(crate) fn typed_unit(ctx: &egui::Context, unit: Unit) -> Unit { - let bare_points = ctx.data(|d| d.get_temp::(egui::Id::new("bare_numbers_are_points"))).unwrap_or(true); + let bare_points = ctx.data(|d| d.get_temp::(egui::Id::unique("bare_numbers_are_points"))).unwrap_or(true); if bare_points && unit == Unit::Picas { Unit::Points } else { unit } } @@ -243,8 +342,15 @@ pub(crate) fn typed_unit(ctx: &egui::Context, unit: Unit) -> Unit { /// when the user commits (Enter / focus loss / Shift+Enter, which keeps the focus outside dialogs). /// Supports unit suffixes and arithmetic; a number without a unit is in `unit` ([`typed_unit`]: in /// points in a picas field with Numbers Without Units Are Points on). -pub fn num_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: Option, unit: Unit, width: f32) -> Option { - unit_field(ui, id, value, unit, width, false, ANY) +pub fn num_field(ui: &mut Ui, id: impl std::hash::Hash, value: Option, unit: Unit, width: f32) -> Option { + num_field_named(ui, id, None, value, unit, width) +} + +/// [`num_field`] for a row whose visible label is not an adjacent label widget (a grid cell with +/// its label painted separately): screen readers name the field `name` (already translated). +pub fn num_field_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, value: Option, unit: Unit, width: f32) -> Option { + let label = take_previous_label(ui); + unit_field(ui, id, label, name, value, unit, width, false, ANY) } /// Any number: the bounds of a field that has none. @@ -258,19 +364,22 @@ fn within(v: f64, bounds: &RangeInclusive) -> f64 { /// [`num_field`]; with `share_of_value` a percentage typed alone is that share of `value` /// ([`share_of`]). ↑/↓, the wheel and a scrub stop at `bounds` (points), and the field shows where /// they stopped. +#[allow(clippy::too_many_arguments)] fn unit_field( ui: &mut Ui, - id: impl std::hash::Hash + std::fmt::Debug, + id: impl std::hash::Hash, + label: Option, + name: Option<&str>, value: Option, unit: Unit, width: f32, share_of_value: bool, bounds: RangeInclusive, ) -> Option { - let id = ui.id().with(id); + let id = id_with(ui.scope_id(), id); let shown = value.map(|v| unit.format(v)).unwrap_or_default(); take_dialog_focus(ui, id, &shown); - let (mut buf, commit, resp, rect) = recessed_text(ui, id, &shown, width, 1); + let (mut buf, commit, resp, rect) = recessed_text(ui, id, &shown, width, 1, label, name); select_all_on_focus(ui, &resp, &buf); // ↑/↓ and the wheel step in the field's unit; a drag on its label scrubs it. let read = typed_unit(ui.ctx(), unit); @@ -404,7 +513,7 @@ fn step_value( /// The flag `dialogs::show` raises while it draws the body of a dialog that has just opened; the /// first enabled field drawn takes it ([`take_dialog_focus`]). pub(crate) fn dialog_focus_flag() -> egui::Id { - egui::Id::new("dialog-focus-first-field") + egui::Id::unique("dialog-focus-first-field") } /// If field `id` (showing `text`) is the first enabled field of a dialog that has just opened, give @@ -438,11 +547,17 @@ pub(crate) fn enter_modifiers(ui: &Ui) -> Option { /// A [`num_field`] for a width or height (Transform panel, Properties panel, Control bar): Ctrl/Cmd+Enter /// also commits it and ends its editing, as Enter does, and [`enter_modifiers`] reports Command. A /// percentage typed alone (`50%`) is that share of the size shown, as in Illustrator (#2401). -pub fn size_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: Option, unit: Unit, width: f32) -> Option { +pub fn size_field(ui: &mut Ui, id: impl std::hash::Hash, value: Option, unit: Unit, width: f32) -> Option { + size_field_named(ui, id, None, value, unit, width) +} + +/// [`size_field`] for a row named explicitly instead of by an adjacent label widget. +pub fn size_field_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, value: Option, unit: Unit, width: f32) -> Option { use egui::{Event, Key}; + let label = take_previous_label(ui); // The text box does not end its editing on an Enter with Ctrl/Cmd: a plain Enter goes in right // after it, so the box first takes the text typed before it in this frame. - let field = ui.id().with(&id); + let field = id_with(ui.scope_id(), &id); if ui.memory(|m| m.has_focus(field)) { ui.input_mut(|i| { let cmd_enter = |e: &Event| matches!(e, Event::Key { key: Key::Enter, pressed: true, modifiers, .. } if modifiers.command); @@ -452,7 +567,7 @@ pub fn size_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value } }); } - unit_field(ui, id, value, unit, width, true, ANY) + unit_field(ui, id, label, name, value, unit, width, true, ANY) } /// Select all of `text` in text field `id`. @@ -465,7 +580,7 @@ fn select_all(ui: &Ui, id: egui::Id, text: &str) { /// The flag that tells the fields a dialog is open in this frame ([`set_dialog_open`]). fn dialog_open_flag() -> egui::Id { - egui::Id::new("dialog-open") + egui::Id::unique("dialog-open") } /// The app sets this at the start of each frame: while a dialog is open, Shift+Enter in a field @@ -570,7 +685,15 @@ fn select_all_on_focus(ui: &Ui, resp: &Response, text: &str) { /// its text kept under `id` while it has focus. Returns the text (as edited), whether the field /// applies it in this frame (it lost the focus, or a single-line field took Shift+Enter and kept /// the focus: [`shift_enter_keeps_focus`]), the text edit's response and the box. -fn recessed_text(ui: &mut Ui, id: egui::Id, shown: &str, width: f32, rows: usize) -> (String, bool, Response, Rect) { +fn recessed_text( + ui: &mut Ui, + id: egui::Id, + shown: &str, + width: f32, + rows: usize, + label: Option, + name: Option<&str>, +) -> (String, bool, Response, Rect) { let t = Tokens::get(ui.ctx()); let editing = ui.memory(|m| m.has_focus(id)); // A press outside the field ends its editing in this frame, so the text typed is committed @@ -630,6 +753,7 @@ fn recessed_text(ui: &mut Ui, id: egui::Id, shown: &str, width: f32, rows: usize (rect, resp) }) .inner; + let resp = name_field(label_field(resp, label), name); let focused = ui.memory(|m| m.has_focus(id)); ui.data_mut(|d| { d.insert_temp(id, buf.clone()); @@ -642,35 +766,68 @@ fn recessed_text(ui: &mut Ui, id: egui::Id, shown: &str, width: f32, rows: usize /// A recessed text field showing `value` (blank when `None`: the selection's values differ), /// `rows` lines tall (1: single line, which Enter and Shift+Enter commit). Returns the new text, /// trimmed, when a change is committed (Enter, Shift+Enter or focus loss). -pub fn text_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: Option<&str>, width: f32, rows: usize) -> Option { - let id = ui.id().with(id); +pub fn text_field(ui: &mut Ui, id: impl std::hash::Hash, value: Option<&str>, width: f32, rows: usize) -> Option { + text_field_named(ui, id, None, value, width, rows) +} + +/// [`text_field`] for a row named explicitly instead of by an adjacent label widget. +pub fn text_field_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, value: Option<&str>, width: f32, rows: usize) -> Option { + let label = take_previous_label(ui); + let id = id_with(ui.scope_id(), id); let shown = value.unwrap_or_default(); - let (buf, commit, _, _) = recessed_text(ui, id, shown, width, rows); + let (buf, commit, _, _) = recessed_text(ui, id, shown, width, rows, label, name); (commit && buf.trim() != shown).then(|| buf.trim().to_string()) } /// A one-line [`text_field`] that keeps the text exactly as typed, spaces included (a tab /// leader such as ". "). Returns it when a change is committed (Enter, Shift+Enter or focus loss). -pub fn exact_text_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: &str, width: f32) -> Option { - let id = ui.id().with(id); - let (buf, commit, _, _) = recessed_text(ui, id, value, width, 1); +pub fn exact_text_field(ui: &mut Ui, id: impl std::hash::Hash, value: &str, width: f32) -> Option { + exact_text_field_named(ui, id, None, value, width) +} + +/// [`exact_text_field`] for a row named explicitly instead of by an adjacent label widget. +pub fn exact_text_field_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, value: &str, width: f32) -> Option { + let label = take_previous_label(ui); + let id = id_with(ui.scope_id(), id); + let (buf, commit, _, _) = recessed_text(ui, id, value, width, 1, label, name); (commit && buf != value).then_some(buf) } /// A form row: `label` in a column `label_width` wide, then what `add` draws. pub fn label_row(ui: &mut Ui, label: &str, label_width: f32, add: impl FnOnce(&mut Ui)) { ui.horizontal(|ui| { - let (r, _) = ui.allocate_exact_size(vec2(label_width, 24.0), Sense::hover()); + let (r, resp) = ui.allocate_exact_size(vec2(label_width, 24.0), Sense::hover()); let t = Tokens::get(ui.ctx()); - ui.painter().text(r.left_center(), egui::Align2::LEFT_CENTER, tl!(label), egui::FontId::proportional(12.5), t.text); + let shown = tl!(label); + ui.painter().text(r.left_center(), egui::Align2::LEFT_CENTER, shown, egui::FontId::proportional(12.5), t.text); scrub::note_label(ui, r); - add(ui); + // The label is painted, not a widget of its own: give it a real semantic node so the + // input beside it can be named by `label_inputs_by` (egui 0.37's strict a11y gate). + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, ui.is_enabled(), shown)); + if label.trim().is_empty() { + add(ui); + } else { + ui.label_inputs_by(resp.id, add); + } }); } /// A plain number field (percent, degrees, counts) with optional suffix. -pub fn plain_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: f64, suffix: &str, decimals: usize, width: f32) -> Option { - mixed_field(ui, id, Some(value), suffix, decimals, width) +pub fn plain_field(ui: &mut Ui, id: impl std::hash::Hash, value: f64, suffix: &str, decimals: usize, width: f32) -> Option { + plain_field_named(ui, id, None, value, suffix, decimals, width) +} + +/// [`plain_field`] for a row named explicitly instead of by an adjacent label widget. +pub fn plain_field_named( + ui: &mut Ui, + id: impl std::hash::Hash, + name: Option<&str>, + value: f64, + suffix: &str, + decimals: usize, + width: f32, +) -> Option { + mixed_field_named(ui, id, name, Some(value), suffix, decimals, width) } /// [`plain_field`] for a number kept within `range` (the values of dialogs, preferences and panels @@ -679,7 +836,22 @@ pub fn plain_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, valu /// ↑/↓, the wheel and a scrub stop at its ends in the field too (#3370). pub fn range_field( ui: &mut Ui, - id: impl std::hash::Hash + std::fmt::Debug, + id: impl std::hash::Hash, + value: impl Into>, + range: RangeInclusive, + suffix: &str, + decimals: usize, + width: f32, +) -> Option { + range_field_named(ui, id, None, value, range, suffix, decimals, width) +} + +/// [`range_field`] for a row named explicitly instead of by an adjacent label widget. +#[allow(clippy::too_many_arguments)] +pub fn range_field_named( + ui: &mut Ui, + id: impl std::hash::Hash, + name: Option<&str>, value: impl Into>, range: RangeInclusive, suffix: &str, @@ -688,32 +860,43 @@ pub fn range_field( ) -> Option { let places = decimals.min(6) as i32; let value = value.into().map(|v| within(v, &range)); - bounded_field(ui, id, value, suffix, decimals, width, &range).map(|v| within(round_to(v, places), &range)) + let label = take_previous_label(ui); + bounded_field(ui, id, label, name, value, suffix, decimals, width, &range).map(|v| within(round_to(v, places), &range)) } /// [`plain_field`] for a value the selected objects may not share: `None` shows a blank field. -pub fn mixed_field( +pub fn mixed_field(ui: &mut Ui, id: impl std::hash::Hash, value: Option, suffix: &str, decimals: usize, width: f32) -> Option { + mixed_field_named(ui, id, None, value, suffix, decimals, width) +} + +/// [`mixed_field`] for a row named explicitly instead of by an adjacent label widget. +pub fn mixed_field_named( ui: &mut Ui, - id: impl std::hash::Hash + std::fmt::Debug, + id: impl std::hash::Hash, + name: Option<&str>, value: Option, suffix: &str, decimals: usize, width: f32, ) -> Option { - bounded_field(ui, id, value, suffix, decimals, width, &ANY) + let label = take_previous_label(ui); + bounded_field(ui, id, label, name, value, suffix, decimals, width, &ANY) } /// [`mixed_field`] whose ↑/↓, wheel and scrub stop at `bounds`. +#[allow(clippy::too_many_arguments)] fn bounded_field( ui: &mut Ui, - id: impl std::hash::Hash + std::fmt::Debug, + id: impl std::hash::Hash, + label: Option, + name: Option<&str>, value: Option, suffix: &str, decimals: usize, width: f32, bounds: &RangeInclusive, ) -> Option { - let id = ui.id().with(id); + let id = id_with(ui.scope_id(), id); let show = |value: f64| field_text(value, suffix, decimals); // The suffix (and %, °) may follow any operand: `45*2°`, `50% / 2`. let read = |buf: &str| { @@ -723,7 +906,7 @@ fn bounded_field( }; let shown = value.map(show).unwrap_or_default(); take_dialog_focus(ui, id, &shown); - let (mut buf, commit, resp, rect) = recessed_text(ui, id, &shown, width, 1); + let (mut buf, commit, resp, rect) = recessed_text(ui, id, &shown, width, 1, label, name); select_all_on_focus(ui, &resp, &buf); // ↑/↓ and the wheel step at the field's precision (a count ignores Ctrl/Cmd's tenth); a drag on // its label scrubs it. @@ -872,7 +1055,7 @@ pub fn chip_button_named(ui: &mut Ui, size: f32, chevron: bool, tip: &str, draw: if tip.is_empty() { return resp; } - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!(tip))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(tip))); resp.on_hover_text(tl!(tip)) } @@ -899,8 +1082,8 @@ pub fn fill_stroke_proxy(ui: &mut Ui, fill: &Paint, stroke: &Paint, mixed: (bool let s = size * 0.62; let fill_r = Rect::from_min_size(rect.min + vec2(0.0, 0.0), Vec2::splat(s)); let stroke_r = Rect::from_min_size(rect.max - Vec2::splat(s), Vec2::splat(s)); - let stroke_resp = ui.interact(stroke_r, ui.id().with("stroke-proxy"), Sense::click_and_drag()); - let fill_resp = ui.interact(fill_r, ui.id().with("fill-proxy"), Sense::click_and_drag()); + let stroke_resp = ui.interact(stroke_r, ui.scope_id().with("stroke-proxy"), Sense::click_and_drag()); + let fill_resp = ui.interact(fill_r, ui.scope_id().with("fill-proxy"), Sense::click_and_drag()); let dropped_fill = if mixed.0 { None } else { proxy_paint_drop(ui, &fill_resp) }; let dropped_stroke = if mixed.1 { None } else { proxy_paint_drop(ui, &stroke_resp) }; // A proxy drags its paint (onto the Swatches panel or art), unless it shows "?". @@ -930,8 +1113,8 @@ pub fn fill_stroke_proxy(ui: &mut Ui, fill: &Paint, stroke: &Paint, mixed: (bool // Swap arrow (top-right) and default (bottom-left) mini buttons. let swap_r = Rect::from_min_size(pos2(rect.right() - size * 0.3, rect.top()), Vec2::splat(size * 0.3)); let def_r = Rect::from_min_size(pos2(rect.left(), rect.bottom() - size * 0.3), Vec2::splat(size * 0.3)); - let swap = ui.interact(swap_r, ui.id().with("swap-proxy"), Sense::click()); - let def = ui.interact(def_r, ui.id().with("default-proxy"), Sense::click()); + let swap = ui.interact(swap_r, ui.scope_id().with("swap-proxy"), Sense::click()); + let def = ui.interact(def_r, ui.scope_id().with("default-proxy"), Sense::click()); icons::paint(ui, "dc-swap", swap_r.shrink(1.0), if swap.hovered() { t.text } else { t.icon }); let a = Rect::from_min_size(def_r.min + vec2(1.0, 1.0), Vec2::splat(size * 0.14)); let b = Rect::from_min_size(def_r.min + vec2(size * 0.1, size * 0.1), Vec2::splat(size * 0.14)); @@ -947,12 +1130,11 @@ pub fn fill_stroke_proxy(ui: &mut Ui, fill: &Paint, stroke: &Paint, mixed: (bool None }; let on = ui.is_enabled(); - fill_resp - .widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, on, fill_active, tl!("Fill (X), double-click for the Color Picker"))); + fill_resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, on, fill_active, tl!("Fill (X), double-click for the Color Picker"))); stroke_resp - .widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, on, !fill_active, tl!("Stroke (X), double-click for the Color Picker"))); - swap.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, on, tl!("Swap Fill and Stroke (Shift+X)"))); - def.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, on, tl!("Default Fill and Stroke (D)"))); + .widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, on, !fill_active, tl!("Stroke (X), double-click for the Color Picker"))); + swap.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, on, tl!("Swap Fill and Stroke (Shift+X)"))); + def.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, on, tl!("Default Fill and Stroke (D)"))); ProxyClicks { fill: fill_resp.on_hover_text(tl!("Fill (X), double-click for the Color Picker")).clicked(), stroke: stroke_resp.on_hover_text(tl!("Stroke (X), double-click for the Color Picker")).clicked(), @@ -980,15 +1162,25 @@ fn proxy_paint_drop(ui: &Ui, resp: &Response) -> Option { } /// A compact dropdown of interface labels (shown in the UI language). Returns the chosen index. -pub fn dropdown(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, current: &str, options: &[&str], width: f32) -> Option { - dropdown_with(ui, id, current, options, width, |_| true) +pub fn dropdown(ui: &mut Ui, id: impl std::hash::Hash, current: &str, options: &[&str], width: f32) -> Option { + dropdown_with_named(ui, id, None, current, options, width, |_| true) +} + +/// [`dropdown`] for a row whose visible label is not an adjacent label widget. +pub fn dropdown_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, current: &str, options: &[&str], width: f32) -> Option { + dropdown_with_named(ui, id, name, current, options, width, |_| true) } /// [`dropdown`] of names that are user, file or system data (font styles, artboards, presets the /// user saved, printers, profiles), shown as they are, never translated. For a list that mixes /// built-in labels with such names, translate the built-in ones (`tl!`) before passing them. -pub fn dropdown_names(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, current: &str, options: &[&str], width: f32) -> Option { - combo(ui, id, current, width, false, |ui| { +pub fn dropdown_names(ui: &mut Ui, id: impl std::hash::Hash, current: &str, options: &[&str], width: f32) -> Option { + dropdown_names_named(ui, id, None, current, options, width) +} + +/// [`dropdown_names`] for a row whose visible label is not an adjacent label widget. +pub fn dropdown_names_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, current: &str, options: &[&str], width: f32) -> Option { + combo_named(ui, id, name, current, width, false, |ui| { let mut chosen = None; for (i, o) in options.iter().enumerate() { if ui.add(egui::Button::selectable(*o == current, *o)).clicked() { @@ -1002,13 +1194,27 @@ pub fn dropdown_names(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, c /// [`dropdown`] whose options can be disabled (`enabled(index)`: greyed and not choosable). pub fn dropdown_with( ui: &mut Ui, - id: impl std::hash::Hash + std::fmt::Debug, + id: impl std::hash::Hash, current: &str, options: &[&str], width: f32, enabled: impl Fn(usize) -> bool, ) -> Option { - combo(ui, id, tl!(current), width, false, |ui| { + dropdown_with_named(ui, id, None, current, options, width, enabled) +} + +/// [`dropdown_with`] for a row whose visible label is not an adjacent label widget. +#[allow(clippy::too_many_arguments)] +pub fn dropdown_with_named( + ui: &mut Ui, + id: impl std::hash::Hash, + name: Option<&str>, + current: &str, + options: &[&str], + width: f32, + enabled: impl Fn(usize) -> bool, +) -> Option { + combo_named(ui, id, name, tl!(current), width, false, |ui| { let mut chosen = None; for (i, o) in options.iter().enumerate() { if ui.add_enabled(enabled(i), egui::Button::selectable(*o == current, tl!(o))).clicked() { @@ -1019,30 +1225,50 @@ pub fn dropdown_with( }) } +/// The id egui's [`egui::ComboBox::from_id_salt`] gives its button in this `Ui`: `from_id_salt` +/// stores `IdSalt::new(salt)` and hashes it once more with the scope, so the app must hash `id` +/// the same way to read the button's focus and open state before it is drawn. +fn combo_button_id(ui: &Ui, id: &impl std::hash::Hash) -> egui::Id { + ui.make_persistent_id(egui::IdSalt::new(HashOnly(id))) +} + /// The recessed combo box of [`dropdown`] showing `current`; `list` draws the options and returns /// the chosen one. A `searchable` list (a search field over a list it scrolls itself) stays open /// while it is clicked: it closes itself when an option is chosen. pub(crate) fn combo( ui: &mut Ui, - id: impl std::hash::Hash + std::fmt::Debug, + id: impl std::hash::Hash, + current: &str, + width: f32, + searchable: bool, + list: impl FnOnce(&mut Ui) -> Option, +) -> Option { + combo_named(ui, id, None, current, width, searchable, list) +} + +/// [`combo`] for a row whose visible label is not an adjacent label widget. +pub(crate) fn combo_named( + ui: &mut Ui, + id: impl std::hash::Hash, + name: Option<&str>, current: &str, width: f32, searchable: bool, list: impl FnOnce(&mut Ui) -> Option, ) -> Option { let t = Tokens::get(ui.ctx()); + let label = take_previous_label(ui); egui::Frame::NONE .fill(t.input) .stroke(Stroke::new(1.0, t.input_border)) .corner_radius(CornerRadius::same(3)) .show(ui, |ui| { if searchable { - return searchable_combo(ui, ui.id().with(id), current, width - 4.0, list); + return searchable_combo(ui, id_with(ui.scope_id(), id), label, name, current, width - 4.0, list); } - let id = ui.id().with(id); - let button_id = ui.make_persistent_id(egui::IdSalt::new(id)); + let button_id = combo_button_id(ui, &id); let enter_for_combo = ui.memory(|m| m.has_focus(button_id)) || egui::ComboBox::is_open(ui.ctx(), button_id); - let response = egui::ComboBox::from_id_salt(id) + let response = egui::ComboBox::from_id_salt(HashOnly(&id)) .selected_text(egui::RichText::new(current).size(12.0)) .width(width - 4.0) .height(ui.spacing().combo_height) @@ -1051,6 +1277,7 @@ pub(crate) fn combo( if enter_for_combo { ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); } + let _ = name_field(label_field(response.response.clone(), label), name); let chosen = response.inner.flatten(); // egui closes the list on a click of the pointer only: an option picked with Space or // Enter closes it here, and the combo gives the keyboard back, so that Enter reaches the @@ -1067,7 +1294,15 @@ pub(crate) fn combo( /// A dropdown whose list keeps its own search field and scrolling rows (the font menu): drawn as /// egui's combo box, but in a popup without the scroll area egui puts around a combo's list, so /// the field stays at the top and only the rows scroll (#555). A click outside closes it. -fn searchable_combo(ui: &mut Ui, id: egui::Id, current: &str, width: f32, list: impl FnOnce(&mut Ui) -> Option) -> Option { +fn searchable_combo( + ui: &mut Ui, + id: egui::Id, + label: Option, + name: Option<&str>, + current: &str, + width: f32, + list: impl FnOnce(&mut Ui) -> Option, +) -> Option { let popup = id.with("popup"); let open = egui::Popup::is_id_open(ui.ctx(), popup); let pad = ui.spacing().button_padding; @@ -1084,8 +1319,9 @@ fn searchable_combo(ui: &mut Ui, id: egui::Id, current: &str, width: f32, lis resp.widget_info(|| egui::WidgetInfo { enabled: ui.is_enabled(), current_text_value: Some(current.to_owned()), - ..egui::WidgetInfo::new(egui::WidgetType::ComboBox) + ..egui::WidgetInfo::new(egui::Role::ComboBox) }); + let resp = name_field(label_field(resp, label), name); if ui.is_rect_visible(rect) { let v = if open { ui.visuals().widgets.open } else { *ui.style().interact(&resp) }; ui.painter().rect(rect.expand(v.expansion), v.corner_radius, v.weak_bg_fill, v.bg_stroke, StrokeKind::Inside); @@ -1164,7 +1400,7 @@ pub fn popover(resp: &Response, toggle: bool, content: impl FnOnce(&mut Ui) - /// Records where the bottom bars (status and hint bars) start this frame, so menus can stop above /// them (#2086). Without bars it is the bottom of the window. pub fn set_menu_floor(ctx: &egui::Context, y: f32) { - ctx.data_mut(|d| d.insert_temp(egui::Id::new("menu_floor"), y)); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique("menu_floor"), y)); } /// The height a menu list may take: from `top` down to `floor` less a small gap, at least 120 pt. @@ -1185,7 +1421,7 @@ pub fn list_scroll_style() -> egui::style::ScrollStyle { /// small windows. Its scroll bar has a column of its own, so it never covers the shortcuts or the /// submenu arrows (#2086). pub fn menu_scroll(ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> R { - let floor = ui.ctx().data(|d| d.get_temp::(egui::Id::new("menu_floor"))).unwrap_or_else(|| ui.ctx().content_rect().bottom()); + let floor = ui.ctx().data(|d| d.get_temp::(egui::Id::unique("menu_floor"))).unwrap_or_else(|| ui.ctx().content_rect().bottom()); let room = menu_room(floor, ui.next_widget_position().y); ui.scope(|ui| { ui.spacing_mut().scroll = list_scroll_style(); @@ -1239,7 +1475,7 @@ pub fn blend_separator_before(i: usize) -> bool { /// The blend-mode dropdown, its groups separated (Normal | darken | lighten | contrast | inversion /// | component modes). `None` (objects that differ) shows blank. Returns the chosen mode. -pub fn blend_dropdown(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, current: Option, width: f32) -> Option { +pub fn blend_dropdown(ui: &mut Ui, id: impl std::hash::Hash, current: Option, width: f32) -> Option { combo(ui, id, current.map_or("", |m| tl!(m.label())), width, false, |ui| { let mut chosen = None; for (i, m) in BlendMode::ALL.into_iter().enumerate() { @@ -1261,7 +1497,7 @@ pub fn blend_param(key: &str, value: &serde_json::Value) -> Option { } /// [`blend_dropdown`] for a blend-mode parameter; returns the chosen mode's parameter value. -pub fn blend_param_dropdown(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, current: BlendMode) -> Option { +pub fn blend_param_dropdown(ui: &mut Ui, id: impl std::hash::Hash, current: BlendMode) -> Option { blend_dropdown(ui, id, Some(current), 120.0).map(|m| serde_json::Value::String(m.label().to_ascii_lowercase())) } @@ -1278,7 +1514,7 @@ pub enum TransparencyEdit { /// `opacity` (0..1) and `blend` are `None` where the objects differ (shown blank). pub fn opacity_blend( ui: &mut Ui, - id: impl std::hash::Hash + std::fmt::Debug + Copy, + id: impl std::hash::Hash + Copy, opacity: Option, blend: Option, enabled: bool, @@ -1301,7 +1537,7 @@ pub fn opacity_blend( let (r, resp) = ui.allocate_exact_size(vec2(18.0, 26.0), if enabled { Sense::click() } else { Sense::hover() }); ui.painter().rect_stroke(r, 2, Stroke::new(1.0, t.input_border), StrokeKind::Inside); icons::paint(ui, "chevron-right", r.shrink2(vec2(3.0, 7.0)), if enabled { t.icon } else { t.text_disabled }); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled && ui.is_enabled(), tl!("Opacity"))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled && ui.is_enabled(), tl!("Opacity"))); egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { let mut o = opacity.unwrap_or(1.0) * 100.0; let r = slider(ui, egui::Slider::new(&mut o, 0.0..=100.0).show_value(false)); @@ -1346,8 +1582,8 @@ pub fn reference_point(ui: &mut Ui, current: usize) -> Option { ]; for (i, name) in names.into_iter().enumerate() { let r = Rect::from_center_size(centre(i), Vec2::splat(sq)); - let resp = ui.interact(r.expand(2.0), ui.id().with(("refpt", i)), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, ui.is_enabled(), i == current, name)); + let resp = ui.interact(r.expand(2.0), ui.scope_id().with(("refpt", i)), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, ui.is_enabled(), i == current, name)); if resp.clicked() { out = Some(i); } @@ -1364,10 +1600,10 @@ pub fn reference_point(ui: &mut Ui, current: usize) -> Option { /// An angle dial `size` points across: a circle with a hand from its centre at `angle` (degrees, /// counter-clockwise from 3 o'clock). Pressing or dragging points the hand at the pointer, in whole /// degrees (-179..180), Shift in 45° steps. Returns the new angle. -pub fn angle_dial(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, angle: f64, size: f32) -> Option { +pub fn angle_dial(ui: &mut Ui, id: impl std::hash::Hash, angle: f64, size: f32) -> Option { let t = Tokens::get(ui.ctx()); let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); - let resp = ui.interact(rect, ui.id().with(id), Sense::click_and_drag()); + let resp = ui.interact(rect, id_with(ui.scope_id(), id), Sense::click_and_drag()); let (c, r) = (rect.center(), size / 2.0 - 1.0); let ring = if resp.hovered() || resp.dragged() { t.text } else { t.input_border }; ui.painter().circle(c, r, t.input, Stroke::new(1.0, ring)); @@ -1396,7 +1632,7 @@ pub fn toggle_icon(ui: &mut Ui, on_icon: &str, on: bool, size: f32, tip: &str) - } else if resp.hovered() { icons::paint(ui, on_icon, rect.shrink(size * 0.2), t.text_disabled); } - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), on, tl!(tip))); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), on, tl!(tip))); resp.on_hover_text(tl!(tip)).clicked() } @@ -1418,7 +1654,7 @@ pub fn icon_button_enabled(ui: &mut Ui, icon: &str, tip: &str, selected: bool, e /// What screen readers get for an icon button: a button called `name` (already translated), /// reported as pressed while `selected` and with no pressed state otherwise. pub(crate) fn icon_button_info(enabled: bool, selected: bool, name: &str) -> egui::WidgetInfo { - egui::WidgetInfo { selected: selected.then_some(true), ..egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, name) } + egui::WidgetInfo { selected: selected.then_some(true), ..egui::WidgetInfo::labeled(egui::Role::Button, enabled, name) } } /// A slider whose rail shows, filled up to the handle: the theme's widget fill matches the panel @@ -1454,7 +1690,7 @@ pub fn link_label(ui: &mut Ui, text: &str) -> Response { ui.painter().line_segment([pos2(x, y), pos2((x + 1.0).min(rect.right()), y)], Stroke::new(1.0, t.text_dim)); x += 2.5; } - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!(text))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(text))); resp } @@ -1532,8 +1768,8 @@ fn check_row_named(ui: &mut Ui, text: &str, name: &str, value: Option, ena Some(false) => {} } resp.widget_info(|| match value { - Some(on) => egui::WidgetInfo::selected(egui::WidgetType::Checkbox, enabled && ui.is_enabled(), on, name), - None => egui::WidgetInfo::labeled(egui::WidgetType::Checkbox, enabled && ui.is_enabled(), name), + Some(on) => egui::WidgetInfo::selected(egui::Role::CheckBox, enabled && ui.is_enabled(), on, name), + None => egui::WidgetInfo::labeled(egui::Role::CheckBox, enabled && ui.is_enabled(), name), }); (enabled && resp.clicked(), resp) } @@ -1558,7 +1794,7 @@ fn radio_row(ui: &mut Ui, text: &str, name: &str, selected: bool, enabled: bool) if selected { ui.painter().circle_filled(c, 2.5, if enabled { Color32::WHITE } else { t.text_disabled }); } - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, enabled && ui.is_enabled(), selected, name)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, enabled && ui.is_enabled(), selected, name)); enabled && resp.clicked() } @@ -1571,7 +1807,7 @@ fn radio_row(ui: &mut Ui, text: &str, name: &str, selected: bool, enabled: bool) #[allow(clippy::too_many_arguments)] pub fn spin_field( ui: &mut Ui, - id: impl std::hash::Hash + std::fmt::Debug + Copy, + id: impl std::hash::Hash + Copy, value: Option, unit: Unit, width: f32, @@ -1579,7 +1815,23 @@ pub fn spin_field( min: f64, presets: &[f64], ) -> Option { - spin_field_auto(ui, id, value, None, unit, width, step, min, presets).and_then(SpinPick::value) + spin_field_named(ui, id, None, value, unit, width, step, min, presets) +} + +/// [`spin_field`] for a row named explicitly instead of by an adjacent label widget. +#[allow(clippy::too_many_arguments)] +pub fn spin_field_named( + ui: &mut Ui, + id: impl std::hash::Hash + Copy, + name: Option<&str>, + value: Option, + unit: Unit, + width: f32, + step: f64, + min: f64, + presets: &[f64], +) -> Option { + spin_field_auto_named(ui, id, name, value, None, unit, width, step, min, presets).and_then(SpinPick::value) } /// What a [`spin_field_auto`] returns: a value (points), or its Auto entry. @@ -1604,7 +1856,7 @@ impl SpinPick { #[allow(clippy::too_many_arguments)] pub fn spin_field_auto( ui: &mut Ui, - id: impl std::hash::Hash + std::fmt::Debug + Copy, + id: impl std::hash::Hash + Copy, value: Option, auto: Option, unit: Unit, @@ -1613,8 +1865,26 @@ pub fn spin_field_auto( min: f64, presets: &[f64], ) -> Option { - spin_generic(ui, value, auto, width, step, min, presets, &|v| unit.format(v), &mut |ui, fw| { - unit_field(ui, id, value, unit, fw, false, min..=f64::INFINITY) + spin_field_auto_named(ui, id, None, value, auto, unit, width, step, min, presets) +} + +/// [`spin_field_auto`] for a row named explicitly instead of by an adjacent label widget. +#[allow(clippy::too_many_arguments)] +pub fn spin_field_auto_named( + ui: &mut Ui, + id: impl std::hash::Hash + Copy, + name: Option<&str>, + value: Option, + auto: Option, + unit: Unit, + width: f32, + step: f64, + min: f64, + presets: &[f64], +) -> Option { + let label = take_previous_label(ui); + spin_generic(ui, label, value, auto, width, step, min, presets, &|v| unit.format(v), &mut |ui, fw, label| { + unit_field(ui, id, label, name, value, unit, fw, false, min..=f64::INFINITY) }) } @@ -1623,7 +1893,7 @@ pub fn spin_field_auto( #[allow(clippy::too_many_arguments)] pub fn spin_plain( ui: &mut Ui, - id: impl std::hash::Hash + std::fmt::Debug + Copy, + id: impl std::hash::Hash + Copy, value: impl Into>, suffix: &str, decimals: usize, @@ -1632,17 +1902,38 @@ pub fn spin_plain( min: f64, presets: &[f64], ) -> Option { + spin_plain_named(ui, id, None, value, suffix, decimals, width, step, min, presets) +} + +/// [`spin_plain`] for a row named explicitly instead of by an adjacent label widget. +#[allow(clippy::too_many_arguments)] +pub fn spin_plain_named( + ui: &mut Ui, + id: impl std::hash::Hash + Copy, + name: Option<&str>, + value: impl Into>, + suffix: &str, + decimals: usize, + width: f32, + step: f64, + min: f64, + presets: &[f64], +) -> Option { + let label = take_previous_label(ui); let value = value.into(); let fmt = |v: f64| format!("{v}{suffix}"); - spin_generic(ui, value, None, width, step, min, presets, &fmt, &mut |ui, fw| { - bounded_field(ui, id, value, suffix, decimals, fw, &(min..=f64::INFINITY)) + spin_generic(ui, label, value, None, width, step, min, presets, &fmt, &mut |ui, fw, label| { + bounded_field(ui, id, label, name, value, suffix, decimals, fw, &(min..=f64::INFINITY)) }) .and_then(SpinPick::value) } +type SpinField<'a> = dyn FnMut(&mut Ui, f32, Option) -> Option + 'a; + #[allow(clippy::too_many_arguments)] fn spin_generic( ui: &mut Ui, + label: Option, value: Option, auto: Option, width: f32, @@ -1650,7 +1941,7 @@ fn spin_generic( min: f64, presets: &[f64], fmt: &dyn Fn(f64) -> String, - field: &mut dyn FnMut(&mut Ui, f32) -> Option, + field: &mut SpinField<'_>, ) -> Option { let t = Tokens::get(ui.ctx()); let mut out = None; @@ -1678,7 +1969,7 @@ fn spin_generic( ui.painter().line_segment([m + vec2(-3.0, -d), m + vec2(0.0, d)], Stroke::new(1.2, c)); ui.painter().line_segment([m + vec2(0.0, d), m + vec2(3.0, -d)], Stroke::new(1.2, c)); } - sresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, tl!("Step up or down"))); + sresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, tl!("Step up or down"))); // A blank field (values that differ) has nothing to step from. if sresp.clicked() && let Some(p) = sresp.interact_pointer_pos() @@ -1688,7 +1979,7 @@ fn spin_generic( out = Some(SpinPick::Value(spinner_step(v, step, min, up.contains(p), modifiers))); } let fw = if presets.is_empty() { width - 15.0 } else { width - 34.0 }; - if let Some(v) = field(ui, fw) { + if let Some(v) = field(ui, fw, label) { out = Some(SpinPick::Value(v.max(min))); } if !presets.is_empty() { @@ -1730,13 +2021,7 @@ pub enum Live { /// A colour slider with a gradient track (`track(t)` gives the colour at 0..1) and a triangular /// thumb below it, like Illustrator's Color panel. Returns the new normalized value and the phase. -pub fn color_slider( - ui: &mut Ui, - id: impl std::hash::Hash + std::fmt::Debug, - value: f32, - width: f32, - track: &dyn Fn(f32) -> Color32, -) -> (Option, Live) { +pub fn color_slider(ui: &mut Ui, id: impl std::hash::Hash, value: f32, width: f32, track: &dyn Fn(f32) -> Color32) -> (Option, Live) { color_slider_named(ui, id, "", value, width, track) } @@ -1744,7 +2029,7 @@ pub fn color_slider( /// value (none when `name` is empty). pub fn color_slider_named( ui: &mut Ui, - id: impl std::hash::Hash + std::fmt::Debug, + id: impl std::hash::Hash, name: &str, value: f32, width: f32, @@ -1792,7 +2077,7 @@ fn slider_info(ui: &Ui, resp: &Response, name: &str, shown: Option) } let enabled = ui.is_enabled(); let Some(s) = shown else { - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Slider, enabled, name)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Slider, enabled, name)); return; }; resp.widget_info(|| egui::WidgetInfo { current_text_value: s.text.clone(), ..egui::WidgetInfo::slider(enabled, s.value, name) }); @@ -1806,14 +2091,14 @@ fn slider_info(ui: &Ui, resp: &Response, name: &str, shown: Option) /// `None`, as for a field left blank for a mixed value, it reports no value. pub fn color_slider_valued( ui: &mut Ui, - id: impl std::hash::Hash + std::fmt::Debug, + id: impl std::hash::Hash, name: &str, shown: Option, value: f32, width: f32, track: &dyn Fn(f32) -> Color32, ) -> (Option, Live) { - let id = ui.id().with(id); + let id = id_with(ui.scope_id(), id); let (_, picked, phase) = color_rail(ui, id, name, shown, value, width, track); (picked, phase) } @@ -1830,14 +2115,14 @@ pub fn color_slider_valued( /// an action gives. pub fn color_slider_stepped( ui: &mut Ui, - id: impl std::hash::Hash + std::fmt::Debug, + id: impl std::hash::Hash, name: &str, shown: Option, value: f32, width: f32, track: &dyn Fn(f32) -> Color32, ) -> (Option, Live, Option) { - let id = ui.id().with(id); + let id = id_with(ui.scope_id(), id); let (resp, picked, phase) = color_rail(ui, id, name, shown.clone(), value, width, track); if resp.drag_started() || resp.clicked() { resp.request_focus(); @@ -1971,14 +2256,14 @@ fn gradient_strip(ui: &Ui, rect: Rect, n: usize, vertical: bool, track: &dyn Fn( /// down) and a ring marks `pos`. Returns the clicked or dragged point and the phase. pub fn color_field( ui: &mut Ui, - id: impl std::hash::Hash + std::fmt::Debug, + id: impl std::hash::Hash, size: Vec2, pos: (f32, f32), color_at: &dyn Fn(f32, f32) -> Color32, ) -> (Option<(f32, f32)>, Live) { let t = Tokens::get(ui.ctx()); let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); - let resp = ui.interact(rect, ui.id().with(id), Sense::click_and_drag()); + let resp = ui.interact(rect, id_with(ui.scope_id(), id), Sense::click_and_drag()); let painter = ui.painter_at(rect.expand(6.0)); painter.add(color_mesh(rect, (32, 32), color_at)); painter.rect_stroke(rect, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); @@ -2019,13 +2304,7 @@ pub fn color_mesh(rect: Rect, (cols, rows): (u32, u32), color_at: &dyn Fn(f32, f /// The Color Picker's vertical channel slider: `track(t)` gives the colour at 0..1 (bottom to top) /// and arrows on both sides mark `value`. Returns the new value and the phase. -pub fn channel_slider( - ui: &mut Ui, - id: impl std::hash::Hash + std::fmt::Debug, - value: f32, - size: Vec2, - track: &dyn Fn(f32) -> Color32, -) -> (Option, Live) { +pub fn channel_slider(ui: &mut Ui, id: impl std::hash::Hash, value: f32, size: Vec2, track: &dyn Fn(f32) -> Color32) -> (Option, Live) { channel_slider_named(ui, id, "", value, size, track) } @@ -2033,7 +2312,7 @@ pub fn channel_slider( /// no value, as for [`color_slider_named`] (none when `name` is empty). pub fn channel_slider_named( ui: &mut Ui, - id: impl std::hash::Hash + std::fmt::Debug, + id: impl std::hash::Hash, name: &str, value: f32, size: Vec2, @@ -2046,7 +2325,7 @@ pub fn channel_slider_named( /// [`color_slider_valued`]. pub fn channel_slider_valued( ui: &mut Ui, - id: impl std::hash::Hash + std::fmt::Debug, + id: impl std::hash::Hash, name: &str, shown: Option, value: f32, @@ -2055,7 +2334,7 @@ pub fn channel_slider_valued( ) -> (Option, Live) { let t = Tokens::get(ui.ctx()); let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); - let resp = ui.interact(rect, ui.id().with(id), Sense::click_and_drag()); + let resp = ui.interact(rect, id_with(ui.scope_id(), id), Sense::click_and_drag()); let bar = rect.shrink2(vec2(6.0, 0.0)); gradient_strip(ui, bar, (bar.height() / 2.0).ceil().max(2.0) as usize, true, track); ui.painter().rect_stroke(bar, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside); @@ -2075,10 +2354,16 @@ pub fn channel_slider_valued( /// A hex colour field (`E67828`, no `#`) after a `#` label. Returns the text on commit when it /// was typed into the field and differs from `hex`. Enter, Shift+Enter (which keeps the focus /// outside dialogs) and focus loss commit it. -pub fn hex_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, hex: &str) -> Option { +pub fn hex_field(ui: &mut Ui, id: impl std::hash::Hash, hex: &str) -> Option { + hex_field_named(ui, id, None, hex) +} + +/// [`hex_field`] for a colour field named explicitly instead of by an adjacent label widget. +pub fn hex_field_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, hex: &str) -> Option { let t = Tokens::get(ui.ctx()); + let label = take_previous_label(ui); ui.label(egui::RichText::new("#").size(15.0).color(t.text)); - let id = ui.id().with(id); + let id = id_with(ui.scope_id(), id); let editing = ui.memory(|m| m.has_focus(id)); // Until the user types into it, the field shows `hex` as it is now, also while it has the // focus. Tab can give it the focus in the frame another field commits an edit, before the edit @@ -2097,7 +2382,9 @@ pub fn hex_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, hex: & .corner_radius(5) .inner_margin(egui::Margin::symmetric(6, 4)) .show(ui, |ui| { - // As tall as the other fields (26 points with the margin), whatever the font's line height. + // As tall as the other fields (26 points with the margin), whatever the font's line + // height: egui 0.37 gives a TextEdit the theme's interact height, so lower it here too. + ui.spacing_mut().interact_size.y = 18.0; ui.set_height(18.0); ui.add( egui::TextEdit::singleline(&mut buf) @@ -2110,6 +2397,7 @@ pub fn hex_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, hex: & ) }) .inner; + let resp = name_field(label_field(resp, label), name); let commit = (resp.lost_focus() || kept) && buf != hex; let focused = ui.memory(|m| m.has_focus(id)); ui.data_mut(|d| { @@ -2189,9 +2477,16 @@ pub fn menu_item_name(ui: &mut Ui, label: &str, enabled: bool, checked: bool) -> /// focus loss, as in [`num_field`]), where `new` is `None` when the field was cleared; typed units /// and arithmetic work as in [`num_field`]. The value is a length: ↑/↓, the wheel and a scrub stop /// at 0. -pub fn opt_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: Option, unit: Unit, width: f32) -> Option> { +pub fn opt_field(ui: &mut Ui, id: impl std::hash::Hash, value: Option, unit: Unit, width: f32) -> Option> { + opt_field_named(ui, id, None, value, unit, width) +} + +/// [`opt_field`] for a field named explicitly instead of by an adjacent label widget (the Stroke +/// panel's dash and gap cells have their labels in a row below them). +pub fn opt_field_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, value: Option, unit: Unit, width: f32) -> Option> { let t = Tokens::get(ui.ctx()); - let id = ui.id().with(id); + let label = take_previous_label(ui); + let id = id_with(ui.scope_id(), id); let shown = value.map(|v| unit.number(v)).unwrap_or_default(); let editing = ui.memory(|m| m.has_focus(id)); // As in `hex_field`: until the user types into it, the field shows `shown` as it is now @@ -2223,6 +2518,7 @@ pub fn opt_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: ) }); let resp = framed.inner; + let resp = name_field(label_field(resp, label), name); select_all_on_focus(ui, &resp, &buf); // ↑/↓ and the wheel step a value (an empty field stays empty); a drag on its label scrubs it. let lengths = 0.0..=f64::INFINITY; @@ -2432,7 +2728,7 @@ pub fn harmony_wheel(ui: &mut Ui, id: &str, size: f32, colors: &[Color], base: O painter.circle_stroke(p, if big { 9.0 } else { 6.5 }, Stroke::new(1.0, Color32::BLACK)); } // Drag a marker: the nearest one within reach of the press. - let drag = ui.id().with((id, "drag")); + let drag = ui.scope_id().with((id, "drag")); if (resp.drag_started() || resp.clicked()) && let Some(p) = resp.interact_pointer_pos() { @@ -2448,7 +2744,7 @@ pub fn harmony_wheel(ui: &mut Ui, id: &str, size: f32, colors: &[Color], base: O let h = (-dv.y).atan2(dv.x).to_degrees().rem_euclid(360.0); out.moved = Some((i, [Some(h), Some((dv.length() / r).clamp(0.0, 1.0)), None])); } - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Slider, ui.is_enabled(), tl!("Color wheel"))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Slider, ui.is_enabled(), tl!("Color wheel"))); }); ui.add_space(6.0); ui.horizontal(|ui| { @@ -2488,7 +2784,7 @@ pub fn tab_bar(ui: &mut Ui, tabs: &[&str], current: usize) -> Option { t.accent, ); } - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, ui.is_enabled(), sel, tl!(tab))); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), sel, tl!(tab))); if resp.clicked() { clicked = Some(i); } @@ -2515,7 +2811,7 @@ pub fn orientation_button(ui: &mut Ui, landscape: bool, selected: bool, tip: &st ui.painter().add(egui::Shape::convex_polygon(outline.clone(), fill, Stroke::new(1.2, color))); let corner = [page.right_top() - vec2(fold, 0.0), page.right_top() + vec2(-fold, fold), page.right_top() + vec2(0.0, fold)]; ui.painter().add(egui::Shape::line(corner.to_vec(), Stroke::new(1.2, if selected { t.panel } else { color }))); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), selected, tl!(tip))); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), selected, tl!(tip))); resp.on_hover_text(tl!(tip)).clicked() } @@ -2561,7 +2857,7 @@ pub fn layout_button(ui: &mut Ui, tiles: &[(u8, u8)], selected: bool, tip: &str) ui.painter().add(egui::Shape::line(path, Stroke::new(1.2, color))); ui.painter().add(egui::Shape::convex_polygon(head, color, Stroke::NONE)); } - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, enabled, selected, tip)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, enabled, selected, tip)); resp.on_hover_text(tip).clicked() } @@ -2595,18 +2891,23 @@ fn chevron_cell(ui: &mut Ui, h: f32) -> Response { t.icon }; icons::paint(ui, "chevron-down", Rect::from_center_size(dr.center(), Vec2::splat(12.0)), c); - dresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Presets"))); + dresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Presets"))); dresp } /// A [`text_field`] with a preset list on its right (Export for Screens' sizes: `2x`, `100w`…). /// Returns the new text when a change is committed (Enter or focus loss) or a preset is picked. -pub fn text_presets(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: &str, presets: &[&str], width: f32) -> Option { +pub fn text_presets(ui: &mut Ui, id: impl std::hash::Hash, value: &str, presets: &[&str], width: f32) -> Option { + text_presets_named(ui, id, None, value, presets, width) +} + +/// [`text_presets`] for a row whose heading names it instead of an adjacent label widget. +pub fn text_presets_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&str>, value: &str, presets: &[&str], width: f32) -> Option { let mut out = None; ui.scope(|ui| { // The field and the chevron share a border. ui.spacing_mut().item_spacing.x = -1.0; - out = text_field(ui, &id, Some(value), width - 19.0, 1); + out = text_field_named(ui, &id, name, Some(value), width - 19.0, 1); let resp = chevron_cell(ui, 26.0); egui::Popup::menu(&resp).show(|ui| { ui.set_min_width(width - 10.0); @@ -3063,6 +3364,9 @@ pub(crate) mod look_tests { for s in &shapes { if let egui::Shape::Rect(r) = s && r.rect.height() > 20.0 + // egui 0.37's TextEdit paints its `Frame::NONE` as a transparent rect too: that is + // not a box of the row. + && r.fill != Color32::TRANSPARENT && !boxes.contains(&r.rect) { boxes.push(r.rect); @@ -3103,7 +3407,7 @@ pub(crate) mod look_tests { let t = Tokens::get(&ctx); let id = std::cell::Cell::new(egui::Id::NULL); let mut add = |ui: &mut Ui| { - id.set(ui.id().with("w")); + id.set(ui.scope_id().with("w")); num_field(ui, "w", Some(1234.0), Unit::Points, 80.0); }; frame(&ctx, 0.0, vec![], &mut add); @@ -3179,6 +3483,99 @@ pub(crate) mod look_tests { assert!(lit(&hovered, row), "the row under the pointer is lit: {row:?}"); } + #[test] + fn a_label_rows_input_is_named_by_the_row_label() { + use egui_kittest::kittest::Queryable; + crate::i18n::set_current(crate::i18n::Lang::EN); + let mut h = egui_kittest::Harness::builder().with_size(vec2(400.0, 200.0)).build_ui(|ui| { + label_row(ui, "Opacity", 70.0, |ui| { + let _ = spin_field(ui, "opacity", Some(50.0), Unit::Points, 80.0, 1.0, 0.0, &[]); + }); + label_row(ui, "Angle", 70.0, |ui| { + let _ = range_field(ui, "plugin-angle", 30.0, 0.0..=360.0, "°", 1, 80.0); + }); + }); + h.run_steps(2); + // The painted row label is the value field's exact accessible name: not its internal id. + let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Opacity"); + let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Angle"); + assert!(h.query_by_role_and_label(egui::accesskit::Role::TextInput, "opacity").is_none(), "no internal widget id"); + assert!(h.query_by_role_and_label(egui::accesskit::Role::TextInput, "plugin-angle").is_none(), "no internal widget id"); + } + + #[test] + fn a_label_does_not_name_a_field_after_another_widget() { + use egui_kittest::kittest::Queryable; + crate::i18n::set_current(crate::i18n::Lang::EN); + let mut h = egui_kittest::Harness::builder().with_size(vec2(400.0, 200.0)).with_accessibility_check(false).build_ui(|ui| { + field_label(ui, "Opacity"); + let _ = check(ui, "Preview", true, true); + let _ = spin_field(ui, "unrelated", Some(50.0), Unit::Points, 80.0, 1.0, 0.0, &[]); + }); + h.run_steps(2); + assert!( + h.query_by_role_and_label(egui::accesskit::Role::TextInput, "Opacity").is_none(), + "the label belongs to the next widget, not a later field" + ); + assert!(h.query_by_role_and_label(egui::accesskit::Role::TextInput, "unrelated").is_none(), "the field is not named by its id"); + } + + /// A grid's label is in the cell before the field: egui moves the cursor to the next cell + /// without allocating a widget, so the label still names that field exactly. + #[test] + fn a_grid_fields_adjacent_label_names_it_exactly() { + use egui_kittest::kittest::Queryable; + crate::i18n::set_current(crate::i18n::Lang::EN); + let mut h = egui_kittest::Harness::builder().with_size(vec2(400.0, 200.0)).build_ui(|ui| { + egui::Grid::new("field-name-grid").num_columns(2).show(ui, |ui| { + dim_label(ui, "X:"); + let _ = num_field(ui, "xfp-x", Some(12.0), Unit::Points, 80.0); + ui.end_row(); + }); + }); + h.run_steps(2); + let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "X:"); + assert!(h.query_by_role_and_label(egui::accesskit::Role::TextInput, "xfp-x").is_none(), "no internal widget id"); + } + + /// A row whose visible names are painted below its fields (Stroke's dash/gap cells) passes + /// the exact translated name, never a Debug-formatted widget id. + #[test] + fn an_explicit_field_name_is_exact_and_has_no_widget_id() { + use egui_kittest::kittest::Queryable; + crate::i18n::set_current(crate::i18n::Lang::EN); + let mut h = egui_kittest::Harness::builder().with_size(vec2(400.0, 200.0)).build_ui(|ui| { + let _ = opt_field_named(ui, ("dash", 0), Some("dash 1"), Some(4.0), Unit::Points, 60.0); + let _ = num_field_named(ui, "xfp-x", Some("X:"), Some(12.0), Unit::Points, 80.0); + }); + h.run_steps(2); + let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "dash 1"); + let _ = h.get_by_role_and_label(egui::accesskit::Role::TextInput, "X:"); + for leaked in ["dash 0", "xfp-x", "(\"dash\", 0)"] { + assert!( + h.query_by_role_and_label(egui::accesskit::Role::TextInput, leaked).is_none(), + "the internal widget id {leaked:?} is not an accessible name" + ); + } + } + + #[test] + fn a_combos_keyboard_guard_reads_the_combos_own_button_id() { + crate::i18n::set_current(crate::i18n::Lang::EN); + let same = std::rc::Rc::new(std::cell::Cell::new(false)); + let seen = same.clone(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(400.0, 200.0)).with_accessibility_check(false).build_ui(move |ui| { + // The id the Enter/focus guard reconstructs before drawing, and the id egui's + // `ComboBox::from_id_salt` gives the button: they must be equal, or the guard + // reads and gives back the keyboard of nothing. + let expected = combo_button_id(ui, &"combo-guard-test"); + let response = egui::ComboBox::from_id_salt("combo-guard-test").selected_text("One").show_ui(ui, |_| ()); + seen.set(expected == response.response.id); + }); + h.run_steps(2); + assert!(same.get(), "the guard's id is the ComboBox button's id"); + } + // Regression test for #3440: the Fill/Stroke chip's popover closed on the click that ended a // drag between its proxies. It stays open for a click on its own anchor and while a panel // drag is on; a click elsewhere still closes it. diff --git a/crates/ui-egui/src/workspaces.rs b/crates/ui-egui/src/workspaces.rs index 46780eab2..882d1ee16 100644 --- a/crates/ui-egui/src/workspaces.rs +++ b/crates/ui-egui/src/workspaces.rs @@ -433,7 +433,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let (mut ok, mut cancel) = (false, false); let mut action: Option<(&str, Value)> = None; let manage = d.kind == "manageWorkspaces"; - crate::dialogs::modal::show(ctx, "Workspace", egui::Id::new("dialog-workspace"), -40.0, 22, |ui| { + crate::dialogs::modal::show(ctx, "Workspace", egui::Id::unique("dialog-workspace"), -40.0, 22, |ui| { ui.set_width(340.0); crate::dialogs::modal::heading(ui, if manage { tl!("Manage Workspaces") } else { tl!("New Workspace") }); ui.add_space(12.0); diff --git a/crates/ui-egui/tests/install_fonts.rs b/crates/ui-egui/tests/install_fonts.rs index a209b80ca..d7ebcaab2 100644 --- a/crates/ui-egui/tests/install_fonts.rs +++ b/crates/ui-egui/tests/install_fonts.rs @@ -55,6 +55,9 @@ fn every_family_the_panels_draw_in_is_bound() { assert!(installed.is_empty(), "{installed:?}"); let egui = egui::FontDefinitions::default(); let proportional = &egui.families[&FontFamily::Proportional]; + // egui 0.37 only bundles its faces with its `default_fonts` feature; this crate asks for + // it, since a dependency's dev-features do not reach us and an empty family lays out tofu. + assert!(!proportional.is_empty(), "egui's default fonts are not compiled in"); assert!(named(theme::FONT_UI).starts_with(proportional)); assert!(named(theme::FONT_UI_SEMIBOLD).starts_with(proportional)); assert!(named(theme::FONT_MONO).starts_with(&egui.families[&FontFamily::Monospace])); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index bd38f27bb..b5630bc69 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -1,6 +1,6 @@ # Control protocol -> **Last updated:** 2026-10-23 · **Change:** symbol library panels (`window.symbolLibrary`, `window.symbolLibrary.other`, `ui.saveSymbolLibrary`); previously: synthetic input budgets (#1709); bounded transport waits (#1726). +> **Last updated:** 2026-10-24 · **Change:** `ui.screenshot`'s 64 MiB inline-PNG cap and its path-plus-data downgrade; previously: symbol library panels (`window.symbolLibrary`, `window.symbolLibrary.other`, `ui.saveSymbolLibrary`); synthetic input budgets (#1709); bounded transport waits (#1726). `vectorcraft --control ` listens on `127.0.0.1:` (loopback only). The port must be a number from 1 to 65535 (`--control=` works too), given once; otherwise the app prints an error and exits @@ -103,7 +103,7 @@ the two can still win that race. | `ui.wheel` | `{x, y, dy?, dx?, unit?: "line"\|"point", shift?, alt?, cmd?}` | a mouse wheel turn over screen point (x, y): `dy` notches up (+) or down, `dx` sideways. Over the canvas the wheel scrolls and Cmd- or Alt-wheel (Option on the Mac) zooms about the pointer; with the `zoomWithMouseWheel` preference the wheel and Alt-wheel zoom about the pointer, Shift-wheel scrolls up and down and Cmd-wheel (Ctrl on Windows and Linux) sideways. Over a focused numeric field (click it first) each notch steps its value as Up/Down do (Shift: ten, Cmd/Ctrl: a tenth) and the panel stays put; over anything else in a panel the wheel scrolls it | | `ui.set` | `{brightness?, panel?, rulers?, outline?, grid?, smartGuides?, boundingBox?, controlBar?, imageTracePreview?}` | `imageTracePreview`: the Image Trace panel's Preview (`ui.inspect` shows it as `ui.image_trace_preview`) | | `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value}` | fill and submit the open dialog | -| `ui.screenshot` | `{path?, data?}` | capture the window (PNG), written to `path`; `data: true` sends it back as `pngBase64` too. Needs a presented frame: with the screen locked or the window minimized/covered it fails after ~8 s with an explanatory error | +| `ui.screenshot` | `{path?, data?}` | capture the window (PNG), written to `path`; `data: true` sends it back as `pngBase64` too. The inline PNG is capped at 64 MiB: with a `path` the file is still written and `pngBase64` is omitted when the image is larger, while a path-less `data: true` request fails if either the raw RGBA image or the encoded PNG exceeds the cap. Needs a presented frame: with the screen locked or the window minimized/covered it fails after ~8 s with an explanatory error | | `ui.render` | `{path?, scale?, artboard?, data?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | | `ui.resize` / `ui.focus` | | | | `app.open` / `app.save` / `app.export` / `app.quit` | `{path}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit | @@ -928,6 +928,7 @@ hidden tab (whose frames the browser pauses) answers when it is shown again. | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Documented `ui.screenshot`'s 64 MiB inline-PNG reply cap: with `path` the file is still written and `pngBase64` is omitted when over the cap; a path-less `data: true` request fails before encoding. | | 2026-10-23 | minor | Documented the symbol library panel commands (`window.symbolLibrary`, `.other`, `ui.saveSymbolLibrary`, `window.userSymbolLibrary1`…`10`). | | 2026-10-13 | minor | Documented count, pending-event and per-frame request budgets (#1709). | | 2026-10-13 | minor | Documented transport deadlines and connection-slot recovery (#1726). | diff --git a/docs/egui-037-migration-plan.md b/docs/egui-037-migration-plan.md new file mode 100644 index 000000000..fee1c1621 --- /dev/null +++ b/docs/egui-037-migration-plan.md @@ -0,0 +1,1517 @@ +# VectorCraft — egui 0.37 migration plan + +**Status (2026-10-25):** reconciled main `8bf090b25389b48aa6f9d7415de57c9ce7ccf8cb` and passed all 12 mandatory CI stages. Managed job `egui037-vectorcraft-artboard-green-ci-20261025-0589` recorded 10,197 passing test executions, zero failures and 14 ignored. This includes the artboard-name geometry regression, demonstrated failing before the explicit inset repair; input/IME fixtures, no-bundled-font checks and the web app build passed. Native/platform GUI and IME workflows, final packages and paired app performance remain open. Earlier source identities and counts below are historical. + +**Earlier status (historical):** implementation revision 4, 2026-10-24. The VectorCraft migration is implemented in the +`vectorcraft-implementation` worktree; this revision incorporates the final upstream-review repairs. The exact +current tree still needs a fresh managed build/test/lint run before publication; earlier managed evidence +(`tests-12`, `lints-13`) is not claimed for these later edits. Every claim below remains source-grounded in the +checkouts named here; historical compiler diagnostics and reviewer findings are clues, not acceptance evidence. + +**Assigned source:** `/home/codex/work/CraftApps/egui037-migration-20261022/vectorcraft-implementation` — the +implementation worktree. The earlier pinned review checkout was `vectorcraft` at +`8906073438a2f7f0d3634d460b900956134f65e6` (2026-10-23 11:50:04 +0700). +**Worktree:** implementation worktree in place; earlier plan worktree `codex/egui-037-plan-20261022`. +**Pinned review baselines:** revision 2 was +`d8c6a7594015919751d2b83bbe0e44ee9fdb4696b4e5c90fb1f8ecbdf7335f83` (107,991 bytes), reviewed in +`plan-reviews/vectorcraft-v2.md`; revision 1 was +`70938f132709da9073910631e90364738e8df3dfc0d7b354920ba7910fc01fbe`, reviewed in +`plan-reviews/vectorcraft-v1.md`. Both reviews are under +`/home/codex/work/ssd2/task-evidence/egui037-migration-20261022/`. +**Remote drift snapshot:** `storytold/vectorcraft` main advanced from `8906073…` to +`4a7cba0dd32e4ac249e4dc50af486b89531481aa` in two commits touching only +`crates/cad/src/import/curve.rs` (DXF spline); see +`/home/codex/work/ssd2/task-evidence/egui037-migration-20261022/remote-plan-bases-20261023-0632.json`. +**Clean-room note:** this plan uses only public specifications, public documentation and source code. It does not use, inspect or compare against any Adobe application, asset or output. + +This plan is deliberately app-specific. The suite-wide dependency order and shared acceptance contract are in the +local planning draft `craft-libs/docs/egui-037-suite-rollout.md` (not yet published at the time of writing); this +document only records what VectorCraft must do, what it must prove and what remains unresolved. The shared +lifecycle, capture-slot, performance and font-fixture contracts in that rollout and in +`egui/docs/egui-037-migration-plan.md` apply here; this document does not weaken them. + +--- + +## 1. Executive decision + +Migrate VectorCraft from the coherent egui **0.36.2** family (currently resolved from crates.io, with `craft-ui` and +`craft-window` pinned to 0.36-era craft-libs revisions) to the selected **egui 0.37.0** family and the suite's +`winit 0.30.13` language fork, in a sequence that never leaves `main` with a mixed or unbuildable dependency graph. + +The migration is not a version-string bump: + +1. The app already has the **`eframe::App::logic` / `App::ui` split and a root `Ui`** on 0.36.2. That split is not a + 0.37 feature and must not be rewritten wholesale. The real work is to make the split honest: background + `tick` work must not read stale UI input or stale `i.time` while the window is hidden, input must be consumed + only in a known UI phase, and the UI pass must not duplicate side effects when egui runs a second layout pass. + `raw_input_hook` is called by both native and web hosts before they decide whether to run a full update or + logic-only; it marks a host iteration, not a UI phase. +2. 0.37's `WidgetType` → `accesskit::Role` deprecation/alias and variant renames, `Id`/`IdSalt` split, `Ui::scope_id`, screenshot callback, `FontsView` privacy, + `ScrollArea::State::offset` privacy, `WidgetInfo.role`, `MouseWheelSource` and `accesskit::Role` changes are + concrete compile/API changes. The historical diagnostics in + `/home/codex/work/ssd2/task-evidence/egui037-audit-20261022/{fork,stock}/vectorcraft.errors` already list many + of them (35 entries; the fork and stock lists are identical). They are line-drifted clues, not a current + acceptance baseline. +3. The app has three behaviours that need explicit redesign, not mechanical renames: + - screenshot completion (`ui.screenshot` reads a removed `Event::Screenshot` and `egui::UserData`); the final + design is a bounded per-request completion slot whose callback holds only a weak slot reference; + - native/web clipboard input (0.37 makes eframe's `clipboard` feature opt-in, and adds `PasteImage` / + `MiddleClickPaste`), including the winit-owned W15P PRIMARY path routed through E4; + - IME replacement and outgoing surrounding text (`ImeEvent::DeleteSurrounding` upstream; + `DeleteSurroundingBytes` in the selected fork; E21b `IMEOutput` payload for W27) for the canvas's custom text + tool. +4. Accessibility is now a build gate: `egui_kittest` 0.37 fails an input widget with no accessible name, and the + kittest harness sets `MissingGlyphPolicy::Panic`; production layout uses the default `Tofu` policy. VectorCraft + has many custom painted fields and custom combo boxes; every visible input needs a real name and role, not just + a painted label. +5. `egui` 0.37 no longer enables `default_fonts` by default. VectorCraft's `theme::install_fonts` starts from + `FontDefinitions::default()`; without the explicit feature its default families are empty, so text silently + lays out as synthetic tofu in production and the `install_fonts` non-empty-family assertion fails. Enable + `egui/default_fonts` explicitly: kittest's own `[dev-dependencies]` fonts do not reach VectorCraft's tests. + +**Selected dependency set (primary):** approved, reachable `storytold/winit` revision containing W1–W15 (minus +W5, plus replacement W20) and W17–W27 plus W15P and the bounded transfer API (D1a), and the complete `storytold/egui` P8 revision +containing P2b, P7, P7b/E21b and P7c (D1b). Final SHAs are execution prerequisites created by that fork work; none +is invented or asserted in this plan, and no intermediate fork stage becomes a production pin. The aiongg +`craft/0.37` (`a4f9a17…`) and `winit craft/0.30.13-lang` (`8b35388b…`) trees remain research inputs, not production +pins. **Fallback:** none for production. Stock 0.37.0 is the measurement/attribution arm only; it lacks E21b, W15P +and P7c, so it cannot substitute for D1b. If the fork work cannot land, stop and report the open gates rather than +publishing a mixed or reduced graph. + +--- + +## 2. Baseline and inventory + +### 2.1 Exact revisions and identities + +| Item | Exact reference | Use in this plan | +|---|---|---| +| VectorCraft pinned review baseline | `8906073438a2f7f0d3634d460b900956134f65e6` (2026-10-23 11:50:04 +0700) | Source for every anchor and the pre-change executable/lock; the assigned checkout stays here during plan repair | +| VectorCraft remote main at drift snapshot | `4a7cba0dd32e4ac249e4dc50af486b89531481aa` | Two commits after the pinned baseline, both only `crates/cad/src/import/curve.rs` (DXF spline). Integration gate: rebase onto this (or later) main, preserve the DXF fix, and isolate it from migration performance (§6.2) | +| Upstream egui 0.37.0 | `841a328ba2dd8f6996beb7189ebb7fc00e668a2a` (tag `0.37.0`) | Behaviour/API reference; release notes and migration decisions | +| Upstream egui 0.36.2 | `49682f8baa058bf49e011035cfbd6e825f88a5ef` | Stock comparison and old coherent set | +| aiongg egui candidate | `a4f9a17f64a6bae61350fb85804c9df0d9865916` (`aiongg/craft/0.37`) | **Research input only**, not a production pin; source comparison anchor | +| storytold egui 0.36.2 | `767b1cad294120689a514bb4bd68c077e46679b9` (`craft/0.36.2`) | Existing published fork for comparison | +| aiongg winit candidate | `8b35388bd719e8cc86a95971ba0bc026c9a8f058` (`aiongg/winit craft/0.30.13-lang`) | **Research input only**, not a production pin; missing W15P and W27 lifecycle revisions | +| Published storytold winit | `ffae5198770d7730e718043f035a772b7dbc3b1b` (`craft/0.30.13`) | Old coherent comparator | +| craft-libs implementation base | `3832c47ef68a0e6214f2c1e85f3526b6adcb686e` | Current local shared-library tree and the base for the implemented port; it already carries PR 199's egui 0.37 update, while the CL-2F capture helper remains uncommitted and is a publication gate | +| craft-libs PR 199 head | `b68de8c5f4f1c060904d211a7b1320aa0f7bce78` | **Stale tree; do not merge.** Reuse selected port hunks only (§3.7) | +| craft-libs PR 199 API commit | `8614e4a04fae516fba16c22b31ff1008cb6be10b` | Historical source of the egui API-port hunks already contained in the `3832c47…` base | +| craft-libs PR 199 README commit | `1764bca` | README/`scope_id` example hunk to review and apply separately | +| craft-libs audit merge | `audit/main-plus-199` (`ec8840af…` locally) | **Evidence of conflict resolution risk, not a source.** It drops security checks and lacks `craft-platform`; see §3.7 | +| Adversarial plan review, revision 1 | `plan-reviews/vectorcraft-v1.md` | Reviewed document `70938f132709da9073910631e90364738e8df3dfc0d7b354920ba7910fc01fbe`; findings resolved in §8.1 | +| Adversarial plan review, revision 2 | `plan-reviews/vectorcraft-v2.md` | Reviewed document `d8c6a7594015919751d2b83bbe0e44ee9fdb4696b4e5c90fb1f8ecbdf7335f83`; findings resolved in §8.2 | +| Remote bases snapshot | `remote-plan-bases-20261023-0632.json` | Records all suite heads and the two-commit VectorCraft drift above | + +Current VectorCraft lock identities (from `Cargo.lock`): + +| Package | Version | Source | +|---|---|---| +| `egui` | `0.36.2` | crates.io | +| `eframe` | `0.36.2` | crates.io | +| `egui-winit` | `0.36.2` | crates.io | +| `egui-wgpu` | `0.36.2` | crates.io | +| `egui_glow` | `0.36.2` | crates.io | +| `egui_extras` | `0.36.2` | crates.io | +| `egui_kittest` | `0.36.2` | crates.io | +| `epaint` | `0.36.2` | crates.io | +| `emath` | `0.36.2` | crates.io | +| `ecolor` | `0.36.2` | crates.io | +| `epaint_default_fonts` | `0.36.2` | crates.io | +| `kittest` | `0.4.0` | crates.io (external test crate, not an egui workspace member) | +| `accesskit` | `0.24.1` | crates.io (external accessibility crate) | +| `accesskit_winit` | `0.32.2` | crates.io (external accessibility crate) | +| `winit` | `0.30.13` | crates.io | +| `wgpu` | `30.0.1` | crates.io | +| `craft-ui` | `0.1.0` | `storytold/craft-libs` rev `5c874d51eeb3de70e735c8ce87a59a7dedd47675` | +| `craft-window` | `0.1.0` | `storytold/craft-libs` rev `1f805378d454dac48b5c214029d254055fdf961b` | +| `craft-pdf` | `0.1.0` | `storytold/craft-libs` rev `ca50d63b773c99f96e75c77ce5a1d21e7c9cda0d` | +| `craft-print` | `0.1.0` | `storytold/craft-libs` rev `d991bf6792f8ade99c51358bf3c1d902c8cd96b5` | +| `craft-docpath` | `0.1.0` | `storytold/craft-libs` rev `82a24b006eefa036b43c09a67fb9a3fbd17ffe63` | + +`rust-version = "1.95"` and `edition = "2024"` are already the egui 0.37 MSRV/edition, so no toolchain change is +implied. Do not treat a compile time as UI performance evidence. + +No final `storytold/egui`, `storytold/winit` or final craft-libs SHA is asserted here. Those revisions are created +by the fork and library stages after their own reviews, with exact full SHAs, tree hashes and clean-fetch evidence +recorded at execution time. The candidate aiongg SHAs above are comparison inputs; they lack W15P and E21b/W27 +lifecycle work and must not become interim production pins. The two-commit main drift from `8906073…` to `4a7cba0…` +is unrelated DXF work; it is a rebase gate, not a reason to change the pinned review checkout now. + +### 2.2 Workspace, manifests, patches and vendors + +Root workspace (`Cargo.toml`): + +- `resolver = "3"`, members `crates/*`, `apps/*`, `xtask`; default members include `apps/vectorcraft`, + `apps/vectorcraft-cli` and `xtask`. +- Workspace UI dependencies at lines 102–105: `egui = "0.36"`, `egui_extras = { version = "0.36", + default-features = false, features = ["svg"] }`, `eframe = { version = "0.36", default-features = false }`, + `egui_kittest = "0.36"`. +- `craft-ui` rev `5c874d…` with `serde`; `craft-window` rev `1f805…` with `egui`. +- Root `[patch.crates-io]` currently patches `hayro-syntax`, `hayro-interpret`, `hayro-jbig2` and `krilla` to the + same craft-libs rev `ca50d63b…`. These are unrelated to egui and must remain unless a separate comparison proves + replacement. +- `vendor/windows-link` is the only vendored package. `vendor/windows-link/VECTORCRAFT-PATCH.md` records the + provenance (Microsoft windows-link 0.2.1, crates.io, MIT OR Apache-2.0), the sole change (`CoTaskMemFree` from + `ole32.dll` on the Windows 7 target), and the removal condition. It is applied only by + `packaging/windows/windows7.ps1` through `--config "patch.crates-io.windows-link.path='vendor/windows-link'"`. + **Keep it.** Do not add a root path patch for it, and re-run the Windows 7 packaging check after the egui bump. +- Nested workspaces: `crates/affinity/fuzz` (libfuzzer only; no egui) and `crates/plugins/example` (wasm plug-in + example; no egui). `crates/plugins/example/Cargo.lock` is separate and must not acquire egui. +- `crates/affinity/fuzz` has no lockfile in the checkout. Do not create one merely for this migration. + +Manifests that participate in the egui graph: + +| Manifest | Current UI declaration | Required change | +|---|---|---| +| `crates/ui-egui/Cargo.toml` | `egui` workspace + `bytemuck`; `egui_extras`; dev `egui_kittest` `wgpu`,`snapshot` | `egui` must also enable `default_fonts` for library tests/embedders and for downstream kittest tests, which do not inherit kittest's own dev-dependency fonts; `egui_extras`/`egui_kittest` to 0.37; no feature rename assumed | +| `apps/vectorcraft/Cargo.toml` | `eframe` features `default_fonts`, `wayland`, `x11`; default features `wgpu`, `accessibility`; `windows7 = ["eframe/glow"]`; direct `winit 0.30` | add **`eframe/clipboard`**; keep `links` off (existing native `open_url` service); keep `system_fonts` off; retain winit feature set | +| `apps/vectorcraft-web/Cargo.toml` | wasm `eframe` features `default_fonts`, `wgpu`, `accesskit` | keep; add `clipboard` only if the chosen eframe 0.37 web path needs the feature (the web clipboard code is not gated by it, but test it); web `open_url` is handled by the web runner | +| `craft-ui` / `craft-window` pins | 0.36-era craft-libs revisions | final L2 craft-libs revision on `3832c47…` with workspace `egui = "0.37"` and the CL-2F capture helper | +| `Cargo.lock` | one coherent 0.36 graph | one coherent 0.37 graph; no second `egui`, `eframe`, `egui-winit`, `egui-wgpu`, `epaint`/`emath`/`ecolor`, or `winit`; external `kittest`/`accesskit*` are separate audited registry identities | + +There is no `[patch]` in `crates/ui-egui`, apps, `xtask`, the fuzz workspace or the plug-in example. A dependency's +`[patch]` section does not propagate into the root; the root `[patch.crates-io]` must carry every fork-source +family crate and winit. The fork's own `Cargo.toml` patch of winit is **not** inherited by VectorCraft. The final +app lock is created only after D1a (complete winit), D1b (complete egui P8) and L2 (final craft-libs/CL-2F), +never against a candidate, an intermediate fork stage or a provisional craft-libs tree. + +### 2.3 Targets, binaries and entry points + +| Target | Path | Current role | +|---|---|---| +| `vectorcraft` binary | `apps/vectorcraft/src/main.rs` | Native eframe/wgpu app, control server, macOS menu, Linux stylus/XWayland, Windows stylus, printing, Windows 7 glow fallback | +| `vectorcraft-web` binary | `apps/vectorcraft-web/src/main.rs`, `web.rs` | wasm eframe web runner, WebGPU/WebGL2, DOM paste/drop, `window.vectorcraft` control API | +| `vectorcraft-cli` binary | `apps/vectorcraft-cli/src/main.rs`, `perf.rs` | Headless run/convert/bench/perf and MCP stdio | +| `vectorcraft-ui-egui` library | `crates/ui-egui/src/lib.rs` | All frontend state, panels, dialogs, canvas, control methods, i18n, clipboard, fonts, workers | +| `vectorcraft-mcp` library | `crates/mcp` | MCP protocol over the engine/control semantics; no direct egui dependency | +| `vectorcraft-testkit` | `crates/testkit` | Test fixtures; dev-only | +| `xtask` | `xtask/src/*` | `ci`, `wasm`, `layers`, `assets`, `brands`, `cursors`, `pdf-deps`, corpus and packaging checks | + +Entry-point details that must remain true: + +- `apps/vectorcraft/src/main.rs::App` implements `eframe::App::logic`, `ui`, `raw_input_hook`, `on_exit`; `logic_frame` + calls `VectorcraftApp::logic`, tracks window geometry/maximize, closes the window, and handles graphics loss. +- `apps/vectorcraft-web/src/web.rs::WebShell` implements the same trait; `logic` also handles pasted/dropped files and + `api::poll_replies`; `ui` calls `app.ui` and polls replies. +- `crates/ui-egui/src/lib.rs::VectorcraftApp::logic` currently does **both** background polling and input-dependent + work (`shortcuts::handle` at the end, `take_dropped_files`, native-menu key processing). This is the central + lifecycle defect to fix. +- `crates/ui-egui/src/lib.rs::VectorcraftApp::ui` guards a panic and calls `ui_frame`, which draws app bar, control + bar, status/hint bars, toolbar, dock, `CentralPanel` + canvas, floating panels, dialogs, progress, palette, + titlebar resize zones, fonts, scrub end, native-menu sync and clipboard format cleanup. +- `crates/ui-egui/src/control.rs` owns the JSON control methods; `apps/vectorcraft/src/control_server.rs` owns the + loopback JSON-lines transport; `apps/vectorcraft-web/src/api.rs` exposes the same methods as page promises. +- The canvas is CPU-rasterized by `vectorcraft_render` and composited by wgpu; egui only uploads the raster texture. + UI-frame work and document rendering are separate measurements. + +### 2.4 Current lifecycle (the facts, not the desired design) + +The current app already has: + +- `App::logic(&mut self, ctx, frame)` and `App::ui(&mut self, ui, frame)` in both hosts. +- `VectorcraftApp::logic(ctx)` called from the host logic, guarded by `vectorcraft_engine::guard::catch_panic`. +- `VectorcraftApp::ui(ui)` called from the host UI, also guarded. +- A root `Ui` in `App::ui`; panels are already shown with `egui::Panel::…::show(ui, …)`. +- Custom `Area`/`Window` dialogs and floating panels; no `show_viewport_deferred`/child native viewports. +- `ctx.run_ui` in many tests; `ctx.run_ui` in test helpers draws either the whole app or one panel/dialog. + +What 0.37 makes explicit: + +- `eframe` calls `App::logic` **inside** `egui_ctx.run_ui`, then `App::ui`, so both are called once per layout pass, + not once per outer frame. A `Grid` created for the first time calls `Context::request_discard`, so the same outer + frame can run `App::logic` and `App::ui` more than once. The host calls `App::raw_input_hook` once before that + closure, not once per pass. +- When a window is hidden/minimized/occluded, native eframe runs `Context::run_logic` instead of `run_ui`, and web + runs `run_logic` after seeing that the tab is hidden. That leaves the last pass's `InputState` (events and time) + in place; only viewport state is updated. Reading `ctx.input` events or `i.time` in logic-only iterations replays + or mis-times work. Stale events must therefore be consumed only in a known UI phase. +- **`raw_input_hook` is not a UI-phase marker.** Native `EpiIntegration::update_logic_only` calls + `prepare_raw_input()`, which calls `app.raw_input_hook()`; web `AppRunner::logic` calls + `self.app.raw_input_hook()` before it decides visibility and calls `run_ui` or `run_logic`. Both hosts call the + hook for full and logic-only iterations. It is useful as a host-iteration boundary, not as evidence that a UI + pass will follow. +- `Context::run_ui` passes the existing root `Ui`; `Context::root_ui` panics if called while that `Ui` is borrowed. + VectorCraft must not call `root_ui` from inside its `App::ui` or tests. +- `ViewportCommand::RequestPaintWhileHidden` exists for a hidden window that must draw once (a dialog, a control + request, a screenshot). A pending `Screenshot` command already asks for paint while hidden. A forced hidden UI + pass is still a UI phase and must consume the input it was forced to present. +- `Context::cumulative_frame_nr()` advances once when a `run_ui` completes (not per pass and not in `run_logic`); + `Context::cumulative_pass_nr()` advances per layout pass, including a discarded rerun. Keep these distinct from + the app's host-iteration counter in §4.2. + +--- + +## 3. Actual 0.36 → 0.37 incompatibilities in this source + +The following is a source audit of the assigned checkout, plus the historical diagnostics as a checklist. Line +numbers will drift; symbols are authoritative. + +### 3.1 Compile errors + +| Class | Historical diagnostic examples | Source anchors in VectorCraft | Required migration | +|---|---|---|---| +| `Event::MouseWheel` gained `source: MouseWheelSource` | `canvas.rs:6226`, `chrome.rs:1691`, `stroke.rs:634`, tests | 21 `Event::MouseWheel` literals; production matches use `..` | Add `source: MouseWheelSource::Unknown` in tests/synthetic input; production events come from egui-winit. Preserve `unit`, `phase`, `delta`, `modifiers`. | +| `WidgetType` is now a deprecated alias for `accesskit::Role`, and variants were renamed | `chrome.rs:640`, `dock.rs:122`, `edit_toolbar.rs:533`, `panel_docking.rs:1241`, `widgets.rs:192/1535/1536/2487` | 54 `WidgetType` call sites | Map per §3.2 to the renamed `Role` variants. Do not hide all roles behind `Role::Unknown`. | +| `Id` is not an `IdSalt` | `dialogs/layer_options.rs:129/130`, `menus.rs:3482/3484` | `Atom::custom(chip, …)`; `Panel::right(Id::new(...))`; `ComboBox::from_id_salt(id)` | `IdSalt::new(...)` only for a parameter typed exactly `IdSalt` (`Atom::custom`); pass the original source to `impl AsIdSalt` APIs (`Panel`, `ComboBox`, `ScrollArea`, `make_persistent_id`). `Id::unique(...)` for root ids. Never pass an `Id` as a salt. | +| `WidgetInfo.typ` removed | `dialogs/tests_warp.rs:509` | one test | Use `info.role`. | +| `scroll_area::State.offset` private | `font_menu.rs:507`, `panels/layers.rs:650`, `tests_numfields.rs:614` | 3 sites | Use the public `offset()` method. | +| `FontsView.fonts` private | `i18n/tests.rs:1606/1628` | 2 test sites | Use `definitions()`, `families()`, `characters(&family)` public API. | +| `egui::UserData` removed | `lib.rs:1032` | screenshot queue | Replace with per-request `ScreenshotCallback`. | +| `Event::Screenshot` removed | `lib.rs:1052` | screenshot collector | Replace with callback completion channels. | + +### 3.2 Role mapping for VectorCraft + +Map each existing semantic `WidgetType` to the equivalent AccessKit role. The mapping used by the suite's PR 199 +(`Checkbox` → `CheckBox`, `SelectableLabel` → `Button`, `Other` → `Unknown`) is the starting point; the additional +VectorCraft-specific rows below are chosen from egui 0.37's own widgets and AccessKit names. + +| `WidgetType` | `Role` | Notes | +|---|---|---| +| `Button` | `Role::Button` | Includes icon buttons, paint chips, toolbar tools | +| `SelectableLabel` | `Role::Button` | Preserve selected state; tabs already set `Role::Tab` manually | +| `Checkbox` | `Role::CheckBox` | Preserve mixed state (`selected: None`) | +| `RadioButton` | `Role::RadioButton` | | +| `ComboBox` | `Role::ComboBox` | Must also gain an accessible name; current `current_text_value` is a value, not a name | +| `Slider` | `Role::Slider` | | +| `CollapsingHeader` | `Role::DisclosureTriangle` | `CollapsingHeader` 0.37 uses this role; the custom VectorCraft header should match | +| `Image` | `Role::Image` | | +| `Other` | `Role::Unknown` | Only where no semantic role exists; not for an input | + +The tests that assert `Role::CheckBox`, `Role::RadioButton`, `Role::Slider`, `Role::ComboBox` and `Role::Button` +already encode the expected accessibility semantics; keep them and add the missing `DisclosureTriangle` and input +names. + +### 3.3 Id migration + +- 294 `Id::new(...)` occurrences across `crates/ui-egui`, `apps/vectorcraft` and `apps/vectorcraft-web` + (`rg -o 'Id::new' … | wc -l`; 283 source lines contain one or more). `Id::new` is deprecated in 0.37; + `Id::unique` hashes the same source with the same seeds, so persisted temp-memory keys keep their identity. + Replace mechanically, then prove UI state/panel/selection tests. +- 100 `ui.id()` calls: rename to `Ui::scope_id()`. `UiBuilder` has no `.id(...)` sites in this checkout, but if one + appears, it is `scope_id`. +- Distinguish the two APIs: + - `impl AsIdSalt` parameters (`Panel::left/right/top/bottom`, `ComboBox::from_id_salt`, `ScrollArea::id_salt`, + `CollapsingHeader::id_salt`, `Ui::make_persistent_id`, `Id::with`) hash their argument themselves. Pass the + original source (string/tuple), never an `Id` and never a pre-built `IdSalt`. + - Exact `IdSalt` parameters (`Atom::custom`, `Id::with_salt`) take `IdSalt::new(source)`. +- `Id` still satisfies the blanket `AsIdSalt` impl (`Hash + Debug`), so some wrong calls compile while hashing an + `Id` as a salt. That is the wrong identity even when it compiles; `Atom::custom` rejects `Id` outright. +- Dynamic dock columns (`floating.rs`: `Panel::right(Id::new(("dock-column", first)))`) must become + `Panel::right(("dock-column", first))`. +- Do not mechanically wrap an existing `Id` in `IdSalt::new(...)` just to satisfy a signature. Audit the few + `make_persistent_id`/`with`/`Atom::custom` sites individually and preserve the intended key with a focus/state + test. `ComboBox::from_id_salt` in `widgets::combo`, `Panel::right` in `floating.rs`, and `Atom::custom` in + `dialogs/layer_options`/`menus` are the concrete sites to review. +- `Id::NULL` and `Id::with(...)` remain valid. +- `Id::unique` also registers debug id sources; do not let debug-only memory growth become a concern. + +### 3.4 Panel identity/persistence + +`crates/ui-egui/src/dock.rs::seed_dock_panel_width` and `note_dock_width` read/write +`egui::containers::panel::PanelState` under `Id::new(panel)`. In 0.37 `Panel` internally derives its bar id from +the parent `Ui`'s `scope_id` plus an `IdSalt`; the old global `Id::new(panel)` is no longer that key. The dock width +would silently stop restoring. + +Do not copy the old global key. Refactor the app-owned width: + +- have `apply_pending_dock_width` set `UiState::dock_width` (clamped) before the panel is shown; +- use `Panel::default_size(legacy_dock_default_width(&app.ui))` / `shared_dock_default_width` for restore; +- after `.show(...)`, take `InnerResponse.response.rect.width()` and store it in `UiState::dock_width`; +- keep `UiState` serde and the existing workspace/preference format unchanged; +- if an in-session preference change must resize an already-laid-out panel, seed the public `PanelState` + under the panel's public parent-scope id, `ui.scope_id().with(panel)` (the same rule `Panel` uses for its bar + id in 0.37: `scope_id.with_salt(IdSalt::new(panel))`), rather than the old `Id::new(panel)`. Encapsulate that in + one helper with a regression test; do not reach into a private field or hard-code a hash; +- test save → new app/context → reopen with the same `UiState` and assert the dock width and layout; also test an + in-session width preference change. + +This removes the old global-id dependency while preserving the current immediate-resize behavior through public +egui types. + +### 3.5 Screenshot path + +Current: + +- `crates/ui-egui/src/lib.rs::drain_control` turns `control::Outcome::Screenshot` into a queued token and a + `PendingScreenshot` with path/data/reply/deadline. +- `issue_screenshots` sends `ViewportCommand::Screenshot(UserData::new(token))`. +- `collect_screenshots` scans `ctx.input(|i| i.raw.events)` for `Event::Screenshot { user_data, image, .. }`, maps + the token, and writes the PNG through `control::save_screenshot`. +- `PendingScreenshot` times out after settle + 8 s with a user-facing "no frame presented" error. + +0.37: + +- `ViewportCommand::Screenshot(ScreenshotCallback)`; `Context::request_screenshot` is the convenience API. +- `ScreenshotCallback` stores a one-shot `FnOnce(Arc) + Send + 'static`; clones share it; completion is + at most once; it does **not** request a repaint. +- `Event::Screenshot` and `UserData` are gone. +- A pending screenshot asks the integration to paint even while hidden (the command's documentation says so). + +Required design (shared weak completion slot, per the suite capture contract): + +- `PendingScreenshot` owns `Arc`, where the slot holds the optional `Arc`, a strong `Context`/owning-viewport wake handle + for the owning-viewport wakeup, and a live/canceled state. The request record owns the strong slot; the app keeps + request id, path, data flag, reply sender, settle policy and deadline. Correlation comes from ownership of that + slot, not a second token map or callback order. +- `issue_screenshots` creates `ScreenshotCallback::new` with a closure that captures **only a `Weak`** + and the callback receives pixels only. The closure stores the image in the live slot, releases the slot lock, then + wakes the owning viewport through the strong `Context`/owning-viewport wake handle the live slot owns; the detached callback never strongly captures `Context`. That + slot-owned wake is the only scheduling: the callback itself has no implicit wake and requests no repaint. No PNG + encoding, file write, command, `Services` access or long lock on the callback thread. A callback that fires after + timeout/close finds the slot canceled and drops the image. +- Keep `collect_screenshots` on the UI thread: take the image once, verify the request is still live and its + geometry epoch is unchanged, then write through `control::save_screenshot` under `file_access::confine`. A late + or stale result writes nothing and sends no second reply. +- Serialize at most one outstanding capture at a time (the current code can issue several due commands in one + frame; a one-shot callback makes correlation easy, but serializing avoids integration differences and bounds + readback memory). Keep the settle-frame policy and the ~8 s deadline. +- **Admission is a pre-issue reservation, not just a post-hoc cap.** Use the shared defaults: **4 pending + requests, 128 MiB per image and 256 MiB aggregate** reserved in-flight/completed bytes, calculated with checked + `width × height × 4` arithmetic, never evicting an earlier request. A stricter explicit budget is allowed only + when a documented intended surface fits it (CADCraft's smaller budget is the example); 4/128/256 MiB admits a + 3840×2160 logical viewport at 2× (≈126.6 MiB) and must not be raised. Reject excess admission with a control + error before issuing, release the reservation on drain/timeout/cancel, and account for renderer/readback transient + allocation separately in peak RSS. +- **Bind a geometry epoch at issuance.** Record the target UI's known logical content and screen bounds, viewport + id, `pixels_per_point` and a surface/geometry epoch. Before saving, reject or retry on **any** resize, DPI, zoom or + surface-epoch change since issuance, including a change back to the original values. Do not assume Wayland + `inner_rect` exists or that callback arrival supplies capture metadata; with an unchanged epoch derive the pixel + scale from the returned image dimensions and verify aspect and rounding. +- **Hidden issuance needs a paint request.** The `Screenshot` command itself forces a paint while hidden, but a + request queued in app logic and not yet issued — including every remaining settling frame — must send + `ViewportCommand::RequestPaintWhileHidden` from logic so a UI pass reaches issuance. A bare `request_repaint()` + only runs hidden logic, and callback arrival does not wake by itself. +- This app is one of the eleven existing craft-ui consumers, so it adopts the already-issued shared `craft-ui` + completion-slot helper from L2 (CL-1 + CL-2F). VectorCraft keeps no app-local or provisional capture slot: the + API must be frozen before the atomic compatibility PR lands. +- Keep the existing response schema: `{ok, result: {path?, width, height, pngBase64?}}` and the explanatory timeout + error. The inline `pngBase64` copy is capped at 64 MiB; when the request named a `path`, write that file and omit + only the oversized inline copy (a path-less `data: true` request fails before encoding). `docs/control-protocol.md` + documents this downgrade. It says a minimized/covered window fails after ~8 s; 0.37 may now paint a hidden window, + so re-test and update the doc only with evidence. + +### 3.6 Clipboard, PRIMARY and paste images + +0.37 adds: + +- `egui::Event::PasteImage(Arc)` (clipboard image when there is no usable text); +- `egui::Event::MiddleClickPaste { pos, text }` for X11/Wayland PRIMARY; +- eframe's `clipboard` feature is **opt-in**; the app currently sets `eframe` `default-features = false` and does + not enable it. `egui-winit` then falls back to an in-app string clipboard for egui's own copy/paste path (the + `Event::Copy/Cut/Paste` events still occur, but `Event::Paste` text and text-field paste read the fallback, not + the OS clipboard). VectorCraft's arboard `SystemClipboard` is a separate, independent rich-format owner and is not + broken by the feature; the egui-winit text path is what needs the feature; +- `egui-winit` uses `ScreenshotCallback`-independent clipboard code and can fall back from smithay to arboard; +- the fork's E4 uses winit's Wayland clipboard instead of smithay, while VectorCraft also uses `arboard` with + `wayland-data-control` and `clipboard-win`/NSPasteboard on other platforms. + +Required design: + +- Add `eframe/clipboard` to the native eframe features (unconditionally, so `--no-default-features --features + windows7` keeps it). Keep the app's `SystemClipboard` as the multi-format owner for Copy/Cut (SVG/PDF/PNG/text) + and for image/PDF/EPS/SVG paste. +- Consume `Event::PasteImage` in `shortcuts::handle` rather than letting it reach a `TextEdit` or the Type tool: + - with a `system_clipboard`, treat it as the textless paste signal and let `adopt_system_clipboard` read the + formats (do not paste twice); + - without one (web/simple host), convert the image to a PNG `Flavour` and invoke the paste command; + - test both a text+image clipboard and an image-only clipboard. +- Consume/handle `Event::MiddleClickPaste`: focused `TextEdit`s get it from egui; for the Type tool, either insert + at the pointer as a normal paste or explicitly ignore it. Do not let it reach `route_type_input` as text, and do + not treat it as a shortcut. Preserve app-internal clipboard precedence. +- **Wayland PRIMARY is the selected winit-owned W15P path, not a choose-one experiment.** W15P adds per-seat SCTK + `PrimarySelectionManagerState`/device/source/offer support on the existing Wayland connection and event queue; + E4/P2b routes egui's get/set-primary through it. Do not bind a second regular `wl_data_device`. Keep the app's + `arboard` rich-format path for regular clipboard Copy/Cut where the app owns it, preserve password selection + exclusion and no-unchanged-selection reclaim, and do not silently disable the app's multi-format clipboard. +- W15P keeps distinct per-seat keyboard-focus and last keyboard/pointer triggering serials with origin and epoch. + A selection claim validates the fresh serial and focused seat, reserves source bytes, creates the source and + calls `set_selection` **synchronously in that same input turn** — keyboard or pointer trigger, never an arbitrary + first-seat fallback and never queued behind I/O. Reads and serving writes are bounded asynchronous work: 2 s total + deadline from admission, 16 MiB byte cap, 4 in-flight reads, 2 writer threads, queue depth 8, cancellation on + owner/seat/focus/request-generation change, and an owner-specific completion wake released before waking. + Egui E4 consumes a completion only for the still-live paste request/owner/input generation; focus changes cancel + it. +- Keep `write_formats_before_text_input` and `drop_formats_after_egui_copy`: they encode the app rule that a text + field/Type tool paste must not overwrite the app's pending rich formats. +- Test: Cmd+C on art writes text/SVG/PDF/PNG (per preferences); Cmd+V pastes the rich formats; Cmd+C in a text + field replaces the clipboard with text and cancels pending app formats; image-only clipboard paste; PRIMARY + middle-click in a field and over the canvas. Under wlroots/KWin/Mutter, test keyboard and pointer focus with + fresh triggering serials per seat/origin, owner/seat changes, empty/absent offers, self-paste, slow/canceled + transfers and responsiveness during a trickle. + +### 3.7 craft-libs PR 199 and #198 + +PR 199 is a source of selected port hunks, not a mergeable branch or tree. Its head `b68de8c` is stale relative +to the `3832c47…` base: it lacks current `craft-platform` and reverts security, settings, docking and printer fixes. The +historical audit tree `ec8840a` is likewise unsafe. Never merge either tree: + +- Its API commit `8614e4a` changes workspace `egui` to 0.37, updates `craft-ui` dev dependencies to + `egui_extras`/`egui_kittest` 0.37, maps `WidgetType` to `Role`, replaces `UiBuilder::id`/`Ui::id` with + `scope_id`, and replaces `Id::new` with `Id::unique` in tests. That is the reusable shape. +- The README/`scope_id` documentation change is `1764bca`; apply it separately after review. +- The local `audit/main-plus-199` merge is based on an older tree and, as resolved there, **drops current security + checks**: + - `crates/ui/src/docking/model.rs`: `Action::MoveFloating` no longer checks that every member of the floating + group is movable; + - `crates/ui/src/docking/surface.rs`: resizing a floating group no longer requires all members movable. + - These checks are present in current craft-libs security work (for example #236, docking group move permission). Do not merge that tree. +- Keep the egui-related `8614e4a`/`1764bca` shape already in the `3832c47…` base and the uncommitted capture + helper, preserving the current permission + checks, `craft-platform`, settings, docking, printing and tests. Record the new commit, its parent and the diff. + Publish a new immutable final craft-libs revision only after the shared tests and an adversarial review pass. + That final revision comes **after** the final reviewed storytold egui/winit revisions (which include E21b and + W15P), so no provisional craft-libs commit pretends to be final. +- The final craft-libs revision also carries the CL-2F frozen capture-slot helper; VectorCraft's final app lock + depends on that revision, not on a provisional prototype. +- `craft-window` has no source change for 0.37, but it must be consumed from a craft-libs revision whose workspace + `egui` is 0.37; otherwise its `egui = "0.36"` requirement cannot be satisfied by a 0.37 patch. +- `craft-print`, `craft-pdf` and `craft-docpath` do not depend on egui; keep their current revisions unless a + separate comparison justifies a bump. Do not churn unrelated pins. +- #198 is a language-support proposal, not a merged dependency. VectorCraft has its own i18n catalog and no RTL UI + language (the catalog list has no Arabic/Hebrew/Persian). The fork's bidi/RTL UI patches still affect text + layout, so they need visual tests, but VectorCraft should not start calling fork-only `set_ui_language`/ + `LayoutJob::direction` APIs in this migration. `ImeEvent::DeleteSurroundingBytes` is the one fork-specific event + the canvas must handle; document that dependency and keep a stock-compatible alternative in the plan. + +### 3.8 eframe features and fonts + +- `egui` 0.37's `default = []`; `default_fonts` is opt-in. `FontDefinitions::default()` is empty without it. + `crates/ui-egui/src/theme.rs::install_fonts` must keep working for library-only builds and for + `--no-default-features`; enable `egui/default_fonts` on the `ui-egui` egui dependency (or explicitly bind every + fallback family, which is more fragile). +- **Corrected font mechanism:** production layout uses the default `MissingGlyphPolicy::Tofu` + (`epaint/src/text/fonts.rs`), so an unbound family draws a synthetic box, not a panic. The kittest harness alone + defaults to `Panic` (`egui_kittest/src/builder.rs`). `install_fonts.rs` is a plain `run_ui` test, so mutation 8 fails + its non-empty-family assertion rather than panicking. `theme.rs`'s "egui panics…" doc comment is scheduled for + correction with that change. +- `egui_kittest`'s own `[dev-dependencies]` enable `default_fonts` and `monochrome_emoji_fonts`, but a dependency's + dev-dependencies do not propagate: VectorCraft's kittest tests get bundled fonts only from its own dependency + features. Inspect the actual graph per test root instead of assuming inherited fonts. +- `eframe` 0.37 `default_fonts` is still a feature; the app/web already request it. +- eframe 0.37 `system_fonts` (fontique/egui_system_font_provider) is a new default in eframe's default feature + set. VectorCraft disables eframe defaults, so it stays off. Keep it off initially: the app owns font discovery + (`vectorcraft_text::FontDb`, `apps/vectorcraft/src/system_fonts.rs`, `mac_fonts.rs`, web `fonts.rs`) and must + build/test with `CRAFT_FONTS` empty. Evaluate #8512 startup cost only if a measured case justifies enabling it. +- `eframe` 0.37 `links` is a feature; VectorCraft's native app installs its own `open_url` service + (`webbrowser`), and the web runner handles `OutputCommand::OpenUrl`. Keep `links` off as before; add it only if a + native path actually needs it. +- `eframe` 0.37 `clipboard` is new and required (above). `eframe` 0.37 `wayland`/`x11` remain required on Linux/CI. +- `egui_extras` 0.37 still exposes `install_image_loaders` at the crate root; `craft-ui` SVG icons use + `ImageSource::Bytes` and the SVG loader. Test the SVG loader with the app's icon set. +- **Font fixtures follow the shared pin.** Use the exact `craft-fonts` revision + `2481a8a324b986a45954e90045ed1f8f2efaaf3c` and the identities in `egui` plan §10.6, with recorded licences: + Arabic `fonts/noto-sans-arabic/NotoSansArabic.ttf` SHA256 + `63111b5b2e074dd48cc67692e0a2726d86ee94c1c37fe8598257b7b4e87e869e`; Thai + `fonts/noto-sans-thai/NotoSansThai.ttf` `5a1c559bb539583c8a1fd99d1c5b9491e5e14478c9cd2bd0970d5c3096cc9ef8`; + CJK SC `fonts/noto-sans-cjk-sc/NotoSansCJKsc-Regular.otf` + `2c76254f6fc379fddfce0a7e84fb5385bb135d3e399294f6eeb6680d0365b74b`; Japanese + `fonts/biz-ud-pgothic/BIZUDPGothic-Regular.ttf` + `258d7156c165f2ff774b6efee637c22c3b950de0d8a10e501137061bc8085d01`; Noto Sans Hebrew variable + `7ef36a2c3593758cdb622e1bdef4f84523e92fbc3ccc667438dd80ff54c2de88` (fix axes to weight 400 / width 100); + Noto Color Emoji v2.047 `39ee3c587e10e89669b9ff32703261d10d5f9c4dd5ad147b6b5a1c5200591817` (CBDT, not a + COLR/SBIX proof). Candidate `epaint_default_fonts` comparison bytes are Ubuntu-Light + `80307b8da7649aa4ee4d484b232140e3ce1ec0ca093073d3c53c8f5a5ced7a70` and NotoEmoji-Regular + `415dc6290378574135b64c808dc640c1df7531973290c4970c51fdeb849cb0c5`; use the same bytes for paired + measurements and preserve the adjacent licence files. Fetch these as test resources into the task evidence + directory; do not copy font binaries into this repository, and do not replace them with system fonts. Keep + VectorCraft's own central-font policy (`vectorcraft_text::FontDb`, `CRAFT_FONTS_DIR`, interface-fonts / + bundled-fonts) and the `CRAFT_FONTS` empty build/test requirement. A missing glyph fails the strict test unless a + deliberate `allow_missing_glyphs` case is documented. + +### 3.9 IME, text and RTL + +- Upstream 0.37 adds `ImeEvent::DeleteSurrounding { before_chars, after_chars }`; the selected fork adds + `ImeEvent::DeleteSurroundingBytes { before_bytes, after_bytes }` (E21) and the winit fork supplies it for + macOS press-and-hold/dictation and Wayland text-input-v3. +- `crates/ui-egui/src/shortcuts.rs::type_text` matches `Preedit`/`Commit` and deliberately leaves everything else + in the event list. `crates/ui-egui/src/canvas.rs` and `vectorcraft_engine`'s `tool_text`/`tool_preedit`/`tool_key` + have no “delete surrounding characters/bytes” operation. A system IME that replaces text around the cursor will + therefore not edit the document as intended. +- Design a single canvas insertion/deletion API in the engine/tool layer: delete `before_chars`/`after_chars` + Unicode scalar values (upstream) or the exact UTF-8 bytes supplied by the fork, in the same undo step as the + commit, then insert the committed text. Never split a UTF-8 sequence; never use byte indices derived from + character counts. The app's `ime_marked`/`ime_discard`/`keep_marked_text` state must remain coherent, and a + `Commit` after a replacement is one undo step. +- **E21b/P7b outgoing surrounding text is a required prerequisite, not a nice-to-have.** The candidate + `egui-winit` never calls W27 `set_ime_surrounding_text`, and candidate `IMEOutput` has no logical text. The final + fork must add a bounded transient owner/context-tagged payload to `IMEOutput` (`Available` including an empty + document, distinct from `Unavailable`), a built-in `TextEdit` producer, and an `egui-winit` Wayland adapter. + The payload carries logical committed text and UTF-8 byte cursor/anchor indices, excludes preedit, remaps indices + when preedit is removed, caps the excerpt at 4,000 bytes without a full-document clone, and returns explicit + unavailable context when the selection cannot fit. Password fields emit no text. The latest completed pass, + including `IMEOutput = None`, is authoritative; do not retain a previous payload with `ime.or(self.ime)`. +- VectorCraft's custom canvas producer (the `IMEOutput` construction in `crates/ui-egui/src/canvas.rs`, around the + Type-tool text edit) must be a real model/preedit/byte-index adapter: it supplies the document's logical text, + not display-bidi text, converts its scalar/grapheme cursor and anchor to UTF-8 byte offsets, excludes the active + preedit, and clears on focus/password/unavailable transitions. Setting the new field to `None` everywhere is not + acceptance, and the adapter belongs to the atomic E21b consumer work. +- W27 must gain the explicit owner/context transition and clear operation: cache and protocol state are cleared at + owner/unavailable changes using the committed disable/enable boundary; old preedit/commit/delete batches are + canceled; ordinary same-owner updates preserve composition. A current-epoch `done` with a mismatched serial + applies incoming edits once while outgoing state waits for a matching serial; an **old-epoch `done` is rejected, + invalidates that epoch and can never edit the new owner**. `Available` with empty text is distinct from + `Unavailable`, and the latest completed pass, including `None`, is authoritative. Tests cover outgoing text after + delete+commit and deletion-only batches, UTF-8 boundaries, preedit exclusion, long selections, focus/late serials, + old-epoch rejection, two windows, password clearing and multi-pass final output. The complete contract is egui + plan §E21b/P7b; VectorCraft consumes it rather than restating a weaker variant. +- `Shortcuts::type_text` must consume the new events (so `route_type_input` does not see them), and tests must cover + preedit → replacement → commit, preedit → cancel, deletion-only batches, and focus/serial changes. +- `ImeEvent::Enabled`/`Disabled` are deprecated; do not add matches that depend on them. +- `Event::PasteImage`/`MiddleClickPaste` are not IME; keep them out of the IME sequence. +- VectorCraft's UI languages are LTR; document text can be RTL and uses `vectorcraft_text`'s own bidi/shaping, not + egui's `LayoutJob`. UI text containing RTL layer names must still lay out and hit-test correctly through the + fork's E5/E12 changes. Add RTL-name UI tests; do not claim a full RTL UI. + +### 3.10 Accessibility and test harness + +- `egui_kittest` 0.37 fails an input widget with no accessible name; `kittest` 0.5 is a new version. +- `MissingGlyphPolicy::Panic` is the **kittest harness** default; `HarnessBuilder::allow_missing_glyphs()` opts out. + Production layout keeps the default `Tofu` policy and draws a synthetic box, so a missing glyph is a visible tofu, + not a crash: tests must assert glyph presence, not rely on a production panic. +- Current VectorCraft custom fields paint labels beside `TextEdit`s (`widgets::recessed_text`, `dialogs/form.rs`, + panel renames, search fields, hex fields, workspace names, canvas name edit). They do not name the input. +- Current custom combo boxes (`widgets::searchable_combo`, `combo`) set `current_text_value`/`Role::ComboBox` but no + label. +- Current custom check/radio/slider widgets already label most controls; audit the rest. +- `Role::SelectableLabel` becomes `Role::Button`; existing tests already expect `Button` for selected tabs, so the + semantic intent survives. `CollapsingHeader` becomes `DisclosureTriangle` and needs a test. + +Required design: + +- Add a small app-local naming helper around `Ui::name_inputs` and `Ui::label_inputs_by`; do not build a generic + compatibility layer. +- `dialogs/form.rs::row_label` already calls `ui.label(egui::RichText::new(tl!(label)).color(t.text_dim))`; it is a + real `Label` that currently returns `()`. Do **not** add a second label or repaint the text. Change `row_label` to + return that existing `Response` (or its `Id`), then wrap `field_w`/`length_field`/form-grid value widgets in + `ui.label_inputs_by(label.id, |ui| …)`. Keep the token colour and translated text unchanged. +- `widgets.rs::label_row` is the custom-painted case: it allocates a hover rect and paints text. Give it a real + labelled response and apply `label_inputs_by` there separately; do not confuse it with `row_label`. +- In `widgets.rs`, give `recessed_text`/`text_field`/`exact_text_field`/`num_field`/`size_field` an accessible name + from the row (or a required `name` parameter at call sites that are not rows). +- Name every standalone `TextEdit`: layer/artboard/asset rename, pattern name, color hex, graph data cells, + workspace name, preference fields, shortcut search, print/PDF ranges, password fields, canvas text/name edit. + Use translated UI strings (`tl!`) where the user sees them; do not use internal ids as names. +- Name combo boxes with the field they choose (font family, layer colour, etc.), not the selected value. +- Keep `WidgetInfo::selected` for check/radio/tab selected state and `WidgetInfo::slider` for sliders. +- Run all kittest tests with the strict name check on. `allow_missing_glyphs` only in a test that deliberately + renders a glyph outside the fixture fonts; document it. +- Add semantic tests for hidden controls disappearing (0.37 hides invisible widgets), disabled state, labels, + values, selected/mixed state, and keyboard activation of checkbox/radio/tab/menu items. + +### 3.11 Other 0.37 behaviour changes to watch + +Source-grounded from the 0.37 changelog and code, with tests rather than assumptions: + +- `Grid` calls `request_discard` on first show; the host `App::logic`/`App::ui` closures run twice on that frame. + Prove no command/undo duplicate (the second pass receives `new_input.take()` with no events, but test it); the + app's `tick` remains once per host iteration. +- popup/menu/modal/panel accessibility trees changed; hidden widgets are removed from the tree. +- `TextEdit` layout/margin and drag-to-select changes; click-away selection (#8763/#8764) can affect + `select_all_on_focus`, field commit, and rename tests. **P7c owns this upstream defect and its fix**: it reproduces + the selection-action loss and adds pointer and keyboard regressions. #8764 is an unmerged proposal, not release + behavior. VectorCraft must not silently accept changed click-away/field-commit behavior as 0.37 semantics; any + app workaround needs equivalent tests and a removal condition, and an unresolved failure blocks release. +- `ScrollArea` clip/scrollbar/rubber-banding changes; panel scroll geometry and wheel tests need review, not + tolerance widening. +- `ComboBox` is built from atoms and takes `IntoAtoms`; custom `searchable_combo` is separate but built-in combos + may shift. +- `Ui::id`/`UiBuilder::id` rename; `scope_id` is stable. +- `Panel`/`Scene` clip rect respects parent (#8562), panel separator width changes; dock/canvas geometry tests. +- `Context::set_fonts`/`add_font` apply immediately when no text has been laid out; `ui_fonts` tests may observe + fonts one frame earlier. +- default style tweaks (roomier buttons, larger corners); app sets most tokens but screenshots will move. +- text tessellation (#8731) and DX12 FXC shader compile (#8736) are upstream reports, not reproduced here. Measure + our own fixtures before changing anything; preserve Repeat/MirroredRepeat semantics and color emoji. +- `eframe` now falls back to a compatibility-profile OpenGL context; Windows 7/glow path must be re-tested. +- #8748 (hidden-parent deferred children) concerns child/deferred viewports. P7c owns the hidden-parent/child fix + and its regression. VectorCraft has no child viewport, so it verifies only the root window's + occluded/minimized logic-only path and that any `Area`/`Window` owned by the root does not assume a parent + viewport that is still painted; it does not add a deferral workaround. +- `eframe` web file handle is opt-in for `DroppedFile::web_file`; eframe web enables it, but the app uses + `bytes_async`, so no source change unless we choose the handle. +- `wgpu` remains 30.0.x; VectorCraft's lock has 30.0.1 while upstream 0.37 pins 30.0.0 for wasm-bindgen reasons. + Check the resolved graph and wasm build rather than assuming a version match. + +--- + +## 4. Migration design + +### 4.1 Dependency graph and patch policy + +The dependency direction is fixed: + +``` +D1a approved storytold/winit: W1–W15 minus W5, plus W17–W27 including replacement W20, W15P, bounded transfer +D1b approved storytold/egui P8: P2b + P7 + P7b/E21b + P7c, complete + -> L1 craft-libs CL-1 API port on 3832c47 (PR199 already applied) + CL-2F helper + -> L2 craft-libs CL-2F capture-helper freeze + -> V2 VectorCraft atomic app lock +``` + +No stage depends on itself and no app lock precedes L2. No intermediate fork stage, aiongg candidate or provisional +craft-libs tree becomes a production pin. Candidate aiongg revisions are research inputs only; final fork SHAs and +tree hashes are recorded when the approved predecessors exist, never invented here. + +One coherent 0.37 family: + +``` +eframe 0.37 ─┬─ egui 0.37 ── epaint/emath/ecolor/epaint_default_fonts 0.37 + ├─ egui-winit 0.37 ── winit 0.30.13 (approved storytold fork) + ├─ egui-wgpu 0.37 ── wgpu 30.0.x + └─ egui_glow 0.37 (windows7) +egui_extras 0.37 +egui_kittest 0.37 ── kittest 0.5.0 (registry) +craft-ui (final craft-libs rev) ── egui 0.37 +craft-window (final craft-libs rev) ── egui 0.37 +``` + +- **Required-used fork-source IDs** (each must appear once from the D1b revision): `egui`, `eframe`, `egui-winit`, + `egui-wgpu`, `egui_glow` (`windows7` target only), `egui_extras`, `epaint`, `emath`, `ecolor`, + `epaint_default_fonts`, `egui_kittest`. +- **Optional or forbidden-if-present IDs:** `egui_demo_lib`, `egui_inspection`, `egui_system_font_provider` (only + with the disabled `system_fonts` feature) and `egui_demo_app` (workspace-only binary). Absence is a pass; if one + appears it must resolve from the same D1b revision. `egui_plot`/`egui_dock` are not workspace crates and are + pinned separately only if a consumer actually uses them; do not assume a 0.37 version number. +- **External registry identities are audited separately, not required to come from egui git:** `kittest`, `wgpu`, + `accesskit`, `accesskit_winit`, `accesskit_consumer` and the transitive `accesskit_ios`, `accesskit_macos`, + `accesskit_windows`, `accesskit_unix`, `accesskit_atspi_common`. The 0.37.0 lock resolves `kittest 0.5.0`, + `accesskit 0.25.1`, `accesskit_consumer 0.39.1`, `accesskit_winit 0.34.1`, `accesskit_atspi_common 0.21.0`, + `accesskit_unix 0.24.0`, `accesskit_macos 0.27.1`, `accesskit_windows 0.35.1` and `accesskit_ios 0.2.1`; the + workspace manifest's requirement strings are lower (`accesskit 0.25.0`, `accesskit_consumer 0.39.0`, + `accesskit_winit 0.34.0`). VectorCraft currently locks `kittest 0.4.0`, `accesskit 0.24.1`, + `accesskit_winit 0.32.2`. The gate checks resolved registry source, compatible version and a single identity; it + must **not** reject them for not being egui-git packages. +- Keep version requirements at `0.37` in `[workspace.dependencies]`, and add root `[patch.crates-io]` entries for + every fork-source crate used **and** `winit` to the final immutable revisions. This makes transitive + dependencies from `craft-ui`/`craft-window` resolve to the same source. A dependency's `[patch]` section is + ignored by the consumer. +- Do not use a branch or a machine-local path in publication. The winit fork's own patch is not inherited. +- Keep `vendor/windows-link` and its separate Windows 7 packaging check, and keep the existing hayro/krilla root + patches and PDF fixes. These are unrelated to the egui graph and must not be removed, folded into the fork pin, or + blanket-deleted with PdfCraft's vendors. Do not add `vendor` copies of egui/winit for VectorCraft. +- Add `cargo xtask ui-deps` (modelled on `xtask/src/pdf_deps.rs`) that runs `cargo metadata --locked` and checks a + canonical ledger of every package ID and source: required-used fork IDs, optional IDs with an `absent` or + `same-revision-if-present` rule, the external registry identities above, and the final `winit` rev. It rejects a + registry fork-family copy, a branch/source mismatch, a **missing required** package, an optional package from an + unexpected source, a duplicate `egui`/`eframe`/`egui-winit`/`egui-wgpu`/`winit`/`epaint`/`emath`/`ecolor`, an old + `craft-ui`/`craft-window`, an unexpected version, or a `path`/`file` source. A version string alone must not pass. + Add negative fixtures for a registry `epaint`, a registry `egui_extras` beside the git copy, a mixed family graph, + wrong external `kittest`/`accesskit*` versions, a missing family patch and an unapproved moving branch. Run it in + `cargo xtask ci`. +- `cargo tree -d`/`cargo tree -i egui` must show one egui, one epaint family and one winit, plus one compatible + external test/accessibility identity. Duplicate `skrifa`/`harfrust`/`glifo` versions are expected from epaint + 0.37 vs `vectorcraft-text`; record them and measure binary size. Do not force a text-engine downgrade without + evidence. +- VectorCraft keeps ownership of its document model, engine commands, undo stack, units, themes, font database, + workspace format and persistence. `craft-ui`/`craft-window` remain shared widget/window helpers; this migration + does not move app semantics into them. + +**Publication reachability:** a clean checkout with no cache must `cargo fetch --locked` the chosen revisions; +`--locked` builds must not update the lock. Recheck the remote commit/tree before merge. If a fork revision or +craft-libs revision is unavailable, stop and report; do not substitute a branch. + +### 4.2 Lifecycle: host iteration, input and UI + +Split the current `logic` into an explicit, testable boundary. Proposed surface in +`crates/ui-egui/src/lib.rs`: + +```text +VectorcraftApp::begin_host_iteration() // called from both hosts' raw_input_hook; root viewport only +VectorcraftApp::tick(ctx) // once per host iteration, full or logic-only; no raw input +VectorcraftApp::ui(ui) // first UI pass in an iteration consumes input; later passes draw only +``` + +Hosts: + +- `apps/vectorcraft/src/main.rs::App::raw_input_hook` calls `self.app.begin_host_iteration()` before injecting host + input and synthetic events; `App::logic` calls `self.app.tick(ctx)`. +- `apps/vectorcraft/src/main.rs::App::ui` calls `self.app.ui(ui)`. +- `apps/vectorcraft-web/src/web.rs::WebShell::raw_input_hook` calls `app.begin_host_iteration()` before taking its + pointer events; `WebShell::logic` calls `app.tick(ctx)` and keeps graphics-loss/restart and `api::poll_replies`. +- `WebShell::ui` keeps its DOM paste/drop handling and calls `app.ui`. +- **This matches the actual host order:** eframe calls `raw_input_hook` from `prepare_raw_input` in both + `EpiIntegration::update` and `EpiIntegration::update_logic_only`; web `AppRunner::logic` calls it before checking + visibility and before `run_ui`/`run_logic`. It is a host-iteration boundary, not a UI-phase marker. It is also + called for a child-viewport update; VectorCraft has only the root viewport, so `begin_host_iteration` must ignore + a non-root update if a child is ever added. It must never be used to infer that a UI pass will follow. +- Test helpers must preserve that order: `raw_input_hook`/`begin_host_iteration` once, then `run_ui` with + `tick` + `ui` inside the closure; logic-only runs `run_logic` with `tick` only. Do not call `tick` outside the + host-ordered closure in realistic tests. + +Both `tick` and `ui` keep the existing `vectorcraft_engine::guard::catch_panic` entry guard: a bug costs one +frame, rolls the document back and shows the existing status error; it must not close the app with unsaved work. + +**Counters (keep them distinct):** + +- `host_iteration`: increments once in `begin_host_iteration`, for every full or logic-only host update, including + repeated hidden iterations and forced hidden paint. It is not a frame or pass count. +- `accepted_ui_frames`: derived from `Context::cumulative_frame_nr()` for the root viewport, which advances once + when a `run_ui` completes and never in `run_logic`. Compare/store the previous value at `tick`; count only real + completed UI frames. Do not use `cumulative_pass_nr()` for this. +- `ui_pass_nr`: `Context::cumulative_pass_nr()` for diagnostics only; a discarded pass increments it, so it is not + an accepted-frame counter. +- Replace `app.frame` uses that mean "outer iteration": `native_menu::state_hash` uses `host_iteration`, and + artboard key-repeat deadlines use monotonic `now_secs` rather than a frame delta. If a compatibility `frame` + field remains, define it as `host_iteration` and document that it also advances on logic-only iterations. + +`tick` rules: + +- Runs when `host_iteration != tick_iteration`, which is exactly once per host iteration: once for a multi-pass + `run_ui` (the hook ran once before all passes), and once for each logic-only `run_logic` (the hook ran before the + visibility decision). Do **not** gate it on `ctx.cumulative_frame_nr()` or on `current_pass_index()`: after a + completed multi-pass frame, `current_pass_index()` is zero, and a hidden logic-only call does not restore a + nonzero pass index. +- Runs background polls (`picks`, `background`, `opening`, `plugin_runs`, `placing`, `screen_color`, `recovery`, + link watch, font check, control request drain, screenshot completion). +- Uses **monotonic host time** (`now_secs`, see below), never `ctx.input(|i| i.time)`. +- Reads only viewport state (`close_requested`, `focused`, `visible`) from `ctx.input`; never raw events, dropped + files or text input. Stale events from a previous UI pass must never be consumed here. +- If tick opens a dialog, a control request injects synthetic input or otherwise changes UI, or a screenshot + request is queued but not yet issued (including settling frames) while the viewport is not visible, send + `ViewportCommand::RequestPaintWhileHidden`; a plain repaint runs only logic for a hidden window, not the UI that + applies synthetic input, shows the dialog or issues the capture. An already-issued `Screenshot` command asks for + paint itself; every other queued `ui.*` request needs the explicit command. +- Keeps `ctx.request_repaint_after` scheduling for background jobs, autosave, recovery heartbeat and pending + screenshots. A logic-only iteration must not stop these. VectorCraft has no audio/playback; the background work + that must survive logic-only is file open, background save/export, plug-in runs, Place reads, link checks, font + checks, Data Recovery and control/screenshot completion. + +`ui` rules: + +- On the first UI pass of a host iteration (`input_iteration != host_iteration`), call `handle_input_once(ctx)` + before drawing, inside the same `file_access::confine` guard as `ui_frame` when `synthetic_frame` is set + (control-injected input must not escape the automation roots), then set `input_iteration = host_iteration`: + - `write_formats_before_text_input`; + - `native_menu::run`; + - `shortcuts::handle`, still gated by `fonts_ready` as today (do not run shortcuts before the UI fonts are + bound); + - native `take_dropped_files`; + - synthetic input is already in `raw_input_hook`; do not drain it again. +- A later pass of the same host iteration (a `request_discard` rerun) draws again but does **not** re-handle input + or re-run commands. This is the only place stale/current input is consumed; logic-only has no `ui`, so no input + is replayed. A forced hidden UI paint is still a UI phase and must consume the input it was forced to present. +- Draws the existing panel order. Multi-pass drawing is allowed; side effects in the drawing code must be + event-gated or deferred, not merely assumed idempotent. +- Measure `perf.frame_ms` on the first pass but finalize frame statistics only when `cumulative_frame_nr()` shows + the frame was accepted; do not count a discarded pass as another frame. + +Scrub undo scopes need an accepted-frame boundary: begin on the first pass that sees a drag, record the release +(and cancel flag) on that accepted pass, and commit the scope once in `tick` at the next host iteration boundary in +the document where it began. Do not commit from a pass egui may discard and rerun. Test a scrub release under a +forced second pass; the result must be exactly one undo step and no empty second scope. + +**Multi-pass regression:** add a test that forces `ctx.request_discard("test")` around a frame containing a click +on a toolbar/panel command and a new `Grid`, then asserts exactly one command/undo step and one `tick` for that +host iteration. If a second pass does fire a duplicate command, introduce a narrowly scoped deferred-action queue +for the affected UI action; do not add a global compatibility layer and do not set `max_passes = 1` (that would +accept a mislaid-out Grid). + +**Logic-only regression:** run a full frame with a pending shortcut/click (hook once, then `run_ui` with +`tick`/`ui`), then call `run_logic(&raw, |ctx| app.tick(ctx))` several times with a fresh +`begin_host_iteration` for each. Assert no command/undo/paste is repeated, background save/recovery/opening +continues, `tick` advances once per call rather than being blocked by a stale pass index, and timers use monotonic +time. Also assert no `ctx.input` event/time read occurs in `tick` (source/static check or a test clock). + +**Version-neutral test driver:** the 0.36.2 baseline has no `tick` API. Its driver must use the existing +`app.logic(ctx)` + `app.ui(ui)` inside `run_ui` and `app.logic(ctx)` inside `run_logic`, preserving the same hook +order (call the 0.36.2 host `raw_input_hook` where the trait provides it; otherwise call its iteration-boundary +helper before the pass). The candidate driver uses `tick` + `ui` inside `run_ui` and `tick` inside `run_logic`. +Record the exact lifecycle and pass count for both in the performance evidence; do not pretend the baseline had a +`tick` surface. + +**Monotonic clock:** add `now_secs()` beside `now_ms` using a process-start `Instant` (native) / `web_time::Instant` +(web). Use it for `system_paste_at`, `font_install_check_at`, `tick_link_updates`, `recovery::frame`, screenshot +deadlines and background scheduling. Keep egui's `i.time` for UI animation/gesture timing only. `recovery::tick`/ +`recovery::frame` already accept an explicit `now`; tests keep using that. + +### 4.3 Root Ui, panels, docking, viewports + +- Pass the existing `&mut Ui` from `App::ui` to panels; never call `Context::root_ui` inside `App::ui`, tests that + use `run_ui`, or any code that already holds the root. `root_ui` is only for a `Context`-only integration, which + VectorCraft does not have. +- Keep `egui::Area`/`Window::show(ctx)` for floating panels/dialogs: they create their own `Ui` and do not borrow + the root. Do not convert them to `root_ui` calls. +- Panel order remains app bar / control bar / status+hint / toolbar / dock / central canvas / floating / dialogs. + The new `Panel` id-salt behavior must not change the order or clip; assert `response.rect`, `ui.clip_rect()` and + hit positions after resize/DPI changes. +- VectorCraft has no `show_viewport_deferred`/child native viewport. Do not add one. Screenshot/root behavior is + single-viewport; still test two queued screenshot requests (issued one at a time) and hidden-window paint because + the callback contract is new. +- Docking/floating use `craft_ui::docking` and app `floating`; keep their layout model and permissions. The + `craft-ui` rebase must preserve the current `MoveFloating` group permission checks. +- Test: panels open/close/reorder, dock width restore, floating panel drag/dock, popup/flyout focus, window resize + and 1x/1.5x/2x scale; screenshots and geometry agree. + +### 4.4 Screenshot completion + +Implement §3.5. Details: + +- `PendingScreenshot` owns `Arc`; the slot holds `Option>`, a strong `Context`/owning-viewport wake handle for the + owning-viewport wakeup, and live/canceled state. The record keeps `path`, `data`, `reply`, `deadline`, settle + policy and a diagnostic id. No second token map. +- `queued_screenshots` becomes a FIFO of admitted requests; issue at most one `Screenshot` command at a time. +- `issue_screenshots` creates the callback in the UI thread. The closure captures **only `Weak`** and + receives pixels only. It writes the image into a live slot, releases the slot lock, then wakes the owning viewport + through the strong `Context`/owning-viewport wake handle held by the live slot. It must not capture `self`, strongly capture `Context`, wake + implicitly, encode PNG, touch `Services`, write files or hold a lock the UI thread needs. +- `collect_screenshots` is called from `tick`; it takes each completed image once, validates it against the request's + geometry epoch (logical/screen bounds, DPI, zoom, viewport and surface epoch; any change since issuance, including + a change back, rejects or retries), verifies pixel scale/aspect/rounding from the image dimensions, then writes on + the UI thread through `control::save_screenshot`, under `file_access::confine`. +- On timeout/close, remove the pending record and cancel the slot; a late callback fails the weak upgrade and the + image drops. Do not reply twice; do not write a file after timeout. +- Reserve the checked bytes **before** issuing with the shared defaults (4 pending requests, 128 MiB per image, + 256 MiB aggregate, no eviction); a stricter limit only when the documented intended surface fits. Release the + reservation when the record is drained, timed out or canceled, and reject a completion whose image exceeds it. +- Send `RequestPaintWhileHidden` from logic when a screenshot is queued but not yet issued, including settling + frames; the `Screenshot` command itself covers only the issued request, and callback arrival does not wake. +- **Use the shared craft-ui completion-slot helper from L2.** VectorCraft is one of the eleven existing craft-ui + consumers, so it adopts the CL-1 + CL-2F API before V2 and keeps only app-owned request records, paths/schemas, + confinement and settle policy around it. There is no app-local or provisional capture slot and no generic + compatibility map. +- Test with a fake completion channel and with a real kittest/eframe screenshot where available. Test hidden/minimized + behavior natively; on web, do not assume a background tab paints. Include a test that dropping the request record + or closing the window makes a queued/late callback a no-op, and that retained image bytes return to zero after + drain/shutdown. + +### 4.5 Clipboard and PRIMARY + +Implement §3.6. Keep the existing `Services::system_clipboard` abstraction and `sysclip` rules. Add: + +- eframe `clipboard` feature; +- `PasteImage`/`MiddleClickPaste` consumption; +- **W15P/E4 PRIMARY integration**: the winit fork's per-seat PRIMARY API on the existing connection/event queue, + routed through E4/P2b; no second regular `wl_data_device`, no arboard PRIMARY default, no first-seat fallback. + Password exclusion and no-unchanged-selection reclaim remain. +- Synchronous ownership claims: validate the fresh keyboard **or** pointer triggering serial and focused seat, + reserve source bytes, create the source and call `set_selection` in the same input turn. +- Bounded asynchronous reads/serving: 2 s total deadline from admission, 16 MiB cap, 4 in-flight reads, 2 writer + threads, queue depth 8, cancellation on owner/seat/focus/generation change and an owner-specific wake released + before waking. A completion is delivered only to the still-live paste request with unchanged input + owner/generation. +- tests for text+image, image-only, rich app copy, text-field copy canceling rich formats, PRIMARY and Wayland + ownership on wlroots/KWin/Mutter (per-seat keyboard and pointer focus/serials with origin and freshness, + owner/seat change, empty/absent offer, self-paste, slow/canceled/oversized transfer, one data device per seat). +- Do not enable eframe `system_fonts` for clipboard or font work. + +### 4.6 IME, text and RTL + +Implement §3.9: + +- Add the engine/tool deletion operation with a clear char/byte contract; use the fork's byte variant for E21. +- Consume the event in `shortcuts::type_text`; keep one undo step; preserve `ime_marked`/`ime_discard`. +- Implement the VectorCraft canvas `IMEOutput` producer for E21b: logical model text, preedit excluded, scalar/ + grapheme cursor and anchor converted to UTF-8 byte indices, 4,000-byte bounded excerpt, owner/context generation, + `Available` (including empty) vs `Unavailable`, and password/no-context clearing. It must not pass display-bidi + text and must not set the payload to `None` as a placeholder. The built-in `TextEdit` producer is inherited; the + canvas adapter is app-owned. +- Rely on the complete D1b W27 `egui-winit` adapter to send the outgoing payload and apply the egui P7b lifecycle + rules: owner identity with `Unavailable` still carrying identity; `Available` with empty text distinct from + `Unavailable`; the committed clear boundary on owner/unavailable/password changes; the latest completed pass, + including `None`, authoritative; a current-epoch mismatched serial applies incoming edits once and defers outgoing + state; an old-epoch batch is invalidated and cannot edit the new owner. Test outgoing state immediately after + delete+commit and deletion-only batches, focus/password clearing, long selections, UTF-8 boundaries, old-epoch + rejection, two windows and multi-pass final output. +- Test UTF-8 boundaries, multi-byte scripts, grapheme clusters, delete-only batches, commit after replacement, + cancel, focus changes, and two windows (if native). Use the locked `unicode-bidi 0.3.18` (Unicode 16) and + `unicode-segmentation 1.13.3` (Unicode 17) data, not “latest”. +- Test UI `TextEdit` IME separately from the canvas type tool; they use different code paths. +- Do not claim full RTL UI; test RTL names in LTR UI and document text through the existing engine tests. + +### 4.7 Fonts and glyphs + +- Enable `egui/default_fonts` explicitly in `crates/ui-egui`, and do not rely on kittest's own dev-dependency + features for downstream tests. +- Keep `theme::install_fonts` as the single binding point for `FONT_UI`, `FONT_UI_SEMIBOLD`, `FONT_MONO`; keep + `add_craft_fonts` and `UiFonts` fallback. +- Keep eframe `system_fonts` off; the app's `FontDb` and platform font modules remain responsible. +- Update `i18n/tests.rs` to the public `FontsView` API (`definitions`, `families`, `characters`). +- Update `font_menu` sample/offset code to `state.offset()`. +- Add a glyph-coverage test for the UI's own symbols (`→ ▾ ● ✓ ★ ☆ …`) and for CJK/RTL names; `interface-fonts` + and `bundled-fonts` on/off matrix. Do not add font files; fonts stay in `craft-fonts`. No new image/icon/font + asset is needed; `ASSETS.md` and `NOTICE` stay unchanged unless a reviewed, licensed asset is separately proposed. +- Use the shared fixture contract from `egui` plan §10.6: craft-fonts `2481a8a…` for Arabic, Thai, CJK SC and + Japanese, plus pinned Hebrew and Noto Color Emoji v2.047, each with recorded SHA256 and licence text. Fetch them + into the task fixture directory and enter them in the evidence manifest. Keep the app's own central-font policy: + `CRAFT_FONTS_DIR` may add fonts, but all builds/tests must still pass with `CRAFT_FONTS` empty and no fixture + copied into the repository. A system font-provider test is a separate nondeterministic row, not a pixel oracle. +- `egui_kittest`'s missing-glyph default is `Panic`; fix fixture fonts rather than globally allowing tofu. Use + `allow_missing_glyphs` only in a documented deliberate case. Production keeps `Tofu`, so glyph-coverage + assertions (not an expected panic) guard the shipped layout. +- Test color emoji/combining marks if the fixture uses them; preserve texture wrap/filter semantics (the + transparency grid uses `Repeat`). + +### 4.8 IDs and accessibility + +Implement §3.3, §3.10 and §3.4: + +- Mechanical `Id::new` → `Id::unique`; `ui.id()` → `ui.scope_id()`; salt-taking APIs get `IdSalt`. +- Keep stable keys for persisted temp state; `Id::unique` uses the same hash. Add a test that UI state/preferences + serialize identically before/after the rename and that a saved workspace restores. +- Replace `WidgetType` per §3.2; update tests to `WidgetInfo.role`. +- Add names to every visible input. Use `label_inputs_by` for rows and `name_inputs` for standalone fields. +- `row_label` returns its existing `ui.label` response; use `ui.label_inputs_by(label.id, |ui| …)` around + `field_w`/`length_field`/form-grid values. Do not add a duplicate label. Treat the custom-painted + `widgets::label_row` as a separate response/capture job. +- Keep role/name/value/selected/disabled/focus tests. Add hidden-control disappearance. +- `craft-ui` role changes are in the shared PR; review its `Other → Unknown` and `SelectableLabel → Button` + decisions semantically, and retain its permission tests. + +### 4.9 GPU and surface behavior + +- Keep `gpu::surface_limit` and `LOW_LATENCY`; the fork's E2/E3 also fits the surface and returns + `WgpuError::ConfigureSurface`/`CreateRenderer`. `gpu::finish` already treats any `eframe::Error::Wgpu` as a + graphics failure and restarts without the adapter; add tests for the two new variants. +- Keep `Dx12Compiler::Fxc` and the indirect-validation flag; 0.37's #8736 is an upstream report, not a license to + change renderer semantics. +- Keep `GraphicsLoss` recovery copies and restart path; test a device-lost callback still writes recovery copies. +- Test texture uploads/frees across DPI/resize, the canvas texture, transparency grid `Repeat`, and screenshot + readback. Measure renderer construction separately from first frame on Windows (FXC). + +--- + +## 5. Tests and regression assertions + +### 5.1 Gate commands + +Planning itself runs no build/test. Implementation must use the server's managed workflow +(`/home/codex/work/maintenance/task.py run/status/finish`) with its own task, source and evidence. Proposed checks: + +```text +cargo xtask ci +cargo xtask wasm +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace +cargo test -p vectorcraft-ui-egui --no-default-features --test install_fonts +cargo clippy -p vectorcraft-ui-egui --no-default-features --all-targets -- -D warnings +cargo test -p vectorcraft-text --no-default-features --test without_bundled_fonts +cargo xtask ui-deps +cargo xtask pdf-deps +CRAFT_FONTS_DIR=/absolute/craft-fonts cargo xtask ci +``` + +`cargo xtask ci` currently runs fmt, clippy, test, no-bundled-fonts, pdf-deps, assets, brands, cursors, layers and +wasm. Add `ui-deps` to that list. On Windows use the clone's toolchain and `--locked`; on Linux use separate +`CARGO_TARGET_DIR` allocations. Do not run a full workspace build inside another build. + +### 5.2 Headless and UI assertions + +Start with the tests that already encode behaviour: + +| Area | Existing tests/files | What to add | +|---|---|---| +| Whole-window frame | `crates/ui-egui/src/graphics.rs` `frame`, texture tests | tick/ui split, multi-pass, hidden paint, texture counts | +| Panels/dock | `panel_docking.rs`, `panel_docking_tests.rs`, `dock.rs` tests | dock width restore, panel order/clip, 1x/1.5x/2x, floating | +| Tabs/menus | `panels/tabs.rs` tests, `menus.rs` | keyboard/accessibility tree, menu geometry | +| Fields | `tests_numfields.rs`, `widgets.rs` tests | wheel, focus, names, roles, click-away selection | +| Canvas | `canvas.rs` tests, `tests_rotateview.rs`, `tests_scrub.rs` | wheel source, zoom/pan, multi-pass, IME | +| Dialogs | `dialogs/tests*.rs`, `dialogs/modal.rs` | sizing pass, names, focus, screen height | +| Clipboard | `tests_sysclip*.rs`, `tests_clipboard.rs`, `tests_pastechords.rs` | eframe clipboard feature, PasteImage, PRIMARY with per-seat keyboard and pointer serials | +| Saving/recovery | `tests_save.rs`, `tests_svgsave.rs`, `tests_recovery.rs` | save/reopen after migration, autosave under logic-only | +| Native menu | `tests_nativemenu.rs` | host-iteration count once, not per pass, menu sync | +| Automation | `tests_automation.rs`, `control_server.rs` tests | screenshot callback routing, timeout, hidden paint | + +Add a `tests_lifecycle.rs` (new) covering: + +- The test driver calls `raw_input_hook`/`begin_host_iteration` once, then `run_ui(|ui| { tick(ui.ctx()); ui(ui) })` + for a full frame, and `run_logic(|ctx| tick(ctx))` for a logic-only frame — the host's actual order. +- `tick` runs once per host iteration (including multi-pass and logic-only), never once per layout pass; logic-only + ticks but reads no raw input. +- A forced second pass does not duplicate a command/undo step. +- A shortcut delivered in a full pass is not replayed in subsequent `run_logic` calls. +- A full frame whose input is consumed, followed by hidden logic-only calls, leaves the consumed events consumed. +- Closing/unsaved prompt from a hidden window sends `RequestPaintWhileHidden`; a forced hidden UI pass consumes its + current input. +- Background save/open/recovery continues while hidden. +- `host_iteration`, `accepted_ui_frames` and `ui_pass_nr` are distinct; menu hashing and key repeat use the first + and monotonic time, not a layout-pass index. +- Baseline driver uses 0.36.2 `logic`/`ui` (no `tick`) and produces the same lifecycle/pass count record. + +Add a `tests_screenshots.rs` (new) covering: + +- one request → exactly one reply and one file; +- two requests → distinct images/paths, exactly two replies; +- timeout/close → late callback writes nothing and sends no second reply; +- dropping/canceling the request record makes a queued callback a no-op; retained image bytes return to zero; +- admission reserves 4/128/256 MiB checked bytes before issue; a fifth request or an oversized image is rejected + without evicting an admitted one, and its reservation is released; +- a resize, DPI, zoom, surface or viewport change before save rejects/retries, including a change back to the + original value; unchanged geometry derives scale from the returned dimensions and checks aspect/rounding; +- hidden/minimized behavior (native only, or a fake integration), including a queued-but-unissued request that + needs `RequestPaintWhileHidden` to reach issuance and a callback that does not wake by itself; +- the callback holds no strong `Context`/app: a drop test retains only the weak slot, and no PNG encoding, file + write or command runs on the callback thread; the live slot owns the strong `Context`/owning-viewport wake handle waker; +- the shared craft-ui helper (from L2, no app-local slot) passes the same correlation/cancel/close/epoch tests. + +Add accessibility tests in the existing `role_and_state` style and via kittest: + +- every visible input has a non-empty accessible name and a sensible role; +- checkbox mixed/checked/unchecked; radio selected; tab selected; slider value; combo value; +- disabled/hidden controls absent or marked disabled; +- keyboard Space/Enter/arrows activate the same command as a click. + +### 5.3 Visual/geometry assertions + +- No new committed raster goldens for the whole app. Cross-renderer pixel goldens are noisy and would require + asset/provenance review. Use geometry, clip rectangles, AccessKit trees and texture counts as committed + assertions; keep paired screenshots as migration evidence under + `/home/codex/work/ssd2/task-evidence/egui037-migration-20261022/vectorcraft/`. +- The shared component suite is separate: run `cargo test -p craft-ui --test visual_regression` on the L1 and L2 + revisions and require its 414 exact positive pairs (icon buttons, toolbar fitting, widget palette, SVG + preparation) plus 36 negative controls, comparing pixels and meshes with no tolerance or automatic baseline + update. App screenshot tolerances never apply to craft-ui's exact-pixel contract. +- For each reviewed visual change, capture unchanged 0.36, **stock 0.37.0 (`841a328b`)** and the candidate at the + same viewport, DPI, theme, fonts and renderer (the stock arm uses the same operation in a pinned minimal adapter + when fork-only code cannot compile there); compare with a justified tolerance (for example ≤0.5% differing pixels + on the same host/renderer), inspect the diff image, and record the reason. The stock arm attributes a difference + to upstream versus the fork. Never bulk-update or auto-accept a golden. +- Check: app bar/titlebar, toolbar, dock tabs, Properties panels, layers rows, color picker, a modal dialog, menu, + canvas rulers, transparency grid, selection handles, floating panel. Compare geometry/hit positions alongside + the image. + +### 5.4 Commands, undo, save and reopen + +- Preserve command ids, labels, menu paths, shortcuts, params and `enabled`; `UI_COMMANDS` unchanged. +- Add an end-to-end test: open `examples/feature-sheet.vectorcraft` (synthetic, committed), edit via command, + undo/redo, save as a temp `.vectorcraft`, close, reopen, assert document meaning, selection/view expectations, + undo semantics and no format change. Also do this through the control channel. +- Test the Type tool's IME replacement as one undo step and a save/reopen of the result. +- Test workspace/preferences persistence after `Id`/panel changes; old `UiState` JSON must load. +- Do not change the document format for this migration. + +### 5.5 Native and web workflows + +- **Linux X11:** run under a managed Xvfb/real X11 session at 1440×900, 1x; control channel; open/edit/save/reopen; + screenshot; Wayland separately under a compositor (Weston or the host session) at 1.5x/2x; test clipboard/PRIMARY, + IME if available, pen pressure if a device is available, window resize. Record compositor/version/driver. +- **Windows:** use a uniquely owned clone per the Craft Windows procedure; build the exact SHA; launch in the + interactive desktop session; drive the control channel; test save/reopen, native pickers, DPI, clipboard, + IME where installed, and a software-rendered path. Do not claim physical GPU evidence from the baseline. +- **macOS:** no macOS host or display is available here. Treat as a blocking acceptance gate for release, not a + pass. The existing unsafe `mac_window::center_buttons` may be replaceable by 0.37's + `Frame::set_traffic_lights_position`, but do not adopt that without macOS evidence; keep the existing code and + test it on real hardware later. +- **Windows 7:** compile/package only under `packaging/windows/windows7.ps1 -Test`; no support claim without a real + Windows 7 run. The `vendor/windows-link` patch remains. +- **Windows ARM64 and FreeBSD:** keep the existing `.github/workflows/windows-arm64.yml` and `freebsd.yml` checks: + compile/package and the smoke they actually run. Do not treat a cross/QEMU build as GUI, IME, clipboard or GPU + evidence; record those rows as unavailable unless a real machine is used. +- **Web:** `trunk build --release`, serve, use headless Chrome with `--remote-debugging-port`; exercise + `window.vectorcraft.openFile/execute/screenshot`, file drop, DOM paste, WebGPU and `?webgl`, save/download, and + screenshot. A compile-only wasm check is not a browser workflow. + +### 5.6 Fonts, IDs, IME and accessibility matrices + +- Fonts: `interface-fonts` on/off; `bundled-fonts` on/off; `CRAFT_FONTS_DIR` empty/set; Latin, CJK, Cyrillic, + combining marks, RTL names, emoji. Compare glyph presence, not just layout success. +- IDs: persisted temp state, workspace, panel width, selection, focused field, scroll offset, open popup. +- IME: preedit, commit, cancel, replacement bytes/chars, focus/serial changes, two windows, UTF-8 boundaries, + graphemes, RTL text; outgoing surrounding text after delete+commit and deletion-only batches, preedit exclusion, + long-selection/unavailable transitions, password clearing, current-epoch serial mismatch (incoming once, outgoing + deferred) and old-epoch rejection; final multi-pass output. +- Accessibility: name/role/value/selected/disabled/focus; hidden controls; keyboard and pointer paths. + +### 5.7 Mutation checks (prove the tests can fail) + +Make each deliberate defect in a local branch, run the relevant test, record the failure, then revert: + +1. Remove the new accessible name from a numeric field → kittest/role test fails. +2. Change a role from `CheckBox` to `Button` → role test fails. +3. Use one screenshot slot/channel for two requests → correlation test fails or one reply is lost. +4. Do not wake the owning viewport after a screenshot completion → pending test times out; capture a strong + `Context` in the callback → weak-slot/drop test detects the retained cycle; skip the geometry epoch → a + resize/DPI change back is accepted and the stale-image test fails. +5. Remove the panel-width update → dock restore test fails. +6. Force a duplicate command on a second pass → undo-count test fails. +7. Wrong clip/offset in a panel → geometry/hit test fails. +8. Remove `default_fonts` → `install_fonts` test fails its non-empty-family assertion (production would draw + tofu; it does not panic). +9. Read `ctx.input` events or `i.time` in `tick` → logic-only stale-input/timer test fails. +10. Insert a paste twice (PasteImage + system clipboard) → clipboard test fails. +11. Reuse a layout-pass index as the host-iteration gate → hidden multi-pass test shows a skipped tick. +12. Add a second `row_label` instead of returning the existing Label response → accessibility tree shows a + duplicate/unlinked label. +13. Set the E21b canvas payload to `None` or pass display-bidi text → outgoing surrounding-text test fails. +14. Accept an old-epoch `done` against the new owner → two-window/old-epoch test fails; treat `Available` empty as + `Unavailable` → empty-editor protocol test fails. +15. Fall back to the first seat or reuse a stale serial for a W15P claim → per-seat serial/focus test fails. + +Record the exact test name, command, expected/actual and the reverted diff in evidence. + +--- + +## 6. Performance + +### 6.1 Baseline before production edits + +Add a UI-frame harness before touching production code. Proposed: + +- `apps/vectorcraft-cli` gains an optional `ui-perf` feature/dependency on `vectorcraft-ui-egui` and + `apps/vectorcraft-cli/src/perf_ui.rs`, invoked as `vectorcraft-cli perf --ui --frames 1200 --json PATH`. +- It builds a deterministic `Session` with `perf::synthetic(50_000)`, installs `theme::install_fonts` and the same + `craft-fonts`/interface fonts as production, and runs a **version-neutral** frame driver on a fixed + `egui::Context`: baseline 0.36.2 calls `VectorcraftApp::logic` then `ui` inside `run_ui` and `logic` inside + `run_logic`; the candidate calls `tick` then `ui` inside `run_ui` and `tick` inside `run_logic`. Both invoke the + host `raw_input_hook`/iteration boundary once per frame, in the real host order. The baseline must not use a + nonexistent `tick` API or a shim that changes its lifecycle. +- Count one sample per accepted `run_ui` frame (use the driver's frame completion / `cumulative_frame_nr()` delta), + not per layout pass. Logic-only iterations are reported separately. Record the exact lifecycle, hook calls and + pass count for both sides. +- Fixed environment: 1440×900, 1x, dark theme, dock open, no hidden panels; `pixels_per_point` set explicitly; + `CRAFT_FONTS_DIR` fixed; same profile/features/compiler. +- Workloads: cold first frame/font load; 1,200 idle frames; 1,000 pan frames; 1,000 zoom frames; selection drag; + panel scroll; dialog open/close; text typing; save/reopen; screenshot request (where renderer available). +- Metrics: full `Context::run_ui` wall time, app `perf.frame_ms`, shape count, tessellation/vertex counts, + `textures_delta` bytes and texture count, peak RSS, idle repaint delay/count, logic-only tick time. +- Report p50/p95/p99 over ≥1,000 accepted frames; 10 warmups + 30 timed iterations for discrete operations; ≥20 + fresh processes for startup. Do not call a p99 from 20/30 samples a tail estimate. + +Also run the existing `vectorcraft-cli perf --paths 50000 --deep` (engine/render budgets) on the same fixture and +`vectorcraft-cli bench FILE` for a representative example. These measure different things; do not compare compile +time or CLI throughput to UI latency. + +### 6.2 Paired comparison + +- **Performance control:** the pre-migration control is current main `4a7cba0…` (the two-commit DXF delta) with its + coherent 0.36 lock, not `8906073…` alone. The candidate is the migration branch rebased onto `4a7cba0…` (or a + later main after the same gate). This keeps `crates/cad/src/import/curve.rs` identical on both sides and isolates + the egui migration. `8906073…` remains the historical planning baseline and rollback identity, not the paired + control. If the DXF delta cannot be included in the control, measure it separately and show it does not touch the + measured UI paths. +- Same compiler (1.95), profile, features (`wgpu`/`accessibility`, `interface-fonts`, `bundled-fonts`), fonts, + fixture, OS, renderer/driver, DPI and viewport. Record all three source SHAs, lock identities and the exact diff + between control and candidate (and the stock adapter, if any). +- **Three arms in the first baseline report and in every investigated regression:** unchanged 0.36, stock + 0.37.0 (`841a328b`) and the selected fork/candidate, with the same environment. The stock arm uses the same + operation in a pinned minimal adapter/harness when fork-only app code cannot compile unchanged; record the + adapter diff and any remaining semantic difference. App acceptance may still use the paired old/candidate arms, + but no upstream attribution is claimed without the stock arm. Keep stock results even when they are not the + acceptance pair. +- Use the shared performance protocol's floors: at least 10 warmups + 30 timed iterations for steady operations, + 20 fresh processes for startup, and at least 1,000 *accepted* frames for any frame distribution. Do not call a + p99 from 20/30 samples a tail estimate. Software and real-GPU results are separate. +- Separate software renderer (Windows WARP / lavapipe) and real GPU (i915 renderD128 on Linux if the chosen + Vulkan/GL backend works) results. No display session is inherited; qualify the backend through managed jobs. +- Alternate the control/candidate order, include the stock arm in the same environment, and run sequentially with + no concurrent builds. +- Predefine gates: investigate a repeatable >5% median or >10% p95 regression, or >5% peak-RSS increase above the + measured noise floor; a new idle busy-loop or repaint storm; or any existing absolute budget failure. For + sub-millisecond operations require >0.05 ms or corroborating allocation/instruction counts. Re-run noisy + comparisons and reject inconclusive ones; never widen a threshold or an expanding noise allowance after seeing a + failure. Existing `perf` budgets are absolute and remain in force. +- Measure renderer construction (DX12 FXC) separately from first frame; measure font discovery separately if + `system_fonts` is ever considered. +- Record raw samples, median/p95/p99, RSS, allocation/texture counters and load average for each arm. Retain + baseline, stock and candidate executables and logs outside disposable targets. + +### 6.3 Regression response + +If #8731-like text tessellation or #8736-like shader cost appears on our fixtures: + +- reproduce on unchanged 0.36, stock 0.37 and selected fork; +- isolate plain vs color-glyph rows, Repeat/MirroredRepeat semantics, first-frame vs steady state; +- only then consider a row-level fast path or explicit override constants; +- clamp-only sampling is not an acceptable fix; preserve wrap/filter semantics. + +--- + +## 7. Reviewable PR stages, dependency DAG and rollback + +### 7.1 Stages + +Every branch/tag push and every new PR in this table needs fresh, explicit human approval after the exact +publication and validation are shown. Planning, implementation and review do not grant publication approval. +Public text carries no agent/model/tool attribution. + +| Stage | Repository | Contents | Depends on | Must pass | +|---|---|---|---|---| +| V0 | VectorCraft | This plan only | — | Adversarial review; no code | +| V1 | VectorCraft | Version-neutral UI perf harness, lifecycle/screenshot/accessibility test scaffolding on **0.36.2**; baseline, `4a7cba0…` control and stock-0.37.0 captures | V0 | `cargo xtask ci` on baseline; baseline/control/stock perf report; no production behavior change | +| D1a | storytold/winit | Complete reviewed immutable winit: W1–W15 minus W5, plus W17–W27 including replacement W20, W15P, bounded transfer; provenance/PATCHES and clean-fetch proof | Independent winit adversarial review; upstream prerequisites | winit test suites, W15P/transfer contract tests, clean `cargo fetch --locked` | +| D1b | storytold/egui | Complete reviewed immutable egui **P8** (`craft/0.37.0-release`): P2/P3/P4/P5/P6, P2b (E4 regular + PRIMARY), P7 (E7/E19/E21), P7b (E21b), P7c (#8763/#8748); provenance/PATCHES and clean-fetch proof | D1a; independent egui adversarial review; P8 prerequisites | egui test suites, E21b/W27 and P7c regression tests, clean `cargo fetch --locked` | +| L1 | craft-libs | CL-1 coherent 0.37 API port on `3832c47…` (which already carries `8614e4a`/`1764bca`): `craft-platform` and security/settings/docking/printing fixes preserved, egui 0.37 pins to D1b; includes the CL-2 capture-slot prototype for the DesignCraft/GridCraft pilots (VectorCraft does not consume it) | D1b | craft-libs fmt/clippy/tests, `cargo test -p craft-ui --test visual_regression` (414 exact positives, 36 negative controls, no autoupdate), no security regression; packaged-manifest check; no stale PR199 tree | +| L2 | craft-libs | CL-2F final capture-helper API freeze after the DesignCraft/GridCraft adapters are reviewed; final immutable craft-libs revision on the L1 base | L1; adapter review | Shared capture contract tests; visual regression; final package/lock check; adversarial review | +| V2 | VectorCraft | Atomic compatibility PR: manifests, lock, root patches, `Id`/`Role`/`MouseWheel`/shared screenshot-helper adoption/fonts/scroll/panel-width changes, required E21 byte deletion and E21b canvas producer/owner lifecycle, **and the accessible names/glyph-fixture fixes required to keep kittest 0.37's strict tests green**; no blanket `with_accessibility_check(false)` | V1, D1a, D1b, L2 | `cargo xtask ci`, `ui-deps`, wasm, native build; exact test counts; rebase/DXF gate | +| V3 | VectorCraft | Lifecycle: host iteration/tick/input/ui, multi-pass/logic-only, hidden paint, monotonic clock, separate counters | V2 | Lifecycle tests; no command/undo duplicates; hidden background jobs | +| V4 | VectorCraft | Extended Clipboard/PRIMARY/PasteImage and native E21/E21b/text/RTL qualification; required canvas adapter already in V2 | V2/V3 | Clipboard/IME tests; native Wayland/X11/macOS-manual gate | +| V5 | VectorCraft | Broader accessibility audit/coverage (all names/roles, hidden/disabled, keyboard), panel width persistence, full font/glyph matrix | V2/V3 | `cargo test --workspace` with kittest strict; no-default-fonts gates | +| V6 | VectorCraft | Full native/web/Windows/Linux validation, performance, screenshots, docs/parity evidence; adversarial implementation review | V2–V5 | Full acceptance matrix; performance gates; review findings closed | +| V7 | VectorCraft | Remove superseded test helpers and local shims (never a capture slot); no behavior change | V6 | Same gates; diff review | + +V2 is deliberately atomic: a manifest/lock bump alone would leave `main` unbuildable. It also cannot postpone the +accessible-name fixes that `egui_kittest` 0.37 now enforces: the workspace tests would fail immediately. Do not keep +a blanket `with_accessibility_check(false)` as a staging shortcut. V3–V5 may land in parallel only if each branch +includes V2 and they do not touch the same lifecycle files; prefer sequential for review. V2 waits for the complete +D1a winit and D1b egui P8 revisions and for L2; no intermediate fork or craft-libs stage is a production pin. + +**Rebase gate before V2:** fetch current `storytold/vectorcraft` main, record its full SHA and diff against the +pinned review baseline. The recorded drift is exactly two commits from `8906073…` to `4a7cba0…`, both changing only +`crates/cad/src/import/curve.rs` (DXF spline). Rebase the migration branch onto that (or a later) main, preserve the +DXF fix, run the DXF import tests, and use the same `4a7cba0…` source as the performance control in §6.2. If more +files or dependency changes appear, stop and re-audit before implementation; do not drop or rewrite the unrelated +fix. + +### 7.2 Rollback + +- Before V2, preserve the exact baseline `Cargo.toml`, `Cargo.lock`, baseline/control/stock executables, D1a/D1b + fork revisions and L1/L2 craft-libs revisions outside disposable build dirs. Do not rely on `cargo update` to + reconstruct an old graph. +- Rollback is a revert of the complete V2 (or V2–V6) commits, not a version-string edit. The old 0.36.2 lock and + craft-libs revisions must remain reachable. Keep the old dependency set in a local branch/commit and in + evidence; do not delete it during build-cache cleanup. The `4a7cba0…` DXF fix is upstream work, not part of the + rollback. +- Per stage: **D1a** and **D1b** roll back by a new immutable revision on the prior base; never force-push or move + a published tag, and never delete a published revision. **L1** and **L2** roll back by a new craft-libs revision + on the previous base (L2 → L1 → `3832c47…`), preserving the prior capture-helper and PR199 port. **V2–V6** revert + the app commits to the coherent 0.36.2 lock and its craft-libs revisions. Record each revert revision, parent and + validation, and keep the superseded immutable revisions reachable. +- If a fork/craft-libs revision becomes unavailable, stop and restore the last coherent graph; do not substitute + a branch, path patch or a second egui version. +- No persistence format change is planned. If one becomes necessary, specify backward compatibility and keep + unreadable data, or abandon that part of the change. + +### 7.3 Documentation and evidence + +- `docs/architecture.md`: revise the UI/lifecycle section to describe host iteration, `tick`/input/`ui`, logic-only iterations, + root `Ui`, screenshot callbacks and the dependency patch policy. +- `docs/development.md`: record the selected egui/winit/craft-libs revisions, `ui-deps`, the UI perf harness and + any changed browser/Windows workflow. Keep the existing font rules (`craft-fonts`, `CRAFT_FONTS_DIR`). +- `docs/control-protocol.md`: update `ui.screenshot` only if native evidence changes the minimized/covered + behavior; keep the timeout/error contract otherwise. +- `ROADMAP.md` progress log and the touched docs' status/revision history: record the dependency migration. Do not + move `docs/target-app-parity.md` scores, `docs/gaps.md` entries or parity checklists unless a measured behavior + actually changes; this migration is infrastructure, not a feature claim. +- Keep all evidence under `/home/codex/work/ssd2/task-evidence/egui037-migration-20261022/vectorcraft/`: exact + source/lock identity, build/test logs with counts, screenshots, perf raw samples, dependency metadata, review + findings and commands. Do not put source or deliverables in disposable build targets. + +--- + +## 8. Blocking unknowns and acceptance criteria + +These are not “future work”; they block calling the migration done: + +1. **Complete final forks and reachability.** Approve and publish reachable immutable **D1a** `storytold/winit` + (W1–W15 minus W5, plus W17–W27 including replacement W20, W15P, bounded transfer) and **D1b** `storytold/egui` P8 (P2b, P7, P7b/E21b, + P7c, complete). Record exact full SHAs and tree hashes, preserve fork authorship/PATCHES, and prove a clean + `cargo fetch --locked`. No intermediate fork stage is a production pin; candidate aiongg revisions remain + research inputs; no final SHA is invented in this plan. +2. **Final craft-libs revision.** Finish the CL-1/CL-2F delta on the `3832c47…` base (which already carries the + selected `8614e4a`/`1764bca` changes), preserving `craft-platform`, security/settings/docking/printing fixes; + pin D1b and pass `craft-ui` visual regression. Do not merge PR199 head `b68de8c` or audit tree `ec8840a` as + trees. L1 (CL-1) and L2 (CL-2F) precede V2; publish only after shared gates and the capture-helper adapter + review. +3. **`default_fonts`/font provider.** `ui-egui` library tests and no-default-feature builds must bind non-empty + families; production `Tofu` would otherwise draw synthetic boxes, and downstream kittest tests do not inherit + kittest's own dev-dependency fonts. `system_fonts` remains off unless measured and approved; the shared + font-fixture identities and licences apply. +4. **kittest strict accessibility.** Every visible input in every test that renders it must have a name and a + correct role. This may require broad, reviewable field/row changes; `row_label` must return its existing label + response rather than add a duplicate. +5. **Capture callback semantics.** The app must prove exactly-one completion, hidden-window paint, timeout and + late-callback safety with a weak completion slot (the live slot owns the strong `Context`/owning-viewport wake handle waker; callback pixels + only; no strong `Context`), pre-issue checked-byte admission (4/128/256 MiB defaults), geometry-epoch binding + and rejection of any resize/DPI/zoom/surface change before save, and `RequestPaintWhileHidden` for a queued but + unissued request. Use the L2 shared craft-ui helper; no app-local slot. +6. **Clipboard/PRIMARY.** eframe `clipboard` must be enabled and must not duplicate the app's `SystemClipboard` + paste, especially for `PasteImage`. W15P/E4 must provide the winit-owned per-seat PRIMARY path with fresh + keyboard-or-pointer serials, synchronous claims, bounded 2 s/16 MiB/4-read/2-writer/queue-8 transfers and no + first-seat fallback; arboard PRIMARY is not an unmeasured default. +7. **IME replacement and outgoing text.** The canvas needs an engine-level delete-surrounding operation with a + precise char/byte contract and one undo step, plus the E21b model/preedit/UTF-8 payload producer and the + complete egui P7b/W27 lifecycle (owner identity, `Available` empty vs `Unavailable`, committed clear boundary, + latest-`None` authority, current-epoch mismatch and old-epoch rejection). UI `TextEdit` behavior is separate. +8. **Upstream rebase gate.** Current main is `4a7cba0…`, two commits ahead of the pinned review baseline, touching + only `crates/cad/src/import/curve.rs` (DXF spline). Rebase before V2, preserve that fix, rerun its import tests, + and isolate it from performance comparisons (§6.2). Recheck if main advanced further. +9. **macOS evidence.** No macOS host/display is available here. Keyboard layouts, IME (including press-and-hold), + Metal/GL, native pickers, termination and traffic-light positioning remain an explicit release gate. +10. **Windows 7.** 0.37 + glow + clipboard + the separate `vendor/windows-link` packaging patch must + compile/package and be smoke-tested; no support claim from a modern runner alone. Keep the hayro/krilla PDF + patches independent. +11. **Performance.** Paired control at `4a7cba0…` and candidate with the same engine delta, plus a stock-0.37.0 + arm in the first report and every investigated regression, using the shared sample floors and the 5% + median / 10% p95 / 5% RSS gates. Upstream #8731, #8736 and #8512 are reports, not results; do not claim a + regression or a fix without ours. +12. **Dependency identities.** Enumerate every fork-family package at one storytold source; audit external + `kittest`/`accesskit*` as compatible registry identities, not egui-git crates. Skrifa/harfrust/glifo duplicates + are expected; measure binary size and confirm no public type crossing. A new `egui`/winit duplicate is a hard + failure. +13. **Role semantics.** Review the `SelectableLabel → Button`, `Checkbox → CheckBox`, `CollapsingHeader → + DisclosureTriangle` and `Other → Unknown` mappings against actual screen-reader semantics, not just compile. +14. **Wayland/X11/GPU backend.** The host has an i915 render device but no inherited display session; a managed + Linux job must qualify the actual compositor/backend/DPI. Availability is not a qualified result. +15. **Web.** A wasm `check` is not a browser workflow; headless Chrome WebGPU/WebGL2, paste/drop, save/download and + screenshots are required. + +**Done means:** one coherent 0.37 graph; `cargo xtask ci` and `ui-deps` pass from a clean checkout; lifecycle +tests prove no duplicate/stopped work and no stale input replay; screenshots use the shared L2 weak slot and are +correlated, epoch-bound and admission-bounded; W15P/E21b/W27 and accessibility tests pass; native Linux/Windows and +browser workflows are recorded; performance is paired against the same-engine control with the stock-0.37 +attribution arm and within gates; macOS is either evidenced or explicitly unresolved; and the final adversarial +review has no open correctness finding. + +### 8.1 Dispositions of the adversarial review (`vectorcraft-v1`) + +This table records how every finding was resolved in this revision. It is a static documentation reconciliation; +no reviewer finding is treated as an executed test result or an implementation claim. + +| # | Finding | Disposition | +|---|---|---| +| 1 | E21b/P7b outgoing IME surrounding text missing | **Accepted and fixed.** Added E21b/W27 requirements, the canvas model/preedit/UTF-8 payload adapter, final-output/password/focus rules and tests in §3.9, §4.6, §5.2/§5.6, §8; D1b (P7b in P8) is a prerequisite. | +| 2 | Final storytold pins and craft-libs publication order not implementable | **Accepted and fixed.** D1a winit and D1b egui P8 precede the craft-libs CL-1 L1 and CL-2F L2; V2 depends on L2; no self-dependency; no invented SHAs; clean-fetch gate retained. §4.1, §7.1. | +| 3 | Required W15P/E4 PRIMARY integration absent | **Accepted and fixed.** W15P is a prerequisite, E4/P2b routes PRIMARY through it, second data device forbidden, bounded-transfer and compositor tests added. §3.6, §4.5, §8. | +| 4 | Screenshot callback violates weak-slot/cancel contract | **Accepted and fixed.** Replaced receiver/strong-`Context` design with a per-request weak completion slot, pre-issue pixel admission, aggregate limits and shared craft-ui helper after CL-2F. §3.5, §4.4, §5.2. | +| 5 | `raw_input_hook`/logic-only lifecycle premise is source-wrong | **Accepted and fixed.** Corrected to both hosts calling the hook before the logic-only decision; it is a host-iteration boundary, not a UI-phase marker. Separate host-iteration and accepted-UI-frame counters; stale input only in a known UI phase; test driver preserves host order; baseline uses `logic`/`ui`, not `tick`. §2.4, §4.2, §5.2, §6.1. | +| 6 | Whole-family pin gate not exhaustive | **Accepted and fixed.** Full fork-family allowlist plus separate external `kittest`/`accesskit*` registry audit; negative fixtures; version alone insufficient. §4.1. | +| 7 | The performance harness uses a nonexistent 0.36.2 `tick` API | **Accepted and fixed.** Version-neutral harness: baseline `logic`+`ui`, candidate `tick`+`ui`, same hook order, accepted-frame counting, same-engine control for isolation. §6.1/§6.2. | +| 8 | `row_label` is already a real `Label` | **Accepted and fixed.** Return the existing `ui.label` response and use `label_inputs_by`; do not duplicate the label; handle custom-painted `widgets::label_row` separately. §3.10, §4.8. | +| Minor | `Id::new` count stale | **Verified and corrected** to 294 occurrences across the three paths. §3.3. | +| Minor | Baseline date stale | **Verified and corrected** to 2026-10-23 11:50:04 +0700. Header/§2.1. | + +### 8.2 Dispositions of the adversarial review (`vectorcraft-v2`) + +Every finding in `plan-reviews/vectorcraft-v2.md` is closed below by source-grounded plan text, not a footnote. + +| # | Finding | Disposition | +|---|---|---| +| 1 | No stock-0.37.0 control arm | **Accepted and fixed.** Stock 0.37.0 (`841a328b`) is a measured arm in the first baseline report and every investigated regression, with the same environment and a pinned minimal adapter where fork-only code cannot compile; retained even when app acceptance pairs 0.36 with the candidate. §5.3, §6.2, §7.1 V1, §8.11. | +| 2 | Capture admission and geometry under-specified; 64 MiB too small | **Accepted and fixed.** Adopted the shared 4 requests / 128 MiB per image / 256 MiB aggregate defaults (stricter only if the documented intended surface fits), checked bytes reserved before issue, no eviction; records logical/screen bounds, viewport, DPI/zoom and surface epoch at issuance and rejects/retries any change before save, including change-back, then verifies scale/aspect. §3.5, §4.4, §5.2, §8.5. | +| 3 | W27 old-epoch invalidation missing | **Accepted and fixed.** Old-epoch `done` is rejected and invalidates that epoch, never edits the new owner; current-epoch mismatch applies incoming once and defers outgoing. §3.9, §4.6, §5.6, §5.7. | +| 4 | P7c ownership of #8763/#8748 absent | **Accepted and fixed.** P7c owns both fixes and their pointer/keyboard and hidden-parent regressions; D1b includes P7c, V2 depends on it, and an app workaround needs equivalent tests plus a removal condition. §3.11, §7.1, §8.1. | +| 5 | Empty-family font claim source-wrong | **Accepted and fixed.** Production `MissingGlyphPolicy` defaults to `Tofu`; the kittest harness alone defaults to `Panic`. Mutation 8 now expects the non-empty-family assertion failure; `theme.rs`'s "egui panics…" comment is scheduled for correction. §1, §3.8, §3.10, §4.7, §5.7, §8.3. | +| 6 | craft-ui visual regression and negative controls missing from L1/V2 | **Accepted and fixed.** L1/L2 run `cargo test -p craft-ui --test visual_regression` with 414 exact positive pairs and 36 negative controls; no tolerance or autoupdate, and app screenshot tolerances stay separate. §5.3, §7.1. | +| 7 | `ui-deps` ledger conflates required and optional packages | **Accepted and fixed.** Required-used, optional/forbidden-if-present and external registry identities are separate ledger classes; the transitive `accesskit_*` platform crates are audited; a missing optional package passes. §4.1. | +| 8 | D1 not split into winit then egui | **Accepted and fixed.** D1a winit precedes D1b egui P8, each with its own review, SHA, tree hash and clean-fetch proof; V2 depends on D1a, D1b and L2. §4.1, §7.1, §8.1. | +| 9 | Rollback is app-only | **Accepted and fixed.** Per-stage rollback: new immutable revisions for D1a/D1b and L1/L2, never force-push or move published tags; V2–V6 revert to the coherent 0.36.2 lock. §7.2. | +| 10 | Dead provisional app-local capture slot | **Accepted and fixed.** Removed: VectorCraft adopts the L2 helper only and keeps no app-local or provisional slot. §3.5, §4.4, §7.1 V7. | +| 11 | Clipboard rationale overstates impact | **Accepted and fixed.** Reworded to egui-winit's text clipboard/`Event::Paste` path; the arboard `SystemClipboard` remains the independent rich-format owner and the textless-paste tests stay. §3.6. | +| 12 | W15P synchronous ownership details absent | **Accepted and fixed.** Fresh keyboard-or-pointer serial and focused seat, synchronous claim in the triggering turn, no first-seat fallback; bounded async reads 2 s/16 MiB/4 reads/2 writers/queue 8, cancellation with owner-specific wake. §3.6, §4.5, §5.7, §8.6. | + +--- + +## 9. Source anchors and citations + +Primary sources inspected (read-only): + +- `/home/codex/work/CraftApps/egui037-migration-20261022/PLANNING-CONTRACT.md` +- `/home/codex/work/CraftApps/egui037-migration-20261022/vectorcraft` (assigned checkout) +- `/home/codex/work/CraftApps/egui037-migration-20261022/egui` at `0.37.0` and `aiongg/craft/0.37` +- `/home/codex/work/CraftApps/egui037-migration-20261022/winit` at `8b35388…`/`ffae519…` +- `/home/codex/work/CraftApps/egui037-migration-20261022/craft-libs-implementation` at `3832c47…` (the local + implementation base, with the uncommitted CL-2F helper) and the historical PR 199/audit branches +- `craft-libs/docs/egui-037-suite-rollout.md` and `craft-libs/docs/egui-037-migration-plan.md` +- `egui/docs/egui-037-migration-plan.md` (E21b, W15P, family pins, §10.6 font fixtures) +- `/home/codex/work/ssd2/task-evidence/egui037-migration-20261022/` (upstream discussions, text-input research, + lifecycle audit, IME-surrounding gap, remote bases) +- `/home/codex/work/ssd2/task-evidence/egui037-migration-20261022/lifecycle-source-audit.md` +- `/home/codex/work/ssd2/task-evidence/egui037-migration-20261022/ime-surrounding-gap.md` +- `/home/codex/work/ssd2/task-evidence/egui037-migration-20261022/remote-plan-bases-20261023-0632.json` +- `/home/codex/work/ssd2/task-evidence/egui037-migration-20261022/plan-reviews/vectorcraft-v1.md` +- `/home/codex/work/ssd2/task-evidence/egui037-migration-20261022/plan-reviews/vectorcraft-v2.md` +- `/home/codex/work/ssd2/task-evidence/egui037-migration-20261022/craft-fonts-manifest-2481a8a.txt` +- `/home/codex/work/ssd2/task-evidence/egui037-audit-20261022/{fork,stock}/vectorcraft.errors` + +Public upstream references (immutable where possible): + +- egui 0.37.0 changelog: `https://github.com/emilk/egui/blob/841a328ba2dd8f6996beb7189ebb7fc00e668a2a/CHANGELOG.md` +- `App::logic`/`App::ui`: `https://github.com/emilk/egui/blob/841a328ba2dd8f6996beb7189ebb7fc00e668a2a/crates/eframe/src/epi.rs` +- `Context::run_ui`/`root_ui`/`run_logic`: `https://github.com/emilk/egui/blob/841a328ba2dd8f6996beb7189ebb7fc00e668a2a/crates/egui/src/context.rs` +- screenshot callback: `https://github.com/emilk/egui/blob/841a328ba2dd8f6996beb7189ebb7fc00e668a2a/crates/egui/src/data/screenshot_callback.rs` +- event/`MouseWheelSource`: `https://github.com/emilk/egui/blob/841a328ba2dd8f6996beb7189ebb7fc00e668a2a/crates/egui/src/data/input/event.rs` +- `Id`/`IdSalt`: `https://github.com/emilk/egui/blob/841a328ba2dd8f6996beb7189ebb7fc00e668a2a/crates/egui/src/id.rs` and `id_salt.rs` +- panel ids: `https://github.com/emilk/egui/blob/841a328ba2dd8f6996beb7189ebb7fc00e668a2a/crates/egui/src/containers/panel.rs` +- `WidgetInfo`/roles: `https://github.com/emilk/egui/blob/841a328ba2dd8f6996beb7189ebb7fc00e668a2a/crates/egui/src/data/output.rs` +- `is_input`: `https://github.com/emilk/egui/blob/841a328ba2dd8f6996beb7189ebb7fc00e668a2a/crates/egui/src/accessibility.rs` +- `FontsView`: `https://github.com/emilk/egui/blob/841a328ba2dd8f6996beb7189ebb7fc00e668a2a/crates/epaint/src/text/fonts.rs` +- kittest strict names/glyphs: `https://github.com/emilk/egui/blob/841a328ba2dd8f6996beb7189ebb7fc00e668a2a/crates/egui_kittest/src/builder.rs` +- upstream discussions collected in `/home/codex/work/ssd2/task-evidence/egui037-migration-20261022/upstream-discussions.json` + (#8731, #8736, #8763/#8764, #8748, #8512, #8657, #8577) +- suite rollout: local `craft-libs/docs/egui-037-suite-rollout.md` planning draft (link only after it lands) +- craft-libs PR 199: `https://github.com/storytold/craft-libs/pull/199`; issue 198: + `https://github.com/storytold/craft-libs/issues/198` + +Fork provenance: + +- egui fork `PATCHES.md` at `a4f9a17…`: E1 viewport hooks, E2+E3 surface fit/errors, E4 Wayland clipboard, + E5/E12 bidi, E6 IME focus, E7/E19 Latin keys, E8 glyph clipping, E9 RTL UI, E10 web keys, E11 single-threaded + vello, E13 grapheme editing, E14 fallback baseline, E15 line breaker, E16 cursor affinity, E17 language, + E18 UI language, E20 widget language, E21 IME delete bytes. The final fork additionally owns **E21b** outgoing + IME surrounding text, which is not present in the candidate. +- winit fork at `8b35388…` has W1–W27 as inspected but not the new **W15P** PRIMARY API or the W27 lifecycle/serial + revisions. The final winit fork must add both; the candidate branch is not a production pin. +- winit fork `PATCHES.md` at `8b35388…`: W1, W2, W3–W10, W11, W12, W13, W14, W15, W17–W27. Relevant to VectorCraft: + W1/W4 (Windows DPI/hidden), W2/W13/W15 (Wayland text/clipboard/drop/wheel), W5–W10/W18–W22 (IME), W11 (X11 DPI), + W14 (macOS termination), W23 (⌘ keys), W24 (`reset_ime`), W25–W27 (Latin/IME surrounding text). +- PR 199 port commit `8614e4a` (author Davis Brief): `WidgetType` → `Role`, `UiBuilder::id`/`Ui::id` → `scope_id`, + `Id::new` → `Id::unique`; test-only U+22EE → `…` because 0.37's bundled fonts dropped it. The U+22EE change is + test-fixture only; VectorCraft production source has no U+22EE (checked). + +--- + +## 10. Open decisions to confirm with the owner/reviewer + +These are decisions this plan recommends but does not silently make: + +- Use the full selected fork (including E5/E12/E21 and the new E21b) even though VectorCraft's UI is LTR. + Recommended: yes, for one suite graph and correct IME; prove UI text layout with tests. +- Keep eframe `system_fonts` off. Recommended: yes, until measured and licensed/tested; the app already has a font + database. The shared pinned fixtures, not system fonts, are the test oracle. +- Keep `links` off on native (existing behavior); add only if a native `ctx.open_url` path is shown to need it. +- Replace `mac_window::center_buttons` with `Frame::set_traffic_lights_position` only after macOS evidence. + Recommended for this migration: no. +- Serialize screenshots (one outstanding) rather than supporting concurrent captures. Recommended: yes; simpler + correlation and memory bound. Use the shared weak completion slot, never a strong `Context` in the callback. +- Add `PasteImage` handling rather than disabling eframe's image clipboard. Recommended: yes, with dedupe tests. +- Keep the app's `SystemClipboard` as the rich-format owner for regular clipboard on Wayland, while the selected + W15P path owns PRIMARY. Recommended: yes; this is the selected design, not an unresolved ownership test. +- Adopt the shared craft-ui capture helper from L2 (CL-1 + CL-2F); no app-local capture slot. + Recommended: yes, for all eleven craft-ui consumers. + +--- + +## 11. Revision history + +| Date | Change | +|---|---| +| 2026-10-23 | Initial source-grounded planning draft. No implementation, build, test or GUI run. | +| 2026-10-23 | Revision 2: resolved every `vectorcraft-v1` finding (§8.1); corrected the lifecycle model (`raw_input_hook` in both hosts, separate host/accepted-frame counters, host-ordered test driver); added E21b and W15P/E4 prerequisites; moved the weak capture slot to the shared craft-ui contract; fixed final-fork → final craft-libs/CL-2F → app ordering; enumerated family and external registry identities; corrected `row_label` and the `Id::new` inventory; recorded the pinned review baseline, `4a7cba0…` drift/rebase gate and same-engine performance control. Static documentation only; no build/test run. | +| 2026-10-23 | Revision 3: resolved every `vectorcraft-v2` finding (§8.2) by replacing contradictory prose: stock-0.37 third arm in visual/performance; shared 4/128/256 MiB capture admission with epoch-bound geometry and hidden paint request; W27 old-epoch invalidation and complete E21b contract; P7c owns #8763/#8748; corrected production `Tofu` vs kittest `Panic` and non-propagating kittest dev-dependency fonts; craft-ui exact-pixel visual regressions; split `ui-deps` ledger; split D1a winit / D1b egui P8; per-stage rollback; removed the dead provisional capture slot and app-local adapter; corrected clipboard scope; added W15P synchronous claims and bounded transfers; set 5% median/10% p95/5% RSS gates with no tolerance widening. Static documentation only; no build/test run. | +| 2026-10-24 | Revision 4: implementation status; repaired the final upstream-review defects (path+data capture downgrade, image-paste repaint and paste-time chord, monotonic hidden tick, IME context invalidation and unavailable revisions, per-viewport exact-once input, Artboard and Split Grid accessible names, fail-closed stale synthetic input); moved the shared-library base reference to the local `3832c47…` implementation base. The exact tree still needs fresh managed verification before publication. | + + +## Local implementation update — 2026-10-23 + +The user clarified that reviewed plans remain local and implementation proceeds before publication. The earlier requirement to publish/land this plan first is superseded; pushes and PR publication still require explicit approval. + +Canonical `storytold/vectorcraft` `main` now resolves to `125df6d8ecfae01d01ce369ac02e4aa03e0deed5`. The owned checkout was fast-forwarded to this revision. Earlier baseline SHAs and line anchors above are retained as historical review evidence; use this new revision and its exact lockfile for current-main integration and paired measurements. The original refs remain reachable. + +Preserve supplied PDF passwords on confirmation at125df6d8, in addition to the previously recorded DXF fixes. Add dialog regression coverage without recording password contents. + +The complete static delta review and exact old/new object identities are retained in the task evidence: `canonical-diffs-refresh-20261023.json` and `overnight-consumer-review-20261023.md`. This amendment claims no new build, native GUI or performance acceptance. + +## Request-bound Type tool IME reset — local implementation, 2026-10-24 + +An interrupted incoming composition now retains the complete ID returned by +`Context::request_ime_reset`. Only its matching `Event::ImeResetCompleted`, in raw event order, +admits subsequent IME preedit/commit/surrounding-delete events. Old terminals, empty preedit, +idle frames and host cancellation return values do not acknowledge the request. A later caret, +target, committed-context or Context change supersedes the old ID. Pointer/navigation/command +notices keep the remaining raw suffix blocked until their actual handler establishes the final +destination. Ordinary uninterrupted composition keeps its existing key ownership. + +Each UI pass observes its actual final custom descriptor, including discarded passes and exits +without geometry. Losing custom publication to absence, hidden/focus state or a stock field +retires a pending ID; returning to the same target/caret requests a new ID once. Hooks installed +before the host's first UI pass distinguish its preceding logic callback from standalone hidden +logic, which cannot claim fresh UI geometry. The callbacks capture no Context or app. + +The regression source covers the previously failing click-A→B/fresh-が path with a real returned +fence, ordered fresh suffixes without an old terminal, stale/foreign/duplicate IDs, pressed +navigation and shortcuts, discarded/stock/focus reactivation, graphics replacement, external +edits at the same caret, and full-pass versus standalone logic. This local update is pending +dependency refresh, formatting, compilation, tests and independent implementation review. Native +and browser IME acceptance remains separate: unsupported integrations, reset failures or missing +acknowledgements keep interrupted IME mutations blocked; elapsed time or a nonempty preedit never +provides a fallback acknowledgement. diff --git a/docs/pdf-backend.md b/docs/pdf-backend.md index 14263a82d..d6b40c58a 100644 --- a/docs/pdf-backend.md +++ b/docs/pdf-backend.md @@ -51,9 +51,9 @@ warnings also become errors before a document is returned. Whitespace and commen EOF remain valid. The typed iterator does not classify every malformed operator, so this is known-incomplete rejection rather than a general PDF validity certificate. -The three patched Hayro crates and Krilla are selected in the root `[patch.crates-io]` +The five patched Hayro crates and Krilla are selected in the root `[patch.crates-io]` from the same immutable revision as `craft-pdf`. `cargo xtask pdf-deps` rejects missing, -duplicate, local-path, registry and mismatched revisions for those five packages. +duplicate, local-path, registry and mismatched revisions for those seven packages. `craft-print` and `craft-ui` keep their existing independent revisions. Dependency asset hashes and sources are in `ASSETS.md`; redistributed license texts are diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index ea333e089..93b440aec 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -122,9 +122,6 @@ if has appimage; then fi APPDIR="$WORK/VectorCraft.AppDir" cp -R "$STAGE" "$APPDIR" - mv "$APPDIR/usr/share/doc" "$WORK/doc-unused" - # Keep the third-party runtime notice after dropping the full documentation tree. - install -Dm644 "$ROOT/NOTICE" "$APPDIR/usr/share/doc/vectorcraft/NOTICE" ln -s usr/bin/vectorcraft "$APPDIR/AppRun" cp "$HERE/$APP_ID.desktop" "$APPDIR/$APP_ID.desktop" cp "$ROOT/assets/app-icon/hicolor/256x256/apps/$APP_ID.png" "$APPDIR/$APP_ID.png"