diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c704eb4d8..f1c204ff7 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: # The same craft-fonts commit as release.yml and freebsd.yml, so the gate tests the fonts that ship. - craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 packages: >- pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev libxi-dev libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 054491322..15a310a49 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -29,4 +29,4 @@ jobs: # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main with: - craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 83b5efab9..168408d87 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,7 @@ jobs: with: app-name: gridcraft app-title: GridCraft - craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695 + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 version: ${{ inputs.version }} secrets: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} diff --git a/Cargo.lock b/Cargo.lock index 7f5203188..236ca5bc8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,21 +4,21 @@ 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", "serde", @@ -27,43 +27,37 @@ dependencies = [ [[package]] name = "accesskit_consumer" -version = "0.35.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344" dependencies = [ "accesskit", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] -name = "accesskit_consumer" -version = "0.36.0" +name = "accesskit_ios" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937" dependencies = [ "accesskit", - "hashbrown 0.16.1", -] - -[[package]] -name = "accesskit_consumer" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" -dependencies = [ - "accesskit", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", + "objc2 0.5.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", @@ -129,7 +124,7 @@ checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" dependencies = [ "cipher", "cpubits", - "cpufeatures", + "cpufeatures 0.3.1", ] [[package]] @@ -431,6 +426,48 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.10.0" @@ -458,6 +495,15 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block-buffer" version = "0.12.1" @@ -619,13 +665,29 @@ dependencies = [ "libc", ] +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + [[package]] name = "chrono" 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]] @@ -634,7 +696,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ - "crypto-common", + "crypto-common 0.2.2", "inout", ] @@ -647,6 +709,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" @@ -696,6 +791,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" @@ -706,6 +807,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" @@ -719,7 +830,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", @@ -732,16 +843,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "libc", ] +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "cpubits" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.1" @@ -754,19 +880,32 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d#d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" +source = "git+https://github.com/storytold/craft-libs?rev=c488caa9fbc2fb1a86e4674887e9b80b598ebbfa#c488caa9fbc2fb1a86e4674887e9b80b598ebbfa" dependencies = [ + "craft-winprint-gdi", + "miniz_oxide 0.8.9", + "roxmltree", "tempfile", + "winprint", + "winsafe", ] [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d#d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" +source = "git+https://github.com/storytold/craft-libs?rev=c488caa9fbc2fb1a86e4674887e9b80b598ebbfa#c488caa9fbc2fb1a86e4674887e9b80b598ebbfa" dependencies = [ "egui", ] +[[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.2" @@ -776,6 +915,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" @@ -816,6 +967,16 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "crypto-common" version = "0.2.2" @@ -831,6 +992,30 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[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 = "derive_arbitrary" version = "1.5.0" @@ -855,14 +1040,24 @@ dependencies = [ "rayon", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + [[package]] name = "digest" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.12.1", + "crypto-common 0.2.2", ] [[package]] @@ -883,6 +1078,17 @@ dependencies = [ "objc2 0.6.5", ] +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "dlib" version = "0.5.3" @@ -914,10 +1120,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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "bytemuck", "emath", @@ -925,9 +1136,8 @@ dependencies = [ [[package]] name = "eframe" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "ahash", "bytemuck", @@ -949,6 +1159,7 @@ dependencies = [ "pollster 1.0.1", "profiling", "raw-window-handle", + "send_wrapper", "static_assertions", "wasm-bindgen", "web-sys", @@ -960,9 +1171,8 @@ dependencies = [ [[package]] name = "egui" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "accesskit", "ahash", @@ -980,9 +1190,8 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "ahash", "bytemuck", @@ -1000,30 +1209,33 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "accesskit_winit", "arboard", + "block2 0.6.2", "bytemuck", "egui", + "image", "log", "objc2 0.6.5", + "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", + "parking_lot", "profiling", "raw-window-handle", "smithay-clipboard", "web-time", + "windows-sys 0.61.2", "winit", ] [[package]] name = "egui_glow" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "bytemuck", "egui", @@ -1035,9 +1247,8 @@ dependencies = [ [[package]] name = "egui_kittest" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "dify", "eframe", @@ -1062,13 +1273,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=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" 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" @@ -1098,9 +1322,8 @@ dependencies = [ [[package]] name = "epaint" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "ahash", "bytemuck", @@ -1116,16 +1339,17 @@ dependencies = [ "self_cell", "skrifa", "smallvec", + "unicode-bidi", "unicode-general-category", + "unicode-properties", "unicode-segmentation", "vello_cpu", ] [[package]] name = "epaint_default_fonts" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" [[package]] name = "equivalent" @@ -1205,6 +1429,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.14" @@ -1217,6 +1451,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" @@ -1228,6 +1468,28 @@ dependencies = [ "zlib-rs", ] +[[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 = "fmv-macos-events" version = "0.1.0" @@ -1239,6 +1501,12 @@ dependencies = [ "objc2-foundation 0.3.2", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -1287,6 +1555,31 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_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" @@ -1323,6 +1616,12 @@ dependencies = [ "syn 3.0.6", ] +[[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" @@ -1336,12 +1635,25 @@ 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", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -1361,6 +1673,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" @@ -1380,8 +1705,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -1397,9 +1725,9 @@ dependencies = [ [[package]] name = "glifo" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" +checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" dependencies = [ "bytemuck", "foldhash 0.2.0", @@ -1627,7 +1955,7 @@ dependencies = [ "gridcraft-formula", "serde", "serde_json", - "sha2", + "sha2 0.11.0", "thiserror 2.0.21", ] @@ -1690,8 +2018,8 @@ dependencies = [ "gridcraft-formula", "gridcraft-model", "quick-xml", - "sha1", - "sha2", + "sha1 0.11.0", + "sha2 0.11.0", "thiserror 2.0.21", "zip", ] @@ -1705,6 +2033,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" @@ -1758,6 +2105,12 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.5.3" @@ -1770,6 +2123,45 @@ 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 = "hybrid-array" version = "0.4.15" @@ -1779,6 +2171,196 @@ dependencies = [ "typenum", ] +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "image" version = "0.25.10" @@ -1814,6 +2396,12 @@ dependencies = [ "hybrid-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" @@ -1956,12 +2544,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]] @@ -2034,6 +2622,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + [[package]] name = "litrs" version = "1.0.0" @@ -2055,6 +2649,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 = "memchr" version = "2.8.3" @@ -2079,6 +2679,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" @@ -2099,6 +2705,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" @@ -2127,6 +2744,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" @@ -2569,6 +3192,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" @@ -2637,6 +3266,25 @@ dependencies = [ "windows-link", ] +[[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" @@ -2669,9 +3317,9 @@ dependencies = [ [[package]] name = "phf" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ "phf_macros", "phf_shared", @@ -2680,9 +3328,9 @@ dependencies = [ [[package]] name = "phf_generator" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" dependencies = [ "fastrand", "phf_shared", @@ -2690,9 +3338,9 @@ dependencies = [ [[package]] name = "phf_macros" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" dependencies = [ "phf_generator", "phf_shared", @@ -2703,9 +3351,9 @@ dependencies = [ [[package]] name = "phf_shared" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" dependencies = [ "siphasher", ] @@ -2822,6 +3470,15 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + [[package]] name = "presser" version = "0.3.1" @@ -2837,6 +3494,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" @@ -2873,6 +3554,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.61.2", +] + [[package]] name = "quote" version = "1.0.47" @@ -2894,6 +3632,38 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +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 = "range-alloc" version = "0.1.5" @@ -3011,6 +3781,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 = "rfd" version = "0.17.2" @@ -3038,6 +3851,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + [[package]] name = "rustc-hash" version = "1.1.0" @@ -3085,12 +3918,99 @@ dependencies = [ "windows-sys 0.61.2", ] +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +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.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-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]] name = "rustversion" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu" +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" @@ -3100,6 +4020,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -3112,6 +4041,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[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" @@ -3124,6 +4076,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" @@ -3167,6 +4125,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" @@ -3187,6 +4156,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 0.10.7", +] + [[package]] name = "sha1" version = "0.11.0" @@ -3194,8 +4186,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -3205,8 +4208,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -3225,6 +4228,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 0.10.7", + "pem", + "rand_core 0.10.1", + "sha2 0.10.9", + "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" @@ -3356,6 +4528,16 @@ dependencies = [ "serde", ] +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "spirv" version = "0.4.0+sdk-1.4.341.0" @@ -3365,12 +4547,65 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "static_assertions" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[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 = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[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" @@ -3393,6 +4628,26 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "sys-locale" version = "0.3.2" @@ -3402,6 +4657,38 @@ dependencies = [ "libc", ] +[[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" @@ -3478,13 +4765,79 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +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.53.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" 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]] @@ -3538,6 +4891,51 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +[[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" @@ -3581,6 +4979,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 = "type-map" version = "0.5.1" @@ -3607,6 +5011,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-general-category" version = "1.1.0" @@ -3619,6 +5029,12 @@ version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -3631,6 +5047,36 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "uuid" version = "1.27.0" @@ -3644,9 +5090,9 @@ dependencies = [ [[package]] name = "vello_common" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" +checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd" dependencies = [ "bytemuck", "fearless_simd", @@ -3659,9 +5105,9 @@ dependencies = [ [[package]] name = "vello_cpu" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" +checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" dependencies = [ "bytemuck", "glifo", @@ -3685,6 +5131,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" @@ -3905,6 +5366,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -4186,6 +5656,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -4381,8 +5862,7 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winit" version = "0.30.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +source = "git+https://github.com/storytold/winit.git?rev=ae48c756a3b81125eb796c0cc6dccf60990d1181#ae48c756a3b81125eb796c0cc6dccf60990d1181" dependencies = [ "ahash", "android-activity", @@ -4393,7 +5873,7 @@ dependencies = [ "calloop 0.13.0", "cfg_aliases", "concurrent-queue", - "core-foundation", + "core-foundation 0.9.4", "core-graphics", "cursor-icon", "dpi", @@ -4438,6 +5918,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.31" @@ -4448,6 +5949,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "winsafe" +version = "0.0.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef0ffc427f045c0cc9ebffd6f4f91153dcd2a1547b5c3c29ee3f541e16e95c6" + [[package]] name = "wit-bindgen" version = "0.57.1" @@ -4472,6 +5979,12 @@ dependencies = [ "wayland-protocols-wlr", ] +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + [[package]] name = "x11-dl" version = "2.21.0" @@ -4504,6 +6017,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 0.10.7", + "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" @@ -4529,6 +6077,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" @@ -4543,6 +6097,29 @@ dependencies = [ "serde_json", ] +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + [[package]] name = "zbus" version = "5.19.0" @@ -4669,6 +6246,80 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "zip" version = "3.0.0" @@ -4762,3 +6413,23 @@ dependencies = [ "syn 3.0.6", "winnow", ] + +[[patch.unused]] +name = "egui_demo_lib" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" + +[[patch.unused]] +name = "egui_extras" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" + +[[patch.unused]] +name = "egui_inspection" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" + +[[patch.unused]] +name = "egui_system_font_provider" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" diff --git a/Cargo.toml b/Cargo.toml index 4d3fd4b83..b3e7b1bb2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"] version = "0.11.1" edition = "2024" license = "MIT OR Apache-2.0" -rust-version = "1.90" +rust-version = "1.95" repository = "https://github.com/storytold/gridcraft" [workspace.dependencies] @@ -43,10 +43,10 @@ sha1 = { version = "0.11", default-features = false } image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } # ui -egui = "0.36" -egui_extras = { version = "0.36", default-features = false } -eframe = { version = "0.36", default-features = false } -egui_kittest = "0.36" +egui = "0.37" +egui_extras = { version = "0.37", default-features = false } +eframe = { version = "0.37", default-features = false } +egui_kittest = "0.37" rfd = "0.17" arboard = { version = "3.6", default-features = false } # The desktop's preferred language, for the first-run interface language (native only). @@ -55,7 +55,7 @@ sys-locale = "0.3" # to egui, which panics on a file it can't parse. Same version as epaint's, so no second copy. skrifa = { version = "0.44", default-features = false, features = ["std"] } # Real font bytes for tests (Ubuntu Light, compiled into epaint). -epaint_default_fonts = "0.36" +epaint_default_fonts = "0.37" [workspace.lints.rust] unsafe_code = "forbid" @@ -79,3 +79,21 @@ opt-level = 3 lto = "thin" codegen-units = 16 debug = 0 + +# Keep the complete egui family and native event bridge on reviewed fork commits. +[patch.crates-io] +ecolor = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +emath = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +epaint = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui-winit = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui_glow = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui_extras = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +eframe = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +winit = { git = "https://github.com/storytold/winit.git", rev = "ae48c756a3b81125eb796c0cc6dccf60990d1181" } diff --git a/apps/gridcraft-web/src/web.rs b/apps/gridcraft-web/src/web.rs index 8e2e60837..ab2984e50 100644 --- a/apps/gridcraft-web/src/web.rs +++ b/apps/gridcraft-web/src/web.rs @@ -60,8 +60,10 @@ pub fn start() { } let mut app = SheetApp::new(session, services); app.ui.system_theme = true; - // Follow the browser's language, except a CJK one: the web build has no system - // CJK fonts (none are bundled), so that interface would render as tofu. + // Follow the browser's language, except a CJK one. CJK coverage comes from + // optional craft-fonts input, not a guaranteed web font set, so a CJK browser + // cannot be assumed to render localized labels without tofu. Users can still + // choose a language explicitly. let browser = gridcraft_ui_egui::i18n::Language::system(); if !matches!( browser, @@ -156,8 +158,8 @@ impl eframe::App for WebShell { } } - fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { - self.app.raw_input_hook(raw); + fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + self.app.raw_input_hook(ctx, raw); } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { diff --git a/apps/gridcraft/Cargo.toml b/apps/gridcraft/Cargo.toml index 94cf3fe9b..0d43cd69d 100644 --- a/apps/gridcraft/Cargo.toml +++ b/apps/gridcraft/Cargo.toml @@ -13,7 +13,7 @@ workspace = true [dependencies] gridcraft-engine = { workspace = true } gridcraft-ui-egui = { workspace = true } -eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] } +eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "clipboard"] } egui = { workspace = true } serde_json = { workspace = true } log = { workspace = true } @@ -25,7 +25,7 @@ chrono = { workspace = true } winresource = "0.1" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" } +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "c488caa9fbc2fb1a86e4674887e9b80b598ebbfa" } rfd = { workspace = true } arboard = { workspace = true } diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 6c0d2f676..b1c5ddd63 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -40,13 +40,12 @@ impl eframe::App for App { apple_events::poll(&self.2, &mut self.0, ctx); } self.0.logic(ctx); - self.0.poll_printing(ctx); if ctx.input(|input| input.viewport().close_requested()) && self.0.printing.busy() { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); } } - fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { - self.0.raw_input_hook(raw); + fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + self.0.raw_input_hook(ctx, raw); // Files dropped on the window open as workbooks. for f in std::mem::take(&mut raw.dropped_files) { if let Some(p) = f.path().to_str().map(str::to_string).filter(|p| !p.is_empty()) { diff --git a/apps/gridcraft/src/print.rs b/apps/gridcraft/src/print.rs index 09bb801bd..dea386cf2 100644 --- a/apps/gridcraft/src/print.rs +++ b/apps/gridcraft/src/print.rs @@ -5,7 +5,10 @@ pub fn discover() -> Discover { Box::new(|| { let (sender, receiver) = std::sync::mpsc::channel(); let _ = std::thread::Builder::new().name("print-discovery".into()).spawn(move || { - let printers = craft_print::printers().into_iter().map(|printer| Printer { name: printer.name, default: printer.default }).collect(); + let printers = craft_print::printers() + .into_iter() + .map(|printer| Printer { name: printer.name, label: printer.description, default: printer.default }) + .collect(); let _ = sender.send(printers); }); receiver diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 85a24dda9..998520c81 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -9,10 +9,15 @@ description = "GridCraft egui frontend (Excel-style UI); swappable — nothing b workspace = true [dependencies] -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" } +# The shared capture slot (`craft_ui::capture`) comes from the same reviewed immutable +# craft-libs revision as the desktop's craft-print dependency. +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "c488caa9fbc2fb1a86e4674887e9b80b598ebbfa" } gridcraft-engine = { workspace = true } gridcraft-chart = { workspace = true } -egui = { workspace = true } +# `monochrome_emoji_fonts` restores the Noto Emoji and emoji-icon faces that 0.37 moved behind +# this feature. The UI draws ✕, ◀, ↘ and 📋 as text; without these faces the strict kittest +# harness (and a host with no symbol font) renders them as tofu. +egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } @@ -36,7 +41,7 @@ sys-locale = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] # Read the browser's preferred language for the first-run web interface language. -web-sys = { version = "0.3", features = ["Window", "Navigator"] } +web-sys = { version = "0.3", features = ["Window", "Navigator", "Performance"] } [build-dependencies] serde_json = { workspace = true } diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index 8a06c5932..f8f59a15f 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -12,6 +12,9 @@ use std::fmt::Write as _; use serde_json::Value; +#[path = "src/craft_fonts_policy.rs"] +mod craft_fonts_policy; + fn main() { // Read when the script runs: `env!` would keep the path from when the script was compiled, // which goes stale if the checkout moves (and a missing file reruns the script every build). @@ -27,6 +30,107 @@ fn main() { if let Err(e) = std::fs::write(&out, generate(&json, &people)) { println!("cargo::warning=writing {}: {e}", out.display()); } + emit_craft_fonts(); +} + +/// The optional craft-fonts build input (, recipe from +/// its `docs/integration.md`). With `CRAFT_FONTS_DIR=` the permitted fonts in +/// its `fonts/manifest.txt` are embedded as `CRAFT_FONTS` for `theme`'s fallback chains; unset, +/// the table is empty. Release and acceptance builds also set `CRAFT_FONTS_REQUIRED=1`, which +/// turns a missing checkout, a missing required face or a malformed row into a build error. +/// +/// GridCraft never copies font bytes into this repository and never reads a fixture from Cargo +/// configuration: the only input is the environment variable. Barred Adobe/Source/Noto CJK faces +/// stay in the shared repository for other consumers but are filtered here. A web build embeds +/// only the explicit `WEB_ALLOWED` faces (no CJK collection). Native builds additionally opt into +/// craft-fonts' `fonts/simplified-chinese-manifest.txt` (the OFL LXGW face DeckCraft uses): it is +/// optional for ordinary builds and required for `CRAFT_FONTS_REQUIRED=1` native builds. +fn emit_craft_fonts() { + println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR"); + println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED"); + let required = std::env::var_os("CRAFT_FONTS_REQUIRED").is_some(); + let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32"); + let mut src = String::new(); + let _ = writeln!(src, "pub static CRAFT_FONTS_REQUIRED: bool = {required};"); + src.push_str("pub static CRAFT_FONTS: &[CraftFont] = &[\n"); + match std::env::var_os("CRAFT_FONTS_DIR").map(std::path::PathBuf::from) { + Some(dir) => match craft_fonts(&dir, wasm) { + Ok(entries) => src.push_str(&entries), + Err(e) if required => { + println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display()); + } + Err(e) => println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {e}", dir.display()), + }, + None if required => { + println!("cargo::error=CRAFT_FONTS_REQUIRED=1 requires CRAFT_FONTS_DIR (an absolute craft-fonts checkout)"); + } + None => {} + } + src.push_str("];\n"); + let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs"); + if let Err(e) = std::fs::write(&out, src) { + println!("cargo::error=writing {}: {e}", out.display()); + } +} + +/// One `CraftFont { .. }` initialiser per manifest row GridCraft may embed. +fn craft_fonts(dir: &std::path::Path, wasm: bool) -> Result { + let required = std::env::var_os("CRAFT_FONTS_REQUIRED").is_some(); + let manifest = dir.join("fonts/manifest.txt"); + println!("cargo::rerun-if-changed={}", manifest.display()); + let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?; + let rows = craft_fonts_policy::parse_manifest(&text)?; + craft_fonts_policy::check_declared_scripts(&rows)?; + if required { + craft_fonts_policy::check_required(&rows)?; + } + let chinese = if wasm { Vec::new() } else { chinese_font_rows(dir, required)? }; + for row in rows.iter().chain(&chinese) { + if craft_fonts_policy::barred(&row.family) { + println!("cargo::warning=craft-fonts: not embedding {} {} (repository asset policy)", row.family, row.style); + } + } + let mut out = String::new(); + for row in craft_fonts_policy::embeddable(&rows, wasm).into_iter().chain(craft_fonts_policy::embeddable(&chinese, wasm)) { + let path = dir.join(&row.file).canonicalize().map_err(|e| format!("{}: {e}", row.file))?; + println!("cargo::rerun-if-changed={}", path.display()); + let scripts: Vec = row.scripts.iter().map(|s| format!("{s:?}")).collect(); + let family = &row.family; + let style = &row.style; + let _ = writeln!( + out, + " CraftFont {{ family: {family:?}, style: {style:?}, scripts: &[{}], bytes: include_bytes!({path:?}) }},", + scripts.join(", "), + ); + } + Ok(out) +} + +/// The opt-in native Simplified Chinese rows. Older craft-fonts checkouts may lack the manifest, so +/// an ordinary build warns and carries on; a required acceptance build fails rather than passing +/// Chinese coverage only because the face was silently absent. +fn chinese_font_rows(dir: &std::path::Path, required: bool) -> Result, String> { + let manifest = dir.join("fonts/simplified-chinese-manifest.txt"); + println!("cargo::rerun-if-changed={}", manifest.display()); + let optional = |e: String| -> Result, String> { + if required { + Err(format!("{}: {e}", manifest.display())) + } else { + println!("cargo::warning=skipping optional Chinese fonts: {}: {e}", manifest.display()); + Ok(Vec::new()) + } + }; + match std::fs::read_to_string(&manifest) { + Ok(text) => match craft_fonts_policy::parse_manifest(&text).and_then(|rows| { + craft_fonts_policy::check_declared_scripts(&rows)?; + craft_fonts_policy::check_chinese_required(&rows)?; + Ok(rows) + }) { + Ok(rows) => Ok(rows), + Err(e) => optional(e), + }, + Err(e) => optional(e.to_string()), + } } fn read(path: &std::path::Path, fallback: &str, parse: impl Fn(&str) -> Result) -> Option { diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index f5bdc20ab..5dfc2b694 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -260,7 +260,7 @@ fn save(app: &mut SheetApp, out: &mut Outcome) { fn nav_item(ui: &mut Ui, icon: Icon, label: &str, selected: bool, text: Color32, sel: Color32) -> egui::Response { let (rect, resp) = ui.allocate_exact_size(vec2(NAV_W, 38.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, selected, label)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, label)); let p = ui.painter(); if selected { p.rect_filled(rect, 0.0, sel); @@ -297,7 +297,7 @@ fn dim(ui: &mut Ui, text: &str) { fn tile(ui: &mut Ui, label: &str, sample: Option) -> egui::Response { let t = Tokens::get(ui.ctx()); let (rect, resp) = ui.allocate_exact_size(vec2(150.0, 140.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label)); let p = ui.painter(); let thumb = Rect::from_min_size(rect.min, vec2(rect.width(), 104.0)); p.rect_filled(thumb, 4.0, t.grid_bg); @@ -388,7 +388,7 @@ fn recent_list(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Ou for path in app.ui.recent.iter().take(limit).cloned().collect::>() { let (name, folder) = split_path(&path); let (rect, resp) = ui.allocate_exact_size(vec2(width, 44.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &name)); let p = ui.painter(); if resp.hovered() { p.rect_filled(rect, 4.0, t.hover); @@ -514,7 +514,7 @@ fn action_row(ui: &mut Ui, icon: Icon, title: &str, detail: &str) -> egui::Respo let t = Tokens::get(ui.ctx()); let width = ui.available_width().min(CONTENT_W); let (rect, resp) = ui.allocate_exact_size(vec2(width, 56.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, title)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, title)); let p = ui.painter(); if resp.hovered() { p.rect_filled(rect, 4.0, t.hover); @@ -529,7 +529,7 @@ fn action_row(ui: &mut Ui, icon: Icon, title: &str, detail: &str) -> egui::Respo fn square_button(ui: &mut Ui, icon: Icon, label: &str) -> egui::Response { let t = Tokens::get(ui.ctx()); let (rect, resp) = ui.allocate_exact_size(vec2(96.0, 88.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label)); let p = ui.painter(); p.rect_filled(rect, 6.0, if resp.hovered() { t.hover } else { t.ribbon }); p.rect_stroke(rect, 6.0, Stroke::new(1.0, if resp.hovered() { t.accent } else { t.ribbon_border }), StrokeKind::Inside); @@ -643,13 +643,13 @@ fn info(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage) { ui.end_row(); for (i, (key, label)) in PROPERTIES.iter().enumerate() { let (Some(draft), Some(saved)) = (bs.props.get_mut(i), current.get(i)) else { continue }; - ui.label(RichText::new(lang.tr(label)).color(t.text_dim)); + let label = ui.label(RichText::new(lang.tr(label)).color(t.text_dim)); let id = ui.make_persistent_id(("backstage_prop", *key)); // Show the workbook's value unless the field is being edited (undo, another window). if !ui.ctx().memory(|m| m.has_focus(id)) { draft.clone_from(saved); } - let resp = ui.add(egui::TextEdit::singleline(draft).id(id).desired_width(360.0)); + let resp = ui.add(egui::TextEdit::singleline(draft).id(id).desired_width(360.0)).labelled_by(label.id); if resp.lost_focus() && draft != saved { let mut params = serde_json::Map::new(); params.insert((*key).to_string(), json!(draft.clone())); @@ -667,14 +667,19 @@ fn save_as(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcom let fmt = bs.save_format; let current = SAVE_TYPES.iter().find(|(ext, _)| *ext == fmt).map_or("Excel Workbook (*.xlsx)", |(_, label)| *label); ui.horizontal(|ui| { - ui.label(lang.tr("Save as type")); - egui::ComboBox::from_id_salt("backstage_save_type").width(280.0).selected_text(lang.tr(current)).show_ui(ui, |ui| { - for (ext, label) in SAVE_TYPES { - if ui.selectable_label(fmt == ext, lang.tr(label)).clicked() { - bs.save_format = ext; + let label = ui.label(lang.tr("Save as type")); + egui::ComboBox::from_id_salt("backstage_save_type") + .width(280.0) + .selected_text(lang.tr(current)) + .show_ui(ui, |ui| { + for (ext, label) in SAVE_TYPES { + if ui.selectable_label(fmt == ext, lang.tr(label)).clicked() { + bs.save_format = ext; + } } - } - }); + }) + .response + .labelled_by(label.id); }); ui.add_space(14.0); // Desktop: the system Save dialog. Web: the file downloads. @@ -728,14 +733,19 @@ fn print(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) } ui.add_space(16.0); let scope = if bs.print_all { "Print Entire Workbook" } else { "Print Active Sheets" }; - egui::ComboBox::from_id_salt("backstage_print_scope").width(230.0).selected_text(lang.tr(scope)).show_ui(ui, |ui| { - for (all, label) in [(false, "Print Active Sheets"), (true, "Print Entire Workbook")] { - if ui.selectable_label(bs.print_all == all, lang.tr(label)).clicked() { - bs.print_all = all; - bs.preview_page = 0; + egui::ComboBox::from_id_salt("backstage_print_scope") + .width(230.0) + .selected_text(lang.tr(scope)) + .show_ui(ui, |ui| { + for (all, label) in [(false, "Print Active Sheets"), (true, "Print Entire Workbook")] { + if ui.selectable_label(bs.print_all == all, lang.tr(label)).clicked() { + bs.print_all = all; + bs.preview_page = 0; + } } - } - }); + }) + .response + .accessible_name(lang.tr("Print What")); ui.add_space(8.0); if ui.checkbox(&mut bs.fit_to_page, lang.tr("Fit Sheet on One Page")).changed() { bs.preview_page = 0; @@ -939,29 +949,39 @@ fn options(app: &mut SheetApp, ui: &mut Ui) { let lang = app.ui.language; section(ui, lang, "Options|General"); egui::Grid::new("backstage_options_general").num_columns(2).spacing(vec2(18.0, 10.0)).show(ui, |ui| { - ui.label(lang.tr("Display theme")); + let label = ui.label(lang.tr("Display theme")); let mode = app.ui.theme_mode(); let current = match mode { "system" => "System", "dark" => "Dark", _ => "Light", }; - egui::ComboBox::from_id_salt("backstage_theme").width(160.0).selected_text(lang.tr(current)).show_ui(ui, |ui| { - for (value, label) in [("system", "System"), ("light", "Light"), ("dark", "Dark")] { - if ui.selectable_label(mode == value, lang.tr(label)).clicked() { - app.run_or_alert("view.theme", json!({"mode": value})); + egui::ComboBox::from_id_salt("backstage_theme") + .width(160.0) + .selected_text(lang.tr(current)) + .show_ui(ui, |ui| { + for (value, label) in [("system", "System"), ("light", "Light"), ("dark", "Dark")] { + if ui.selectable_label(mode == value, lang.tr(label)).clicked() { + app.run_or_alert("view.theme", json!({"mode": value})); + } } - } - }); + }) + .response + .labelled_by(label.id); ui.end_row(); - ui.label(lang.tr("Interface language")); - egui::ComboBox::from_id_salt("backstage_language").width(160.0).selected_text(lang.name()).show_ui(ui, |ui| { - for l in i18n::Language::for_picker() { - if ui.selectable_label(l == lang, l.name()).clicked() { - app.run_or_alert("app.language.set", json!({"language": l.code()})); + let label = ui.label(lang.tr("Interface language")); + egui::ComboBox::from_id_salt("backstage_language") + .width(160.0) + .selected_text(lang.name()) + .show_ui(ui, |ui| { + for l in i18n::Language::for_picker() { + if ui.selectable_label(l == lang, l.name()).clicked() { + app.run_or_alert("app.language.set", json!({"language": l.code()})); + } } - } - }); + }) + .response + .labelled_by(label.id); ui.end_row(); }); ui.add_space(6.0); diff --git a/crates/ui-egui/src/border_preview.rs b/crates/ui-egui/src/border_preview.rs index 11ff351e6..dc0a83ae4 100644 --- a/crates/ui-egui/src/border_preview.rs +++ b/crates/ui-egui/src/border_preview.rs @@ -10,7 +10,7 @@ use crate::theme::{self, Tokens}; /// from the selection outline. Labels stay available to accessibility tools. pub(crate) fn preset_button(ui: &mut Ui, label: &str, preset: &str) -> Response { let response = ui.add(egui::Button::new("").frame(false).min_size(vec2(254.0, 26.0))); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label)); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), label)); let t = Tokens::get(ui.ctx()); let rect = Rect::from_center_size(pos2(response.rect.left() + 16.0, response.rect.center().y), vec2(20.0, 16.0)); let edges = [ @@ -55,7 +55,7 @@ pub(crate) fn sample(ui: &mut Ui, borders: &Borders, theme: &Theme) { ui.add_space(8.0); ui.label("Preview"); let (area, response) = ui.allocate_exact_size(vec2(248.0, 104.0), Sense::hover()); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, "Border preview")); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, "Border preview")); let t = Tokens::get(ui.ctx()); ui.painter().rect_filled(area, 4.0, t.grid_bg); ui.painter().rect_stroke(area, 4.0, Stroke::new(1.0, t.input_border), StrokeKind::Inside); diff --git a/crates/ui-egui/src/cell_pictures.rs b/crates/ui-egui/src/cell_pictures.rs index 461014022..4930e7b97 100644 --- a/crates/ui-egui/src/cell_pictures.rs +++ b/crates/ui-egui/src/cell_pictures.rs @@ -122,7 +122,7 @@ pub(crate) fn paint(painter: &Painter, cell: Rect, picture: &Arc) { } // Context-local handles must never leak between offscreen, native or browser // egui contexts, even when a workbook shares its Arc payload with another view. - let cache = painter.ctx().data_mut(|data| data.get_temp_mut_or_default::>>(egui::Id::new("gridcraft.cell_pictures")).clone()); + let cache = painter.ctx().data_mut(|data| data.get_temp_mut_or_default::>>(egui::Id::unique("gridcraft.cell_pictures")).clone()); let texture = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner).texture(painter.ctx(), picture); let painter = painter.with_clip_rect(cell.intersect(painter.clip_rect())); if let Some(texture) = texture { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 11d535ac4..6eb8aece9 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -18,6 +18,7 @@ use std::sync::mpsc::Sender; +use craft_ui::capture::CaptureSlot; use gridcraft_engine::core::CellRef; use serde_json::{Value as Json, json}; @@ -66,18 +67,318 @@ pub enum Outcome { Screenshot { path: Option }, } +/// Most screenshots one client may have in flight or waiting for its reply at once. +pub const SCREENSHOT_MAX_REQUESTS: usize = 4; +/// Retained bytes for one screenshot image. The renderer's readback buffer is a separate +/// transient allocation (recorded in peak RSS), not part of this reservation. +pub const SCREENSHOT_MAX_IMAGE_BYTES: usize = 128 * 1024 * 1024; +/// Aggregate bytes reserved by every outstanding screenshot request. +pub const SCREENSHOT_MAX_AGGREGATE_BYTES: usize = 256 * 1024 * 1024; +/// Largest transient working set allowed while one capture is encoded. It covers the RGBA copy +/// made from the retained `ColorImage`, the PNG stream and (for base64 replies) the encoded text. +/// Admission checks this before a request is issued, so a large capture cannot make the process +/// peak several times above its retained-image budget. +pub const SCREENSHOT_MAX_ENCODING_BYTES: usize = 256 * 1024 * 1024; +/// Largest base64 reply this client produces. The encoded length is checked before the string is +/// allocated; at most [`SCREENSHOT_MAX_REQUESTS`] such replies can therefore wait in the channel, +/// keeping queued base64 text bounded too. +pub const SCREENSHOT_MAX_BASE64_BYTES: usize = 64 * 1024 * 1024; +/// Hard bound on retained request images plus one capture's encoding working set. Admission, +/// issuance and every retry reject a request that would push the process over this, so the +/// aggregate reservation and the transient encoder peak together stay bounded. +pub const SCREENSHOT_MAX_PEAK_BYTES: usize = 512 * 1024 * 1024; +/// Capture retries allowed inside one request's deadline after a resize/DPI/zoom/surface change. +pub const SCREENSHOT_MAX_RETRIES: u32 = 2; +/// Reported once when the active document changes before a queued capture is issued or completed. +pub const SCREENSHOT_DOCUMENT_CHANGED: &str = "screenshot failed: the document changed while the capture was queued"; +/// Reported once when a capture request or completion is not for the active viewport. +pub const SCREENSHOT_VIEWPORT_CHANGED: &str = "screenshot failed: the capture belongs to another viewport"; + +/// Reserved while the viewport has no capturable size yet (minimized, or not laid out at all): +/// the largest single image the per-image limit allows. The real size replaces it at issuance. +pub const UNKNOWN_CAPTURE_BYTES: usize = SCREENSHOT_MAX_IMAGE_BYTES; + +/// The viewport geometry a capture is admitted, issued and completed against. +/// +/// `SheetApp` keeps a monotonic epoch (see [`crate::SheetApp::logic`]) that increments on any +/// change of these values, so a resize back to the original size is still a mismatch: an image +/// taken in another layout must never be combined with the current geometry. The viewport id, +/// minimized/occluded state and visibility are part of the identity too: a minimize/restore or +/// an occlusion transition at the same pixel size is a new surface generation, not a no-op. +/// +/// The identity deliberately keeps `zoom`, `visible` and `occluded` even though interface zoom +/// does not change the expected physical surface size. A capture is bound to one layout generation, +/// so a zoom or occlusion transition during it is retried within [`SCREENSHOT_MAX_RETRIES`] and +/// then reported as a bounded failure instead of risking pixels from another generation. The common +/// wheel-zoom path is frozen while a capture is in flight; a non-glide zoom or a compositor +/// occlusion flap can still exhaust the retries, which is accepted as a rare, explicit error. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CaptureGeometry { + /// The viewport this capture belongs to. + pub viewport_id: egui::ViewportId, + /// Logical size of the content that is captured, or `None` when the window currently has no + /// capturable surface (minimized, or not laid out yet). A hidden-but-occluded window still + /// reports a size. + pub viewport: Option, + pub pixels_per_point: f32, + pub zoom: f32, + /// Maximum physical width/height the surface/renderer can capture. + pub max_texture_side: usize, + pub minimized: Option, + pub occluded: Option, + pub visible: Option, +} + +impl CaptureGeometry { + /// The geometry described by a native frame's fresh [`egui::RawInput`]. + /// + /// `raw.screen_rect` is the authoritative content size when `ViewportInfo::inner_rect` is + /// absent (Wayland always omits it, and a minimized window can omit both). The fallback to the + /// last UI pass's rect is deliberately *not* used here: a logic-only tick after a resize or + /// minimize must record the new state (or the explicit unknown state), never the old layout. + pub fn from_raw_input(raw: &egui::RawInput, ctx: &egui::Context) -> Self { + let info = raw.viewports.get(&raw.viewport_id).cloned().unwrap_or_default(); + Self::build(raw.viewport_id, &info, raw.screen_rect, raw.max_texture_side, ctx.zoom_factor(), ctx.pixels_per_point()) + } + + fn build( + viewport_id: egui::ViewportId, + info: &egui::ViewportInfo, + screen_rect: Option, + max_texture_side: Option, + zoom: f32, + fallback_pixels_per_point: f32, + ) -> Self { + let minimized = info.minimized; + let visible = info.visible(); + // A minimized window has no surface to capture even if the platform still reports the + // last inner rectangle. Both rectangles missing likewise means "unknown size", not a + // reason to re-use an earlier layout. + let viewport = if minimized == Some(true) { + None + } else { + info.inner_rect.map(|rect| rect.size()).filter(positive_size).or_else(|| screen_rect.map(|rect| rect.size()).filter(positive_size)) + }; + let pixels_per_point = + info.native_pixels_per_point.filter(|p| p.is_finite() && *p > 0.0).map_or(fallback_pixels_per_point, |native| native * zoom); + Self { + viewport_id, + viewport, + pixels_per_point, + zoom, + // Zero means "unreported" (a bare test context); `expected_size` treats it as no cap. + max_texture_side: max_texture_side.unwrap_or(0), + minimized, + occluded: info.occluded, + visible, + } + } + + /// The geometry of egui's current input. This is a fallback for callers that do not run + /// [`crate::SheetApp::raw_input_hook`] (tests and embedders); native eframe always records the + /// fresh [`Self::from_raw_input`] value first, so issuance and drain never fall back here. + pub fn current(ctx: &egui::Context) -> Self { + let (viewport_id, info, screen_rect, max_texture_side, fallback_ppp) = ctx.input(|i| { + let viewport_id = i.raw.viewport_id; + let info = i.raw.viewports.get(&viewport_id).cloned().unwrap_or_default(); + // `screen_rect` from the pass if one supplied it; otherwise egui's retained content + // rect (which is only fresh when this is called from a UI pass, never a logic tick). + let screen_rect = i.raw.screen_rect.or_else(|| Some(i.viewport_rect())); + (viewport_id, info, screen_rect, i.max_texture_side, i.pixels_per_point()) + }); + Self::build(viewport_id, &info, screen_rect, Some(max_texture_side), ctx.zoom_factor(), fallback_ppp) + } + + /// Physical pixel size the screenshot should have, rounded and capped by the surface limit. + /// `None` when the viewport has no positive, finite size (minimized or not laid out yet). + pub fn expected_size(&self) -> Option<[usize; 2]> { + let viewport = self.viewport?; + let ppp = self.pixels_per_point; + if !viewport.x.is_finite() || !viewport.y.is_finite() || viewport.x <= 0.0 || viewport.y <= 0.0 { + return None; + } + if !ppp.is_finite() || ppp <= 0.0 { + return None; + } + // An unreported surface limit (0) must not cap every capture to one pixel. + let max = if self.max_texture_side == 0 { usize::MAX } else { self.max_texture_side }; + let axis = |points: f32| -> Option { + let px = (f64::from(points) * f64::from(ppp)).round(); + if !px.is_finite() || px <= 0.0 || px > usize::MAX as f64 { + return None; + } + Some((px as usize).min(max)) + }; + Some([axis(viewport.x)?, axis(viewport.y)?]) + } + + /// Checked `width * height * 4` bytes for the expected image, capped by the surface limit. + pub fn expected_bytes(&self) -> Option { + let [w, h] = self.expected_size()?; + w.checked_mul(h)?.checked_mul(4) + } + + /// Checked bytes of the largest image [`validate_image`] accepts for this geometry: one + /// physical pixel of rounding per axis. Reservations and the slot's per-image bound both use + /// this, so the aggregate budget is strict for the bytes actually retained. + pub fn retained_bytes(&self) -> Option { + let [w, h] = self.expected_size()?; + let w = w.checked_add(1)?; + let h = h.checked_add(1)?; + w.checked_mul(h)?.checked_mul(4) + } +} + +fn positive_size(size: &egui::Vec2) -> bool { + size.x.is_finite() && size.y.is_finite() && size.x > 0.0 && size.y > 0.0 +} + +/// Conservative worst-case size of the PNG stream for a `bytes`-sized RGBA image with `height` +/// rows. PNG adds a filter byte per row and zlib framing; uncompressed blocks add a few bytes per +/// 64 KiB. 1% + 64 KiB plus the row bytes is safely above the real encoder output. +fn png_worst_bytes(bytes: usize, height: usize) -> Option { + bytes.checked_add(bytes / 100)?.checked_add(height.checked_mul(2)?)?.checked_add(64 * 1024) +} + +/// Base64 length (with padding) for `len` bytes. +fn base64_len(len: usize) -> Option { + len.checked_add(2)?.checked_div(3)?.checked_mul(4) +} + +/// Worst transient bytes for encoding one expected capture: the retained `ColorImage` plus the +/// RGBA copy made from it, then (for base64) the PNG stream and the encoded text. `None` on +/// overflow. Admission rejects a request whose working set exceeds +/// [`SCREENSHOT_MAX_ENCODING_BYTES`] before any slot or command exists. +pub fn encoding_working_bytes(expected: [usize; 2], base64: bool) -> Option { + let [w, h] = expected; + let bytes = w.checked_mul(h)?.checked_mul(4)?; + let retained_and_copy = bytes.checked_mul(2)?; + if !base64 { + return Some(retained_and_copy); + } + let png = png_worst_bytes(bytes, h)?; + let encoded = base64_len(png)?; + Some(retained_and_copy.max(bytes.checked_add(png)?.checked_add(encoded)?)) +} + +/// Reject an expected capture whose encoding working set or base64 reply would exceed the +/// explicit encoding limits. Checked before admission and again immediately before issuance. +fn check_encoding_budget(expected: [usize; 2], base64: bool) -> Result<(), String> { + let [w, h] = expected; + let Some(bytes) = w.checked_mul(h).and_then(|p| p.checked_mul(4)) else { + return Err("screenshot failed: the window size overflows the screenshot budget".to_string()); + }; + let Some(working) = encoding_working_bytes(expected, base64) else { + return Err("screenshot failed: the window size overflows the screenshot encoding budget".to_string()); + }; + if working > SCREENSHOT_MAX_ENCODING_BYTES { + return Err(format!( + "screenshot encoding budget exceeded: {working} bytes of transient working memory for one {w}×{h} capture, over the {SCREENSHOT_MAX_ENCODING_BYTES}-byte encoding limit" + )); + } + if base64 { + let Some(png) = png_worst_bytes(bytes, h) else { + return Err("screenshot failed: the window size overflows the screenshot encoding budget".to_string()); + }; + let Some(encoded) = base64_len(png) else { + return Err("screenshot failed: the window size overflows the screenshot encoding budget".to_string()); + }; + if encoded > SCREENSHOT_MAX_BASE64_BYTES { + return Err(format!( + "screenshot base64 budget exceeded: the reply for one {w}×{h} capture could be {encoded} bytes, over the {SCREENSHOT_MAX_BASE64_BYTES}-byte base64 limit; request a path instead" + )); + } + } + Ok(()) +} + +/// Reject a request whose reserved retained bytes plus its encoding working set exceed +/// [`SCREENSHOT_MAX_PEAK_BYTES`]. `reserved` already includes this request's own reservation. +fn check_peak_budget(reserved: usize, expected: [usize; 2], base64: bool) -> Result<(), String> { + let Some(working) = encoding_working_bytes(expected, base64) else { + return Err("screenshot failed: the window size overflows the screenshot encoding budget".to_string()); + }; + let peak = reserved.saturating_add(working); + if peak > SCREENSHOT_MAX_PEAK_BYTES { + return Err(format!( + "screenshot peak-memory budget exceeded: {reserved} bytes retained plus {working} bytes encoding for one capture is over the {SCREENSHOT_MAX_PEAK_BYTES}-byte peak limit" + )); + } + Ok(()) +} + +/// One admitted `ui.screenshot` request. The slot owns the image and its viewport waker; the +/// callback holds only a weak reference to it and never writes a file or sends a reply. +struct Shot { + token: u64, + path: Option, + reply: Sender, + /// Monotonic deadline for the whole request, including retries. + deadline: f64, + /// Settle time before the first issuance (the UI must be still). + ready_at: f64, + /// Real (non-discarded) UI passes counted towards settling. + frames: u32, + /// `cumulative_pass_nr` of the last pass counted in `frames`, so repeated `ui` calls in one + /// pass count once and a settled frame is never counted twice. + settle_pass: Option, + issued: bool, + retries: u32, + /// Geometry at admission, updated immediately before issuance. + geometry: CaptureGeometry, + /// The viewport this request was admitted for. A callback or slot from another viewport must + /// never fill this request. + viewport_id: egui::ViewportId, + /// The active document (`DocState::uid`) at admission. A queued capture is never rebound to a + /// document that became active later. + document: Option, + /// Monotonic geometry epoch at issuance; any later change rejects the image. + epoch: u64, + /// Physical size expected at issuance; `None` while the window has no capturable size. + expected: Option<[usize; 2]>, + /// Bytes reserved from the aggregate budget until the request completes or is dropped. This + /// is the largest image the request may retain (one pixel of rounding per axis included) and + /// is also the slot's per-image bound. + bytes: usize, + /// Created at issuance (and anew for every retry), never at admission: the bound must match + /// the geometry and reservation the command is issued against. + slot: Option, +} + #[derive(Default)] pub struct Shots { token: u64, - queued: Vec<(u64, f64, u32)>, - pending: Vec<(u64, Option, Sender, f64)>, + requests: Vec, } impl Shots { /// A screenshot was asked for and hasn't been delivered yet: animations hold still, so the /// picture shows the settled state. pub(crate) fn in_flight(&self) -> bool { - !self.queued.is_empty() || !self.pending.is_empty() + !self.requests.is_empty() + } + + /// Whether a hidden window still needs `RequestPaintWhileHidden` to reach issuance. Only a + /// request that is admitted or settling with a capturable size needs a first `ui` pass; once + /// `issue_screenshots` has sent the `Screenshot` command, that command's own `wants_paint` + /// paints the hidden window. A minimized request has no capturable size, so requesting hidden + /// full frames for its whole wait cannot make progress (the restore pass issues it). + pub(crate) fn needs_hidden_paint(&self) -> bool { + self.requests.iter().any(|r| !r.issued && r.expected.is_some()) + } + + /// Bytes currently reserved by admitted requests. + fn reserved(&self) -> usize { + self.requests.iter().fold(0usize, |sum, r| sum.saturating_add(r.bytes)) + } + + /// Drop every outstanding request without a reply (app shutdown): callbacks become inert. + pub fn cancel_all(&mut self) { + for request in self.requests.drain(..) { + if let Some(slot) = request.slot { + slot.cancel(); + } + } } } @@ -166,21 +467,48 @@ fn cell_center(app: &SheetApp, c: CellRef) -> Option { Some(geo.screen_cell_rect(sh, c).center()) } -/// Most synthetic pointer events a control client may keep queued at once (the queue drains one batch per frame). -const MAX_QUEUED_POINTER_EVENTS: usize = 10_000; +/// Most synthetic events (pointer, key, text and move alike) a control client may keep queued at +/// once. The queue offers one batch per UI pass, and a hidden logic-only frame keeps the same +/// batch unapplied, so every producer must honour this budget. +pub const MAX_QUEUED_SYNTHETIC_EVENTS: usize = 10_000; +/// Most bytes of synthetic input queued at once: the `Event` storage plus every text payload. +pub const MAX_QUEUED_SYNTHETIC_BYTES: usize = 8 * 1024 * 1024; +/// Largest single `ui.text` string accepted (well below the control line limit; one paste-sized +/// string is plenty for a spreadsheet cell and keeps a burst from exhausting memory). +pub const MAX_SYNTHETIC_TEXT_BYTES: usize = 1024 * 1024; -/// Refuses a request that would push the queued pointer events past [`MAX_QUEUED_POINTER_EVENTS`]; the queue is left unchanged. -fn pointer_budget(app: &SheetApp, extra: usize) -> Result<(), String> { - let queued: usize = app.synthetic.iter().map(Vec::len).sum(); - if queued.saturating_add(extra) > MAX_QUEUED_POINTER_EVENTS { - return Err(format!("too many pointer events (limit {MAX_QUEUED_POINTER_EVENTS} queued at once)")); +fn event_bytes(event: &egui::Event) -> usize { + std::mem::size_of::() + + match event { + egui::Event::Text(text) => text.len(), + _ => 0, + } +} + +fn synthetic_usage(app: &SheetApp) -> (usize, usize) { + app.synthetic + .iter() + .flat_map(|batch| batch.iter()) + .fold((0usize, 0usize), |(events, bytes), event| (events.saturating_add(1), bytes.saturating_add(event_bytes(event)))) +} + +/// Refuses a request that would push the queued synthetic input past either limit; the queue is +/// left unchanged. +fn synthetic_budget(app: &SheetApp, extra_events: usize, extra_bytes: usize) -> Result<(), String> { + let (events, bytes) = synthetic_usage(app); + if events.saturating_add(extra_events) > MAX_QUEUED_SYNTHETIC_EVENTS { + return Err(format!("too many synthetic input events (limit {MAX_QUEUED_SYNTHETIC_EVENTS} queued at once)")); + } + if bytes.saturating_add(extra_bytes) > MAX_QUEUED_SYNTHETIC_BYTES { + return Err(format!("synthetic input exceeds the {MAX_QUEUED_SYNTHETIC_BYTES}-byte queue limit")); } Ok(()) } fn push_click(app: &mut SheetApp, pos: egui::Pos2, button: egui::PointerButton, count: u32, m: egui::Modifiers) -> Result<(), String> { let count = count.max(1) as usize; - pointer_budget(app, count.saturating_mul(2).saturating_add(1))?; + let events = count.saturating_mul(2).saturating_add(1); + synthetic_budget(app, events, events.saturating_mul(std::mem::size_of::()))?; app.synthetic.push_back(vec![egui::Event::PointerMoved(pos)]); let mut batch = Vec::with_capacity(count * 2); for _ in 0..count { @@ -193,7 +521,8 @@ fn push_click(app: &mut SheetApp, pos: egui::Pos2, button: egui::PointerButton, fn push_drag(app: &mut SheetApp, a: egui::Pos2, b: egui::Pos2, steps: usize, m: egui::Modifiers) -> Result<(), String> { let steps = steps.max(1); - pointer_budget(app, steps.saturating_add(3))?; + let events = steps.saturating_add(3); + synthetic_budget(app, events, events.saturating_mul(std::mem::size_of::()))?; app.synthetic.push_back(vec![egui::Event::PointerMoved(a)]); app.synthetic.push_back(vec![egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: m }]); for i in 1..=steps { @@ -204,6 +533,33 @@ fn push_drag(app: &mut SheetApp, a: egui::Pos2, b: egui::Pos2, steps: usize, m: Ok(()) } +fn push_key(app: &mut SheetApp, key: egui::Key, m: egui::Modifiers) -> Result<(), String> { + let events = 2; + synthetic_budget(app, events, events * std::mem::size_of::())?; + app.synthetic.push_back(vec![ + egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: m }, + egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: m }, + ]); + Ok(()) +} + +fn push_text(app: &mut SheetApp, text: String) -> Result<(), String> { + if text.len() > MAX_SYNTHETIC_TEXT_BYTES { + return Err(format!("text input is {} bytes, over the {MAX_SYNTHETIC_TEXT_BYTES}-byte limit for one event", text.len())); + } + let event = egui::Event::Text(text); + synthetic_budget(app, 1, event_bytes(&event))?; + app.synthetic.push_back(vec![event]); + Ok(()) +} + +fn push_move(app: &mut SheetApp, pos: egui::Pos2) -> Result<(), String> { + let event = egui::Event::PointerMoved(pos); + synthetic_budget(app, 1, event_bytes(&event))?; + app.synthetic.push_back(vec![event]); + Ok(()) +} + /// Keeps the commands whose id, label or ribbon path contains `search` (case-insensitive); `None` keeps all. fn filter_commands(commands: Vec, search: Option<&str>) -> Vec { let Some(q) = search.map(str::to_ascii_lowercase) else { return commands }; @@ -358,21 +714,27 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - } "ui.key" => { let Some(k) = s("key").and_then(key_from) else { return err("unknown or missing `key`") }; - let m = modifiers(p); - app.synthetic.push_back(vec![ - egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m }, - egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: m }, - ]); - ok(Json::Null) + match push_key(app, k, modifiers(p)) { + Ok(()) => ok(Json::Null), + Err(e) => err(e), + } } "ui.text" => { - app.synthetic.push_back(vec![egui::Event::Text(s("text").unwrap_or("").to_string())]); - ok(Json::Null) - } - "ui.move" => { - app.synthetic.push_back(vec![egui::Event::PointerMoved(egui::pos2(f("x"), f("y")))]); - ok(Json::Null) + let text = s("text").unwrap_or(""); + // Check the borrowed payload first: do not make a second, potentially large copy + // before the per-event bound is known to hold. + if text.len() > MAX_SYNTHETIC_TEXT_BYTES { + return err(format!("text input is {} bytes, over the {MAX_SYNTHETIC_TEXT_BYTES}-byte limit for one event", text.len())); + } + match push_text(app, text.to_string()) { + Ok(()) => ok(Json::Null), + Err(e) => err(e), + } } + "ui.move" => match push_move(app, egui::pos2(f("x"), f("y"))) { + Ok(()) => ok(Json::Null), + Err(e) => err(e), + }, "ui.click" => { let button = if s("button") == Some("right") { egui::PointerButton::Secondary } else { egui::PointerButton::Primary }; let count = p.get("count").and_then(Json::as_u64).unwrap_or(1).min(u64::from(u32::MAX)) as u32; @@ -460,12 +822,7 @@ pub fn poll(app: &mut SheetApp, ctx: &egui::Context) { Outcome::Done(v) => { let _ = req.reply.send(v); } - Outcome::Screenshot { path } => { - app.shots.token += 1; - let token = app.shots.token; - app.shots.queued.push((token, crate::now_ms() + 100.0, 0)); - app.shots.pending.push((token, path, req.reply, crate::now_ms() + 8000.0)); - } + Outcome::Screenshot { path } => admit(app, ctx, path, req.reply), } } app.control_rx = Some(rx); @@ -474,73 +831,506 @@ pub fn poll(app: &mut SheetApp, ctx: &egui::Context) { } } -pub fn issue_screenshots(app: &mut SheetApp, ctx: &egui::Context) { - let now = crate::now_ms(); - app.shots.queued.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 +fn reject_shot(reply: Sender, token: u64, message: impl std::fmt::Display) { + let _ = reply.send(json!({"ok": false, "error": message.to_string(), "request": token})); +} + +/// Put the request token on every screenshot reply (success, rejection, timeout), so a client can +/// correlate an asynchronous capture even when the reply order differs from the request order. +fn with_token(mut reply: Json, token: u64) -> Json { + if let Some(object) = reply.as_object_mut() { + object.insert("request".into(), json!(token)); + } + reply +} + +/// The geometry to use for admission, issuance and drain. +/// +/// Native eframe calls [`crate::SheetApp::raw_input_hook`] before every frame (including a +/// logic-only tick), so the recorded value carries fresh `RawInput` geometry even when egui's +/// last UI pass is stale. Tests and embedders that never call the hook fall back to egui's current +/// input; that fallback is only used when no raw frame was seen. +pub(crate) fn current_capture_geometry(app: &SheetApp, ctx: &egui::Context) -> CaptureGeometry { + if app.capture_geometry_from_raw { app.capture_geometry.unwrap_or_else(|| CaptureGeometry::current(ctx)) } else { CaptureGeometry::current(ctx) } +} + +/// Record `geometry` as the layout captures are bound to and bump the epoch when it differs. +/// +/// Issuance and drain use this instead of writing the fields separately: during a hidden +/// logic-only tick the viewport info can be newer than the last UI pass, and the epoch must +/// change together with the geometry or a valid image would be retried against its own epoch. +pub(crate) fn note_capture_geometry(app: &mut SheetApp, geometry: CaptureGeometry) -> u64 { + if app.capture_geometry != Some(geometry) { + app.capture_geometry = Some(geometry); + app.capture_epoch = app.capture_epoch.wrapping_add(1); + } + app.capture_epoch +} + +fn next_token(app: &mut SheetApp) -> u64 { + let mut token = app.shots.token.wrapping_add(1); + if token == 0 { + token = 1; + } + app.shots.token = token; + token +} + +/// Admit one screenshot request against the count and byte budgets **before** any capture is +/// issued. A rejected request gets one error reply and never evicts an earlier request. +fn admit(app: &mut SheetApp, ctx: &egui::Context, path: Option, reply: Sender) { + let token = next_token(app); + let geometry = current_capture_geometry(app, ctx); + let expected = geometry.expected_size(); + let base64 = path.is_none(); + // A minimized or not-yet-laid-out window has no size: reserve the largest image the + // per-image limit allows and keep the request queued. Issuance waits for a real size, and the + // deadline reports a timeout if none arrives. + let bytes = match expected { + Some(size) => { + let Some(bytes) = geometry.retained_bytes() else { + reject_shot(reply, token, "screenshot failed: the window size overflows the screenshot budget"); + return; + }; + if bytes > SCREENSHOT_MAX_IMAGE_BYTES { + reject_shot( + reply, + token, + format!( + "screenshot budget exceeded: {bytes} bytes for one {}×{} image, over the {SCREENSHOT_MAX_IMAGE_BYTES}-byte per-image limit", + size[0], size[1] + ), + ); + return; + } + if let Err(message) = check_encoding_budget(size, base64) { + reject_shot(reply, token, message); + return; + } + bytes } + None => UNKNOWN_CAPTURE_BYTES, + }; + if app.shots.requests.len() >= SCREENSHOT_MAX_REQUESTS { + reject_shot( + reply, + token, + format!("screenshot budget exceeded: {} requests are already outstanding (limit {SCREENSHOT_MAX_REQUESTS})", app.shots.requests.len()), + ); + return; + } + let reserved = app.shots.reserved().saturating_add(bytes); + if reserved > SCREENSHOT_MAX_AGGREGATE_BYTES { + reject_shot( + reply, + token, + format!( + "screenshot budget exceeded: {reserved} bytes reserved by outstanding requests, over the {SCREENSHOT_MAX_AGGREGATE_BYTES}-byte aggregate limit" + ), + ); + return; + } + if let Some(size) = expected + && let Err(message) = check_peak_budget(reserved, size, base64) + { + reject_shot(reply, token, message); + return; + } + let now = crate::monotonic_ms(); + let document = app.session.active().map(|d| d.uid); + let epoch = note_capture_geometry(app, geometry); + app.shots.requests.push(Shot { + token, + path, + reply, + deadline: now + 8000.0, + ready_at: now + 100.0, + frames: 0, + settle_pass: None, + issued: false, + retries: 0, + geometry, + viewport_id: geometry.viewport_id, + document, + epoch, + expected, + bytes, + // Issued only from `ui`, so the slot is created there against the geometry in force. + slot: None, }); - if !app.shots.queued.is_empty() || !app.shots.pending.is_empty() { + // A queued request with a capturable size has not issued a paint-requesting command yet; + // `logic` keeps asking for hidden paints until `issue_screenshots` runs. A size-less request + // asks for none (see `Shots::needs_hidden_paint`). + ctx.request_repaint(); +} + +fn timeout_reply(message: &str, token: u64) -> Json { + json!({"ok": false, "error": message, "request": token}) +} + +/// Issued from `ui`: counts one real (non-discarded) settling pass per request, revalidates the +/// geometry and reserves the exact retained bytes, then sends one +/// `ViewportCommand::Screenshot(callback)` per request. +/// +/// The slot is created here, not at admission. Its per-image bound is the same reservation the +/// aggregate budget holds, so an image that would exceed the budget cannot be retained; a retry +/// after a resize creates a fresh slot for the new geometry. +pub fn issue_screenshots(app: &mut SheetApp, ctx: &egui::Context) { + let now = crate::monotonic_ms(); + let pass = ctx.cumulative_pass_nr(); + // A pass whose output is already going to be discarded is not a settling frame and its + // layout is not the one to capture. GridCraft requests no discard after this call. + let discard = ctx.will_discard(); + let active_document = app.session.active().map(|d| d.uid); + let current_viewport = ctx.viewport_id(); + let len = app.shots.requests.len(); + let mut failures: Vec<(usize, String)> = Vec::new(); + for i in 0..len { + let (issued, ready_at, settle_pass) = { + let r = &app.shots.requests[i]; + (r.issued, r.ready_at, r.settle_pass) + }; + if issued || discard || settle_pass == Some(pass) { + continue; + } + // One count per egui pass, however many times this `ui` runs in it. + let frames = { + let r = &mut app.shots.requests[i]; + r.settle_pass = Some(pass); + r.frames = r.frames.saturating_add(1); + r.frames + }; + if now < ready_at || frames < 3 { + continue; + } + // A queued capture belongs to the document and viewport it was admitted for. A switch + // before issuance is reported once and never rebound to the new document. + let (viewport_id, document, base64) = { + let r = &app.shots.requests[i]; + (r.viewport_id, r.document, r.path.is_none()) + }; + if viewport_id != current_viewport { + failures.push((i, SCREENSHOT_VIEWPORT_CHANGED.into())); + continue; + } + if document != active_document { + failures.push((i, SCREENSHOT_DOCUMENT_CHANGED.into())); + continue; + } + let current = current_capture_geometry(app, ctx); + let Some(expected) = current.expected_size() else { + // Still minimized or not laid out: keep the request queued and try again on a later + // real pass; `drain_screenshots` reports the size-specific timeout if none arrives. + app.shots.requests[i].expected = None; + continue; + }; + let Some(bytes) = current.retained_bytes() else { + failures.push((i, "screenshot failed: the window size overflows the screenshot budget".into())); + continue; + }; + if bytes > SCREENSHOT_MAX_IMAGE_BYTES { + failures.push(( + i, + format!( + "screenshot budget exceeded: {bytes} bytes for one {}×{} image, over the {SCREENSHOT_MAX_IMAGE_BYTES}-byte per-image limit", + expected[0], expected[1] + ), + )); + continue; + } + if let Err(message) = check_encoding_budget(expected, base64) { + failures.push((i, message)); + continue; + } + let others = app.shots.requests.iter().enumerate().filter(|(j, _)| *j != i).fold(0usize, |sum, (_, r)| sum.saturating_add(r.bytes)); + if others.saturating_add(bytes) > SCREENSHOT_MAX_AGGREGATE_BYTES { + failures.push(( + i, + format!( + "screenshot budget exceeded: {bytes} bytes for this image would exceed the {SCREENSHOT_MAX_AGGREGATE_BYTES}-byte aggregate limit" + ), + )); + continue; + } + if let Err(message) = check_peak_budget(others.saturating_add(bytes), expected, base64) { + failures.push((i, message)); + continue; + } + // Update the geometry and its epoch together, so the drain accepts exactly the layout the + // capture is issued against even when a hidden logic tick already saw the new viewport. + let epoch = note_capture_geometry(app, current); + let r = &mut app.shots.requests[i]; + r.geometry = current; + r.expected = Some(expected); + r.bytes = bytes; + r.epoch = epoch; + if let Some(old) = r.slot.take() { + old.cancel(); + } + let (slot, callback) = CaptureSlot::new(ctx, viewport_id, bytes); + r.slot = Some(slot); + ctx.send_viewport_cmd_to(viewport_id, egui::ViewportCommand::Screenshot(callback)); + r.issued = true; + } + for (i, message) in failures.into_iter().rev() { + let request = app.shots.requests.remove(i); + if let Some(slot) = request.slot { + slot.cancel(); + } + reject_shot(request.reply, request.token, message); + } + if app.shots.in_flight() { ctx.request_repaint_after(std::time::Duration::from_millis(16)); } } -pub fn collect_screenshots(app: &mut SheetApp, ctx: &egui::Context) { - if app.shots.pending.is_empty() { - return; +fn validate_image(image: &egui::ColorImage, expected: [usize; 2]) -> Result<(), String> { + let [w, h] = image.size; + let [ew, eh] = expected; + let pixels = w.checked_mul(h).ok_or_else(|| "screenshot dimensions overflow".to_string())?; + if image.pixels.len() != pixels { + return Err(format!("screenshot has {} pixels for its {w}×{h} dimensions", image.pixels.len())); } - let events: Vec<(u64, std::sync::Arc)> = 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())) + // Allow one physical pixel of rounding per axis, but not independent distortion: an image + // that is one pixel wider and one pixel shorter has a different aspect and cannot be the + // recorded viewport. Rejecting it forces the retry/timeout path instead of saving skewed pixels. + if w.abs_diff(ew) > 1 || h.abs_diff(eh) > 1 { + return Err(format!("screenshot is {w}×{h} pixels, expected {ew}×{eh}: the window changed size while it was captured")); + } + // Keep the returned scale's aspect within the rounding error of the recorded logical viewport. + let cross = w.checked_mul(eh).and_then(|a| h.checked_mul(ew).map(|b| a.abs_diff(b))); + let Some(cross) = cross else { + return Err("screenshot dimensions overflow the aspect check".to_string()); + }; + if cross > ew.max(eh) { + return Err(format!("screenshot is {w}×{h} pixels, expected {ew}×{eh}: the aspect ratio changed while it was captured")); + } + Ok(()) +} + +/// Called from `logic`: takes each completed slot once, validates identity, size, geometry/epoch +/// and **deadline** and only then encodes, writes and replies outside the slot. A stale, oversized +/// or late image is dropped without writing a file. +pub fn drain_screenshots(app: &mut SheetApp, ctx: &egui::Context) { + let now = crate::monotonic_ms(); + let current_geometry = current_capture_geometry(app, ctx); + let epoch = note_capture_geometry(app, current_geometry); + let active_document = app.session.active().map(|d| d.uid); + let current_viewport = ctx.viewport_id(); + let mut i = 0; + while i < app.shots.requests.len() { + enum Action { + Keep, + Remove(Json), + Restart, + } + let action = { + let r = &mut app.shots.requests[i]; + // A document or viewport change is a definitive mismatch: cancel once, never retry and + // never write the pixels against the new identity. + if r.viewport_id != current_viewport { + if let Some(slot) = r.slot.take() { + slot.cancel(); } - _ => None, - }) - .collect() - }); - for (token, image) in events { - if let Some(i) = app.shots.pending.iter().position(|(t, ..)| *t == token) { - let (_, path, reply, _) = app.shots.pending.remove(i); - let _ = reply.send(save_image(&image, path.as_deref())); + Action::Remove(json!({"ok": false, "error": SCREENSHOT_VIEWPORT_CHANGED})) + } else if r.document != active_document { + if let Some(slot) = r.slot.take() { + slot.cancel(); + } + Action::Remove(json!({"ok": false, "error": SCREENSHOT_DOCUMENT_CHANGED})) + } else if !r.issued { + if now >= r.deadline { + if let Some(slot) = r.slot.take() { + slot.cancel(); + } + let message = if r.expected.is_none() { + "screenshot timeout: the window never reported a capturable size (it may be minimized or not laid out)" + } else { + "no frame was presented before the screenshot timeout" + }; + Action::Remove(timeout_reply(message, r.token)) + } else { + Action::Keep + } + } else { + let taken = r.slot.as_ref().and_then(|slot| slot.take()); + match taken { + // A completion that arrives past the deadline is late even if the pixels are + // valid: the client was promised one answer within the deadline, so nothing is + // written for it. + Some(result) if now >= r.deadline => { + drop(result); + Action::Remove(timeout_reply("screenshot timeout: the image completed after its deadline; no file was written", r.token)) + } + Some(Ok(image)) => { + let validated = match r.expected { + Some(expected) => validate_image(&image, expected), + None => Err("screenshot has no expected size".to_string()), + }; + match validated { + Err(_) if r.retries < SCREENSHOT_MAX_RETRIES && now < r.deadline => { + drop(image); + Action::Restart + } + Err(message) => Action::Remove(json!({"ok": false, "error": message})), + Ok(()) if epoch != r.epoch || current_geometry != r.geometry => { + if r.retries < SCREENSHOT_MAX_RETRIES && now < r.deadline { + drop(image); + Action::Restart + } else { + Action::Remove(json!({ + "ok": false, + "error": "screenshot geometry changed while it was captured; no image was written" + })) + } + } + Ok(()) => Action::Remove(save_image(image, r.path.as_deref())), + } + } + // The slot refused the image before `validate_image` saw it: an image + // larger than the reservation is as much evidence of a resize as a smaller + // one. Retry within the same bounded budget instead of failing only when the + // window grew. A non-resize fault still exhausts the retries and reports the + // slot's error, so this stays fail-closed. + Some(Err(_)) if r.retries < SCREENSHOT_MAX_RETRIES && now < r.deadline => Action::Restart, + Some(Err(e)) => Action::Remove(json!({"ok": false, "error": e.to_string()})), + None if now >= r.deadline => { + if let Some(slot) = r.slot.take() { + slot.cancel(); + } + Action::Remove(timeout_reply("no frame was presented before the screenshot timeout", r.token)) + } + None => Action::Keep, + } + } + }; + match action { + Action::Keep => i += 1, + Action::Remove(reply) => { + let request = app.shots.requests.remove(i); + if let Some(slot) = request.slot { + slot.cancel(); + } + let _ = request.reply.send(with_token(reply, request.token)); + } + Action::Restart => match retry_capture(app, i, current_geometry, epoch, now) { + Ok(()) => i += 1, + Err(message) => { + let request = app.shots.requests.remove(i); + if let Some(slot) = request.slot { + slot.cancel(); + } + reject_shot(request.reply, request.token, message); + } + }, } } - let now = crate::now_ms(); - app.shots.pending.retain(|(_, _, reply, deadline)| { - if now < *deadline { - return true; + if app.shots.in_flight() { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } +} + +/// Re-arm one request for a fresh issuance against `geometry`: a new reservation (the maximum +/// retained image, or [`UNKNOWN_CAPTURE_BYTES`] while there is no size), a new epoch and no slot. +/// A fresh slot and callback are created by [`issue_screenshots`]; the old image was dropped by +/// the caller. +fn retry_capture(app: &mut SheetApp, i: usize, geometry: CaptureGeometry, epoch: u64, now: f64) -> Result<(), String> { + let expected = geometry.expected_size(); + let base64 = app.shots.requests.get(i).is_some_and(|r| r.path.is_none()); + let bytes = match expected { + Some(size) => { + let bytes = geometry.retained_bytes().ok_or_else(|| "screenshot failed: the window size overflows the screenshot budget".to_string())?; + if bytes > SCREENSHOT_MAX_IMAGE_BYTES { + return Err(format!( + "screenshot budget exceeded: {bytes} bytes for one {}×{} image, over the {SCREENSHOT_MAX_IMAGE_BYTES}-byte per-image limit", + size[0], size[1] + )); + } + check_encoding_budget(size, base64)?; + bytes } - let _ = reply.send(json!({"ok": false, "error": "no frame was presented before the screenshot timeout; make the window visible, use `ui.focus`, then retry `ui.screenshot`"})); - false - }); + None => UNKNOWN_CAPTURE_BYTES, + }; + let others = app.shots.requests.iter().enumerate().filter(|(j, _)| *j != i).fold(0usize, |sum, (_, r)| sum.saturating_add(r.bytes)); + if others.saturating_add(bytes) > SCREENSHOT_MAX_AGGREGATE_BYTES { + return Err(format!( + "screenshot budget exceeded: {bytes} bytes for this image would exceed the {SCREENSHOT_MAX_AGGREGATE_BYTES}-byte aggregate limit" + )); + } + if let Some(size) = expected { + check_peak_budget(others.saturating_add(bytes), size, base64)?; + } + let r = &mut app.shots.requests[i]; + r.retries = r.retries.saturating_add(1); + r.issued = false; + r.frames = 3; + r.settle_pass = None; + r.ready_at = now; + r.expected = expected; + r.bytes = bytes; + r.geometry = geometry; + r.epoch = epoch; + if let Some(slot) = r.slot.take() { + slot.cancel(); + } + Ok(()) } /// Encodes a screenshot as PNG to `path`, or returns it as base64. -pub fn save_image(image: &egui::ColorImage, path: Option<&str>) -> Json { +/// +/// The retained `ColorImage` is released before encoding, and a path capture streams the PNG +/// straight to the file. A base64 reply is length-checked against +/// [`SCREENSHOT_MAX_BASE64_BYTES`] *before* the string is allocated, so one admitted capture cannot +/// retain an unbounded reply alongside its image. +pub fn save_image(image: std::sync::Arc, path: Option<&str>) -> Json { + use image::ImageEncoder as _; let [w, h] = image.size; - let mut rgba = Vec::with_capacity(w * h * 4); + let Some(capacity) = w.checked_mul(h).and_then(|p| p.checked_mul(4)) else { return json!({"ok": false, "error": "bad image"}) }; + if image.pixels.len().checked_mul(4) != Some(capacity) { + return json!({"ok": false, "error": "bad image"}); + } + let (Ok(width), Ok(height)) = (u32::try_from(w), u32::try_from(h)) else { return json!({"ok": false, "error": "bad image"}) }; + // One RGBA copy is unavoidable with the `image` encoder API. Drop the retained image before + // encoding so the peak is the copy plus encoder buffers, not both images plus the PNG stream. + let mut rgba = Vec::with_capacity(capacity); for p in &image.pixels { rgba.extend_from_slice(&p.to_array()); } - let Some(img) = image::RgbaImage::from_raw(w as u32, h as u32, rgba) else { return json!({"ok": false, "error": "bad image"}) }; - let mut png = Vec::new(); - if let Err(e) = image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) { - return json!({"ok": false, "error": e.to_string()}); - } + drop(image); match path { - Some(p) => match std::fs::write(p, &png) { - Ok(()) => json!({"ok": true, "result": {"path": p, "width": w, "height": h}}), + Some(p) => match std::fs::File::create(p) { + Ok(file) => { + let encoder = image::codecs::png::PngEncoder::new(file); + match encoder.write_image(&rgba, width, height, image::ExtendedColorType::Rgba8) { + Ok(()) => json!({"ok": true, "result": {"path": p, "width": w, "height": h}}), + Err(e) => { + // Do not leave a truncated PNG behind. + let _ = std::fs::remove_file(p); + json!({"ok": false, "error": e.to_string()}) + } + } + } Err(e) => json!({"ok": false, "error": e.to_string()}), }, - None => json!({"ok": true, "result": {"base64": gridcraft_engine::io::base64_encode(&png), "width": w, "height": h}}), + None => { + let mut png = Vec::new(); + let encoder = image::codecs::png::PngEncoder::new(&mut png); + if let Err(e) = encoder.write_image(&rgba, width, height, image::ExtendedColorType::Rgba8) { + return json!({"ok": false, "error": e.to_string()}); + } + let Some(encoded_len) = base64_len(png.len()) else { + return json!({"ok": false, "error": "screenshot base64 length overflow"}); + }; + if encoded_len > SCREENSHOT_MAX_BASE64_BYTES { + return json!({ + "ok": false, + "error": format!( + "screenshot base64 budget exceeded: the {w}×{h} reply would be {encoded_len} bytes, over the {SCREENSHOT_MAX_BASE64_BYTES}-byte base64 limit; request a path instead" + ) + }); + } + json!({"ok": true, "result": {"base64": gridcraft_engine::io::base64_encode(&png), "width": w, "height": h}}) + } } } @@ -586,11 +1376,11 @@ mod tests { assert_eq!(app.synthetic.iter().map(Vec::len).sum::(), 5); assert!(push_click(&mut app, p, btn, u32::MAX, m).is_err()); assert!(push_drag(&mut app, p, p, usize::MAX, m).is_err()); - assert!(push_drag(&mut app, p, p, MAX_QUEUED_POINTER_EVENTS, m).is_err()); + assert!(push_drag(&mut app, p, p, MAX_QUEUED_SYNTHETIC_EVENTS, m).is_err()); assert_eq!(app.synthetic.len(), 2); assert_eq!(app.synthetic.iter().map(Vec::len).sum::(), 5); // Already queued input counts toward the budget. - assert!(push_click(&mut app, p, btn, (MAX_QUEUED_POINTER_EVENTS / 2) as u32, m).is_err()); + assert!(push_click(&mut app, p, btn, (MAX_QUEUED_SYNTHETIC_EVENTS / 2) as u32, m).is_err()); assert_eq!(app.synthetic.len(), 2); // Normal requests still queue in full. assert!(push_drag(&mut app, p, p + egui::vec2(40.0, 40.0), 3, m).is_ok()); @@ -636,4 +1426,785 @@ mod tests { assert!(docs.contains(&format!("\"{}\"", l.code())), "app.language.set docs miss {:?}: {docs}", l.code()); } } + /// One headless UI pass. A test has no renderer to apply the font-atlas delta, so dropping + /// it is intentional (0.37 panics when a `TexturesDelta` is dropped unapplied). + fn ui_pass(ctx: &egui::Context, raw: egui::RawInput, f: impl FnMut(&mut egui::Ui)) -> egui::FullOutput { + let mut out = ctx.run_ui(raw, f); + out.textures_delta.clear(); + out + } + + fn sized_context(size: egui::Vec2) -> egui::Context { + let ctx = egui::Context::default(); + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), + max_texture_side: Some(8192), + ..Default::default() + }; + let _ = ui_pass(&ctx, raw, |_| {}); + ctx + } + + fn raw_of(size: egui::Vec2) -> egui::RawInput { + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), max_texture_side: Some(8192), ..Default::default() } + } + + fn screenshot_callbacks(out: &egui::FullOutput, ctx: &egui::Context) -> Vec { + out.viewport_output + .get(&ctx.viewport_id()) + .map(|output| { + output + .commands + .iter() + .filter_map(|command| match command { + egui::ViewportCommand::Screenshot(callback) => Some(callback.clone()), + _ => None, + }) + .collect::>() + }) + .unwrap_or_default() + } + + /// Run three settled passes with `raw` and return the callbacks issued in them. + fn issue_with(ctx: &egui::Context, app: &mut SheetApp, raw: egui::RawInput) -> Vec { + let mut callbacks = Vec::new(); + for _ in 0..3 { + let out = ui_pass(ctx, raw.clone(), |ui| issue_screenshots(app, ui.ctx())); + callbacks.extend(screenshot_callbacks(&out, ctx)); + } + callbacks + } + + fn temp_png(name: &str) -> std::path::PathBuf { + let path = std::env::temp_dir().join(format!("gridcraft-shot-{name}-{}.png", std::process::id())); + let _ = std::fs::remove_file(&path); + path + } + + #[test] + fn capture_geometry_reports_checked_capped_pixels() { + let geometry = CaptureGeometry { + viewport_id: egui::ViewportId::ROOT, + viewport: Some(egui::vec2(800.0, 600.0)), + pixels_per_point: 2.0, + zoom: 1.0, + max_texture_side: 4096, + minimized: None, + occluded: None, + visible: None, + }; + assert_eq!(geometry.expected_size(), Some([1600, 1200])); + assert_eq!(geometry.expected_bytes(), Some(1600 * 1200 * 4)); + assert_eq!(geometry.retained_bytes(), Some(1601 * 1201 * 4), "one pixel of rounding per axis is reserved too"); + let capped = CaptureGeometry { max_texture_side: 1024, ..geometry }; + assert_eq!(capped.expected_size(), Some([1024, 1024]), "the surface limit caps both axes"); + let empty = CaptureGeometry { viewport: Some(egui::Vec2::ZERO), ..geometry }; + assert_eq!(empty.expected_size(), None, "a minimized window has no capturable size"); + assert_eq!(empty.retained_bytes(), None); + let huge = CaptureGeometry { viewport: Some(egui::vec2(f32::MAX, f32::MAX)), ..geometry }; + assert_eq!(huge.expected_size(), None, "overflow is refused, not truncated"); + } + + #[test] + fn screenshot_admission_stops_on_the_count_budget_without_evicting() { + let ctx = sized_context(egui::vec2(800.0, 600.0)); + let mut app = test_app(); + let mut replies = Vec::new(); + for _ in 0..SCREENSHOT_MAX_REQUESTS { + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + replies.push(rx); + } + assert_eq!(app.shots.requests.len(), SCREENSHOT_MAX_REQUESTS); + assert!(replies.iter_mut().all(|r| r.try_recv().is_err()), "admitted requests do not reply early"); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + let reply = rx.try_recv().expect("the over-count request is rejected"); + assert_eq!(reply["ok"], false); + assert!(reply["error"].as_str().is_some_and(|e| e.contains("limit 4")), "{reply}"); + assert!(reply["request"].as_u64().is_some(), "a rejection carries its request token: {reply}"); + assert_eq!(app.shots.requests.len(), SCREENSHOT_MAX_REQUESTS, "an earlier request is never evicted"); + } + + #[test] + fn screenshot_admission_stops_on_aggregate_bytes_before_the_count() { + let ctx = sized_context(egui::vec2(5000.0, 5000.0)); + let mut app = test_app(); + let path = temp_png("aggregate").to_string_lossy().into_owned(); + let mut rejected = None; + for _ in 0..3 { + let (tx, rx) = std::sync::mpsc::channel(); + // A path capture (not base64) isolates the retained-image aggregate budget. + admit(&mut app, &ctx, Some(path.clone()), tx); + if let Ok(reply) = rx.try_recv() { + rejected = Some(reply); + } + } + let reply = rejected.expect("three 100 MB captures exceed the 256 MiB aggregate budget"); + assert!(reply["error"].as_str().is_some_and(|e| e.contains("aggregate limit")), "{reply}"); + assert_eq!(app.shots.requests.len(), 2); + } + + #[test] + fn requests_without_a_size_stay_queued_and_time_out() { + let ctx = sized_context(egui::Vec2::ZERO); + let mut app = test_app(); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + assert!(rx.try_recv().is_err(), "an unknown size is queued, not refused with an overflow error"); + assert_eq!(app.shots.requests[0].bytes, UNKNOWN_CAPTURE_BYTES, "the largest possible image is reserved meanwhile"); + // Settling passes without a size neither issue nor fail the request. + let callbacks = issue_with(&ctx, &mut app, raw_of(egui::Vec2::ZERO)); + assert!(callbacks.is_empty()); + assert!(!app.shots.requests[0].issued); + assert!(rx.try_recv().is_err()); + app.shots.requests[0].deadline = 0.0; + let _ = ui_pass(&ctx, raw_of(egui::Vec2::ZERO), |ui| drain_screenshots(&mut app, ui.ctx())); + let reply = rx.try_recv().expect("the size-less request times out once"); + assert!(reply["error"].as_str().is_some_and(|e| e.contains("capturable size")), "{reply}"); + assert_eq!(app.shots.reserved(), 0, "the placeholder reservation is released"); + } + + fn hidden_paints(out: &egui::LogicOutput) -> usize { + out.viewport_commands.values().flatten().filter(|c| matches!(c, egui::ViewportCommand::RequestPaintWhileHidden)).count() + } + + #[test] + fn a_queued_capture_requests_hidden_paint_until_it_is_issued() { + let ctx = sized_context(egui::vec2(64.0, 48.0)); + let mut app = test_app(); + let (tx, _rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + let queued = ctx.run_logic(&raw_of(egui::vec2(64.0, 48.0)), |ctx| app.logic(ctx)); + assert_eq!(hidden_paints(&queued), 1, "a queued capture needs a real `ui` pass"); + + app.shots.requests[0].ready_at = 0.0; + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert_eq!(callbacks.len(), 1); + let issued = ctx.run_logic(&raw_of(egui::vec2(64.0, 48.0)), |ctx| app.logic(ctx)); + assert_eq!(hidden_paints(&issued), 0, "the issued Screenshot command paints the hidden window by itself"); + } + + #[test] + fn a_minimized_capture_does_not_force_hidden_frames_while_it_waits() { + let ctx = egui::Context::default(); + let mut app = test_app(); + let mut raw = raw_of(egui::vec2(800.0, 600.0)); + if let Some(v) = raw.viewports.get_mut(&egui::ViewportId::ROOT) { + v.inner_rect = None; + v.minimized = Some(true); + } + app.raw_input_hook(&ctx, &mut raw); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + assert!(rx.try_recv().is_err()); + let waited = ctx.run_logic(&raw, |ctx| app.logic(ctx)); + assert_eq!(hidden_paints(&waited), 0, "a minimized request cannot issue until it is restored"); + app.shots.cancel_all(); + } + + #[test] + fn one_admitted_capture_issues_one_callback_and_replies_once() { + use egui_kittest::kittest::Queryable as _; + + let mut app = test_app(); + app.use_prepared_fonts(); + let mut h = egui_kittest::Harness::builder() + .with_size(egui::vec2(900.0, 700.0)) + .with_context_setup(|ctx| { + ctx.set_fonts(crate::theme::pinned_font_definitions()); + crate::theme::apply(ctx, false); + }) + // This test completes the callback itself. Keep the real app/AccessKit tree, but + // do not let kittest's automatic screenshot handling initialize a GPU renderer. + .renderer(egui_kittest::LazyRenderer::Uninitialized { textures_delta: Default::default(), builder: None }) + .build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + assert!(h.query_by_label("Name Box").is_some(), "the capture fixture draws the real app, not an empty tree"); + h.check_accessibility(); + + let ctx = h.ctx.clone(); + let (tx, rx) = std::sync::mpsc::channel(); + admit(h.state_mut(), &ctx, None, tx); + let token = h.state().shots.requests[0].token; + h.state_mut().shots.requests[0].ready_at = 0.0; + let mut callbacks = Vec::new(); + for _ in 0..3 { + h.step(); + callbacks.extend(screenshot_callbacks(h.output(), &ctx)); + } + assert_eq!(callbacks.len(), 1, "the settled request is issued once"); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([900, 700], egui::Color32::RED))); + h.step(); // the real logic drains the callback and the UI repaints + h.check_accessibility(); + assert!(h.query_by_label("Name Box").is_some(), "the app tree survives callback drain and repaint"); + let reply = rx.try_recv().expect("the drain replies once"); + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(reply["result"]["width"], 900); + assert_eq!(reply["result"]["height"], 700); + assert_eq!(reply["request"].as_u64(), Some(token), "the reply names its request"); + assert!(rx.try_recv().is_err(), "a completed slot is taken exactly once"); + assert_eq!(h.state().shots.reserved(), 0, "the reservation is released after the reply"); + } + + #[test] + fn out_of_order_completions_reply_on_their_own_request() { + let ctx = sized_context(egui::vec2(8.0, 8.0)); + let mut app = test_app(); + let p1 = temp_png("order-1"); + let p2 = temp_png("order-2"); + let (tx1, rx1) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(p1.to_string_lossy().into_owned()), tx1); + let token1 = app.shots.requests[0].token; + let (tx2, rx2) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(p2.to_string_lossy().into_owned()), tx2); + let token2 = app.shots.requests[1].token; + for request in &mut app.shots.requests { + request.ready_at = 0.0; + } + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert_eq!(callbacks.len(), 2, "one callback per admitted request"); + // Complete the second request first: slots, not callback order, carry the result. + callbacks[1].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([8, 8], egui::Color32::BLUE))); + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + let second = rx2.try_recv().expect("the second request gets its own reply"); + assert_eq!(second["ok"], true, "{second}"); + assert_eq!(second["request"].as_u64(), Some(token2)); + assert_eq!(second["result"]["path"].as_str(), Some(p2.to_string_lossy().as_ref())); + assert!(!p1.exists(), "the unfinished request has not written anything"); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([8, 8], egui::Color32::RED))); + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + let first = rx1.try_recv().expect("the first request gets its own reply"); + assert_eq!(first["request"].as_u64(), Some(token1)); + assert_eq!(first["result"]["path"].as_str(), Some(p1.to_string_lossy().as_ref())); + assert_ne!(std::fs::read(&p1).unwrap(), std::fs::read(&p2).unwrap(), "each reply carries its own pixels"); + let _ = std::fs::remove_file(&p1); + let _ = std::fs::remove_file(&p2); + assert_eq!(app.shots.reserved(), 0); + } + + #[test] + fn a_timed_out_capture_replies_once_and_never_writes() { + let ctx = sized_context(egui::vec2(64.0, 48.0)); + let mut app = test_app(); + let path = temp_png("timeout"); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); + app.shots.requests[0].deadline = 0.0; + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + let reply = rx.try_recv().expect("the timeout replies"); + assert_eq!(reply["ok"], false); + assert!(reply["error"].as_str().is_some_and(|e| e.contains("timeout")), "{reply}"); + assert!(!path.exists(), "a timed-out capture never writes a file"); + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + assert!(rx.try_recv().is_err(), "a timeout is reported once"); + assert_eq!(app.shots.reserved(), 0); + } + + #[test] + fn a_capture_completed_after_its_deadline_writes_nothing() { + let ctx = sized_context(egui::vec2(64.0, 48.0)); + let mut app = test_app(); + let path = temp_png("late"); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); + let token = app.shots.requests[0].token; + app.shots.requests[0].ready_at = 0.0; + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert_eq!(callbacks.len(), 1); + // The deadline passes while the callback is outstanding; the image arrives afterwards. + app.shots.requests[0].deadline = 0.0; + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([64, 48], egui::Color32::RED))); + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + let reply = rx.try_recv().expect("the late completion gets one timeout reply"); + assert_eq!(reply["ok"], false, "{reply}"); + assert!(reply["error"].as_str().is_some_and(|e| e.contains("timeout")), "{reply}"); + assert_eq!(reply["request"].as_u64(), Some(token)); + assert!(!path.exists(), "a capture that completed after its deadline never writes a file"); + assert!(rx.try_recv().is_err(), "the late image is reported once"); + assert_eq!(app.shots.reserved(), 0); + } + + #[test] + fn a_callback_after_the_request_is_removed_is_inert() { + let ctx = sized_context(egui::vec2(32.0, 24.0)); + let mut app = test_app(); + let path = temp_png("removed"); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); + app.shots.requests[0].ready_at = 0.0; + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert_eq!(callbacks.len(), 1); + app.shots.cancel_all(); + assert!(rx.try_recv().is_err(), "cancelling a request does not reply"); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([32, 24], egui::Color32::RED))); + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + assert!(rx.try_recv().is_err(), "a callback for a removed request is inert"); + assert!(!path.exists()); + assert_eq!(app.shots.reserved(), 0); + } + + #[test] + fn a_resize_between_admission_and_issuance_does_not_cost_a_retry() { + let ctx = sized_context(egui::vec2(64.0, 48.0)); + let mut app = test_app(); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + app.shots.requests[0].ready_at = 0.0; + let admitted_epoch = app.shots.requests[0].epoch; + let resized = raw_of(egui::vec2(32.0, 24.0)); + let callbacks = issue_with(&ctx, &mut app, resized.clone()); + assert_eq!(callbacks.len(), 1); + let request = &app.shots.requests[0]; + assert_eq!(request.retries, 0, "the first issuance adopts the current geometry instead of spending a retry"); + assert_eq!(request.geometry.viewport, Some(egui::vec2(32.0, 24.0))); + assert_eq!(request.expected, Some([32, 24])); + assert_eq!(request.bytes, 33 * 25 * 4); + assert!(request.epoch > admitted_epoch, "issuance records the new epoch together with the geometry"); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([32, 24], egui::Color32::RED))); + let _ = ui_pass(&ctx, resized, |ui| drain_screenshots(&mut app, ui.ctx())); + let reply = rx.try_recv().expect("the once-issued capture replies"); + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(app.shots.reserved(), 0); + } + + #[test] + fn the_retained_reservation_is_the_slot_bound() { + let ctx = sized_context(egui::vec2(10.0, 8.0)); + let mut app = test_app(); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + // validate_image accepts one pixel of rounding per axis: an 11x9 image is the largest. + assert_eq!(app.shots.requests[0].bytes, 11 * 9 * 4); + app.shots.requests[0].ready_at = 0.0; + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert_eq!(callbacks.len(), 1); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([11, 9], egui::Color32::RED))); + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + let reply = rx.try_recv().expect("the rounded image is accepted"); + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(reply["result"]["width"], 11); + assert_eq!(app.shots.reserved(), 0); + } + + #[test] + fn an_image_beyond_the_reservation_is_released_without_writing() { + let ctx = sized_context(egui::vec2(10.0, 8.0)); + let mut app = test_app(); + let p1 = temp_png("over-1"); + let p2 = temp_png("over-2"); + let (tx1, rx1) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(p1.to_string_lossy().into_owned()), tx1); + let (tx2, rx2) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(p2.to_string_lossy().into_owned()), tx2); + for request in &mut app.shots.requests { + request.ready_at = 0.0; + } + assert_eq!(app.shots.reserved(), 2 * 11 * 9 * 4); + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert_eq!(callbacks.len(), 2); + // The slot refuses an image over the reservation instead of retaining bytes beyond it. + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([12, 9], egui::Color32::RED))); + // The other request completes with valid pixels. + callbacks[1].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([10, 8], egui::Color32::BLUE))); + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + // A larger image can be a genuine resize, so it receives the same bounded retries as + // a smaller one. Each oversized callback is released and never written while retrying. + for attempt in 1..=SCREENSHOT_MAX_RETRIES { + assert_eq!(app.shots.requests[0].retries, attempt); + assert!(rx1.try_recv().is_err(), "retry {attempt} has not replied early"); + assert!(!p1.exists(), "oversized pixels are never written on retry {attempt}"); + let callbacks = issue_with(&ctx, &mut app, raw_of(egui::vec2(10.0, 8.0))); + assert_eq!(callbacks.len(), 1, "the other valid request is already complete"); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([12, 9], egui::Color32::RED))); + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + } + let refused = rx1.try_recv().expect("the oversized image is reported after bounded retries"); + assert_eq!(refused["ok"], false, "{refused}"); + assert!(refused["error"].as_str().is_some_and(|e| e.contains("per-image limit")), "{refused}"); + assert!(!p1.exists()); + let accepted = rx2.try_recv().expect("the valid image is written"); + assert_eq!(accepted["ok"], true, "{accepted}"); + let _ = std::fs::remove_file(&p2); + assert_eq!(app.shots.reserved(), 0, "both reservations are released"); + } + + #[test] + fn settling_counts_one_real_pass_and_skips_a_discarded_one() { + let ctx = sized_context(egui::vec2(64.0, 48.0)); + let mut app = test_app(); + let (tx, _rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + // Two `ui` calls in one pass are one settling frame. + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| { + issue_screenshots(&mut app, ui.ctx()); + issue_screenshots(&mut app, ui.ctx()); + }); + assert_eq!(app.shots.requests[0].frames, 1); + // A pass that already asked to be discarded does not count, even though `run_ui` repeats it. + let mut asked = false; + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| { + if !asked { + asked = true; + ui.ctx().request_discard("settling test"); + } + issue_screenshots(&mut app, ui.ctx()); + }); + assert_eq!(app.shots.requests[0].frames, 2, "only the rerun (real) pass counted"); + } + + #[test] + fn a_discard_requested_after_issuance_still_issues_and_writes_once() { + let ctx = sized_context(egui::vec2(64.0, 48.0)); + let mut app = test_app(); + let path = temp_png("discard-after-issue"); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); + app.shots.requests[0].ready_at = 0.0; + let mut asked = false; + let mut callbacks = Vec::new(); + for _ in 0..3 { + let out = ui_pass(&ctx, egui::RawInput::default(), |ui| { + issue_screenshots(&mut app, ui.ctx()); + if !asked && app.shots.requests.first().is_some_and(|r| r.issued) { + asked = true; + ui.ctx().request_discard("discard after screenshot issuance"); + } + }); + callbacks.extend(screenshot_callbacks(&out, &ctx)); + } + assert!(asked, "the test requested a discard after issuance"); + assert_eq!(callbacks.len(), 1, "issuance happens exactly once across the discard repeat"); + let image = std::sync::Arc::new(egui::ColorImage::filled([64, 48], egui::Color32::RED)); + callbacks[0].clone().complete(image.clone()); + callbacks[0].clone().complete(image); + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + let reply = rx.try_recv().expect("the discarded pass still delivers one reply"); + assert_eq!(reply["ok"], true, "{reply}"); + assert!(path.exists(), "the image is written exactly once"); + assert!(rx.try_recv().is_err(), "a second completion of the same callback is inert"); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn an_image_larger_than_the_reservation_retries_like_a_smaller_one() { + let ctx = sized_context(egui::vec2(10.0, 8.0)); + let mut app = test_app(); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + app.shots.requests[0].ready_at = 0.0; + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert_eq!(callbacks.len(), 1); + + // The surface grew by two pixels while the image was outstanding. The slot's reservation + // refuses the larger image before `validate_image`; drain must retry at the new geometry + // instead of failing because the resize went in this direction. + let mut grown = raw_of(egui::vec2(20.0, 16.0)); + app.raw_input_hook(&ctx, &mut grown); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([12, 9], egui::Color32::RED))); + let _ = ctx.run_logic(&grown, |ctx| app.logic(ctx)); + let request = &app.shots.requests[0]; + assert_eq!(request.retries, 1, "the slot's per-image refusal spends one retry, not a failure"); + assert!(!request.issued); + assert_eq!(request.geometry.viewport, Some(egui::vec2(20.0, 16.0))); + assert!(rx.try_recv().is_err(), "a retried capture does not reply yet"); + + let callbacks = issue_with(&ctx, &mut app, raw_of(egui::vec2(20.0, 16.0))); + assert_eq!(callbacks.len(), 1); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([20, 16], egui::Color32::BLUE))); + let _ = ui_pass(&ctx, raw_of(egui::vec2(20.0, 16.0)), |ui| drain_screenshots(&mut app, ui.ctx())); + let reply = rx.try_recv().expect("the retried capture replies"); + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(app.shots.reserved(), 0); + } + + #[test] + fn a_hidden_resize_rejects_an_image_issued_before_it_and_reissues() { + let ctx = sized_context(egui::vec2(64.0, 48.0)); + let mut app = test_app(); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + app.shots.requests[0].ready_at = 0.0; + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert_eq!(callbacks.len(), 1); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([64, 48], egui::Color32::RED))); + + // A hidden tick carries the resize in `RawInput::viewports`; the last UI pass still says + // 64x48, so only the fresh viewport info lets the drain see the change. + let hidden = || { + let mut raw = raw_of(egui::vec2(64.0, 48.0)); + if let Some(viewport) = raw.viewports.get_mut(&egui::ViewportId::ROOT) { + viewport.inner_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(32.0, 400.0))); + viewport.native_pixels_per_point = Some(1.0); + } + raw + }; + let _ = ctx.run_logic(&hidden(), |ctx| app.logic(ctx)); + let request = &app.shots.requests[0]; + assert!(!request.issued, "the image captured before the hidden resize is not accepted"); + assert_eq!(request.retries, 1); + assert_eq!(request.geometry.viewport, Some(egui::vec2(32.0, 400.0))); + assert!(rx.try_recv().is_err(), "a retried capture does not reply yet"); + + // The next real pass issues against the new size and its image is accepted. + let resized = raw_of(egui::vec2(32.0, 24.0)); + let callbacks = issue_with(&ctx, &mut app, resized.clone()); + assert_eq!(callbacks.len(), 1, "the retry is issued once against the current geometry"); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([32, 24], egui::Color32::BLUE))); + let _ = ui_pass(&ctx, resized, |ui| drain_screenshots(&mut app, ui.ctx())); + let reply = rx.try_recv().expect("the retried capture replies"); + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(app.shots.reserved(), 0); + } + + #[test] + fn a_drained_capture_frees_its_slot_for_the_next_admission() { + let ctx = sized_context(egui::vec2(8.0, 8.0)); + let mut app = test_app(); + let mut receivers = Vec::new(); + for _ in 0..SCREENSHOT_MAX_REQUESTS { + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + receivers.push(rx); + } + for request in &mut app.shots.requests { + request.ready_at = 0.0; + } + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + for callback in &callbacks { + callback.clone().complete(std::sync::Arc::new(egui::ColorImage::filled([8, 8], egui::Color32::GREEN))); + } + assert_eq!(app.shots.requests.len(), SCREENSHOT_MAX_REQUESTS); + // Draining before admitting is what `logic` does: completed requests release first. + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + assert_eq!(app.shots.requests.len(), 0); + assert!(receivers.iter_mut().all(|rx| rx.try_recv().is_ok())); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + assert!(rx.try_recv().is_err(), "a new request is admitted once the drained slots are free"); + assert_eq!(app.shots.requests.len(), 1); + } + + #[test] + fn raw_input_geometry_uses_screen_rect_when_inner_rect_is_absent() { + // Wayland always omits `inner_rect`; the fresh `RawInput::screen_rect` is the only size. + let ctx = sized_context(egui::vec2(800.0, 600.0)); + let mut app = test_app(); + let mut raw = raw_of(egui::vec2(1280.0, 720.0)); + if let Some(v) = raw.viewports.get_mut(&egui::ViewportId::ROOT) { + v.inner_rect = None; + v.native_pixels_per_point = Some(2.0); + v.minimized = Some(false); + v.occluded = Some(false); + } + app.raw_input_hook(&ctx, &mut raw); + let geo = app.capture_geometry.expect("the raw frame records its geometry"); + assert_eq!(geo.viewport, Some(egui::vec2(1280.0, 720.0))); + assert_eq!(geo.pixels_per_point, 2.0, "native scale times zoom"); + assert_eq!(geo.expected_size(), Some([2560, 1440])); + + let epoch = app.capture_epoch; + // A hidden resize lands on a logic-only tick: no UI pass ran, but the raw frame is fresh. + let mut hidden = raw_of(egui::vec2(640.0, 480.0)); + if let Some(v) = hidden.viewports.get_mut(&egui::ViewportId::ROOT) { + v.inner_rect = None; + v.native_pixels_per_point = Some(2.0); + v.minimized = Some(false); + v.occluded = Some(true); + } + app.raw_input_hook(&ctx, &mut hidden); + let _ = ctx.run_logic(&hidden, |ctx| app.logic(ctx)); + assert!(app.capture_epoch > epoch, "a hidden resize is a new epoch"); + assert_eq!(app.capture_geometry.expect("geometry").viewport, Some(egui::vec2(640.0, 480.0))); + } + + #[test] + fn minimized_geometry_has_no_size_and_restore_bumps_the_epoch() { + let ctx = egui::Context::default(); + let mut app = test_app(); + let mut minimized = raw_of(egui::vec2(800.0, 600.0)); + if let Some(v) = minimized.viewports.get_mut(&egui::ViewportId::ROOT) { + v.inner_rect = None; + v.minimized = Some(true); + } + app.raw_input_hook(&ctx, &mut minimized); + let geo = app.capture_geometry.expect("recorded"); + assert_eq!(geo.viewport, None, "a minimized window has no capturable size"); + assert_eq!(geo.visible, Some(false)); + + // Admission while minimized reserves the unknown-size maximum and waits. + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + assert_eq!(app.shots.requests[0].bytes, UNKNOWN_CAPTURE_BYTES); + assert!(rx.try_recv().is_err()); + let epoch = app.capture_epoch; + + // Restoring at the same logical size is still a new surface generation. + let mut restored = raw_of(egui::vec2(800.0, 600.0)); + if let Some(v) = restored.viewports.get_mut(&egui::ViewportId::ROOT) { + v.inner_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))); + v.minimized = Some(false); + v.occluded = Some(false); + } + app.raw_input_hook(&ctx, &mut restored); + assert!(app.capture_epoch > epoch, "minimize/restore at the same size changes the epoch"); + assert_eq!(app.capture_geometry.expect("geometry").viewport, Some(egui::vec2(800.0, 600.0))); + app.shots.cancel_all(); + } + + #[test] + fn a_document_switch_before_issuance_cancels_without_writing() { + let ctx = sized_context(egui::vec2(64.0, 48.0)); + let mut app = test_app(); + let path = temp_png("doc-queued"); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); + app.shots.requests[0].ready_at = 0.0; + let admitted = app.shots.requests[0].document; + assert!(app.run("file.new", json!({})).is_ok()); + assert_ne!(app.session.active().map(|d| d.uid), admitted, "the test switched documents"); + + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert!(callbacks.is_empty(), "a capture for another document is never issued"); + let reply = rx.try_recv().expect("the queued request is cancelled once"); + assert!(reply["error"].as_str().is_some_and(|e| e.contains("document changed")), "{reply}"); + assert!(!path.exists(), "no pixels for the old document are written to its path"); + assert_eq!(app.shots.reserved(), 0); + } + + #[test] + fn a_document_switch_after_issuance_rejects_the_completed_image() { + let ctx = sized_context(egui::vec2(64.0, 48.0)); + let mut app = test_app(); + let path = temp_png("doc-issued"); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); + app.shots.requests[0].ready_at = 0.0; + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert_eq!(callbacks.len(), 1); + assert!(app.run("file.new", json!({})).is_ok()); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([64, 48], egui::Color32::RED))); + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + let reply = rx.try_recv().expect("the switched document is reported once"); + assert!(reply["error"].as_str().is_some_and(|e| e.contains("document changed")), "{reply}"); + assert!(!path.exists(), "a completion after a document switch never writes"); + assert_eq!(app.shots.reserved(), 0); + } + + #[test] + fn a_completion_for_another_viewport_is_rejected() { + let ctx = sized_context(egui::vec2(32.0, 24.0)); + let mut app = test_app(); + let path = temp_png("viewport"); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); + app.shots.requests[0].ready_at = 0.0; + let callbacks = issue_with(&ctx, &mut app, egui::RawInput::default()); + assert_eq!(callbacks.len(), 1); + // A slot/request bound to a different viewport must not fill this one. + app.shots.requests[0].viewport_id = egui::ViewportId::from_hash_of("other-window"); + callbacks[0].clone().complete(std::sync::Arc::new(egui::ColorImage::filled([32, 24], egui::Color32::RED))); + let _ = ui_pass(&ctx, egui::RawInput::default(), |ui| drain_screenshots(&mut app, ui.ctx())); + let reply = rx.try_recv().expect("the viewport mismatch is reported once"); + assert!(reply["error"].as_str().is_some_and(|e| e.contains("another viewport")), "{reply}"); + assert!(!path.exists()); + assert_eq!(app.shots.reserved(), 0); + } + + #[test] + fn validate_image_rejects_distortion_but_allows_one_pixel_rounding() { + let image = |w: usize, h: usize| egui::ColorImage::filled([w, h], egui::Color32::RED); + assert!(validate_image(&image(100, 100), [100, 100]).is_ok()); + assert!(validate_image(&image(101, 100), [100, 100]).is_ok(), "one pixel of rounding on one axis is legitimate"); + assert!(validate_image(&image(100, 101), [100, 100]).is_ok()); + assert!(validate_image(&image(101, 99), [100, 100]).is_err(), "independent per-axis tolerance must not accept a distorted image"); + assert!(validate_image(&image(99, 101), [100, 100]).is_err()); + assert!(validate_image(&image(102, 100), [100, 100]).is_err(), "more than one pixel per axis is a different surface"); + } + + #[test] + fn encoding_working_set_is_bounded_for_admitted_captures() { + assert!(encoding_working_bytes([8191, 2048], false).expect("checked") <= SCREENSHOT_MAX_ENCODING_BYTES); + assert!(encoding_working_bytes([8192, 8192], false).expect("checked") > SCREENSHOT_MAX_ENCODING_BYTES); + assert!(encoding_working_bytes([4000, 3000], true).expect("checked") <= SCREENSHOT_MAX_ENCODING_BYTES); + assert!(encoding_working_bytes([5000, 4000], true).expect("checked") > SCREENSHOT_MAX_ENCODING_BYTES); + // The base64 reply itself is capped too, before the string exists. + let png = png_worst_bytes(5000 * 4000 * 4, 4000).expect("checked"); + assert!(base64_len(png).expect("checked") > SCREENSHOT_MAX_BASE64_BYTES); + // Retained requests plus one encoding working set are held to a hard peak. + assert!(check_peak_budget(0, [64, 48], false).is_ok()); + assert!(check_peak_budget(SCREENSHOT_MAX_AGGREGATE_BYTES, [9000, 4096], false).is_err()); + } + + #[test] + fn base64_capture_over_the_encoding_budget_is_rejected_before_admission() { + let ctx = sized_context(egui::vec2(5000.0, 4000.0)); + let mut app = test_app(); + let (tx, rx) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, None, tx); + let reply = rx.try_recv().expect("an oversized base64 capture is refused at admission"); + assert_eq!(reply["ok"], false, "{reply}"); + assert!( + reply["error"].as_str().is_some_and(|e| e.contains("encoding") || e.contains("base64")), + "the error names the encoding budget: {reply}" + ); + assert!(app.shots.requests.is_empty(), "a refused request reserves nothing"); + + // The same capture as a file is admitted within the per-image and encoding budgets. + let path = temp_png("encoding-path"); + let (tx2, rx2) = std::sync::mpsc::channel(); + admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx2); + assert!(rx2.try_recv().is_err(), "the path form is admitted"); + assert_eq!(app.shots.requests.len(), 1); + app.shots.cancel_all(); + } + + #[test] + fn synthetic_text_key_and_move_producers_honour_the_queue_budget() { + let ctx = egui::Context::default(); + let mut app = test_app(); + // One oversized text event is refused before it is queued. + let huge = "x".repeat(MAX_SYNTHETIC_TEXT_BYTES + 1); + let Outcome::Done(reply) = handle(&mut app, &ctx, "ui.text", &json!({"text": huge})) else { panic!("ui.text returns immediately") }; + assert_eq!(reply["ok"], false, "{reply}"); + assert!(app.synthetic.is_empty()); + + // The byte budget stops repeated maximum-size texts; the queue is left unchanged. + let max = "x".repeat(MAX_SYNTHETIC_TEXT_BYTES); + let mut refused_at = None; + for i in 0..64 { + let Outcome::Done(reply) = handle(&mut app, &ctx, "ui.text", &json!({"text": max.clone()})) else { + panic!("ui.text returns immediately") + }; + if reply["ok"] == false { + refused_at = Some((i, app.synthetic.len())); + break; + } + } + let (accepted, queued) = refused_at.expect("the synthetic byte budget is enforced"); + assert!(accepted > 0 && accepted < 64, "{accepted} texts fit"); + let Outcome::Done(reply) = handle(&mut app, &ctx, "ui.text", &json!({"text": max})) else { panic!("ui.text returns immediately") }; + assert_eq!(reply["ok"], false); + assert_eq!(app.synthetic.len(), queued, "a refused text leaves the queue alone"); + + // Event count: keys and moves use the same overall budget as pointer gestures. + app.synthetic.clear(); + let pos = egui::pos2(1.0, 1.0); + app.synthetic.push_back(vec![egui::Event::PointerMoved(pos); MAX_QUEUED_SYNTHETIC_EVENTS - 1]); + let Outcome::Done(reply) = handle(&mut app, &ctx, "ui.key", &json!({"key":"enter"})) else { panic!("ui.key returns immediately") }; + assert_eq!(reply["ok"], false, "two key events do not fit in the last slot: {reply}"); + assert_eq!(app.synthetic.iter().map(Vec::len).sum::(), MAX_QUEUED_SYNTHETIC_EVENTS - 1); + + app.synthetic.clear(); + app.synthetic.push_back(vec![egui::Event::PointerMoved(pos); MAX_QUEUED_SYNTHETIC_EVENTS]); + let Outcome::Done(reply) = handle(&mut app, &ctx, "ui.move", &json!({"x":2.0,"y":2.0})) else { panic!("ui.move returns immediately") }; + assert_eq!(reply["ok"], false, "{reply}"); + assert_eq!(app.synthetic.iter().map(Vec::len).sum::(), MAX_QUEUED_SYNTHETIC_EVENTS); + } } diff --git a/crates/ui-egui/src/craft_fonts_policy.rs b/crates/ui-egui/src/craft_fonts_policy.rs new file mode 100644 index 000000000..131aec2b4 --- /dev/null +++ b/crates/ui-egui/src/craft_fonts_policy.rs @@ -0,0 +1,244 @@ +//! craft-fonts build policy shared by `build.rs` and the UI crate. +//! +//! GridCraft stays a clean-room application: the shared repository may keep Adobe, Source Han and +//! Noto CJK faces for other consumers, but GridCraft never embeds them. The permitted faces are +//! matched by exact family and style, and their manifest script tags are asserted instead of +//! trusting the free-form parser in `docs/integration.md`. +//! +//! The file is compiled twice: by `build.rs` for the full parser/comparison/embedding path (via +//! `#[path]`), and by this crate, where `theme` only needs the barred-family filter and the web +//! allowlist. The rest of the policy is exercised by this file's own tests, so it is not dead +//! code to remove. +#![allow(dead_code)] + +/// Families GridCraft must never embed: the repository asset policy excludes Adobe/Source faces +/// and the Noto CJK collection. There is deliberately no broad `Noto Sans` prefix: the permitted +/// `Noto Sans Symbols` and `Noto Sans Symbols 2` faces start with it. +pub const BARRED_PREFIXES: &[&str] = &["Adobe", "Source Han", "Source Sans", "Source Serif", "Noto Sans CJK", "Noto Serif CJK"]; + +/// The faces GridCraft's production font set relies on, with their exact manifest script tags. +/// Every release/acceptance build that sets `CRAFT_FONTS_REQUIRED=1` must contain all of them. +pub const REQUIRED_FACES: &[(&str, &str, &[&str])] = &[ + ("BIZ UDPGothic", "Regular", &["Jpan", "Latn"]), + ("BIZ UDPGothic", "Bold", &["Jpan", "Latn"]), + ("NanumGothic", "Regular", &["Hang", "Kore"]), + ("Noto Sans Symbols", "Regular", &["Zsym"]), + ("Noto Sans Symbols 2", "Regular", &["Zsym"]), +]; + +/// The native-only Simplified Chinese face from craft-fonts' separate +/// `fonts/simplified-chinese-manifest.txt` (the same opt-in DeckCraft uses). It is OFL and not +/// barred by the asset policy, but it never enters a web bundle; older native checkouts may omit +/// the manifest, so only `CRAFT_FONTS_REQUIRED=1` builds demand it. +pub const CHINESE_FACES: &[(&str, &str, &[&str])] = &[("LXGW WenKai Lite", "Regular", &["Hani", "Hans"])]; + +/// The explicit web allowlist. Browser builds embed only these faces; the Noto CJK collection and +/// the document-only faces stay off the wasm bundle. `Noto Sans Symbols` supplies `⌃`, and +/// `Noto Sans Symbols 2` supplies `✕`, `◀` and `✓`; `📋` still comes from egui's bundled emoji. +pub const WEB_ALLOWED: &[(&str, &str)] = &[ + ("BIZ UDPGothic", "Regular"), + ("BIZ UDPGothic", "Bold"), + ("NanumGothic", "Regular"), + ("Noto Sans Symbols", "Regular"), + ("Noto Sans Symbols 2", "Regular"), +]; + +/// One validated `fonts/manifest.txt` row. Only the fields GridCraft consumes are kept. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ManifestRow { + pub family: String, + pub style: String, + pub file: String, + pub scripts: Vec, + pub sha256: String, +} + +/// Whether `family` is barred by GridCraft's asset policy. +pub fn barred(family: &str) -> bool { + BARRED_PREFIXES.iter().any(|prefix| family.starts_with(prefix)) +} + +/// Whether a web build may embed this exact family and style. +pub fn web_allowed(family: &str, style: &str) -> bool { + WEB_ALLOWED.iter().any(|(f, s)| *f == family && *s == style) +} + +/// The exact script tags a required face must declare. +pub fn required_scripts(family: &str, style: &str) -> Option<&'static [&'static str]> { + REQUIRED_FACES.iter().chain(CHINESE_FACES.iter()).find(|(f, s, _)| *f == family && *s == style).map(|(_, _, scripts)| *scripts) +} + +/// Parse the craft-fonts eight-field manifest. Comments and blank lines are skipped; every other +/// line must be complete and contain a valid SHA-256. A malformed row is an error, never a +/// silently ignored font. +pub fn parse_manifest(text: &str) -> Result, String> { + let mut rows = Vec::new(); + for (number, line) in text.lines().enumerate() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let fields: Vec<&str> = line.split(" | ").map(str::trim).collect(); + let [family, style, file, scripts, licence, licence_file, sha256, source] = fields.as_slice() else { + return Err(format!("manifest line {}: expected 8 fields separated by \" | \": {line}", number + 1)); + }; + if [family, style, file, scripts, licence, licence_file, sha256, source].iter().any(|f| f.is_empty()) { + return Err(format!("manifest line {}: an empty field: {line}", number + 1)); + } + if sha256.len() != 64 || !sha256.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(format!("manifest line {}: sha256 must be 64 hex digits: {sha256}", number + 1)); + } + let scripts: Vec = scripts.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect(); + if scripts.is_empty() || scripts.iter().any(|tag| tag.len() != 4 || !tag.chars().all(|c| c.is_ascii_alphabetic())) { + return Err(format!("manifest line {}: script tags must be ISO 15924 four-letter tags: {line}", number + 1)); + } + rows.push(ManifestRow { + family: (*family).to_string(), + style: (*style).to_string(), + file: (*file).to_string(), + scripts, + sha256: (*sha256).to_string(), + }); + } + Ok(rows) +} + +/// Check the script tags of every face GridCraft knows. The craft-fonts parser accepts arbitrary +/// strings, so a face relabelled without the scripts GridCraft relies on is rejected here. +pub fn check_declared_scripts(rows: &[ManifestRow]) -> Result<(), String> { + for row in rows { + if let Some(expected) = required_scripts(&row.family, &row.style) { + if row.scripts.iter().map(String::as_str).ne(expected.iter().copied()) { + return Err(format!( + "{} {} must declare scripts {} but the manifest says {}", + row.family, + row.style, + expected.join(","), + row.scripts.join(",") + )); + } + } else if REQUIRED_FACES.iter().chain(CHINESE_FACES.iter()).any(|(family, _, _)| *family == row.family) { + return Err(format!("{} has an unexpected style {:?}; GridCraft only permits the exact required faces", row.family, row.style)); + } + } + Ok(()) +} + +/// Every required face must be present. Used only for `CRAFT_FONTS_REQUIRED=1` builds. +pub fn check_required(rows: &[ManifestRow]) -> Result<(), String> { + let present = |family: &str, style: &str| rows.iter().any(|row| row.family == family && row.style == style); + let missing: Vec = REQUIRED_FACES.iter().filter(|(f, s, _)| !present(f, s)).map(|(f, s, _)| format!("{f} {s}")).collect(); + if missing.is_empty() { Ok(()) } else { Err(format!("the craft-fonts manifest is missing required faces: {}", missing.join(", "))) } +} + +/// Every native-only Chinese face must be present. Used only for `CRAFT_FONTS_REQUIRED=1` native +/// builds; ordinary builds may use an older craft-fonts checkout without the opt-in manifest. +pub fn check_chinese_required(rows: &[ManifestRow]) -> Result<(), String> { + let present = |family: &str, style: &str| rows.iter().any(|row| row.family == family && row.style == style); + let missing: Vec = CHINESE_FACES.iter().filter(|(f, s, _)| !present(f, s)).map(|(f, s, _)| format!("{f} {s}")).collect(); + if missing.is_empty() { + Ok(()) + } else { + Err(format!("the simplified-Chinese craft-fonts manifest is missing required faces: {}", missing.join(", "))) + } +} + +/// The rows this build should embed, in manifest order. Barred families are always excluded; a +/// web build additionally keeps only [`WEB_ALLOWED`]. +pub fn embeddable(rows: &[ManifestRow], wasm: bool) -> Vec<&ManifestRow> { + rows.iter().filter(|row| !barred(&row.family) && (!wasm || web_allowed(&row.family, &row.style))).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + const GOOD: &str = "NanumGothic | Regular | fonts/nanum-gothic/NanumGothic-Regular.ttf | Hang,Kore | OFL-1.1 | fonts/nanum-gothic/OFL.txt | 76f45ef4a6bcff344c837c95a7dcc26e017e38b5846d5ae0cdcb5b86be2e2d31 | https://example.invalid/nanum"; + + #[test] + fn the_web_allowlist_is_exact_and_never_barred() { + assert_eq!( + WEB_ALLOWED, + &[ + ("BIZ UDPGothic", "Regular"), + ("BIZ UDPGothic", "Bold"), + ("NanumGothic", "Regular"), + ("Noto Sans Symbols", "Regular"), + ("Noto Sans Symbols 2", "Regular"), + ] + ); + for (family, _) in WEB_ALLOWED { + assert!(!barred(family), "{family} must not be barred"); + assert!(REQUIRED_FACES.iter().any(|(f, _, _)| f == family), "{family} must be a required face"); + } + assert!(!WEB_ALLOWED.iter().any(|(family, _)| family.contains("CJK")), "the web allowlist must contain no CJK collection face"); + } + + #[test] + fn the_barred_prefixes_keep_the_permitted_noto_symbol_faces() { + for family in ["Adobe Source Sans", "Source Han Sans", "Source Sans 3", "Source Serif 4", "Noto Sans CJK SC", "Noto Serif CJK JP"] { + assert!(barred(family), "{family} must be barred"); + } + for family in ["Noto Sans Symbols", "Noto Sans Symbols 2", "BIZ UDPGothic", "NanumGothic"] { + assert!(!barred(family), "{family} must be permitted"); + } + } + + #[test] + fn a_complete_manifest_row_parses() { + let rows = parse_manifest(GOOD).unwrap_or_default(); + assert_eq!(rows.len(), 1); + assert_eq!(rows.first().map(|r| (r.family.as_str(), r.style.as_str())), Some(("NanumGothic", "Regular"))); + assert_eq!(rows.first().map(|r| r.scripts.clone()), Some(vec!["Hang".to_string(), "Kore".to_string()])); + } + + #[test] + fn malformed_rows_are_errors() { + for bad in [ + "NanumGothic | Regular | fonts/x.ttf | Hang,Kore", + "NanumGothic | Regular | | Hang,Kore | OFL-1.1 | fonts/OFL.txt | 76f45ef4a6bcff344c837c95a7dcc26e017e38b5846d5ae0cdcb5b86be2e2d31 | https://example.invalid", + "NanumGothic | Regular | fonts/x.ttf | Hang,Kore | OFL-1.1 | fonts/OFL.txt | notahash | https://example.invalid", + "NanumGothic | Regular | fonts/x.ttf | Korean | OFL-1.1 | fonts/OFL.txt | 76f45ef4a6bcff344c837c95a7dcc26e017e38b5846d5ae0cdcb5b86be2e2d31 | https://example.invalid", + ] { + assert!(parse_manifest(bad).is_err(), "must reject {bad:?}"); + } + } + + #[test] + fn required_faces_and_script_tags_are_checked() { + let rows = parse_manifest(GOOD).unwrap_or_default(); + assert!(check_required(&rows).is_err(), "one row cannot satisfy every required face"); + assert!(check_declared_scripts(&rows).is_ok()); + let relabelled = parse_manifest(&GOOD.replace("Hang,Kore", "Kore,Hang")).unwrap_or_default(); + assert!(check_declared_scripts(&relabelled).is_err(), "script order and exact tags are asserted"); + let unexpected_style = parse_manifest(&GOOD.replace("Regular", "Bold")).unwrap_or_default(); + assert!(check_declared_scripts(&unexpected_style).is_err(), "a required family in an unlisted style is rejected"); + } + + #[test] + fn the_native_chinese_face_is_permitted_and_checked() { + let chinese = "LXGW WenKai Lite | Regular | fonts/lxgw-wenkai-lite/LXGWWenKaiLite-Regular.ttf | Hani,Hans | OFL-1.1 | fonts/lxgw-wenkai-lite/OFL.txt | f799a538a3355877e597a0ae04050e897932c9ae7689eb9227c2953c5dd549ea | https://example.invalid/lxgw"; + let rows = parse_manifest(chinese).unwrap_or_default(); + assert!(check_declared_scripts(&rows).is_ok()); + assert!(check_chinese_required(&rows).is_ok()); + assert!(!barred("LXGW WenKai Lite"), "the OFL Chinese face is permitted"); + assert!(!web_allowed("LXGW WenKai Lite", "Regular"), "the Chinese face is native-only"); + let relabelled = parse_manifest(&chinese.replace("Hani,Hans", "Hans,Hani")).unwrap_or_default(); + assert!(check_declared_scripts(&relabelled).is_err(), "the Chinese script tags are asserted"); + let unexpected_style = parse_manifest(&chinese.replace("Regular", "Bold")).unwrap_or_default(); + assert!(check_declared_scripts(&unexpected_style).is_err(), "an unlisted Chinese style is rejected"); + assert!(check_chinese_required(&[]).is_err(), "a required native build needs the Chinese face"); + } + + #[test] + fn web_embedding_keeps_only_the_allowlist() { + let manifest = format!( + "{GOOD}\nNoto Sans CJK SC | Regular | fonts/cjk.otf | Hans,Latn | OFL-1.1 | fonts/OFL.txt | 76f45ef4a6bcff344c837c95a7dcc26e017e38b5846d5ae0cdcb5b86be2e2d31 | https://example.invalid/cjk" + ); + let rows = parse_manifest(&manifest).unwrap_or_default(); + let native: Vec<&str> = embeddable(&rows, false).iter().map(|r| r.family.as_str()).collect(); + let web: Vec<&str> = embeddable(&rows, true).iter().map(|r| r.family.as_str()).collect(); + assert_eq!(native, ["NanumGothic"], "the barred CJK face is never embedded natively"); + assert_eq!(web, ["NanumGothic"], "the web build keeps only allowlisted faces"); + } +} diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index 5ab099c2d..adf13bfea 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -212,7 +212,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(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("Show"); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index bfb203c16..ecd273f98 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1157,7 +1157,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { _ => 380.0, }; let window = egui::Window::new(app.ui.language.tr(&d.title)) - .id(egui::Id::new("dialog").with(&d.name)) + .id(egui::Id::unique("dialog").with(&d.name)) .collapsible(false) .resizable(false) .default_width(width) @@ -1643,7 +1643,7 @@ fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) { match f { Field::Text { key, label } => { ui.vertical(|ui| { - ui.label(lang.tr(label)); + let label = ui.label(lang.tr(label)); let mut s = d .values .get(key) @@ -1651,9 +1651,9 @@ fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) { .unwrap_or_default(); let multiline = key == "text"; let r = if multiline { - ui.add(egui::TextEdit::multiline(&mut s).desired_rows(4).desired_width(320.0)) + ui.add(egui::TextEdit::multiline(&mut s).desired_rows(4).desired_width(320.0)).labelled_by(label.id) } else { - ui.add(egui::TextEdit::singleline(&mut s).desired_width(320.0)) + ui.add(egui::TextEdit::singleline(&mut s).desired_width(320.0)).labelled_by(label.id) }; take_first_focus(d, &r); if r.changed() { @@ -1664,9 +1664,10 @@ fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) { Field::Password { key, label } => { // Masked as typed (Excel shows dots); "Show password" reveals it on purpose. ui.vertical(|ui| { - ui.label(lang.tr(label)); + let label = ui.label(lang.tr(label)); let mut s = d.values.get(key).and_then(Json::as_str).unwrap_or_default().to_string(); - if ui.add(egui::TextEdit::singleline(&mut s).password(!d.show_password).desired_width(320.0)).changed() { + if ui.add(egui::TextEdit::singleline(&mut s).password(!d.show_password).desired_width(320.0)).labelled_by(label.id).changed() + { d.values.insert(key.into(), json!(s)); } ui.checkbox(&mut d.show_password, lang.tr("Show password")); @@ -1674,11 +1675,11 @@ fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) { } Field::Number { key, label } => { ui.horizontal(|ui| { - ui.label(lang.tr(label)); + let label = ui.label(lang.tr(label)); let mut n = d.values.get(key).and_then(Json::as_f64).unwrap_or(0.0); // Typed values count straight away: otherwise Enter or OK in the same frame // confirms the old value, because a number field only commits when it loses focus. - let r = ui.add(egui::DragValue::new(&mut n).speed(0.5).update_while_editing(true)); + let r = ui.add(egui::DragValue::new(&mut n).speed(0.5).update_while_editing(true)).labelled_by(label.id); take_first_focus(d, &r); if r.changed() { d.values.insert(key.into(), json!(n)); @@ -1687,17 +1688,22 @@ fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) { } Field::Choice { key, label, options } => { ui.vertical(|ui| { - ui.label(egui::RichText::new(lang.tr(label)).strong()); + let field_label = ui.label(egui::RichText::new(lang.tr(label)).strong()); let cur = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string(); if d.name == "dataValidation" { let selected = options.iter().find(|(val, _)| *val == cur).map(|(_, text)| lang.tr(text)).unwrap_or(""); - egui::ComboBox::from_id_salt(("dataValidation", key)).selected_text(selected).width(300.0).show_ui(ui, |ui| { - for (val, text) in options { - if ui.selectable_label(cur == val, lang.tr(text)).clicked() { - d.set_value(key, json!(val)); + egui::ComboBox::from_id_salt(("dataValidation", key)) + .selected_text(selected) + .width(300.0) + .show_ui(ui, |ui| { + for (val, text) in options { + if ui.selectable_label(cur == val, lang.tr(text)).clicked() { + d.set_value(key, json!(val)); + } } - } - }); + }) + .response + .labelled_by(field_label.id); } else { for (val, text) in options { if ui.radio(cur == val, lang.tr(text)).clicked() { @@ -1828,8 +1834,8 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: let preview = app.session.active().map(|doc| gridcraft_engine::display::format(&sample, &code, &doc.wb).text).unwrap_or_default(); cols[1].label(egui::RichText::new(preview).font(theme::ui_font(15.0))); cols[1].add_space(8.0); - cols[1].label(lang.tr("Type (custom format code):")); - cols[1].text_edit_singleline(&mut code); + let label = cols[1].label(lang.tr("Type (custom format code):")); + cols[1].text_edit_singleline(&mut code).labelled_by(label.id); for c in [ "0", "0.00", @@ -1965,8 +1971,8 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: ] { let label = lang.tr(label); let response = ui.selectable_label(slot.style == s, label); - response - .widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::SelectableLabel, ui.is_enabled(), format!("{n} {label}"))); + let selected = slot.style == s; + response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), selected, format!("{n} {label}"))); if response.clicked() { slot.style = s; } @@ -2094,8 +2100,8 @@ fn find(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) { let mut what = d.values.get("what").and_then(Json::as_str).unwrap_or("").to_string(); let mut with = d.values.get("with").and_then(Json::as_str).unwrap_or("").to_string(); ui.horizontal(|ui| { - ui.label("Find what:"); - let r = ui.add(egui::TextEdit::singleline(&mut what).desired_width(260.0)); + let label = ui.label("Find what:"); + let r = ui.add(egui::TextEdit::singleline(&mut what).desired_width(260.0)).labelled_by(label.id); // Like Excel, the cursor starts in "Find what" - asked for once, so clicking another field still works. if !d.values.contains_key("focused") { r.request_focus(); @@ -2104,8 +2110,8 @@ fn find(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) { }); if d.tab == "Replace" { ui.horizontal(|ui| { - ui.label("Replace with:"); - ui.add(egui::TextEdit::singleline(&mut with).desired_width(244.0)); + let label = ui.label("Replace with:"); + ui.add(egui::TextEdit::singleline(&mut with).desired_width(244.0)).labelled_by(label.id); }); } let mut case = d.values.get("matchCase").and_then(Json::as_bool).unwrap_or(false); @@ -2216,6 +2222,7 @@ fn zoom(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) } fn go_to(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { + let lang = app.ui.language; let names: Vec = app.session.active().map(|doc| doc.wb.names.iter().map(|n| n.name.clone()).collect()).unwrap_or_default(); for n in names { if ui.selectable_label(false, &n).clicked() { @@ -2224,8 +2231,8 @@ fn go_to(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bo } let mut r = d.values.get("reference").and_then(Json::as_str).unwrap_or("").to_string(); ui.horizontal(|ui| { - ui.label("Reference:"); - ui.text_edit_singleline(&mut r).request_focus(); + let label = ui.label(lang.tr("Reference:")); + ui.text_edit_singleline(&mut r).labelled_by(label.id).request_focus(); }); d.values.insert("reference".into(), json!(r.clone())); if ui.button("Special…").clicked() { @@ -2244,6 +2251,7 @@ fn go_to(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bo } fn sort(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { + let lang = app.ui.language; let mut header = d.values.get("header").and_then(Json::as_bool).unwrap_or(true); ui.checkbox(&mut header, "My list has headers"); d.values.insert("header".into(), json!(header)); @@ -2274,16 +2282,20 @@ fn sort(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut boo let mut remove = None; for (i, lv) in levels.iter_mut().enumerate() { ui.horizontal(|ui| { - ui.label(if i == 0 { "Sort by" } else { "Then by" }); + let label = ui.label(lang.tr(if i == 0 { "Sort by" } else { "Then by" })); let mut col = lv["column"].as_str().unwrap_or("A").to_string(); if columns.is_empty() { - ui.add(egui::TextEdit::singleline(&mut col).desired_width(50.0)); + ui.add(egui::TextEdit::singleline(&mut col).desired_width(50.0)).labelled_by(label.id); } else { - egui::ComboBox::from_id_salt(("sort_col", i)).selected_text(col.clone()).show_ui(ui, |ui| { - for (letter, label) in &columns { - ui.selectable_value(&mut col, letter.clone(), label.as_str()); - } - }); + egui::ComboBox::from_id_salt(("sort_col", i)) + .selected_text(col.clone()) + .show_ui(ui, |ui| { + for (letter, label) in &columns { + ui.selectable_value(&mut col, letter.clone(), label.as_str()); + } + }) + .response + .labelled_by(label.id); } lv["column"] = json!(col.to_ascii_uppercase()); let mut by = lv["by"].as_str().unwrap_or("values").to_string(); @@ -2297,13 +2309,16 @@ fn sort(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut boo ui.selectable_value(&mut by, "values".into(), "Cell Values"); ui.selectable_value(&mut by, "cellColor".into(), "Cell Color"); ui.selectable_value(&mut by, "fontColor".into(), "Font Color"); - }); + }) + .response + .accessible_name(lang.tr("Sort On")); lv["by"] = json!(by); let mut desc = lv["order"].as_str() == Some("desc"); ui.radio_value(&mut desc, false, "A to Z"); ui.radio_value(&mut desc, true, "Z to A"); lv["order"] = json!(if desc { "desc" } else { "asc" }); - if ui.small_button("✕").clicked() { + if crate::widgets::icon_button(ui, Icon::Close, crate::theme::Tokens::get(ui.ctx()).text_dim, "Remove level", vec2(18.0, 18.0)).clicked() + { remove = Some(i); } }); @@ -2515,9 +2530,9 @@ fn recommended_charts(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, con fn select_data(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { let text = |d: &Dialog, k: &str| d.values.get(k).and_then(Json::as_str).unwrap_or("").to_string(); let edit = |ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str| { - ui.label(label); + let label = ui.label(label); let mut v = text(d, key); - if ui.add(egui::TextEdit::singleline(&mut v).desired_width(420.0)).changed() { + if ui.add(egui::TextEdit::singleline(&mut v).desired_width(420.0)).labelled_by(label.id).changed() { d.values.insert(key.into(), json!(v)); } }; @@ -2860,16 +2875,17 @@ fn analysis_toolpak(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confi /// Data › Solver: the Solver Parameters form. Solve runs `data.solver` through the dialog's /// command; the result opens Solver Results. fn solver(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) { + let lang = crate::i18n::current(ui.ctx()); let text = |d: &Dialog, k: &str| d.values.get(k).map(|v| v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())).unwrap_or_default(); - let edit = |ui: &mut egui::Ui, d: &mut Dialog, k: &str, width: f32| { + let edit = |ui: &mut egui::Ui, d: &mut Dialog, k: &str, width: f32, label: &str| { let mut t = text(d, k); - if ui.add(egui::TextEdit::singleline(&mut t).desired_width(width)).changed() { + if ui.add(egui::TextEdit::singleline(&mut t).desired_width(width)).accessible_name(lang.tr(label)).changed() { d.values.insert(k.into(), json!(t)); } }; ui.horizontal(|ui| { ui.label("Set Objective:"); - edit(ui, d, "objective", 200.0); + edit(ui, d, "objective", 200.0, "Set Objective:"); }); let goal = text(d, "goal"); ui.horizontal(|ui| { @@ -2879,10 +2895,10 @@ fn solver(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool d.values.insert("goal".into(), json!(g)); } } - edit(ui, d, "value", 80.0); + edit(ui, d, "value", 80.0, "Value Of:"); }); ui.label("By Changing Variable Cells:"); - edit(ui, d, "variables", 420.0); + edit(ui, d, "variables", 420.0, "By Changing Variable Cells:"); ui.label("Subject to the Constraints:"); let list: Vec = d .values @@ -2901,17 +2917,22 @@ fn solver(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool }); }); ui.horizontal(|ui| { - edit(ui, d, "newLhs", 110.0); + edit(ui, d, "newLhs", 110.0, "Constraint Cell Reference"); let op = text(d, "newOp"); - egui::ComboBox::from_id_salt("solverOp").selected_text(op.clone()).width(60.0).show_ui(ui, |ui| { - for o in ["<=", "=", ">=", "int", "bin"] { - if ui.selectable_label(op == o, o).clicked() { - d.values.insert("newOp".into(), json!(o)); + egui::ComboBox::from_id_salt("solverOp") + .selected_text(op.clone()) + .width(60.0) + .show_ui(ui, |ui| { + for o in ["<=", "=", ">=", "int", "bin"] { + if ui.selectable_label(op == o, o).clicked() { + d.values.insert("newOp".into(), json!(o)); + } } - } - }); + }) + .response + .accessible_name(lang.tr("Constraint Operator")); if !matches!(op.as_str(), "int" | "bin") { - edit(ui, d, "newRhs", 110.0); + edit(ui, d, "newRhs", 110.0, "Constraint Value"); } if ui.button("Add").clicked() { let lhs = text(d, "newLhs"); @@ -2944,13 +2965,17 @@ fn solver(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool "evolutionary" => "Evolutionary", _ => "GRG Nonlinear", }; - egui::ComboBox::from_id_salt("solverMethod").selected_text(label(&method)).show_ui(ui, |ui| { - for m in ["grg", "simplex", "evolutionary"] { - if ui.selectable_label(method == m, label(m)).clicked() { - d.values.insert("method".into(), json!(m)); + egui::ComboBox::from_id_salt("solverMethod") + .selected_text(label(&method)) + .show_ui(ui, |ui| { + for m in ["grg", "simplex", "evolutionary"] { + if ui.selectable_label(method == m, label(m)).clicked() { + d.values.insert("method".into(), json!(m)); + } } - } - }); + }) + .response + .accessible_name(lang.tr("Select a Solving Method:")); }); ui.label( egui::RichText::new(match method.as_str() { diff --git a/crates/ui-egui/src/floating_pictures.rs b/crates/ui-egui/src/floating_pictures.rs index 68de6e203..7d2c53e3f 100644 --- a/crates/ui-egui/src/floating_pictures.rs +++ b/crates/ui-egui/src/floating_pictures.rs @@ -138,7 +138,7 @@ fn decode(data: &[u8], side: u32) -> Option<(egui::ColorImage, bool)> { fn cache(ctx: &egui::Context) -> Arc> { // Context-local, like cell pictures: texture handles never leak between offscreen, native // and browser contexts. - ctx.data_mut(|data| data.get_temp_mut_or_default::>>(egui::Id::new("gridcraft.floating_pictures")).clone()) + ctx.data_mut(|data| data.get_temp_mut_or_default::>>(egui::Id::unique("gridcraft.floating_pictures")).clone()) } /// Drops the textures of workbooks that are no longer open. Called every frame the grid paints. diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index b88699f57..215c6070c 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -38,7 +38,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { ui.painter().rect_filled(field, 4.0, t.input_bg); ui.painter().rect_stroke(field, 4.0, Stroke::new(1.0, t.input_border), StrokeKind::Inside); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(field.shrink2(vec2(6.0, 3.0)))); - let id = egui::Id::new("gridcraft.formula_bar"); + let id = egui::Id::unique("gridcraft.formula_bar"); let read_only = active_spill_anchor(app).is_some(); if app.editor.is_some() { editor_widget(app, &mut child, id, theme::ui_font(13.0), expanded, field.width() - 12.0); @@ -102,14 +102,19 @@ fn active_input(app: &SheetApp) -> String { } fn name_box(app: &mut SheetApp, ui: &mut egui::Ui, t: &Tokens) { - let id = egui::Id::new("gridcraft.name_box"); + let id = egui::Id::unique("gridcraft.name_box"); let shown = app.name_box_text(); let mut text = app.name_box.clone().unwrap_or(shown.clone()); let rect = Rect::from_min_size(ui.cursor().min, vec2(118.0, 24.0)); ui.painter().rect_filled(rect, 4.0, t.input_bg); ui.painter().rect_stroke(rect, 4.0, Stroke::new(1.0, t.input_border), StrokeKind::Inside); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_size(rect.min + vec2(6.0, 3.0), vec2(96.0, 18.0)))); - let resp = child.add(TextEdit::singleline(&mut text).id(id).frame(egui::Frame::NONE).font(theme::ui_font(13.0)).desired_width(96.0)); + // The box has no visible label of its own: screen readers and the strict accessibility check + // need a name (egui 0.37 makes unnamed text inputs a test failure). + let accessible_name = app.ui.language.tr("Name Box").to_string(); + let resp = child + .add(TextEdit::singleline(&mut text).id(id).frame(egui::Frame::NONE).font(theme::ui_font(13.0)).desired_width(96.0)) + .accessible_name(accessible_name); if resp.gained_focus() { app.name_box = Some(shown.clone()); } @@ -175,7 +180,7 @@ fn entry_shrank(chars_before: usize, text: &str) -> bool { /// The text editor used in the cell and in the formula bar (they share `app.editor`). pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: FontId, multiline: bool, width: f32) { let Some(mut ed) = app.editor.take() else { return }; - let mine = ed.from_formula_bar == (id == egui::Id::new("gridcraft.formula_bar")); + let mine = ed.from_formula_bar == (id == egui::Id::unique("gridcraft.formula_bar")); // Keys the editor handles itself (before the TextEdit sees them), only where it has focus. let focused = ui.ctx().memory(|m| m.focused()); let has_focus = focused == Some(id) || (mine && (ed.request_focus || focused.is_none())); @@ -294,7 +299,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: // The text widget. let refs = formula_refs(&ed.text); let text_color = Tokens::get(ui.ctx()).text; - let cell_text_color = if id == egui::Id::new("gridcraft.cell_editor") { Color32::BLACK } else { text_color }; + let cell_text_color = if id == egui::Id::unique("gridcraft.cell_editor") { Color32::BLACK } else { text_color }; let font2 = font.clone(); let mut layouter = move |ui: &egui::Ui, buf: &dyn egui::TextBuffer, wrap_width: f32| { let s = buf.as_str(); @@ -316,7 +321,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: }; let chars_before = ed.text.chars().count(); // The in-cell editor is as tall as its lines (a multiline field reserves four rows). - let in_cell = id == egui::Id::new("gridcraft.cell_editor"); + let in_cell = id == egui::Id::unique("gridcraft.cell_editor"); let edit = if multiline || ed.text.contains('\n') { TextEdit::multiline(&mut ed.text).desired_rows(if in_cell { 1 } else { 4 }) } else { @@ -331,6 +336,9 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: .lock_focus(true) .event_filter(egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true }) .show(ui); + // Both editors are text inputs without a visible label next to them: name the one being shown. + let accessible_name = (if in_cell { app.ui.language.tr("Cell Editor") } else { app.ui.language.tr("Formula Bar") }).to_string(); + (*output.response).clone().accessible_name(accessible_name); let resp = output.response.clone(); let synced = mine && (ed.request_focus || ed.sync_caret); if synced { @@ -344,7 +352,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: output.state.store(ui.ctx(), id); } else if resp.clicked() || resp.gained_focus() { // Clicking into the other editor moves editing there. - ed.from_formula_bar = id == egui::Id::new("gridcraft.formula_bar"); + ed.from_formula_bar = id == egui::Id::unique("gridcraft.formula_bar"); ed.enter_mode = false; app.session.mode = gridcraft_engine::Mode::Edit; } @@ -394,8 +402,11 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: ui.painter().rect_filled(r, 0.0, Color32::from_rgb(0x2E, 0x6F, 0xD8)); ui.painter().galley(pos, g, Color32::WHITE); } - // Autocomplete list and argument hint under the editor. - if mine && ui.is_rect_visible(resp.rect) && (!ed.autocomplete.is_empty() || ed.current_function().is_some()) { + // Autocomplete list and argument hint under the editor. Decide visibility from the area the + // editor was allotted, not from its response: egui 0.37 gives every text field a + // `spacing.interact_size.y` floor, so a one-line cell editor's response can reach below a short + // cell and touch the visible quadrant even while the cell itself is hidden under a frozen row. + if mine && ui.max_rect().intersect(ui.clip_rect()).is_positive() && (!ed.autocomplete.is_empty() || ed.current_function().is_some()) { let below = resp.rect.left_bottom() + vec2(0.0, 4.0); let language = app.ui.language; // Areas have their own clip rect: keep cell helpers inside the worksheet. @@ -639,6 +650,9 @@ mod spill_tests { app, ); h.run_steps(3); + // `run_steps` does not enforce the 0.37 strict policy; run it explicitly so the + // formula bar's accessible names are actually tested. + h.check_accessibility(); let disabled = Tokens::get(&h.ctx).text_disabled; assert!(h.output().shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Text(t) if t.galley.job.text == "=SEQUENCE(3)" && t.galley.job.sections.iter().all(|section| section.format.color == disabled)))); @@ -649,6 +663,7 @@ mod spill_tests { h.run_steps(3); h.get_by_label("=SEQUENCE(3)").click(); h.run_steps(3); + h.check_accessibility(); assert!(h.state().editor.as_ref().is_some_and(|e| e.from_formula_bar)); } } diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index f81cfb28e..1ddce7e6d 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -894,7 +894,7 @@ fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &G let last = sh.used_range().map(|r| r.end.row.saturating_add(10)).unwrap_or(100).clamp(100, MAX_ROWS - 1); let content = geo.scroll_top_of(sh, last); let extent = (content - visible).max(scroll).max(0.0).min(limit); - let id = ui.id().with("vscroll"); + let id = ui.scope_id().with("vscroll"); let resp = ui.interact(track, id, Sense::click_and_drag()); let drag_id = id.with("extent"); if resp.drag_started() { @@ -947,7 +947,7 @@ pub(crate) fn horizontal_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, track: let last = sh.used_range().map(|r| r.end.col.saturating_add(10)).unwrap_or(26).clamp(26, MAX_COLS - 1); let content = geo.scroll_left_of(sh, last); let extent = (content - visible).max(scroll).max(0.0); - let id = ui.id().with("hscroll"); + let id = ui.scope_id().with("hscroll"); let resp = ui.interact(track, id, Sense::click_and_drag()); let drag_id = id.with("extent"); if resp.drag_started() { @@ -2875,7 +2875,7 @@ fn hidden_row_edge(geo: &Geo, sh: &Sheet, y: f32) -> Option { /// has no such shape. Black on a white outline, like the system cursors. fn paint_split_cursor(ctx: &egui::Context, at: Pos2, cols: bool) { ctx.set_cursor_icon(CursorIcon::None); - let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("gridcraft.split_cursor"))); + let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("gridcraft.split_cursor"))); // Drawn for columns (bars vertical); rows swap the axes. let pt = |x: f32, y: f32| if cols { pos2(at.x + x, at.y + y) } else { pos2(at.x + y, at.y + x) }; let arrow = |dir: f32| vec![pt(dir * 10.0, 0.0), pt(dir * 6.0, -4.0), pt(dir * 6.0, 4.0)]; @@ -2964,7 +2964,7 @@ fn select_ranges(app: &mut SheetApp, r: RangeRef, add: bool, active: CellRef) { /// Grid keyboard handling when no text field has focus. fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: &Geo, sh: &Sheet) { // Focus left on an editor that no longer exists goes back to the grid. - let stale = [egui::Id::new("gridcraft.cell_editor"), egui::Id::new("gridcraft.formula_bar"), crate::text_box::editor_id()]; + let stale = [egui::Id::unique("gridcraft.cell_editor"), egui::Id::unique("gridcraft.formula_bar"), crate::text_box::editor_id()]; let editing = app.editor.is_some() || app.text_box_editor.is_some(); if !editing && ctx.memory(|m| m.focused()).is_some_and(|f| stale.contains(&f)) { resp.request_focus(); @@ -3009,6 +3009,14 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: app.copy_to_clipboard(ctx, "edit.cut"); } egui::Event::Paste(text) => app.paste_external(text), + // GridCraft has no image-clipboard pipeline and no cell-picture insert-from-clipboard + // command. Choosing to ignore `PasteImage` is explicit: inserting the bytes as a + // floating picture without a chosen anchor/size would be a new feature, not parity. + // A text representation, when the clipboard has one, still arrives as `Paste` above. + egui::Event::PasteImage(_) => {} + // PRIMARY middle-click paste belongs to egui's TextEdit; the grid has no caret to + // insert text at, so a middle click on a cell is deliberately not a paste. + egui::Event::MiddleClickPaste { .. } => {} egui::Event::Text(_) if shortcut_pressed || shortcut_ran => {} egui::Event::Text(text) if text.chars().all(|c| !c.is_control()) && !text.is_empty() => { app.begin_edit(Some(text), false); @@ -3156,11 +3164,20 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet, pos2(if cell.col >= geo.fc { split.x } else { geo.cells.left() }, if cell.row >= geo.fr { split.y } else { geo.cells.top() }), pos2(if cell.col >= geo.fc { geo.cells.right() } else { split.x }, if cell.row >= geo.fr { geo.cells.bottom() } else { split.y }), ); - let clip = ui.clip_rect().intersect(quadrant); + // A cell scrolled entirely under a frozen row/column keeps its edit (Escape still works), + // but neither the cell nor its helper may paint. Test the cell's own visible quadrant and + // give the shared editor an empty clip when the cell is hidden: a one-line cell editor's + // field can be larger than the cell (egui 0.37 gives text fields a minimum height), so its + // own max_rect is not a reliable "is the cell on screen" test at the frozen boundary. The + // pre-wrap cell rect must also be positive: a hidden row/column has zero height/width, but + // Wrap Text can expand the editor below/right of it, which must not paint over the next line. + let cell_visible = rect.width() > 0.0 && rect.height() > 0.0; + let on_screen = cell_visible && erect.intersect(quadrant).is_positive() && !geo.hidden_under_frozen(sh, cell); + let clip = if on_screen { ui.clip_rect().intersect(quadrant) } else { Rect::NOTHING }; let painter = ui.painter().with_clip_rect(clip); painter.rect_filled(erect, 0.0, fill); painter.rect_stroke(erect.expand(1.0), 0.0, Stroke::new(2.0, Tokens::get(ui.ctx()).sel_border), StrokeKind::Middle); - let id = egui::Id::new("gridcraft.cell_editor"); + let id = egui::Id::unique("gridcraft.cell_editor"); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(erect.shrink2(vec2(2.0, 1.0)))); child.set_clip_rect(clip); // The theme's caret is meant for the UI chrome and vanishes on some fills (and, in the dark @@ -3214,16 +3231,16 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) } let wb = app.session.active().map(|d| d.wb.clone()).unwrap_or(wb); let values = gridcraft_engine::cmd::data::filter_values(&wb, si, col); - let key = egui::Id::new(("filter_sel", col)); - let search_key = egui::Id::new(("filter_search", col)); - let buttons_key = egui::Id::new(("filter_buttons", col)); + let key = egui::Id::unique(("filter_sel", col)); + let search_key = egui::Id::unique(("filter_search", col)); + let buttons_key = egui::Id::unique(("filter_buttons", col)); // Enter is the menu's OK, as in Excel: taken before the search box (which would only drop its // focus) and the value checkboxes (which would toggle) see it. A focused button keeps it as its click. let buttons: Vec = ui.ctx().data_mut(|d| d.get_temp(buttons_key)).unwrap_or_default(); let on_button = ui.ctx().memory(|m| m.focused()).is_some_and(|f| buttons.contains(&f)); let enter = !on_button && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); let mut close = false; - let area = egui::Area::new(egui::Id::new("filter_menu")).fixed_pos(at + vec2(-180.0, 10.0)).order(egui::Order::Foreground); + let area = egui::Area::new(egui::Id::unique("filter_menu")).fixed_pos(at + vec2(-180.0, 10.0)).order(egui::Order::Foreground); let response = area.show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_width(230.0); @@ -3306,7 +3323,7 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) { let Some(at) = app.grid.context_menu else { return }; let mut close = false; - let resp = egui::Area::new(egui::Id::new("cell_context")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + let resp = egui::Area::new(egui::Id::unique("cell_context")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_min_width(200.0); let insert_copied = insert_copied_item(app); @@ -3430,8 +3447,8 @@ fn fill_options_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet return; } let lang = crate::i18n::current(ui.ctx()); - let resp = ui.interact(button, egui::Id::new("gridcraft.fill_options_tag"), Sense::click()).on_hover_text(lang.tr("Auto Fill Options")); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, lang.tr("Auto Fill Options"))); + let resp = ui.interact(button, egui::Id::unique("gridcraft.fill_options_tag"), Sense::click()).on_hover_text(lang.tr("Auto Fill Options")); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, lang.tr("Auto Fill Options"))); let t = Tokens::get(ui.ctx()); let p = ui.painter(); p.rect_filled(button, 3.0, if resp.hovered() { t.hover } else { t.window }); @@ -3475,7 +3492,7 @@ fn fill_options_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet fn fill_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { let Some((src, target, at)) = app.grid.fill_menu else { return }; let mut close = false; - let response = egui::Area::new(egui::Id::new("fill_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + let response = egui::Area::new(egui::Id::unique("fill_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_min_width(190.0); let items = [FILL_MODES[0], FILL_MODES[1], FILL_MODES[2], FILL_MODES[3], ("-", ""), ("Series…", "ui:series")]; @@ -3507,7 +3524,7 @@ fn fill_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { fn object_menu(app: &mut SheetApp, ui: &mut egui::Ui) { let Some((at, kind, id)) = app.grid.object_menu else { return }; let mut close = false; - let resp = egui::Area::new(egui::Id::new("object_context")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + let resp = egui::Area::new(egui::Id::unique("object_context")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_min_width(200.0); let mut items = vec![("Delete", "object.delete")]; @@ -3564,8 +3581,8 @@ fn error_check_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet) return; } let lang = crate::i18n::current(ui.ctx()); - let resp = ui.interact(tag, egui::Id::new("gridcraft.error_check_tag"), Sense::click()).on_hover_text(lang.tr("Number Stored as Text")); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, lang.tr("Error checking options"))); + let resp = ui.interact(tag, egui::Id::unique("gridcraft.error_check_tag"), Sense::click()).on_hover_text(lang.tr("Number Stored as Text")); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, lang.tr("Error checking options"))); let t = Tokens::get(ui.ctx()); let p = ui.painter(); p.rect_filled(tag, 3.0, if resp.hovered() { t.hover } else { t.window }); @@ -3658,15 +3675,18 @@ pub(crate) fn paint_overlays( { let r = geo.cell_rect(sh, sel.active); let (at, pivot) = (geo.mirror_pos(pos2(r.left() + 8.0, r.bottom() + 6.0)), if geo.rtl { Align2::RIGHT_TOP } else { Align2::LEFT_TOP }); - egui::Area::new(egui::Id::new("dv_input")).fixed_pos(at).pivot(pivot).order(egui::Order::Tooltip).interactable(false).show(ui.ctx(), |ui| { - egui::Frame::popup(ui.style()).fill(Color32::from_rgb(0xFF, 0xFF, 0xE1)).show(ui, |ui| { - ui.set_max_width(220.0); - if !dv.input_title.is_empty() { - ui.label(egui::RichText::new(&dv.input_title).strong().color(Color32::BLACK)); - } - ui.label(egui::RichText::new(&dv.input_message).color(Color32::BLACK)); - }); - }); + egui::Area::new(egui::Id::unique("dv_input")).fixed_pos(at).pivot(pivot).order(egui::Order::Tooltip).interactable(false).show( + ui.ctx(), + |ui| { + egui::Frame::popup(ui.style()).fill(Color32::from_rgb(0xFF, 0xFF, 0xE1)).show(ui, |ui| { + ui.set_max_width(220.0); + if !dv.input_title.is_empty() { + ui.label(egui::RichText::new(&dv.input_title).strong().color(Color32::BLACK)); + } + ui.label(egui::RichText::new(&dv.input_message).color(Color32::BLACK)); + }); + }, + ); } // Notes: shown on hover (or always when marked visible). let hover = app.grid.hover_cell; @@ -3683,7 +3703,7 @@ pub(crate) fn paint_overlays( let at = pos2(r.right() + 10.0, r.top()); p.line_segment([r.right_top(), at + vec2(0.0, 6.0)], Stroke::new(1.0, Color32::from_gray(90))); let (at, pivot) = (geo.mirror_pos(at), if geo.rtl { Align2::RIGHT_TOP } else { Align2::LEFT_TOP }); - egui::Area::new(egui::Id::new(("note", c.row, c.col))).fixed_pos(at).pivot(pivot).order(egui::Order::Tooltip).interactable(false).show( + egui::Area::new(egui::Id::unique(("note", c.row, c.col))).fixed_pos(at).pivot(pivot).order(egui::Order::Tooltip).interactable(false).show( ui.ctx(), |ui| { egui::Frame::popup(ui.style()).fill(if cm.threaded { Color32::WHITE } else { Color32::from_rgb(0xFF, 0xFF, 0xE1) }).show(ui, |ui| { @@ -3703,7 +3723,7 @@ pub(crate) fn paint_overlays( && app.editor.is_none() { let tip = h.tooltip.clone().unwrap_or_else(|| format!("{}\nClick once to follow. Click and hold to select this cell.", h.target)); - egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new("link_tip"), egui::PopupAnchor::Pointer).show(|ui| { + egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::unique("link_tip"), egui::PopupAnchor::Pointer).show(|ui| { ui.label(tip); }); } @@ -3772,7 +3792,7 @@ fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) (true, true) => (geo.mirror_pos(r.left_top()), Align2::RIGHT_BOTTOM), }; let mut close = false; - let response = egui::Area::new(egui::Id::new("dv_list")).fixed_pos(at).pivot(pivot).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + let response = egui::Area::new(egui::Id::unique("dv_list")).fixed_pos(at).pivot(pivot).order(egui::Order::Foreground).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).inner_margin(2.0).show(ui, |ui| { ui.set_min_width(r.width().max(100.0)); egui::ScrollArea::vertical().max_height(height).min_scrolled_height(height).show(ui, |ui| { @@ -3796,7 +3816,7 @@ fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { let Some((at, rows)) = app.grid.header_menu else { return }; let mut close = false; - let response = egui::Area::new(egui::Id::new("header_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + let response = egui::Area::new(egui::Id::unique("header_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_min_width(190.0); let insert_copied = insert_copied_item(app); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 345037b52..7ff863263 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -283,7 +283,7 @@ pub fn saved_language(ui_json: &serde_json::Value) -> Option { /// egui context-data key holding the language of the frame being drawn. Widgets that only get a /// `Ui` (not the app) read it to translate their labels; [`set_current`] writes it once per frame. fn lang_key() -> egui::Id { - egui::Id::new("gridcraft.language") + egui::Id::unique("gridcraft.language") } /// The language the current frame is drawing in. `En` when unset. @@ -374,6 +374,21 @@ mod tests { } } + /// The screen-reader names for the formula-bar controls must be translated in every catalog + /// (no silent English fallback) and stay single-line labels. + #[test] + fn accessible_control_names_are_translated_in_every_catalog() { + for key in ["Name Box", "Cell Editor", "Font Size", "Sheet Name", "Text Box"] { + for language in Language::ALL { + let shown = language.tr(key); + assert!(!shown.contains('\n'), "{}: {key:?} is not a single-line label: {shown:?}", language.code()); + if language != Language::En { + assert_ne!(shown, key, "{} falls back to English for {key:?}", language.code()); + } + } + } + } + #[test] fn translations_never_show_the_context_prefix() { // `tr` returns a found translation as it is, so a catalog that keeps `context|` in its diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index fc9181feb..912d6c26b 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -186,6 +186,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Page Break\nPreview" => "Náhled\nkonců stránek", "Page\nLayout" => "Rozložení\nstránky", "Formula Bar" => "Řádek vzorců", + "Name Box" => "Pole názvu", + "Cell Editor" => "Editor buňky", + "Font Size" => "Velikost písma", "Gridlines" => "Mřížka", "Headings" => "Záhlaví", "Zoom" => "Lupa", @@ -413,6 +416,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "More\nFunctions" => "Další\nfunkce", "Sheet Options|View" => "Zobrazit", "Print" => "Tisknout", + "Print What" => "Co tisknout", // Additional runtime labels and tooltips, grouped by source file. // formula_bar.rs (alphabetical keys). diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 61c2292f4..565e3609d 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -190,6 +190,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Page Break\nPreview" => "Seitenumbruch-\nvorschau", "Page\nLayout" => "Seiten-\nlayout", "Formula Bar" => "Formelleiste", + "Name Box" => "Namensfeld", + "Cell Editor" => "Zelleneditor", + "Font Size" => "Schriftgröße", "Gridlines" => "Gitternetzlinien", "Headings" => "Überschriften", "Zoom" => "Zoom", @@ -421,6 +424,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Dark" => "Dunkel", "Sheet Options|View" => "Anzeige", "Print" => "Drucken", + "Print What" => "Was drucken", // grid.rs cell and row/column header context menus. "Cut" => "Ausschneiden", "Copy" => "Kopieren", @@ -449,6 +453,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Next sheet" => "Nächstes Blatt", "All sheets" => "Alle Blätter", "New sheet (⇧F11)" => "Neues Blatt (⇧F11)", + "Text Box" => "Textfeld", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/el.rs b/crates/ui-egui/src/i18n/el.rs index e54416a6f..b97e70ed7 100644 --- a/crates/ui-egui/src/i18n/el.rs +++ b/crates/ui-egui/src/i18n/el.rs @@ -184,6 +184,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Page Break\nPreview" => "Προεπισκόπηση\nαλλαγών σελίδας", "Page\nLayout" => "Διάταξη\nσελίδας", "Formula Bar" => "Γραμμή τύπων", + "Name Box" => "Πλαίσιο ονόματος", + "Cell Editor" => "Επεξεργαστής κελιού", + "Font Size" => "Μέγεθος γραμματοσειράς", "Gridlines" => "Γραμμές πλέγματος", "Headings" => "Επικεφαλίδες", "Zoom" => "Μεγέθυνση", @@ -393,6 +396,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Dark" => "Σκοτεινό", "Sheet Options|View" => "Προβολή", "Print" => "Εκτύπωση", + "Print What" => "Τι θα εκτυπωθεί", "PivotTable Fields" => "Πεδία συγκεντρωτικού πίνακα", "Choose fields to add to report:" => "Επιλέξτε πεδία για προσθήκη στην αναφορά:", "Use a field's menu to move it between areas:" => "Χρησιμοποιήστε το μενού ενός πεδίου για να το μετακινήσετε μεταξύ περιοχών:", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index 36898c098..350b3b6eb 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -189,6 +189,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Page Break\nPreview" => "Vista previa de\nsalto de página", "Page\nLayout" => "Diseño\nde página", "Formula Bar" => "Barra de fórmulas", + "Name Box" => "Cuadro de nombre", + "Cell Editor" => "Editor de celda", + "Font Size" => "Tamaño de fuente", "Gridlines" => "Líneas de cuadrícula", "Headings" => "Encabezados", "Zoom" => "Zoom", @@ -418,6 +421,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Dark" => "Oscuro", "Sheet Options|View" => "Ver", "Print" => "Imprimir", + "Print What" => "Qué imprimir", "PivotTable Fields" => "Campos de tabla dinámica", "Choose fields to add to report:" => "Seleccione los campos para agregar al informe:", "Use a field's menu to move it between areas:" => "Use el menú de un campo para moverlo entre áreas:", @@ -465,6 +469,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Next sheet" => "Hoja siguiente", "All sheets" => "Todas las hojas", "New sheet (⇧F11)" => "Nueva hoja (⇧F11)", + "Text Box" => "Cuadro de texto", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 317647c2f..f8c7ac80c 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -210,6 +210,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Page Break\nPreview" => "Aperçu des sauts\nde page", "Page\nLayout" => "Mise en\npage", "Formula Bar" => "Barre de formule", + "Name Box" => "Zone de nom", + "Cell Editor" => "Éditeur de cellule", + "Font Size" => "Taille de police", "Gridlines" => "Quadrillage", "Headings" => "En-têtes", "Zoom" => "Zoom", @@ -308,6 +311,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "More\nFunctions" => "Autres\nfonctions", "Sheet Options|View" => "Affichage", "Print" => "Impression", + "Print What" => "Quoi imprimer", "Click to add data" => "Cliquez pour ajouter des données", "Add header" => "Ajouter un en-tête", "Add footer" => "Ajouter un pied de page", @@ -435,6 +439,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Next sheet" => "Feuille suivante", "All sheets" => "Toutes les feuilles", "New sheet (⇧F11)" => "Nouvelle feuille (⇧F11)", + "Text Box" => "Zone de texte", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 65f356669..f476be532 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -204,6 +204,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Page Break\nPreview" => "Anteprima interruzioni\ndi pagina", "Page\nLayout" => "Layout\ndi pagina", "Formula Bar" => "Barra della formula", + "Name Box" => "Casella nome", + "Cell Editor" => "Editor di cella", + "Font Size" => "Dimensione carattere", "Gridlines" => "Griglia", "Headings" => "Intestazioni", "Zoom" => "Zoom", @@ -301,6 +304,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Dark" => "Scuro", "Sheet Options|View" => "Visualizza", "Print" => "Stampa", + "Print What" => "Cosa stampare", // Page Layout view and the Header & Footer tab. "Click to add data" => "Fai clic per aggiungere dati", "Add header" => "Aggiungi intestazione", diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 2df4cfc11..e66610564 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -209,6 +209,9 @@ pub(super) const CORE: &[(&str, &str)] = &[ ("Page Break\nPreview", "改ページ プレビュー"), ("Page\nLayout", "ページ レイアウト"), ("Formula Bar", "数式バー"), + ("Name Box", "名前ボックス"), + ("Cell Editor", "セル エディター"), + ("Font Size", "フォント サイズ"), ("Gridlines", "目盛線"), ("Headings", "見出し"), ("Zoom", "ズーム"), @@ -306,6 +309,7 @@ pub(super) const CORE: &[(&str, &str)] = &[ ("More\nFunctions", "その他の関数"), ("Sheet Options|View", "画面"), ("Print", "印刷"), + ("Print What", "印刷する内容"), ("Click to add data", "クリックしてデータを追加"), ("Add header", "ヘッダーの追加"), ("Add footer", "フッターの追加"), diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index d3c440cf3..198d7f197 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -207,6 +207,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Page Break\nPreview" => "페이지 나누기\n미리 보기", "Page\nLayout" => "페이지\n레이아웃", "Formula Bar" => "수식 입력줄", + "Name Box" => "이름 상자", + "Cell Editor" => "셀 편집기", + "Font Size" => "글꼴 크기", "Gridlines" => "눈금선", "Headings" => "머리글", "Zoom" => "확대/축소", @@ -306,6 +309,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Dark" => "어둡게", "Sheet Options|View" => "화면", "Print" => "인쇄", + "Print What" => "인쇄할 내용", // Page Layout view and the Header & Footer tab. "Click to add data" => "클릭하여 데이터 추가", "Add header" => "머리글 추가", @@ -433,6 +437,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Next sheet" => "다음 시트", "All sheets" => "모든 시트", "New sheet (⇧F11)" => "새 시트 (⇧F11)", + "Text Box" => "텍스트 상자", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/nl.rs b/crates/ui-egui/src/i18n/nl.rs index c1f952357..03d392675 100644 --- a/crates/ui-egui/src/i18n/nl.rs +++ b/crates/ui-egui/src/i18n/nl.rs @@ -205,6 +205,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Page Break\nPreview" => "Pagina-einde-\nweergave", "Page\nLayout" => "Pagina-\nindeling", "Formula Bar" => "Formulebalk", + "Name Box" => "Naamvak", + "Cell Editor" => "Celeditor", + "Font Size" => "Tekengrootte", "Gridlines" => "Celrasterlijnen", "Headings" => "Kopteksten", "Zoom" => "Zoomen", @@ -301,6 +304,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "More\nFunctions" => "Meer\nfuncties", "Sheet Options|View" => "Weergeven", "Print" => "Afdrukken", + "Print What" => "Wat afdrukken", "Click to add data" => "Klik om gegevens toe te voegen", "Add header" => "Koptekst toevoegen", "Add footer" => "Voettekst toevoegen", diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index e3185d55a..3bf64c190 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -200,6 +200,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Page Break\nPreview" => "Podgląd\npodziału stron", "Page\nLayout" => "Układ\nstrony", "Formula Bar" => "Pasek formuły", + "Name Box" => "Pole nazwy", + "Cell Editor" => "Edytor komórki", + "Font Size" => "Rozmiar czcionki", "Gridlines" => "Linie siatki", "Headings" => "Nagłówki", "Zoom" => "Powiększenie", @@ -297,6 +300,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Dark" => "Ciemny", "Sheet Options|View" => "Wyświetl", "Print" => "Drukuj", + "Print What" => "Co drukować", "Click to add data" => "Kliknij, aby dodać dane", "Add header" => "Dodaj nagłówek", "Add footer" => "Dodaj stopkę", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 1b56ff504..a58954fcf 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -204,6 +204,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Page Break\nPreview" => "Visualização\nde Quebras de Página", "Page\nLayout" => "Layout\nde Página", "Formula Bar" => "Barra de Fórmulas", + "Name Box" => "Caixa de nome", + "Cell Editor" => "Editor de célula", + "Font Size" => "Tamanho da fonte", "Gridlines" => "Linhas de Grade", "Headings" => "Títulos", "Zoom" => "Zoom", @@ -302,6 +305,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Dark" => "Escuro", "Sheet Options|View" => "Exibir", "Print" => "Imprimir", + "Print What" => "O que imprimir", // Page Layout view and the Header & Footer tab. "Click to add data" => "Clique para adicionar dados", "Add header" => "Adicionar cabeçalho", diff --git a/crates/ui-egui/src/i18n/ro.rs b/crates/ui-egui/src/i18n/ro.rs index 9e0d92516..2ddaebde9 100644 --- a/crates/ui-egui/src/i18n/ro.rs +++ b/crates/ui-egui/src/i18n/ro.rs @@ -207,6 +207,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Page Break\nPreview" => "Examinare\nsfârșituri de pagină", "Page\nLayout" => "Aspect\npagină", "Formula Bar" => "Bară de formule", + "Name Box" => "Casetă de nume", + "Cell Editor" => "Editor de celule", + "Font Size" => "Dimensiune font", "Gridlines" => "Linii de grilă", "Headings" => "Titluri", "Zoom" => "Zoom", @@ -304,6 +307,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Dark" => "Întunecată", "Sheet Options|View" => "Vizualizare", "Print" => "Imprimare", + "Print What" => "Ce imprimați", "Click to add data" => "Faceți clic pentru a adăuga date", "Add header" => "Adăugare antet", "Add footer" => "Adăugare subsol", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index ce675701e..03d938d58 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -207,6 +207,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Page Break\nPreview" => "Предварительный просмотр\nразрывов страниц", "Page\nLayout" => "Разметка\nстраницы", "Formula Bar" => "Строка формул", + "Name Box" => "Поле имени", + "Cell Editor" => "Редактор ячейки", + "Font Size" => "Размер шрифта", "Gridlines" => "Сетка", "Headings" => "Заголовки", "Zoom" => "Масштаб", @@ -306,6 +309,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Dark" => "Тёмная", "Sheet Options|View" => "На экране", "Print" => "Печать", + "Print What" => "Что печатать", // Page Layout view and the Header & Footer tab. "Click to add data" => "Щелкните, чтобы добавить данные", "Add header" => "Добавить верхний колонтитул", @@ -435,6 +439,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Next sheet" => "Следующий лист", "All sheets" => "Все листы", "New sheet (⇧F11)" => "Новый лист (⇧F11)", + "Text Box" => "Надпись", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/sr.rs b/crates/ui-egui/src/i18n/sr.rs index c71ce53a8..84ffd579b 100644 --- a/crates/ui-egui/src/i18n/sr.rs +++ b/crates/ui-egui/src/i18n/sr.rs @@ -202,6 +202,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Page Break\nPreview" => "Pregled preloma\nstranica", "Page\nLayout" => "Izgled\nstranice", "Formula Bar" => "Traka formule", + "Name Box" => "Polje imena", + "Cell Editor" => "Uređivač ćelije", + "Font Size" => "Veličina fonta", "Gridlines" => "Linije mreže", "Headings" => "Zaglavlja", "Zoom" => "Uvećanje", @@ -300,6 +303,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "More\nFunctions" => "Još\nfunkcija", "Sheet Options|View" => "Prikaz", "Print" => "Štampaj", + "Print What" => "Šta štampati", "Click to add data" => "Kliknite da dodate podatke", "Add header" => "Dodaj zaglavlje", "Add footer" => "Dodaj podnožje", @@ -433,6 +437,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Next sheet" => "Следећи лист", "All sheets" => "Сви листови", "New sheet (⇧F11)" => "Нови лист (⇧F11)", + "Text Box" => "Текстуално поље", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/tr.rs b/crates/ui-egui/src/i18n/tr.rs index ce2bc8e1e..d362d517c 100644 --- a/crates/ui-egui/src/i18n/tr.rs +++ b/crates/ui-egui/src/i18n/tr.rs @@ -187,6 +187,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Page Break\nPreview" => "Sayfa Sonu\nÖnizleme", "Page\nLayout" => "Sayfa\nDüzeni", "Formula Bar" => "Formül Çubuğu", + "Name Box" => "Ad kutusu", + "Cell Editor" => "Hücre düzenleyici", + "Font Size" => "Yazı tipi boyutu", "Gridlines" => "Kılavuz Çizgileri", "Headings" => "Başlıklar", "Zoom" => "Yakınlaştır", @@ -407,6 +410,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Dark" => "Koyu", "Sheet Options|View" => "Görünüm", "Print" => "Yazdır", + "Print What" => "Yazdırılacaklar", + "Text Box" => "Metin Kutusu", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 97659cc12..f62bceb3a 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -202,6 +202,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Page Break\nPreview" => "Попередній перегляд\nрозривів сторінок", "Page\nLayout" => "Розмітка\nсторінки", "Formula Bar" => "Рядок формул", + "Name Box" => "Поле імені", + "Cell Editor" => "Редактор клітинки", + "Font Size" => "Розмір шрифту", "Gridlines" => "Сітка", "Headings" => "Заголовки", "Zoom" => "Масштаб", @@ -299,6 +302,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Dark" => "Темна", "Sheet Options|View" => "На екрані", "Print" => "Друк", + "Print What" => "Що друкувати", // Page Layout view and the Header & Footer tab. "Click to add data" => "Клацніть, щоб додати дані", "Add header" => "Додати верхній колонтитул", @@ -418,7 +422,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Do you want to save the changes you made to {title}?" => "Do you want to save the changes you made to {title}?", "Save a copy as .{format}" => "Save a copy as .{format}", "Select a chart first." => "Select a chart first.", - "Text Box" => "Text Box", + "Text Box" => "Напис", "Select Data" => "Select Data", "Row Height" => "Row Height", "Column Width" => "Column Width", diff --git a/crates/ui-egui/src/i18n/vi.rs b/crates/ui-egui/src/i18n/vi.rs index 94c32b3a5..794061b0c 100644 --- a/crates/ui-egui/src/i18n/vi.rs +++ b/crates/ui-egui/src/i18n/vi.rs @@ -208,6 +208,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Page Break\nPreview" => "Xem trước\nngắt trang", "Page\nLayout" => "Bố trí\ntrang", "Formula Bar" => "Thanh công thức", + "Name Box" => "Hộp tên", + "Cell Editor" => "Trình soạn thảo ô", + "Font Size" => "Cỡ chữ", "Gridlines" => "Đường lưới", "Headings" => "Tiêu đề hàng/cột", "Zoom" => "Thu phóng", @@ -304,6 +307,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "More\nFunctions" => "Hàm\nkhác", "Sheet Options|View" => "Xem", "Print" => "In", + "Print What" => "Nội dung in", // Page Layout view and the Header & Footer tab. "Click to add data" => "Bấm để thêm dữ liệu", "Add header" => "Thêm đầu trang", @@ -409,6 +413,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Workbook calculation" => "Tính toán sổ làm việc", "Enable iterative calculation" => "Bật tính toán lặp", "Calculation settings are saved with the workbook." => "Thiết lập tính toán được lưu cùng sổ làm việc.", + "Text Box" => "Hộp văn bản", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index c9d3ecb7d..8ad508300 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -207,6 +207,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Page Break\nPreview" => "分页符\n预览", "Page\nLayout" => "页面\n布局", "Formula Bar" => "编辑栏", + "Name Box" => "名称框", + "Cell Editor" => "单元格编辑器", + "Font Size" => "字号", "Gridlines" => "网格线", "Headings" => "标题", "Zoom" => "缩放", @@ -304,6 +307,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Dark" => "深色", "Sheet Options|View" => "屏幕", "Print" => "打印", + "Print What" => "打印内容", // Page Layout view and the Header & Footer tab. "Click to add data" => "单击可添加数据", "Add header" => "添加页眉", @@ -425,6 +429,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Next sheet" => "下一个工作表", "All sheets" => "所有工作表", "New sheet (⇧F11)" => "新建工作表 (⇧F11)", + "Text Box" => "文本框", _ => return None, }) } diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 3e5cdf379..60ba3cd11 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -192,6 +192,17 @@ pub const YELLOW: Color32 = Color32::from_rgb(0xF2, 0xC3, 0x2E); pub const ORANGE: Color32 = Color32::from_rgb(0xE8, 0x7D, 0x2B); pub const PURPLE: Color32 = Color32::from_rgb(0x88, 0x55, 0xC8); +/// The code-drawn icon that stands in for a character the current fonts cannot draw (`✕` and +/// `✓` have no bundled egui face). Interface chrome uses this instead of painting a tofu box; a +/// workbook cell still needs a real glyph because its characters are user data. +pub fn fallback_for_symbol(c: char) -> Option { + match c { + '✕' => Some(Icon::Close), + '✓' => Some(Icon::Check), + _ => None, + } +} + /// Draws `icon` into `rect` (square-ish), in colour `c`. pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) { let side = rect.width().min(rect.height()); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 676359355..7edffe2cd 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -11,6 +11,7 @@ mod border_preview; mod cell_pictures; pub mod chartview; pub mod control; +mod craft_fonts_policy; pub mod credits; pub mod dialogs; pub mod editor; @@ -260,13 +261,23 @@ pub struct SheetApp { pub services: Services, pub printing: printing::State, print_feedback_seen: Option, - /// Synthetic input from agents, one batch per frame (drags need several frames). + /// Synthetic input from agents, one batch per step (drags need several steps). pub synthetic: std::collections::VecDeque>, + /// The front synthetic batch was injected into `RawInput` but no `ui` pass has applied it + /// yet. eframe retains that unconsumed input across hidden logic-only ticks; the flag makes + /// the hook offer it once and asks for a hidden paint until a UI pass applies it. + pub(crate) synthetic_pending: bool, + /// `cumulative_pass_nr` when the front batch was last injected. A completed egui pass that + /// did not apply it (the font gate's early return) consumed the events with `begin_pass` + /// without reaching input handling, so the hook must offer the same step again. + pub(crate) synthetic_injected_at_pass: Option, pub control_rx: Option>, pub grid: grid::GridState, pub perf: Perf, pub fonts_ready: bool, fonts_set: bool, + /// Test harness set: rebuilds use the deterministic pinned font set instead of the host scan. + prepared_fonts: bool, /// Workbook fonts bound into the current font set; a change rebinds (installed fonts only). fonts_bound: Vec, /// The (doc uid, revision) `fonts_bound` was computed for, so the style table is walked only @@ -287,6 +298,16 @@ pub struct SheetApp { /// Transient ribbon keyboard navigation; never saved with UI preferences. pub keytips: keytips::KeyTips, pub(crate) shots: control::Shots, + /// Monotonic epoch of [`control::CaptureGeometry`]: any resize/DPI/zoom/surface change bumps + /// it, including a change back to an earlier value, so a capture taken in another layout is + /// never saved against the current geometry. + pub(crate) capture_epoch: u64, + /// The geometry `capture_epoch` describes; `None` before the first frame. + pub(crate) capture_geometry: Option, + /// The recorded geometry came from a native `RawInput` frame (see `raw_input_hook`). When + /// false (a bare test/embedder that never runs the hook) capture code falls back to egui's + /// current input instead of reusing a stale recorded value. + pub(crate) capture_geometry_from_raw: bool, /// Chart selected on the sheet (id). pub selected_chart: Option, /// The system clipboard's text when GridCraft last copied without publishing to it (a @@ -324,6 +345,8 @@ impl SheetApp { printing: Default::default(), print_feedback_seen: None, synthetic: Default::default(), + synthetic_pending: false, + synthetic_injected_at_pass: None, control_rx: None, grid: grid::GridState { last_border: "bottom".into(), @@ -334,6 +357,7 @@ impl SheetApp { perf: Perf::default(), fonts_ready: false, fonts_set: false, + prepared_fonts: false, fonts_bound: Vec::new(), fonts_seen: (0, 0), fonts_rebind: false, @@ -344,6 +368,9 @@ impl SheetApp { name_box: None, keytips: keytips::KeyTips::default(), shots: control::Shots::default(), + capture_epoch: 0, + capture_geometry: None, + capture_geometry_from_raw: false, selected_chart: None, clipboard_text_at_copy: None, cut_text: None, @@ -368,6 +395,21 @@ impl SheetApp { theme::apply(ctx, dark); } + /// Test-harness setup: mark a deterministic font set (see + /// [`theme::pinned_font_definitions`]) as already installed, so the first `logic` pass neither + /// starts the host font scan nor replaces the harness's pinned set. Production uses + /// [`Self::setup_context`] instead. + #[doc(hidden)] + pub fn use_prepared_fonts(&mut self) { + self.prepared_fonts = true; + self.fonts_ready = true; + self.fonts_set = true; + self.fonts_rebind = false; + self.fonts_han = Some(theme::HanOrder::of(self.ui.language)); + self.fonts_bound = self.used_fonts(); + self.fonts_seen = self.session.active().map(|d| (d.uid, d.revision)).unwrap_or((0, 0)); + } + /// The font names the active workbook uses: every style's font plus the theme's heading/body /// fonts. These are the families [`fonts`] binds an installed file for. pub fn used_fonts(&self) -> Vec { @@ -1094,6 +1136,8 @@ impl SheetApp { } /// Terminal feedback also reaches the normal window when printing starts through control. + /// Polls printing feedback. Called once per frame from [`Self::logic`]; the direct calls in + /// `tests/native_print.rs` deliberately bypass the lifecycle to test this helper alone. pub fn poll_printing(&mut self, ctx: &egui::Context) { self.printing.poll(ctx); if self.printing.message == self.print_feedback_seen { @@ -1164,16 +1208,26 @@ impl SheetApp { // Floating pictures' textures go with their workbook, even when no sheet is painted. let open: Vec = self.session.documents().iter().map(|d| d.uid).collect(); floating_pictures::forget_closed(ctx, &open); - if self.fonts_han.is_some_and(|han| han != theme::HanOrder::of(self.ui.language)) { + // A pending synthetic step owns the next real `ui` pass: `ui` returns at the font gate + // before it handles input, so a font change requested now would consume the step without + // applying it. Delay the change one frame; the hidden-paint request keeps it coming. + let defer_font_change = self.synthetic_pending || !self.synthetic.is_empty(); + if !defer_font_change && self.fonts_han.is_some_and(|han| han != theme::HanOrder::of(self.ui.language)) { self.fonts_ready = false; self.fonts_set = false; } if !self.fonts_ready { - // New fonts apply from the next frame on: paint nothing until then. + // New fonts apply from the next frame on: paint nothing until then. Startup always + // proceeds: without it a queued step would be re-offered forever. if self.fonts_set { self.fonts_ready = true; } else { - SheetApp::setup_context_for_language(ctx, self.ui.dark, self.ui.language); + if self.prepared_fonts { + ctx.set_fonts(theme::pinned_font_definitions()); + theme::apply(ctx, self.ui.dark); + } else { + SheetApp::setup_context_for_language(ctx, self.ui.dark, self.ui.language); + } self.fonts_set = true; // The rebuilt set has no workbook fonts yet: bind them again once it is active. self.fonts_bound.clear(); @@ -1184,7 +1238,7 @@ impl SheetApp { } else if self.fonts_rebind { // A font-set change made last frame is active now. self.fonts_rebind = false; - } else if fonts::scan_done() { + } else if !defer_font_change && !self.prepared_fonts && fonts::scan_done() { // Bind the workbook's installed fonts once the background scan has finished. Adding // fonts re-applies the font set, which egui activates at the next pass, so skip this // frame's paint (same one-frame cost as startup). Only when the workbook changed. @@ -1232,7 +1286,21 @@ impl SheetApp { ctx.set_zoom_factor(zoom); ctx.request_repaint(); } + // The geometry captures are bound to. Native frames recorded it from their fresh + // `RawInput` in `raw_input_hook` (including hidden logic-only ticks); the epoch is + // monotonic, so a resize back to the original values is still a new epoch and old pixels + // are never saved with new geometry. + let capture_geometry = control::current_capture_geometry(self, ctx); + control::note_capture_geometry(self, capture_geometry); + // Drain before admission: completed or timed-out captures release their slots and + // reservation first, so a new request is never refused for budget that is about to free. + // Screenshot results, deadlines and geometry validation belong to `logic`; `ui` only + // issues the capture command (after every widget, so a command cannot be missed). + control::drain_screenshots(self, ctx); control::poll(self, ctx); + // Printing feedback is polled once per frame, here, so hidden windows and logic-only + // frames still see worker completion. `ui` and the desktop app no longer poll it. + self.poll_printing(ctx); self.guard_window_close(ctx); // Files read asynchronously (web file picker, dropped files). let arrived: Vec<(String, Vec)> = self @@ -1257,14 +1325,79 @@ impl SheetApp { } self.after_engine(); } + // AutoSave: save workbooks that have a file shortly after they change. This decision + // lives in `logic` so a hidden or logic-only frame still saves; the monotonic timestamp + // check keeps a forced hidden UI pass from saving the same change twice. + let now = crate::monotonic_ms(); + let autosave_due = self.ui.autosave + && self.editor.is_none() + && self.text_box_editor.is_none() + && now - self.grid.last_autosave > 2000.0 + && self.session.active().is_some_and(|d| d.is_dirty() && d.path.as_deref().is_some_and(|p| p.ends_with(".xlsx"))); + if autosave_due { + self.grid.last_autosave = now; + self.store_view_state(); + let _ = self.session.run("file.save", json!({})); + } else if self.ui.autosave && self.session.active().is_some_and(|d| d.is_dirty() && d.path.as_deref().is_some_and(|p| p.ends_with(".xlsx"))) { + // Keep checking even when no input arrives while the window is hidden. + ctx.request_repaint_after(std::time::Duration::from_secs(2)); + } + // Window title, next to the close/autosave state: `ViewportCommand::Title` is valid + // outside a pass and a control-channel rename while hidden updates it on return. + if let Some(d) = self.session.active() { + let title = format!("{}{}", d.display_title(), if d.is_dirty() { " — Edited" } else { "" }); + if self.grid.last_title.as_deref() != Some(title.as_str()) { + ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone())); + self.grid.last_title = Some(title); + } + } + // A queued/settling capture with a capturable size and an unapplied synthetic step both + // need a real `ui` pass. Once a capture is issued, its `Screenshot` command's own + // `wants_paint` paints the hidden window; after that a full hidden pass every frame only + // wastes CPU. A minimized request has no size, so no hidden pass can reach issuance: the + // restore pass issues it instead. Native eframe honours `RequestPaintWhileHidden`; the + // web runner does not (hidden tabs only run `run_logic`), so a hidden web tab queues the + // work until it is shown again. This is a shared eframe web follow-up, not a + // GridCraft-local workaround, and the control protocol documents the limitation. + if self.shots.needs_hidden_paint() || self.synthetic_pending || !self.synthetic.is_empty() { + // The web runner has no `RequestPaintWhileHidden`; sending it there only logs an + // unhandled command. A visible tab still gets the repaint below; a hidden tab keeps + // the work queued (see the shared eframe follow-up in docs/egui-037-local-followups.md). + #[cfg(not(target_arch = "wasm32"))] + ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden); + ctx.request_repaint(); + } if self.session.active().is_none() { self.session.new_workbook(); } } - pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { - if let Some(mut batch) = self.synthetic.pop_front() { - raw.events.append(&mut batch); + pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + // Capture geometry from this frame's real input before any synthetic event is added. This + // runs on every native frame, including hidden/minimized logic-only ticks, so a resize, + // DPI/zoom change, Wayland frame without `inner_rect`, or minimize/restore is recorded + // even though egui's last UI pass still describes the old layout. + let geometry = control::CaptureGeometry::from_raw_input(raw, ctx); + control::note_capture_geometry(self, geometry); + self.capture_geometry_from_raw = true; + // One unapplied gesture step at a time. eframe retains an unconsumed `RawInput` for the + // next pass and for a hidden logic-only tick, so the batch is offered once and survives + // until a `ui` pass applies it (which pops it). Press and release stay distinct batches. + // + // A completed egui pass may have consumed the injected events without applying them: the + // font gate returns before input handling, but `begin_pass` has already taken the events. + // `cumulative_pass_nr` only advances when a pass ends, so a newer value with the batch + // still pending means exactly that, and the step is offered again for the next real pass. + if self.synthetic_pending { + let consumed_by_an_unapplied_pass = self.synthetic_injected_at_pass.is_some_and(|pass| ctx.cumulative_pass_nr() > pass); + if !consumed_by_an_unapplied_pass { + return; + } + } + if let Some(batch) = self.synthetic.front() { + raw.events.extend(batch.iter().cloned()); + self.synthetic_pending = true; + self.synthetic_injected_at_pass = Some(ctx.cumulative_pass_nr()); } } @@ -1277,7 +1410,6 @@ impl SheetApp { /// Lays out the whole window. pub fn ui(&mut self, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); - self.poll_printing(&ctx); if !self.fonts_ready || self.fonts_rebind { ctx.request_repaint(); return; @@ -1323,7 +1455,6 @@ impl SheetApp { widgets::message_box(self, &ctx); widgets::toast(self, &ctx); control::issue_screenshots(self, &ctx); - control::collect_screenshots(self, &ctx); if !ctx.input(|i| i.focused) { // Nothing else to do. } @@ -1336,24 +1467,14 @@ impl SheetApp { } } self.perf.last = now; - // AutoSave: save workbooks that have a file shortly after they change. - if self.ui.autosave - && self.editor.is_none() - && self.text_box_editor.is_none() - && now - self.grid.last_autosave > 2000.0 - && self.session.active().is_some_and(|d| d.is_dirty() && d.path.as_deref().is_some_and(|p| p.ends_with(".xlsx"))) - { - self.grid.last_autosave = now; - self.store_view_state(); - let _ = self.session.run("file.save", json!({})); - } - // Window title. - if let Some(d) = self.session.active() { - let title = format!("{}{}", d.display_title(), if d.is_dirty() { " — Edited" } else { "" }); - if self.grid.last_title.as_deref() != Some(title.as_str()) { - ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone())); - self.grid.last_title = Some(title); - } + // The step is applied only after a completed UI pass. A discarded or repeated pass of the + // same frame must not offer it again (grid sizing calls `ui` more than once per frame); + // provenance is the injected pass number, so only a pass that missed input handling is + // re-offered by `raw_input_hook`. + if self.synthetic_pending { + self.synthetic.pop_front(); + self.synthetic_pending = false; + self.synthetic_injected_at_pass = None; } } @@ -1442,10 +1563,252 @@ pub fn clean_error(e: &str) -> String { e.to_string() } +/// Wall-clock milliseconds since the Unix epoch, for user-visible timestamps (Backstage's "last +/// changed", toasts). It can jump when the system clock is adjusted, so it must not drive +/// deadlines or pacing; use [`monotonic_ms`] for those. pub fn now_ms() -> f64 { gridcraft_engine::core::date::unix_now_ms().unwrap_or(0.0) } +/// Monotonic milliseconds since the first call, for screenshot deadlines and AutoSave pacing. +/// +/// Deliberately separate from [`now_ms`]: a wall-clock adjustment must not extend (or expire) a +/// screenshot deadline or save a workbook twice. Native uses [`std::time::Instant`]; wasm uses the +/// browser's `performance.now()`, which is monotonic too. +#[cfg(not(target_arch = "wasm32"))] +#[allow(clippy::disallowed_methods)] // the std clock is read only off wasm +pub fn monotonic_ms() -> f64 { + use std::sync::OnceLock; + static START: OnceLock = OnceLock::new(); + START.get_or_init(std::time::Instant::now).elapsed().as_secs_f64() * 1000.0 +} + +#[cfg(target_arch = "wasm32")] +pub fn monotonic_ms() -> f64 { + web_sys::window().and_then(|window| window.performance()).map_or(0.0, |performance| performance.now()) +} + pub fn cell(r: u32, c: u32) -> CellRef { CellRef::new(r, c) } + +#[cfg(test)] +mod lifecycle_tests { + use super::*; + + fn key_event(pressed: bool) -> egui::Event { + egui::Event::Key { key: egui::Key::A, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::NONE } + } + + fn app() -> SheetApp { + let mut session = Session::new(); + session.new_workbook(); + SheetApp::new(session, Services::default()) + } + + /// One headless pass; a test has no renderer for the texture atlas delta (0.37 panics when it + /// is dropped unapplied). + fn ui_pass(ctx: &egui::Context, raw: egui::RawInput, f: impl FnMut(&mut egui::Ui)) -> egui::FullOutput { + let mut out = ctx.run_ui(raw, f); + out.textures_delta.clear(); + out + } + + #[test] + fn a_hidden_logic_tick_keeps_one_unapplied_synthetic_step() { + let ctx = egui::Context::default(); + let mut app = app(); + app.synthetic.push_back(vec![key_event(true)]); + app.synthetic.push_back(vec![key_event(false)]); + + let mut first = egui::RawInput::default(); + app.raw_input_hook(&ctx, &mut first); + assert_eq!(first.events.len(), 1, "only one step is offered"); + assert!(app.synthetic_pending); + assert_eq!(app.synthetic.len(), 2, "a logic-only frame does not consume the step"); + + // eframe carries the unconsumed `RawInput` into the next pass, so the hook offers the step + // only once; a hidden logic-only tick does not duplicate it or offer the release. + let mut second = egui::RawInput::default(); + app.raw_input_hook(&ctx, &mut second); + assert!(second.events.is_empty(), "an unapplied step is not injected a second time"); + assert_eq!(app.synthetic.len(), 2, "press and release stay distinct batches"); + } + + fn raw_with_size(size: egui::Vec2) -> egui::RawInput { + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), ..Default::default() } + } + + #[test] + fn a_font_gated_pass_offers_the_synthetic_step_again() { + let ctx = egui::Context::default(); + ctx.set_fonts(theme::font_definitions()); + let mut app = app(); + app.fonts_ready = true; + app.fonts_set = true; + // Binds `session.text_width`, as eframe's first frame does. + let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); + app.synthetic.push_back(vec![key_event(true)]); + + // The font gate returns before input handling; `begin_pass` has still consumed the events. + app.fonts_rebind = true; + let mut raw = raw_with_size(egui::vec2(900.0, 700.0)); + app.raw_input_hook(&ctx, &mut raw); + assert_eq!(raw.events.len(), 1); + let _ = ui_pass(&ctx, raw, |ui| app.ui(ui)); + assert!(app.synthetic_pending, "the early-returning pass did not apply the step"); + assert_eq!(app.synthetic.len(), 1, "the step is still queued"); + + // A completed pass that did not apply it made the hook offer it again for a real pass. + app.fonts_rebind = false; + let mut raw = raw_with_size(egui::vec2(900.0, 700.0)); + app.raw_input_hook(&ctx, &mut raw); + assert_eq!(raw.events.len(), 1, "the step is not lost across the font gate"); + let _ = ui_pass(&ctx, raw, |ui| app.ui(ui)); + assert!(!app.synthetic_pending); + assert!(app.synthetic.is_empty(), "the real UI pass applied the step"); + } + + #[test] + fn a_pending_step_delays_a_font_change() { + let ctx = egui::Context::default(); + let mut app = app(); + app.fonts_ready = true; + app.fonts_han = Some(theme::HanOrder::Chinese); + app.ui.language = i18n::Language::Ja; + app.synthetic.push_back(vec![key_event(true)]); + app.synthetic_pending = true; + let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); + assert!(app.fonts_ready, "the font rebuild waits for the pass that applies the step"); + app.synthetic.clear(); + app.synthetic_pending = false; + let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); + assert!(!app.fonts_ready, "without pending input the language rebuild proceeds"); + } + + #[test] + fn a_hidden_logic_tick_sees_fresh_viewport_info_and_keeps_the_epoch_monotonic() { + fn tick(ctx: &egui::Context, app: &mut SheetApp, width: f32, native_ppp: f32) { + let mut raw = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 400.0))), ..Default::default() }; + if let Some(viewport) = raw.viewports.get_mut(&egui::ViewportId::ROOT) { + viewport.inner_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 400.0))); + viewport.native_pixels_per_point = Some(native_ppp); + } + let _ = ctx.run_logic(&raw, |ctx| app.logic(ctx)); + } + let ctx = egui::Context::default(); + let mut app = app(); + tick(&ctx, &mut app, 800.0, 2.0); + let first = app.capture_geometry.expect("the first logic tick records the geometry"); + assert_eq!(first.viewport, Some(egui::vec2(800.0, 400.0))); + assert_eq!(first.pixels_per_point, 2.0, "native scale times the interface zoom"); + tick(&ctx, &mut app, 900.0, 2.0); + let resized = app.capture_geometry.expect("geometry"); + assert_eq!(resized.viewport, Some(egui::vec2(900.0, 400.0)), "run_logic's ViewportInfo is the fresh size"); + let epoch = app.capture_epoch; + tick(&ctx, &mut app, 800.0, 2.0); + assert_eq!(app.capture_geometry.map(|g| g.viewport), Some(Some(egui::vec2(800.0, 400.0)))); + assert!(app.capture_epoch > epoch, "resizing back is still a new epoch: old pixels must not be saved"); + } + + #[test] + fn the_capture_epoch_changes_even_when_the_geometry_returns() { + fn tick(ctx: &egui::Context, app: &mut SheetApp, width: f32) { + let raw = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 400.0))), ..Default::default() }; + let _ = ui_pass(ctx, raw, |ui| app.logic(ui.ctx())); + } + let ctx = egui::Context::default(); + let mut app = app(); + tick(&ctx, &mut app, 800.0); + let base = app.capture_epoch; + tick(&ctx, &mut app, 900.0); + let changed = app.capture_epoch; + assert!(changed > base, "a resize increments the capture epoch"); + tick(&ctx, &mut app, 800.0); + assert!(app.capture_epoch > changed, "resizing back is still a new epoch: old pixels must not be saved"); + } + + fn titles(out: &egui::LogicOutput) -> Vec { + out.viewport_commands + .values() + .flatten() + .filter_map(|c| match c { + egui::ViewportCommand::Title(title) => Some(title.clone()), + _ => None, + }) + .collect() + } + + #[test] + fn a_logic_only_tick_autosaves_a_dirty_workbook_once() { + let ctx = egui::Context::default(); + let mut app = app(); + let dir = std::env::temp_dir().join(format!("gridcraft-logic-autosave-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("autosave.xlsx"); + app.run("cell.set", serde_json::json!({"cell": "A1", "input": "42"})).unwrap(); + app.ui.autosave = true; + app.session.active_mut().unwrap().path = Some(path.to_string_lossy().into_owned()); + app.grid.last_autosave = monotonic_ms() - 3000.0; + + let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); + assert!(path.exists(), "a hidden logic-only tick saves a dirty workbook with a file"); + assert!(!app.session.active().unwrap().is_dirty(), "the save clears the dirty state"); + let saved_at = app.grid.last_autosave; + let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); + assert_eq!(app.grid.last_autosave, saved_at, "the same change is not saved a second time"); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_logic_only_tick_updates_the_title_once_per_change() { + let ctx = egui::Context::default(); + let mut app = app(); + let first = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); + let title = app.session.active().unwrap().display_title().to_string(); + let expected = format!("{title}{}", if app.session.active().unwrap().is_dirty() { " — Edited" } else { "" }); + assert_eq!(titles(&first), [expected], "the first logic tick titles the window"); + let second = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); + assert!(titles(&second).is_empty(), "an unchanged title is not sent every frame"); + + app.session.active_mut().unwrap().title = "Renamed".into(); + let third = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); + let expected = format!("Renamed{}", if app.session.active().unwrap().is_dirty() { " — Edited" } else { "" }); + assert_eq!(titles(&third), [expected], "a rename while hidden updates the title"); + } + + #[test] + fn a_logic_only_tick_polls_printing_feedback() { + let ctx = egui::Context::default(); + let mut app = app(); + app.printing.printers.push(crate::printing::Printer { name: "Queue".into(), label: String::new(), default: true }); + app.run("ui.printSettings", serde_json::json!({"printer": "Queue"})).unwrap(); + app.services.print_native = Some(Box::new(|_, _| { + let (sender, receiver) = std::sync::mpsc::channel(); + sender.send(Err("printer is offline".into())).unwrap(); + receiver + })); + app.run("ui.printSubmit", serde_json::json!({})).unwrap(); + let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx)); + assert_eq!(app.printing.message, Some(crate::printing::Feedback::Failed("printer is offline".into()))); + assert!(app.message.as_ref().is_some_and(|(_, text)| text.contains("printer is offline")), "logic surfaces the failure"); + } + + #[test] + fn a_pending_synthetic_step_requests_a_hidden_paint() { + let ctx = egui::Context::default(); + let mut app = app(); + app.synthetic.push_back(vec![key_event(true)]); + app.synthetic_pending = true; + let out = ui_pass(&ctx, egui::RawInput::default(), |ui| app.logic(ui.ctx())); + let painted = out + .viewport_output + .get(&ctx.viewport_id()) + .is_some_and(|output| output.commands.iter().any(|c| matches!(c, egui::ViewportCommand::RequestPaintWhileHidden))); + assert!(painted, "logic must ask for a real UI pass while a synthetic step is unapplied"); + } +} diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index a8a7e35f4..8a7486431 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -79,7 +79,7 @@ fn comments(app: &mut SheetApp, ui: &mut Ui) { for (a, txt) in &c.replies { ui.label(egui::RichText::new(format!("↳ {a}: {txt}")).color(Color32::from_gray(60))); } - let key = egui::Id::new(("reply", &cell)); + let key = egui::Id::unique(("reply", &cell)); let mut draft: String = ui.ctx().data_mut(|m| m.get_temp(key)).unwrap_or_default(); // Buttons first, right to left, so the reply field gets exactly the width they leave: // a field sized by a guess wider than the row pushes the resizable pane wider every frame. @@ -125,7 +125,7 @@ fn watch(app: &mut SheetApp, ui: &mut Ui) { ui.label(&cell); ui.label(w["value"].as_str().unwrap_or("")); ui.label(w["formula"].as_str().unwrap_or("")); - if ui.small_button("✕").clicked() { + if crate::widgets::icon_button(ui, crate::icons::Icon::Close, Tokens::get(ui.ctx()).text_dim, "Delete", vec2(18.0, 18.0)).clicked() { let _ = app.session.run("formulas.watchWindow", json!({"remove": cell})); } ui.end_row(); @@ -153,7 +153,7 @@ fn selection(app: &mut SheetApp, ui: &mut Ui) { if ui.small_button("▼").on_hover_text("Send Backward").clicked() { app.run_or_alert("arrange.sendBackward", json!({"kind": kind, "id": id})); } - if ui.small_button("✕").on_hover_text("Delete").clicked() { + if crate::widgets::icon_button(ui, crate::icons::Icon::Close, Tokens::get(ui.ctx()).text_dim, "Delete", vec2(18.0, 18.0)).clicked() { app.run_or_alert("object.delete", json!({"kind": kind, "id": id})); } }); @@ -171,7 +171,7 @@ fn format_chart(app: &mut SheetApp, ui: &mut Ui) { }; let theme = app.session.active().map(|d| d.wb.theme.clone()).unwrap_or_default(); ui.collapsing("Chart Title", |ui| { - let field = egui::Id::new(("gridcraft.chart_title", id)); + let field = egui::Id::unique(("gridcraft.chart_title", id)); if let Some(title) = crate::widgets::committed_text(ui, field, chart.title.as_deref().unwrap_or(""), None) { app.run_or_alert("chart.set", json!({"chart": id, "title": title})); } @@ -216,14 +216,14 @@ fn format_chart(app: &mut SheetApp, ui: &mut Ui) { } ui.horizontal(|ui| { ui.label("Horizontal axis title:"); - let field = egui::Id::new(("gridcraft.chart_x_title", id)); + let field = egui::Id::unique(("gridcraft.chart_x_title", id)); if let Some(xt) = crate::widgets::committed_text(ui, field, chart.x_title.as_deref().unwrap_or(""), None) { app.run_or_alert("chart.set", json!({"chart": id, "xTitle": if xt.is_empty() { Json::Null } else { json!(xt) }})); } }); ui.horizontal(|ui| { ui.label("Vertical axis title:"); - let field = egui::Id::new(("gridcraft.chart_y_title", id)); + let field = egui::Id::unique(("gridcraft.chart_y_title", id)); if let Some(yt) = crate::widgets::committed_text(ui, field, chart.y_title.as_deref().unwrap_or(""), None) { app.run_or_alert("chart.set", json!({"chart": id, "yTitle": if yt.is_empty() { Json::Null } else { json!(yt) }})); } @@ -270,7 +270,7 @@ fn format_chart(app: &mut SheetApp, ui: &mut Ui) { }); ui.collapsing("Size", |ui| { // Keep the value being typed or dragged between frames; reloading it from the chart every frame threw the edit away. - let draft = egui::Id::new(("gridcraft.chart_size_draft", id)); + let draft = egui::Id::unique(("gridcraft.chart_size_draft", id)); let (mut w, mut h) = ui.data_mut(|d| d.get_temp::<(f32, f32)>(draft)).unwrap_or((chart.anchor.width, chart.anchor.height)); let a = ui.add(egui::DragValue::new(&mut w).prefix("Width ").range(40.0..=4000.0)); let b = ui.add(egui::DragValue::new(&mut h).prefix("Height ").range(40.0..=4000.0)); diff --git a/crates/ui-egui/src/paper_view.rs b/crates/ui-egui/src/paper_view.rs index 621a97903..73c2d6bf9 100644 --- a/crates/ui-egui/src/paper_view.rs +++ b/crates/ui-egui/src/paper_view.rs @@ -467,7 +467,7 @@ fn editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid) { let anchor = [Align2::LEFT_TOP, Align2::CENTER_TOP, Align2::RIGHT_TOP][k]; let x = [r.left() + 3.0, r.center().x, r.right() - 3.0][k]; p.text(pos2(x, r.top() + 2.0), anchor, text, font.clone(), Color32::BLACK); - if ui.interact(*r, ui.id().with(("hf_section", k)), egui::Sense::click()).clicked() { + if ui.interact(*r, ui.scope_id().with(("hf_section", k)), egui::Sense::click()).clicked() { switch_to = Some(k); } } @@ -476,9 +476,9 @@ fn editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid) { app.grid.hf_edit = Some(e); return; }; - let id = egui::Id::new("gridcraft.header_footer_editor"); + let id = egui::Id::unique("gridcraft.header_footer_editor"); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(3.0, 2.0)).intersect(geo.cells))); - let align = [egui::Align::LEFT, egui::Align::Center, egui::Align::RIGHT][e.section.min(2)]; + let align = [egui::Align2::LEFT_TOP, egui::Align2::CENTER_TOP, egui::Align2::RIGHT_TOP][e.section.min(2)]; if let Some(text) = e.texts.get_mut(e.section) { let mut output = TextEdit::multiline(text) .id(id) @@ -486,7 +486,7 @@ fn editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid) { .font(font) .text_color(Color32::BLACK) .background_color(Color32::WHITE) - .horizontal_align(align) + .align(align) .desired_width(r.width() - 6.0) .desired_rows(1) .lock_focus(true) @@ -774,8 +774,8 @@ fn ruler_margins(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid } let (hr, vr) = ruler_rects(outer, geo); // Right-to-left sheets mirror the pages and the horizontal ruler; hit-testing is logical. - let hresp = ui.interact(hr, egui::Id::new("ruler_margin_h"), egui::Sense::drag()); - let vresp = ui.interact(vr, egui::Id::new("ruler_margin_v"), egui::Sense::drag()); + let hresp = ui.interact(hr, egui::Id::unique("ruler_margin_h"), egui::Sense::drag()); + let vresp = ui.interact(vr, egui::Id::unique("ruler_margin_v"), egui::Sense::drag()); let margins_in = |sh: &Sheet| -> [f32; 6] { let m = |i: usize| sh.print.margins.get(i).copied().unwrap_or(0.5); [m(0), m(1), m(2), m(3), m(4), m(5)] @@ -785,7 +785,7 @@ fn ruler_margins(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid let tip = |ui: &egui::Ui, app: &SheetApp, edge: Edge, inches: f32| { let value = if metric { format!("{:.2} cm", inches * 2.54) } else { format!("{inches:.2} in") }; let text = format!("{}: {value}", app.ui.language.tr(edge.label())); - egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new("ruler_margin_tip"), egui::PopupAnchor::Pointer).show(|ui| { + egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::unique("ruler_margin_tip"), egui::PopupAnchor::Pointer).show(|ui| { ui.label(text); }); }; diff --git a/crates/ui-egui/src/pivot_pane.rs b/crates/ui-egui/src/pivot_pane.rs index 8787e92ae..c29c71b9d 100644 --- a/crates/ui-egui/src/pivot_pane.rs +++ b/crates/ui-egui/src/pivot_pane.rs @@ -168,13 +168,17 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { if ui.button(lang.tr("Calculated Field…")).clicked() { app.run_or_alert("pivot.calculatedField", json!({"pivot": name})); } - egui::ComboBox::from_id_salt("pv_layout").selected_text(lang.tr("Report Layout")).show_ui(ui, |ui| { - for (l, v) in [("Compact", "compact"), ("Outline", "outline"), ("Tabular", "tabular")] { - if ui.button(lang.tr(l)).clicked() { - app.run_or_alert("pivot.layout", json!({"pivot": name, "layout": v})); + egui::ComboBox::from_id_salt("pv_layout") + .selected_text(lang.tr("Report Layout")) + .show_ui(ui, |ui| { + for (l, v) in [("Compact", "compact"), ("Outline", "outline"), ("Tabular", "tabular")] { + if ui.button(lang.tr(l)).clicked() { + app.run_or_alert("pivot.layout", json!({"pivot": name, "layout": v})); + } } - } - }); + }) + .response + .accessible_name(lang.tr("Report Layout")); }); let _: Option = None; }); diff --git a/crates/ui-egui/src/printing.rs b/crates/ui-egui/src/printing.rs index 7193678c2..46a2576d3 100644 --- a/crates/ui-egui/src/printing.rs +++ b/crates/ui-egui/src/printing.rs @@ -2,6 +2,13 @@ use craft_ui::printing::Labels; pub use craft_ui::printing::{Discover, Duplex, Feedback, Printer, Settings, State, Submit}; +/// Draws the shared print controls. +/// +/// `craft_ui::printing::State::show` also calls `poll`, so this path polls a second time on top of +/// [`crate::SheetApp::poll_printing`] in `logic`. That poll is idempotent (it only drains worker +/// messages into the same state), and the logic-side poll is what lets a hidden window and a +/// logic-only frame see completion, so it stays. Splitting poll out of the shared `show` API is a +/// craft-ui follow-up, not a GridCraft-local change. pub fn show(state: &mut State, ui: &mut egui::Ui, discover: &Discover, translate: impl Fn(&str) -> String) { let labels = Labels { printer: &translate("Printer"), @@ -15,7 +22,7 @@ pub fn show(state: &mut State, ui: &mut egui::Ui, discover: &Discover, translate short_edge: &translate("Two-sided: short edge"), grayscale: &translate("Grayscale"), }; - state.show(ui, egui::Id::new("gridcraft_print_settings"), discover, &labels); + state.show(ui, egui::Id::unique("gridcraft_print_settings"), discover, &labels); if let Some(message) = &state.message { ui.label(feedback(message, crate::i18n::current(ui.ctx()), translate)); } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 45856565f..b35aa3f72 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -41,7 +41,7 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { egui::Panel::top("title_bar").exact_size(TITLE_BAR_H).frame(egui::Frame::NONE.fill(t.window)).show(ui, |ui| { let rect = ui.max_rect(); // Dragging the empty title area moves the window. - let bg = ui.interact(rect, ui.id().with("drag_title"), Sense::click_and_drag()); + let bg = ui.interact(rect, ui.scope_id().with("drag_title"), Sense::click_and_drag()); if bg.drag_started() { ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); } @@ -143,7 +143,7 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { let lang = app.ui.language; let theme = icon_button(ui, Icon::Appearance, t.text_dim, "Dark Mode", vec2(28.0, 26.0)); let dark = ui.ctx().theme() == egui::Theme::Dark; - theme.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, dark, lang.tr("Dark Mode"))); + theme.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, dark, lang.tr("Dark Mode"))); if theme.clicked() { act(app, "view.darkMode", json!({})); } @@ -376,7 +376,7 @@ fn act(app: &mut SheetApp, id: &str, params: serde_json::Value) { fn keytip(app: &SheetApp, ui: &Ui, sequence: &str, at: egui::Pos2) { let Some(rest) = app.keytips.prefix().and_then(|prefix| sequence.strip_prefix(prefix)).filter(|s| !s.is_empty()) else { return }; let t = Tokens::get(ui.ctx()); - let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("ribbon_keytips"))).with_clip_rect(ui.clip_rect()); + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("ribbon_keytips"))).with_clip_rect(ui.clip_rect()); let text = painter.layout_no_wrap(rest.into(), theme::ui_bold(10.5), t.text); let rect = Rect::from_center_size(at, text.size() + vec2(7.0, 3.0)); painter.rect_filled(rect, 2.0, t.input_bg); @@ -614,7 +614,7 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { let st = active_style(app); // Clipboard let paste = big_button(ui, Icon::Paste, "Paste", "Paste", true); - paste.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), "Paste")); + paste.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), "Paste")); for sequence in keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["edit.paste", "edit.pasteSpecial", "dialog:pasteSpecial"]) { @@ -677,7 +677,7 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { } let fp_on = app.session.format_painter.is_some(); let fp = toggle_button(ui, Icon::Brush, fp_on, "Format Painter (double-click to keep it on)"); - fp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), fp_on, "Format Painter")); + fp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), fp_on, "Format Painter")); control_keytip(app, ui, &fp, "edit.formatPainter"); if fp.double_clicked() { act(app, "edit.formatPainter", json!({"sticky": true})); @@ -742,7 +742,7 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { if b { act(app, "home.borders", json!({"preset": app.grid.last_border.clone()})); } - ba.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), "Border presets")); + ba.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), "Border presets")); egui::Popup::menu(&ba).show(|ui| { for (label, preset) in [ ("Bottom Border", "bottom"), @@ -1194,7 +1194,7 @@ fn size_combo(app: &mut SheetApp, ui: &mut Ui, st: &Style) { let t = Tokens::get(ui.ctx()); let size = st.font.size; let current = if size.fract() == 0.0 { format!("{}", size as i32) } else { format!("{size}") }; - let field = egui::Id::new("gridcraft.font_size_box"); + let field = egui::Id::unique("gridcraft.font_size_box"); let draft = field.with("draft"); let mut text = ui.data_mut(|d| d.get_temp::(draft)).unwrap_or_else(|| current.clone()); // One frame like the Font name box beside it (same fill, border, height and corners), with the @@ -1213,6 +1213,7 @@ fn size_combo(app: &mut SheetApp, ui: &mut Ui, st: &Style) { .desired_width(text_rect.width() - 12.0) .show(&mut field_ui); let resp = out.response; + ui.ctx().accesskit_node_builder(resp.id, |node| node.set_label(app.ui.language.tr("Font Size"))); control_keytip(app, ui, &resp, "home.fontSize"); if invoke(app, "home.fontSize") { ui.memory_mut(|m| m.request_focus(field)); @@ -1242,7 +1243,7 @@ fn size_combo(app: &mut SheetApp, ui: &mut Ui, st: &Style) { let looks = if resp.has_focus() || ui.rect_contains_pointer(frame) { &visuals.widgets.hovered } else { &visuals.widgets.inactive }; let stroke = if resp.has_focus() { visuals.selection.stroke } else { looks.bg_stroke }; ui.painter().set(bg, egui::epaint::RectShape::new(frame, looks.corner_radius, looks.weak_bg_fill, stroke, egui::StrokeKind::Inside)); - arrow.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), "Font Size")); + arrow.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), "Font Size")); let sizes = [8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 22.0, 24.0, 26.0, 28.0, 36.0, 48.0, 72.0]; let labels: Vec = sizes.iter().map(|s| format!("{}", *s as i32)).collect(); let items: Vec<(&str, String)> = labels.iter().map(|s| (s.as_str(), s.clone())).collect(); @@ -1522,7 +1523,7 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) { sep(ui); let pic = big_button(ui, Icon::Picture, "Pictures", "Insert a picture", true); anchor_keytip(app, ui, &pic, &["insert.picture"]); - pic.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), "Pictures")); + pic.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), "Pictures")); egui::Popup::menu(&pic).show(|ui| { for (label, placement) in [("Place in Cell", "cell"), ("Place over Cells", "overCells")] { if ui.button(label).clicked() { @@ -1705,7 +1706,8 @@ fn insert(app: &mut SheetApp, ui: &mut Ui) { "€", "£", "¥", "©", "®", "™", "±", "≠", "≤", "≥", "÷", "×", "∞", "µ", "α", "β", "π", "Σ", "Ω", "°", "•", "…", "→", "←", "↑", "↓", "✓", "★", "½", "¼", ] { - if ui.button(ch).clicked() { + // No bundled face has ✓: the button paints a code-drawn check instead of a tofu box. + if crate::widgets::glyph_button(ui, ch.chars().next().unwrap_or('?'), &format!("Insert {ch}")).clicked() { act(app, "insert.symbol", json!({"char": ch})); } } @@ -1994,7 +1996,7 @@ fn formulas(app: &mut SheetApp, ui: &mut Ui) { } }); ui.vertical(|ui| { - let __k = small_button(ui, Icon::Fx, "Show Formulas", "Show Formulas (⌃`)", false); + let __k = small_button(ui, Icon::Fx, "Show Formulas", "Show Formulas", false); control_keytip(app, ui, &__k, "formulas.showFormulas"); if __k.clicked() { act(app, "formulas.showFormulas", json!({})); @@ -2344,20 +2346,25 @@ fn view(app: &mut SheetApp, ui: &mut Ui) { ); }); ui.vertical(|ui| { - ui.label(lang.tr("Display theme")); + let label_response = ui.label(lang.tr("Display theme")); let mode = app.ui.theme_mode(); let label = match mode { "system" => "System", "dark" => "Dark", _ => "Light", }; - egui::ComboBox::from_id_salt("display_theme").selected_text(lang.tr(label)).width(88.0).show_ui(ui, |ui| { - for (value, label) in [("system", "System"), ("light", "Light"), ("dark", "Dark")] { - if ui.selectable_label(mode == value, lang.tr(label)).clicked() { - act(app, "view.theme", json!({"mode": value})); + egui::ComboBox::from_id_salt("display_theme") + .selected_text(lang.tr(label)) + .width(88.0) + .show_ui(ui, |ui| { + for (value, label) in [("system", "System"), ("light", "Light"), ("dark", "Dark")] { + if ui.selectable_label(mode == value, lang.tr(label)).clicked() { + act(app, "view.theme", json!({"mode": value})); + } } - } - }); + }) + .response + .labelled_by(label_response.id); let mut animations = app.ui.animations; let hint = lang.tr("Glide the selection outline to the cell you move to instead of jumping there"); if ui.checkbox(&mut animations, lang.tr("Animations")).on_hover_text(hint).changed() { @@ -2367,7 +2374,7 @@ fn view(app: &mut SheetApp, ui: &mut Ui) { sep(ui); // Interface size (issue #230): scales the whole window on top of the desktop's scale. ui.vertical(|ui| { - ui.label(egui::RichText::new(lang.tr("Interface size")).font(theme::ui_font(11.5)).color(t.text_dim)); + let label = ui.label(egui::RichText::new(lang.tr("Interface size")).font(theme::ui_font(11.5)).color(t.text_dim)); let current = app.ui.interface_scale; let text = |v: u16| if v == 100 { lang.tr("Auto").to_string() } else { format!("{v}%") }; egui::ComboBox::from_id_salt("ui_interface_size") @@ -2379,22 +2386,26 @@ fn view(app: &mut SheetApp, ui: &mut Ui) { act(app, "view.interfaceScale", json!({"percent": v})); } } - }); + }) + .response + .labelled_by(label.id); }); // Interface language: settings live in the View tab, like Excel's Options. The label follows // the current language so it stays readable; the choices are always shown in their own script. ui.vertical(|ui| { - ui.label(egui::RichText::new(lang.tr("Interface language")).font(theme::ui_font(11.5)).color(t.text_dim)); - egui::ComboBox::from_id_salt("ui_language").width(120.0).selected_text(egui::RichText::new(lang.name()).font(theme::ui_font(12.5))).show_ui( - ui, - |ui| { + let label = ui.label(egui::RichText::new(lang.tr("Interface language")).font(theme::ui_font(11.5)).color(t.text_dim)); + egui::ComboBox::from_id_salt("ui_language") + .width(120.0) + .selected_text(egui::RichText::new(lang.name()).font(theme::ui_font(12.5))) + .show_ui(ui, |ui| { for l in crate::i18n::Language::ALL { if ui.selectable_label(l == lang, l.name()).clicked() { act(app, "app.language.set", json!({"language": l.code()})); } } - }, - ); + }) + .response + .labelled_by(label.id); }); } @@ -2434,7 +2445,7 @@ fn table_design(app: &mut SheetApp, ui: &mut Ui) { }; ui.vertical(|ui| { ui.label(egui::RichText::new("Table Name:").small()); - let field = egui::Id::new(("gridcraft.table_name", &tname)); + let field = egui::Id::unique(("gridcraft.table_name", &tname)); if let Some(name) = crate::widgets::committed_text(ui, field, &tname, Some(120.0)) && name != tname { diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index bc6fcc1d8..c81c4793e 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -70,7 +70,7 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { app.run_or_alert("sheet.next", json!({})); } let all = crate::widgets::icon_button(ui, Icon::SheetList, t.text_dim, app.ui.language.tr("All sheets"), vec2(24.0, 24.0)); - all.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, app.ui.language.tr("All sheets"))); + all.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, app.ui.language.tr("All sheets"))); let items = app.session.active().map(|d| navigator_items(&d.wb.sheets)).unwrap_or_default(); let active = app.session.active().map(|d| d.wb.active_sheet.to_string()).unwrap_or_default(); let rows: Vec<(&str, String)> = items.iter().map(|(i, name)| (i.as_str(), name.clone())).collect(); @@ -94,8 +94,8 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { let tabs_width = (ui.available_width() * 0.6).max(100.0); // Reveal the selected sheet after navigation or layout changes, but // allow browsing other tabs without snapping back on every frame. - let reveal_key = egui::Id::new((d.uid, d.wb.active_sheet, &sheets, tabs_width.to_bits())); - let reveal_id = ui.id().with("worksheet_tabs_reveal"); + let reveal_key = egui::Id::unique((d.uid, d.wb.active_sheet, &sheets, tabs_width.to_bits())); + let reveal_id = ui.scope_id().with("worksheet_tabs_reveal"); let reveal_active = ui.ctx().data_mut(|data| { let changed = data.get_temp::(reveal_id) != Some(reveal_key); data.insert_temp(reveal_id, reveal_key); @@ -154,8 +154,9 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { if renaming { let mut text = app.grid.rename_text.clone(); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(6.0, 5.0)))); - let ed = - child.add(egui::TextEdit::singleline(&mut text).font(theme::ui_font(13.0)).desired_width(r.width() - 12.0)); + let ed = child + .add(egui::TextEdit::singleline(&mut text).font(theme::ui_font(13.0)).desired_width(r.width() - 12.0)) + .accessible_name(app.ui.language.tr("Sheet Name")); app.grid.rename_text = text.clone(); if rename_needs_focus(ed.has_focus(), ed.lost_focus()) { ed.request_focus(); @@ -310,6 +311,7 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) { } let mut z = zoom as f32; let slider = ui.add(egui::Slider::new(&mut z, 10.0..=400.0).show_value(false).logarithmic(true)); + ui.ctx().accesskit_node_builder(slider.id, |node| node.set_label(app.ui.language.tr("Zoom"))); if slider.changed() { let _ = app.session.run("view.zoom", json!({"percent": z.round()})); } diff --git a/crates/ui-egui/src/text_box.rs b/crates/ui-egui/src/text_box.rs index a0b30c025..21b788cfa 100644 --- a/crates/ui-egui/src/text_box.rs +++ b/crates/ui-egui/src/text_box.rs @@ -18,7 +18,7 @@ pub struct EditState { } pub(crate) fn editor_id() -> egui::Id { - egui::Id::new("gridcraft.text_box_editor") + egui::Id::unique("gridcraft.text_box_editor") } impl SheetApp { @@ -147,20 +147,22 @@ pub(crate) fn show(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &gridcr let mut child = ui.new_child(egui::UiBuilder::new().id_salt(id).max_rect(rect)); child.set_clip_rect(rect); egui::ScrollArea::vertical().id_salt(id).max_height(rect.height()).auto_shrink([false, false]).show(&mut child, |ui| { - let response = ui.add( - TextEdit::multiline(&mut ed.text) - .id(id) - .font(FontId::new(14.0 * geo.z, egui::FontFamily::Name(theme::CELL.into()))) - .text_color(Color32::BLACK) - .frame(egui::Frame::NONE) - .margin(egui::Margin::ZERO) - .desired_width(rect.width()) - .desired_rows(1) - // shape.setText's limit (a cell's). - .char_limit(32_767) - .min_size(rect.size()) - .lock_focus(true), - ); + let response = ui + .add( + TextEdit::multiline(&mut ed.text) + .id(id) + .font(FontId::new(14.0 * geo.z, egui::FontFamily::Name(theme::CELL.into()))) + .text_color(Color32::BLACK) + .frame(egui::Frame::NONE) + .margin(egui::Margin::ZERO) + .desired_width(rect.width()) + .desired_rows(1) + // shape.setText's limit (a cell's). + .char_limit(32_767) + .min_size(rect.size()) + .lock_focus(true), + ) + .accessible_name(app.ui.language.tr("Text Box")); if ed.request_focus { response.request_focus(); ed.request_focus = false; diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index d454f0380..ab4495bfe 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -1,14 +1,37 @@ //! Design tokens and fonts. //! -//! Colours are our own, tuned to read like a modern desktop spreadsheet. Fonts come from the -//! operating system at runtime (nothing bundled): the UI uses the platform UI font, cells use the -//! best available match for the workbook font (Calibri/Carlito/Aptos/Arial…) with egui's built-in -//! fonts as the last resort. +//! Colours are our own, tuned to read like a modern desktop spreadsheet. Fonts come from egui's +//! bundled faces and the operating system at runtime: the UI uses the platform UI font, cells use +//! the best available match for the workbook font (Calibri/Carlito/Aptos/Arial…), and egui's +//! bundled Ubuntu/Hack/egui-icons and monochrome emoji faces are the last resort. A build with +//! `CRAFT_FONTS_DIR` also embeds the permitted OFL craft-fonts faces (barred Adobe/Source/Noto CJK +//! families never enter it, and a web build keeps only its explicit allowlist). Native builds opt +//! into craft-fonts' OFL LXGW Chinese face, so a host without a Han system face still draws Chinese +//! interface text; no font file is stored in this repo. use std::sync::Arc; +#[cfg(not(target_arch = "wasm32"))] +use egui::epaint::text::Blob; use egui::{Color32, FontData, FontDefinitions, FontFamily, FontId, Stroke, Visuals}; +/// A font from the optional craft-fonts build input (`CRAFT_FONTS_DIR` at build time; see +/// and its `docs/integration.md`). The slice is empty +/// when the app is built without the checkout; nothing is downloaded and no font is in this repo. +#[derive(Debug)] +pub struct CraftFont { + pub family: &'static str, + pub style: &'static str, + /// ISO 15924 scripts the family is intended for, from the craft-fonts manifest. + pub scripts: &'static [&'static str], + pub bytes: &'static [u8], +} + +mod craft_fonts { + use super::CraftFont; + include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs")); +} + #[derive(Clone, Copy, Debug, PartialEq)] pub struct Tokens { pub dark: bool, @@ -243,43 +266,40 @@ pub(crate) fn font_parses(bytes: &[u8], index: u32) -> bool { #[cfg(not(target_arch = "wasm32"))] fn try_load(paths: &[(&str, u32)]) -> Option { for (p, index) in paths { - if let Some(bytes) = font_file(p, *index) { - let mut fd = FontData::from_static(bytes); - fd.index = *index; - return Some(fd); + if let Some(blob) = font_file(p, *index) { + return Some(FontData::from_blob(blob, *index)); } } None } -/// The interface and script fallback font files, each read once per run and kept for the rest of -/// it. egui copies an owned font's bytes into its text engine and keeps the original too -/// (`epaint`'s `blob_from_font_data`), so every one of these files was in memory twice, about -/// 100 MB on a Mac; bytes it borrows for `'static` it shares instead. These faces are loaded by -/// every font rebuild for as long as the app runs, so keeping them costs nothing, and rebuilds no -/// longer read them from disk again. Only a file that parses is kept: a missing or broken one is -/// looked for again next time. +/// The interface and script fallback font files, each read once per run and kept as a shared +/// `Blob` for the rest of it. egui shares the blob instead of copying the file into its text +/// engine, because every one of these files is loaded by every font rebuild for as long as the +/// app runs. Only a file that parses is kept: a missing or broken one is looked for again next +/// time. #[cfg(not(target_arch = "wasm32"))] -fn font_file(path: &str, index: u32) -> Option<&'static [u8]> { - static FILES: std::sync::Mutex> = std::sync::Mutex::new(std::collections::BTreeMap::new()); +fn font_file(path: &str, index: u32) -> Option { + static FILES: std::sync::Mutex> = std::sync::Mutex::new(std::collections::BTreeMap::new()); let mut files = FILES.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - if let Some(&bytes) = files.get(path) { - return font_parses(bytes, index).then_some(bytes); + if let Some(blob) = files.get(path) { + let bytes: &[u8] = (**blob).as_ref(); + return font_parses(bytes, index).then(|| std::sync::Arc::clone(blob)); } let bytes = std::fs::read(path).ok()?; if !font_parses(&bytes, index) { log::warn!("skipping font {path} (face {index}): not a font file egui can read"); return None; } - let bytes: &'static [u8] = Box::leak(bytes.into_boxed_slice()); - files.insert(path.to_string(), bytes); - Some(bytes) + let blob: Blob = std::sync::Arc::new(bytes); + files.insert(path.to_string(), blob.clone()); + Some(blob) } /// Last line of defence before [`FontDefinitions`] reach egui: drops every face epaint could not /// parse (and its key from every family), so a bad font costs a fallback, never a crash. pub(crate) fn drop_unparseable_fonts(fonts: &mut FontDefinitions) { - let bad: Vec = fonts.font_data.iter().filter(|(_, fd)| !font_parses(&fd.font, fd.index)).map(|(k, _)| k.clone()).collect(); + let bad: Vec = fonts.font_data.iter().filter(|(_, fd)| !font_parses(fd.bytes(), fd.index)).map(|(k, _)| k.clone()).collect(); for key in &bad { log::warn!("dropping font {key:?}: egui can't parse it"); fonts.font_data.remove(key); @@ -291,6 +311,90 @@ pub(crate) fn drop_unparseable_fonts(fonts: &mut FontDefinitions) { } } +/// egui's bundled monospace face, used as the last-resort symbol fallback (see `Hack-Regular.ttf` +/// in `epaint_default_fonts`). It is present whenever egui is built with `default_fonts`. +const HACK: &str = "Hack"; + +/// The permitted craft-fonts faces with their `FontDefinitions` keys, in manifest order. Empty +/// without a `CRAFT_FONTS_DIR` checkout at build time. The filter is repeated here so a stale +/// generated table can never smuggle in a barred face or a web-build face outside the allowlist. +fn craft_font_faces() -> Vec<(String, &'static CraftFont)> { + let wasm = cfg!(target_arch = "wasm32"); + craft_fonts::CRAFT_FONTS + .iter() + .enumerate() + .filter(|(_, font)| !crate::craft_fonts_policy::barred(font.family)) + .filter(|(_, font)| !wasm || crate::craft_fonts_policy::web_allowed(font.family, font.style)) + .map(|(i, font)| (format!("craft-{i}-{}-{}", font.family, font.style), font)) + .collect() +} + +/// Adds the optional craft-fonts faces to `fonts` and returns their keys. +fn add_craft_fonts(fonts: &mut FontDefinitions) -> Vec { + let mut keys = Vec::new(); + for (key, font) in craft_font_faces() { + fonts.font_data.insert(key.clone(), Arc::new(FontData::from_static(font.bytes))); + keys.push(key); + } + keys +} + +/// Whether the optional craft-fonts input was embedded in this build. +pub fn craft_fonts_loaded() -> bool { + !craft_fonts::CRAFT_FONTS.is_empty() +} + +/// Whether the native-only Simplified Chinese craft face (LXGW WenKai Lite) was embedded. A web +/// build or an older craft-fonts checkout without `fonts/simplified-chinese-manifest.txt` has none. +pub fn chinese_font_loaded() -> bool { + craft_font_faces().iter().any(|(_, f)| f.family == "LXGW WenKai Lite" && f.style == "Regular") +} + +/// Whether this build was made with `CRAFT_FONTS_REQUIRED=1`. Acceptance tests must fail rather +/// than skip when this is true and the faces are absent. +pub fn craft_fonts_required() -> bool { + craft_fonts::CRAFT_FONTS_REQUIRED +} + +/// A bundled-face character with the same meaning as `c`, for interface chrome whose exact symbol +/// no bundled face has (`✕`, `✓`, `⌃`). Document text still needs a real glyph; this is only a +/// display fallback and never changes the characters a command inserts or stores. +pub fn symbol_fallback(c: char) -> Option { + match c { + '✕' => Some('×'), + '✓' => Some('✔'), + '⌃' => Some('^'), + _ => None, + } +} + +/// `text` with every character the current font set cannot draw replaced by its semantic fallback +/// (see [`symbol_fallback`]). Used by chrome tooltips so a host without symbol faces never paints +/// a tofu box; it is not applied to workbook text. +pub fn available_text(ui: &egui::Ui, text: &str) -> String { + let font = ui_font(13.0); + text.chars().map(|c| if ui.ctx().fonts_mut(|f| f.has_glyph(&font, c)) { c } else { symbol_fallback(c).unwrap_or(c) }).collect() +} + +/// The family, style and scripts of every embedded craft-fonts face (empty without the input). +pub fn craft_font_scripts() -> impl Iterator { + craft_font_faces().into_iter().map(|(_, f)| (f.family, f.style, f.scripts)) +} + +/// Whether one embedded craft-fonts face maps every non-whitespace character in `text`. Tests use +/// this to tell "the pinned fixture covers this script" from "this host has no face for it", +/// without trusting a manifest script tag alone. +pub fn craft_fonts_cover(text: &str) -> bool { + craft_font_faces().iter().any(|(_, f)| { + use skrifa::MetadataProvider; + let Ok(font) = skrifa::FontRef::from_index(f.bytes, 0) else { + return false; + }; + let map = font.charmap(); + text.chars().filter(|c| !c.is_whitespace()).all(|c| map.map(c).is_some()) + }) +} + #[cfg(target_arch = "wasm32")] fn try_load(_paths: &[(&str, u32)]) -> Option { None @@ -451,22 +555,38 @@ pub fn font_definitions_for_language(language: crate::i18n::Language) -> FontDef font_definitions_with(language, &[]) } +/// The bundled faces plus the pinned craft-fonts faces, with no system-font probing or installed +/// workbook-font binding. +/// +/// Integration tests use this through their shared harness, so a minimal CI image and a developer +/// machine see the same font set. Craft-fonts remains a pinned build input rather than a repository +/// asset; without it only egui's bundled faces are available, which is the documented no-font mode. +pub fn pinned_font_definitions() -> FontDefinitions { + font_definitions_impl(crate::i18n::Language::En, &[], false) +} + /// [`font_definitions_for_language`] that also binds the workbook fonts in `used` that are /// installed, so cells set in them render with them; the rest fall back to the roles. The bound /// families are recorded in [`crate::fonts`] so [`cell_family`] only names a family egui has. pub fn font_definitions_with(language: crate::i18n::Language, used: &[String]) -> FontDefinitions { + font_definitions_impl(language, used, true) +} + +fn font_definitions_impl(language: crate::i18n::Language, used: &[String], system: bool) -> FontDefinitions { let mut fonts = FontDefinitions::default(); let base_prop: Vec = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default(); let base_mono: Vec = fonts.families.get(&FontFamily::Monospace).cloned().unwrap_or_default(); let mut bound: Vec = Vec::new(); // The non-Latin fallback faces have to sit at the end of each chain: a Latin font owns Latin, // and epaint picks the first face that has the glyph, so they only catch their own scripts. - let fallback = load_fallback(&mut fonts, HanOrder::of(language)); + let fallback = if system { load_fallback(&mut fonts, HanOrder::of(language)) } else { Vec::new() }; + let hack = fonts.font_data.contains_key(HACK).then_some(HACK); for role in [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO] { let paths = candidates(role); let refs: Vec<(&str, u32)> = paths.iter().map(|(p, i)| (p.as_str(), *i)).collect(); let mut chain: Vec = Vec::new(); - if let Some(mut fd) = try_load(&refs) { + let loaded = if system { try_load(&refs) } else { None }; + if let Some(mut fd) = loaded { // Helvetica Neue Bold's own TrueType hints close Cyrillic е into ө at 13 pt on a Retina // screen (#1184). Core Text never runs them, so this draws it the way macOS does. if cfg!(target_os = "macos") && role == UI_BOLD { @@ -481,14 +601,25 @@ pub fn font_definitions_with(language: crate::i18n::Language, used: &[String]) - chain.push(format!("sys-{CELL}")); } chain.extend(if role == MONO { base_mono.clone() } else { base_prop.clone() }); + // egui bundles Hack, a monospace face rich in arrows, triangles and keyboard symbols, but + // 0.37 only puts it in the Monospace family. The interface draws ↑, ▲, ▼, ▸, ▾ and ↳ as + // text (filter menus, ribbon, keytips), so add it behind the UI/cell and emoji faces; it + // only catches characters those do not have. + if let Some(hack) = hack + && !chain.iter().any(|k| k.as_str() == hack) + { + chain.push(hack.to_string()); + } chain.extend(fallback.iter().cloned()); chain.retain(|k| fonts.font_data.contains_key(k)); fonts.families.insert(FontFamily::Name(role.into()), chain); bound.push(role.to_string()); } // Installed workbook fonts, so a cell's chosen font actually renders in it. - for family in crate::fonts::installed_among(used) { - bound.extend(crate::fonts::bind_family(&mut fonts, &family)); + if system { + for family in crate::fonts::installed_among(used) { + bound.extend(crate::fonts::bind_family(&mut fonts, &family)); + } } // Interface text (formula bar, name box, dialogs) falls back to the cell font before the // script faces: whatever a cell can draw, the formula bar can draw too, even where no script @@ -508,6 +639,21 @@ pub fn font_definitions_with(language: crate::i18n::Language, used: &[String]) - if let Some(ui) = fonts.families.get(&FontFamily::Name(UI.into())).cloned() { fonts.families.insert(FontFamily::Proportional, ui); } + // The optional craft-fonts faces go at the very end of every family: a system or bundled face + // that has the script keeps its glyph shapes, while the permitted OFL faces cover scripts and + // symbols the host image lacks (Japanese, Korean, Thai, Arabic, Georgian and the UI symbols). + // Chinese comes from the native-only OFL LXGW face when embedded; a system Han face still + // leads, and the barred Noto CJK collection never enters. + let craft = add_craft_fonts(&mut fonts); + if !craft.is_empty() { + for chain in fonts.families.values_mut() { + for key in &craft { + if !chain.contains(key) { + chain.push(key.clone()); + } + } + } + } drop_unparseable_fonts(&mut fonts); // Only families egui still has data for count as bound (a dropped face can empty a chain). bound.retain(|f| fonts.families.get(&FontFamily::Name(f.as_str().into())).is_some_and(|c| !c.is_empty())); @@ -819,6 +965,12 @@ pub fn apply(ctx: &egui::Context, dark: bool) { v.widgets.hovered.weak_bg_fill = t.hover; v.widgets.active.weak_bg_fill = t.pressed; v.widgets.inactive.bg_stroke = Stroke::new(1.0, t.input_border); + // 0.37 raises the default widget corner radii; GridCraft keeps its 0.36 shape (#8617). + v.widgets.noninteractive.corner_radius = egui::CornerRadius::same(2); + v.widgets.inactive.corner_radius = egui::CornerRadius::same(2); + v.widgets.hovered.corner_radius = egui::CornerRadius::same(3); + v.widgets.active.corner_radius = egui::CornerRadius::same(2); + v.widgets.open.corner_radius = egui::CornerRadius::same(2); v.window_corner_radius = egui::CornerRadius::same(10); v.menu_corner_radius = egui::CornerRadius::same(8); v.popup_shadow = egui::epaint::Shadow { offset: [0, 4], blur: 16, spread: 0, color: t.shadow }; @@ -910,13 +1062,83 @@ mod tests { } for role in [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO] { let chain = fonts.families.get(&FontFamily::Name(role.into())).map(Vec::as_slice).unwrap_or_default(); - assert!(chain.len() >= fb_keys.len(), "{role} chain too short for its fallback"); - let tail = &chain[chain.len() - fb_keys.len()..]; - assert_eq!(tail, fb_keys.as_slice(), "{role} must end its chain with the fallback faces"); + // The optional craft-fonts faces follow the system fallbacks at the very end. + let end = chain.iter().rposition(|k| !k.starts_with("craft-")).map_or(0, |i| i + 1); + assert!(end >= fb_keys.len(), "{role} chain too short for its fallback"); + assert_eq!(&chain[end - fb_keys.len()..end], fb_keys.as_slice(), "{role} must end its non-craft chain with the fallback faces"); } } } + #[test] + fn craft_fonts_are_the_last_fallback_in_every_family() { + // Ordering: app/bundled faces, Hack/emoji, system script fallbacks, permitted craft faces. + // A craft face must never displace a system face that already covers a glyph. + let fonts = font_definitions_for_language(Language::En); + let expected: Vec = craft_font_faces().into_iter().map(|(key, _)| key).collect(); + if expected.is_empty() { + return; // optional input absent + } + for (family, chain) in &fonts.families { + let first = chain.iter().position(|k| k.starts_with("craft-")).unwrap_or(chain.len()); + assert_eq!(&chain[first..], expected.as_slice(), "{family:?} must end with exactly the permitted craft faces in manifest order"); + } + } + + #[test] + fn a_required_native_build_embeds_the_chinese_face() { + if cfg!(target_arch = "wasm32") || !craft_fonts_required() { + return; + } + assert!( + chinese_font_loaded(), + "CRAFT_FONTS_REQUIRED=1 native builds must embed the LXGW Chinese face from fonts/simplified-chinese-manifest.txt" + ); + let face = craft_font_scripts().find(|(f, s, _)| *f == "LXGW WenKai Lite" && *s == "Regular"); + assert_eq!(face.map(|(_, _, scripts)| scripts), Some(["Hani", "Hans"].as_slice()), "the Chinese manifest's script tags are asserted"); + } + + #[test] + fn barred_faces_never_enter_the_font_set() { + for (family, _, _) in craft_font_scripts() { + assert!(!family.contains("CJK"), "the barred Noto CJK collection must never be embedded: {family}"); + assert!(!family.starts_with("Source ") && !family.starts_with("Adobe "), "a barred Adobe/Source face was embedded: {family}"); + } + } + + #[test] + fn the_required_craft_faces_keep_their_exact_scripts() { + if !craft_fonts_loaded() { + assert!(!craft_fonts_required(), "CRAFT_FONTS_REQUIRED=1 but no craft-fonts faces were embedded"); + return; + } + let faces: Vec<_> = craft_font_scripts().collect(); + for (family, style, scripts) in [ + ("BIZ UDPGothic", "Regular", ["Jpan", "Latn"].as_slice()), + ("BIZ UDPGothic", "Bold", ["Jpan", "Latn"].as_slice()), + ("NanumGothic", "Regular", ["Hang", "Kore"].as_slice()), + ("Noto Sans Symbols", "Regular", ["Zsym"].as_slice()), + ("Noto Sans Symbols 2", "Regular", ["Zsym"].as_slice()), + ] { + assert!( + faces.iter().any(|(f, s, declared)| *f == family && *s == style && *declared == scripts), + "{family} {style} with {scripts:?} must be embedded; got {faces:?}" + ); + } + } + + #[test] + fn available_text_maps_unbundled_chrome_symbols() { + // No bundled face has ⌃ ✓ ✕; chrome text must show ^ ✔ × instead of tofu. Document text is + // never rewritten: this helper is only used for interface tooltips. + let ctx = egui::Context::default(); + ctx.set_fonts(FontDefinitions::default()); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + assert_eq!(available_text(ui, "Show Formulas (⌃`) ✓ ✕"), "Show Formulas (^`) ✔ ×"); + }); + out.textures_delta.clear(); + } + #[test] fn interface_families_fall_back_to_the_cell_font() { // Structural (#349): the formula bar, name box and dialogs draw every script the cells can, @@ -990,7 +1212,7 @@ mod tests { fallback_faces(HanOrder::of(language)).into_iter().filter_map(|(k, _)| fonts.font_data.get(k)).collect(); for (i, a) in present.iter().enumerate() { for b in present.iter().skip(i + 1) { - assert!(a.font != b.font, "the same font file is loaded for two fallback scripts"); + assert!(!std::sync::Arc::ptr_eq(&a.font, &b.font), "the same font file is loaded for two fallback scripts"); } } } @@ -998,16 +1220,21 @@ mod tests { #[test] fn fallback_covers_its_script() { - // Real render check, per script: the proportional family (what cells and the UI use) must - // resolve a sample string to a real glyph, not the tofu replacement char. Skips a script - // whose face is absent (a minimal machine) and fails if a face is present but not wired in. - let cases: [(&str, Vec<(String, u32)>, &str); 8] = [ + // Real glyph check, per script: the proportional family (what cells and the UI use) must + // have a glyph for every non-space character in the sample. A script runs when a system + // face or the optional craft-fonts input provides one; the printed skip is a recorded + // gate, and a present face that is not wired in fails. + let cases: [(&str, Vec<(String, u32)>, &str); 10] = [ ("sys-cjk-han-ja", cjk_han_japanese(), "カテゴリ"), + // Chinese uses a system Han face or the native-only permitted LXGW craft face; the + // barred Noto CJK collection never supplies it. ("sys-cjk-han-zh", cjk_han_simplified(), "页面布局 客户端配置"), ("sys-cjk-hangul", cjk_hangul(), "한글"), ("sys-thai", thai(), "ทดสอบ"), ("sys-lao", lao(), "ພາສາລາວ"), ("sys-hebrew", hebrew(), "עברית"), + ("craft-noto-sans-arabic", Vec::new(), "مرحبا"), + ("craft-noto-sans-georgian", Vec::new(), "ქართული"), ("ui", candidates(UI), "Русский"), // Czech interface labels: every Czech accented letter, in both cases. ("ui", candidates(UI), "Příliš žluťoučký kůň úpěl ďábelské ódy ŘŠČŽÝÁÍÉÚŮĚŤĎŇÓ"), @@ -1017,26 +1244,19 @@ mod tests { ctx.set_fonts(font_definitions_for_language(language)); let mut out = ctx.run_ui(egui::RawInput::default(), |_ui| {}); out.textures_delta.clear(); // nothing consumes the atlas in a headless test - // `has_glyphs` is a false negative when the face that owns the script is also the face - // that draws the replacement glyph (DejaVu Sans as the UI font owns Hebrew), so compare - // the laid out glyphs with the tofu glyph itself instead. for family in [FontFamily::Proportional, FontFamily::Name(CELL.into())] { - let font = FontId::new(14.0, family); - let glyphs = |text: &str| { - let galley = ctx.fonts_mut(|f| f.layout_no_wrap(text.to_owned(), font.clone(), Color32::BLACK)); - galley.rows.iter().flat_map(|r| r.glyphs.iter().filter(|g| g.chr != ' ').map(|g| g.uv_rect)).collect::>() - }; - let tofu = glyphs("\u{25FB}"); + let font = FontId::new(14.0, family.clone()); for (key, cands, sample) in &cases { - if !cands.iter().any(|(p, _)| std::path::Path::new(p).exists()) { - continue; // no face for this script on this machine; nothing to assert + // A script runs when a system face or the optional craft-fonts input provides + // one. The skip is printed: a missing fixture is a recorded gate, not a pass. + let system = cands.iter().any(|(p, _)| std::path::Path::new(p).exists()); + if !system && !craft_fonts_cover(sample) { + eprintln!("skipped {key}: no system face and no craft-fonts face for {sample:?}"); + continue; } - let drawn = glyphs(sample); - assert!( - !drawn.is_empty() && drawn.iter().all(|g| !tofu.contains(g)), - "{key} is present but {sample:?} still renders as tofu ({language:?}, {:?})", - font.family - ); + let missing: String = + sample.chars().filter(|c| !c.is_whitespace()).filter(|&c| !ctx.fonts_mut(|f| f.has_glyph(&font, c))).collect(); + assert!(missing.is_empty(), "{key} is present but {sample:?} has no glyph for {missing:?} ({language:?}, {family:?})"); } } } @@ -1062,7 +1282,7 @@ mod tests { fn unparseable_font_files_are_rejected_not_fatal() { // #226: epaint panics in `set_fonts` on a face it can't parse ("An offset was out of // bounds"). Garbage, a truncated real font and a bad collection index must all be refused. - let real = FontDefinitions::default().font_data.values().next().map(|fd| fd.font.to_vec()).unwrap_or_default(); + let real = FontDefinitions::default().font_data.values().next().map(|fd| fd.bytes().to_vec()).unwrap_or_default(); assert!(font_parses(&real, 0), "egui's own font must parse"); assert!(!font_parses(b"", 0)); assert!(!font_parses(&[0xAB; 64], 0)); @@ -1095,13 +1315,13 @@ mod tests { let _ = std::fs::create_dir_all(&dir); let bad = dir.join("broken.ttf"); let good = dir.join("good.ttf"); - let real = FontDefinitions::default().font_data.values().next().map(|fd| fd.font.to_vec()).unwrap_or_default(); + let real = FontDefinitions::default().font_data.values().next().map(|fd| fd.bytes().to_vec()).unwrap_or_default(); let _ = std::fs::write(&bad, real.get(..40).unwrap_or_default()); let _ = std::fs::write(&good, &real); let (bad_s, good_s) = (bad.to_string_lossy().into_owned(), good.to_string_lossy().into_owned()); assert!(try_load(&[(bad_s.as_str(), 0)]).is_none()); let loaded = try_load(&[(bad_s.as_str(), 0), (good_s.as_str(), 0)]); - assert_eq!(loaded.map(|fd| fd.font.len()), Some(real.len())); + assert_eq!(loaded.map(|fd| fd.bytes().len()), Some(real.len())); let _ = std::fs::remove_dir_all(&dir); } @@ -1116,11 +1336,11 @@ mod tests { let first = try_load(&[(p.as_str(), 0)]).unwrap(); std::fs::remove_file(&path).unwrap(); let second = try_load(&[(p.as_str(), 0)]).expect("kept from the first read"); - let (std::borrow::Cow::Borrowed(a), std::borrow::Cow::Borrowed(b)) = (&first.font, &second.font) else { - panic!("interface font bytes must be borrowed, so egui shares them instead of copying"); - }; - assert!(std::ptr::eq(*a, *b)); - assert_eq!(*a, epaint_default_fonts::UBUNTU_LIGHT); + assert!( + std::sync::Arc::ptr_eq(&first.font, &second.font), + "interface font bytes must be one shared blob, so egui shares them instead of copying" + ); + assert_eq!(first.bytes(), epaint_default_fonts::UBUNTU_LIGHT); assert!(try_load(&[(p.as_str(), 3)]).is_none(), "a face the kept file doesn't have is still refused"); } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index e5322c3ec..be5da0e0f 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -31,6 +31,9 @@ const TOOLTIP_SHORTCUTS: &[(&str, &str, &str)] = &[ ("Insert Link", "⌘K", "Ctrl+K"), ("Filter", "⌘⇧F", "Ctrl+Shift+L"), ("Flash Fill", "⌘E", "Ctrl+E"), + // The Control key, written the way each platform names it. macOS's ⌃ is supplied by its + // system fonts; elsewhere the Windows spelling avoids a glyph no bundled face has. + ("Show Formulas", "⌃`", "Ctrl+`"), ]; /// The shortcut a ribbon tooltip shows after `tip`, for this platform (`mac`). @@ -43,10 +46,12 @@ pub fn tooltip_shortcut(tip: &str, mac: bool) -> Option<&'static str> { fn tip_text(ui: &Ui, tip: &str) -> String { // A name shared with a two-line ribbon label (Czech "Automatický\nsoučet") reads as one line. let name = tr(ui, tip).replace('\n', " "); - match tooltip_shortcut(tip, cfg!(target_os = "macos")) { + let text = match tooltip_shortcut(tip, cfg!(target_os = "macos")) { Some(keys) => format!("{name} ({keys})"), - None => name.to_string(), - } + None => name, + }; + // A tooltip must not paint a tofu box on a host whose faces lack one of its symbols. + theme::available_text(ui, &text) } /// A flat square icon button with hover highlight and tooltip. @@ -62,6 +67,20 @@ pub fn icon_button(ui: &mut Ui, icon: Icon, color: Color32, tip: &str, size: egu resp.on_hover_text(tip_text(ui, tip)) } +/// A button for a character the interface inserts (the Symbol gallery). When the current font set +/// has no glyph for it, the code-drawn icon with the same meaning is painted instead of a tofu +/// box. Only the button's appearance changes: clicking still inserts the exact requested character. +pub fn glyph_button(ui: &mut Ui, ch: char, tip: &str) -> Response { + let font = theme::ui_font(13.0); + let has_glyph = ui.ctx().fonts_mut(|f| f.has_glyph(&font, ch)); + if !has_glyph && let Some(icon) = icons::fallback_for_symbol(ch) { + return icon_button(ui, icon, Tokens::get(ui.ctx()).text, tip, vec2(24.0, 22.0)); + } + // An unknown future character without a fallback icon still gets a readable label. + let label = if has_glyph { ch.to_string() } else { format!("U+{:04X}", ch as u32) }; + ui.button(label).on_hover_text(tip_text(ui, tip)) +} + /// A toggle-able small button (e.g. Bold) showing a checked state. pub fn toggle_button(ui: &mut Ui, icon: Icon, on: bool, tip: &str) -> Response { let (rect, resp) = ui.allocate_exact_size(vec2(26.0, 24.0), Sense::click()); @@ -146,8 +165,8 @@ pub fn split_button(ui: &mut Ui, icon: Icon, accent: Option, tip: &str) let (rect, _) = ui.allocate_exact_size(vec2(38.0, 24.0), Sense::hover()); let main = Rect::from_min_size(rect.min, vec2(25.0, 24.0)); let arrow = Rect::from_min_max(pos2(main.right(), rect.top()), rect.max); - let m = ui.interact(main, ui.id().with((tip, "main")), Sense::click()); - let a = ui.interact(arrow, ui.id().with((tip, "arrow")), Sense::click()); + let m = ui.interact(main, ui.scope_id().with((tip, "main")), Sense::click()); + let a = ui.interact(arrow, ui.scope_id().with((tip, "arrow")), Sense::click()); if m.hovered() || a.hovered() { ui.painter().rect_filled(rect, 4.0, t.hover); ui.painter().line_segment([pos2(main.right(), rect.top() + 3.0), pos2(main.right(), rect.bottom() - 3.0)], Stroke::new(1.0, t.separator)); @@ -209,7 +228,7 @@ pub fn remember_color(recent: &mut Vec, hex: &str) { } fn recent_colors_id() -> egui::Id { - egui::Id::new("gridcraft.recent_colors") + egui::Id::unique("gridcraft.recent_colors") } /// The More Colors… picker's state while it is open in a palette. @@ -243,7 +262,7 @@ fn palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str, more_colors: let mut picked = None; const ROWS: usize = 7; // 6 tint rows + 1 standard row const COLS: usize = 10; - let pal_id = ui.id().with("__color_palette"); + let pal_id = ui.scope_id().with("__color_palette"); let mut cur: Option<(usize, usize)> = ui.data_mut(|d| d.get_temp(pal_id)).unwrap_or(None); let more_id = pal_id.with("more"); let mut more: Option = ui.data_mut(|d| d.get_temp(more_id)).unwrap_or(None); @@ -324,7 +343,7 @@ fn palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str, more_colors: ui.label(egui::RichText::new("Theme Colors").small()); let order = [0usize, 1, 2, 3, 4, 5, 6, 7, 8, 9]; let tints = [0.0f64, 0.8, 0.6, 0.4, -0.25, -0.5]; - egui::Grid::new(ui.id().with("theme_grid")).spacing(vec2(2.0, 2.0)).show(ui, |ui| { + egui::Grid::new(ui.scope_id().with("theme_grid")).spacing(vec2(2.0, 2.0)).show(ui, |ui| { for (row, tint) in tints.iter().enumerate() { for (col, &i) in order.iter().enumerate() { let base = theme_colors.get(i).copied().unwrap_or(0); @@ -438,7 +457,7 @@ pub fn message_box(app: &mut SheetApp, ctx: &egui::Context) { let title = app.ui.language.tr(&title).to_owned(); let msg = app.ui.language.tr_error(&msg); let mut close = false; - egui::Modal::new(egui::Id::new("message_box")).show(ctx, |ui| { + egui::Modal::new(egui::Id::unique("message_box")).show(ctx, |ui| { ui.set_width(380.0); ui.horizontal(|ui| { let (r, _) = ui.allocate_exact_size(vec2(36.0, 36.0), Sense::hover()); @@ -474,7 +493,7 @@ pub fn toast(app: &mut SheetApp, ctx: &egui::Context) { } let msg = app.ui.language.tr_error(&msg); let Some(rect) = app.grid.rect else { return }; - egui::Area::new(egui::Id::new("toast")).fixed_pos(pos2(rect.center().x - 160.0, rect.bottom() - 48.0)).order(egui::Order::Tooltip).show( + egui::Area::new(egui::Id::unique("toast")).fixed_pos(pos2(rect.center().x - 160.0, rect.bottom() - 48.0)).order(egui::Order::Tooltip).show( ctx, |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { @@ -565,6 +584,26 @@ mod color_tests { } } +#[cfg(test)] +mod glyph_button_tests { + use super::*; + + #[test] + fn a_symbol_with_no_bundled_glyph_paints_a_code_icon() { + // ✓ has no bundled egui face. The gallery button must paint Icon::Check (vector paths), + // not the missing character, without weakening the font or accessibility policy. + let ctx = egui::Context::default(); + ctx.set_fonts(egui::FontDefinitions::default()); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + let response = glyph_button(ui, '✓', "Insert check mark"); + assert!(response.rect.width() > 0.0); + }); + out.textures_delta.clear(); + assert!(!out.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Text(t) if t.galley.job.text.contains('✓')))); + assert!(out.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Path(_))), "the code-drawn check must paint vector paths"); + } +} + #[cfg(test)] mod tooltip_shortcut_tests { use super::tooltip_shortcut; @@ -581,7 +620,7 @@ mod tooltip_shortcut_tests { assert_eq!(tooltip_shortcut("Search commands", false), None); for (name, mac, other) in super::TOOLTIP_SHORTCUTS { assert!(!other.contains(['⌘', '⇧', '⌥']), "{name}: {other}"); - assert!(mac.starts_with('⌘'), "{name}: {mac}"); + assert!(mac.starts_with('⌘') || mac.starts_with('⌃'), "{name}: {mac}"); } } } diff --git a/crates/ui-egui/tests/common/mod.rs b/crates/ui-egui/tests/common/mod.rs new file mode 100644 index 000000000..1b91dfcfb --- /dev/null +++ b/crates/ui-egui/tests/common/mod.rs @@ -0,0 +1,32 @@ +//! The shared strict test harness: bundled faces plus the pinned craft-fonts input, never the +//! host's installed fonts. +//! +//! `egui_kittest` keeps `MissingGlyphPolicy::Panic` and accessibility checks on. The production +//! font set probes system fonts for scripts a workstation may lack; the suite must not, or a +//! minimal CI image would fail for a reason unrelated to the change under test. `CRAFT_FONTS_DIR` +//! is the pinned input (never a font copy in this repository); without it only egui's bundled +//! faces are installed and the non-Latin language cases are skipped explicitly in the one test +//! that renders every catalog. + +use egui_kittest::HarnessBuilder; +use gridcraft_engine::Session; +use gridcraft_ui_egui::{Services, SheetApp}; + +/// A kittest builder with the deterministic font set and GridCraft's visuals installed. +pub fn builder() -> HarnessBuilder { + egui_kittest::Harness::builder().with_context_setup(|ctx| { + ctx.set_fonts(gridcraft_ui_egui::theme::pinned_font_definitions()); + gridcraft_ui_egui::theme::apply(ctx, false); + }) +} + +/// A test app whose first `logic` pass keeps the harness's pinned font set. +pub fn app(session: Session) -> SheetApp { + prepare(SheetApp::new(session, Services::default())) +} + +/// Mark `app` as already using the harness's pinned font set. +pub fn prepare(mut app: SheetApp) -> SheetApp { + app.use_prepared_fonts(); + app +} diff --git a/crates/ui-egui/tests/it/accounting_fill.rs b/crates/ui-egui/tests/it/accounting_fill.rs index 492f581cd..8dffbed9a 100644 --- a/crates/ui-egui/tests/it/accounting_fill.rs +++ b/crates/ui-egui/tests/it/accounting_fill.rs @@ -20,12 +20,12 @@ fn harness() -> Harness { s.execute("home.columnWidth", json!({"cols": "A:A", "chars": 7})).unwrap(); s.execute("home.columnWidth", json!({"cols": "B:B", "chars": 20})).unwrap(); s.execute("selection.set", json!({"range": "H20"})).unwrap(); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { app.logic(ui.ctx()); app.ui(ui); }, - SheetApp::new(s, Default::default()), + crate::common::app(s), ); h.run_steps(4); h diff --git a/crates/ui-egui/tests/it/advanced_filter.rs b/crates/ui-egui/tests/it/advanced_filter.rs index 449c6a47e..9a1394a09 100644 --- a/crates/ui-egui/tests/it/advanced_filter.rs +++ b/crates/ui-egui/tests/it/advanced_filter.rs @@ -10,12 +10,12 @@ use serde_json::{Value as Json, json}; fn harness() -> egui_kittest::Harness<'static, SheetApp> { let mut session = Session::new(); session.new_workbook(); - let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { app.logic(&ui.ctx().clone()); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(6); h @@ -37,7 +37,7 @@ fn dialog_offers_copy_action_and_runs_unique_extract() { h.run_steps(6); h.get_by_label("Copy matching rows elsewhere").click(); h.run_steps(6); - let rect = h.ctx.memory(|m| m.area_rect(egui::Id::new("dialog").with("advancedFilter"))).unwrap(); + let rect = h.ctx.memory(|m| m.area_rect(egui::Id::unique("dialog").with("advancedFilter"))).unwrap(); assert!(rect.width() < 500.0 && rect.height() < 400.0, "dialog must fit its controls: {rect:?}"); assert!( h.state() diff --git a/crates/ui-egui/tests/it/argument_names.rs b/crates/ui-egui/tests/it/argument_names.rs index 1702cf839..725c2edce 100644 --- a/crates/ui-egui/tests/it/argument_names.rs +++ b/crates/ui-egui/tests/it/argument_names.rs @@ -29,7 +29,7 @@ fn editing(text: &str) -> (Context, SheetApp) { let ctx = Context::default(); let mut session = Session::new(); session.new_workbook(); - let mut app = SheetApp::new(session, Default::default()); + let mut app = crate::common::app(session); for t in 0..4 { frame(&ctx, &mut app, f64::from(t) * 0.01, vec![]); } diff --git a/crates/ui-egui/tests/it/autosave_persist.rs b/crates/ui-egui/tests/it/autosave_persist.rs index 50f3d3b05..5f211148f 100644 --- a/crates/ui-egui/tests/it/autosave_persist.rs +++ b/crates/ui-egui/tests/it/autosave_persist.rs @@ -1,11 +1,10 @@ //! #1901: AutoSave is a preference — it must survive a restart like the other UI settings. use gridcraft_engine::Session; -use gridcraft_ui_egui::SheetApp; #[test] fn autosave_survives_a_restart() { - let mut app = SheetApp::new(Session::new(), Default::default()); + let mut app = crate::common::app(Session::new()); assert!(!app.ui.autosave, "off by default"); app.toggle_autosave(); assert!(app.ui.autosave, "the toggle flips the persisted flag"); diff --git a/crates/ui-egui/tests/it/borders.rs b/crates/ui-egui/tests/it/borders.rs index f248fc842..a9f203012 100644 --- a/crates/ui-egui/tests/it/borders.rs +++ b/crates/ui-egui/tests/it/borders.rs @@ -13,12 +13,12 @@ fn harness() -> egui_kittest::Harness<'static, SheetApp> { session.new_workbook(); session.run("range.setValues", json!({"range": "A1", "values": [[1, 2], [3, 4]]})).unwrap(); session.run("selection.set", json!({"range": "A1:B2"})).unwrap(); - let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { app.logic(&ui.ctx().clone()); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(6); h diff --git a/crates/ui-egui/tests/it/cell_editor_caret.rs b/crates/ui-egui/tests/it/cell_editor_caret.rs index 95b0ff6f3..39c7f4cec 100644 --- a/crates/ui-egui/tests/it/cell_editor_caret.rs +++ b/crates/ui-egui/tests/it/cell_editor_caret.rs @@ -21,7 +21,7 @@ fn caret(fill: Option<&str>, dark: bool) -> Color32 { theme::apply(&ctx, dark); let mut session = Session::new(); session.new_workbook(); - let mut app = SheetApp::new(session, Default::default()); + let mut app = crate::common::app(session); if let Some(color) = fill { app.run("home.fillColor", json!({"range": "A1", "color": color})).unwrap(); } diff --git a/crates/ui-egui/tests/it/cell_pictures.rs b/crates/ui-egui/tests/it/cell_pictures.rs index c35bdf054..39838962f 100644 --- a/crates/ui-egui/tests/it/cell_pictures.rs +++ b/crates/ui-egui/tests/it/cell_pictures.rs @@ -28,12 +28,12 @@ fn png() -> Vec { fn harness(services: Services) -> egui_kittest::Harness<'static, SheetApp> { let mut session = Session::new(); session.new_workbook(); - let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { app.logic(&ui.ctx().clone()); app.ui(ui); }, - SheetApp::new(session, services), + crate::common::prepare(SheetApp::new(session, services)), ); h.run_steps(4); h diff --git a/crates/ui-egui/tests/it/chart_resize.rs b/crates/ui-egui/tests/it/chart_resize.rs index fb3e99165..1f811b1ee 100644 --- a/crates/ui-egui/tests/it/chart_resize.rs +++ b/crates/ui-egui/tests/it/chart_resize.rs @@ -17,13 +17,13 @@ fn fixture() -> (Harness, u32) { session.execute("selection.set", json!({"range": "A1:A2"})).unwrap(); let chart = session.execute("insert.chart", json!({"type": "column", "at": "D3", "width": 300, "height": 200})).unwrap(); let id = chart["chart"].as_u64().unwrap() as u32; - let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(4); (h, id) @@ -79,7 +79,7 @@ fn open_size_fields(h: &mut Harness, id: u32) -> egui::Rect { h.state_mut().selected_chart = Some(id); h.state_mut().grid.pane = Some("formatChart".into()); h.run_steps(2); - h.get_by_role_and_label(Role::Button, "Size").click(); + h.get_by_role_and_label(Role::DisclosureTriangle, "Size").click(); h.run_steps(2); h.get_all_by_role(Role::SpinButton).next().unwrap().rect() } @@ -151,7 +151,7 @@ fn right_clicking_a_chart_opens_its_menu_not_the_cells() { let (mut h, id) = fixture(); let inside = corner(&h, id) - vec2(100.0, 80.0); right_click(&mut h, inside, vec2(0.0, 0.0)); - h.get_by_label("Delete").click(); + h.get_by_role_and_label(Role::Button, "Delete").click(); h.run_steps(3); assert!(h.state().session.doc().unwrap().wb.active().unwrap().charts.is_empty()); assert!(h.state().grid.object_menu.is_none()); diff --git a/crates/ui-egui/tests/it/clipboard.rs b/crates/ui-egui/tests/it/clipboard.rs index 08f83b53c..be1983b61 100644 --- a/crates/ui-egui/tests/it/clipboard.rs +++ b/crates/ui-egui/tests/it/clipboard.rs @@ -16,13 +16,13 @@ fn harness(services: Services) -> egui_kittest::Harness<'static, SheetApp> { session.new_workbook(); session.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Score"], ["Ada", 42]]})).unwrap(); session.execute("selection.set", json!({"range": "A1:B2"})).unwrap(); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); app.ui(ui); }, - SheetApp::new(session, services), + crate::common::prepare(SheetApp::new(session, services)), ); h.run_steps(4); h diff --git a/crates/ui-egui/tests/it/comments_pane.rs b/crates/ui-egui/tests/it/comments_pane.rs index fe615084b..475aa9e44 100644 --- a/crates/ui-egui/tests/it/comments_pane.rs +++ b/crates/ui-egui/tests/it/comments_pane.rs @@ -13,12 +13,12 @@ const WIDTH: f32 = 1200.0; fn harness() -> Harness { let mut session = Session::new(); session.new_workbook(); - let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(WIDTH, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_step_dt(1.0 / 60.0).with_size(vec2(WIDTH, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { app.logic(ui.ctx()); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(4); h diff --git a/crates/ui-egui/tests/it/commit_after_pointing.rs b/crates/ui-egui/tests/it/commit_after_pointing.rs index 89e1ccd29..75bf3fbf7 100644 --- a/crates/ui-egui/tests/it/commit_after_pointing.rs +++ b/crates/ui-egui/tests/it/commit_after_pointing.rs @@ -13,12 +13,12 @@ fn pointing_on_second_sheet(range: &str) -> egui_kittest::Harness<'static, Sheet session.execute("home.insertSheet", json!({})).unwrap(); session.execute("sheet.activate", json!({"sheet": 0})).unwrap(); session.execute("selection.set", json!({"range": range})).unwrap(); - let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { app.logic(ui.ctx()); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(4); h.state_mut().begin_edit(Some("=1+2".into()), false); diff --git a/crates/ui-egui/tests/it/committed_text_fields.rs b/crates/ui-egui/tests/it/committed_text_fields.rs index 565a3d15e..0dca99c0d 100644 --- a/crates/ui-egui/tests/it/committed_text_fields.rs +++ b/crates/ui-egui/tests/it/committed_text_fields.rs @@ -9,13 +9,13 @@ use serde_json::json; type Harness = egui_kittest::Harness<'static, SheetApp>; fn harness(session: Session) -> Harness { - let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(4); h @@ -55,7 +55,7 @@ fn typing_a_chart_title_in_format_chart_area_sets_it() { h.state_mut().selected_chart = Some(id); h.state_mut().grid.pane = Some("formatChart".into()); h.run_steps(2); - h.get_by_role_and_label(Role::Button, "Chart Title").click(); + h.get_by_role_and_label(Role::DisclosureTriangle, "Chart Title").click(); h.run_steps(2); // The pane sits on the right; the formula bar and name box are further left. let field = h.get_all_by_role(Role::TextInput).map(|n| n.rect()).find(|r| r.left() > 850.0).unwrap(); diff --git a/crates/ui-egui/tests/it/context_menu_stays_open.rs b/crates/ui-egui/tests/it/context_menu_stays_open.rs index dada039a6..c48c659d0 100644 --- a/crates/ui-egui/tests/it/context_menu_stays_open.rs +++ b/crates/ui-egui/tests/it/context_menu_stays_open.rs @@ -16,12 +16,12 @@ fn harness() -> Harness { fn harness_at(ppp: f32) -> Harness { let mut session = Session::new(); session.new_workbook(); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_pixels_per_point(ppp).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).with_pixels_per_point(ppp).build_ui_state( |ui, app: &mut SheetApp| { app.logic(ui.ctx()); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(4); h diff --git a/crates/ui-egui/tests/it/contextual_ribbon_tab.rs b/crates/ui-egui/tests/it/contextual_ribbon_tab.rs index 5221f2d60..be5661d30 100644 --- a/crates/ui-egui/tests/it/contextual_ribbon_tab.rs +++ b/crates/ui-egui/tests/it/contextual_ribbon_tab.rs @@ -10,13 +10,13 @@ use serde_json::json; type Harness = egui_kittest::Harness<'static, SheetApp>; fn harness(session: Session) -> Harness { - let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(4); h @@ -32,9 +32,9 @@ fn session_with_table() -> Session { } fn app_restored_on(tab: &str, session: Session) -> Harness { - let mut app = SheetApp::new(session, Default::default()); + let mut app = crate::common::app(session); app.ui.ribbon_tab = tab.into(); - let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); diff --git a/crates/ui-egui/tests/it/control_timeout.rs b/crates/ui-egui/tests/it/control_timeout.rs index f4e3c947d..c9fca5e05 100644 --- a/crates/ui-egui/tests/it/control_timeout.rs +++ b/crates/ui-egui/tests/it/control_timeout.rs @@ -8,7 +8,7 @@ use serde_json::json; fn app() -> (SheetApp, egui::Context) { let mut s = Session::new(); s.new_workbook(); - (SheetApp::new(s, Default::default()), egui::Context::default()) + (crate::common::app(s), egui::Context::default()) } fn a1(app: &SheetApp) -> String { diff --git a/crates/ui-egui/tests/it/dialog_button_row.rs b/crates/ui-egui/tests/it/dialog_button_row.rs index 7a7520484..e0242bf6d 100644 --- a/crates/ui-egui/tests/it/dialog_button_row.rs +++ b/crates/ui-egui/tests/it/dialog_button_row.rs @@ -10,13 +10,13 @@ use serde_json::json; fn ok_and_cancel_sit_under_the_last_field() { let mut s = Session::new(); s.new_workbook(); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 1000.0)).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 1000.0)).build_ui_state( |ui, app: &mut SheetApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); app.ui(ui); }, - SheetApp::new(s, Default::default()), + crate::common::app(s), ); h.state_mut().open_dialog("cfQuick", json!({"type": "cellIs", "operator": "greaterThan", "title": "Greater Than…"})); h.run_steps(4); diff --git a/crates/ui-egui/tests/it/dialog_confirm.rs b/crates/ui-egui/tests/it/dialog_confirm.rs index 8ec98b040..1231798ce 100644 --- a/crates/ui-egui/tests/it/dialog_confirm.rs +++ b/crates/ui-egui/tests/it/dialog_confirm.rs @@ -10,12 +10,12 @@ use serde_json::{Value as Json, json}; fn harness() -> egui_kittest::Harness<'static, SheetApp> { let mut session = Session::new(); session.new_workbook(); - let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { app.logic(&ui.ctx().clone()); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(6); h diff --git a/crates/ui-egui/tests/it/dialog_focus.rs b/crates/ui-egui/tests/it/dialog_focus.rs index ee0896f8f..7161adb21 100644 --- a/crates/ui-egui/tests/it/dialog_focus.rs +++ b/crates/ui-egui/tests/it/dialog_focus.rs @@ -11,12 +11,12 @@ fn harness() -> egui_kittest::Harness<'static, SheetApp> { let mut session = Session::new(); session.new_workbook(); session.execute("selection.set", json!({"cell": "C3"})).unwrap(); - let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { app.logic(&ui.ctx().clone()); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(6); h diff --git a/crates/ui-egui/tests/it/dialog_move.rs b/crates/ui-egui/tests/it/dialog_move.rs index 7548342c7..68b232844 100644 --- a/crates/ui-egui/tests/it/dialog_move.rs +++ b/crates/ui-egui/tests/it/dialog_move.rs @@ -10,19 +10,19 @@ type Harness = egui_kittest::Harness<'static, SheetApp>; fn harness() -> Harness { let mut session = Session::new(); session.new_workbook(); - let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { app.logic(&ui.ctx().clone()); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(6); h } fn dialog_rect(h: &Harness, name: &str) -> egui::Rect { - h.ctx.memory(|m| m.area_rect(egui::Id::new("dialog").with(name))).unwrap() + h.ctx.memory(|m| m.area_rect(egui::Id::unique("dialog").with(name))).unwrap() } fn button(h: &mut Harness, pos: egui::Pos2, pressed: bool) { diff --git a/crates/ui-egui/tests/it/drag_cursor.rs b/crates/ui-egui/tests/it/drag_cursor.rs index 3198f044f..321e25355 100644 --- a/crates/ui-egui/tests/it/drag_cursor.rs +++ b/crates/ui-egui/tests/it/drag_cursor.rs @@ -13,12 +13,12 @@ fn harness() -> Harness { let mut session = Session::new(); session.new_workbook(); session.execute("range.setValues", json!({"range": "A1", "values": [["A", "B", "C", "D", "E"], [1, 2]]})).unwrap(); - let mut h = Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { app.logic(ui.ctx()); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(4); h diff --git a/crates/ui-egui/tests/it/file_menu.rs b/crates/ui-egui/tests/it/file_menu.rs index 092876b8d..78f7d0fb4 100644 --- a/crates/ui-egui/tests/it/file_menu.rs +++ b/crates/ui-egui/tests/it/file_menu.rs @@ -10,13 +10,13 @@ use gridcraft_ui_egui::{Services, SheetApp}; fn harness(services: Services) -> egui_kittest::Harness<'static, SheetApp> { let mut session = Session::new(); session.new_workbook(); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); app.ui(ui); }, - SheetApp::new(session, services), + crate::common::prepare(SheetApp::new(session, services)), ); h.run_steps(4); h @@ -115,12 +115,15 @@ fn file_options_opens_the_options_page_and_pages_are_reachable_by_command() { let r = h.state_mut().run("file.options", serde_json::json!({})).unwrap(); assert_eq!(r["calculation"]["mode"], "automatic"); h.run_steps(3); + h.check_accessibility(); assert_eq!(page(&h), Some("options")); click(&mut h, "Info"); + h.check_accessibility(); assert_eq!(page(&h), Some("info")); for id in ["print", "export", "saveAs", "new", "open", "home"] { h.state_mut().run("ui.backstage", serde_json::json!({"page": id})).unwrap(); h.run_steps(2); + h.check_accessibility(); assert_eq!(page(&h), Some(id)); } assert!(h.state_mut().run("ui.backstage", serde_json::json!({"page": "feedback"})).is_err()); @@ -130,6 +133,7 @@ fn file_options_opens_the_options_page_and_pages_are_reachable_by_command() { h.state_mut().run("ui.backstage", serde_json::json!({})).unwrap(); h.run_steps(2); click(&mut h, "Back"); + h.check_accessibility(); assert!(h.state().backstage.is_none()); } diff --git a/crates/ui-egui/tests/it/fill_handle_double_click.rs b/crates/ui-egui/tests/it/fill_handle_double_click.rs index 88b87376f..f0b46e622 100644 --- a/crates/ui-egui/tests/it/fill_handle_double_click.rs +++ b/crates/ui-egui/tests/it/fill_handle_double_click.rs @@ -13,12 +13,12 @@ fn harness() -> egui_kittest::Harness<'static, SheetApp> { session.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3], [4], [5], [6]]})).unwrap(); session.execute("range.setValues", json!({"range": "B1", "values": [[10], [20], [30]]})).unwrap(); session.execute("selection.set", json!({"range": "B1:B3"})).unwrap(); - let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { app.logic(ui.ctx()); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(4); h diff --git a/crates/ui-egui/tests/it/fill_handle_right_drag.rs b/crates/ui-egui/tests/it/fill_handle_right_drag.rs index 9562c1d63..f114393cb 100644 --- a/crates/ui-egui/tests/it/fill_handle_right_drag.rs +++ b/crates/ui-egui/tests/it/fill_handle_right_drag.rs @@ -14,12 +14,12 @@ fn harness(setup: impl FnOnce(&mut Session)) -> Harness { let mut session = Session::new(); session.new_workbook(); setup(&mut session); - let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { app.logic(ui.ctx()); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(4); h diff --git a/crates/ui-egui/tests/it/fill_options_button.rs b/crates/ui-egui/tests/it/fill_options_button.rs index d735f846a..90cda6dbd 100644 --- a/crates/ui-egui/tests/it/fill_options_button.rs +++ b/crates/ui-egui/tests/it/fill_options_button.rs @@ -14,12 +14,12 @@ fn harness(setup: impl FnOnce(&mut Session)) -> Harness { let mut session = Session::new(); session.new_workbook(); setup(&mut session); - let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { app.logic(ui.ctx()); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(4); h diff --git a/crates/ui-egui/tests/it/filter_list_scroll.rs b/crates/ui-egui/tests/it/filter_list_scroll.rs index 35406fa89..e58922f00 100644 --- a/crates/ui-egui/tests/it/filter_list_scroll.rs +++ b/crates/ui-egui/tests/it/filter_list_scroll.rs @@ -18,12 +18,12 @@ fn filter_menu_on_a_long_list() -> Harness { values.extend((1..=80).map(|i| vec![json!(format!("ITEM-{i:03}"))])); session.execute("range.setValues", json!({"range": "A1", "values": values})).unwrap(); session.execute("data.filter", json!({"range": "A1:A81"})).unwrap(); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { app.logic(ui.ctx()); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(4); let button = { @@ -51,6 +51,7 @@ fn filter_list_scrolls_with_the_pointer_right_of_the_values() { h.run_steps(2); h.input_mut().events.push(Event::MouseWheel { unit: MouseWheelUnit::Line, + source: egui::MouseWheelSource::Unknown, delta: vec2(0.0, -3.0), phase: TouchPhase::Move, modifiers: Modifiers::NONE, diff --git a/crates/ui-egui/tests/it/filter_menu_enter.rs b/crates/ui-egui/tests/it/filter_menu_enter.rs index bd45a2cc1..97ba8d248 100644 --- a/crates/ui-egui/tests/it/filter_menu_enter.rs +++ b/crates/ui-egui/tests/it/filter_menu_enter.rs @@ -17,12 +17,12 @@ fn filter_menu_open() -> Harness { session.execute("range.setValues", json!({"range": "A1", "values": [["Fruit"], ["Apple"], ["Banana"], ["Cherry"]]})).unwrap(); session.execute("data.filter", json!({"range": "A1:A4"})).unwrap(); session.execute("selection.set", json!({"cell": "A1"})).unwrap(); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { app.logic(ui.ctx()); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(4); key(&mut h, Key::ArrowDown, Modifiers::ALT); diff --git a/crates/ui-egui/tests/it/find_dialog_focus.rs b/crates/ui-egui/tests/it/find_dialog_focus.rs index 08cee3fc8..2c23e12ea 100644 --- a/crates/ui-egui/tests/it/find_dialog_focus.rs +++ b/crates/ui-egui/tests/it/find_dialog_focus.rs @@ -13,13 +13,13 @@ const CTRL: Modifiers = Modifiers { alt: false, ctrl: true, shift: false, mac_cm fn harness() -> Harness { let mut session = Session::new(); session.new_workbook(); - let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(6); h diff --git a/crates/ui-egui/tests/it/floating_pictures.rs b/crates/ui-egui/tests/it/floating_pictures.rs index f14d40d85..b9bd52efc 100644 --- a/crates/ui-egui/tests/it/floating_pictures.rs +++ b/crates/ui-egui/tests/it/floating_pictures.rs @@ -17,12 +17,12 @@ fn png(color: [u8; 4]) -> Vec { fn harness() -> egui_kittest::Harness<'static, SheetApp> { let mut session = Session::new(); session.new_workbook(); - let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { app.logic(&ui.ctx().clone()); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(4); h diff --git a/crates/ui-egui/tests/it/font_size_box.rs b/crates/ui-egui/tests/it/font_size_box.rs index 0337543ad..057aa26ff 100644 --- a/crates/ui-egui/tests/it/font_size_box.rs +++ b/crates/ui-egui/tests/it/font_size_box.rs @@ -13,12 +13,12 @@ fn harness() -> Harness { let mut s = Session::new(); s.new_workbook(); s.execute("selection.set", json!({"range": "A1:B2"})).unwrap(); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { app.logic(ui.ctx()); app.ui(ui); }, - SheetApp::new(s, Default::default()), + crate::common::app(s), ); h.run_steps(4); h @@ -107,7 +107,7 @@ fn escape_cancels_the_typed_size() { #[test] fn the_arrow_opens_the_plain_list_of_sizes() { let mut h = harness(); - h.get_by_label("Font Size").click(); + h.get_by_role_and_label(Role::Button, "Font Size").click(); h.run_steps(3); assert!(h.query_by_label("Type to filter…").is_none(), "the size list has no filter field"); // The grid's row headers carry the same numbers as labels: pick the list's button. @@ -116,7 +116,7 @@ fn the_arrow_opens_the_plain_list_of_sizes() { assert_eq!(size(&h, "B2"), 16.0, "clicking a size applies it"); let mut h = harness(); - h.get_by_label("Font Size").click(); + h.get_by_role_and_label(Role::Button, "Font Size").click(); h.run_steps(3); key(&mut h, Key::ArrowDown); key(&mut h, Key::Enter); diff --git a/crates/ui-egui/tests/it/format_painter.rs b/crates/ui-egui/tests/it/format_painter.rs index 54a78930a..62e34c33b 100644 --- a/crates/ui-egui/tests/it/format_painter.rs +++ b/crates/ui-egui/tests/it/format_painter.rs @@ -18,13 +18,13 @@ fn harness() -> Harness { session.execute("home.bold", json!({"range": "A1", "on": true})).unwrap(); session.execute("home.fillColor", json!({"range": "A1", "color": "#FFFF00"})).unwrap(); session.execute("selection.set", json!({"cell": "A1"})).unwrap(); - let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(4); h diff --git a/crates/ui-egui/tests/it/formula_locale.rs b/crates/ui-egui/tests/it/formula_locale.rs index 98ab70d57..9be35d6e8 100644 --- a/crates/ui-egui/tests/it/formula_locale.rs +++ b/crates/ui-egui/tests/it/formula_locale.rs @@ -9,7 +9,7 @@ use serde_json::json; fn app() -> SheetApp { let mut session = Session::new(); session.new_workbook(); - let mut app = SheetApp::new(session, Default::default()); + let mut app = crate::common::app(session); app.ui.language = Language::En; app } @@ -81,7 +81,7 @@ fn localized_argument_hints_ignore_decimal_commas_arrays_and_nested_calls() { fn localized_autocomplete_is_rendered_and_inserts_the_selected_name() { let mut app = app(); app.run("app.language.set", json!({"language":"es"})).unwrap(); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); @@ -158,7 +158,7 @@ fn canonical_engine_templates_enter_the_selected_formula_language() { if let Ok(path) = std::env::var("GRIDCRAFT_LOCALE_SCREENSHOT") { app.ui.ribbon_tab = "Formulas".into(); app.run("formulas.insertFunction", json!({"name":"SUM"})).unwrap(); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); diff --git a/crates/ui-egui/tests/it/formula_point_headers.rs b/crates/ui-egui/tests/it/formula_point_headers.rs index 97bd790d2..61c4192fa 100644 --- a/crates/ui-egui/tests/it/formula_point_headers.rs +++ b/crates/ui-egui/tests/it/formula_point_headers.rs @@ -10,12 +10,12 @@ fn harness() -> egui_kittest::Harness<'static, SheetApp> { let mut session = Session::new(); session.new_workbook(); session.execute("selection.set", json!({"range": "F8"})).unwrap(); - let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { app.logic(ui.ctx()); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(4); h.state_mut().begin_edit(Some("=SUM(".into()), false); diff --git a/crates/ui-egui/tests/it/formula_popup.rs b/crates/ui-egui/tests/it/formula_popup.rs index 94886144b..ec726093c 100644 --- a/crates/ui-egui/tests/it/formula_popup.rs +++ b/crates/ui-egui/tests/it/formula_popup.rs @@ -21,7 +21,7 @@ fn frames(ctx: &Context, app: &mut SheetApp) { } fn visible(ctx: &Context, editor: &str) -> bool { - ctx.memory(|m| m.areas().is_visible(&LayerId::new(Order::Tooltip, Id::new(editor).with("ac")))) + ctx.memory(|m| m.areas().is_visible(&LayerId::new(Order::Tooltip, Id::unique(editor).with("ac")))) } #[test] @@ -30,7 +30,7 @@ fn cell_formula_helpers_hide_when_scrolled_out_and_return_with_the_editor() { let ctx = Context::default(); let mut session = Session::new(); session.new_workbook(); - let mut app = SheetApp::new(session, Default::default()); + let mut app = crate::common::app(session); frames(&ctx, &mut app); app.begin_edit(Some(text.into()), false); if text == "=SU" { @@ -48,12 +48,50 @@ fn cell_formula_helpers_hide_when_scrolled_out_and_return_with_the_editor() { } } +/// A wrapped in-cell edit in a hidden row/column must not paint (or show its helper) over the +/// visible line; the edit survives and comes back when the row and column are shown again. +#[test] +fn hidden_row_and_column_hide_the_wrapped_editor_until_unhidden() { + let ctx = Context::default(); + let mut session = Session::new(); + session.new_workbook(); + session.run("home.wrapText", serde_json::json!({"range": "A2", "on": true})).unwrap(); + session.run("home.hideRows", serde_json::json!({"rows": "2:2"})).unwrap(); + session.run("home.hideColumns", serde_json::json!({"cols": "A:A"})).unwrap(); + session.run("selection.set", serde_json::json!({"range": "A2"})).unwrap(); + let mut app = crate::common::app(session); + frames(&ctx, &mut app); + app.begin_edit(Some("=SUM(".into()), false); + frames(&ctx, &mut app); + assert!(!visible(&ctx, "gridcraft.cell_editor"), "a hidden cell must not show its helper"); + assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(", "hiding the cell preserves the edit"); + let cells = app.grid.cells_rect.expect("the grid drew"); + assert!(!paints_visible_text(&frame(&ctx, &mut app), "=SUM(", cells), "a hidden cell must not paint its editor"); + + app.session.run("home.unhideRows", serde_json::json!({"rows": "2:2"})).unwrap(); + app.session.run("home.unhideColumns", serde_json::json!({"cols": "A:A"})).unwrap(); + frames(&ctx, &mut app); + assert!(visible(&ctx, "gridcraft.cell_editor"), "unhiding the cell restores its helper"); + assert!(paints_visible_text(&frame(&ctx, &mut app), "=SUM(", cells), "unhiding the cell restores its editor"); + assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(", "unhiding the cell preserves the edit"); +} + +/// Whether `text` is painted with a non-empty clip that reaches the worksheet (a hidden cell's +/// editor may still be in the shape list, clipped to nothing; the formula bar shows the same text +/// above the grid and must not satisfy this check). +fn paints_visible_text(output: &egui::FullOutput, text: &str, cells: Rect) -> bool { + output + .shapes + .iter() + .any(|s| matches!(&s.shape, egui::Shape::Text(t) if t.galley.text() == text && s.clip_rect.intersect(t.visual_bounding_rect()).intersect(cells).is_positive())) +} + #[test] fn formula_bar_hint_remains_visible_when_the_worksheet_scrolls() { let ctx = Context::default(); let mut session = Session::new(); session.new_workbook(); - let mut app = SheetApp::new(session, Default::default()); + let mut app = crate::common::app(session); frames(&ctx, &mut app); app.begin_edit(Some("=SUM(".into()), true); frames(&ctx, &mut app); @@ -68,7 +106,7 @@ fn cell_argument_hint_is_clipped_to_the_worksheet() { let ctx = Context::default(); let mut session = Session::new(); session.new_workbook(); - let mut app = SheetApp::new(session, Default::default()); + let mut app = crate::common::app(session); frames(&ctx, &mut app); app.begin_edit(Some("=SUM(".into()), false); frames(&ctx, &mut app); @@ -85,7 +123,7 @@ fn scrolled_out_cell_editor_still_handles_escape() { let ctx = Context::default(); let mut session = Session::new(); session.new_workbook(); - let mut app = SheetApp::new(session, Default::default()); + let mut app = crate::common::app(session); frames(&ctx, &mut app); app.begin_edit(Some("=SUM(".into()), false); frames(&ctx, &mut app); @@ -116,7 +154,7 @@ fn cell_editor_and_helper_stay_below_the_frozen_top_row() { session.new_workbook(); session.run("view.freezeTopRow", serde_json::json!({})).unwrap(); session.run("selection.set", serde_json::json!({"range": "B5"})).unwrap(); - let mut app = SheetApp::new(session, Default::default()); + let mut app = crate::common::app(session); frames(&ctx, &mut app); let sh = app.session.active().unwrap().wb.active().unwrap(); let frozen_h = sh.row_height(0); @@ -136,6 +174,18 @@ fn cell_editor_and_helper_stay_below_the_frozen_top_row() { assert!(editor.clip_rect.top() >= frozen_bottom, "editor must be clipped below the frozen row: {:?}", editor.clip_rect); app.view_mut().unwrap().scroll.y = scroll; frames(&ctx, &mut app); + let geo = app.grid.geo.as_ref().expect("grid drew"); + let sh = app.session.active().unwrap().wb.active().unwrap(); + let edited = gridcraft_engine::core::CellRef::new(4, 1); + assert!( + geo.hidden_under_frozen(sh, edited), + "B5 must be scrolled fully under the frozen row: requested {scroll}, kept {:?}, geo scroll {:?}, cells {:?}, frozen_h {}, cell {:?}", + app.view().scroll, + geo.scroll, + geo.cells, + geo.frozen_h, + geo.cell_rect(sh, edited) + ); assert!(!visible(&ctx, "gridcraft.cell_editor"), "cell hidden under the frozen row must hide its helper"); assert_eq!(app.editor.as_ref().unwrap().text, "=SUM("); app.view_mut().unwrap().scroll.y = 0.0; @@ -149,7 +199,7 @@ fn argument_hint_shows_the_function_description() { let ctx = Context::default(); let mut session = Session::new(); session.new_workbook(); - let mut app = SheetApp::new(session, Default::default()); + let mut app = crate::common::app(session); frames(&ctx, &mut app); app.begin_edit(Some("=DATEDIF(A1,B1,".into()), false); frames(&ctx, &mut app); diff --git a/crates/ui-egui/tests/it/freeze_handles.rs b/crates/ui-egui/tests/it/freeze_handles.rs index 877c4cf3d..71a3b54a4 100644 --- a/crates/ui-egui/tests/it/freeze_handles.rs +++ b/crates/ui-egui/tests/it/freeze_handles.rs @@ -11,12 +11,12 @@ type Harness = egui_kittest::Harness<'static, SheetApp>; fn harness() -> Harness { let mut session = Session::new(); session.new_workbook(); - let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { app.logic(ui.ctx()); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(4); h diff --git a/crates/ui-egui/tests/it/header_autofit.rs b/crates/ui-egui/tests/it/header_autofit.rs index 5e123809f..d02d11683 100644 --- a/crates/ui-egui/tests/it/header_autofit.rs +++ b/crates/ui-egui/tests/it/header_autofit.rs @@ -21,12 +21,12 @@ fn harness(selection: &str) -> egui_kittest::Harness<'static, SheetApp> { session.execute("home.columnWidth", json!({"cols": "A:D", "width": 100})).unwrap(); session.execute("home.rowHeight", json!({"rows": "1:4", "height": 60})).unwrap(); session.execute("selection.set", json!({"range": selection})).unwrap(); - let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { app.logic(ui.ctx()); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(4); h.state_mut().session.active_mut().unwrap().undo.clear(); diff --git a/crates/ui-egui/tests/it/header_column_drag.rs b/crates/ui-egui/tests/it/header_column_drag.rs index d28f9ae33..422f0fdb9 100644 --- a/crates/ui-egui/tests/it/header_column_drag.rs +++ b/crates/ui-egui/tests/it/header_column_drag.rs @@ -10,12 +10,12 @@ fn harness() -> egui_kittest::Harness<'static, SheetApp> { session.new_workbook(); session.execute("range.setValues", json!({"range": "A1", "values": [["A", "B", "C", "D", "E"]]})).unwrap(); session.execute("selection.set", json!({"range": "B:C"})).unwrap(); - let mut harness = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( + let mut harness = crate::common::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { app.logic(ui.ctx()); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); harness.run_steps(4); harness.state_mut().session.active_mut().unwrap().undo.clear(); diff --git a/crates/ui-egui/tests/it/host_chrome.rs b/crates/ui-egui/tests/it/host_chrome.rs index 627e24025..f1105bf16 100644 --- a/crates/ui-egui/tests/it/host_chrome.rs +++ b/crates/ui-egui/tests/it/host_chrome.rs @@ -7,9 +7,9 @@ use gridcraft_ui_egui::{Chrome, Services, SheetApp, backstage::Page}; fn harness(chrome: Chrome) -> egui_kittest::Harness<'static, SheetApp> { let mut session = Session::new(); session.new_workbook(); - let mut app = SheetApp::new(session, Services::default()); + let mut app = crate::common::prepare(SheetApp::new(session, Services::default())); app.chrome = chrome; - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); diff --git a/crates/ui-egui/tests/it/hyperlink_ctrl_click.rs b/crates/ui-egui/tests/it/hyperlink_ctrl_click.rs index a61350d3d..56bfc1b77 100644 --- a/crates/ui-egui/tests/it/hyperlink_ctrl_click.rs +++ b/crates/ui-egui/tests/it/hyperlink_ctrl_click.rs @@ -15,12 +15,12 @@ fn harness() -> Harness { session.execute("insert.link", json!({"cell": "B2", "target": "https://example.org/a"})).unwrap(); session.execute("cell.set", json!({"cell": "B4", "input": "jump"})).unwrap(); session.execute("insert.link", json!({"cell": "B4", "target": "Sheet1!F9"})).unwrap(); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { app.logic(ui.ctx()); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(4); h diff --git a/crates/ui-egui/tests/it/in_cell_editor_size.rs b/crates/ui-egui/tests/it/in_cell_editor_size.rs index 4d82cd711..0b266c4e4 100644 --- a/crates/ui-egui/tests/it/in_cell_editor_size.rs +++ b/crates/ui-egui/tests/it/in_cell_editor_size.rs @@ -11,12 +11,12 @@ type Harness = egui_kittest::Harness<'static, SheetApp>; fn harness() -> Harness { let mut session = Session::new(); session.new_workbook(); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { app.logic(ui.ctx()); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(4); h diff --git a/crates/ui-egui/tests/it/in_cell_editor_wrap.rs b/crates/ui-egui/tests/it/in_cell_editor_wrap.rs index 76fd9a749..f3592b5fa 100644 --- a/crates/ui-egui/tests/it/in_cell_editor_wrap.rs +++ b/crates/ui-egui/tests/it/in_cell_editor_wrap.rs @@ -27,11 +27,11 @@ fn editor_rect(wrap: bool) -> Rect { session.new_workbook(); session.execute("home.columnWidth", json!({"cols": "A:A", "chars": 12})).unwrap(); session.execute("home.wrapText", json!({"range": "A1", "on": wrap})).unwrap(); - let mut app = SheetApp::new(session, Default::default()); + let mut app = crate::common::app(session); frames(&ctx, &mut app); app.begin_edit(Some(TEXT.into()), false); frames(&ctx, &mut app); - ctx.read_response(Id::new("gridcraft.cell_editor")).expect("the in-cell editor is shown").rect + ctx.read_response(Id::unique("gridcraft.cell_editor")).expect("the in-cell editor is shown").rect } #[test] @@ -50,11 +50,11 @@ fn a_short_wrap_text_edit_stays_one_line_tall() { let mut session = Session::new(); session.new_workbook(); session.execute("home.wrapText", json!({"range": "A1", "on": true})).unwrap(); - let mut app = SheetApp::new(session, Default::default()); + let mut app = crate::common::app(session); frames(&ctx, &mut app); app.begin_edit(Some("short".into()), false); frames(&ctx, &mut app); - let field = ctx.read_response(Id::new("gridcraft.cell_editor")).expect("the in-cell editor is shown").rect; + let field = ctx.read_response(Id::unique("gridcraft.cell_editor")).expect("the in-cell editor is shown").rect; assert!(field.height() < 30.0, "a one-line edit doesn't reach over the rows below: {field:?}"); } @@ -68,11 +68,11 @@ fn the_editor_measures_the_text_at_the_fields_wrap_width() { session.new_workbook(); session.execute("home.columnWidth", json!({"cols": "A:A", "chars": 12})).unwrap(); session.execute("home.wrapText", json!({"range": "A1", "on": true})).unwrap(); - let mut app = SheetApp::new(session, Default::default()); + let mut app = crate::common::app(session); frames(&ctx, &mut app); app.begin_edit(Some(TEXT.into()), false); frames(&ctx, &mut app); - let field = ctx.read_response(Id::new("gridcraft.cell_editor")).expect("the in-cell editor is shown").rect; + let field = ctx.read_response(Id::unique("gridcraft.cell_editor")).expect("the in-cell editor is shown").rect; let sheet = app.session.active().unwrap().wb.active().unwrap().clone(); let st = app.session.active().unwrap().wb.styles.get(sheet.style_id(gridcraft_engine::cell(0, 0))).clone(); // The grid draws at its zoom: the cell is the field plus the 2 px inset a side, the font scales. @@ -93,14 +93,21 @@ fn an_unwrapped_multiline_edit_shows_every_line() { let ctx = Context::default(); let mut session = Session::new(); session.new_workbook(); - let mut app = SheetApp::new(session, Default::default()); + let mut app = crate::common::app(session); frames(&ctx, &mut app); app.begin_edit(Some(text.into()), false); frames(&ctx, &mut app); - ctx.read_response(Id::new("gridcraft.cell_editor")).expect("the in-cell editor is shown").rect + ctx.read_response(Id::unique("gridcraft.cell_editor")).expect("the in-cell editor is shown").rect }; let one = field("one").height(); + let two = field("one\ntwo").height(); let three = field("one\ntwo\nthree").height(); - assert!(three > 2.5 * one, "three lines are shown: {three} vs one line {one}"); - assert!(three < 3.5 * one, "and no more rows are reserved: {three} vs one line {one}"); + let four = field("one\ntwo\nthree\nfour").height(); + // egui 0.37 gives a one-line TextEdit a widget-height floor, so compare the increments from + // two lines up rather than a fraction of the one-line field. + let line = three - two; + assert!(line > 0.0, "a third line adds height: {two} then {three}"); + assert!((four - three - line).abs() < 1.0, "each extra line takes the same height: {two}, {three}, {four}"); + assert!(three > one + line / 2.0, "the third line is taller than the one-line floor: {one} then {three}"); + assert!(four < three + line + 1.0, "and no more rows are reserved: {three} then {four}"); } diff --git a/crates/ui-egui/tests/it/insert_copied_cells.rs b/crates/ui-egui/tests/it/insert_copied_cells.rs index d6ca79f2d..ccdcb3fdd 100644 --- a/crates/ui-egui/tests/it/insert_copied_cells.rs +++ b/crates/ui-egui/tests/it/insert_copied_cells.rs @@ -13,12 +13,12 @@ fn harness() -> Harness { let mut session = Session::new(); session.new_workbook(); session.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3], [4]]})).unwrap(); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { app.logic(ui.ctx()); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(4); h diff --git a/crates/ui-egui/tests/it/interaction.rs b/crates/ui-egui/tests/it/interaction.rs index 94fefe6d3..faa69987a 100644 --- a/crates/ui-egui/tests/it/interaction.rs +++ b/crates/ui-egui/tests/it/interaction.rs @@ -9,13 +9,13 @@ use gridcraft_ui_egui::{SheetApp, grid::Geo}; use serde_json::json; fn harness(session: Session) -> egui_kittest::Harness<'static, SheetApp> { - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(4); h @@ -215,7 +215,7 @@ fn name_box_enter_goes_to_the_typed_cell_not_the_one_below() { let mut h = harness(blank()); h.state_mut().session.run("selection.set", json!({"cell": "C5"})).unwrap(); h.run_steps(2); - h.ctx.memory_mut(|m| m.request_focus(egui::Id::new("gridcraft.name_box"))); + h.ctx.memory_mut(|m| m.request_focus(egui::Id::unique("gridcraft.name_box"))); h.run_steps(2); h.input_mut().events.push(Event::Key { key: Key::A, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }); h.input_mut().events.push(Event::Text("A1".into())); @@ -247,9 +247,11 @@ fn typing_enters_values_and_moves_down() { let mut h = harness(blank()); text(&mut h, "42"); assert!(h.state().editor.is_some(), "typing starts editing"); + h.check_accessibility(); key(&mut h, Key::Enter, Modifiers::NONE); assert_eq!(value(&h, "A1"), Value::Number(42.0)); text(&mut h, "=A1*2"); + h.check_accessibility(); key(&mut h, Key::Tab, Modifiers::NONE); assert_eq!(value(&h, "A2"), Value::Number(84.0)); let active = h.state().session.active().map(|d| d.selection.active.a1()); @@ -395,9 +397,11 @@ fn renders_every_ribbon_tab_without_panicking() { for tab in ["Home", "Insert", "Draw", "Page Layout", "Formulas", "Data", "Review", "View", "Automate"] { h.state_mut().ui.ribbon_tab = tab.into(); h.run_steps(2); + h.check_accessibility(); } h.state_mut().ui.dark = true; h.run_steps(3); + h.check_accessibility(); } #[test] @@ -415,9 +419,17 @@ fn language_switch_renders_the_ribbon_and_persists() { for id in ["app.language.set", "app.language.english", "app.language.japanese"] { assert!(ids.contains(&id), "engine.commands lists {id}"); } - for language in Language::ALL { + // A minimal CI image has egui's bundled faces but no Japanese/Korean/Chinese face. Those + // catalogs are exercised when the pinned craft-fonts input is present; skipping them here is + // the documented no-font gap, not a missing-glyph or accessibility opt-out. + let languages: Vec = Language::ALL + .into_iter() + .filter(|l| gridcraft_ui_egui::theme::craft_fonts_loaded() || !matches!(l, Language::Ja | Language::Ko | Language::Zh)) + .collect(); + for language in languages { h.state_mut().run("app.language.set", json!({"language": language.code()})).unwrap(); - h.run_steps(2); // every locale renders the ribbon without panicking + h.run_steps(2); // every available locale renders the ribbon without panicking + h.check_accessibility(); assert_eq!(h.state().ui.language, language); let restored: gridcraft_ui_egui::UiState = serde_json::from_str(&serde_json::to_string(&h.state().ui).unwrap()).unwrap(); assert_eq!(restored.language, language); @@ -434,6 +446,7 @@ fn pivot_fields_pane_follows_the_interface_language() { h.state_mut().run("insert.pivotTable", json!({"source": "Sheet1!$A$1:$B$3", "rows": ["Region"]})).unwrap(); h.state_mut().run("app.language.set", json!({"language": "es"})).unwrap(); h.run_steps(4); + h.check_accessibility(); let es = |english| Language::Es.tr(english); for english in ["PivotTable Fields", "Rows", "Values", "Refresh", "Calculated Field…"] { assert!(h.query_by_label(es(english)).is_some(), "the pane shows `{}`", es(english)); @@ -443,6 +456,7 @@ fn pivot_fields_pane_follows_the_interface_language() { } h.get_by_label("Region ▾").click(); h.run_steps(2); + h.check_accessibility(); for english in ["Move to Column Labels", "Group by Years", "Remove Field"] { assert!(h.query_by_label(es(english)).is_some(), "the field menu shows `{}`", es(english)); } @@ -619,6 +633,7 @@ fn dialogs_open_and_close() { ] { h.state_mut().open_dialog(name, json!({})); h.run_steps(2); + h.check_accessibility(); key(&mut h, Key::Escape, Modifiers::NONE); h.state_mut().dialog = None; h.state_mut().message = None; diff --git a/crates/ui-egui/tests/it/interface_scale.rs b/crates/ui-egui/tests/it/interface_scale.rs index b885bf8ff..1ecd36097 100644 --- a/crates/ui-egui/tests/it/interface_scale.rs +++ b/crates/ui-egui/tests/it/interface_scale.rs @@ -8,12 +8,12 @@ use serde_json::json; fn interface_size_applies_persists_and_clamps() { let mut session = Session::new(); session.new_workbook(); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { app.logic(ui.ctx()); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(2); assert_eq!(h.ctx.zoom_factor(), 1.0); diff --git a/crates/ui-egui/tests/it/keyboard_shortcuts.rs b/crates/ui-egui/tests/it/keyboard_shortcuts.rs index 4739bd147..aa50070bf 100644 --- a/crates/ui-egui/tests/it/keyboard_shortcuts.rs +++ b/crates/ui-egui/tests/it/keyboard_shortcuts.rs @@ -11,13 +11,13 @@ use serde_json::json; type Harness = egui_kittest::Harness<'static, SheetApp>; fn harness(session: Session) -> Harness { - let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(4); h @@ -172,7 +172,7 @@ fn alt_down_opens_autofilter_headers_without_changing_the_workbook() { // Opening focuses the existing search box; typing must not edit the header. h.input_mut().events.push(Event::Text("1".into())); h.run_steps(2); - assert_eq!(h.ctx.data(|data| data.get_temp::(egui::Id::new(("filter_search", column)))).as_deref(), Some("1")); + assert_eq!(h.ctx.data(|data| data.get_temp::(egui::Id::unique(("filter_search", column)))).as_deref(), Some("1")); assert!(h.state().editor.is_none()); assert_eq!(h.state().session.active().unwrap().wb, before); key(&mut h, Key::Escape, Modifiers::NONE); diff --git a/crates/ui-egui/tests/it/keytips_generated.rs b/crates/ui-egui/tests/it/keytips_generated.rs index fe70a5c9a..cc20035af 100644 --- a/crates/ui-egui/tests/it/keytips_generated.rs +++ b/crates/ui-egui/tests/it/keytips_generated.rs @@ -14,12 +14,12 @@ use gridcraft_ui_egui::SheetApp; type Harness = egui_kittest::Harness<'static, SheetApp>; fn harness(session: Session) -> Harness { - let mut h = egui_kittest::Harness::builder().with_os(OperatingSystem::Nix).with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_os(OperatingSystem::Nix).with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { app.logic(ui.ctx()); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(4); h diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index fc00fb520..f5ea89071 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -3,6 +3,9 @@ //! `header_autofit_fonts.rs` and `keytips.rs` stay separate binaries: they set process-wide state //! (the installed-font catalog, a registered test command) that would leak into these tests. +#[path = "../common/mod.rs"] +mod common; + mod accounting_fill; mod advanced_filter; mod argument_names; diff --git a/crates/ui-egui/tests/it/menu_hover.rs b/crates/ui-egui/tests/it/menu_hover.rs index 61e265235..296831f71 100644 --- a/crates/ui-egui/tests/it/menu_hover.rs +++ b/crates/ui-egui/tests/it/menu_hover.rs @@ -12,12 +12,12 @@ type Harness = egui_kittest::Harness<'static, SheetApp>; fn harness() -> Harness { let mut s = Session::new(); s.new_workbook(); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { app.logic(ui.ctx()); app.ui(ui); }, - SheetApp::new(s, Default::default()), + crate::common::app(s), ); h.run_steps(4); h diff --git a/crates/ui-egui/tests/it/merged_cells.rs b/crates/ui-egui/tests/it/merged_cells.rs index c28e0806e..ffa31c354 100644 --- a/crates/ui-egui/tests/it/merged_cells.rs +++ b/crates/ui-egui/tests/it/merged_cells.rs @@ -14,12 +14,12 @@ fn harness(input: &str) -> Harness { session.new_workbook(); session.execute("home.mergeCells", json!({"range": "A1:B2"})).unwrap(); session.execute("cell.set", json!({"cell": "A1", "input": input})).unwrap(); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { app.logic(ui.ctx()); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(4); h diff --git a/crates/ui-egui/tests/it/more_colors.rs b/crates/ui-egui/tests/it/more_colors.rs index 72704d50b..54675a5ba 100644 --- a/crates/ui-egui/tests/it/more_colors.rs +++ b/crates/ui-egui/tests/it/more_colors.rs @@ -10,12 +10,12 @@ type Harness = egui_kittest::Harness<'static, SheetApp>; fn harness() -> Harness { let mut s = Session::new(); s.new_workbook(); - let mut h = egui_kittest::Harness::builder().with_os(OperatingSystem::Nix).with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_os(OperatingSystem::Nix).with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { app.logic(ui.ctx()); app.ui(ui); }, - SheetApp::new(s, Default::default()), + crate::common::app(s), ); h.run_steps(4); h @@ -73,7 +73,9 @@ fn a_colour_that_is_not_hex_does_not_apply_and_cancel_applies_nothing() { press(&mut h, Key::Enter, Modifiers::NONE); assert!(h.state().session.journal.iter().all(|(id, _)| id != "home.fillColor")); h.get_by_label("Hex"); - h.get_by_label("Cancel").click(); + // The formula bar's icon-only Cancel also carries that tooltip name (role Unknown); the + // palette's own Cancel is the button here. + h.get_by_role_and_label(egui::accesskit::Role::Button, "Cancel").click(); h.run_steps(3); assert!(h.state().session.journal.iter().all(|(id, _)| id != "home.fillColor")); assert!(h.query_by_label("Hex").is_none()); diff --git a/crates/ui-egui/tests/it/native_print.rs b/crates/ui-egui/tests/it/native_print.rs index 7875dc069..03548d85c 100644 --- a/crates/ui-egui/tests/it/native_print.rs +++ b/crates/ui-egui/tests/it/native_print.rs @@ -24,7 +24,7 @@ fn native_print_uses_selected_settings_and_reports_spool_errors() { let pdf = app.run("file.print", json!({"sheets":"active"})).unwrap(); assert!(pdf["base64"].as_str().is_some(), "the engine still returns PDF bytes without submitting"); assert!(jobs.borrow().is_empty(), "opening settings/exporting does not spool"); - app.printing.printers.push(Printer { name: "Chosen queue".into(), default: false }); + app.printing.printers.push(Printer { name: "Chosen queue".into(), label: String::new(), default: false }); let configured = app.run("ui.printSettings", json!({"printer":"Chosen queue", "copies":3,"duplex":"shortEdge","grayscale":true})).unwrap(); assert_eq!(configured["ready"], true); assert_eq!(configured["printers"][0]["name"], "Chosen queue"); @@ -47,11 +47,11 @@ fn print_settings_are_visible_in_a_narrow_window() { use egui_kittest::kittest::{NodeT, Queryable}; let mut session = Session::new(); session.new_workbook(); - let mut app = SheetApp::new(session, Services::default()); + let mut app = crate::common::prepare(SheetApp::new(session, Services::default())); app.run("cell.set", json!({"cell":"A1", "input":"A sheet to print"})).unwrap(); app.services.printers = Some(Box::new(|| { let (sender, receiver) = std::sync::mpsc::channel(); - sender.send(vec![Printer { name: "Test queue".into(), default: true }]).unwrap(); + sender.send(vec![Printer { name: "Test queue".into(), label: String::new(), default: true }]).unwrap(); receiver })); app.services.print_native = Some(Box::new(|_, _| { @@ -60,7 +60,7 @@ fn print_settings_are_visible_in_a_narrow_window() { receiver })); app.run("ui.backstage", json!({"page":"print"})).unwrap(); - let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(800.0, 700.0)).build_ui_state( + let mut harness = crate::common::builder().with_size(egui::vec2(800.0, 700.0)).build_ui_state( |ui, app: &mut SheetApp| { app.logic(ui.ctx()); app.ui(ui); @@ -81,7 +81,7 @@ fn invalid_print_settings_are_rejected_without_partial_changes() { let mut session = Session::new(); session.new_workbook(); let mut app = SheetApp::new(session, Services::default()); - app.printing.printers.push(Printer { name: "Queue".into(), default: true }); + app.printing.printers.push(Printer { name: "Queue".into(), label: String::new(), default: true }); let before = app.run("ui.printSettings", json!({})).unwrap(); for params in [ json!({"printer":"Missing"}), @@ -106,7 +106,7 @@ fn repeated_fast_jobs_each_surface_an_identical_receipt() { let mut session = Session::new(); session.new_workbook(); let mut app = SheetApp::new(session, Services::default()); - app.printing.printers.push(Printer { name: "Queue".into(), default: true }); + app.printing.printers.push(Printer { name: "Queue".into(), label: String::new(), default: true }); app.run("ui.printSettings", json!({"printer":"Queue"})).unwrap(); app.services.print_native = Some(Box::new(|_, _| { let (sender, receiver) = std::sync::mpsc::channel(); diff --git a/crates/ui-egui/tests/it/nowrap_line_breaks.rs b/crates/ui-egui/tests/it/nowrap_line_breaks.rs index 413f441d0..5b96c2e72 100644 --- a/crates/ui-egui/tests/it/nowrap_line_breaks.rs +++ b/crates/ui-egui/tests/it/nowrap_line_breaks.rs @@ -15,12 +15,12 @@ fn harness() -> Harness { s.execute("cell.set", json!({"cell": "A3", "input": "Wrapped\nText"})).unwrap(); s.execute("home.formatCells", json!({"range": "A3", "style": {"align": {"wrap": true}}})).unwrap(); s.execute("selection.set", json!({"range": "H20"})).unwrap(); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut gridcraft_ui_egui::SheetApp| { app.logic(ui.ctx()); app.ui(ui); }, - gridcraft_ui_egui::SheetApp::new(s, Default::default()), + crate::common::app(s), ); h.run_steps(4); h diff --git a/crates/ui-egui/tests/it/number_stored_as_text.rs b/crates/ui-egui/tests/it/number_stored_as_text.rs index 8664cc6d3..0abc997f7 100644 --- a/crates/ui-egui/tests/it/number_stored_as_text.rs +++ b/crates/ui-egui/tests/it/number_stored_as_text.rs @@ -13,13 +13,13 @@ fn harness(active: &str) -> Harness { s.execute("cell.set", json!({"cell": "B2", "input": "'123"})).unwrap(); s.execute("cell.set", json!({"cell": "B3", "input": "'abc"})).unwrap(); s.execute("selection.set", json!({"cell": active})).unwrap(); - let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); app.ui(ui); }, - SheetApp::new(s, Default::default()), + crate::common::app(s), ); h.run_steps(4); h diff --git a/crates/ui-egui/tests/it/open_encrypted.rs b/crates/ui-egui/tests/it/open_encrypted.rs index fabbd5638..3dbdec30e 100644 --- a/crates/ui-egui/tests/it/open_encrypted.rs +++ b/crates/ui-egui/tests/it/open_encrypted.rs @@ -11,13 +11,13 @@ const PW: &str = "synthetic-pw-8842"; fn harness(services: Services) -> egui_kittest::Harness<'static, SheetApp> { let mut session = Session::new(); session.new_workbook(); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); app.ui(ui); }, - SheetApp::new(session, services), + crate::common::prepare(SheetApp::new(session, services)), ); h.run_steps(4); h diff --git a/crates/ui-egui/tests/it/outline.rs b/crates/ui-egui/tests/it/outline.rs index 7b507af3e..c926c44e5 100644 --- a/crates/ui-egui/tests/it/outline.rs +++ b/crates/ui-egui/tests/it/outline.rs @@ -11,12 +11,12 @@ type Harness = egui_kittest::Harness<'static, SheetApp>; fn harness() -> Harness { let mut session = Session::new(); session.new_workbook(); - let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { app.logic(ui.ctx()); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(4); h diff --git a/crates/ui-egui/tests/it/page_layout_view.rs b/crates/ui-egui/tests/it/page_layout_view.rs index b2e80d0d9..222e6fbd5 100644 --- a/crates/ui-egui/tests/it/page_layout_view.rs +++ b/crates/ui-egui/tests/it/page_layout_view.rs @@ -31,17 +31,14 @@ fn fixture_with(rtl: bool, setup: Option) -> Harness { } session.execute("pageLayout.headerFooter", json!({"header": "&LLeft"})).unwrap(); session.execute("view.pageLayout", json!({})).unwrap(); - let mut h = egui_kittest::Harness::builder() - .with_size(vec2(1200.0, 900.0)) - .with_step_dt(1.0 / 60.0) - .with_os(egui::os::OperatingSystem::Mac) - .build_ui_state( + let mut h = + crate::common::builder().with_size(vec2(1200.0, 900.0)).with_step_dt(1.0 / 60.0).with_os(egui::os::OperatingSystem::Mac).build_ui_state( |ui, app: &mut SheetApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(4); h diff --git a/crates/ui-egui/tests/it/page_setup_margins.rs b/crates/ui-egui/tests/it/page_setup_margins.rs index c604d77e3..f808afd40 100644 --- a/crates/ui-egui/tests/it/page_setup_margins.rs +++ b/crates/ui-egui/tests/it/page_setup_margins.rs @@ -11,7 +11,7 @@ fn margins(app: &SheetApp) -> [f32; 6] { fn app_with_custom_margins() -> SheetApp { let mut session = Session::new(); session.new_workbook(); - let mut app = SheetApp::new(session, Default::default()); + let mut app = crate::common::app(session); app.run("pageLayout.margins", json!({"left": 2.0, "right": 2.5, "top": 3.0, "bottom": 0.25, "header": 0.1, "footer": 0.2})).unwrap(); assert_eq!(margins(&app), [2.0, 2.5, 3.0, 0.25, 0.1, 0.2]); app diff --git a/crates/ui-egui/tests/it/paste_special_dialog.rs b/crates/ui-egui/tests/it/paste_special_dialog.rs index f74a34c88..161d35292 100644 --- a/crates/ui-egui/tests/it/paste_special_dialog.rs +++ b/crates/ui-egui/tests/it/paste_special_dialog.rs @@ -14,13 +14,13 @@ fn harness() -> Harness { s.execute("range.setValues", json!({"range": "C1", "values": [[10], [10]]})).unwrap(); s.execute("edit.copy", json!({"range": "A1:A2"})).unwrap(); s.execute("selection.set", json!({"cell": "C1"})).unwrap(); - let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); app.ui(ui); }, - SheetApp::new(s, Default::default()), + crate::common::app(s), ); h.state_mut().open_dialog("pasteSpecial", json!({})); h.run_steps(4); diff --git a/crates/ui-egui/tests/it/pivot_pane.rs b/crates/ui-egui/tests/it/pivot_pane.rs index d08c0e076..5dbf21c9c 100644 --- a/crates/ui-egui/tests/it/pivot_pane.rs +++ b/crates/ui-egui/tests/it/pivot_pane.rs @@ -2,7 +2,7 @@ use egui::{Color32, RawInput, Rect, Shape, pos2, vec2}; use gridcraft_engine::Session; -use gridcraft_ui_egui::{SheetApp, theme}; +use gridcraft_ui_egui::theme; use serde_json::json; fn render_pivot_field_buttons(dark: bool) -> Vec { @@ -14,7 +14,7 @@ fn render_pivot_field_buttons(dark: bool) -> Vec { .unwrap(); session.execute("selection.set", json!({"cell": "D3"})).unwrap(); - let mut app = SheetApp::new(session, Default::default()); + let mut app = crate::common::app(session); app.ui.dark = dark; let ctx = egui::Context::default(); let mut output = egui::FullOutput::default(); diff --git a/crates/ui-egui/tests/it/pivot_show_details.rs b/crates/ui-egui/tests/it/pivot_show_details.rs index 9f11b29eb..46ac433d6 100644 --- a/crates/ui-egui/tests/it/pivot_show_details.rs +++ b/crates/ui-egui/tests/it/pivot_show_details.rs @@ -16,12 +16,12 @@ fn harness() -> egui_kittest::Harness<'static, SheetApp> { .execute("insert.pivotTable", json!({"source": "Sheet1!A1:B4", "destination": "Sheet1!D3", "rows": ["Region"], "values": ["Sales"]})) .unwrap(); session.execute("selection.set", json!({"cell": "A1"})).unwrap(); - let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { app.logic(ui.ctx()); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(4); h diff --git a/crates/ui-egui/tests/it/popup_dismissal.rs b/crates/ui-egui/tests/it/popup_dismissal.rs index 24d9fafbc..bb0cfc292 100644 --- a/crates/ui-egui/tests/it/popup_dismissal.rs +++ b/crates/ui-egui/tests/it/popup_dismissal.rs @@ -27,12 +27,12 @@ fn harness() -> Harness { session.execute("data.validation", json!({"range": "E3", "type": "list", "formula1": "\"First,Second\""})).unwrap(); session.execute("home.insertSheet", json!({})).unwrap(); session.execute("sheet.activate", json!({"sheet": 0})).unwrap(); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { app.logic(ui.ctx()); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(4); h @@ -187,7 +187,7 @@ fn open_list_of(h: &mut Harness, cell: CellRef, count: usize) -> egui::Rect { let arrow = grid::validation_arrow(sh, &geometry, cell).unwrap().center(); click(h, arrow, PointerButton::Primary); assert!(is_open(h, Menu::Validation)); - h.ctx.memory(|m| m.area_rect(egui::Id::new("dv_list"))).unwrap() + h.ctx.memory(|m| m.area_rect(egui::Id::unique("dv_list"))).unwrap() } #[test] diff --git a/crates/ui-egui/tests/it/protect_password.rs b/crates/ui-egui/tests/it/protect_password.rs index 7a2fe551e..f77ea1cc1 100644 --- a/crates/ui-egui/tests/it/protect_password.rs +++ b/crates/ui-egui/tests/it/protect_password.rs @@ -11,13 +11,13 @@ const SECRET: &str = "s3cret"; fn harness() -> egui_kittest::Harness<'static, SheetApp> { let mut session = Session::new(); session.new_workbook(); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(4); h diff --git a/crates/ui-egui/tests/it/right_to_left.rs b/crates/ui-egui/tests/it/right_to_left.rs index 3be2f3bdb..f0703af32 100644 --- a/crates/ui-egui/tests/it/right_to_left.rs +++ b/crates/ui-egui/tests/it/right_to_left.rs @@ -14,13 +14,13 @@ fn harness() -> Harness { session.execute("range.setValues", json!({"range": "A1", "values": [["first", 2], [3, "fourth"]]})).unwrap(); session.execute("view.rightToLeft", json!({"on": true})).unwrap(); session.execute("selection.set", json!({"cell": "B2"})).unwrap(); - let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(4); h diff --git a/crates/ui-egui/tests/it/scroll_position.rs b/crates/ui-egui/tests/it/scroll_position.rs index 7945eb344..858389ab7 100644 --- a/crates/ui-egui/tests/it/scroll_position.rs +++ b/crates/ui-egui/tests/it/scroll_position.rs @@ -19,12 +19,12 @@ fn harness(freeze: Option<(u32, u32)>) -> Harness { sh.freeze = freeze; let mut session = Session::new(); session.add_document(DocState::new(wb, None, "Book1".into())); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { app.logic(ui.ctx()); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(4); h diff --git a/crates/ui-egui/tests/it/scrollbar_column_fill.rs b/crates/ui-egui/tests/it/scrollbar_column_fill.rs index cd14f09ba..ba3b4eb75 100644 --- a/crates/ui-egui/tests/it/scrollbar_column_fill.rs +++ b/crates/ui-egui/tests/it/scrollbar_column_fill.rs @@ -10,12 +10,12 @@ use serde_json::json; fn dark_theme_fills_the_strip_right_of_the_last_column_heading() { let mut session = Session::new(); session.new_workbook(); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { app.logic(ui.ctx()); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.state_mut().run("view.theme", json!({"mode": "dark"})).unwrap(); h.run_steps(6); diff --git a/crates/ui-egui/tests/it/selection.rs b/crates/ui-egui/tests/it/selection.rs index b698927b7..693a07e37 100644 --- a/crates/ui-egui/tests/it/selection.rs +++ b/crates/ui-egui/tests/it/selection.rs @@ -15,13 +15,13 @@ fn harness(initial: &str) -> Harness { session.execute("cell.set", json!({"cell": "C3", "input": "123"})).unwrap(); session.execute("cell.set", json!({"cell": "F7", "input": "=C3*2"})).unwrap(); session.execute("selection.set", json!({"cell": initial})).unwrap(); - let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(4); h diff --git a/crates/ui-egui/tests/it/selection_glide.rs b/crates/ui-egui/tests/it/selection_glide.rs index c8c253ac8..b09a61b64 100644 --- a/crates/ui-egui/tests/it/selection_glide.rs +++ b/crates/ui-egui/tests/it/selection_glide.rs @@ -16,11 +16,11 @@ const DT: f32 = 1.0 / 240.0; fn app() -> SheetApp { let mut session = Session::new(); session.new_workbook(); - SheetApp::new(session, Default::default()) + crate::common::app(session) } fn harness() -> Harness { - let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_step_dt(DT).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).with_step_dt(DT).build_ui_state( |ui, app: &mut SheetApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); diff --git a/crates/ui-egui/tests/it/selection_scroll.rs b/crates/ui-egui/tests/it/selection_scroll.rs index 9a78f9b9f..f80894577 100644 --- a/crates/ui-egui/tests/it/selection_scroll.rs +++ b/crates/ui-egui/tests/it/selection_scroll.rs @@ -23,13 +23,13 @@ fn harness() -> Harness { let across: Vec = (1..=100).collect(); s.execute("range.setValues", json!({"range": "A1", "values": [across]})).unwrap(); s.execute("selection.set", json!({"cell": "A1"})).unwrap(); - let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); app.ui(ui); }, - SheetApp::new(s, Default::default()), + crate::common::app(s), ); h.run_steps(4); h diff --git a/crates/ui-egui/tests/it/sheet_navigator.rs b/crates/ui-egui/tests/it/sheet_navigator.rs index a16e306ed..2685352dc 100644 --- a/crates/ui-egui/tests/it/sheet_navigator.rs +++ b/crates/ui-egui/tests/it/sheet_navigator.rs @@ -17,13 +17,13 @@ fn harness() -> egui_kittest::Harness<'static, SheetApp> { } s.run("sheet.hide", json!({"sheet": 1})).unwrap(); s.run("sheet.activate", json!({"sheet": 0})).unwrap(); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); app.ui(ui); }, - SheetApp::new(s, Default::default()), + crate::common::app(s), ); h.run_steps(4); h diff --git a/crates/ui-egui/tests/it/sheet_tab_rename.rs b/crates/ui-egui/tests/it/sheet_tab_rename.rs index 5ed2b117e..0c97066d6 100644 --- a/crates/ui-egui/tests/it/sheet_tab_rename.rs +++ b/crates/ui-egui/tests/it/sheet_tab_rename.rs @@ -8,13 +8,13 @@ use gridcraft_ui_egui::{SheetApp, grid::Geo}; fn harness() -> egui_kittest::Harness<'static, SheetApp> { let mut s = Session::new(); s.new_workbook(); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); app.ui(ui); }, - SheetApp::new(s, Default::default()), + crate::common::app(s), ); h.run_steps(4); h diff --git a/crates/ui-egui/tests/it/sheet_tabs.rs b/crates/ui-egui/tests/it/sheet_tabs.rs index c3e5d13fb..da98331ff 100644 --- a/crates/ui-egui/tests/it/sheet_tabs.rs +++ b/crates/ui-egui/tests/it/sheet_tabs.rs @@ -19,17 +19,17 @@ fn harness_with(width: f32, sheets: usize, active: usize) -> Harness { } s.execute("sheet.activate", json!({"sheet":active})).unwrap(); let mut fonts_ready = false; - let mut h = Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(width, 120.0)).build_ui_state( + let mut h = crate::common::builder().with_step_dt(1.0 / 60.0).with_size(vec2(width, 120.0)).build_ui_state( move |ui, app: &mut SheetApp| { if !fonts_ready { - ui.ctx().set_fonts(gridcraft_ui_egui::theme::font_definitions()); + ui.ctx().set_fonts(gridcraft_ui_egui::theme::pinned_font_definitions()); fonts_ready = true; return; } app.grid.cells_rect = Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(ui.max_rect().width(), 80.0))); gridcraft_ui_egui::tabs::sheet_tabs(app, ui); }, - SheetApp::new(s, Default::default()), + crate::common::app(s), ); h.run_steps(20); h @@ -94,6 +94,7 @@ fn manual_wheel_and_trackpad_browsing_does_not_change_selection_or_snap_back() { Event::PointerMoved(clip.center()), Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, phase: egui::TouchPhase::Start, delta: vec2(0.0, 0.0), modifiers: Modifiers::NONE, @@ -102,6 +103,7 @@ fn manual_wheel_and_trackpad_browsing_does_not_change_selection_or_snap_back() { h.step(); h.input_mut().events.push(Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, phase: egui::TouchPhase::Move, delta, modifiers: Modifiers::NONE, @@ -109,6 +111,7 @@ fn manual_wheel_and_trackpad_browsing_does_not_change_selection_or_snap_back() { h.step(); h.input_mut().events.push(Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, phase: egui::TouchPhase::End, delta: vec2(0.0, 0.0), modifiers: Modifiers::NONE, diff --git a/crates/ui-egui/tests/it/show_objects.rs b/crates/ui-egui/tests/it/show_objects.rs index 9dcac7ce8..f5eec206d 100644 --- a/crates/ui-egui/tests/it/show_objects.rs +++ b/crates/ui-egui/tests/it/show_objects.rs @@ -20,13 +20,13 @@ fn fixture() -> (Harness, u32) { let shape = session.execute("insert.shape", json!({"kind": "rectangle", "at": "D3", "fill": "123456"})).unwrap(); let id = shape["shape"].as_u64().unwrap() as u32; session.execute("selection.set", json!({"range": "A1"})).unwrap(); - let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(4); (h, id) diff --git a/crates/ui-egui/tests/it/shrink_to_fit.rs b/crates/ui-egui/tests/it/shrink_to_fit.rs index a9428fa23..e3eee09f1 100644 --- a/crates/ui-egui/tests/it/shrink_to_fit.rs +++ b/crates/ui-egui/tests/it/shrink_to_fit.rs @@ -20,12 +20,12 @@ fn harness() -> Harness { s.execute("cell.set", json!({"cell": "A5", "input": "123456789012"})).unwrap(); s.execute("home.formatCells", json!({"range": "A5", "style": {"num_fmt": "#,##0"}})).unwrap(); s.execute("selection.set", json!({"range": "H20"})).unwrap(); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { app.logic(ui.ctx()); app.ui(ui); }, - SheetApp::new(s, Default::default()), + crate::common::app(s), ); h.run_steps(4); h diff --git a/crates/ui-egui/tests/it/sort_dialog_columns.rs b/crates/ui-egui/tests/it/sort_dialog_columns.rs index fb685c3e0..a6d3b23a8 100644 --- a/crates/ui-egui/tests/it/sort_dialog_columns.rs +++ b/crates/ui-egui/tests/it/sort_dialog_columns.rs @@ -10,13 +10,13 @@ use serde_json::json; type Harness = egui_kittest::Harness<'static, SheetApp>; fn harness(session: Session) -> Harness { - let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(4); h diff --git a/crates/ui-egui/tests/it/symbol_glyphs.rs b/crates/ui-egui/tests/it/symbol_glyphs.rs index 7f2a55f74..dfea9eb6a 100644 --- a/crates/ui-egui/tests/it/symbol_glyphs.rs +++ b/crates/ui-egui/tests/it/symbol_glyphs.rs @@ -1,30 +1,215 @@ //! The interface's symbol characters must not render as empty boxes (#197, #54): the UI font often -//! lacks arrows, check marks and geometric shapes, so a symbol face backs it up. +//! lacks arrows, check marks and geometric shapes, so egui's bundled symbol faces (Hack and the +//! monochrome emoji fonts), the optional permitted craft-fonts faces, and code-drawn chrome icons +//! back it up. +//! +//! The checks ask the font set whether it has a real glyph for each character (`has_glyph`), which +//! is the property the user sees. The replacement-box probe is a separate test that documents why +//! "differs from the tofu glyph" alone is not evidence that a face has the character. +//! +//! Two modes are deliberate: +//! - no-font/default: no `CRAFT_FONTS_DIR`. Bundled/system faces provide `↘ ◀ ↑ 📋`, and the three +//! symbols no bundled face has (`✕ ✓ ⌃`) are chrome-only gaps covered by code-drawn icons and +//! their ASCII equivalents. `✓` and `⌃` stay documented optional-input/platform gaps. +//! - extended acceptance: `CRAFT_FONTS_DIR` set (release/acceptance builds also set +//! `CRAFT_FONTS_REQUIRED=1`). All seven exact codepoints and Korean UI text must render, or the +//! test fails; it never skips a loaded or required face set. -use egui::{Color32, FontFamily, FontId}; +use egui::{FontFamily, FontId}; +use gridcraft_ui_egui::icons::{self, Icon}; use gridcraft_ui_egui::theme; -/// Symbols the interface draws as text (buttons, menus, dialogs, tooltips, Insert › Symbol). -const UI_SYMBOLS: &str = "▲▼▸▾✓✕←↑→↓↳↘↗⇧⌃⌘★•›≠≤≥∞"; +/// Symbols the bundled faces provide on a host with no symbol, CJK or Latin-Extended face: +/// Hack supplies `↘ ◀ ↑`; Noto Emoji and emoji-icon-font supply `📋`. +const BUNDLED_REQUIRED_UI_SYMBOLS: &str = "↘◀↑📋"; -#[test] -fn ui_symbols_have_glyphs_when_a_symbol_face_is_installed() { - if !theme::symbol_face_installed() { - return; // a minimal machine without any symbol face; nothing to assert - } +/// The rest of what the interface draws as text that egui's bundled faces cover: Ubuntu-Light +/// supplies punctuation and maths, Hack the arrows/triangles and keyboard arrows, and the +/// monochrome emoji faces ⌘ and ★. +const BUNDLED_UI_SYMBOLS: &str = "▲▼▸▾←→↓↳↗⇧⌘★•›≠≤≥∞"; + +/// The production union. `✕ ✓ ⌃` have no bundled egui face: with craft-fonts, Noto Sans Symbols 2 +/// supplies `✕` and `✓`, and Noto Sans Symbols supplies `⌃`; `📋` still comes from egui's emoji. +const CRAFT_UI_SYMBOLS: &str = "↘✕◀↑📋✓⌃"; + +/// Korean interface labels the extended acceptance set must render (not a tiny sample). +const KOREAN_UI_TEXT: &[&str] = &["데이터", "한글", "파일", "편집", "보기", "삽입", "서식", "저장", "열기", "닫기", "인쇄", "취소", "확인"]; + +/// A context with the deterministic bundled + pinned craft-fonts set installed, plus the families +/// the interface uses. System fonts are deliberately excluded so this check is the same on CI. +fn themed() -> egui::Context { let ctx = egui::Context::default(); - ctx.set_fonts(theme::font_definitions()); + ctx.set_fonts(theme::pinned_font_definitions()); let mut out = ctx.run_ui(egui::RawInput::default(), |_ui| {}); out.textures_delta.clear(); // nothing consumes the atlas in a headless test - for family in [FontFamily::Proportional, FontFamily::Name(theme::CELL.into())] { - let font = FontId::new(14.0, family); - let glyphs = |text: &str| { - let galley = ctx.fonts_mut(|f| f.layout_no_wrap(text.to_owned(), font.clone(), Color32::BLACK)); - galley.rows.iter().flat_map(|r| r.glyphs.iter().map(|g| (g.chr, g.uv_rect))).collect::>() - }; - // A private-use code point no font covers renders as the replacement box. - let tofu: Vec<_> = glyphs("\u{10FFFD}").into_iter().map(|(_, uv)| uv).collect(); - let missing: String = glyphs(UI_SYMBOLS).into_iter().filter(|(_, uv)| tofu.contains(uv)).map(|(c, _)| c).collect(); - assert!(missing.is_empty(), "these interface symbols render as boxes in {:?}: {missing}", font.family); + ctx +} + +fn missing(ctx: &egui::Context, symbols: &str, family: FontFamily) -> String { + let font = FontId::new(14.0, family); + symbols.chars().filter(|&c| !ctx.fonts_mut(|f| f.has_glyph(&font, c))).collect() +} + +fn families() -> [FontFamily; 2] { + [FontFamily::Proportional, FontFamily::Name(theme::CELL.into())] +} + +#[test] +fn bundled_required_ui_symbols_resolve_to_real_glyphs() { + let ctx = themed(); + for family in families() { + let missing = missing(&ctx, BUNDLED_REQUIRED_UI_SYMBOLS, family.clone()); + assert!( + missing.is_empty(), + "these required interface symbols have no real glyph in {family:?}: {missing} — \ + egui must be built with `monochrome_emoji_fonts` and the Hack fallback must stay in \ + theme::font_definitions" + ); + } +} + +#[test] +fn bundled_symbols_cover_the_rest_of_the_interface() { + let ctx = themed(); + for family in families() { + let missing = missing(&ctx, BUNDLED_UI_SYMBOLS, family.clone()); + assert!(missing.is_empty(), "egui's bundled faces have no real glyph in {family:?} for: {missing}"); + } +} + +#[test] +fn unknown_codepoint_is_the_tofu_probe_not_a_symbol() { + // A private-use code point no font covers. This is the only place that relies on the + // replacement box: real-glyph checks above use `has_glyph`, because comparing a glyph's + // texture with the replacement glyph would pass for two distinct missing characters. + let ctx = themed(); + for family in families() { + let font = FontId::new(14.0, family.clone()); + assert!(!ctx.fonts_mut(|f| f.has_glyph(&font, '\u{10FFFD}')), "U+10FFFD must not resolve in {family:?}"); + } +} + +#[test] +fn chrome_symbols_without_a_bundled_glyph_have_code_drawn_or_semantic_fallbacks() { + // No bundled face has ✕, ✓ or ⌃. The buttons that used to paint ✕/✓ as text now draw the + // original code icons Icon::Close/Icon::Check, and the one Control-key tooltip falls back to + // "^" when ⌃ is absent. Nothing here suppresses a glyph check or the accessibility check. + assert_eq!(icons::fallback_for_symbol('✕'), Some(Icon::Close), "the close buttons must be code-painted"); + assert_eq!(icons::fallback_for_symbol('✓'), Some(Icon::Check), "the Symbol gallery check must be code-painted"); + assert_eq!(theme::symbol_fallback('⌃'), Some('^'), "the Control-key glyph needs a readable ASCII fallback"); + let ctx = themed(); + for family in families() { + let missing = missing(&ctx, "×^", family.clone()); + assert!(missing.is_empty(), "the semantic fallbacks × and ^ must resolve in {family:?}: missing {missing}"); + } +} + +#[test] +fn no_font_mode_documents_the_optional_symbol_gaps() { + if theme::craft_fonts_loaded() { + return; + } + let ctx = themed(); + let missing: String = families().iter().map(|family| missing(&ctx, "✓⌃", family.clone())).collect(); + // A diagnostic, not an acceptance skip: the bundled assertions above still run strictly. + eprintln!( + "no-font mode: {missing:?} are optional-input/platform gaps (set CRAFT_FONTS_DIR for the \ + extended acceptance set); chrome uses code-drawn icons or ASCII fallbacks instead" + ); +} + +#[test] +fn extended_craft_fonts_cover_the_required_symbol_union_and_korean() { + if !theme::craft_fonts_loaded() { + // Optional-input diagnostic mode only. A required acceptance build must fail, not skip. + assert!(!theme::craft_fonts_required(), "CRAFT_FONTS_REQUIRED=1 but the build embedded no craft-fonts faces"); + eprintln!("diagnostic: no CRAFT_FONTS_DIR; the extended seven-symbol and Korean acceptance checks were not run"); + return; + } + let ctx = themed(); + for family in families() { + let missing_symbols = missing(&ctx, CRAFT_UI_SYMBOLS, family.clone()); + assert!( + missing_symbols.is_empty(), + "the craft-fonts production set has no real glyph in {family:?} for {missing_symbols:?}; \ + Noto Sans Symbols supplies ⌃↘↑ and Noto Sans Symbols 2 supplies ✕◀✓, while egui's emoji supply 📋" + ); + for sample in KOREAN_UI_TEXT { + let missing = missing(&ctx, sample, family.clone()); + assert!(missing.is_empty(), "Korean interface text {sample:?} has no glyph for {missing:?} in {family:?}"); + } + } + assert_exact_permitted_faces(); +} + +/// The required faces are embedded, the native-only Chinese face keeps its contract when present, +/// and no barred family entered the set. Native builds embed every non-barred manifest row (for +/// example the Thai, Arabic, Georgian and Hebrew faces), so this asserts the required set rather +/// than claiming the embedded set is limited to the five web faces. +fn assert_exact_permitted_faces() { + let faces: Vec<_> = theme::craft_font_scripts().collect(); + let expected: &[(&str, &str, &[&str])] = &[ + ("BIZ UDPGothic", "Regular", &["Jpan", "Latn"]), + ("BIZ UDPGothic", "Bold", &["Jpan", "Latn"]), + ("NanumGothic", "Regular", &["Hang", "Kore"]), + ("Noto Sans Symbols", "Regular", &["Zsym"]), + ("Noto Sans Symbols 2", "Regular", &["Zsym"]), + ]; + for (family, style, scripts) in expected { + assert!( + faces.iter().any(|(f, s, declared)| f == family && s == style && *declared == *scripts), + "craft-fonts must embed {family} {style} with scripts {scripts:?}; embedded: {faces:?}" + ); + } + if theme::chinese_font_loaded() { + assert!( + faces.iter().any(|(f, s, declared)| *f == "LXGW WenKai Lite" && *s == "Regular" && *declared == ["Hani", "Hans"].as_slice()), + "the native-only LXGW Chinese face keeps its Hani,Hans script tags; embedded: {faces:?}" + ); + } + for (family, _, _) in &faces { + assert!(!family.contains("CJK"), "the barred Noto CJK collection must never be embedded: {family}"); + assert!(!family.starts_with("Source ") && !family.starts_with("Adobe "), "a barred Adobe/Source face was embedded: {family}"); + } +} + +#[test] +fn craft_fonts_cover_the_advertised_scripts() { + if !theme::craft_fonts_loaded() { + assert!(!theme::craft_fonts_required(), "CRAFT_FONTS_REQUIRED=1 but the build embedded no craft-fonts faces"); + eprintln!("craft-fonts not embedded (set CRAFT_FONTS_DIR to the pinned checkout); script coverage check not run"); + return; + } + // Chinese needs the native-only LXGW face (the only face that could supply it otherwise, Noto + // Sans CJK SC, is barred), so those samples join the strict set only when it was embedded. + let mut samples = vec![ + "カテゴリ", + "한글", + "데이터", + "파일", + "편집", + "보기", + "삽입", + "서식", + "저장", + "열기", + "닫기", + "인쇄", + "취소", + "확인", + "ทดสอบ", + "مرحبا", + "ქართული", + "Příliš žluťoučký kůň", + "STRAẞENBAHN", + ]; + if theme::chinese_font_loaded() { + samples.extend(["数据", "自动换行", "文件", "编辑", "插入"]); + } + for sample in samples { + assert!( + theme::craft_fonts_cover(sample), + "the permitted craft-fonts faces have no face for {sample:?}; add the OFL face to craft-fonts rather than relaxing this test" + ); } } diff --git a/crates/ui-egui/tests/it/system_theme.rs b/crates/ui-egui/tests/it/system_theme.rs index aa8469354..9b9efec34 100644 --- a/crates/ui-egui/tests/it/system_theme.rs +++ b/crates/ui-egui/tests/it/system_theme.rs @@ -9,9 +9,9 @@ use serde_json::{Value, json}; fn harness(ui: UiState) -> egui_kittest::Harness<'static, SheetApp> { let mut session = Session::new(); session.new_workbook(); - let mut app = SheetApp::new(session, Default::default()); + let mut app = crate::common::app(session); app.ui = ui; - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state( |ui, app: &mut SheetApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); diff --git a/crates/ui-egui/tests/it/table_headings.rs b/crates/ui-egui/tests/it/table_headings.rs index 5a2685e01..d95710270 100644 --- a/crates/ui-egui/tests/it/table_headings.rs +++ b/crates/ui-egui/tests/it/table_headings.rs @@ -131,12 +131,12 @@ fn the_button_in_a_replaced_heading_opens_that_columns_filter_menu() { let mut s = session("C20"); s.execute("home.hideColumns", json!({"cols": "B:B"})).unwrap(); s.execute("selection.set", json!({"cell": "C20"})).unwrap(); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { app.logic(ui.ctx()); app.ui(ui); }, - SheetApp::new(s, Default::default()), + crate::common::app(s), ); h.run_steps(4); let sh = sheet(&h.state().session); diff --git a/crates/ui-egui/tests/it/text_box_editing.rs b/crates/ui-egui/tests/it/text_box_editing.rs index 1d3b053ec..67b9b0307 100644 --- a/crates/ui-egui/tests/it/text_box_editing.rs +++ b/crates/ui-egui/tests/it/text_box_editing.rs @@ -22,17 +22,14 @@ fn fixture() -> (Harness, u32) { session.execute("selection.set", json!({"cell": "B2"})).unwrap(); let id = session.execute("insert.textBox", json!({"at": "B2", "width": 240, "height": 120, "text": ORIGINAL})).unwrap()["shape"].as_u64().unwrap() as u32; - let mut h = egui_kittest::Harness::builder() - .with_size(vec2(1200.0, 800.0)) - .with_step_dt(1.0 / 60.0) - .with_os(egui::os::OperatingSystem::Mac) - .build_ui_state( + let mut h = + crate::common::builder().with_size(vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).with_os(egui::os::OperatingSystem::Mac).build_ui_state( |ui, app: &mut SheetApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(4); (h, id) @@ -108,6 +105,7 @@ fn assert_cell_unchanged(h: &Harness) { fn double_click_edits_multiline_text_and_commits_on_outside_click() { let (mut h, id) = fixture(); edit_box(&mut h, id); + h.check_accessibility(); assert_eq!(h.state().selected_chart, Some(id)); let anchor = shape(&h, id).anchor; let start = shape_rect(&h, id).min + vec2(5.0, 10.0); @@ -129,6 +127,7 @@ fn double_click_edits_multiline_text_and_commits_on_outside_click() { let outside = cell_center(&h, "J15"); click(&mut h, outside); + h.check_accessibility(); assert_eq!(shape(&h, id).text, "New first line\nNew second line"); assert_cell_unchanged(&h); } diff --git a/crates/ui-egui/tests/it/text_overflow_scroll.rs b/crates/ui-egui/tests/it/text_overflow_scroll.rs index bb7c13bf1..10d5e1848 100644 --- a/crates/ui-egui/tests/it/text_overflow_scroll.rs +++ b/crates/ui-egui/tests/it/text_overflow_scroll.rs @@ -16,12 +16,12 @@ fn harness(setup: impl FnOnce(&mut Session)) -> Harness { s.new_workbook(); setup(&mut s); s.execute("selection.set", json!({"range": "A20"})).unwrap(); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { app.logic(ui.ctx()); app.ui(ui); }, - SheetApp::new(s, Default::default()), + crate::common::app(s), ); h.run_steps(4); h diff --git a/crates/ui-egui/tests/it/touch_pan.rs b/crates/ui-egui/tests/it/touch_pan.rs index f55e84246..b38fec787 100644 --- a/crates/ui-egui/tests/it/touch_pan.rs +++ b/crates/ui-egui/tests/it/touch_pan.rs @@ -6,13 +6,13 @@ use gridcraft_engine::core::CellRef; use gridcraft_ui_egui::SheetApp; fn harness(session: Session) -> egui_kittest::Harness<'static, SheetApp> { - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(4); h diff --git a/crates/ui-egui/tests/it/trace_navigation.rs b/crates/ui-egui/tests/it/trace_navigation.rs index 11f79fcff..b0516dbaa 100644 --- a/crates/ui-egui/tests/it/trace_navigation.rs +++ b/crates/ui-egui/tests/it/trace_navigation.rs @@ -9,13 +9,13 @@ use serde_json::json; type Harness = egui_kittest::Harness<'static, SheetApp>; fn harness(session: Session) -> Harness { - let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(4); h diff --git a/crates/ui-egui/tests/it/translated_form_fields.rs b/crates/ui-egui/tests/it/translated_form_fields.rs index b284bf786..bb64fb233 100644 --- a/crates/ui-egui/tests/it/translated_form_fields.rs +++ b/crates/ui-egui/tests/it/translated_form_fields.rs @@ -10,13 +10,13 @@ use serde_json::json; fn harness() -> egui_kittest::Harness<'static, SheetApp> { let mut session = Session::new(); session.new_workbook(); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(4); h diff --git a/crates/ui-egui/tests/it/validation_alert.rs b/crates/ui-egui/tests/it/validation_alert.rs index eb80822c9..dea2285f2 100644 --- a/crates/ui-egui/tests/it/validation_alert.rs +++ b/crates/ui-egui/tests/it/validation_alert.rs @@ -20,9 +20,9 @@ fn harness() -> Harness { s.execute("cell.set", json!({"cell": "A1", "input": "5"})).unwrap(); s.execute("data.validation", json!({"range": "A1", "type": "whole", "operator": "between", "formula1": "1", "formula2": "10"})).unwrap(); s.execute("selection.set", json!({"range": "A1"})).unwrap(); - let mut app = SheetApp::new(s, Default::default()); + let mut app = crate::common::app(s); app.ui.language = Language::En; - let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); diff --git a/crates/ui-egui/tests/it/validation_input_message.rs b/crates/ui-egui/tests/it/validation_input_message.rs index e05dbcefe..df7af74bd 100644 --- a/crates/ui-egui/tests/it/validation_input_message.rs +++ b/crates/ui-egui/tests/it/validation_input_message.rs @@ -16,12 +16,12 @@ fn harness() -> Harness { session.new_workbook(); session.execute("data.validation", json!({"range": "B2", "type": "list", "formula1": "\"Red,Green,Blue\"", "inputMessage": MESSAGE})).unwrap(); session.execute("selection.set", json!({"cell": "B2"})).unwrap(); - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { app.logic(ui.ctx()); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(4); h diff --git a/crates/ui-egui/tests/it/wheel_zoom.rs b/crates/ui-egui/tests/it/wheel_zoom.rs index 397fb6be8..82bbd6f22 100644 --- a/crates/ui-egui/tests/it/wheel_zoom.rs +++ b/crates/ui-egui/tests/it/wheel_zoom.rs @@ -1,22 +1,26 @@ //! Ctrl+wheel zoom steps along a ladder of percentages that always comes back to 100%. -use egui::{Event, Modifiers, MouseWheelUnit, TouchPhase, vec2}; +use egui::{Event, Modifiers, MouseWheelSource, MouseWheelUnit, TouchPhase, vec2}; use gridcraft_engine::Session; use gridcraft_ui_egui::SheetApp; use serde_json::json; type Harness = egui_kittest::Harness<'static, SheetApp>; +fn wheel_event(unit: MouseWheelUnit, source: MouseWheelSource, phase: TouchPhase, delta: egui::Vec2, modifiers: Modifiers) -> Event { + Event::MouseWheel { unit, delta, phase, modifiers, source } +} + fn harness() -> Harness { let mut session = Session::new(); session.new_workbook(); - let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state( |ui, app: &mut SheetApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(4); let over_grid = h.state().grid.rect.unwrap().center(); @@ -40,7 +44,7 @@ fn wheel_turns(h: &mut Harness, turns: &[f32], frames: usize) { h.input_mut().events.push(Event::ModifiersChanged(Modifiers::COMMAND)); for &t in turns { let delta = vec2(0.0, t); - h.input_mut().events.push(Event::MouseWheel { unit: MouseWheelUnit::Line, delta, phase: TouchPhase::Move, modifiers: Modifiers::COMMAND }); + h.input_mut().events.push(wheel_event(MouseWheelUnit::Line, MouseWheelSource::Unknown, TouchPhase::Move, delta, Modifiers::COMMAND)); h.run_steps(frames); } h.run_steps(60); @@ -64,7 +68,7 @@ fn animated() -> Harness { fn turn_and_watch(h: &mut Harness, notches: f32, frames: usize) -> Vec { h.input_mut().events.push(Event::ModifiersChanged(Modifiers::COMMAND)); let delta = vec2(0.0, notches); - h.input_mut().events.push(Event::MouseWheel { unit: MouseWheelUnit::Line, delta, phase: TouchPhase::Move, modifiers: Modifiers::COMMAND }); + h.input_mut().events.push(wheel_event(MouseWheelUnit::Line, MouseWheelSource::Unknown, TouchPhase::Move, delta, Modifiers::COMMAND)); (0..frames) .map(|_| { h.step(); @@ -184,3 +188,85 @@ fn from_a_zoom_set_another_way_a_notch_goes_to_the_next_step_or_100() { wheel(&mut h, -2.0); assert_eq!(zoom(&h), 85); } + +/// One notch in `unit`: a line event or its equivalent in points (egui's 40-point line speed). +fn turn_in_unit(h: &mut Harness, unit: MouseWheelUnit, delta: egui::Vec2, frames: usize) -> Vec { + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::COMMAND)); + h.input_mut().events.push(wheel_event(unit, MouseWheelSource::Wheel, TouchPhase::Move, delta, Modifiers::COMMAND)); + let seen = (0..frames) + .map(|_| { + h.step(); + zoom(h) + }) + .collect(); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + seen +} + +/// A trackpad/momentum gesture: the starting phase carries no delta, the move does. +fn gesture(h: &mut Harness, source: MouseWheelSource, delta: f32, frames: usize) -> Vec { + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::COMMAND)); + h.input_mut().events.push(wheel_event(MouseWheelUnit::Point, source, TouchPhase::Start, vec2(0.0, 0.0), Modifiers::COMMAND)); + h.input_mut().events.push(wheel_event(MouseWheelUnit::Point, source, TouchPhase::Move, vec2(0.0, delta), Modifiers::COMMAND)); + let seen = (0..frames) + .map(|_| { + h.step(); + zoom(h) + }) + .collect(); + h.run_steps(2); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + seen +} + +#[test] +fn point_units_use_the_same_zoom_ladder_as_lines() { + let mut h = animated(); + // egui converts a line to 40 points (native); the same physical notch must reach 120%. + let seen = turn_in_unit(&mut h, MouseWheelUnit::Point, vec2(0.0, 40.0), 40); + assert!(seen.windows(2).all(|w| w[0] <= w[1]), "{seen:?}"); + assert_eq!(seen.last(), Some(&120), "{seen:?}"); +} + +#[test] +fn trackpad_and_momentum_sources_keep_the_notch_ladder() { + for source in [MouseWheelSource::Trackpad, MouseWheelSource::Momentum] { + let mut h = animated(); + let seen = gesture(&mut h, source, 40.0, 40); + assert!(seen.windows(2).all(|w| w[0] <= w[1]), "{source:?}: {seen:?}"); + assert_eq!(seen.last(), Some(&120), "{source:?}: {seen:?}"); + } +} + +#[test] +fn a_trackpad_gesture_without_an_end_still_steps_on_after_the_idle_timeout() { + let mut h = animated(); + let first = gesture(&mut h, MouseWheelSource::Trackpad, 40.0, 40); + assert_eq!(first.last(), Some(&120), "{first:?}"); + // No `TouchPhase::End` arrives; egui's scroll-action timeout must still release the gesture. + h.run_steps(60); + let second = gesture(&mut h, MouseWheelSource::Trackpad, 40.0, 40); + assert_eq!(second.last(), Some(&140), "a stale trackpad gesture must not swallow the next notch: {second:?}"); +} + +#[test] +fn page_and_point_units_scroll_the_grid_by_their_converted_distance() { + let mut h = harness(); + let grid = h.state().grid.cells_rect.unwrap(); + let over = grid.center(); + h.input_mut().events.push(Event::PointerMoved(over)); + h.input_mut().events.push(wheel_event(MouseWheelUnit::Page, MouseWheelSource::Wheel, TouchPhase::Move, vec2(0.0, -1.0), Modifiers::NONE)); + h.run_steps(60); + let page = h.state().view().scroll.y; + assert!(page > 100.0, "one page down scrolls roughly a viewport, not zero: {page}"); + + h.state_mut().view_mut().unwrap().scroll.y = 0.0; + h.run_steps(2); + h.input_mut().events.push(Event::PointerMoved(over)); + h.input_mut().events.push(wheel_event(MouseWheelUnit::Point, MouseWheelSource::Trackpad, TouchPhase::Move, vec2(0.0, -40.0), Modifiers::NONE)); + h.run_steps(60); + let point = h.state().view().scroll.y; + assert!(point > 0.0 && point < page, "40 points scroll less than a page: point {point}, page {page}"); +} diff --git a/crates/ui-egui/tests/it/whole_line_shortcuts.rs b/crates/ui-egui/tests/it/whole_line_shortcuts.rs index 2bbcf3adf..58e6d3f6f 100644 --- a/crates/ui-egui/tests/it/whole_line_shortcuts.rs +++ b/crates/ui-egui/tests/it/whole_line_shortcuts.rs @@ -14,13 +14,13 @@ fn harness(selection: &str) -> Harness { session.new_workbook(); session.execute("range.setValues", json!({"range": "A1", "values": [["a1", "b1"], ["a2", "b2"], ["a3", "b3"]]})).unwrap(); session.execute("selection.set", json!({"range": selection})).unwrap(); - let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(4); h diff --git a/crates/ui-egui/tests/it/zoom_dialog.rs b/crates/ui-egui/tests/it/zoom_dialog.rs index f300d68f0..a3f7a4623 100644 --- a/crates/ui-egui/tests/it/zoom_dialog.rs +++ b/crates/ui-egui/tests/it/zoom_dialog.rs @@ -10,12 +10,12 @@ use serde_json::{Value as Json, json}; fn harness() -> egui_kittest::Harness<'static, SheetApp> { let mut session = Session::new(); session.new_workbook(); - let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { app.logic(&ui.ctx().clone()); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(6); h diff --git a/crates/ui-egui/tests/keytips.rs b/crates/ui-egui/tests/keytips.rs index 92cd8a239..16f34b8ba 100644 --- a/crates/ui-egui/tests/keytips.rs +++ b/crates/ui-egui/tests/keytips.rs @@ -1,5 +1,8 @@ //! Ribbon access keys through the complete egui input and UI path. +#[path = "common/mod.rs"] +mod common; + use egui::{Event, Key, Modifiers, PointerButton, os::OperatingSystem}; use egui_kittest::kittest::Queryable as _; use gridcraft_engine::Session; @@ -15,12 +18,12 @@ fn harness(session: Session) -> Harness { } fn harness_on(session: Session, os: OperatingSystem) -> Harness { - let mut h = egui_kittest::Harness::builder().with_os(os).with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_os(os).with_size(egui::vec2(1200.0, 800.0)).build_ui_state( |ui, app: &mut SheetApp| { app.logic(ui.ctx()); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(4); h diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0c24e9252..aa218d7f1 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -13,7 +13,10 @@ Then send newline-delimited JSON to `127.0.0.1:7979`. Every request line gets on {"id": 1, "ok": true, "result": {"cell": "B2", "value": "45"}} ``` -Errors come back as `{"id": …, "ok": false, "error": "…"}`. +Errors come back as `{"id": …, "ok": false, "error": "…"}`. Asynchronous work that +finishes later than the reply (a `ui.screenshot` capture) adds its own `request` token to +the reply so the client can match it; a capture that completes after its 8 s deadline returns +the timeout error and writes no file. A line that is not a JSON object with a string `method` (an HTTP request, text that isn't JSON, bytes that aren't UTF-8, a line over 64 MiB) gets one error reply and the server closes the @@ -37,7 +40,7 @@ connection; nothing sent after it runs. Blank lines are skipped. | `ui.ribbon` | `{tab}` | Switch ribbon tab | | `ui.dialog` / `ui.dialog.set` / `ui.dialog.confirm` / `ui.dialog.cancel` | `{name}` / `{field, value}` | Open, fill in and confirm dialogs; `confirm` runs the dialog's command with the same parameters as its OK button | | `ui.backstage` (via `engine.execute`) | `{page?: home\|new\|open\|info\|saveAs\|print\|export\|options\|account\|close}` | Open the File tab's Backstage view on a page, or close it (`ui.inspect` reports the open page as `backstage`) | -| `ui.screenshot` | `{path?}` | PNG of the window (base64 when no path). Animations are settled first: the picture shows the final state | +| `ui.screenshot` | `{path?}` | PNG of the window (base64 when no path). Animations are settled first: the picture shows the final state. On the native app it works while hidden or occluded when the platform still reports the current content size (the request asks for a hidden paint); a minimized window has no capturable size, so its request stays queued until the window is restored and otherwise reports the 8 s timeout. In a **hidden browser tab** the web runner does not implement that paint request, so the capture waits until the tab is shown again or reports the 8 s timeout. Up to 4 outstanding requests, 128 MiB per image and 256 MiB aggregate, reserving the largest image the ±1-pixel rounding rule can retain; a request that would exceed a budget is refused without evicting earlier ones. A resize/DPI/zoom/visibility/occlusion change during capture is retried within the bounded retry budget or reported instead of saving stale pixels; the identity intentionally includes zoom and occlusion even though the expected physical size may be unchanged, so a capture never crosses layout generations. Every reply — success, refusal or timeout — carries the request `token` as `request` | | `ui.set` | `{dark?, formulaBar?, ribbonCollapsed?, animations?}` | UI preferences (`formulaBar` runs `view.formulaBar`) | | `ui.resize` / `ui.focus` | `{width, height}` | Window control | | `app.open` / `app.save` / `app.quit` | `{path, password?}` / `{path?}` | Files and lifetime. `password` opens a password-protected workbook; without it, such a workbook returns an error (never a dialog) | diff --git a/docs/egui-037-local-followups.md b/docs/egui-037-local-followups.md new file mode 100644 index 000000000..dffe3ceed --- /dev/null +++ b/docs/egui-037-local-followups.md @@ -0,0 +1,226 @@ +# egui 0.37 local port — follow-ups and gates + +Status: local implementation on GridCraft `b8771d51fb4da60bd48c13cf313d6d6014dc5e66` with canonical +immutable dependency pins. Final managed full CI +`egui037-gridcraft-candidate-20261024-full-ci-queue-07` succeeded at 2026-10-24 05:21 UTC after both +accessibility repairs: all ten stages passed, with 2,249 tests passed, none failed and one existing +release timing experiment ignored. The representative explicit accessibility/capture checks +passed. The required local precommit CI gate is complete for the formatted runtime source; +this final refresh changes only the two migration documents. Native/platform/performance +acceptance remains separate. No code +push or PR publication is authorized; each requires fresh human approval of contents and validation. + +## Current integration inputs + +- Root Cargo patches select the egui family at `173cc7375d35dc5fbc5b5d0994fdf787f547c5d1` + and winit at `ae48c756a3b81125eb796c0cc6dccf60990d1181`. Selected egui packages are 0.37.0; + there is no machine-local path override or second egui 0.36 source. +- `craft-ui` and `craft-print` both pin `c488caa9fbc2fb1a86e4674887e9b80b598ebbfa`. + The shared capture slot is consumed through that revision. Canonical Cargo.lock SHA256 is + `98209e84412b8ce08bf290ab706c20909389ca6d1e6668aca929f717ad55ea2f`. +- CI, release and FreeBSD select craft-fonts `b076417b5880bd4154bd22f00695b1d28c6bea83`. + Local validation uses command-scoped private Git mirrors for unpublished immutable inputs; + clean remote fetching after approved publication remains a separate gate. +- The shared capture contract fits GridCraft's requirements without changes: one slot per request, + `take()` at most once, `cancel()`/drop makes late callbacks inert, a per-image byte bound, and a + viewport waker that runs after the slot lock is released. GridCraft adds only its admission + budget, request records, geometry epoch and output routing around it. +- The `CaptureSlot` callback holds only a weak reference and never encodes, writes a file or sends + a control reply; GridCraft drains the slot from `logic` and writes outside the slot. + +## Capture lifecycle repair (2026-10-23) + +The previous managed run of this checkout (`egui037-gridcraft-candidate-20261023-tests-01`) +finished with 154 passed and 8 failed: five screenshot/lifecycle tests dropped an unapplied +`TexturesDelta`, two more lifecycle tests failed on the same atlas delta, and +`formula_bar::spill_tests::formula_bar_click_and_theme_presentation` reported the Name Box +`TextInput` without an accessible name. Those failures are historical; final full CI07 passed the repaired lifecycle and +accessibility tests. + +The local source now addresses the fresh review's capture findings: + +- A completion that arrives after the request deadline is refused with the timeout error before + any encoding or file write; timeouts, geometry mismatches, oversize images and removed + requests all release their reservation. Every screenshot reply carries its `request` token. +- Screenshot deadlines and AutoSave pacing use a monotonic clock (`std::time::Instant` natively, + `performance.now()` on wasm). `now_ms()` stays the wall clock for user-visible timestamps such + as Backstage's "last changed". +- `CaptureGeometry::current` reads `ViewportInfo` (refreshed by `run_logic`) for the viewport + `inner_rect` and `native_pixels_per_point * zoom`, falling back to the last screen rect; a + hidden resize is therefore seen without a UI pass. Issuance and drain update the geometry and + its epoch through one helper, and the slot is created at issuance with a per-image bound equal + to the aggregate reservation (which includes the accepted one-pixel rounding). +- A request admitted while the window has no size (minimized or not laid out) reserves + `UNKNOWN_CAPTURE_BYTES` and waits for a real size; it times out with a size-specific message. + Drain runs before admission, and settling counts one non-discarded egui pass per request. +- The geometry identity intentionally includes `zoom`, `visible` and `occluded` even when the + expected physical size is unchanged. A capture is bound to one layout generation; the two-retry + budget followed by an explicit error is the accepted bounded failure, not a reason to compare + only the expected size. +- A synthetic step consumed by a font-gated `ui` early return is offered again on the next pass + (the hook tracks the `cumulative_pass_nr` at injection), and font-set changes wait while an + input step is pending. Re-offering the step is safe: the early-returning pass handled no input, + so egui's state changes from those events (keys down, pointer down) have not applied a step. +- The Name Box and the formula-bar/in-cell editors carry explicit accessible names, so the strict + kittest accessibility check passes without disabling it or the missing-glyph policy. + +### Shared eframe web follow-up: `RequestPaintWhileHidden` + +Native `egui-winit` handles `ActionRequested::wants_paint()` for `RequestPaintWhileHidden` and +paints the hidden viewport. The pinned `eframe` web runner does not: a hidden tab runs only +`run_logic`, and `handle_viewport_commands` handles `Screenshot`/`SetTheme` while every other +command — including `RequestPaintWhileHidden` — falls into its unhandled-command log. A queued +`ui.screenshot` or a queued `ui.key`/`ui.text` step in a hidden browser tab therefore cannot reach +its `ui` pass until the tab is visible; the screenshot request reports its timeout instead. The +fix belongs in the shared eframe web runner (paint a hidden tab on request, with the same +queued-work trigger GridCraft sends from `logic` on native), not in a GridCraft-local channel. +GridCraft therefore sends `RequestPaintWhileHidden` only off wasm (a visible web tab still gets +`request_repaint`). The control protocol documents this web limitation instead of claiming hidden +web screenshots work. + +## E21b — application qualification remains open + +The final canonical egui fork includes `IMEOutput::surrounding_text`, owner/context generation +and the Wayland outgoing adapter; the earlier statement that no shared producer/adapter exists is +superseded. GridCraft must qualify its actual formula-bar, in-cell and text-box editing paths, +including available-empty versus unavailable, stale epoch/serial deletion and commit, deletion-only +batches, composition ownership and two-surface isolation. Full CI07 is not a native IME or complete +E21b/W27 acceptance result. No application-local API stub is introduced. + +## Review repair (2026-10-24) + +- `RequestPaintWhileHidden` is sent only for a queued/settling request that has a capturable size; + an issued capture relies on the `Screenshot` command's `wants_paint`, and a minimized request no + longer forces full hidden frames while it waits. +- A slot-level per-image refusal (an image larger than the reservation) now spends the same bounded + retry as a smaller image, instead of failing only when the window grew. +- The shared deterministic harness, the `Text Box` accessible-name translations, the explicit + `PasteImage`/`MiddleClickPaste` no-op choice, the lifecycle once-per-host-frame tests + (autosave, title, print poll, discard after issuance) and wheel unit/source/phase coverage are in + the working tree. Final full CI07 exercised these repairs and the explicit accessibility + assertions below successfully. + +## Remaining acceptance gates + +- Required local CI is complete: full CI07 passed i18n-report, fmt, strict workspace all-target + clippy, workspace tests, doctest, assets (34 attributed files), layers, WASM, parity and keytips. + The existing timing-sensitive `recalc_with_range_formulas_scales_linearly` test remains ignored + by the normal CI command; it is not a performance result. No additional CI execution is claimed + for this documentation-only refresh. +- Strict `egui_kittest` harness qualification completed for the selected end states: `tests/common/mod.rs` now installs + `theme::pinned_font_definitions()` (bundled faces plus the pinned craft-fonts input, no host + system scan), `SheetApp::use_prepared_fonts()` keeps that set through the first `logic` pass, and + `MissingGlyphPolicy::Panic` stays on. Since `run_steps` does not invoke kittest's accessibility + checker, selected ribbon, locale, pane/menu, dialog, active-editor, backstage, text-box and + capture/repaint end states now call `check_accessibility()` explicitly; these assertions passed in CI07, + without claiming coverage of every UI state. `CRAFT_FONTS_DIR` (and + `CRAFT_FONTS_REQUIRED=1` for acceptance) is the CI contract; without it the deterministic + bundled set is used and the all-catalog language test records the `Ja`/`Ko`/`Zh` gap. +- End-to-end hidden/occluded `ui.screenshot` with a capturable current size and a minimized + request that waits for restore (or times out), through a real wgpu/kittest or native window; the + per-image/aggregate budget stress cases, resize-back/DPI/zoom epoch retries, and native + clipboard/PRIMARY/IME evidence. No native hidden/minimize/restore run has been performed for + this source. +- Native Linux, Windows clone, browser and macOS workflows; performance, text-tessellation, + Windows renderer-construction and print measurements against the recorded baseline. +- Accessibility roles/names beyond the mechanical `Role` port (G3) and native screen-reader smoke + tests. + +## Font provisioning (2026-10-23, font-coverage integration) + +The font gap is addressed in the font set itself. Test harnesses keep +`MissingGlyphPolicy::Panic` and the enabled accessibility-check policy; the representative explicit +checks above are needed to execute that policy on `run_steps` fixtures. No test calls `allow_missing_glyphs()` +or disables the accessibility check, and production still renders Tofu for a character no face +has. + +- `gridcraft-ui-egui` enables `egui/default_fonts` and `egui/monochrome_emoji_fonts`. egui 0.37 + moved Noto Emoji and emoji-icon-font behind the monochrome feature, and with `default_fonts` + alone no installed face has `✕` or `📋` (Hack, which the theme adds as a fallback, has the + arrows and triangles but neither of those). +- `theme::font_definitions_with` appends egui's bundled `Hack` face (arrows, triangles, `↳`) behind + the UI/cell and emoji faces of every family, and appends the optional craft-fonts faces at the + very end. The order is: app/bundled faces, Hack/emoji, system script fallbacks, permitted craft + faces last. A craft face never displaces a system face that already has the glyph. +- `crates/ui-egui/build.rs` embeds the faces named by `CRAFT_FONTS_DIR` through + `crates/ui-egui/src/craft_fonts_policy.rs`, the single policy shared by the build script and the + tests. Nothing reads a fixture path from Cargo configuration: the checked `.cargo/config.toml` + holds only the `xtask` alias, so every local and managed run sets the variable explicitly. With + it unset the generated table is empty and the app builds, tests and runs; a script that no + installed system face and no bundled face covers stays the platform gap it was before (Tofu in + production, and the strict kittest harness still panics on it by design). +- Required faces, matched by exact family and style and by their exact manifest script tags: + `BIZ UDPGothic` `Regular` and `Bold` (`Jpan,Latn`), `NanumGothic` `Regular` (`Hang,Kore`), + `Noto Sans Symbols` `Regular` (`Zsym`) and `Noto Sans Symbols 2` `Regular` (`Zsym`). + `NanumGothic` has no space, and `Noto Sans Symbols` is not `Noto Sans Symbols 2`. +- `CRAFT_FONTS_REQUIRED=1` turns a missing `CRAFT_FONTS_DIR`, a missing `fonts/manifest.txt`, a + malformed manifest row, a missing font file, an unexpected style of a required family or any + missing required face into a build error. +- Barred families never enter the font set, although the shared repository keeps them for other + consumers: `Adobe*`, `Source Han*`, `Source Sans*`, `Source Serif*`, `Noto Sans CJK*` and + `Noto Serif CJK*`. There is deliberately no broad `Noto Sans` prefix, because it would also + reject the permitted `Noto Sans Symbols` faces. +- Web (`wasm32`) embeds only the explicit allowlist: `BIZ UDPGothic` `Regular` and `Bold`, + `NanumGothic` `Regular`, `Noto Sans Symbols` `Regular` and `Noto Sans Symbols 2` `Regular`; no + CJK collection and no document-only face. Those five files are 12,970,840 bytes raw. Raw font + bytes, raw wasm and compressed wasm are **not measured yet**: the size gate is a parent + acceptance item, not a claim made here. +- Native builds opt into the OFL `LXGW WenKai Lite` face from craft-fonts' + `fonts/simplified-chinese-manifest.txt` (the same face DeckCraft uses). The barred `Noto Sans CJK` + collection still never enters; system Han faces still lead when installed. `CRAFT_FONTS_REQUIRED=1` + native builds require the Chinese manifest, ordinary builds tolerate an older checkout without + it, and `WEB_ALLOWED` excludes it. The Chinese samples join the strict script test only when the + face is embedded. +- Native also embeds every other non-barred manifest row (for example `BIZ UDMincho`, + `Shippori Mincho`, `Noto Sans Arabic/Georgian/Hebrew/Thai`). They are OFL and asset-policy safe, + but the actual set is wider than the five required faces; the size gate must measure it rather + than citing only the web allowlist. +- No-font/default mode: the bundled faces supply `↘ ◀ ↑ 📋` and the rest of the interface's + symbols, which `tests/symbol_glyphs.rs` asserts strictly. `✕`, `✓` and `⌃` have no bundled egui + face, so chrome draws them instead of painting them: the Symbol gallery and the close/check + controls paint the code-drawn `Icon::Check`/`Icon::Close` (`icons::fallback_for_symbol`, + `widgets::glyph_button`), and a tooltip whose symbol is missing falls back to `^` + (`theme::symbol_fallback` through `theme::available_text`). Only workbook text still needs a real + glyph, because its characters are the user's data. The test documents `✓`/`⌃` as optional-input + gaps rather than claiming them. +- Extended acceptance mode (`CRAFT_FONTS_DIR` set, and `CRAFT_FONTS_REQUIRED=1` for release): + `tests/symbol_glyphs.rs` requires the exact union `↘ ✕ ◀ ↑ 📋 ✓ ⌃` in both `Proportional` and + `CELL` plus the Korean interface labels, and fails instead of skipping when a loaded or required + set lacks a face. `⌃` comes from `Noto Sans Symbols`, `✕ ◀ ✓` from `Noto Sans Symbols 2`, + `↘`/`↑` from either Symbols face (NanumGothic also has them), and `📋` from egui's emoji faces. +- The required faces live in the pinned `craft-fonts` checkout at + `b076417b5880bd4154bd22f00695b1d28c6bea83`. Point `CRAFT_FONTS_DIR` at that checkout: the older `fixtures/craft-fonts` snapshot has no Korean or + symbol rows, and a loaded set without the required faces fails the extended checks by design. +- `theme::fallback_covers_its_script` is strict whenever a system or craft-fonts face provides the + script, now including Arabic and Georgian; its skip prints the script name and sample. +- The production positive-rectangle fix in `formula_bar.rs`, the `Role::DisclosureTriangle` + queries, the linear per-line editor-height assertions and the explicit accessible names for the + Name Box and both editors are unchanged. + + +## Explicit G1 accessibility repair — 2026-10-24 + +Seven existing integration tests now check rendered ribbon/light-dark, each supported locale, +pivot pane and open menu, fifteen dialogs, active cell/formula edits, backstage pages and active +text-box/committed states. The existing capture callback test now uses a full SheetApp Harness, +asserts the Name Box tree is present, and checks accessibility before issuance and after callback +drain/repaint. Its manually supplied pixels still test callback lifecycle rather than GPU readback; +the explicit no-GPU renderer prevents kittest from silently turning it into a platform test. +One-callback, request-token, one-reply and released-reservation assertions remain. + +All eight affected tests (seven integration tests and the capture unit test) passed in full CI07. +The four CI06 failures exposed unnamed Options/ribbon ComboBoxes, custom format input and pivot +layout control; visible localized labels or precise semantic names now identify them. The fresh +source review found no blocker; CI07's formatting changed only wrapping/trailing commas in those +four reviewed production files. Original roles, input sequences and behavior assertions remain. +Full CI05 is historical pre-repair evidence and CI06 remains a failed run. + +Actual native screenshots/hidden/minimize/restore, screen-reader output, application E21b IME and +platform/performance acceptance remain open. Full CI07 uses the canonical lock, required craft-fonts +input, command-scoped exact-source mirrors and the private Vulkan loader; it is not host-loader or +clean public-fetch qualification. Exact job/log hashes, review union, complete final source inventory +and documentation-only delta are preserved in local task evidence under +`egui037-migration-20261022/final-landing/gridcraft-final-qualification`. + +Revision: 2026-10-24 — replaced pending accessibility/CI statements with actual full CI07 results, +corrected the shared outgoing IME inventory, and retained native/performance/publication gates. diff --git a/docs/egui-037-migration-plan.md b/docs/egui-037-migration-plan.md new file mode 100644 index 000000000..6d706d5a6 --- /dev/null +++ b/docs/egui-037-migration-plan.md @@ -0,0 +1,1335 @@ +# GridCraft — egui 0.37 migration plan + +Status: local implementation based on GridCraft `b8771d51fb4da60bd48c13cf313d6d6014dc5e66`. +Canonical pins are egui `173cc7375d35dc5fbc5b5d0994fdf787f547c5d1`, winit +`ae48c756a3b81125eb796c0cc6dccf60990d1181`, craft-ui/craft-print +`c488caa9fbc2fb1a86e4674887e9b80b598ebbfa` and craft-fonts +`b076417b5880bd4154bd22f00695b1d28c6bea83`. Canonical lock SHA256 is +`98209e84412b8ce08bf290ab706c20909389ca6d1e6668aca929f717ad55ea2f`. +Final managed full CI `egui037-gridcraft-candidate-20261024-full-ci-queue-07` succeeded on +2026-10-24 at 05:21 UTC after the explicit-accessibility and accessible-name repairs. All ten stages +passed: i18n-report, fmt, strict workspace all-target clippy, workspace tests, doctest, assets, +layers, WASM, parity and keytips. Actual totals are 2,249 passed / 0 failed / 1 existing timing +ignore, including 202 UI unit, 769 UI integration, 25 keytip integration and 2 font tests. The +representative explicit accessibility/capture checks passed. This completes the required local +precommit CI gate for that formatted runtime source; the final documentation refresh changes only +the two local migration documents. Native/platform/performance and application E21b qualification +remain open; canonical pins and full CI do not imply the complete P8 contract is qualified. +Every push/tag/new PR requires fresh explicit human approval after contents and validation review; +none is granted. Earlier compiler logs, source anchors, baseline inventories and proposed implementation steps +below are historical design records; this status and the final qualification update govern current +pins and acceptance. Final source/review/CI bindings are retained under +`egui037-migration-20261022/final-landing/gridcraft-final-qualification` in local task evidence. + +Scope: `storytold/gridcraft` only. The shared `storytold/craft-libs`, egui fork and winit fork are +dependencies of this plan, not files this plan changes. Application document models, commands, undo +semantics, XLSX persistence and UI policy remain owned by GridCraft. + +## 1. Baseline, source identity and targets + +### 1.1 Exact source baseline + +| Item | Revision / value | +| --- | --- | +| GridCraft checkout | `/home/codex/work/CraftApps/egui037-migration-20261022/gridcraft-implementation` | +| GridCraft baseline SHA | `b8771d51fb4da60bd48c13cf313d6d6014dc5e66` (current `main` merge base for this migration; the earlier `ac8da7d4` release baseline is retained as historical evidence below) | +| Workspace version / edition / MSRV | `0.11.1`, edition 2024, declared `rust-version = "1.95"` | +| Lockfile | `Cargo.lock`, format version 4 | +| Current egui family | selected packages at 0.37.0 from canonical173cc737; `egui_extras` declared at 0.37 but unused | +| Current winit / wgpu | winit 0.30.13 at canonical ae48c756; wgpu 30.0.1 | +| Current accesskit / kittest | accesskit 0.25.1, accesskit_consumer 0.39.1, kittest 0.5.0, accesskit_winit 0.34.1 | +| Current skrifa | 0.44.0, shared by `epaint` and `gridcraft-ui-egui` | +| Shared UI dependency | craft-ui and craft-print both pin canonical c488caa9; the complete 0.37 UI/capture tree is included | +| Shared capture helper | `craft-ui` CL-2 provisional capture slot; GridCraft/DesignCraft adapters are the pilot and precede CL-2F. CL-1 and CL-2F are the shared gate for the eleven craft-ui consumers | +| Target upstream release | egui annotated tag `0.37.0`, peeled commit `841a328ba2dd8f6996beb7189ebb7fc00e668a2a` (2026-10-15) | +| Historical review candidate egui (superseded by current immutable pin) | `aiongg/egui` `craft/0.37` at `a4f9a17f64a6bae61350fb85804c9df0d9865916`; inspect only, never publish this branch | +| Historical review candidate winit (superseded by current immutable pin) | `aiongg/winit` `craft/0.30.13-lang` at `8b35388bd719e8cc86a95971ba0bc026c9a8f058`; published base `storytold/winit` `craft/0.30.13` at `ffae5198770d7730e718043f035a772b7dbc3b1b` | +| Current implementation egui/winit pins |173cc7375d35dc5fbc5b5d0994fdf787f547c5d1 / ae48c756a3b81125eb796c0cc6dccf60990d1181; full P8/E21b and platform/performance acceptance remain separate, explicitly unqualified gates | +| Stock comparison | egui 0.36.2 `49682f8baa058bf49e011035cfbd6e825f88a5ef`, 0.37.0 `841a328b…`; stock 0.37 is a measurement control only, never a production stage or pin | +| Existing shared update | `storytold/craft-libs` PR 199, branch head `b68de8c5f4f1c060904d211a7b1320aa0f7bce78`; open and conflicting at inspection | +| Historical diagnostics | `/home/codex/work/ssd2/task-evidence/egui037-audit-20261022/{fork,stock}/gridcraft.errors` | + +The existing audit merge `ec8840af8c5fda695cf195cdd05e5ae078415c07` is not a base: its +`Cargo.lock` still resolves egui 0.36.2 while the source expects 0.37. The PR-199 branch itself has a +0.37 registry lock but no fork/winit pin. Neither is accepted evidence for GridCraft. + +`rust-version = "1.90"` is already inconsistent with the current eframe 0.36.2, whose manifest says +Rust 1.95. Cargo only checks the active compiler, so this has not surfaced. The migration must align +the workspace `rust-version` and CI toolchain to 1.95 (or the higher of the selected fork's MSRV), +because 0.37.0's egui/epaint/eframe manifests declare 1.95. + +### 1.2 Build targets and entry points + +| Target | Package | UI path | Notes | +| --- | --- | --- | --- | +| Desktop native | `gridcraft` (`apps/gridcraft`) | `App::logic` / `App::ui` wrapping `SheetApp` | eframe trait: `fn logic(&mut self, ctx: &Context, frame: &mut Frame)` and `fn ui(&mut self, ui: &mut Ui, frame: &mut Frame)` (plus `raw_input_hook`); `SheetApp::logic(&Context)`/`ui(&mut Ui)` take no `Frame`. The split exists on 0.36.2 and is **not** 0.37-only | +| Web | `gridcraft-web` (`apps/gridcraft-web`) | `WebShell::logic` / `WebShell::ui` wrapping `SheetApp` | wasm32, eframe web runner, WebGPU with WebGL2 fallback, rfd async picker, no TCP control channel | +| UI library/tests | `gridcraft-ui-egui` | `SheetApp::logic`, `SheetApp::ui`, `examples/snapshot.rs` | 85 integration test files (750 `#[test]` in `tests/`) plus 153 in-crate unit tests: 903 `#[test]` attributes in the crate by static count | +| CLI/headless | `gridcraft-cli` | none | no egui dependency; must keep building/tests | +| Shared controls | `craft-ui` (`printing` only) | `printing::State::show` inside dialogs | GridCraft does not use craft-ui docking/tabs/buttons | +| macOS native menu test | `apps/gridcraft/tests/mac_menu_appkit.rs` | AppKit/muda, no egui | must still run on macOS | +| Tooling | `xtask` | `ci`, `layers`, `wasm`, `assets`, `parity`, `keytips` | `cargo xtask ci` is the repository gate | + +There are no child `ViewportId`s, deferred viewports, docking areas, audio/playback queues, or +nested workspaces in GridCraft. The single root viewport is the only egui window. `view.newWindow` +is an engine document command, not an egui child window. The plan therefore does **not** add +viewport routing or docking migrations that GridCraft does not use. + +### 1.3 Historical pre-migration manifest/feature inventory + +The following inventory describes the baseline before implementation. The current root adds +canonical family/winit patches, both shared-library pins are c488caa9, native clipboard is enabled, +and the current lock/pins are recorded above. Baseline absence of patches is not current state. + +- Root `Cargo.toml`: workspace resolver 3; `egui`, `egui_extras`, `eframe`, `egui_kittest`, + `epaint_default_fonts` pins; `skrifa = 0.44`; no `[patch]` section anywhere in the repository. +- `crates/ui-egui/Cargo.toml`: `egui` (workspace, default features), `craft-ui` git revision, + `skrifa`, Linux-only `zbus = "=5.19.0"`, dev `egui_kittest` with `wgpu` + `snapshot`, dev + `epaint_default_fonts`, `gridcraft-xlsx` test support, wasm `web-sys`. +- `apps/gridcraft/Cargo.toml`: eframe `default-features = false` plus `default_fonts`, `wgpu`, + `accesskit`, `wayland`, `x11` at baseline; G1 adds native `clipboard` to preserve the + 0.36 integration-forced OS clipboard. `craft-print` git revision; desktop `rfd`, `arboard` + (`wayland-data-control` on Linux/FreeBSD), Windows `pollster`, macOS `muda` and + `fmv-macos-events`, macOS AppKit dev-dependencies. +- `apps/gridcraft-web/Cargo.toml`: `egui` workspace; wasm-only eframe `default_fonts`, `wgpu`, + `accesskit`; rfd, wasm-bindgen, js-sys, web-sys feature list. +- All other crates: no egui dependency. `xtask/src/layers.rs` already classifies + `gridcraft-ui-egui` as the only crate allowed `egui`/`wgpu`. +- `.cargo/config.toml` only aliases `xtask`; no rustflags, target overrides or patches. +- No `vendor/` directory, no `[patch]` entries, no nested workspace, no examples or benches with + egui except `crates/ui-egui/examples/snapshot.rs`. +- CI: `.github/workflows/ci.yml` runs `cargo xtask ci` (fmt, clippy `-D warnings`, workspace tests, + assets, layers, wasm, parity, keytips). FreeBSD, Nix-evaluation, packaging-lint, release and + Windows ARM64 workflows exist; none runs an egui snapshot job today. + +The 0.37 graph also changes the egui feature contract: + +- `egui` and `epaint` no longer enable `default_fonts` by default (`#8583`). + `gridcraft-ui-egui` must explicitly request `egui/default_fonts` (and update + `epaint_default_fonts` to 0.37) to make its production fallback policy explicit. + `egui_kittest`'s normal `egui` dependency inherits `default-features = false`. Only + kittest's own dev-dependencies enable `default_fonts`/`monochrome_emoji_fonts`, and + dev-dependencies do not propagate to consumers. Package-scoped kittest/snapshot builds are + therefore font-empty unless `gridcraft-ui-egui` itself enables `egui/default_fonts` or the + harness installs pinned fonts. Inspect the resolved feature graph for each production and + test root; do not infer fonts from kittest's dev-dependencies. +- `egui_kittest` must move to 0.37/`kittest` 0.5; its `wgpu` and `snapshot` features remain. +- `egui_extras` is declared but unused; keep it out of the graph or pin it consistently, but do not + delete the workspace entry as part of an unrelated cleanup. + +## 2. Historical diagnostics and what they already prove + +The audit logs from 2026-10-22 (not current acceptance) list five fork errors in GridCraft: + +1. `control.rs`: `egui::UserData` no longer exists. +2. `control.rs`: `egui::Event::Screenshot` no longer exists. +3. `dialogs.rs:1969`: `Role::SelectableLabel` no longer exists. +4. `ribbon.rs:146`: `Role::Checkbox` no longer exists. +5. `theme.rs:282`: `FontData::font` is no longer `Cow<[u8]>`; it is `Arc + Send + Sync>`. + +The stock log adds the same font-family API errors at `theme.rs` 993/1065/1098/1104/1119 (comparison, +`to_vec`, `len`, `Cow` construction). The current source confirms those anchors. These errors are +only the first compilation wall; they do not cover the behavioural work in sections 3–9. + +## 3. Actual incompatibilities and design decisions + +### 3.1 Root `Ui`, lifecycle and hidden-window logic + +GridCraft already uses the correct 0.36/0.37 shape: + +- `crates/ui-egui/src/lib.rs:1148` `SheetApp::logic(&Context)` handles control polling, font + readiness, desktop theme, zoom, close guard and the asynchronous file inbox; the screenshot + deadline/slot drain moves here in 3.5. +- `crates/ui-egui/src/lib.rs:1270` `SheetApp::ui(&mut Ui)` lays out the title bar, ribbon, + backstage, formula bar, status bar, tabs, panes and central grid. +- `apps/gridcraft/src/main.rs` `impl eframe::App for App` calls `self.0.logic(ctx)` in `logic` and + `self.0.ui(ui)` in `ui`; `apps/gridcraft-web/src/web.rs` does the same for `WebShell`. +- `egui_kittest` harnesses call `app.logic(ui.ctx()); app.ui(ui)` through + `Harness::build_ui_state` (for example `tests/interaction.rs:10`). `examples/snapshot.rs` uses the + same closure and `harness.render()`. + +Do **not** rewrite this into `Context::run_pass`/`root_ui` calls. `Context::root_ui` panics while +`App::ui`/`run_ui` already holds the root `Ui` (`#8535`). Panels are already created on the passed +root `Ui`; `CentralPanel::show(ui, …)` in `SheetApp::ui` stays. Tests keep `Context::run_ui` +(`#8535` keeps it as a wrapper over `run_pass` + `root_ui`). + +**Actual lifecycle contract.** Both native (`eframe/src/native/epi_integration.rs::update`) and web +(`eframe/src/web/app_runner.rs::logic`) call `App::logic` and then `App::ui` *inside* the +`Context::run_ui` closure, once per layout pass. A multi-pass frame therefore calls `logic` more +than once. When nothing is painted, eframe calls `App::logic` through `Context::run_logic` instead; +that call leaves events, input time and layout stale and must not consume UI input. After a normally +completed `run_ui`, `current_pass_index()` is zero even after several passes (`end_pass` takes the +output and only `run_dyn` restores the count before the next pass), so it cannot mark hidden work. +`ViewportInfo::visible()` reports minimized/occluded (`None` unknown), not the phase: an +**issued** screenshot command or `ViewportCommand::RequestPaintWhileHidden` forces a full hidden UI +pass and paint. A screenshot merely queued in `logic` has not reached that point (section 3.5). +Use consumed queues, request/slot identity and monotonic deadlines, not a visibility flag or pass +index, to decide background work; input handling belongs in `ui`. + +Only these background paths move into `logic`: the control poll (already there), printing feedback +polling, screenshot result drain/deadlines, autosave, the window title and the existing queued file +inbox. Do not move `ribbon_keys`, pointer handling, `ctx.text_edit_focused()` or event-list scans. + +Two lifecycle defects must be fixed while the graph changes: + +1. **Autosave currently runs in `ui`** (`lib.rs:1331`). Move the decision into `logic`, keep + `store_view_state()`/`file.save` there, and call `ctx.request_repaint_after(2 s)` while AutoSave + is on and the active workbook is dirty and has a path. Keep one monotonic `last_autosave` check + so a forced hidden UI pass (logic called again) cannot save twice. Preserve the existing guards: + no cell editor, no text-box editor, `.xlsx` path, `last_autosave > 2000 ms`. +2. **Printing feedback is polled twice per frame, and neither path is `SheetApp::logic`.** + `apps/gridcraft/src/main.rs:43` calls `self.0.poll_printing(ctx)` immediately after + `self.0.logic(ctx)`, and `SheetApp::ui` calls `poll_printing` again at `lib.rs:1272`. Move the one + poll into `SheetApp::logic`, delete the `ui` call and delete the desktop app's extra call. The web + shell has no extra poll. `printing::State::poll` and `print_feedback_seen` remain idempotent; the + direct calls in `tests/native_print.rs:40,121,124` move to a real `logic` drive, or stay as + explicit helper tests with a name that says they bypass the lifecycle. + +Also move the window-title update (`lib.rs:1342`) into `logic`, next to the close/autosave state. +`ViewportCommand::Title` is valid outside a pass. This makes a control-channel rename while hidden +update the native title when the window returns. Keep `last_title` in `GridState`/`SheetApp` so it +is sent only on change. + +**Multiple UI passes and synthetic input.** `Context::run_dyn` takes `RawInput` fields once per +frame, so a second pass sees no pointer/key events and must not replay a click. It can still re-run +non-input side effects. `App::raw_input_hook` runs once per frame, before `run_ui` or `run_logic`, +not once per pass; in a hidden logic-only tick it can run with no UI to consume its events. Do not +dequeue-and-discard a gesture in the hook. Keep one unapplied gesture step in app state and carry it +across repeated hidden logic ticks until a full `ui` pass consumes it (eframe also retains an +unconsumed `RawInput` for the next pass); do not offer the next step while one is unapplied, and +keep press and release as distinct steps rather than collapsing them. While a step is unapplied, +`logic` sends `ViewportCommand::RequestPaintWhileHidden` every frame (the command holds for one +frame) so a UI pass runs. An issued screenshot command requests paint for its own capture, but it +does not make synthetic input run, nor does it issue itself. Audit `SheetApp::ui` for +pass-dependent effects: + +- `control::issue_screenshots` runs after every widget but before `collect_screenshots`, autosave + and the title update; it is the last UI-only command issuer, so no GridCraft widget can request a + discard after it. It issues once per admitted request (section 3.5). Do not gate it on + `will_discard()`: that query only reports a discard already requested and cannot predict a later + one. +- `control::collect_screenshots` is replaced by a logic-side slot drain (section 3.5). +- `poll_printing` moves to `logic` (above). +- `ctx.send_viewport_cmd(ViewportCommand::Title)` and `store_view_state`/autosave become `logic` + calls. +- `backstage`, dialog and popup state changes are input-driven; the second pass has no events, so + they do not repeat. +- `grid::show` writes `grid.rect`, `grid.geo`, `grid.cells_rect` and scroll state. These are + recomputed on every pass from the same `SheetApp`/session; a discarded pass may leave values from + the discarded layout, but the final pass overwrites them. + +The discard-injection hook cannot be a `#[cfg(test)]` field used by `crates/ui-egui/tests/*`: +integration tests compile the library as a normal dependency, so `cfg(test)` is off there. Put the +private `force_discard_once` field and the discard tests inside a `#[cfg(test)] mod` in +`crates/ui-egui/src/lib.rs`, where unit tests can set it and call `ui`/`control` directly; keep +integration tests to observable commands without the hook. Tests: +- click a ribbon command, then a hook requests `request_discard` **after** screenshot issuance in + that pass → exactly one journal entry/undo step, one title command, one autosave, and the + screenshot request completes exactly once (its command is appended to the combined pass output; + no duplicate command is issued); +- a forced hidden UI pass after a completed multi-pass frame still runs autosave/printing once and + applies queued synthetic input, while a plain hidden logic tick runs no `ui`; +- repeated hidden logic-only ticks with a queued gesture retain one unapplied step (press and + release still distinct) and a later full UI pass applies it exactly once; a queued-but-not-issued + hidden/occluded screenshot receives `RequestPaintWhileHidden` and completes once a current size + is reported (a minimized request waits for restore or reports its timeout); +- `logic` only (no `ui`) with a dirty autosave workbook → exactly one save after the delay and no + duplicate when a forced `ui` pass follows. + +### 3.2 `WidgetType` → `accesskit::Role` + +Sixteen call sites use the deprecated `WidgetType` alias: + +| File | Sites | Mapping | +| --- | --- | --- | +| `crates/ui-egui/src/backstage.rs` | 5 | `WidgetType::Button` → `Role::Button` | +| `crates/ui-egui/src/border_preview.rs` | 2 | `Button` and `Label` → `Role::Button`, `Role::Label` | +| `crates/ui-egui/src/dialogs.rs:1969` | 1 | `SelectableLabel` → `Role::Button` with `WidgetInfo::selected(Role::Button, enabled, selected, label)`; keep and assert the selected-state claim | +| `crates/ui-egui/src/grid.rs:3421` | 1 | `Button` | +| `crates/ui-egui/src/ribbon.rs` | 6 | `Checkbox` → `Role::CheckBox`; the rest `Button` | +| `crates/ui-egui/src/tabs.rs:73` | 1 | `Button` | + +Use `egui::Role` (re-export of `accesskit::Role`), not a local enum. Preserve the existing +`selected`/`enabled` arguments and labels; the border-style items in `dialogs.rs` use +`WidgetInfo::selected` so their checked state survives. Add contract tests against the actual +controls: `Role::CheckBox` with checked state on Dark Mode (the only `WidgetType::Checkbox` site; +the custom-painted ribbon/tab controls are not checkboxes), `Role::Button` plus selected state on +Format Painter and a selected Backstage row, `Role::Button` on `All sheets` and on each sheet tab, +and `Role::Label` on the border preview. The custom-painted sheet tabs currently expose no widget +info, so G1 gives each one `WidgetInfo::selected(Role::Button, …)` with its sheet name before +asserting selection. Compilation alone is not evidence of a role change. + +### 3.3 Deprecated IDs + +`Id::new` and `Ui::id` still compile in 0.37 but warn, and `cargo xtask ci` denies warnings. Replace +all of them mechanically; the values are identical: + +- 46 production `Id::new(` occurrences and 10 test occurrences (excluding `FontId::new`/ + `LayerId::new`), across `grid.rs` (17), `formula_bar.rs` (6), `panes.rs` (5), `paper_view.rs` + (4), `widgets.rs` (3), `ribbon.rs` (3), and one each in `cell_pictures.rs`, `credits.rs`, + `dialogs.rs`, `floating_pictures.rs`, `i18n.rs`, `printing.rs`, `tabs.rs`, `text_box.rs`. +- 9 `ui.id()` uses: `tabs.rs:98`, `widgets.rs:149,150,246,327`, `paper_view.rs:470`, + `ribbon.rs:44`, `grid.rs:867,920` (nine lines; `widgets.rs` contributes four). +- No `UiBuilder::id` or `AsId` uses. + +Replace with `Id::unique` and `Ui::scope_id`. Do not change the source values or add salts. The +debug ID tracker in 0.37 is stricter about unstable IDs; a `ui.scope_id().with(...)` under a +reordered row is stable only if the salt is stable. Existing salts already include row/column/ +document identity; keep them. Add a focus-retention test: focus the name box, switch language, +open/close the comments pane, and verify focus stays on the name box (not on another field). + +### 3.4 Font data API + +`epaint::text::FontData.font` changed from `Cow<'static, [u8]>` to +`Blob = Arc + Send + Sync>`. `FontData` still derives `Clone` and implements +`PartialEq`/`Debug` itself; the `Blob` type has no `PartialEq`. Tests that want pointer identity +must therefore compare the blobs with `Arc::ptr_eq`, not rely on `FontData` equality. + +Concrete changes in `crates/ui-egui/src/theme.rs`: + +- `try_load` (`theme.rs:244`): build `FontData::from_blob(blob, *index)` or + `FontData { index, ..FontData::from_static(bytes) }`. +- `font_file` (`theme.rs:263`): cache `Blob` rather than `&'static [u8]` in the existing + `Mutex>`. Return `Option`; clone the `Arc` for each `FontData`. This preserves + the existing “one file loaded once” memory intent and makes `Arc::ptr_eq` meaningful. +- `drop_unparseable_fonts` (`theme.rs:281`): `font_parses(fd.bytes(), fd.index)`. +- `font_definitions_with` (`theme.rs:457`) and `load_fallback` continue to insert `Arc::new(fd)`; + `font_data` is still `BTreeMap>`. +- `crates/ui-egui/src/fonts.rs:355` `bind_family` uses `FontData::from_owned(bytes)`; unchanged, + except any `.font` field access must use `.bytes()`. + +Tests in `theme.rs` must change, not be deleted: + +- `fallback_faces_load_each_file_once`: compare `!Arc::ptr_eq(&a.font, &b.font)` instead of + `a.font != b.font`. +- `unparseable_font_files_are_rejected_not_fatal`: `fd.bytes().to_vec()`, `fd.bytes().len()`. +- `interface_fonts_are_read_once_and_shared_with_egui`: with the `Blob` cache, assert + `Arc::ptr_eq(&first.font, &second.font)` and `first.bytes() == epaint_default_fonts::UBUNTU_LIGHT`. + This is stronger than the old `Cow::Borrowed` pointer check and proves egui shares the bytes. +- Keep `drop_unparseable_fonts` as the last line of defence. It is not a vendored patch; it is the + app's protection against epaint panicking on a corrupt system font (#226), and 0.37 still parses + faces during `set_fonts`. + +Add `egui = { workspace = true, features = ["default_fonts"] }` to +`crates/ui-egui/Cargo.toml` (or enable it on the workspace dependency) and update +`epaint_default_fonts` to 0.37. Without this, `FontDefinitions::default()` is empty in +`font_definitions_with` in a production library graph without another font-enabling dependency. +The consumer's `egui_kittest` dev-dependency does **not** propagate kittest's own +`default_fonts`/`monochrome_emoji_fonts` dev-features, so the test harness must enable +`egui/default_fonts` itself or install pinned fonts. + +### 3.5 Screenshot callback — exact lifecycle + +Current code (`crates/ui-egui/src/control.rs`): + +- `Shots { token, queued: Vec<(token, at_ms, frames)>, pending: Vec<(token, path, Sender, deadline)> }` + (`control.rs:70`). +- `poll` enqueues a screenshot request and a pending reply (`control.rs:450`). +- `issue_screenshots` (`control.rs:477`) waits 100 ms and three UI frames, then sends + `ViewportCommand::Screenshot(UserData::new(token))`. +- `collect_screenshots` (`control.rs:493`) scans `Event::Screenshot` events in the next input, + writes/replies, and expires pending requests after 8 s. +- `selection_glide` and `grid` use `app.shots.in_flight()` to freeze animation while a capture is + pending. + +0.37 replaces both the input event and `UserData` with a one-shot callback. GridCraft adopts the +shared CL-2 completion slot; it does not add a local `SyncSender` channel or a second token map. +The selected topology is CL-2 provisional slot API -> GridCraft and DesignCraft adapters against +that exact revision -> CL-2F API freeze after joint review. The slot owns one result and the +request record owns the slot; the screenshot callback holds only a `Weak` reference to it: + +```rust +// Sketch of the shared CL-2 ownership, not an existing API. +let weak = Arc::downgrade(&request.slot); // app owns the strong slot and its waker +let callback = egui::ScreenshotCallback::new(move |image: Arc| { + // Short lock only; never encode, write, reply, run commands or touch app state here. + let Some(slot) = weak.upgrade() else { return }; // canceled/dropped: release image now + let wake = slot.offer(token, image); // one result, replaces nothing + drop(slot); // release the lock before waking + if let Some(wake) = wake { wake.wake(); } // slot-owned UI waker, after unlock +}); +ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(callback)); +``` + +Design: + +- `Shots` keeps request identity, path/reply routing, settle counter, 8 s deadline and the + `in_flight()` query. Replace the `queued`/`pending` vectors with one slot per admitted request + from `craft_ui::capture` (CL-2 provisional revision); do not add a second channel. The app owns + the strong slot and the waker; the slot stores at most one `Arc`, records the + issuance-time UI logical geometry and geometry epoch, and is released on drop/cancel/timeout. + The callback holds only `Weak`, unlocks before invoking the slot's waker, and holds no strong + `Context`; it does not encode, write, reply or request its own repaint. A late callback after + cancel or app shutdown upgrades to nothing and drops the image without a file or reply. + Correlation is slot ownership plus the request token; callback order is irrelevant and a late + image cannot bind to a new request. +- Admission is bounded by **count and aggregate bytes before issuance**, with checked arithmetic, + not just by the number of slots. Adopt the shared capture defaults: 4 outstanding requests, + 128 MiB per image and 256 MiB aggregate reserved for in-flight/completed images; an app may + choose stricter explicit limits. Reserve checked `width * height * 4` bytes for the expected + capture from the current viewport logical size and `pixels_per_point`, rounded up and capped by + the surface/`max_texture_side` **before issuing**; reject with a clear “screenshot budget + exceeded” reply when count or bytes would exceed the budget, without evicting earlier requests. + The reservation and the `CaptureSlot` per-image bound are the largest image `validate_image` + accepts (one pixel of rounding per axis), so the aggregate bound is strict for retained bytes; + a viewport with no size yet reserves the per-image maximum and waits for a real size or the + deadline instead of being refused as an overflow. + Revalidate the geometry immediately before issuing. Record the issuance-time UI logical + content/screen geometry and a geometry epoch and bind them to that image; cancel or retry on + **any** intervening resize/DPI/zoom/surface change before save, even if the value changes back + to its original. The callback supplies pixels only, so derive scale from the returned image + dimensions and the recorded logical viewport, and verify aspect/rounding. On completion reject + an oversized, stale or epoch-mismatched image, release its bytes and reservation, and keep + retained slot bytes back at zero after drain/shutdown. The renderer/readback transient + allocation is recorded separately in peak RSS; a post-callback size check alone does not prove + the peak is bounded. +- `issue_screenshots` runs after every widget but before autosave and the title update, so no + GridCraft widget can request a discard later in that pass. Issue once per admitted request (one + callback, one command); `Context::run_dyn` appends each pass's output before it checks + `requested_discard()`, so a command issued in a discarded pass still reaches the integration. + Settling counts one real pass per request (`cumulative_pass_nr`, so repeated `ui` calls in one + pass count once) and skips a pass that has already asked to be discarded; a discard requested + after issuance can still cost one counted pass, but the image is geometry-validated at drain. + `request_repaint_after` + alone wakes `logic` only, and a queued request has not yet issued its paint-requesting command. + Therefore, while a request is queued or settling **with a capturable size**, `logic` sends + `ViewportCommand::RequestPaintWhileHidden` on each frame until `issue_screenshots` runs; once + issued, the `Screenshot` command's `wants_paint` forces the painted frame, so no further hidden + paint request is sent. A minimized request has no capturable size and sends none: its hidden + full-frame wait cannot reach issuance, and the restore-triggered pass issues it instead. Also + keep the 16 ms `request_repaint_after` from both `ui` and `logic` for completion and timeout. +- `collect_screenshots` becomes `drain_screenshots(app, ctx, now)` called from `logic` (not `ui`). + G1 includes the drain, deadline/timeout handling and the basic exactly-one reply, so its own + screenshot smoke can pass; G2 owns the adversarial byte-budget/discard stress. For each completed + slot: validate request identity, size, geometry/epoch and deadline, take the image once, then + encode/write/reply outside the slot lock. A completed request with no live record drops its image + without a reply. +- `logic` checks deadlines and replies with the existing error text (`control.rs:521`), updated if + it still tells users to make the window visible (an issued screenshot command paints a hidden + window; a queued one reaches issuance through `RequestPaintWhileHidden`). It must not hold a + slot lock while calling `reply.send`. +- Request IDs use `wrapping_add(1)` and skip zero; never index arrays by token. A reused viewport + or document does not matter because the slot dies with its request record. +- `in_flight()` remains true while any request record has an unfinished slot, so animation stays + frozen through a late/queued capture. +- If CL-2's stress tests expose a weakness, fix it in CL-2/CL-2F with the DesignCraft adapter in + the same review; a GridCraft-local channel is not a fallback. + +Tests (`crates/ui-egui/src/control.rs` unit tests or `tests/screenshot_callback.rs`): + +1. Build a `Context`, run `issue_screenshots` for a request and inspect the + `ViewportCommand::Screenshot(callback)` from `FullOutput`; complete it with a 2×2 red image; + run `logic`; assert one PNG/path reply with `width/height`, and the file bytes decode. +2. Two requests with distinct slots: complete out of order; each reply gets its own image/path. +3. Timeout/cancel/drop: inject a fake clock, cancel one request and time out another, then complete + both callbacks; assert no second reply, no file write and retained bytes return to zero. +4. Count and byte budgets separately: with an aggregate budget below four 4K captures and a + per-image budget below one oversized capture, admission stops on bytes before the count; extras + are rejected once with an error; completed/oversized images release their reservation. Assert + peak retained bytes, not only slot count. +5. Discard after issuance: a unit-test hook calls `request_discard` immediately after + `issue_screenshots`; assert exactly one command was issued, the capture completes exactly once, + and the reply/file is written once. Do not require a post-pass API. +6. Resize/DPI/zoom: change viewport size/scale between admission and issuance, and again between + issuance and completion including a change back to the original values; assert the geometry + epoch mismatch retries within the deadline or returns the size/geometry error and releases the + reservation; never combine old pixels with current geometry. The identity intentionally keeps + `zoom`/visibility/occlusion, so a bounded retry failure is acceptable instead of comparing only + the expected physical size. +7. End-to-end `ui.screenshot` through `ControlRequest`/`handle` in a kittest harness with wgpu: + file and base64 forms, invalid path error, hidden/occluded-window capture with a current size + (queued admission sends `RequestPaintWhileHidden`, then the issued command paints the hidden + window), a minimized request that waits for restore and otherwise times out, and no reply + after drop. + +### 3.6 `MouseWheel` event source + +0.37 adds the required `source: MouseWheelSource` field to `Event::MouseWheel` (`#8665`). Every +literal constructor must add `source`, preserving the test's intent: + +- `crates/ui-egui/tests/it/wheel_zoom.rs`: line events use `source: MouseWheelSource::Unknown` + (simulated wheel); point/page units and the `Wheel`, `Trackpad` and `Momentum` sources are now + covered, including a gesture with no `TouchPhase::End`. +- `crates/ui-egui/tests/it/sheet_tabs.rs` and `tests/it/filter_list_scroll.rs`: `Unknown`. + +Production matches (`keytips.rs:64`, `grid.rs:2945`) use `..` or a wildcard and need no field +change. Add tests for `Wheel`, `Trackpad` and `Momentum` as well, because the 0.37 wheel smoothing +changes (`#8662`, `#8678`) and the winit W13 source reporting affect the notch counting in +`grid::show` (`grid.rs:591-637`) and Ctrl+wheel zoom. The expected zoom ladder +(`grid::wheel_zoom_step`) and scroll distance must not change silently. `Event::PasteImage` and +`Event::MiddleClickPaste` are new variants; no exhaustive match exists today, but new handlers must +choose explicitly (section 6.4). + +### 3.7 Style and geometry pinning + +0.37 changes default widget styling (`#8617`, `#8445`): widget `corner_radius` rises from 2/3 to +4/6, button padding from 4/1 to 8/2, and `interact_size.y` from 18 to 20. GridCraft already sets +`button_padding = (6,3)` and `interact_size.y = 22` in `theme::apply` (`theme.rs:804`, values at +831–832), but does not +set widget corner radii. To avoid an unintended restyle: + +- In `theme::apply`, set `v.widgets.noninteractive.corner_radius = CornerRadius::same(2)`, + `inactive = 2`, `hovered = 3`, `active = 2`, `open = 2` (the 0.36 defaults), in both light and + dark themes. Keep the existing `window_corner_radius = 10`, `menu_corner_radius = 8`, popup + shadow, and all token colours. +- Do not rely on `Visuals::dark()`/`light()` defaults for anything GridCraft paints with egui + widgets. GridCraft's custom-painted ribbon/grid already set their own radii. +- `TextEdit::horizontal_align(Align)` is deprecated (`paper_view.rs:489`). Replace with + `TextEdit::align(Align2)`, preserving vertical top alignment: + `[LEFT_TOP, CENTER_TOP, RIGHT_TOP][e.section.min(2)]`. Do not use `Align2::LEFT_CENTER`, which + would move the header/footer text vertically. +- 0.37 TextEdit changes (`#8628` unclipped growth, `#8420` min size, `#8763` focus surrender) affect + the formula bar, in-cell editor, header/footer editor, dialogs and tab rename. Review + `tests/in_cell_editor_size.rs`, `in_cell_editor_wrap.rs`, `committed_text_fields.rs`, + `formula_bar` tests, `page_layout_view.rs`, `sheet_tab_rename.rs` and add a click-away selection + regression (section 8). + +## 4. Root-Ui, multi-pass and logic-only acceptance + +| Risk | Observable assertion | +| --- | --- | +| Hidden logic stops | With the window hidden/minimized and AutoSave on, a dirty saved `.xlsx` is saved once after the delay; control `ui.inspect` still answers; a plain hidden tick runs logic only (a forced hidden UI pass is covered separately) | +| Duplicate side effects | A unit-test hook requests `request_discard` after a command **and after screenshot issuance**: one journal entry/undo step; one title command; one autosave; screenshot completes once | +| Root Ui borrow | No `Context::root_ui` call inside `SheetApp::ui`; the passed `&mut Ui` is used for every panel; a debug build does not panic with the root-Ui borrow message | +| Panel order | Title bar, ribbon/backstage, notice, formula bar, status bar, sheet tabs, pivot pane, panes, central panel reserve space in that order at 1200×800 and 640×420; `grid.rect` equals the central panel's inner rect | +| Clip/hit agreement | `Geo` cell positions, `grid.cells_rect`, scrollbars and `ui.cell.click` hit the same cells after resize, zoom (75/100/200%), right-to-left sheets and frozen panes | +| Multi-pass geometry | A pass discarded by a `Grid` sizing pass leaves final `grid.geo` equal to a single-pass render | +| Logic input | A control `ui.key`/`ui.click` while hidden is applied in forced hidden UI passes; one unapplied gesture step is retained across repeated hidden logic ticks with press/release distinct, `logic` sends `RequestPaintWhileHidden` until a full `ui` pass consumes it, and `logic` never scans `Event::Key`/`Pointer` | + +Test anchors: `tests/interaction.rs` (harness shape), `tests/interface_scale.rs`, `tests/host_chrome.rs`, +`tests/right_to_left.rs`, `tests/wheel_zoom.rs`, `tests/scroll_position.rs`, +`tests/selection_scroll.rs`, `tests/freeze_handles.rs`. + +## 5. Dependency graph, fork and vendoring decisions + +### 5.1 Intended GridCraft graph + +For suite validation, GridCraft must resolve one copy of each egui-family crate and one winit: + +```text +gridcraft (eframe + egui + egui-wgpu + egui-winit + epaint) + -> eframe 0.37 (approved storytold rev) + -> egui 0.37 (approved storytold rev) + -> egui-wgpu 0.37 (approved storytold rev, E2+E3) + -> egui-winit 0.37 (approved P8 rev: E4/P2b W15P routing + E7/E19 held-key + E21 + E21b/P7b) + -> epaint 0.37 (approved P8 rev, E5/E8/E11/E12/E13/E14/E15/E16/E20) + -> ecolor/emath 0.37 (approved P8 rev) + -> winit 0.30.13 (approved completed rev, W17-W27 + W15P + W27 lifecycle) + -> craft-ui / craft-print (craft-libs approved rev -> egui 0.37 fork) +``` + +G1 explicitly enables native `eframe/clipboard`; verify `egui-winit/clipboard` and +`arboard` in the native feature graph. Web features are unchanged. Cross-application +copy/paste must work in the grid, formula bar and in-cell TextEdit, including text from +an independent process and copy back to it; an in-process clipboard is not acceptance. + +Add a root `[patch.crates-io]` in `gridcraft/Cargo.toml` for every egui-family package GridCraft +actually resolves: `egui`, `epaint`, `ecolor`, `emath`, `eframe`, `egui-wgpu`, `egui-winit`, +`egui_kittest`, `epaint_default_fonts`, plus `winit`. Use full revision SHAs, never branches. A +dependency's patch section does not propagate; this root patch is required. `egui_extras` can be +patched too if it stays in the graph, but it is currently unused. + +Publication rules: + +- No `path =` overrides in the published tree; no machine-local patches; no moving branch. +- `cargo metadata --locked` and `cargo tree -i egui`/`-i winit` must show exactly one source per + package for native and wasm feature sets. +- `cargo check --locked` must work in a clean checkout with network access to the public fork. +- `aiongg` is a review candidate only. The production graph must use the complete + reviewed/published `storytold` egui **P8** release (P2b async W15P routing, P7 held-key pairing, + P7b/E21b, P7c's selected #8763/#8748 fixes and justified performance fixes) and the completed + winit series (W15P and W27 lifecycle). No stock 0.37 or intermediate P stage is a production + candidate. The final SHAs are **not selected yet**; do not pin a candidate head, branch, old SHA + or invented SHA. S2 and G1 stay blocked until the approved full SHAs are recorded in 1.1 and + checked in a clean `--locked` build. +- Keep the old coherent set (current `Cargo.lock` + craft-libs `d27646a5`) as the rollback point. + +### 5.2 `craft-libs` PR 199 and issue 198 + +GridCraft consumes only `craft_ui::printing` today; G1 adds `craft_ui::capture` (CL-2) for +screenshots. PR 199's role/ID/docking/ellipsis edits are relevant to craft-libs itself, not to +GridCraft's source. Integration decisions: + +- Do **not** rebase or merge PR 199's tree. Its head `b68de8c` and the audit merge `ec8840a` are + stale relative to `49190884278f16c9a9f5823d7698a8ffb5deb062`: they omit `craft-platform` and + revert security, settings, docking and printing fixes. Port only the API-port hunks from + `8614e4a04fae516fba16c22b31ff1008cb6be10b` plus the relevant README change from + `1764bca` onto current `4919088`, preserving intervening commits; gate the result with a diff + check against `4919088` that shows only those intended hunks. +- CL-1 must add the winit patch and a publication-compatible check (registry egui 0.37 build of the + packaged crate). GridCraft needs the fork pin separately. +- CL-1 and CL-2F are the shared gate for all eleven `craft-ui` consumers at the P8 stage. + GridCraft and DesignCraft are the CL-2 provisional pilot; both adapters are reviewed before + CL-2F freezes the API. A consumer already bumped to an incompatible `craft-ui` revision must + land that bump in the same atomic dependency stage as the egui P8 landing; no mixed + `craft-ui`/egui graph is acceptable. +- Bump `craft-ui` **and** `craft-print` to one approved craft-libs revision. Leaving `craft-print` + at `d27646a5` while `craft-ui` moves creates two git checkouts of the same repository and an + incoherent graph. +- Issue 198 is a language-support proposal, not a merged dependency. Do not import its locale or + formatting roadmap. GridCraft keeps its own `i18n` table and formula locale; only consume the + fork APIs the suite actually selects (for example `Context::set_language` on the fork, E17). +- If craft-libs' `printing::State` API changes in CL-1, update + `crates/ui-egui/src/printing.rs` and `apps/gridcraft/src/print.rs`; the current wrapper already + isolates GridCraft from most of craft-ui. + +### 5.3 Vendored fixes and provenance + +GridCraft has no vendor directory. Its current root uses canonical egui/winit Cargo patches; +those dependency selections do not replace these app-local workarounds, which must be preserved: + +| Fix | Provenance | Decision | +| --- | --- | --- | +| `graphics.rs` backend selection, no Vulkan by default, FXC for DX12, GL fallback | GridCraft #120, #339, #354 | Keep in `apps/gridcraft/src/graphics.rs`; E2/E3 fork errors make the existing fallback path effective | +| `theme.rs` font parse guard and `drop_unparseable_fonts` | GridCraft #226 | Keep and adapt to `Blob`; add a corrupt-font regression | +| Custom `text_box.rs` wrapper and selection glide | GridCraft UI | Keep the wrapper, its state sync and selection glide; its inner field is built-in `TextEdit` and inherits E21b. Do not replace the wrapper with a bare `TextEdit` | +| `cell_pictures` 64 MiB/4096-entry texture cache, `floating_pictures` size cap | GridCraft image work | Keep; verify texture IDs/uploads under 0.37 | +| macOS AppKit exit workaround in `apps/gridcraft/src/main.rs` `on_exit` | GridCraft #117 | Keep; re-test on macOS; the 0.37 AccessKit adapter may change the teardown | +| `fmv-macos-events`, `muda` pins | macOS file-open/menu | Keep; winit remains 0.30.13 | + +Relevant fork patches by provenance: + +- **E2+E3** (`9dd799ab`): surface fit within `max_texture_dimension_2d` and returned + `WgpuError::ConfigureSurface`/`CreateRenderer`; provenance is PdfCraft's vendored egui-wgpu + (pdfcraft#609, #617, #1042), now in the shared fork. Required by GridCraft #761 and by the + existing `graphics.rs` fallback/retry logic. Without it, a too-large window or a GL surface + timeout can panic before the app exists and the fallback never runs. +- **E4** (`66bacf23`) is only the regular-clipboard half: it initializes arboard, leaves + `smithay: None` and routes text through winit; the candidate keeps arboard for PRIMARY. The + selected completion is winit **W15P**: SCTK 0.19.2 `PrimarySelectionManagerState` on the existing + connection/event queue, with per-seat PRIMARY device/source/offer objects and no second regular + `wl_data_device`. E4's `get_primary_text`/`set_primary_text` route through W15P in the separate + P2b stage. GridCraft's own arboard handle is used for HTML (`wayland-data-control`); plain-text + and PRIMARY routing must follow the single-path design in 6.4, with real compositor evidence. +- **E5/E8/E12/E13/E14/E15/E16** (epaint/egui): bidi shaping/order, TextEdit clipping, grapheme + caret/delete, fallback alignment, line breaking, visual cursor. GridCraft draws CJK/Thai/Hebrew/ + Arabic workbook text and uses an app-managed in-cell editor built on `TextEdit`; preserve and + test. +- **E7/E19/E10** (keyboard layout): letter keys from Latin layout/physical code. E19 needs winit + W25 (`KeyEventExtLatinLayout`). GridCraft's keytips and shortcuts must keep working on + Russian/Arabic/Hebrew layouts and web keyboard events. +- **E17/E18/E20** (language): `Context::set_language`, `UiBuilder::language`, accessible language, + widget text language. GridCraft's `UiState.language` should drive these; map `PtBr` to `pt-BR` + rather than the current short `code()` string, and do not duplicate RTL processing in app text. +- **E21** (`a70470b0`): `ImeEvent::DeleteSurroundingBytes` and exact-byte deletion. GridCraft's cell + editor undo transaction must remain one step; pair with W26/W27. +- **E21b/P7b** (new prerequisite, no candidate commit yet): bounded outgoing IME surrounding text in + `IMEOutput`, produced by the built-in `TextEdit` and sent by egui-winit through W27 + `set_ime_surrounding_text` on Wayland. GridCraft's acceptance is the complete shared E21b/W27 + contract, not a payload restatement: the owner/context generation is present even when text is + `Unavailable`; an available-empty document is distinct and sent as an explicit + `set_surrounding_text("", 0, 0)` plus commit; an owner/unavailable transition clears caches and + commits the disable/enable boundary before the new owner; the final completed pass, including + an authoritative `None`, replaces the old `ime.or(self.ime)` accumulation; a current-epoch + `done` serial mismatch applies incoming edits once and defers outgoing state until a matching + serial, while an obsolete epoch invalidates the batch; the new <=4,000-byte payload extraction + must not allocate a full-document clone (existing TextEdit change-detection/undo clones are + baseline, not this requirement). Preedit is excluded and cursor/anchor are valid UTF-8 byte + offsets; password input never supplies text. GridCraft's formula bar + (`formula_bar.rs:321-323`) and text-box/in-cell editors (`text_box.rs:151`) use built-in + `TextEdit`, so they inherit the producer; they still need focus, deletion+commit, + deletion-only, preedit, reversed-selection, emoji/combining/RTL, empty-vs-unavailable, + final-pass `None`, both serial cases, owner generation, committed clear and two-surface tests. + Custom painters that synthesize `IMEOutput` would need their own model adapter; GridCraft + currently has none, and must not set the new field to `None` as a port. +- **E1** (`ViewportHooks`): not used by GridCraft (single viewport); do not add a hook. +- **E6** (`request_focus` during IME): relevant to the in-cell editor; test. +- **E9** (`Style::right_to_left`): GridCraft's sheet mirroring is app-level; do not turn on global + RTL UI style. It would mirror the ribbon/dialogs incorrectly. +- **E11** (vello multithreading): not enabled by GridCraft; no action unless a dependency turns it on. + +## 6. Behaviour-specific refactors and tests + +### 6.1 Commands, undo and persistence + +The engine and command registry do not change. Required checks: + +- Every UI action still goes through `SheetApp::run`/`Session::execute`; no direct model mutation + from the 0.37 API changes. +- `home.bold`, `cell.set`, `edit.copy`/`cut`/`paste`, `edit.undo`/`redo`, `file.save`, + `file.saveAs` and `file.open` produce the same journal and undo labels. +- A UI click and the control-channel command produce the same one undo step. +- Programmatic empty params never open a dialog (`UiRequest::Dialog` only from menu-style + invocation). +- Save → close → reopen a synthetic workbook: values, formulas, number formats, styles, merges, + tables, frozen panes, right-to-left flag, print settings and `UiState` prefs round-trip. Compare + `sheet.read`, `document.inspect`, and raw XLSX package parts/hashes where stable. +- Autosave in `logic` writes exactly once per change after 2 s; saving while hidden does not touch + UI state. +- `engine.journal` replay still works; no password appears in journal/errors. + +Anchors: `crates/engine/src/cmd/*` (unchanged), `crates/ui-egui/src/lib.rs::run_command`, +`tests/autosave_persist.rs`, `tests/interaction.rs`, `crates/engine/tests/command_sequence_fuzz.rs`. + +### 6.2 Fonts, glyphs and text layout + +- Font definitions: `theme::font_definitions_with` must still build UI, UI-bold, cell, cell-bold, + cell-italic, cell-bold-italic, serif, mono, plus loadable script fallbacks and workbook families. +- `FontData` API refactor as in 3.4; keep the `Blob` cache and `drop_unparseable_fonts`. +- 0.37 `MissingGlyphPolicy`: the production `Context` default is Tofu; `egui_kittest` defaults to + `MissingGlyphPolicy::Panic` and `check_accessibility = true`. GridCraft has 76 + `Harness::builder`/`build_ui_state`/`build_state` call sites in 73 test files (plus the snapshot + example) and no shared harness module, so the default would make CI depend on installed fonts and + on names that do not exist yet. `egui_kittest`'s own `default_fonts`/`monochrome_emoji_fonts` + dev-dependencies do not propagate into GridCraft, so the harness must enable + `egui/default_fonts` itself or install pinned fonts. G1 must make the suite green **without + globally disabling the checks**: + - `crates/ui-egui/tests/common/mod.rs` is the one harness constructor now used by the `tests/it` + binary and `keytips.rs`. It installs `theme::pinned_font_definitions()` (egui's bundled faces + plus the pinned craft-fonts input, never the host system scan) through + `HarnessBuilder::with_context_setup`, marks the test `SheetApp` with + `SheetApp::use_prepared_fonts()` so the first `logic` pass does not replace the pinned set, and + leaves `check_accessibility = true` and `MissingGlyphPolicy` at `Panic`. The snapshot example + and `header_autofit_fonts.rs` deliberately keep the production/system-font path where the + subject of the test is installed-font behavior. + - The CI/acceptance font contract is the existing pinned input: set `CRAFT_FONTS_DIR` to the + craft-fonts checkout (and `CRAFT_FONTS_REQUIRED=1` for release/acceptance). A minimal image + without it runs the deterministic bundled-face harness; the one test that renders every + catalog skips `Ja`/`Ko`/`Zh` in that mode and records the gap, rather than relaxing + `MissingGlyphPolicy` or accessibility globally. + - Complete all accessible names/roles that the existing test suite exercises as part of G1 (the + form fields, Name Box/formula bar, Font Size box, tab rename, dialog labels and any other + unnamed input the strict harness reports). G3 adds the remaining dialog/grid/popup names and + semantic assertions; it does not own "make G1 green". + - `allow_missing_glyphs()` or `with_accessibility_check(false)` is not a suite-wide default. A + specific test may opt out only with a recorded reason, an owner-facing follow-up gate and a + comment at that call site; it must not be hidden in the shared constructor. + - `tests/symbol_glyphs.rs` is the installed-face coverage test and is strict in both modes + (below); do not weaken it. + - Do not change production font policy from Tofu. +- Fonts come from pinned, OFL-licensed faces in `storytold/craft-fonts`, never from a copy in this + repository: `BIZ UDPGothic` Regular/Bold (`Jpan,Latn`), `NanumGothic` Regular (`Hang,Kore`), + `Noto Sans Symbols` Regular and `Noto Sans Symbols 2` Regular (`Zsym`). + `crates/ui-egui/src/craft_fonts_policy.rs` is the single policy the build script and the tests + share: exact family/style plus manifest script tags, the barred-family filter (`Adobe*`, + `Source Han*`, `Source Sans*`, `Source Serif*`, `Noto Sans CJK*`, `Noto Serif CJK*`, with no + broad `Noto Sans` prefix that would reject the permitted Symbols faces), the required-face check + and the web allowlist. `CRAFT_FONTS_DIR` is the only input (no fixture path lives in Cargo + configuration), and without it the generated table is empty and the build is unchanged. + `CRAFT_FONTS_REQUIRED=1` makes a missing checkout, manifest, row, font file or required face a + build error. +- Two symbol modes, each asserted exactly: + - no-font/default: bundled faces must supply `↘ ◀ ↑ 📋` and the rest of the interface set. `✕`, + `✓` and `⌃` have no bundled egui face, so chrome paints `Icon::Close`/`Icon::Check` and the + Control-key tooltip falls back to `^`; workbook text still refuses to fake a glyph. + - extended acceptance (`CRAFT_FONTS_DIR`, `CRAFT_FONTS_REQUIRED=1`): the exact union + `↘ ✕ ◀ ↑ 📋 ✓ ⌃` in both `Proportional` and `CELL`, plus the Korean interface labels, must + render; a loaded or required set that lacks a face fails instead of skipping. +- Native builds embed **every non-barred row** of `fonts/manifest.txt`, not only the five required + faces: permitted `BIZ UDMincho`, `Shippori Mincho`, `Noto Sans Arabic`, `Noto Sans Georgian`, + `Noto Sans Hebrew` and `Noto Sans Thai` rows are also present when the pinned checkout carries + them. All are OFL/non-barred, so the asset policy holds; the policy and size gate must describe + the actual set, not claim five faces. +- Simplified Chinese is a native-only opt-in: the OFL `LXGW WenKai Lite` row from craft-fonts' + `fonts/simplified-chinese-manifest.txt` (the face DeckCraft uses). It is outside `WEB_ALLOWED` + and never enters the wasm bundle; `CRAFT_FONTS_REQUIRED=1` native builds require the manifest, + while ordinary builds tolerate an older checkout without it. System Han faces still lead the + chain when present. The native font set (including this face) needs its own raw/compressed size + measurement; the current measured figure is the five-file web allowlist, 12,970,840 bytes raw. +- Web (`wasm32`) embeds only `BIZ UDPGothic` Regular/Bold, `NanumGothic` Regular and the two + Symbols faces. Raw font bytes, raw wasm and compressed wasm still need measuring against the + consumer's real hosting limit; do not record "within budget" before that measurement. +- Font rebuild gate: 0.37 applies `set_fonts` immediately when no text has been laid out this pass + (`#8602`, `#8609`). GridCraft's one-frame `fonts_ready` skip remains correct but may now skip a + frame unnecessarily. Re-test language switch, workbook-font discovery and installed-font rebind; + if the extra frame is observable, gate only when text already exists. +- 0.37 `#8512` system-font discovery/provider: GridCraft has its own `fonts::start_scan` and + `theme` candidate probing. Do not switch to `egui_system_font_provider` without measuring + startup, preserving workbook font names, and keeping fallback order. This is a blocking + comparison, not an automatic adoption. +- Text shaping: E5/E12/E13/E16 change RTL rows, caret/selection and grapheme deletion. Add tests for + mixed LTR/Arabic/Hebrew in a cell, visual order, caret at the visual end, Delete/Backspace over a + combining cluster, and formula-bar caret. Use installed fallback faces and skip only when the face + is absent, as `theme::fallback_covers_its_script` already does. +- E14 fallback alignment and E15 line breaking: the defaults are intended to preserve the old + rules, but compare wrapped cells, CJK/Indic fallback rows and vertical alignment against the + baseline. A changed fallback baseline must be explained, not accepted as a new golden. +- Screenshot/font fixtures: record the exact font files and hashes used for every visual evidence + run. Do not commit font files or screenshots of Microsoft software. + +### 6.3 Accessibility and input + +0.37 makes unnamed input widgets a kittest failure (`#8589`) and improves popup/menu/panel trees +(`#8581`, `#8607`, `#8608`, `#8612`). GridCraft has many custom-painted controls and several +TextEdits without labels. The names and roles exercised by the existing harnesses land in **G1** +with the shared strict harness; the broader coverage below lands in G3. Required work: + +- `crates/ui-egui/src/text_box.rs`'s text-box editor uses the translated `Text Box` accessible + name in every catalog; the `i18n` unit test checks it alongside the Name Box, Cell Editor, Font + Size and Sheet Name names. +- `crates/ui-egui/src/dialogs.rs::form_fields`: wrap each label + field in + `ui.label_inputs_by(label.id, |ui| …)` or call `.labelled_by(label.id)` on the field. Do this for + text, password and number fields. +- `crates/ui-egui/src/formula_bar.rs`: give the Name Box and formula bar explicit + `accessible_name`/`labelled_by`; keep their existing `Id`s. +- `crates/ui-egui/src/ribbon.rs::size_combo`: the TextEdit has no label; add + `.accessible_name("Font Size")` (translated) and keep `control_keytip`. +- `crates/ui-egui/src/tabs.rs`: the tab rename TextEdit gets the sheet name as its accessible name. +- `crates/ui-egui/src/widgets.rs`: icon-only controls already use `on_hover_text`, which 0.37 maps + to a description/name; verify with `unnamed_widgets()` rather than assuming. +- `crates/ui-egui/src/grid.rs`: scrollbars, freeze handles, header dividers and fill-handle areas + are custom interactions. Give each a role/name only where it is a real control (scrollbar + `Role::ScrollBar`; other clickable overlays `Role::Button` with a name), and keep decorative + overlays out of the tree. Do not invent `Role::Unknown` nodes for every painted rect. +- Popup/menu roles: 0.37 exposes menu items/submenus; ensure context menus, filter menus, fill menu + and cell context menu have correct names and that hidden popups disappear from the tree. +- Focus/keyboard: Tab/Shift+Tab, Enter/Space, Escape and arrow keys must trigger the same commands + as pointer clicks; focus must not move when language/theme changes or a pane is hidden. +- IME: preedit, commit, cancel and replacement produce one edit/undo step. Test E21 incoming + delete-surrounding and the complete E21b contract (section 5.3) in the formula bar and + `text_box.rs`: owner/context generation even when unavailable, available-empty vs unavailable, + committed cache clear/disable-enable on owner transition, final-pass `None` authoritative, + current-epoch serial mismatch applying incoming edits once while outgoing state is deferred, and + obsolete epochs invalidating the batch; plus deletion-only batches, delete+commit, preedit + exclusion, reversed selection, emoji/combining/RTL byte indices, focus loss/password clearing and + two windows. Native IME behavior (including a Wayland Vietnamese/predictive trace) still needs + real Windows/macOS/Linux evidence; E21 alone is not surrounding-text acceptance. + +Accessibility acceptance: + +- `Harness::unnamed_widgets()` is empty for the main window and each reviewed dialog. +- Named inputs expose role, value, selected/checked, disabled and focus states. +- A deliberate mutation (remove one name) makes the check fail; revert it before landing. +- Screen-reader native smoke test on Windows/Linux (Orca/AT-SPI) if available; otherwise record the + gap. + +### 6.4 Clipboard, PRIMARY and files + +- **One routing design for two clipboard owners.** GridCraft's `Services` owns an arboard handle + for HTML and plain text (`apps/gridcraft/src/main.rs:150-163`; `wayland-data-control` on + Linux/FreeBSD), while egui/egui-winit owns the text + clipboard used by `TextEdit` copy/paste. After E4/W15P both must not compete for the same Wayland + selection: + - Grid copy/cut keeps using the one arboard data-control handle, because winit's selected + clipboard API has no multi-MIME HTML offer; it publishes HTML+plain text. TextEdit copy/paste + and PRIMARY go through egui/egui-winit (and W15P for PRIMARY). GridCraft must not create a + second arboard handle or a second `wl_data_device` for the plain-text path. + - Paste reads HTML first through arboard, then the current plain text (arboard or the egui + clipboard), with one defined owner order and no stale re-read. A grid copy followed by a + TextEdit paste, and a TextEdit copy followed by a grid paste, must both work on Wayland. + - Create the arboard handle lazily and keep it owned by `Services` for the app's lifetime; record + which protocol each path uses, and prove that an HTML paste from a browser and a plain-text + paste from another app each work after the other path has taken ownership. + - Password fields never publish text or PRIMARY; no unchanged-selection reclaim; canceled/slow + transfers must not leave a half-published owner. +- PRIMARY (W15P): `#8428` makes Label/TextEdit selections publish PRIMARY and middle-click paste + into a TextEdit. The contract is the winit bounded-transfer one: a fresh triggering serial from + **either** a pointer press/release or keyboard input on the focused seat (never a keyboard-only + serial rule and never an arbitrary first seat); synchronous ownership claim on that input turn + while only later serving/reads are asynchronous; bounded async reads with a 2 s total deadline, + 16 MiB cap, at most 4 read workers, 2 writer threads and queue depth 8; a live owner-specific + completion waker; canceled or stale results discarded; password fields never publish. Test: + select formula text → middle-click in the in-cell editor at a position → text is inserted + there, one undo step; pointer and keyboard triggers, focus movement, owner/seat changes, + empty/absent offers, self-paste, trickle/oversized/canceled transfers and event-loop + responsiveness; non-PRIMARY platforms unaffected. Native gates are wlroots/Hyprland, KWin and + Mutter, not one compositor only. +- `Event::PasteImage` and `Event::MiddleClickPaste` are new. GridCraft deliberately ignores both in + the grid: there is no image-clipboard pipeline or cell-picture insert-from-clipboard command, and + inserting at an unchosen anchor/size would be a new feature. A clipboard with a text + representation still arrives as `Event::Paste`; PRIMARY middle-click paste belongs to egui's + TextEdit. The match arms are explicit and commented, so a future image-paste feature must choose + behavior rather than inherit a silent no-op. +- File dialogs (rfd), drag-and-drop and `fmv-macos-events` are native workflows; kittest cannot + prove them. Use the Windows clone and a macOS session with real evidence. The web picker uses the + async inbox; test cancel/retry with two dialogs so an inbox cannot cross-deliver. + +### 6.5 Viewports, GPU and screenshots + +- One root viewport only. No `show_viewport_deferred`, no child-window title/focus/close tests. +- `ViewportCommand::Screenshot` is the only viewport command affected by the callback change; the + other commands (`InnerSize`, `Focus`, `Close`, `CancelClose`, `Title`, `StartDrag`, `Maximized`) + stay. +- GPU: preserve `cell_pictures`/`floating_pictures` texture caches. 0.37's texture manager and + `max_texture_side` behavior must not change image sizes or leak textures. Test texture IDs, + upload deltas, eviction at 64 MiB, images larger than `max_texture_side`, and drop on workbook + close. +- E2/E3: verify the Windows fallback still triggers on a failed renderer/surface. The fork's + `surface_fit` test is a dependency test; GridCraft adds a visible large-window/resize workflow on + the owned Windows clone where the adapter limit permits it. +- 0.37 color emoji changes texture sampling. Keep `TextureOptions` semantics for cell pictures and + icons (nearest/linear, wrap mode); do not accept a clamp-only optimisation as a replacement for + Repeat/MirroredRepeat. +- An **issued** screenshot command requests a real paint of a hidden window: the candidate marks + `ActionRequested::Screenshot` (and `RequestPaintWhileHidden`) as `wants_paint`, and eframe treats + that as visible for painting. But issuance is UI-only, so a hidden window must first be made to + run `ui`: while a request is queued or settling **with a capturable size**, `logic` sends + `ViewportCommand::RequestPaintWhileHidden`; only then does `issue_screenshots` emit the + paint-requesting `Screenshot` command, after which no further hidden paint request is needed. A + hidden or occluded window that still reports its current content size can complete this way; a + minimized window has no capturable size, so it sends none and its request stays queued until + restore and otherwise reports the 8 s timeout. Do not reuse an old + rect for a minimized window: issuance and drain fail closed on the fresh geometry. Do not assert + a hidden/occluded screenshot timeout. Test the real path: issue `ui.screenshot` while + hidden/occluded and assert one image/reply; then minimize, issue another request, restore, and + assert it completes (or, without a restore, assert the documented timeout). Use `ui.focus` only + when focus itself is part of the workflow. A timeout covers "no frame was presented" (event loop + stalled/closing) or "no capturable size", not "window hidden". Update the existing error text if + it tells users to make the window visible. Synthetic input also runs only in a UI pass and + follows the one-unapplied-step rule in section 3.1. +- Capture identity stays strict across resize/DPI/zoom/visibility/occlusion. Keeping `zoom` and the + occlusion flags in the identity is intentional even when the expected physical size is unchanged: + a bounded retry (two attempts) followed by an explicit error is preferable to combining pixels + from different layout generations. This bounded failure is accepted behaviour, not a relaxation + target. +- Native qualification for a real hidden/occluded capture and a minimized/restore request remains + required. This plan describes the intended behaviour; no native hidden/minimize/restore run has + been performed for this source. + +### 6.6 Printing: keep the existing synchronous construction, measure it + +`SheetApp::submit_print` runs `file.print` synchronously (`lib.rs:595-611`), and the engine builds +the whole PDF in `build_pdf` before returning bytes (`crates/engine/src/cmd/print.rs:1600-1621`). +This is existing baseline behavior, not a 0.37 regression: opening Print settings is lazy (a menu +`file.print` opens the backstage when a native printer is configured), while the Print button and +`ui.printSubmit` block the UI while the PDF is generated and handed to the worker. The migration +does **not** move PDF generation to a background engine unless new behavior requires it or a +predeclared budget says so. Required gate: + +- Measure the three phases separately on the same workbook: settings-open latency (no PDF), PDF + generation (`file.print`/`build_pdf`) and submission (`printing.begin`/worker hand-off), plus the + frame that contains them. Use the 100k-row fixture and a representative print area. +- Define the candidate acceptance as no regression against the recorded baseline within 9.3's + thresholds; a regression is fixed in the print path or explicitly budgeted and reviewed. +- Test a hidden/minimized print submission and feedback polling once through `logic`; print + discovery/worker completion must not depend on a painted `ui`. +- If a future requirement moves generation off-thread, it needs its own command/undo/worker + ownership design, cancellation and a regression test; it is not part of this migration. + +## 7. Native, web and platform workflows + +### 7.1 Native Linux + +- Build/test via `task.py run --kind build` on a fresh allocation; record source SHA, lock hash, + feature set, rustc, font package versions and evidence path. +- Native measurement is staged, not assumed: (1) managed build/test and offscreen/kittest + render, (2) provisioned X11 with a named window manager or Wayland with a named compositor, + (3) interactive workflow with real input. No `DISPLAY`/`WAYLAND_DISPLAY` is inherited on this + server, and the host's i915/`/dev/dri/renderD128` access is availability evidence only, not a + qualified display or GPU backend. Record the chosen backend and driver per row. +- In the provisioned session run `gridcraft --sample sales --control 7979`, drive the control + channel, take native screenshots, exercise wheel/trackpad, Ctrl+wheel zoom, clipboard/PRIMARY, + IME (if installed), file picker cancel and window resize. Record compositor and scale factor. +- FreeBSD: run the existing FreeBSD workflow/row; verify the Linux/FreeBSD `arboard` + `wayland-data-control` cfg and winit configuration do not change the build or clipboard behavior. +- Software renderer (llvmpipe) and real GPU are separate rows; neither is accepted until measured + with identified adapter/driver. + +### 7.2 Windows + +- Allocate one uniquely named `craft-windows` clone; use its own SSH/VNC ports. Check allocator + capacity first: a full copy needs its reservation, and an overlay is allowed only with an + adequate budget. If neither fits, the row stays blocked and no existing VM is stopped or moved. + Build the exact task checkout with a separate `CARGO_TARGET_DIR` and run + `cargo test --workspace` (or the repository's build/test script for the checkout) through + `guest-exec.py`, submitting each guest command with `task.py run --kind vm` under the clone's + task ID. +- Use the interactive scheduled-task launcher for the GUI; verify the app is on the active console + with Explorer. Drive `--sample sales --control 7979`, edit/save/reopen, open a password-protected + fixture, use the printer settings (discovery may have no printer), resize/minimize, and capture + screenshots. +- Measure renderer construction separately from first frame because of `#8736` (FXC DX12 shader + compile). GridCraft forces FXC, so this is on its default path. Time a fresh-process + `egui_wgpu::Renderer::new` probe (or the `wgpu_core=trace` bracket between + `create_pipeline_layout` and `create_render_pipeline`) separately from the first presented + frame; record both. +- The baseline VM is software-rendered (Microsoft Basic Render Driver); report it as such, not as + a physical-GPU result. Keep the Windows ARM64 packaging workflow in the matrix; it is not a + substitute for a native x64 GUI/IME run. + +### 7.3 macOS + +- Build/test on a real macOS host with the macOS CI gate; cross-compilation is not acceptance. +- Cover AppKit native menu, `fmv-macos-events` double-click/Open With, close/quit with unsaved work, + `on_exit` workaround, Command shortcuts, IME (Korean/Japanese/Chinese), trackpad wheel/momentum, + Retina scale 1x/2x and Metal/wgpu. This environment is not available on the server; record it as a + rollout blocker until a real run exists. + +### 7.4 Web + +- Staged browser evidence: (1) wasm compile in CI, (2) a pinned headless browser run where a + pinned Chromium/Firefox is provisioned, (3) interactive WebGPU/WebGL2 with recorded browser and + adapter identity. No host browser is inherited at planning time; an unavailable runtime is an + explicit blocker, not a compile-only pass. +- `cd apps/gridcraft-web && trunk build --release` and `trunk serve --release`; test in at least + Chromium and Firefox with WebGPU where available and `?webgl` forcing WebGL2. +- Workflows: `?sample=sales`, edit/save/download/reopen, browser file picker cancel/retry, dropped + file, paste HTML/plain text, language switch (including CJK tofu behavior), interface scale, + resize, and keyboard layout (`E10`). +- `cargo xtask wasm` only proves compilation. Browser behavior, IME and clipboard require a real + browser run; record versions and screenshots/logs. +- Record raw and compressed `.wasm` size; the 0.37 family may change code size. + +### 7.5 CI and commands + +Baseline and candidate commands (all new jobs use `maintenance/task.py run/status/finish`, a unique +`--task`, and a fresh `--job-id`). Example for the repository gate: + +```sh +python3 /home/codex/work/maintenance/task.py run \ + --job-id gridcraft-egui037-ci-01 --kind build --task gridcraft-egui037 \ + --source /home/codex/work/CraftApps/egui037-migration-20261022/gridcraft \ + --evidence /home/codex/work/ssd2/task-evidence/egui037-migration-20261022/gridcraft \ + -- bash -c 'cargo xtask ci' +``` + +Inside that managed job (never as a direct host command), the candidate checks are: + +```sh +cargo xtask ci +cargo test --workspace --locked +cargo test -p gridcraft-ui-egui --all-targets +cargo check -p gridcraft-web --target wasm32-unknown-unknown --locked +cargo run -p gridcraft-ui-egui --example snapshot -- --sample sales out.png +cargo tree -i egui +cargo tree -i winit --target all +cargo metadata --format-version 1 --locked +``` + +`cargo xtask ci` already runs fmt, clippy `-D warnings`, workspace tests, assets, layers, wasm, +parity and keytips. Add a dependency-identity check to CI (a small script or xtask step) that fails +if any egui-family package or winit comes from a second source/revision, or if a branch/path patch +exists. Test the check with a deliberately wrong pin. + +## 8. User-observable regression assertions + +### 8.1 Geometry and hit testing + +Use `Geo::new`, `grid.rect`, `grid.cells_rect`, `screen_cell_rect` and `ui.inspect`: + +- 1200×800 and 640×420, ppp 1.0 and 2.0, light/dark, en/es/ja/zh, RTL on/off. +- Click the centre of A1/B2 and drag C3:F7; selection matches; frozen panes and split panes keep + headers fixed; scrollbar thumbs map to the same scroll. +- Ribbon tab/height, formula bar, status bar, tabs and central grid boundaries stay within 0.5 pt of + the baseline where the style pin is intended to preserve them. +- Right-to-left sheet mirrors columns and hit tests; outline buttons and ruler drags keep sides. +- Interface scale 75/100/200% keeps `ctx.zoom_factor()` and geometry consistent. + +### 8.2 Fixtures + +Fixtures are built in code and never committed as large binaries: + +- `Session::new()` + `file.new {sample: "sales"|"budget"|"grades"}` for the three samples. +- A 100,000-row × 6-column workbook built through `range.setValues` in 10,000-row chunks, plus + formulas `G=B*C+D`, a chained running sum, `SUM`/`SUMIFS`/`XLOOKUP` and a sort, mirroring + `crates/engine/examples/perf.rs`. +- A two-sheet workbook with a formula being pointed across sheets, a right-to-left sheet, frozen + panes, merged cells, a table, a chart, comments/notes, validation and a cell picture. +- Password-protected and corrupt-font fixtures through `gridcraft-xlsx`'s `test-support` feature + and a temporary file; no password is written to logs or evidence. +- 128 small generated PNGs for the picture cache, plus one image larger than `max_texture_side`. +- Pinned UI text in en/es/ja/zh (and Greek/Cyrillic/Hebrew where a face exists) so snapshots and + accessibility names are deterministic. + +Record the fixture builder's source hash and the exact parameters in every evidence run. + +### 8.3 Screenshots and pixel probes + +- Produce before/after PNGs with `examples/snapshot` at 1440×900, scale 1 and 2, light/dark, + Home/Insert/View, `sales`/`budget`/`grades`, formula autocomplete (`GRIDCRAFT_LOCALE_SCREENSHOT`), + table headings (`GRIDCRAFT_TABLE_HEADINGS_SCREENSHOT`) and representative dialogs. +- Store them in durable evidence, not as unreviewed repo assets. Record font files/hashes, OS, + renderer, DPI and viewport. +- Add a small kittest render test with fixed harness size/ppp/theme that probes known pixels: + window background, ribbon fill, selected cell fill, grid line, header fill, selection outline, + formula bar background. Use exact RGB for flat tokens and a small tolerance for antialiased + edges. The test must fail if a probe is moved by 3 px or a token colour changes; verify with a + deliberate mutation, then revert. +- Do not bulk-refresh any golden. Review every changed image and explain it (style pin, font, + renderer, or a real regression). + +### 8.4 Accessibility + +- `unnamed_widgets()` empty for main window, Backstage, Find/Replace, Format Cells, Sort, Data + Validation, print settings and Paste Special. +- Query roles/names/values/selected/disabled/focus for: ribbon buttons, tab list, sheet tabs, name + box, formula bar, font size box, dark-mode checkbox, dialog fields, OK/Cancel, filter menu. +- Keyboard: Tab order reaches every input; Enter/Space activate the same command; Escape closes the + right popup; focus survives language/theme change and pane show/hide. +- A deliberate missing name or wrong role makes the assertion fail. + +### 8.5 Input, IME and text + +- `Event::Text`, `Key`, `PointerButton`, `MouseWheel` (with `MouseWheelSource::Unknown`, `Wheel`, + `Trackpad`, `Momentum`), touch pan and pinch zoom. +- Wheel zoom ladder (`grid::wheel_zoom_step`) at 100/121/99/400%, fine-grained trackpad fractions, + Ctrl held vs released, momentum phase; no latch from a missing `TouchPhase::End`. +- In-cell editor: type, Enter/Tab/arrow, Escape, multiline Alt+Enter, click-away commit, formula + point mode, F4, autocomplete; one undo step per commit. +- Regression for `#8763`: select text in the formula bar, then right-click → Copy (or click a + custom toolbar action that reads the text selection); the action must see the selection that + existed when the click started (or the app must restore it). The final P8 fork's P7c fixes + (selected #8763/#8748) must satisfy this case. Only add a local workaround for a proven + app-specific difference, with a test and removal condition; PR #8764 itself is open/unmerged. +- Bidi/grapheme: Arabic/Hebrew mixed cells, combining marks, emoji clusters, selection and caret. +- IME: preedit/commit/cancel/replacement in the formula bar and in-cell editor produce one undo + step; E21 delete-surrounding and the complete E21b contract are checked together; a deletion-only + batch, delete+commit, current-epoch serial mismatch vs obsolete epoch, owner-generation change, + empty-vs-unavailable, committed clear, final-pass `None`, password clearing and two windows are + separate cases. +- Clipboard/PRIMARY as in 6.4. + +### 8.6 Persistence and lifecycle + +- Edit → save → close → reopen; compare values/formulas/styles/merges/tables/freeze/RTL/print and + `UiState`/`Session.prefs`. +- Undo/redo after reopen? (Undo history is not persisted; assert it is empty as today.) +- Hidden/minimized autosave; close with unsaved work asks; `app.quit` does not ask; printing busy + cancels close. +- Control `ui.screenshot` and `ui.inspect` across resize/minimize/focus; a hidden/occluded + queued screenshot reaches issuance through `RequestPaintWhileHidden`, then completes from the + issued command's own paint request when a current size is reported (no artificial timeout); a + minimized request stays queued until restore and otherwise reports the 8 s timeout. +- Window title updates on document rename/dirty change, including while hidden. + +## 9. Performance measurement and gates + +### 9.1 Baseline before production change + +Record the baseline at `b8771d51fb4da60bd48c13cf313d6d6014dc5e66` with the current lock and the reviewed `craft-libs` base +`3832c47`; the earlier `ac8da7d4`/`d27646a5` pair stays as historical evidence only. No candidate +numbers may be reported without a paired current baseline. Capture exact SHA, lockfile hash, features, fonts, fixture, +OS, renderer/driver, DPI, viewport, profile, rustc, hardware and load. Use paired runs, alternate +order, 10 warmups and at least 30 timed iterations for steady operations, 20 fresh processes for +startup, and at least 1,000 frames for frame distributions. Report raw samples and the paired +median with a confidence interval for 30-iteration operations; a 30-sample p95 is the 29th +observation and is not a tail estimate, so do not use it as an acceptance threshold. Use p95 from +an expanded tail run (at least 1,000 samples) and p99 only from at least 1,000 frames; report the +sample count with every percentile. The first baseline report and every investigated regression +include an equivalent stock-0.37 control, measured in the same harness alongside 0.36 and the +fork. Stock 0.37 is a measurement control, never a production stage; where an operation cannot +compile unchanged on stock, use a pinned minimal adapter and record the equivalence limit. Also +record peak RSS, texture uploads/allocation counts where practical, and idle CPU/repaint count. +Never report compile duration as UI performance. + +### 9.2 Scenes + +| Scene | Operation | Measurement | +| --- | --- | --- | +| Startup | fresh process → first painted frame / control channel ready | wall time, split font setup, wgpu renderer creation, first frame | +| Grid scroll | `sales`/100k-row synthetic workbook, 1,000 frames of vertical/horizontal scroll | frame distribution, `grid_ms`, tessellation time, vertices/indices, uploads | +| Cell edit | type/commit 1,000 cells via control channel | command latency, frame CPU, undo memory | +| Recalc | `crates/engine/examples/perf` on the same workbook | engine-only latency, recalc cell count (not a UI number) | +| Dialog/ribbon | open/close Format Cells, switch tabs, type filter | CPU frame time, paint counts | +| Text tessellation | fixed 120-ASCII-label scene + mixed color-glyph row | per-tessellate time/instructions; reproduce #8731 | +| Fonts | 20 cold starts with the same font set | discovery/build/set_fonts time, peak RSS | +| Memory | 100k-row workbook + 128 pictures | peak RSS, texture bytes, cache evictions | +| Idle | visible and hidden, 10 s | logic-tick and painted-frame counts, CPU, no busy loop | +| Print | settings open, `file.print` PDF generation, submission | three separate latencies plus the containing frame; 100k-row fixture | +| Windows startup | renderer construction vs first frame | #8736, FXC DX12, software WARP separately | +| Web | release wasm load + first frame + 1,000 scroll frames | raw/compressed wasm, browser frame timing | + +A benchmark harness should be added before the migration, not after: + +- `crates/ui-egui/examples/frame_bench.rs`: build a deterministic `SheetApp` with a synthetic + workbook, call `logic`/`ui` in `ctx.run_ui`, then time `ctx.tessellate` separately; emit JSON + samples and vertex/index counts. No GPU required. +- `apps/gridcraft` opt-in `GRIDCRAFT_FRAME_LOG=`: a small logger called from both + `SheetApp::logic` and `SheetApp::ui` appends one JSON line per invocation with an explicit + `phase` (`"logic"` or `"ui"`), timestamp, `perf.frame_ms`/`grid_ms` where available, viewport + size, scale and `visible()` as data (never as a phase inference); the app adds + `eframe::Frame::info().cpu_usage` when it has a `Frame`. Hidden logic-only ticks therefore have + samples, and a separate 10 s idle probe records process CPU and tick counts. No behavior change + when unset. +- `egui_kittest` wgpu render loop for offscreen GPU frame time and texture upload deltas; record + adapter info from a small `wgpu::Instance` probe. + +### 9.3 Thresholds and noise policy + +Freeze these before candidate runs (stricter repo budgets win): + +- Investigate a repeatable >5% median regression (paired CI above the noise floor excludes noise) + in any scene, or >10% p95 only where p95 comes from the expanded tail run; >5% peak-RSS + increase; any new idle busy-loop or printing underrun; any loss of an existing absolute budget. + A 30-sample p95 is reported but never used as a gate. +- For sub-millisecond measurements, require the relative increase to also exceed 0.05 ms or + corroborate with allocation/instruction counts. +- Text tessellation: compare 0.36.2, stock 0.37.0 and the fork on the same scene. If #8731 + reproduces (expected from the upstream report), the row-level color-glyph fast path must land in + the fork before GridCraft accepts the migration; do not accept a 60% text-tessellation regression + as “expected 0.37 cost”. +- Windows startup: measure renderer construction separately. If #8736's reported cost reproduces + as a repeatable >5% total startup regression, evaluate the fork shader fix with equivalent wrap + arithmetic; clamp-only is not valid. The upstream ~40 ms figure is a report, not our measurement. +- Reject noisy comparisons as inconclusive and rerun; never auto-widen a threshold or tolerance + after seeing a failure. Document any pre-measurement exception and its review. + +## 10. Publication stages, dependency DAG and rollback + +### 10.1 Stages + +| Stage | Content | Depends on | Rollback | +| --- | --- | --- | --- | +| P0 | This plan; evidence notes | none | delete plan branch | +| S1 | Shared craft-libs CL-1 for the eleven `craft-ui` consumers: port only `8614e4a` API hunks + `1764bca` README onto `4919088`, final approved egui/winit pins (blocked until those refs exist), registry compatibility, release SHA | P0 review, final refs selected | revert CL-1; keep old `d27646a5` | +| S2 | Complete reviewed/published `storytold` egui **P8** (E1–E21 + E21b, P2b async W15P routing, P7 held-key pairing, P7b/E21b, P7c selected #8763/#8748 fixes, justified perf fixes) and completed winit (W17–W27 + W15P + W27 lifecycle), immutable approved SHAs. No stock 0.37 or intermediate stage | P0 review, fork review, final refs selected and published | revert fork pins; keep 0.36.2/0.36 craft set | +| CL-2 | craft-libs provisional capture-slot API in `crates/ui/src/capture.rs`; cancellation/out-of-order/shutdown stress; lazy-print tests | P0 review | revert helper with its adapters | +| CL-2F | Shared capture API freeze for the eleven `craft-ui` consumers after reviewing the DesignCraft and GridCraft CL-2 pilot adapters | CL-2, G1 (Grid adapter), DesignCraft adapter | keep CL-2 revision; no app change | +| G0 | Baseline harnesses on `ac8da7d4`: frame bench, phase-tagged frame log, 0.36-compatible geometry/screenshot tests, benchmark evidence (0.37-only assertions arrive in G1/G3) | P0 | remove harnesses; no production behavior change | +| G1 | Atomic dependency/compile migration: root patches, manifests (native eframe `clipboard`), lock, `craft-ui`/`craft-print` bump, CL-2 slot adapter, E21b/W15P consumption, Role/ID/FontData/screenshot-callback source changes including `drain_screenshots`, timeout and the basic reply, strict shared harness with required names/fonts, style pin | S1+S2+CL-2, G0 baseline | revert G1; restore baseline lock/manifests | +| G2 | Lifecycle: autosave/title/printing in logic, adversarial screenshot byte-budget/discard/timeout/epoch tests, hidden synthetic-input paint | G1 | revert G2; G1 still builds | +| G3 | Remaining accessibility names/roles, pixel probes, focus/ID tests, broader IME/RTL coverage | G1 (G2 if same files) | revert G3 | +| G4 | Platform/performance acceptance (staged): Linux X11/Wayland, Windows VM, browser, macOS; print and CL-2 evidence; fix findings | G2+G3+CL-2F | revert G4 fixes; retain evidence | +| G5 | Docs: architecture, ui-parity, control protocol notes, release notes | G4 | revert docs | + +Implementation does not start until P0 has a fresh independent adversarial review, the complete +S1/S2 revisions are fixed and published, and CL-2's provisional API exists. The final production +`storytold` SHAs for egui P8/winit are unknown until that approved implementation; their absence +blocks S2/G1 rather than being filled with a candidate SHA or a stock/intermediate stage. Each app +PR is reviewable on its own; do not land an intermediate dependency bump that leaves `main` +unbuildable. A consumer already carrying an incompatible `craft-ui` bump must put that bump in the +same atomic dependency stage as the egui P8 landing. G1 is the only atomic “must compile and pass +CI” PR: it includes the shared strict harness, every accessible name and pinned font needed by the +existing suite (no suite-wide `allow_missing_glyphs`/`with_accessibility_check(false)`), and the +CL-2 adapter. G2/G3 can be separate commits/PRs once G1 +is green. CL-2F freezes the shared capture API only after both initial adapters are reviewed. G4 is +validation plus targeted fixes, not a feature grab; rerun the affected performance and visual checks +after any fix. + +### 10.2 Compatibility pin and lockfile checks + +Before each publication: + +- `cargo metadata --locked` and `cargo tree` show one egui-family source/revision and one winit. +- Root `[patch.crates-io]` entries use full SHAs; no branch/path. +- `craft-ui` and `craft-print` resolve to one approved craft-libs SHA that contains CL-1, CL-2's + provisional-or-frozen capture slot and any CL-3 contract fixes GridCraft consumes. CL-1 and + CL-2F are published for the eleven sharing consumers; GridCraft/DesignCraft use the CL-2 + provisional pilot and their adapters precede CL-2F. Do not add a GridCraft-local capture + channel/token map while the shared slot is the selected design, and do not leave a mixed + `craft-ui`/egui stage. +- The lockfile has no second `egui`/`eframe` version and no stale accesskit_consumer duplicates + except ones proven necessary by an unrelated dependency. +- `cargo check --locked` in a clean checkout works; CI's dependency check fails on a missing patch. +- `docs/` version statements that mention 0.36 are updated only after validation; do not rewrite + historical citations. + +### 10.3 Rollback + +Rollback restores the entire coherent set: `Cargo.toml`, all manifests, `Cargo.lock`, root patches, +source adaptations, `craft-ui`/`craft-print` revision and any test harness changes. Do not “roll +back” by changing only the egui version string. Keep the old lockfile, the 0.36.2 registry crates and +craft-libs `d27646a5` reachable. No persistence format changes are planned; `ui.json`, `prefs.json`, XLSX and +control JSON remain compatible. If a persistence change becomes necessary, specify backward +compatibility and preserve unreadable data. + +## 11. Original planning risks and remaining acceptance criteria + +Current disposition (2026-10-24): exact canonical revisions and the coherent capture API in +items 1–2 are selected as recorded at the top of this plan. The representative strict harness +checks in item 9 passed in final full CI07. Earlier candidate/PR states below are planning history, +not current pin blockers. Native/platform/IME, screen-reader and performance acceptance remain +separate; the final qualification update records what actually passed. + +1. **Final production fork revisions.** `aiongg/egui`/`aiongg/winit` are review candidates, not + production sources. The exact immutable `storytold` egui **P8** (including P2b async W15P + routing, P7 held-key pairing, P7b/E21b, P7c's selected #8763/#8748 fixes and justified perf + fixes) and completed winit (W15P + W27 lifecycle) SHAs are not selected at planning time. + Pinning is blocked until the approved implementation publishes them; record the full SHAs in + 1.1 and verify `--locked` before S2/G1. No stock 0.37 or intermediate stage is accepted. Without + E2/E3, #761's crash/fallback behavior is not qualified; without E4/W15P and E19/E21/E21b, + Wayland clipboard, PRIMARY, non-Latin and IME behavior regress. +2. **craft-libs CL-1 and CL-2 exact revisions.** PR 199's tree is stale; CL-1 is the hunk port onto + `4919088`, not a rebase. GridCraft cannot bump `craft-ui`/`craft-print` until CL-1 and CL-2's + provisional capture slot exist with clean tests and a coherent lock. CL-1 and CL-2F are the + shared gate for the eleven `craft-ui` consumers; GridCraft/DesignCraft are the CL-2 pilot and + their adapters are reviewed before CL-2F freezes the API. Any incompatible existing `craft-ui` + bump must land in the same atomic dependency stage. +3. **PRIMARY and HTML coexistence.** W15P must land in the approved winit revision, and the 6.4 + routing must be proven on wlroots/KWin/Mutter: grid HTML+plain-text copy through the one + arboard data-control handle, TextEdit copy/paste and PRIMARY through egui-winit/W15P, no second + arboard handle or `wl_data_device`, password exclusion and owner handoff intact. Native evidence + is required. +4. **E21b outgoing IME text.** The final canonical fork includes the shared producer and Wayland + adapter; the original candidate lacked them. GridCraft-specific acceptance still requires the + complete shared contract: owner generation present even when unavailable, available-empty + distinct and explicitly sent as an empty payload, cache clear plus committed disable/enable on + owner/unavailable transition, final completed pass including `None` authoritative, a + current-epoch serial mismatch applying incoming edits once and deferring outgoing state until a + match while obsolete epochs invalidate, and no full-document allocation for the new bounded + payload. Formula-bar/in-cell surrounding text, W27 delivery, deletion+commit, preedit exclusion, + focus/password clearing and two-window isolation remain gates; E21 alone is not enough. +5. **#8763/#8748 TextEdit selection and hidden-child regressions.** 0.37 collapses a focused + TextEdit's selection before the clicked widget runs. The final egui P8's P7c owns the selected + #8763/#8748 fixes and regression coverage; GridCraft verifies its formula-bar/context-action + paths under section 8.5. A local workaround requires a demonstrated app-specific need and + removal condition; PR #8764 itself is open/unmerged. +6. **#8731 text tessellation.** Stock 0.37 is reported ~60% slower for plain text; stock is the + measurement control. If reproduced, the fork needs the justified row-level fast path in P8 + before GridCraft accepts. GridCraft renders a large text grid, so this is a release blocker, + not a note. +7. **#8736 DX12 shader compile.** FXC is GridCraft's default. Measure renderer construction and + first frame; decide on the fork shader fix without weakening wrap modes. The upstream figure is + a report, not our measurement. +8. **#8512 system fonts.** Do not adopt the new provider without a measured startup/coverage + comparison; GridCraft's per-workbook font binding and script fallback must remain. +9. **Strict harness names/fonts.** G1 must pass `egui_kittest`'s Panic/accessibility defaults using + pinned fonts and complete accessible names, not a global opt-out. If a specific fixture cannot + be pinned, record the gap and the per-test exception; do not make CI depend on host fonts. +10. **Staged platform evidence.** Native Linux needs a provisioned X11/Wayland display and + compositor; Windows needs an owned clone that fits the allocator budget and remains + software-rendered until a qualified GPU run; browser needs a pinned runtime; macOS and physical + Windows GPU/audio need real hosts. Host `/dev/dri/renderD128` access is not display/GPU + acceptance. These stay explicit rollout blockers until recorded. +11. **AccessKit tree changes.** Role/tree/name changes may alter native screen-reader output. + kittest assertions are necessary but not sufficient; native AT-SPI/UIA/VoiceOver smoke tests are + required where available. +12. **Print scope.** PDF construction remains synchronous on the UI path as it is today. A measured + candidate regression is fixed or explicitly budgeted; moving generation to a background engine + is a separate, designed change. +13. **No generic compatibility layer.** craft-ui owns the capture slot because multiple apps need + the same completion lifecycle. GridCraft keeps its timeout, paths, tokens, JSON and animation + policy and does not add a local channel alternative. + +Acceptance for G1 is: `cargo xtask ci` passes at the exact approved P8/winit revisions with actual +test counts and the strict shared harness (no global glyph/accessibility opt-out); `cargo check +--locked` passes for native and wasm; one egui/winit source; E21b/W15P/CL-2 APIs are consumed; +`drain_screenshots`, timeout and the basic screenshot reply work, including a hidden/occluded +queued capture admitted through `RequestPaintWhileHidden` (a minimized request waits for restore or +times out); the historical compile errors are gone; and the +G1 smoke tests (open/edit/undo/save/reopen, screenshot, language switch, resize) pass. Acceptance for the migration as a whole adds G2–G4 evidence, the +visual/geometry/accessibility checks, paired performance results against the recorded baseline, +print and capture measurements, and no unresolved blocking unknown above. + + +## 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/gridcraft` `main` for this migration is `b8771d51fb4da60bd48c13cf313d6d6014dc5e66` (the checkout in +`/home/codex/work/CraftApps/egui037-migration-20261022/gridcraft-implementation`). The earlier +`bdf8b8cc…` and `ac8da7d4…` SHAs and line anchors above are retained as historical review +evidence; use `b8771d51fb4da60bd48c13cf313d6d6014dc5e66` and its exact lockfile for current-main integration and paired +measurements. Shared `craft-libs` integration now pins canonical c488caa9; 3832c47 is its +historical integration base. The original refs remain reachable. These unpublished immutable +pins still require an approved publication and clean remote-fetch verification. + +Add Ctrl+8 outline symbols/headings combinations to geometry and hit tests; consume reveal_from once; include Auto Fill Options, accounting fill and Ctrl+click hyperlink regressions. Pin fonts when using the now host-font-dependent snapshot example. + +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. + + +## Explicit accessibility qualification update — 2026-10-24 + +Full CI07 is successful evidence for the final formatted runtime source, including the seven +representative integration tests that explicitly call `check_accessibility()` on rendered +ribbon/locale/pane/menu/dialog/editor/backstage states. The real SheetApp capture Harness also +passed checks before issuance and after manual callback drain/repaint, preserving callback count, +request token, one-reply and released-reservation assertions. The CI06 unnamed controls were +repaired with visible localized label relationships or precise semantic names; roles and behavior +assertions were preserved. The four source-file differences from the fresh accessible-name review +are formatter-only. Full CI07 passed all ten stages with 2,249 passed, 0 failed and the existing +release timing experiment ignored. CI05 remains historical pre-repair evidence; CI06 remains a +failed historical run. + +This is callback and representative UI-tree coverage, not GPU/native hidden-window, native +screen-reader or every-state accessibility acceptance. The canonical egui fork includes outgoing +IME surrounding-text support and its Wayland adapter; application-specific formula-bar, in-cell +and text-box owner transitions, stale deletion/commit handling and native composition remain +separate qualification gates. Native open/edit/undo/save/reopen, language/resize, clipboard/PRIMARY, +hidden/minimize/restore/readback and browser/macOS/Windows workflows remain open, as do paired +performance, renderer/print and font/WASM size acceptance. Required CI success permits local +commit review; it does not authorize a push, tag or PR publication. Every publication still needs +fresh explicit human approval, followed by clean remote-fetch verification of the exact pins. + +Revision: 2026-10-24 — bound CI07, review union and final canonical source; no production changes. diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 4de3aaf80..466610d5c 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -131,7 +131,6 @@ if has appimage; then } APPDIR="$WORK/GridCraft.AppDir" cp -R "$STAGE" "$APPDIR" - mv "$APPDIR/usr/share/doc" "$WORK/doc-unused" ln -s usr/bin/gridcraft "$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" diff --git a/packaging/windows/gridcraft.wxs b/packaging/windows/gridcraft.wxs index 9a64b6119..dcc441be9 100644 --- a/packaging/windows/gridcraft.wxs +++ b/packaging/windows/gridcraft.wxs @@ -13,6 +13,9 @@ install side by side instead of replacing the old version. --> + + + + + + + + + + + diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index 8b780f849..d841ef565 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -90,11 +90,25 @@ Copy-Item (Join-Path $Bin 'gridcraft.exe'), (Join-Path $Bin 'gridcraft-cli.exe') & (Join-Path $PSScriptRoot 'sign.ps1') (Join-Path $Stage 'gridcraft.exe') (Join-Path $Stage 'gridcraft-cli.exe') +# ---- font notices for the MSI ------------------------------------------------------------------ +# The portable ZIP already carries these notices; stage the complete files before MSI creation too. +$Docs = Join-Path $Stage 'font-licences' +New-Item -ItemType Directory -Force -Path $Docs | Out-Null +$HasCraftFontLicences = 0 +if ($env:CRAFT_FONTS_DIR) { + foreach ($ofl in Get-ChildItem -Path (Join-Path $env:CRAFT_FONTS_DIR 'fonts\*\OFL.txt') -File -ErrorAction SilentlyContinue) { + Copy-Item -LiteralPath $ofl.FullName -Destination (Join-Path $Docs "OFL-$($ofl.Directory.Name).txt") + $HasCraftFontLicences = 1 + } +} +# ---- end font notices for the MSI -------------------------------------------------------------- + # ---- MSI --------------------------------------------------------------------------------------- $Msi = Join-Path $Dist "gridcraft-$Version-windows-$Arch.msi" Invoke-Native 'wix build' { wix build (Join-Path $PSScriptRoot 'gridcraft.wxs') -arch $Arch ` -d "Version=$MsiVersion" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root 'assets\app-icon\gridcraft.ico')" ` + -d "DocsDir=$Docs" -d "HasCraftFontLicences=$HasCraftFontLicences" ` -o $Msi } # wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets. diff --git a/xtask/src/keytips.rs b/xtask/src/keytips.rs index f6df981b7..98538b8db 100644 --- a/xtask/src/keytips.rs +++ b/xtask/src/keytips.rs @@ -344,7 +344,7 @@ use gridcraft_ui_egui::SheetApp; type Harness = egui_kittest::Harness<'static, SheetApp>; fn harness(session: Session) -> Harness { - let mut h = egui_kittest::Harness::builder() + let mut h = crate::common::builder() .with_os(OperatingSystem::Nix) .with_size(egui::vec2(1200.0, 800.0)) .build_ui_state( @@ -352,7 +352,7 @@ fn harness(session: Session) -> Harness { app.logic(ui.ctx()); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); h.run_steps(4); h