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/ATTRIBUTION.md b/ATTRIBUTION.md index 36678d8a4..a1e4d8fbc 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -8,6 +8,10 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra | Asset | Author | Source | Licence | Notes | |---|---|---|---|---| +| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset; emoji-icon-font.ttf is the full monochrome fallback. Registered by the production default font definitions; dependency assets unmodified here, no font file copied into this repository. | +| `epaint_default_fonts::HACK_REGULAR` (dependency font input) | Source Foundry Authors (Hack; DejaVu and Bitstream Vera foundations) | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Hack](https://github.com/source-foundry/Hack) | MIT + Bitstream Vera License (full upstream texts in `NOTICE`) | Hack-Regular.ttf is egui's bundled monospace face and GridCraft's last-resort symbol fallback. Dependency asset unmodified here, no font file copied into this repository. | +| `epaint_default_fonts::NOTO_EMOJI_REGULAR` (dependency font input) | The Noto Project Authors | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Noto Emoji](https://fonts.google.com/noto/specimen/Noto+Emoji) | SIL Open Font License 1.1 (full upstream text in `NOTICE`) | NotoEmoji-Regular.ttf is enabled by egui's `monochrome_emoji_fonts` feature and used as the monochrome emoji fallback. Dependency asset unmodified here, no font file copied into this repository. | +| `epaint_default_fonts::UBUNTU_LIGHT` (dependency font input) | Canonical Ltd. and the Ubuntu Font Family authors | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Ubuntu font](https://design.ubuntu.com/font) | Ubuntu Font Licence 1.0 (full upstream text in `NOTICE`) | Ubuntu-Light.ttf is egui's default proportional face. Dependency asset unmodified here, no font file copied into this repository. | | `docs/brand/artcraft-logo.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source | | `docs/brand/artcraft-logo.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark | | `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark | diff --git a/Cargo.lock b/Cargo.lock index a24d64c2b..eaaa24559 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=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f" 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=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f" 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=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" 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=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" 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=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" 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=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" 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=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit_winit", "arboard", + "block2 0.6.2", "bytemuck", "egui", + "image", "log", "objc2 0.6.5", + "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", + "parking_lot", "profiling", "raw-window-handle", "smithay-clipboard", "web-time", + "windows-sys 0.61.2", "winit", ] [[package]] name = "egui_glow" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "egui", @@ -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=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" 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=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", ] +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + [[package]] name = "endi" version = "1.1.1" @@ -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=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" 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=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" [[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=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc" 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=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_extras" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_inspection" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_system_font_provider" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" diff --git a/Cargo.toml b/Cargo.toml index 35852e923..56d3e504b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"] version = "0.12.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 = "6d518fc799681571963842f95b55a6b68431b15a" } +emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" } diff --git a/NOTICE b/NOTICE index 1e3668b9d..78c72b9ac 100644 --- a/NOTICE +++ b/NOTICE @@ -12,5 +12,265 @@ Third-party material This product bundles third-party material under its own licenses, listed below. The per-file list (author, source, license) is in ATTRIBUTION.md. -None: every bundled asset is original to GridCraft. Builds made with craft-fonts -(CRAFT_FONTS_DIR) embed the fonts listed in ATTRIBUTION.md under the SIL Open Font License 1.1. +GridCraft's original assets and the UI's bundled egui font inputs are listed in ATTRIBUTION.md. +Builds made with craft-fonts (CRAFT_FONTS_DIR) also embed the fonts listed there under the SIL Open +Font License 1.1. + + +The UI's egui-icons.ttf subset and emoji-icon-font.ttf fallback are provided by epaint_default_fonts +and registered through egui's default font definitions. They derive from emoji-icon-font by +John Slegers, under the MIT License. The subset and full face share the following upstream notice. +Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts +Original: https://github.com/jslegers/emoji-icon-font +The following text is reproduced verbatim from emoji-icon-font-mit-license.txt: + +MIT License + +Copyright (c) 2014 John Slegers + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Hack-Regular.ttf is provided by epaint_default_fonts and is egui's bundled monospace face. The +work is Copyright 2018 Source Foundry Authors and is distributed under the MIT License and the +Bitstream Vera License. The DejaVu work it derives from was committed to the public domain. +Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts +Upstream: https://github.com/source-foundry/Hack +The following text is reproduced verbatim from Hack-Regular.txt: + +The work in the Hack project is Copyright 2018 Source Foundry Authors and licensed under the MIT License + +The work in the DejaVu project was committed to the public domain. + +Bitstream Vera Sans Mono Copyright 2003 Bitstream Inc. and licensed under the Bitstream Vera License with Reserved Font Names "Bitstream" and "Vera" +MIT License + +Copyright (c) 2018 Source Foundry Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +BITSTREAM VERA LICENSE + +Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a trademark of Bitstream, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of the fonts accompanying this license ("Fonts") and associated documentation files (the "Font Software"), to reproduce and distribute the Font Software, including without limitation the rights to use, copy, merge, publish, distribute, and/or sell copies of the Font Software, and to permit persons to whom the Font Software is furnished to do so, subject to the following conditions: + +The above copyright and trademark notices and this permission notice shall be included in all copies of one or more of the Font Software typefaces. + +The Font Software may be modified, altered, or added to, and in particular the designs of glyphs or characters in the Fonts may be modified and additional glyphs or characters may be added to the Fonts, only if the fonts are renamed to names not containing either the words "Bitstream" or the word "Vera". + +This License becomes null and void to the extent applicable to Fonts or Font Software that has been modified and is distributed under the "Bitstream Vera" names. + +The Font Software may be sold as part of a larger software package but no copy of one or more of the Font Software typefaces may be sold by itself. + +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. + +Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org. + +NotoEmoji-Regular.ttf is provided by epaint_default_fonts and is enabled by egui's +monochrome_emoji_fonts feature. It is distributed under the SIL Open Font License, Version 1.1. +Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts +Upstream: https://fonts.google.com/noto/specimen/Noto+Emoji +The following text is reproduced verbatim from OFL.txt: + +This Font Software is licensed under the SIL Open Font License, +Version 1.1. + +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + +Ubuntu-Light.ttf is provided by epaint_default_fonts and is egui's default proportional face. It +is distributed under the Ubuntu Font Licence, Version 1.0. +Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts +Upstream: https://design.ubuntu.com/font +The following text is reproduced verbatim from UFL.txt: + +------------------------------- +UBUNTU FONT LICENCE Version 1.0 +------------------------------- + +PREAMBLE +This licence allows the licensed fonts to be used, studied, modified and +redistributed freely. The fonts, including any derivative works, can be +bundled, embedded, and redistributed provided the terms of this licence +are met. The fonts and derivatives, however, cannot be released under +any other licence. The requirement for fonts to remain under this +licence does not require any document created using the fonts or their +derivatives to be published under this licence, as long as the primary +purpose of the document is not to be a vehicle for the distribution of +the fonts. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this licence and clearly marked as such. This may +include source files, build scripts and documentation. + +"Original Version" refers to the collection of Font Software components +as received under this licence. + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to +a new environment. + +"Copyright Holder(s)" refers to all individuals and companies who have a +copyright ownership of the Font Software. + +"Substantially Changed" refers to Modified Versions which can be easily +identified as dissimilar to the Font Software by users of the Font +Software comparing the Original Version with the Modified Version. + +To "Propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification and with or without charging +a redistribution fee), making available to the public, and in some +countries other activities as well. + +PERMISSION & CONDITIONS +This licence does not grant any rights under trademark law and all such +rights are reserved. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of the Font Software, to propagate the Font Software, subject to +the below conditions: + +1) Each copy of the Font Software must contain the above copyright +notice and this licence. These can be included either as stand-alone +text files, human-readable headers or in the appropriate machine- +readable metadata fields within text or binary files as long as those +fields can be easily viewed by the user. + +2) The font name complies with the following: +(a) The Original Version must retain its name, unmodified. +(b) Modified Versions which are Substantially Changed must be renamed to +avoid use of the name of the Original Version or similar names entirely. +(c) Modified Versions which are not Substantially Changed must be +renamed to both (i) retain the name of the Original Version and (ii) add +additional naming elements to distinguish the Modified Version from the +Original Version. The name of such Modified Versions must be the name of +the Original Version, with "derivative X" where X represents the name of +the new work, appended to that name. + +3) The name(s) of the Copyright Holder(s) and any contributor to the +Font Software shall not be used to promote, endorse or advertise any +Modified Version, except (i) as required by this licence, (ii) to +acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with +their explicit written permission. + +4) The Font Software, modified or unmodified, in part or in whole, must +be distributed entirely under this licence, and must not be distributed +under any other licence. The requirement for fonts to remain under this +licence does not affect any document created using the Font Software, +except any version of the Font Software extracted from a document +created using the Font Software may only be distributed under this +licence. + +TERMINATION +This licence becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER +DEALINGS IN THE FONT SOFTWARE. diff --git a/apps/gridcraft-web/src/web.rs b/apps/gridcraft-web/src/web.rs index 1b59dc0d4..43923bde1 100644 --- a/apps/gridcraft-web/src/web.rs +++ b/apps/gridcraft-web/src/web.rs @@ -8,6 +8,7 @@ use wasm_bindgen::JsValue; const CANVAS_ID: &str = "gridcraft_canvas"; const LOADING_ID: &str = "gridcraft_loading"; +/// Starts the eframe web runner for the GridCraft canvas. It adjusts the WebGPU device limits to the adapter's, optionally selects the WebGL backend from the query string, and builds the shell with the requested sample or host bridge, browser language rules, stored preferences and the loading/error page state. pub fn start() { eframe::WebLogger::init(log::LevelFilter::Info).ok(); wasm_bindgen_futures::spawn_local(async { @@ -71,8 +72,10 @@ pub fn start() { _ => {} } } - // 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, @@ -167,8 +170,9 @@ impl eframe::App for WebShell { } } - fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { - self.app.raw_input_hook(raw); + /// Forwards a browser frame's raw input to the shared app before eframe handles it. + 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..85481b731 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 = "25da1314d443171b4adeaad2cd9454b273f35c6f" } rfd = { workspace = true } arboard = { workspace = true } diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 1f90bb865..29dac2f89 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -40,13 +40,13 @@ 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); + /// Forwards raw input to the shared app, then opens every non-empty dropped file path as a workbook. + 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 4ab1f32db..2132fc601 100644 --- a/apps/gridcraft/src/print.rs +++ b/apps/gridcraft/src/print.rs @@ -1,11 +1,15 @@ //! Desktop spooler adapter. Discovery and submission never run on the UI thread. use gridcraft_ui_egui::printing::{Discover, Duplex, Printer, Settings, Submit}; +/// Returns a printer-discovery closure that enumerates `craft_print::printers()` on a named background thread and sends the mapped list through a channel. 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..3adb3d7c1 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 = "25da1314d443171b4adeaad2cd9454b273f35c6f" } 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..07ab18a23 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -12,6 +12,10 @@ use std::fmt::Write as _; use serde_json::Value; +#[path = "src/craft_fonts_policy.rs"] +mod craft_fonts_policy; + +/// Build script: reads the contributor JSON/TOML and writes generated `credits.rs`, then emits `craft_fonts.rs`; missing or malformed optional inputs warn, while required mode reports build errors. 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 +31,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` (exactly +/// `1`; any other value leaves required mode off), 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 = craft_fonts_policy::required_mode(std::env::var("CRAFT_FONTS_REQUIRED").ok().as_deref()); + 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, required) { + 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, required: bool) -> Result { + 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 dbd24d581..ec097c201 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -258,9 +258,10 @@ fn save(app: &mut SheetApp, out: &mut Outcome) { // ---------------------------------------------------------------- navigation +/// Allocates and paints one Backstage navigation row: selected or hover fill, icon, label and button semantics. Returns the row's click response. 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 +298,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 +389,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 +515,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 +530,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); @@ -589,6 +590,7 @@ pub fn protect_workbook(app: &mut SheetApp) { } } +/// Shows the workbook title, path, sheet count and structure protection, plus editable document properties. Committing a changed field after it loses focus runs `file.properties`. fn info(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage) { let t = Tokens::get(ui.ctx()); let lang = app.ui.language; @@ -643,13 +645,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())); @@ -660,6 +662,7 @@ fn info(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage) { }); } +/// Draws the Save As type picker and action: xlsx opens the `saveAs` dialog, other types export, and the page closes when the save/export succeeds (or a web download handles it). fn save_as(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) { let lang = app.ui.language; dim(ui, lang.tr("Save the workbook under another name or in another folder.")); @@ -667,14 +670,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. @@ -696,6 +704,7 @@ fn save_as(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcom dim(ui, lang.tr("A CSV file, web page or PDF is a copy: the workbook keeps its own file.")); } +/// Builds the print scope and fit-to-page parameters, caches `file.printPreview` per document revision/options, and draws page thumbnails with navigation plus the print settings and Print action. fn print(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) { let t = Tokens::get(ui.ctx()); let lang = app.ui.language; @@ -728,14 +737,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; @@ -935,33 +949,48 @@ pub fn export_as(app: &mut SheetApp, fmt: &str) -> bool { } } +/// Excel's File › Options page: General (display theme, interface language and Show formula bar), +/// Editing (AutoComplete) and Save (the autosave checkbox toggling `app.ui.autosave`), plus Formulas +/// (calculation mode and iterative calculation) when a document is active. Theme, language, formula +/// bar, AutoComplete and calculation changes run their commands through `run_or_alert`; calculation +/// settings are saved with the workbook. 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); @@ -1005,10 +1034,14 @@ fn options(app: &mut SheetApp, ui: &mut Ui) { ui.horizontal(|ui| { ui.add_space(24.0); egui::Grid::new("backstage_options_iteration").num_columns(2).spacing(vec2(12.0, 6.0)).show(ui, |ui| { - ui.label(lang.tr("Maximum Iterations:")); + let label = ui.label(lang.tr("Maximum Iterations:")); let id = ui.make_persistent_id("backstage_max_iterations"); // Text left in the field when iteration is switched off (it loses focus) is dropped. - if let Some(text) = crate::widgets::committed_text(ui, id, &calc.max_iterations.to_string(), Some(80.0)).filter(|_| iterative) { + let committed = crate::widgets::committed_text(ui, id, &calc.max_iterations.to_string(), Some(80.0)); + if let Some(response) = ui.ctx().read_response(id) { + response.labelled_by(label.id); + } + if let Some(text) = committed.filter(|_| iterative) { match parse_max_iterations(&text) { Some(n) if n != calc.max_iterations => { app.run_or_alert("formulas.calculationOptions", json!({"maxIterations": n})); @@ -1018,9 +1051,13 @@ fn options(app: &mut SheetApp, ui: &mut Ui) { } } ui.end_row(); - ui.label(lang.tr("Maximum Change:")); + let label = ui.label(lang.tr("Maximum Change:")); let id = ui.make_persistent_id("backstage_max_change"); - if let Some(text) = crate::widgets::committed_text(ui, id, &calc.max_change.to_string(), Some(80.0)).filter(|_| iterative) { + let committed = crate::widgets::committed_text(ui, id, &calc.max_change.to_string(), Some(80.0)); + if let Some(response) = ui.ctx().read_response(id) { + response.labelled_by(label.id); + } + if let Some(text) = committed.filter(|_| iterative) { match parse_max_change(&text, lang.formula_locale()) { Some(d) if d != calc.max_change => { app.run_or_alert("formulas.calculationOptions", json!({"maxChange": d})); 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 c28167c4d..524faf87d 100644 --- a/crates/ui-egui/src/cell_pictures.rs +++ b/crates/ui-egui/src/cell_pictures.rs @@ -116,13 +116,14 @@ fn fitted_rect(cell: Rect, size: [usize; 2]) -> Option { Some(Rect::from_center_size(cell.center(), source * scale)) } +/// Draws a cached cell picture fitted inside the cell and clipped to it, or paints the picture fallback icon when no texture is available. pub(crate) fn paint(painter: &Painter, cell: Rect, picture: &Arc) { if cell.width() <= 0.0 || cell.height() <= 0.0 || !cell.intersects(painter.clip_rect()) { return; } // 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/code_editor.rs b/crates/ui-egui/src/code_editor.rs index 906ddf3f1..6f02b4044 100644 --- a/crates/ui-egui/src/code_editor.rs +++ b/crates/ui-egui/src/code_editor.rs @@ -551,7 +551,10 @@ pub fn code_editor(ui: &mut Ui, id: egui::Id, text: &mut String, lang: Lang, max let caret = out.galley.pos_from_cursor(CCursor::new(e.caret)).translate(out.galley_pos.to_vec2()); ui.scroll_to_rect(caret, None); } - let mut r = out.response.response; + // The field has no visible label; name it for screen readers and the strict + // kittest accessibility check. The editor is a generic code field, so the name is + // not part of the UI's translated strings. + let mut r = out.response.response.accessible_name("Code Editor"); if edited.is_some() { r.mark_changed(); } @@ -667,17 +670,15 @@ mod tests { } #[test] + /// Shift+Tab removes four leading spaces, while a plain Tab still inserts four spaces at the caret. fn shift_tab_in_the_editor_dedents_rather_than_indents() { let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state( |ui, text: &mut String| { if ui.ctx().cumulative_pass_nr() == 0 { - let mut fonts = egui::FontDefinitions::default(); - let mono = fonts.families.get(&egui::FontFamily::Monospace).cloned().unwrap_or_default(); - fonts.families.insert(egui::FontFamily::Name(theme::MONO.into()), mono); - ui.ctx().set_fonts(fonts); + ui.ctx().set_fonts(theme::pinned_font_definitions()); return; } - let id = egui::Id::new("code"); + let id = egui::Id::unique("code"); ui.memory_mut(|m| m.request_focus(id)); code_editor(ui, id, text, Lang::Python, 5); }, @@ -694,14 +695,12 @@ mod tests { assert_eq!(h.state(), " x "); } - /// Gives the context the editor's monospace family (the test has no craft fonts). + /// Gives the context the deterministic font set, including the editor's monospace family. fn install_mono(ctx: &egui::Context) { - let mut fonts = egui::FontDefinitions::default(); - let mono = fonts.families.get(&egui::FontFamily::Monospace).cloned().unwrap_or_default(); - fonts.families.insert(egui::FontFamily::Name(theme::MONO.into()), mono); - ctx.set_fonts(fonts); + ctx.set_fonts(theme::pinned_font_definitions()); } + /// Builds a 400x300 code-editor harness with pinned mono fonts, focus, two passes and an accessibility check. fn editor_harness(text: &str) -> egui_kittest::Harness<'static, String> { let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state( |ui, text: &mut String| { @@ -709,13 +708,14 @@ mod tests { install_mono(ui.ctx()); return; } - let id = egui::Id::new("code"); + let id = egui::Id::unique("code"); ui.memory_mut(|m| m.request_focus(id)); code_editor(ui, id, text, Lang::Python, 5); }, text.to_string(), ); h.run_steps(2); + h.check_accessibility(); h } @@ -869,9 +869,10 @@ mod tests { } #[test] + /// Events held by the previous pass are applied once in the next pass; older held events are dropped and cleared. fn held_back_events_land_in_the_next_pass_only() { let mut h = editor_harness(""); - let id = egui::Id::new("code"); + let id = egui::Id::unique("code"); let held = |pass, c: &str| Held { pass, events: vec![egui::Event::Text(c.into())] }; // What the pass just completed held back comes in with the next one. let last = h.ctx.cumulative_pass_nr() - 1; @@ -886,6 +887,7 @@ mod tests { } #[test] + /// Held keyboard input does not land in the next focused text field once the code editor has lost focus. fn held_back_events_go_nowhere_once_the_editor_lost_the_focus() { // Typed into the editor, they must not land in whatever has the focus next. let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state( @@ -894,10 +896,10 @@ mod tests { install_mono(ui.ctx()); return; } - let (code, other) = (egui::Id::new("code"), egui::Id::new("other")); + let (code, other) = (egui::Id::unique("code"), egui::Id::unique("other")); ui.memory_mut(|m| m.request_focus(if s.2 { code } else { other })); code_editor(ui, code, &mut s.0, Lang::Python, 5); - ui.add(TextEdit::singleline(&mut s.1).id(other)); + ui.add(TextEdit::singleline(&mut s.1).id(other)).accessible_name("Other field"); }, (String::new(), String::new(), true), ); @@ -911,6 +913,7 @@ mod tests { } #[test] + /// A discard-triggered repeat pass receives held keys with the selection made by the first pass. fn a_repeat_pass_of_the_frame_counts_as_the_next_pass() { // When a widget asks egui to discard and repeat the pass, the repeat pass gets the // held-back keys, with the selection the field made in the first pass. @@ -920,7 +923,7 @@ mod tests { install_mono(ui.ctx()); return; } - let id = egui::Id::new("code"); + let id = egui::Id::unique("code"); ui.memory_mut(|m| m.request_focus(id)); code_editor(ui, id, &mut s.0, Lang::Python, 5); if std::mem::take(&mut s.1) { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 8e29bccb4..9d093e913 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,320 @@ 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()) + } + + /// Builds a `CaptureGeometry` from fresh viewport information. A minimized window has no size; `inner_rect` falls back to the raw screen rect; native scale is multiplied by interface zoom, and a zero surface limit means uncapped. + 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) + } +} + +/// True only when both width and height are finite and positive. +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 +469,51 @@ 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)")); +/// Bytes charged to one synthetic event: `size_of::()` plus the payload of a `Text` event. +fn event_bytes(event: &egui::Event) -> usize { + std::mem::size_of::() + + match event { + egui::Event::Text(text) => text.len(), + _ => 0, + } +} + +/// Saturating totals of queued synthetic events and their charged bytes across every pending batch. +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(()) } +/// Checks the synthetic-event budget, then queues one pointer move followed by `count` press/release pairs (`count` is at least one). 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 { @@ -191,9 +524,11 @@ fn push_click(app: &mut SheetApp, pos: egui::Pos2, button: egui::PointerButton, Ok(()) } +/// Checks the synthetic-event budget, then queues a move, a press, `steps` interpolated moves and a release. 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 +539,36 @@ fn push_drag(app: &mut SheetApp, a: egui::Pos2, b: egui::Pos2, steps: usize, m: Ok(()) } +/// Checks the synthetic-event budget and queues one key press/release pair. +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(()) +} + +/// Rejects text longer than `MAX_SYNTHETIC_TEXT_BYTES`, then queues one `Text` event under the shared queue budget. +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(()) +} + +/// Queues one pointer-move event under the shared synthetic queue budget. +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 }; @@ -215,6 +580,7 @@ fn filter_commands(commands: Vec, search: Option<&str>) -> Vec { commands.into_iter().filter(|c| has(c, "id") || has(c, "label") || in_menu(c)).collect() } +/// Dispatches one control request: commands, inspection, UI state, dialogs, editing, synthetic input, open/save/quit and screenshots. Unknown methods (and unknown command ids) return an error. Immediate answers request a repaint; `ui.screenshot` is admitted asynchronously instead of being answered here. pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -> Outcome { let s = |k: &str| p.get(k).and_then(Json::as_str); let f = |k: &str| p.get(k).and_then(Json::as_f64).unwrap_or(0.0) as f32; @@ -359,21 +725,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; @@ -448,6 +820,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - out } +/// Drains queued control requests, drops requests whose client stopped waiting, handles immediate outcomes and admits screenshots, then requests a repaint while synthetic input remains. pub fn poll(app: &mut SheetApp, ctx: &egui::Context) { let Some(rx) = app.control_rx.take() else { return }; while let Ok(req) = rx.try_recv() { @@ -461,12 +834,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); @@ -475,73 +843,530 @@ 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 +/// Sends one screenshot rejection carrying the request token. +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 +} + +/// Returns the next screenshot token, wrapping and skipping zero. +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(); +} + +/// Builds the standard token-carrying timeout reply for a screenshot request. +fn timeout_reply(message: &str, token: u64) -> Json { + json!({"ok": false, "error": message, "request": token}) +} + +/// The bytes to reserve for one capture of `expected` against `geometry`, after checking the +/// applicable limits. Issuance and retry both use this (admission keeps its own inline checks) so a +/// retry cannot drift from the limits the first issuance applied. `expected` is `None` when the +/// viewport has no capturable size yet: that reserves [`UNKNOWN_CAPTURE_BYTES`] and still checks +/// the aggregate limit, while the per-image, encoding and peak checks only apply once a size is +/// known. +fn capture_bytes(app: &SheetApp, i: usize, geometry: CaptureGeometry, expected: Option<[usize; 2]>, base64: bool) -> Result { + let bytes = match expected { + Some(expected) => { + 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", + expected[0], expected[1] + )); + } + check_encoding_budget(expected, base64)?; + bytes + } + 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(expected) = expected { + check_peak_budget(others.saturating_add(bytes), expected, base64)?; + } + Ok(bytes) +} + +/// 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 bytes = match capture_bytes(app, i, current, Some(expected), base64) { + Ok(bytes) => bytes, + Err(message) => { + 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() { + reject_shot_at(app, i, 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; +/// Checks that the image has the pixel count its dimensions imply, is within one pixel of the expected size on each axis, and keeps the expected aspect ratio within that rounding. +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())) - } - _ => 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())); + // 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(()) +} + +/// What [`drain_screenshots`] must do with one request after inspecting it. +enum DrainAction { + /// Still waiting; leave the request at the same index. + Keep, + /// Answer the request with this reply and remove it. + Remove(Json), + /// Re-arm the request for a fresh capture within its retry budget. + Restart, +} + +/// 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() { + match drain_action(app, i, now, current_geometry, epoch, active_document, current_viewport) { + DrainAction::Keep => i += 1, + DrainAction::Remove(reply) => finish_shot(app, i, reply), + DrainAction::Restart => match retry_capture(app, i, current_geometry, epoch, now) { + Ok(()) => i += 1, + Err(message) => reject_shot_at(app, i, 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)); + } +} + +/// Validates one request and decides whether it stays queued, is answered or is re-armed. +fn drain_action( + app: &mut SheetApp, + i: usize, + now: f64, + current_geometry: CaptureGeometry, + epoch: u64, + active_document: Option, + current_viewport: egui::ViewportId, +) -> DrainAction { + 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 { + cancel_shot(r); + return DrainAction::Remove(json!({"ok": false, "error": SCREENSHOT_VIEWPORT_CHANGED})); + } + if r.document != active_document { + cancel_shot(r); + return DrainAction::Remove(json!({"ok": false, "error": SCREENSHOT_DOCUMENT_CHANGED})); + } + if !r.issued { + return drain_unissued(r, now); + } + drain_completion(r, now, current_geometry, epoch) +} + +/// A request that never issued a capture: keep waiting, or report the timeout that fits whether +/// the window ever had a capturable size. +fn drain_unissued(r: &mut Shot, now: f64) -> DrainAction { + if now < r.deadline { + return DrainAction::Keep; + } + cancel_shot(r); + 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" + }; + DrainAction::Remove(timeout_reply(message, r.token)) +} + +/// A request that issued a capture: inspect its slot once, then apply the deadline, validation +/// and geometry checks in the same order as issuance. +fn drain_completion(r: &mut Shot, now: f64, current_geometry: CaptureGeometry, epoch: u64) -> DrainAction { + match r.slot.as_ref().and_then(|slot| slot.take()) { + // 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); + DrainAction::Remove(timeout_reply("screenshot timeout: the image completed after its deadline; no file was written", r.token)) } - 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 - }); + Some(Ok(image)) => drain_image(r, image, now, current_geometry, epoch), + // 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 retry_allowed(r, now) => DrainAction::Restart, + Some(Err(e)) => DrainAction::Remove(json!({"ok": false, "error": e.to_string()})), + None if now >= r.deadline => { + cancel_shot(r); + DrainAction::Remove(timeout_reply("no frame was presented before the screenshot timeout", r.token)) + } + None => DrainAction::Keep, + } +} + +/// Validates a completed image against the size recorded at issuance and the current +/// geometry/epoch; only a valid image is saved, and only a retry that is still inside the +/// request's deadline and retry budget gets a fresh attempt. +fn drain_image(r: &mut Shot, image: std::sync::Arc, now: f64, current_geometry: CaptureGeometry, epoch: u64) -> DrainAction { + let validated = match r.expected { + Some(expected) => validate_image(&image, expected), + None => Err("screenshot has no expected size".to_string()), + }; + match validated { + Err(_) if retry_allowed(r, now) => { + drop(image); + DrainAction::Restart + } + Err(message) => DrainAction::Remove(json!({"ok": false, "error": message})), + Ok(()) if epoch != r.epoch || current_geometry != r.geometry => { + if !retry_allowed(r, now) { + return DrainAction::Remove(json!({ + "ok": false, + "error": "screenshot geometry changed while it was captured; no image was written" + })); + } + drop(image); + DrainAction::Restart + } + Ok(()) => DrainAction::Remove(save_image(image, r.path.as_deref())), + } +} + +/// Whether this request may take another capture: both limits are fixed for the whole request. +fn retry_allowed(r: &Shot, now: f64) -> bool { + r.retries < SCREENSHOT_MAX_RETRIES && now < r.deadline +} + +/// Takes and cancels the request's capture slot, if it has one. +fn cancel_shot(r: &mut Shot) { + if let Some(slot) = r.slot.take() { + slot.cancel(); + } +} + +/// Remove request `i`, cancel its slot and answer it once with a token-carrying reply. +fn finish_shot(app: &mut SheetApp, i: usize, reply: Json) { + let request = app.shots.requests.remove(i); + if let Some(slot) = request.slot { + slot.cancel(); + } + let _ = request.reply.send(with_token(reply, request.token)); +} + +/// Remove request `i`, cancel its slot and reject it with `message`. +fn reject_shot_at(app: &mut SheetApp, i: usize, message: impl std::fmt::Display) { + let request = app.shots.requests.remove(i); + if let Some(slot) = request.slot { + slot.cancel(); + } + reject_shot(request.reply, request.token, message); +} + +/// 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 = capture_bytes(app, i, geometry, expected, 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}}) + } } } @@ -578,6 +1403,7 @@ mod tests { } #[test] + /// Checks that oversized click/drag counts are refused without changing already queued batches, that queued input counts toward the budget, and that normal gestures still queue fully. fn oversized_pointer_requests_are_refused_and_leave_the_queue_alone() { let mut app = test_app(); let p = egui::pos2(20.0, 20.0); @@ -587,11 +1413,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()); @@ -637,4 +1463,900 @@ 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 + } + + /// Returns an egui context initialized by one blank pass at `size` with an 8192-pixel texture limit. + 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 + } + + /// Returns blank raw input with a screen rect at `size` and an 8192-pixel texture limit. + 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() } + } + + /// Extracts cloned `ViewportCommand::Screenshot` callbacks from a `FullOutput` for the context's viewport. + 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 + } + + /// Returns a per-process temporary PNG path after removing any stale file at that path. + 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] + /// Checks rounded physical pixels, expected/retained byte counts, the texture-axis cap, the no-size case and overflow refusal. + 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] + /// Checks that one request beyond `SCREENSHOT_MAX_REQUESTS` is rejected with its token while earlier requests stay queued and do not reply early. + 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] + /// Three roughly 100 MB path captures hit the aggregate byte limit; the third is rejected and only two requests remain. + 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] + /// An unknown-size request reserves the placeholder, issues nothing while settling, then replies once on timeout and releases the reservation. + 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"); + } + + /// Counts `RequestPaintWhileHidden` commands in a logic output. + fn hidden_paints(out: &egui::LogicOutput) -> usize { + out.viewport_commands.values().flatten().filter(|c| matches!(c, egui::ViewportCommand::RequestPaintWhileHidden)).count() + } + + #[test] + /// A queued capture requests a hidden paint; once issued, the screenshot command supplies the paint itself. + 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] + /// A minimized request stays queued without requesting hidden paints, because it cannot issue until restored. + 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] + /// The real app harness issues exactly one callback, replies once with the token and dimensions, and releases the reservation. + 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] + /// Completing callbacks in reverse order replies on each matching token/path and writes each request's own pixels. + 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] + /// An expired request replies once with a timeout, writes no file and releases its reservation. + 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] + /// A valid image that completes after its deadline gets one timeout reply and is not written. + 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] + /// Cancelling a request does not reply; a later callback for it is ignored and writes nothing. + 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] + /// The first issuance adopts a new geometry, updates the epoch and reservation, spends no retry, and the capture then succeeds. + 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] + /// The reservation is the largest accepted rounded image (11×9×4 bytes for a 10×8 viewport), and that image is accepted. + 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] + /// Oversized images retry within the bounded budget, never write, then report the per-image limit, while the valid peer is written. + 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] + /// Two `ui` calls in one pass count as one settling frame, and a pass that requests a discard is not counted; only its rerun counts. + 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] + /// Issuance survives a discard requested after it, the image is written once, and a duplicate callback completion is inert. + 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] + /// A grow-resize spends one retry, reissues at the new size and then accepts the new image. + 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] + /// Minimize retries reserve unknown-size bytes; only two fit the aggregate budget and stay queued (the rest are reported), and the survivors write after restore. + fn a_minimize_retry_without_a_size_still_checks_the_aggregate_budget() { + let ctx = sized_context(egui::vec2(64.0, 48.0)); + let mut app = test_app(); + let mut paths = Vec::new(); + let mut receivers = Vec::new(); + for n in 0..SCREENSHOT_MAX_REQUESTS { + let path = temp_png(&format!("minimize-retry-{n}")); + let (tx, rx) = std::sync::mpsc::channel(); + // A path capture isolates the retained-image aggregate budget. + admit(&mut app, &ctx, Some(path.to_string_lossy().into_owned()), tx); + paths.push(path); + 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()); + assert_eq!(callbacks.len(), SCREENSHOT_MAX_REQUESTS, "four path captures fit every budget"); + assert_eq!(app.shots.reserved(), SCREENSHOT_MAX_REQUESTS * 65 * 49 * 4); + + // The window is minimized with every capture outstanding. The drain retries each one + // against the new surface generation, which has no capturable size, so every retry + // reserves UNKNOWN_CAPTURE_BYTES. Only two unknown-size reservations fit the aggregate + // limit; the rest must be reported instead of inflating `reserved()` over the budget. + let mut minimized = raw_of(egui::vec2(64.0, 48.0)); + if let Some(viewport) = minimized.viewports.get_mut(&egui::ViewportId::ROOT) { + viewport.inner_rect = None; + viewport.minimized = Some(true); + } + app.raw_input_hook(&ctx, &mut minimized); + let image = std::sync::Arc::new(egui::ColorImage::filled([64, 48], egui::Color32::RED)); + for callback in &callbacks { + callback.clone().complete(image.clone()); + } + let _ = ctx.run_logic(&minimized, |ctx| app.logic(ctx)); + + assert!( + app.shots.reserved() <= SCREENSHOT_MAX_AGGREGATE_BYTES, + "size-less retries reserved {} bytes, over the {SCREENSHOT_MAX_AGGREGATE_BYTES}-byte aggregate limit", + app.shots.reserved() + ); + let rejected: Vec = receivers.iter_mut().filter_map(|rx| rx.try_recv().ok()).collect(); + assert_eq!(rejected.len(), 2, "two unknown-size retries no longer fit the aggregate budget"); + assert!(rejected.iter().all(|reply| reply["error"].as_str().is_some_and(|e| e.contains("aggregate limit"))), "{rejected:?}"); + assert_eq!(app.shots.requests.len(), 2, "the two fitting retries stay queued"); + assert!(app.shots.requests.iter().all(|r| !r.issued && r.expected.is_none() && r.bytes == UNKNOWN_CAPTURE_BYTES)); + assert!(receivers.iter_mut().all(|rx| rx.try_recv().is_err()), "the surviving retries wait for the restore"); + + // Restoring re-issues only the surviving requests at the real size; both fit because the + // over-reserved retries were rejected rather than kept. + let mut restored = raw_of(egui::vec2(64.0, 48.0)); + if let Some(viewport) = restored.viewports.get_mut(&egui::ViewportId::ROOT) { + viewport.inner_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(64.0, 48.0))); + viewport.minimized = Some(false); + viewport.occluded = Some(false); + } + app.raw_input_hook(&ctx, &mut restored); + let callbacks = issue_with(&ctx, &mut app, restored.clone()); + assert_eq!(callbacks.len(), 2, "the surviving requests issue on restore"); + for callback in &callbacks { + callback.clone().complete(image.clone()); + } + let _ = ctx.run_logic(&restored, |ctx| app.logic(ctx)); + + let mut delivered = 0; + for rx in &mut receivers { + if let Ok(reply) = rx.try_recv() { + assert_eq!(reply["ok"], true, "{reply}"); + delivered += 1; + } + } + assert_eq!(delivered, 2, "the two surviving captures reply once each"); + let written = paths.iter().filter(|path| path.exists()).count(); + assert_eq!(written, 2, "only the surviving captures write their image"); + for path in &paths { + let _ = std::fs::remove_file(path); + } + assert_eq!(app.shots.reserved(), 0); + } + + #[test] + /// Fresh hidden viewport geometry invalidates the old image, spends one retry, and the reissued image succeeds. + 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] + /// Draining four completed captures replies to all of them and permits a new admission. + 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] + /// Uses `screen_rect` when `inner_rect` is absent, applies native scale times zoom, and advances the epoch on a hidden resize. + 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] + /// Minimized geometry has no size and reserves unknown bytes; restoring at the same size still changes the epoch. + 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] + /// Switching documents before issuance cancels the request once with a document-changed error and writes nothing. + 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] + /// Switching documents after issuance rejects the completed image once and writes nothing. + 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] + /// A request bound to another viewport is rejected once with a viewport error and writes nothing. + 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] + /// Accepts one-pixel rounding on a single axis, but rejects opposite-axis deviation, aspect distortion and more than one pixel of error. + 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] + /// Checks the encoding working-set threshold, the base64 reply cap, and the retained-plus-encoding peak budget. + 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] + /// An oversized base64 capture is refused with nothing reserved, while the same capture as a path is admitted. + 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] + /// Oversized text, repeated maximum texts, keys and moves are refused without changing the queue, and the byte/event limits are both enforced. + 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..d88fa6a75 --- /dev/null +++ b/crates/ui-egui/src/craft_fonts_policy.rs @@ -0,0 +1,267 @@ +//! 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 the `CRAFT_FONTS_REQUIRED` value enables required mode. Only exactly `1` does: +/// `0`, an empty value and other words are not accepted as an implicit yes. +pub fn required_mode(value: Option<&str>) -> bool { + value == Some("1") +} + +/// 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] + /// Asserts the exact web allowlist and that every entry is unbarred, required and not a CJK collection face. + 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] + /// Adobe/Source and Noto CJK families are barred, while the permitted Noto Symbols, BIZ and Nanum faces are not. + 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] + /// Only the exact string `"1"` enables required mode; absent and all other values leave it off. + fn only_the_value_one_enables_required_mode() { + assert!(!required_mode(None)); + for value in ["", "0", "00", "01", "false", "true", "yes"] { + assert!(!required_mode(Some(value)), "{value:?} must not enable required mode"); + } + assert!(required_mode(Some("1"))); + } + + #[test] + /// A complete eight-field row yields the expected family, style and script list. + 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] + /// Missing fields, an empty file field, a bad hash and a non-ISO script tag are all rejected. + 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] + /// One row cannot satisfy every required face; script order and exact tags are checked, and a required family in an unlisted style is rejected. + 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] + /// LXGW is permitted but native-only; its script order and style are checked, and a required native build must include it. + 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] + /// Native embedding excludes the barred CJK face; web embedding keeps only allowlisted faces. + 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 8cf491b60..46b9b0b2e 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1251,6 +1251,7 @@ pub(crate) fn build_params(d: &Dialog) -> Json { v } +/// Takes and draws the active dialog, dispatches its body by name, consumes picture-inbox results, runs OK/confirm commands, handles Esc/close and restores the back-dialog stack. pub fn show(app: &mut SheetApp, ctx: &egui::Context) { let Some(mut d) = app.dialog.take() else { return }; if d.name == "pasteSpecial" { @@ -1271,7 +1272,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) @@ -1775,7 +1776,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) @@ -1783,9 +1784,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() { @@ -1799,9 +1800,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")); @@ -1809,11 +1811,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)); @@ -1822,17 +1824,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() { @@ -1924,6 +1931,7 @@ fn macros(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) { }); } +/// Draws the six-tab Format Cells editor (number, alignment, font, border, fill, protection); the Number tab shows a sample preview, and the style draft updates. fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { // `d.tab` stays the English name (it is what `open_dialog` and tests pass); only the drawn // labels are translated. The Fill tab is keyed by context: the Alignment tab's "Fill" is a @@ -1963,8 +1971,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", @@ -2100,8 +2108,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; } @@ -2218,6 +2226,7 @@ fn function_category_label(id: &str) -> &str { } } +/// Draws the Find/Replace form with case, whole-cell and workbook options, requests field focus once, and runs find/replace with the current parameters. fn find(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.horizontal(|ui| { for tab in ["Find", "Replace"] { @@ -2229,8 +2238,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(); @@ -2239,8 +2248,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); @@ -2363,7 +2372,9 @@ fn go_to_entries(app: &SheetApp) -> Vec { .unwrap_or_default() } +/// Draws a virtualized name/reference list and editable reference field, links to Go To Special, and confirms with `edit.goTo`. fn go_to(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) { + let lang = app.ui.language; let entries: Vec = d.result.as_ref().and_then(Json::as_array).map(|a| a.iter().filter_map(Json::as_str).map(str::to_owned).collect()).unwrap_or_default(); let current = d.values.get("reference").and_then(Json::as_str).unwrap_or("").to_string(); @@ -2382,8 +2393,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() { @@ -2401,7 +2412,9 @@ fn go_to(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bo } } +/// Draws the sort-level editor: header toggle, column/by/order controls, add/remove level, and `data.sort` on confirmation. 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)); @@ -2432,16 +2445,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(); @@ -2455,13 +2472,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); } }); @@ -2746,12 +2766,13 @@ fn recommended_charts(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, con } } +/// Draws chart range, series keep/remove and category-range controls and runs `chart.selectData` when something changed. 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)); } }; @@ -3094,16 +3115,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| { @@ -3113,10 +3135,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 @@ -3135,17 +3157,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"); @@ -3178,13 +3205,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 5caa0869c..7e30a5bdb 100644 --- a/crates/ui-egui/src/floating_pictures.rs +++ b/crates/ui-egui/src/floating_pictures.rs @@ -142,10 +142,11 @@ fn decode(data: &[u8], side: u32) -> Option<(egui::ColorImage, bool)> { Some((egui::ColorImage::from_rgba_unmultiplied([rgba.width() as usize, rgba.height() as usize], rgba.as_raw()), complete)) } +/// Returns the context-local shared cache of floating-picture textures. 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 49e0f89ce..81a668700 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -47,8 +47,9 @@ impl Default for BarLayout { } impl BarLayout { + /// Returns the stable widget id used for the formula-bar layout state. pub fn id() -> egui::Id { - egui::Id::new("formula_bar.layout") + egui::Id::unique("formula_bar.layout") } /// Last frame's layout. @@ -64,6 +65,7 @@ pub struct EditorInfo { pub caret: Option, } +/// Draws the formula bar: line-count-driven height, Name Box, cancel/enter/Fx buttons, the editable field, muted read-only spill text and the expansion chevron. pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let font = theme::ui_font(13.0); @@ -73,7 +75,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { // at MAX_LINES. It eases only when that target changes. let lines = if app.ui.formula_bar_expanded { MAX_LINES } else { last.lines.clamp(1, MAX_LINES) }; let target = one_line(row_h) + (lines - 1) as f32 * row_h; - let h = ui.ctx().animate_value_with_time(egui::Id::new("formula_bar.height"), target, EASE_SECONDS); + let h = ui.ctx().animate_value_with_time(egui::Id::unique("formula_bar.height"), target, EASE_SECONDS); let mut layout = BarLayout { height: h, ..Default::default() }; egui::Panel::top("formula_bar") .exact_size(h) @@ -106,7 +108,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { ui.painter().rect_stroke(field, 4.0, Stroke::new(1.0, t.input_border), StrokeKind::Inside); let inner = field.shrink2(vec2(6.0, 3.0)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner)); - let id = egui::Id::new("gridcraft.formula_bar"); + let id = egui::Id::unique("gridcraft.formula_bar"); // A spill child shows its anchor's formula muted and read-only. let read_only = active_spill_anchor(app).is_some(); let info = if app.editor.is_some() { @@ -185,15 +187,21 @@ fn active_input(app: &SheetApp) -> String { String::new() } +/// Draws the Name Box and its name dropdown; Enter jumps to a reference or known name (or defines a new name) and is consumed so the grid does not move down. fn name_box(app: &mut SheetApp, ui: &mut egui::Ui, t: &Tokens) -> Rect { - 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()); } @@ -284,7 +292,7 @@ fn show_edit<'t>( /// `max_height` of text and scrolls beyond that, keeping the caret in view. pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: FontId, multiline: bool, width: f32, max_height: f32) -> EditorInfo { let Some(mut ed) = app.editor.take() else { return EditorInfo::default() }; - 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())); @@ -412,7 +420,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 t = Tokens::get(ui.ctx()); - let on_sheet = id == egui::Id::new("gridcraft.cell_editor"); + let on_sheet = id == egui::Id::unique("gridcraft.cell_editor"); let cell_text_color = if on_sheet { t.cell_text } else { t.text }; let font2 = font.clone(); let mut layouter = move |ui: &egui::Ui, buf: &dyn egui::TextBuffer, wrap_width: f32| { @@ -467,7 +475,9 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: }) .inner }; - let resp = output.response.clone(); + // Both editors are text inputs without a visible label next to them: name the one being shown. + let accessible_name = (if on_sheet { app.ui.language.tr("Cell Editor") } else { app.ui.language.tr("Formula Bar") }).to_string(); + let resp = output.response.response.clone().accessible_name(accessible_name); if synced { if ed.request_focus { resp.request_focus(); @@ -486,7 +496,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; } @@ -540,8 +550,11 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: ui.painter().rect_filled(r, 0.0, t.accent); ui.painter().galley(pos, g, t.on_accent); } - // 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. @@ -768,6 +781,7 @@ mod spill_tests { check(&mut app, "A2", "=FILTER(D1:D3,D1:D3>0)", true); } #[test] + /// A disabled spill formula paints disabled and does not edit; clicking an active formula starts formula-bar editing in both light and dark themes, with accessibility checks. fn formula_bar_click_and_theme_presentation() { use egui_kittest::kittest::Queryable as _; for dark in [false, true] { @@ -788,6 +802,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)))); @@ -798,6 +815,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 b16a88803..d13b6e4df 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -937,7 +937,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() { @@ -990,7 +990,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() { @@ -2941,7 +2941,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)]; @@ -3030,7 +3030,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(); @@ -3075,6 +3075,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); @@ -3183,6 +3191,7 @@ fn editor_width(text_w: f32, cell_w: f32, size: f32) -> f32 { if text_w + 14.0 <= cell_w { cell_w } else { text_w + editor_slack(size) } } +/// Draws the in-cell editor, growing and wrapping with its text, mirroring for right-to-left sheets, choosing contrasting caret ink and clipping cells hidden under frozen panes. fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet, wb: &Workbook) { let Some(ed) = app.editor.as_ref() else { return }; if ed.sheet != wb.active_sheet { @@ -3225,18 +3234,29 @@ 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; + // `erect` and `ui.clip_rect()` are in screen coordinates; `quadrant` is logical until mirrored. + let screen_quadrant = geo.mirror_rect(quadrant); + let on_screen = cell_visible && erect.intersect(screen_quadrant).is_positive() && !geo.hidden_under_frozen(sh, cell); + let clip = if on_screen { ui.clip_rect().intersect(screen_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 // theme, on the white paper): pick black or white against the fill, like Excel. child.visuals_mut().text_cursor.stroke.color = ink_on(fill); crate::formula_bar::editor_widget(app, &mut child, id, font, wrap, erect.width() - 4.0, erect.height() - 2.0); - ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("gridcraft.cell_editor.rect"), erect)); + ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique("gridcraft.cell_editor.rect"), erect)); } /// Test the popup itself, not whether the pointer is over any part of the app. @@ -3256,6 +3276,7 @@ fn filter_table_range(sh: &Sheet, col: u32, row: u32) -> Option { sh.tables.iter().find(|t| t.range.start.row == row && col >= t.range.start.col && col <= t.range.end.col).map(|t| t.range) } +/// Draws the filter-header popup: sort, search/select-all, apply/clear, a lazily installed table autofilter, new-open search focus and dismissal. fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) { let Some((col, row, at)) = app.grid.filter_menu else { return }; let Some(d) = app.session.active() else { return }; @@ -3284,16 +3305,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); @@ -3373,10 +3394,11 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) } } +/// Draws and dispatches the cell context menu, including Show Details when offered, clipboard and insert/format actions; closes on Esc or an outside press. 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); @@ -3545,8 +3567,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 }); @@ -3593,7 +3615,7 @@ fn fill_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { let mut close = false; let mut items = fill_modes(app, src); items.extend([("-", ""), ("Series…", "ui:series")]); - 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 lang = crate::i18n::current(ui.ctx()); @@ -3624,7 +3646,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")]; @@ -3681,8 +3703,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 }); @@ -3747,6 +3769,7 @@ pub fn validation_arrow(sh: &Sheet, geo: &Geo, c: CellRef) -> Option { } #[allow(clippy::too_many_arguments)] +/// Paints the validation arrow and input message, notes, the hyperlink tooltip and trace precedent/dependent arrows. pub(crate) fn paint_overlays( app: &mut SheetApp, ui: &egui::Ui, @@ -3776,15 +3799,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; @@ -3801,7 +3827,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| { @@ -3821,7 +3847,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); }); } @@ -3868,6 +3894,7 @@ fn list_picker_height(n: usize, row_h: f32, gap: f32) -> f32 { rows * row_h + (rows - 1.0) * gap } +/// Draws the validation list popup, choosing above/below and left/right placement for RTL, and applies the clicked item with `cell.set`. fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) { let Some(c) = app.grid.list_picker else { return }; let Some(d) = app.session.active() else { return }; @@ -3892,7 +3919,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| { @@ -3913,10 +3940,11 @@ fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) } } +/// Draws row/column header context menus with insert/delete, format, size, hide/unhide and group actions, plus Insert Copied Cells when available. 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 1c1e8580a..e24dad5e1 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -1,5 +1,6 @@ //! Czech translations for the interface strings in i18n.rs. +/// Returns the Czech translation for `text`, or `None` so callers fall back to English. pub(super) fn translate(text: &str) -> Option<&'static str> { Some(match text { "Paste" => "Vložit", @@ -187,6 +188,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", @@ -420,6 +424,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 0c311ac1f..218eceb43 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -1,6 +1,7 @@ //! German translations for the interface strings in i18n.rs. Labels follow the wording German //! spreadsheet users know; anything absent falls back to English. +/// Returns the German translation for `text`, or `None` so callers fall back to English. pub(super) fn translate(text: &str) -> Option<&'static str> { Some(match text { "Paste" => "Einfügen", @@ -191,6 +192,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", @@ -428,6 +432,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", @@ -456,6 +461,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 ae8320c59..d826b1eb8 100644 --- a/crates/ui-egui/src/i18n/el.rs +++ b/crates/ui-egui/src/i18n/el.rs @@ -2,6 +2,7 @@ //! Command ids, cell references and formula identifiers are not translated. //! Source labels are kept verbatim and unmatched labels fall back to English. +/// Returns the Greek translation for `text`, or `None` so callers fall back to English. pub(super) fn translate(text: &str) -> Option<&'static str> { Some(match text { "Paste" => "Επικόλληση", @@ -185,6 +186,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" => "Μεγέθυνση", @@ -396,6 +400,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 c6d86bf05..ab1fa1276 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -2,6 +2,7 @@ //! wording (sentence case, «Inmovilizar paneles», «Combinar y centrar»); anything absent falls back //! to English. Formula names and separators are localized separately (`formula_locale`). +/// Returns the Spanish translation for `text`, or `None` so callers fall back to English. pub(super) fn translate(text: &str) -> Option<&'static str> { Some(match text { "Paste" => "Pegar", @@ -190,6 +191,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", @@ -425,6 +429,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:", @@ -472,6 +477,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 5161e779d..34511876a 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -1,5 +1,6 @@ //! French translations for the interface strings in i18n.rs. +/// Returns the French translation for `text`, or `None` so callers fall back to English. pub(super) fn translate(text: &str) -> Option<&'static str> { Some(match text { "Different First Page" => "Première page différente", @@ -213,6 +214,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", @@ -311,6 +315,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", @@ -444,6 +449,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 c881ef1e4..7f79b4076 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -2,6 +2,7 @@ //! wording (sentence case, «Blocca riquadri», «Unisci e allinea al centro»); anything absent falls //! back to English. +/// Returns the Italian translation for `text`, or `None` so callers fall back to English. pub(super) fn translate(text: &str) -> Option<&'static str> { Some(match text { "Paste" => "Incolla", @@ -205,6 +206,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", @@ -302,6 +306,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 3f4fa3f4e..ea48f8074 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -210,6 +210,9 @@ pub(super) const CORE: &[(&str, &str)] = &[ ("Page Break\nPreview", "改ページ プレビュー"), ("Page\nLayout", "ページ レイアウト"), ("Formula Bar", "数式バー"), + ("Name Box", "名前ボックス"), + ("Cell Editor", "セル エディター"), + ("Font Size", "フォント サイズ"), ("Gridlines", "目盛線"), ("Headings", "見出し"), ("Zoom", "ズーム"), @@ -307,6 +310,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 3d4a6e8bb..936e7417c 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -1,5 +1,6 @@ //! Korean translations for the interface strings in i18n.rs. +/// Returns the Korean translation for `text`, or `None` so callers fall back to English. pub(super) fn translate(text: &str) -> Option<&'static str> { Some(match text { "Paste" => "붙여넣기", @@ -208,6 +209,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" => "확대/축소", @@ -307,6 +311,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" => "머리글 추가", @@ -440,6 +445,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 13c717828..9cedcce74 100644 --- a/crates/ui-egui/src/i18n/nl.rs +++ b/crates/ui-egui/src/i18n/nl.rs @@ -1,5 +1,6 @@ //! Dutch translations for the interface strings in i18n.rs. +/// Returns the Dutch translation for `text`, or `None` so callers fall back to English. pub(super) fn translate(text: &str) -> Option<&'static str> { Some(match text { "Paste" => "Plakken", @@ -206,6 +207,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", @@ -302,6 +306,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 c63206800..1a229f90d 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -1,3 +1,4 @@ +/// Returns the Polish translation for `text`, or `None` so callers fall back to English. pub(super) fn translate(text: &str) -> Option<&'static str> { Some(match text { "Paste" => "Wklej", @@ -201,6 +202,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", @@ -298,6 +302,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 697caf3f4..d72ee2460 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -1,6 +1,7 @@ //! Brazilian Portuguese translations for the interface strings in i18n.rs. Labels follow Excel's //! pt-BR wording; anything absent falls back to English. +/// Returns the Portuguese translation for `text`, or `None` so callers fall back to English. pub(super) fn translate(text: &str) -> Option<&'static str> { Some(match text { "Paste" => "Colar", @@ -205,6 +206,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", @@ -303,6 +307,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 f9e732cf8..e71576848 100644 --- a/crates/ui-egui/src/i18n/ro.rs +++ b/crates/ui-egui/src/i18n/ro.rs @@ -5,6 +5,7 @@ //! usual in Romanian software ("Salvare", "Inserare…") and messages address the user formally. //! An AI-assisted draft (Claude) for a Romanian-speaking contributor, pending native-speaker review. +/// Returns the Romanian translation for `text`, or `None` so callers fall back to English. pub(super) fn translate(text: &str) -> Option<&'static str> { Some(match text { "Paste" => "Lipire", @@ -208,6 +209,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", @@ -305,6 +309,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 b1b7c6305..e3ae28581 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -1,5 +1,6 @@ //! Russian translations for the interface strings in i18n.rs. +/// Returns the Russian translation for `text`, or `None` so callers fall back to English. pub(super) fn translate(text: &str) -> Option<&'static str> { Some(match text { "Paste" => "Вставить", @@ -208,6 +209,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" => "Масштаб", @@ -307,6 +311,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" => "Добавить верхний колонтитул", @@ -442,6 +447,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 3c341b11e..db75b63c2 100644 --- a/crates/ui-egui/src/i18n/sr.rs +++ b/crates/ui-egui/src/i18n/sr.rs @@ -1,5 +1,6 @@ //! Serbian (Latin script, ekavian) translations for the interface strings in i18n.rs. +/// Returns the Serbian translation for `text`, or `None` so callers fall back to English. pub(super) fn translate(text: &str) -> Option<&'static str> { Some(match text { "Paste" => "Nalepi", @@ -203,6 +204,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", @@ -301,6 +305,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", @@ -440,6 +445,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 042ccdb70..ea1a16380 100644 --- a/crates/ui-egui/src/i18n/tr.rs +++ b/crates/ui-egui/src/i18n/tr.rs @@ -1,6 +1,7 @@ //! Turkish translations for the interface strings in i18n.rs. Labels follow the wording Turkish //! spreadsheet users know; anything absent falls back to English. +/// Returns the Turkish translation for `text`, or `None` so callers fall back to English. pub(super) fn translate(text: &str) -> Option<&'static str> { Some(match text { "Paste" => "Yapıştır", @@ -188,6 +189,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", @@ -410,6 +414,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 466795365..8055f77fc 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -1,5 +1,6 @@ //! Ukrainian translations for the interface strings in i18n.rs. +/// Returns the Ukrainian translation for `text`, or `None` so callers fall back to English. pub(super) fn translate(text: &str) -> Option<&'static str> { Some(match text { "Paste" => "Вставити", @@ -203,6 +204,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" => "Масштаб", @@ -300,6 +304,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" => "Додати верхній колонтитул", @@ -421,7 +426,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 920e75732..874c7caff 100644 --- a/crates/ui-egui/src/i18n/vi.rs +++ b/crates/ui-egui/src/i18n/vi.rs @@ -1,6 +1,7 @@ //! Vietnamese translations for the interface strings in i18n.rs. Labels follow the wording //! Vietnamese spreadsheet users know; anything absent falls back to English. +/// Returns the Vietnamese translation for `text`, or `None` so callers fall back to English. pub(super) fn translate(text: &str) -> Option<&'static str> { Some(match text { "Paste" => "Dán", @@ -209,6 +210,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", @@ -305,6 +309,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", @@ -412,6 +417,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 1ce4de868..9ce01e39d 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -1,5 +1,6 @@ //! Simplified Chinese translations for the interface strings in i18n.rs. +/// Returns the Chinese translation for `text`, or `None` so callers fall back to English. pub(super) fn translate(text: &str) -> Option<&'static str> { Some(match text { "Paste" => "粘贴", @@ -208,6 +209,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" => "缩放", @@ -305,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" => "添加页眉", @@ -432,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/icons.rs b/crates/ui-egui/src/icons.rs index 7667ddd56..385ed076b 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -211,6 +211,17 @@ impl Palette { } } +/// 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`, with the current theme's palette. pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) { paint_with(p, rect, icon, c, &Palette::of(&Tokens::get(p.ctx()))); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index eb9aa2e22..712cce16a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -12,6 +12,7 @@ mod cell_pictures; pub mod chartview; pub mod code_editor; pub mod control; +mod craft_fonts_policy; pub mod credits; pub mod dialogs; pub mod editor; @@ -261,13 +262,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 @@ -288,6 +299,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 @@ -308,6 +329,7 @@ pub struct SheetApp { } impl SheetApp { + /// Constructs a `SheetApp` with default UI, grid, printing, control and screenshot state and the caller's session and services. pub fn new(session: Session, services: Services) -> SheetApp { SheetApp { session, @@ -325,6 +347,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(), @@ -335,6 +359,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, @@ -345,6 +370,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, @@ -369,6 +397,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 { @@ -1100,6 +1143,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 { @@ -1160,55 +1205,117 @@ impl SheetApp { /// Per-frame logic (control channel, screenshots). Call before `ui`. pub fn logic(&mut self, ctx: &egui::Context) { - if self.session.text_width.is_none() { - let ctx = ctx.clone(); - self.session.text_width = Some(std::sync::Arc::new(move |text, font, exact| theme::cell_text_width(&ctx, text, font, exact))); - } + self.ensure_text_width(ctx); // A keytip control request lives one frame: the ribbon consumes it during `ui`, and // anything that never reached a control (collapsed ribbon, tab switch) is dropped. self.ui.keytip_invoke = None; // 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)) { + self.apply_font_state(ctx); + if let Some(answer) = self.desktop_theme.take_change() { + self.desktop_dark = Some(answer); + } + self.sync_theme_preference(ctx); + self.sync_input_options(ctx); + // 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). + self.poll_async_opens(); + self.autosave(ctx); + self.update_title(ctx); + self.request_hidden_paint(ctx); + if self.session.active().is_none() { + self.session.new_workbook(); + } + } + + /// Installs the text-width closure once, on the first frame. + fn ensure_text_width(&mut self, ctx: &egui::Context) { + if self.session.text_width.is_some() { + return; + } + let ctx = ctx.clone(); + self.session.text_width = Some(std::sync::Arc::new(move |text, font, exact| theme::cell_text_width(&ctx, text, font, exact))); + } + + /// Applies the font gates: a language Han-order change, startup/prepared fonts and workbook + /// font binding. Every branch keeps its one-frame paint gate. + fn apply_font_state(&mut self, ctx: &egui::Context) { + // 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. - if self.fonts_set { - self.fonts_ready = true; - } 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(); - self.fonts_seen = (0, 0); - self.fonts_han = Some(theme::HanOrder::of(self.ui.language)); - ctx.request_repaint(); - } + self.start_font_setup(ctx); } else if self.fonts_rebind { // A font-set change made last frame is active now. self.fonts_rebind = false; - } else if 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. - let seen = self.session.active().map(|d| (d.uid, d.revision)).unwrap_or((0, 0)); - if seen != self.fonts_seen { - let used = self.used_fonts(); - self.fonts_seen = seen; - if used != self.fonts_bound { - ctx.set_fonts(theme::font_definitions_with(self.ui.language, &used)); - self.fonts_bound = used; - self.fonts_rebind = true; - ctx.request_repaint(); - } - } + } else if !defer_font_change && !self.prepared_fonts && fonts::scan_done() { + self.rebind_workbook_fonts(ctx); } - if let Some(answer) = self.desktop_theme.take_change() { - self.desktop_dark = Some(answer); + } + + /// Applies the pending font set. 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. + fn start_font_setup(&mut self, ctx: &egui::Context) { + if self.fonts_set { + self.fonts_ready = true; + return; } + 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(); + self.fonts_seen = (0, 0); + self.fonts_han = Some(theme::HanOrder::of(self.ui.language)); + ctx.request_repaint(); + } + + /// Binds 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. + fn rebind_workbook_fonts(&mut self, ctx: &egui::Context) { + let seen = self.session.active().map(|d| (d.uid, d.revision)).unwrap_or((0, 0)); + if seen == self.fonts_seen { + return; + } + let used = self.used_fonts(); + self.fonts_seen = seen; + if used == self.fonts_bound { + return; + } + ctx.set_fonts(theme::font_definitions_with(self.ui.language, &used)); + self.fonts_bound = used; + self.fonts_rebind = true; + ctx.request_repaint(); + } + + /// Follows the desktop/system theme preference and records the effective theme. + fn sync_theme_preference(&mut self, ctx: &egui::Context) { let preference = match ( self.ui.system_theme, ctx.system_theme().or_else(|| self.desktop_dark.map(|d| if d { egui::Theme::Dark } else { egui::Theme::Light })), @@ -1222,6 +1329,10 @@ impl SheetApp { ctx.request_repaint(); } self.effective_dark = ctx.theme() == egui::Theme::Dark; + } + + /// Keeps egui's own keyboard zoom and quit shortcuts off and applies GridCraft's zoom factor. + fn sync_input_options(&mut self, ctx: &egui::Context) { // Interface size: the preference owns the zoom factor, so egui's own Ctrl/Cmd +/-/0 zoom // keys stay off (they would also swallow GridCraft's shortcuts). if ctx.options(|o| o.zoom_with_keyboard) { @@ -1238,9 +1349,10 @@ impl SheetApp { ctx.set_zoom_factor(zoom); ctx.request_repaint(); } - control::poll(self, ctx); - self.guard_window_close(ctx); - // Files read asynchronously (web file picker, dropped files). + } + + /// Opens files that arrived asynchronously (web file picker, dropped files). + fn poll_async_opens(&mut self) { let arrived: Vec<(String, Vec)> = self .services .inbox @@ -1263,14 +1375,84 @@ impl SheetApp { } self.after_engine(); } - if self.session.active().is_none() { - self.session.new_workbook(); + } + + /// 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. + fn autosave(&mut self, ctx: &egui::Context) { + let dirty_xlsx = self.session.active().is_some_and(|d| d.is_dirty() && d.path.as_deref().is_some_and(|p| p.ends_with(".xlsx"))); + 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 && dirty_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 && dirty_xlsx { + // Keep checking even when no input arrives while the window is hidden. + ctx.request_repaint_after(std::time::Duration::from_secs(2)); } } - 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); + /// 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. + fn update_title(&mut self, ctx: &egui::Context) { + let Some(d) = self.session.active() else { return }; + 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. + fn request_hidden_paint(&self, ctx: &egui::Context) { + if !(self.shots.needs_hidden_paint() || self.synthetic_pending || !self.synthetic.is_empty()) { + return; + } + // 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(); + } + + /// Records capture geometry from the fresh raw frame, then offers exactly one pending synthetic batch and tracks whether it was applied. + 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()); } } @@ -1283,7 +1465,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; @@ -1329,7 +1510,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. } @@ -1342,24 +1522,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; } } @@ -1460,10 +1630,268 @@ 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")] +/// Web monotonic clock: the browser's `performance.now()`, or 0 when it is unavailable. +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::*; + + /// Builds a key-A press or release event without modifiers. + fn key_event(pressed: bool) -> egui::Event { + egui::Event::Key { key: egui::Key::A, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::NONE } + } + + /// Returns a test app with a new workbook and default services. + 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] + /// Only one synthetic step is offered, is not duplicated by a hidden logic-only tick, and press/release remain distinct batches. + 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"); + } + + /// Returns raw input with a screen rect at the given size. + 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] + /// A font-gated early-returning pass does not lose the queued step; the hook offers it again and a later real pass applies it. + 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] + /// A language-driven font rebuild waits while a synthetic step is pending and proceeds once it is clear. + 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] + /// Logic ticks see fresh viewport and DPI information; resizing back to the original size still advances the capture epoch. + fn a_hidden_logic_tick_sees_fresh_viewport_info_and_keeps_the_epoch_monotonic() { + /// Runs one logic tick with the requested width and native scale. + 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] + /// A resize and a resize back both advance the capture epoch. + fn the_capture_epoch_changes_even_when_the_geometry_returns() { + /// Runs one UI pass at the requested width. + 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"); + } + + /// Extracts title viewport commands from a logic output. + 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] + /// A hidden logic tick saves a dirty file-backed workbook once and clears its dirty state. + 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] + /// The title is sent on the first tick and after a rename, but not on every unchanged frame. + 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] + /// A hidden tick surfaces a failed print job in the app message. + 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] + /// Pending synthetic input makes logic request a real hidden paint. + 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 9d9d20482..65d169c87 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -50,6 +50,7 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { }); } +/// Lists comments and replies, links each to its cell, and runs delete/resolve/reopen/reply commands. fn comments(app: &mut SheetApp, ui: &mut Ui) { let Some(d) = app.session.active() else { return }; let Some(sh) = d.wb.active() else { return }; @@ -80,7 +81,7 @@ fn comments(app: &mut SheetApp, ui: &mut Ui) { for (a, txt) in &c.replies { ui.label(egui::RichText::new(format!("↳ {a}: {txt}")).color(t.text)); } - 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. @@ -106,6 +107,7 @@ fn comments(app: &mut SheetApp, ui: &mut Ui) { } } +/// Adds the active cell, lists watch values and formulas, and removes watched entries. fn watch(app: &mut SheetApp, ui: &mut Ui) { if ui.button("Add Watch (active cell)").clicked() && let Some(d) = app.session.active() @@ -126,7 +128,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(); @@ -134,6 +136,7 @@ fn watch(app: &mut SheetApp, ui: &mut Ui) { }); } +/// Lists the sheet's objects and dispatches select, bring-forward, send-backward and delete. fn selection(app: &mut SheetApp, ui: &mut Ui) { let list = app.session.run("arrange.selectionPane", json!({})).unwrap_or(Json::Null); let items = list.as_array().cloned().unwrap_or_default(); @@ -154,13 +157,14 @@ 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})); } }); } } +/// Draws the chart format pane: title, type, legend, elements/axis titles, series colors, palettes/layouts and size. fn format_chart(app: &mut SheetApp, ui: &mut Ui) { let Some(id) = app.selected_chart else { ui.label(egui::RichText::new("Select a chart to format it.").italics()); @@ -172,7 +176,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})); } @@ -217,14 +221,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) }})); } @@ -271,7 +275,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 d0005d76d..fa2313d67 100644 --- a/crates/ui-egui/src/paper_view.rs +++ b/crates/ui-egui/src/paper_view.rs @@ -438,6 +438,7 @@ pub fn go_to(app: &mut SheetApp, footer: bool) { begin(app, sheet, &sh, page, HfZone { footer, section }); } +/// Edits a header/footer section across left/center/right, cancels on Esc, switches sections and preserves focus/caret while the page scrolls. fn editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid) { let Some(mut e) = app.grid.hf_edit.take() else { return }; if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Escape)) { @@ -469,7 +470,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); } } @@ -478,9 +479,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) @@ -488,7 +489,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) @@ -776,8 +777,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)] @@ -787,7 +788,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..a6bffce4e 100644 --- a/crates/ui-egui/src/pivot_pane.rs +++ b/crates/ui-egui/src/pivot_pane.rs @@ -17,6 +17,7 @@ pub fn active_pivot(app: &SheetApp) -> Option { sh.pivots.iter().find(|p| p.last_range.is_some_and(|r| r.contains(a)) || p.anchor == a).map(|p| p.name.clone()) } +/// Draws the PivotTable Fields pane: add/remove fields, move them between areas, sort/group/value settings, refresh, calculated field and report layout. pub fn show(app: &mut SheetApp, ui: &mut Ui) { let Some(name) = active_pivot(app) else { return }; let t = Tokens::get(ui.ctx()); @@ -168,13 +169,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 1415b6831..8ac5ae778 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -35,13 +35,14 @@ const TITLE_BAR_H: f32 = if MAC { MAC_TITLE_BAR } else { 38.0 }; const TITLE_CONTROL_H: f32 = if MAC { 22.0 } else { 26.0 }; const _: () = assert!(TITLE_CONTROL_H < TITLE_BAR_H, "title-bar controls keep a margin above and below"); +/// Draws the title bar: drag/maximize, AutoSave toggle, Home/Save/Undo/Redo/More, search, share, comments and theme controls, with a centered truncated title. pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let ch = TITLE_CONTROL_H; 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); } @@ -80,7 +81,7 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { let can_redo = gridcraft_engine::cmd::can_redo(&app.session).is_ok(); // Named and marked enabled or not for assistive technology (and the UI tests). let undo = icon_button(ui, Icon::Undo, if can_undo { t.text_dim } else { t.text_disabled }, "Undo", vec2(26.0, ch)); - undo.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, can_undo, "Undo")); + undo.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, can_undo, "Undo")); if undo.clicked() && can_undo { app.run_or_alert("edit.undo", json!({})); } @@ -101,7 +102,7 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { } }); let redo = icon_button(ui, Icon::Redo, if can_redo { t.text_dim } else { t.text_disabled }, "Redo", vec2(26.0, ch)); - redo.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, can_redo, "Redo")); + redo.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, can_redo, "Redo")); if redo.clicked() && can_redo { app.run_or_alert("edit.redo", json!({})); } @@ -153,7 +154,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!({})); } @@ -210,6 +211,7 @@ fn paint_title(ui: &Ui, center: egui::Pos2, title: &str, dirty: bool, max_w: f32 } } +/// Draws the ribbon tabs and the active tab's horizontally scrollable card, resetting an unavailable contextual tab to Home. pub fn show(app: &mut SheetApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let collapsed = app.ui.ribbon_collapsed; @@ -292,30 +294,39 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { ui.scope_builder(egui::UiBuilder::new().max_rect(inner), |ui| { // One scroll state per tab: a tab opened after scrolling another starts at its // left edge, and a tab keeps its own offset while others are used. - let scroll = egui::ScrollArea::horizontal() - .id_salt(("ribbon_scroll", app.ui.ribbon_tab.as_str())) - .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing = vec2(2.0, 2.0); - match app.ui.ribbon_tab.as_str() { - "Home" => home(app, ui), - "Insert" => insert(app, ui), - "Draw" => draw(app, ui), - "Page Layout" => page_layout(app, ui), - "Formulas" => formulas(app, ui), - "Data" => data(app, ui), - "Review" => review(app, ui), - "View" => view(app, ui), - "Automate" => automate(app, ui), - "Table Design" => table_design(app, ui), - "Chart Design" => chart_design(app, ui), - crate::paper_view::TAB => crate::paper_view::ribbon_tab(app, ui), - _ => home(app, ui), - } - }); + let tab = app.ui.ribbon_tab.clone(); + let mut area = egui::ScrollArea::horizontal() + .id_salt(("ribbon_scroll", tab.as_str())) + .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden); + // egui 0.37 keeps `ScrollArea`'s offset private; `scroll_offset` is the supported + // way to move it. An arrow click records the requested offset for the next frame. + // Expire it if this tab stops rendering, rather than replaying it much later. + if let Some((frame, x)) = ui.ctx().data_mut(|d| d.remove_temp::<(u64, f32)>(RibbonOverflow::scroll_target_id(tab.as_str()))) + && ui.ctx().cumulative_frame_nr().saturating_sub(frame) <= 1 + { + area = area.horizontal_scroll_offset(x); + } + let scroll = area.show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing = vec2(2.0, 2.0); + match app.ui.ribbon_tab.as_str() { + "Home" => home(app, ui), + "Insert" => insert(app, ui), + "Draw" => draw(app, ui), + "Page Layout" => page_layout(app, ui), + "Formulas" => formulas(app, ui), + "Data" => data(app, ui), + "Review" => review(app, ui), + "View" => view(app, ui), + "Automate" => automate(app, ui), + "Table Design" => table_design(app, ui), + "Chart Design" => chart_design(app, ui), + crate::paper_view::TAB => crate::paper_view::ribbon_tab(app, ui), + _ => home(app, ui), + } }); - scroll_arrows(ui, &t, scroll); + }); + scroll_arrows(ui, &t, tab.as_str(), scroll); }); }); } @@ -336,8 +347,14 @@ pub struct RibbonOverflow { } impl RibbonOverflow { + /// Returns the stable id for the ribbon overflow state. fn id() -> egui::Id { - egui::Id::new("ribbon_overflow") + egui::Id::unique("ribbon_overflow") + } + + /// Where an arrow click asks the next frame's `ScrollArea` to scroll to. + fn scroll_target_id(tab: &str) -> egui::Id { + egui::Id::unique(("ribbon_scroll_target", tab)) } /// Last frame's overflow. @@ -351,15 +368,15 @@ impl RibbonOverflow { } } -/// When the tab's groups are wider than the card, arrows at its edges scroll them (the mouse wheel -/// over the card still does too). -fn scroll_arrows(ui: &mut Ui, t: &Tokens, mut scroll: egui::scroll_area::ScrollAreaOutput<()>) { +/// When the tab's groups are wider than the card, arrows at its edges scroll them (horizontal wheel or +/// trackpad input over the card still does too). +fn scroll_arrows(ui: &mut Ui, t: &Tokens, tab: &str, scroll: egui::scroll_area::ScrollAreaOutput<()>) { let inner = scroll.inner_rect; let max = (scroll.content_size.x - inner.width()).max(0.0); let overflow = RibbonOverflow { content: scroll.content_size.x, visible: inner.width(), - offset: scroll.state.offset.x, + offset: scroll.state.clamped_offset().x, content_height: scroll.content_size.y, visible_height: inner.height(), }; @@ -378,19 +395,19 @@ fn scroll_arrows(ui: &mut Ui, t: &Tokens, mut scroll: egui::scroll_area::ScrollA } else { Rect::from_min_size(pos2(inner.right() - 18.0, inner.top()), vec2(18.0, inner.height())) }; - let resp = ui.interact(r, ui.id().with(("ribbon_scroll_arrow", left)), Sense::click()); + let resp = ui.interact(r, ui.scope_id().with(("ribbon_scroll_arrow", left)), Sense::click()); ui.painter().rect_filled(r, 4.0, if resp.hovered() { t.hover } else { t.ribbon }); ui.painter().rect_stroke(r, 4.0, Stroke::new(1.0, t.ribbon_border), StrokeKind::Inside); icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), if left { Icon::Left } else { Icon::Right }, t.text); let name = if left { "Scroll ribbon left" } else { "Scroll ribbon right" }; - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, name)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, name)); if resp.on_hover_text(crate::i18n::current(ui.ctx()).tr(name)).clicked() { target = Some(if left { overflow.offset - step } else { overflow.offset + step }); } } if let Some(x) = target { - scroll.state.offset.x = x.clamp(0.0, max); - scroll.state.store(ui.ctx(), scroll.id); + let frame = ui.ctx().cumulative_frame_nr(); + ui.ctx().data_mut(|d| d.insert_temp(RibbonOverflow::scroll_target_id(tab), (frame, x.clamp(0.0, max)))); ui.ctx().request_repaint(); } } @@ -489,7 +506,7 @@ fn circular_references(app: &SheetApp) -> Vec<(usize, String, String)> { 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); @@ -722,12 +739,13 @@ fn menu_items(app: &mut SheetApp, ui: &mut Ui, items: &[(&str, &str, serde_json: } } +/// Draws the Home tab: clipboard/paste menu, font, alignment, number, style, cell and editing commands with keytips. fn home(app: &mut SheetApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); 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"]) { @@ -790,7 +808,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})); @@ -855,7 +873,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"), @@ -1311,7 +1329,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 @@ -1330,6 +1348,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)); @@ -1359,7 +1378,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(); @@ -1627,6 +1646,7 @@ fn cell_style_gallery(app: &mut SheetApp, ui: &mut Ui) { } } +/// Draws the Insert tab: PivotTable, Table, Pictures, Shapes, Icons, charts, slicer, sparklines, checkbox, links, comments, text box, header/footer and symbols. fn insert(app: &mut SheetApp, ui: &mut Ui) { let __k = big_button(ui, Icon::PivotTable, "PivotTable", "PivotTable", false); control_keytip(app, ui, &__k, "insert.pivotTable"); @@ -1641,7 +1661,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() { @@ -1826,7 +1846,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})); } } @@ -2087,7 +2108,7 @@ fn formulas(app: &mut SheetApp, ui: &mut Ui) { egui::Popup::menu(&use_in).show(|ui| { // The menu lists the names Paste Names lists; clicking one runs `formulas.useInFormula`, which puts it at the caret. // Listed when the menu opens, and again only after the workbook changes. - let cache = egui::Id::new("gridcraft.use_in_formula_names"); + let cache = egui::Id::unique("gridcraft.use_in_formula_names"); let key = app.session.active().map(|d| (d.uid, d.revision, d.wb.active_sheet)); let cached = ui.ctx().data(|m| m.get_temp::<(Option<(u64, u64, usize)>, std::sync::Arc>)>(cache)).filter(|(k, _)| *k == key); let names = match cached { @@ -2138,7 +2159,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!({})); @@ -2423,6 +2444,7 @@ fn review(app: &mut SheetApp, ui: &mut Ui) { } } +/// Draws the View tab: view modes, ruler/gridlines/formula-bar/headings toggles, zoom, freeze panes, theme, animations, interface size and language. fn view(app: &mut SheetApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let _ = t; @@ -2510,20 +2532,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() { @@ -2533,7 +2560,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") @@ -2545,22 +2572,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); }); } @@ -2590,6 +2621,7 @@ fn automate(app: &mut SheetApp, ui: &mut Ui) { } } +/// Draws the Table Design tab: rename, resize, remove duplicates, convert to range, slicer, table options and the style strip. fn table_design(app: &mut SheetApp, ui: &mut Ui) { let Some((tname, header, totals, banded, banded_c, first, last, filter, style)) = app.session.active().and_then(|d| { let sh = d.wb.active()?; @@ -2600,7 +2632,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 65557ed15..b2216ca93 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -79,7 +79,9 @@ fn dragged_share(x: f32, bar: (f32, f32), right: f32) -> f32 { /// or less of the bar, as in Excel, for the workbook `uid`. fn tab_split_handle(app: &mut SheetApp, ui: &mut Ui, split: Rect, bar: (f32, f32), right: f32, uid: u64, t: &Tokens) { app.grid.tab_split = Some(split); - let resp = ui.interact(split, ui.id().with("tab_split"), Sense::drag()); + let resp = ui.interact(split, ui.scope_id().with("tab_split"), Sense::drag()); + let name = app.ui.language.tr("Split tabs"); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, name)); if resp.hovered() || resp.dragged() { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); } @@ -95,6 +97,7 @@ fn tab_split_handle(app: &mut SheetApp, ui: &mut Ui, split: Rect, bar: (f32, f32 } } +/// Draws the worksheet tab strip: navigation, scroll/reveal, rename/drag/move with optional copy, context menu, new-sheet button, split handle and grid scrollbar. pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); egui::Panel::bottom("sheet_tabs").exact_size(32.0).frame(egui::Frame::NONE.fill(t.tab_bar)).show(ui, |ui| { @@ -110,7 +113,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(); @@ -145,8 +148,8 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { }; // 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); @@ -206,8 +209,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(); @@ -315,6 +319,7 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { }); } +/// Draws status mode/messages, zoom controls/view-mode buttons and selection statistics. pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); egui::Panel::bottom("status_bar").exact_size(26.0).frame(egui::Frame::NONE.fill(t.status_bar).inner_margin(egui::Margin::symmetric(12, 0))).show( @@ -322,7 +327,7 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) { |ui| { // A right-click anywhere on the status bar opens the statistics menu, the text included, so its labels are not // selectable. The zoom and view controls sense clicks and handle their own. - let background = ui.interact(ui.max_rect(), egui::Id::new("gridcraft.status_bar"), Sense::click()); + let background = ui.interact(ui.max_rect(), egui::Id::unique("gridcraft.status_bar"), Sense::click()); background.context_menu(|ui| status_bar_menu(app, ui)); let text = |ui: &mut Ui, rich: egui::RichText| { ui.add(egui::Label::new(rich).selectable(false)); @@ -379,6 +384,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..c618c89f4 100644 --- a/crates/ui-egui/src/text_box.rs +++ b/crates/ui-egui/src/text_box.rs @@ -17,8 +17,9 @@ pub struct EditState { rect: Option, } +/// Returns the stable id for the text-box editor. pub(crate) fn editor_id() -> egui::Id { - egui::Id::new("gridcraft.text_box_editor") + egui::Id::unique("gridcraft.text_box_editor") } impl SheetApp { @@ -110,6 +111,7 @@ pub(crate) fn contains_pointer(app: &SheetApp, ctx: &egui::Context) -> bool { app.text_box_editor.as_ref().and_then(|ed| ed.rect).is_some_and(|r| ctx.input(|i| i.pointer.hover_pos().is_some_and(|p| r.contains(p)))) } +/// Draws the text-box editor with focus/caret state, save shortcuts, scrolling/clipping, and clears editing mode when the shape is gone. pub(crate) fn show(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &gridcraft_engine::model::Sheet) { let Some(mut ed) = app.text_box_editor.take() else { return }; if app.view_key() != Some((ed.uid, ed.sheet)) { @@ -147,20 +149,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 2de471c8f..478b8b86b 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -1,9 +1,13 @@ //! 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. //! //! Chrome colours belong in [`Tokens`], each with a light and a dark value; colours //! drawn on the sheet keep their light value in both themes, because the sheet stays white. The @@ -12,8 +16,27 @@ 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")); +} + /// The light or dark value of a colour, by mode. fn pick(dark: bool, light: T, dark_value: T) -> T { if dark { dark_value } else { light } @@ -329,43 +352,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); @@ -377,6 +397,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 @@ -538,63 +642,58 @@ 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) +} + +/// Builds the role font chains, script fallbacks, installed workbook fonts and optional craft-font faces, drops unparseable data and records which families were bound. +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) { - // 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 { - fd.tweak.hinting = Some(false); - } - let key = format!("sys-{role}"); - fonts.font_data.insert(key.clone(), Arc::new(fd)); - chain.push(key); - } else if role == UI_BOLD { - chain.push(format!("sys-{UI}")); - } else if role.starts_with("cell-") { - chain.push(format!("sys-{CELL}")); - } - chain.extend(if role == MONO { base_mono.clone() } else { base_prop.clone() }); - chain.extend(fallback.iter().cloned()); - chain.retain(|k| fonts.font_data.contains_key(k)); + let chain = build_role_chain(&mut fonts, role, &base_prop, &base_mono, &fallback, hack, system); 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 // fallback face was found (#349: macOS UI font without Hebrew, cells in Arial with it). - let cell_key = format!("sys-{CELL}"); - if fonts.font_data.contains_key(&cell_key) { - for role in [UI, UI_BOLD, SERIF, MONO] { - if let Some(chain) = fonts.families.get_mut(&FontFamily::Name(role.into())) - && !chain.contains(&cell_key) - { - let at = chain.iter().position(|k| fallback.contains(k)).unwrap_or(chain.len()); - chain.insert(at, cell_key.clone()); - } - } - } + bind_cell_fallback(&mut fonts, &fallback); // Default proportional text in widgets uses the UI font. 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. + append_craft_faces(&mut fonts); 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())); @@ -602,6 +701,84 @@ pub fn font_definitions_with(language: crate::i18n::Language, used: &[String]) - fonts } +/// Builds one role family's chain: the loaded system face (or its role fallback), egui's bundled +/// proportional/monospace base, Hack's symbols, then the script fallbacks. Keys without font data +/// are dropped, as before. +fn build_role_chain( + fonts: &mut FontDefinitions, + role: &str, + base_prop: &[String], + base_mono: &[String], + fallback: &[String], + hack: Option<&str>, + system: bool, +) -> Vec { + 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(); + 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 { + fd.tweak.hinting = Some(false); + } + let key = format!("sys-{role}"); + fonts.font_data.insert(key.clone(), Arc::new(fd)); + chain.push(key); + } else if role == UI_BOLD { + chain.push(format!("sys-{UI}")); + } else if role.starts_with("cell-") { + chain.push(format!("sys-{CELL}")); + } + chain.extend(if role == MONO { base_mono.to_vec() } else { base_prop.to_vec() }); + // 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)); + chain +} + +/// Inserts the loaded cell face before the script fallbacks of the interface families, so the +/// formula bar and dialogs can draw whatever a cell can (#349). +fn bind_cell_fallback(fonts: &mut FontDefinitions, fallback: &[String]) { + let cell_key = format!("sys-{CELL}"); + if !fonts.font_data.contains_key(&cell_key) { + return; + } + for role in [UI, UI_BOLD, SERIF, MONO] { + if let Some(chain) = fonts.families.get_mut(&FontFamily::Name(role.into())) + && !chain.contains(&cell_key) + { + let at = chain.iter().position(|k| fallback.contains(k)).unwrap_or(chain.len()); + chain.insert(at, cell_key.clone()); + } + } +} + +/// Appends the permitted craft-fonts faces to every family, preserving the leading system or +/// bundled face's glyph shapes. +fn append_craft_faces(fonts: &mut FontDefinitions) { + let craft = add_craft_fonts(fonts); + if craft.is_empty() { + return; + } + for chain in fonts.families.values_mut() { + for key in &craft { + if !chain.contains(key) { + chain.push(key.clone()); + } + } + } +} + /// Loads the system non-Latin fallback faces (one file per script family) and returns their keys so /// every family can carry them as fallback. egui's bundled fonts have no coverage beyond Latin, so /// without this a Thai, Hebrew, Japanese or Chinese workbook shows tofu boxes even though the text @@ -894,6 +1071,7 @@ pub fn symbol_face_installed() -> bool { } } +/// Installs light and dark token visuals in both theme slots, keeps the 2-3 px widget and 8-10 px window/menu corner radii from 0.36, and sets spacing, text styles and the active theme. pub fn apply(ctx: &egui::Context, dark: bool) { // Install both palettes before System can switch between the style slots. for theme in [egui::Theme::Light, egui::Theme::Dark] { @@ -912,6 +1090,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 }; @@ -1088,6 +1272,7 @@ mod tests { use crate::i18n::Language; #[test] + /// Loaded script-fallback faces end each role chain just before the craft fonts, in the language's order. fn fallback_faces_are_appended_to_every_family() { // Structural: whatever loaded, the non-Latin fallback keys must sit at the end of each chain // (after the Latin/default fonts) so Latin still wins for Latin text, in the language's order. @@ -1101,13 +1286,88 @@ 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] + /// Every font family ends with exactly the permitted craft faces in manifest order. + 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] + /// Skips on wasm or when required mode is off; a required native build must embed LXGW with `Hani`/`Hans`. + 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] + /// No embedded craft face belongs to the barred Noto CJK, Adobe or Source families. + 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] + /// Skips when craft fonts are absent unless required mode is set; otherwise the required faces and their exact script tags are embedded. + 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] + /// Interface text maps unbundled `⌃`, `✓` and `✕` to readable chrome fallbacks; document text is not rewritten. + 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, @@ -1172,6 +1432,7 @@ mod tests { } #[test] + /// A font file shared by several fallback scripts is loaded once, with one shared font blob rather than a copy per script. fn fallback_faces_load_each_file_once() { // The region-merged Noto Sans CJK .ttc serves Japanese, Chinese and Hangul; it must be loaded // once, not once per script (tens of MB each). @@ -1181,24 +1442,30 @@ 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"); } } } } #[test] + /// Checks that the proportional and cell families have glyphs for every present script sample; samples with no system or craft face are printed as skipped. 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 ŘŠČŽÝÁÍÉÚŮĚŤĎŇÓ"), @@ -1208,26 +1475,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:?})"); } } } @@ -1250,10 +1510,11 @@ mod tests { } #[test] + /// Empty, garbage, truncated and bad-index fonts are refused; a bad face is dropped from the definitions and `set_fonts` does not panic. 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)); @@ -1280,24 +1541,26 @@ mod tests { #[cfg(not(target_arch = "wasm32"))] #[test] + /// A broken existing font candidate is skipped in favor of a later readable candidate. fn try_load_skips_a_broken_candidate() { // A candidate that exists but is not a readable font must not win over a good one behind it. let dir = std::env::temp_dir().join(format!("gridcraft-font-test-{}", std::process::id())); 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); } #[cfg(not(target_arch = "wasm32"))] #[test] + /// Repeated loads reuse one shared font blob, while a face the kept file does not contain is still refused. fn interface_fonts_are_read_once_and_shared_with_egui() { // egui copies owned font bytes into its text engine and keeps the original too; borrowed // bytes it shares. A rebuild of the font set reuses the file read the first time. @@ -1307,11 +1570,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 295f0eafd..63490a36e 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -32,6 +32,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`). @@ -44,10 +47,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. + if text.chars().any(|c| theme::symbol_fallback(c).is_some()) { theme::available_text(ui, &text) } else { text } } /// A flat square icon button with hover highlight and tooltip. @@ -63,6 +68,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()); @@ -104,8 +123,8 @@ pub fn big_split_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str) -> (Res let split = rect.top() + 38.0; let main_rect = Rect::from_min_max(rect.min, pos2(rect.right(), split)); let arrow_rect = Rect::from_min_max(pos2(rect.left(), split), rect.max); - let main = ui.interact(main_rect, ui.id().with((label, "big-split-main")), Sense::click()); - let arrow = ui.interact(arrow_rect, ui.id().with((label, "big-split-arrow")), Sense::click()); + let main = ui.interact(main_rect, ui.scope_id().with((label, "big-split-main")), Sense::click()); + let arrow = ui.interact(arrow_rect, ui.scope_id().with((label, "big-split-arrow")), Sense::click()); if main.hovered() || arrow.hovered() { ui.painter().rect_filled(rect, 5.0, t.hover); ui.painter().line_segment([pos2(rect.left() + 4.0, split), pos2(rect.right() - 4.0, split)], Stroke::new(1.0, t.separator)); @@ -119,8 +138,8 @@ pub fn big_split_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str) -> (Res paint_big_label(ui, rect, &lines, true); let name = label.replace('\n', " "); let enabled = ui.is_enabled(); - main.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, &name)); - arrow.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, format!("{name} options"))); + main.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, &name)); + arrow.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, format!("{name} options"))); let tip = tip_text(ui, tip); (main.on_hover_text(tip.clone()), arrow.on_hover_text(tip)) } @@ -194,8 +213,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)); @@ -218,8 +237,12 @@ pub fn small_split_button(ui: &mut Ui, icon: Icon, label: &str, tip: &str) -> (R let (rect, _) = ui.allocate_exact_size(vec2(24.0 + tw + 14.0, 23.0), Sense::hover()); let main = Rect::from_min_size(rect.min, vec2(24.0 + tw, rect.height())); 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()); + let name = label.replace('\n', " "); + let enabled = ui.is_enabled(); + m.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, &name)); + a.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, format!("{name} options"))); if m.hovered() || a.hovered() { ui.painter().rect_filled(rect, 4.0, t.hover); for (part, r) in [(&m, main), (&a, arrow)] { @@ -284,8 +307,9 @@ pub fn remember_color(recent: &mut Vec, hex: &str) { recent.truncate(RECENT_COLORS); } +/// Returns the stable id for the recent-colors cache. 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. @@ -315,12 +339,21 @@ pub fn color_palette_fixed(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &s palette(ui, theme_colors, none_label, false) } +/// Shared body of [`color_palette`] and [`color_palette_fixed`]: a 7×10 palette (six tinted theme rows +/// above ten standard colours), then Recent Colors; the separator and More Colors… button appear only +/// when `more_colors` is true; an existing picker state renders while open. Arrow keys move a highlight +/// through all 70 swatches (rows clamp, columns +/// wrap), Enter picks it, and those key events are stripped so the grid behind does not react; while More +/// Colors… is open its HSVA/hex widgets take the keys instead. The None label returns `Some("none")`; +/// otherwise the result is the picked hex or `None`. Highlight, picker state and recent colours live in +/// egui temp storage, and More Colors… OK falls back to the picker colour for an invalid typed hex and +/// records the result as the newest recent colour (capped at `RECENT_COLORS`). fn palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str, more_colors: bool) -> Option { let t = Tokens::get(ui.ctx()); 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); @@ -401,7 +434,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); @@ -510,12 +543,13 @@ fn palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str, more_colors: picked } +/// Draws the modal warning/error message; OK, Enter or Esc clears it and restores editor focus when an edit exists. pub fn message_box(app: &mut SheetApp, ctx: &egui::Context) { let Some((title, msg)) = app.message.clone() else { return }; 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()); @@ -544,6 +578,7 @@ pub fn message_box(app: &mut SheetApp, ctx: &egui::Context) { } } +/// Shows a translated toast for 2.5 seconds and clears it after the deadline. pub fn toast(app: &mut SheetApp, ctx: &egui::Context) { let Some((msg, at)) = app.toast.clone() else { return }; if crate::now_ms() - at > 2500.0 { @@ -552,7 +587,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| { @@ -643,11 +678,33 @@ mod color_tests { } } +#[cfg(test)] +mod glyph_button_tests { + use super::*; + + #[test] + /// The `✓` gallery button paints vector paths instead of the missing character. + 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; #[test] + /// Windows/Linux tooltips use Ctrl/Alt shortcuts and macOS uses Command glyphs; a command without a shortcut reports none. fn tooltips_show_each_platforms_own_shortcut() { // #1191: Windows and Linux show Excel for Windows' shortcuts, never the Mac ⌘ ones. assert_eq!(tooltip_shortcut("Bold", false), Some("Ctrl+B")); @@ -659,7 +716,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..aa7774fc1 --- /dev/null +++ b/crates/ui-egui/tests/common/mod.rs @@ -0,0 +1,57 @@ +//! 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}; + +pub type Harness = egui_kittest::Harness<'static, 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 +} + +/// Drive `app`'s `logic` then `ui` each frame in a harness built with `builder`. +/// +/// Tests that need a non-standard size, step timing, OS or pixel ratio configure the builder +/// here so those details stay explicit; the shared font and lifecycle setup lives in `builder` +/// and `app`. +pub fn harness_with(builder: HarnessBuilder, app: SheetApp, steps: usize) -> Harness { + let mut h = builder.build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(steps); + h +} + +/// A 1200×800 harness that reports `os` to egui, for platform-specific input. +pub fn os_harness(session: Session, os: egui::os::OperatingSystem) -> Harness { + harness_with(builder().with_os(os).with_size(egui::vec2(1200.0, 800.0)), app(session), 4) +} 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..f838ce827 100644 --- a/crates/ui-egui/tests/it/advanced_filter.rs +++ b/crates/ui-egui/tests/it/advanced_filter.rs @@ -1,24 +1,12 @@ //! Advanced Filter's dialog must route both actions to the same engine command. -use egui::vec2; use egui_kittest::kittest::Queryable; -use gridcraft_engine::Session; use gridcraft_ui_egui::SheetApp; use gridcraft_ui_egui::control::{Outcome, handle}; 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( - |ui, app: &mut SheetApp| { - app.logic(&ui.ctx().clone()); - app.ui(ui); - }, - SheetApp::new(session, Default::default()), - ); - h.run_steps(6); - h + crate::support::blank_harness(6) } fn control(h: &mut egui_kittest::Harness<'static, SheetApp>, method: &str, params: Json) -> Json { @@ -30,6 +18,7 @@ fn control(h: &mut egui_kittest::Harness<'static, SheetApp>, method: &str, param } #[test] +/// Checks that the copy destination field appears and that a unique extract writes `B` to C3, closing the dialog without a message. fn dialog_offers_copy_action_and_runs_unique_extract() { let mut h = harness(); h.state_mut().run("range.setValues", json!({"range": "A1", "values": [["Name"], ["A"], ["A"], ["B"]]})).unwrap(); @@ -37,7 +26,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..b415baf01 100644 --- a/crates/ui-egui/tests/it/autosave_persist.rs +++ b/crates/ui-egui/tests/it/autosave_persist.rs @@ -1,11 +1,11 @@ //! #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] +/// AutoSave defaults off, toggles on and off, and survives `UiState` serialization. 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/cf_rule_pick.rs b/crates/ui-egui/tests/it/cf_rule_pick.rs index a523c9c6d..710827bc1 100644 --- a/crates/ui-egui/tests/it/cf_rule_pick.rs +++ b/crates/ui-egui/tests/it/cf_rule_pick.rs @@ -14,12 +14,12 @@ fn harness() -> Harness { session.execute("home.insertSheet", json!({})).unwrap(); session.execute("sheet.activate", json!({"sheet": 0})).unwrap(); session.execute("selection.set", json!({"range": "F8:G9"})).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().open_dialog("cfQuick", json!({"type": "expression", "title": "New Formatting Rule"})); diff --git a/crates/ui-egui/tests/it/chart_resize.rs b/crates/ui-egui/tests/it/chart_resize.rs index fb3e99165..a2c36e3f3 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() } @@ -133,6 +133,7 @@ fn right_click(h: &mut Harness, pos: egui::Pos2, wobble: egui::Vec2) { } #[test] +/// A right-click selects the chart and opens its menu rather than the cell menu without moving the chart; Delete removes it, and an outside right-click still opens the cell menu. fn right_clicking_a_chart_opens_its_menu_not_the_cells() { // #2067: a right-click on a chart opened the menu of the cell under it, or nothing at all. for wobble in [vec2(0.0, 0.0), vec2(20.0, 10.0)] { @@ -151,7 +152,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..e1e8c6d8a 100644 --- a/crates/ui-egui/tests/it/committed_text_fields.rs +++ b/crates/ui-egui/tests/it/committed_text_fields.rs @@ -1,6 +1,6 @@ //! Text fields over workbook values (chart titles, table name) keep what the user types until they commit it. -use egui::{Event, Key, Modifiers, accesskit::Role, vec2}; +use egui::{Event, Key, Modifiers, accesskit::Role}; use egui_kittest::kittest::Queryable as _; use gridcraft_engine::Session; use gridcraft_ui_egui::SheetApp; @@ -9,16 +9,7 @@ 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( - |ui, app: &mut SheetApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - SheetApp::new(session, Default::default()), - ); - h.run_steps(4); - h + crate::support::harness(session, 4) } fn session() -> Session { @@ -46,6 +37,7 @@ fn retype(h: &mut Harness, field: egui::Rect, text: &str) { } #[test] +/// Editing the Format Chart title field sets the chart title. fn typing_a_chart_title_in_format_chart_area_sets_it() { let mut session = session(); session.execute("selection.set", json!({"range": "A1:B3"})).unwrap(); @@ -55,7 +47,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..ae318a22a 100644 --- a/crates/ui-egui/tests/it/contextual_ribbon_tab.rs +++ b/crates/ui-egui/tests/it/contextual_ribbon_tab.rs @@ -2,7 +2,6 @@ //! The tab remembered in the saved UI state, or left behind when the selection moves away, must //! not leave the ribbon blank: it falls back to Home. -use egui::vec2; use gridcraft_engine::Session; use gridcraft_ui_egui::SheetApp; use serde_json::json; @@ -10,16 +9,7 @@ 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( - |ui, app: &mut SheetApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - SheetApp::new(session, Default::default()), - ); - h.run_steps(4); - h + crate::support::harness(session, 4) } fn session_with_table() -> Session { @@ -31,19 +21,11 @@ fn session_with_table() -> Session { session } +/// Sets a saved ribbon tab on a fresh app and builds the stepped harness, for unavailable-tab cases. 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( - |ui, app: &mut SheetApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - app, - ); - h.run_steps(4); - h + crate::support::app_harness(app, 4) } #[test] 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..5bbaca51f 100644 --- a/crates/ui-egui/tests/it/dialog_button_row.rs +++ b/crates/ui-egui/tests/it/dialog_button_row.rs @@ -7,16 +7,17 @@ use gridcraft_ui_egui::SheetApp; use serde_json::json; #[test] +/// OK sits between 0 and 40 points below the last dialog option. 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..9736f700f 100644 --- a/crates/ui-egui/tests/it/dialog_confirm.rs +++ b/crates/ui-egui/tests/it/dialog_confirm.rs @@ -1,24 +1,12 @@ //! `ui.dialog.confirm` runs a dialog's command with the parameters its OK button sends (#1459). -use egui::vec2; -use gridcraft_engine::Session; use gridcraft_engine::model::CfRule; use gridcraft_ui_egui::SheetApp; use gridcraft_ui_egui::control::{Outcome, handle}; 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( - |ui, app: &mut SheetApp| { - app.logic(&ui.ctx().clone()); - app.ui(ui); - }, - SheetApp::new(session, Default::default()), - ); - h.run_steps(6); - h + crate::support::blank_harness(6) } fn control(h: &mut egui_kittest::Harness<'static, SheetApp>, method: &str, params: Json) -> Json { diff --git a/crates/ui-egui/tests/it/dialog_focus.rs b/crates/ui-egui/tests/it/dialog_focus.rs index 9ccce5f0f..98133c371 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..1b63d601a 100644 --- a/crates/ui-egui/tests/it/dialog_move.rs +++ b/crates/ui-egui/tests/it/dialog_move.rs @@ -10,19 +10,20 @@ 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 } +/// Returns the remembered egui area rect for a named dialog. 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/error_checking_menu.rs b/crates/ui-egui/tests/it/error_checking_menu.rs index e0e1e8e41..e0129c5c1 100644 --- a/crates/ui-egui/tests/it/error_checking_menu.rs +++ b/crates/ui-egui/tests/it/error_checking_menu.rs @@ -10,9 +10,11 @@ use serde_json::json; type Harness = egui_kittest::Harness<'static, SheetApp>; -/// The submenu item, with the arrow egui puts after a submenu's label. -const CIRCULAR: &str = "Circular References ⏵"; +/// The submenu item's accessible name. egui 0.37 draws the submenu arrow as decoration and no +/// longer folds it into the label, so screen readers (and kittest) see the purpose without it. +const CIRCULAR: &str = "Circular References"; +/// Builds the Formulas-tab harness, optionally adding cross-sheet and same-sheet circular references and selecting H20. fn harness(loops: bool) -> Harness { let mut s = Session::new(); s.new_workbook(); @@ -26,12 +28,12 @@ fn harness(loops: bool) -> Harness { s.execute("cell.set", json!({"cell": "C2", "input": "=C2+1"})).unwrap(); } s.execute("selection.set", json!({"range": "H20"})).unwrap(); - let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 800.0)).build_ui_state( + let mut h = crate::common::builder().with_size(vec2(1400.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.state_mut().ui.ribbon_tab = "Formulas".into(); @@ -130,6 +132,7 @@ fn circular_references_scrolls_to_the_cell_picked() { } #[test] +/// Wheel scrolling brings A100 into view, and clicking it selects A100. fn circular_references_scrolls_a_list_taller_than_the_window() { let mut h = harness(false); for row in 1..=100 { @@ -145,6 +148,7 @@ fn circular_references_scrolls_a_list_taller_than_the_window() { for _ in 0..20 { h.input_mut().events.push(Event::MouseWheel { unit: egui::MouseWheelUnit::Line, + source: egui::MouseWheelSource::Unknown, delta: vec2(0.0, -10.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, diff --git a/crates/ui-egui/tests/it/file_menu.rs b/crates/ui-egui/tests/it/file_menu.rs index 6b4cbc6a7..835fdf965 100644 --- a/crates/ui-egui/tests/it/file_menu.rs +++ b/crates/ui-egui/tests/it/file_menu.rs @@ -8,18 +8,7 @@ use gridcraft_ui_egui::i18n::Language; 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( - |ui, app: &mut SheetApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - SheetApp::new(session, services), - ); - h.run_steps(4); - h + crate::support::services_harness(services, 4) } /// Opens File (the Backstage view) and clicks a navigation entry or page button. @@ -110,17 +99,21 @@ fn save_actions_include_the_cell_draft_and_close_keeps_the_unsaved_prompt() { } #[test] +/// Options opens, named Backstage pages are reachable and accessible, an invalid page errors, and both close and Back leave Backstage. fn file_options_opens_the_options_page_and_pages_are_reachable_by_command() { let mut h = harness(Services::default()); 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 +123,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()); } @@ -338,23 +332,20 @@ fn options_sets_maximum_iterations_and_change_only_while_iteration_is_on() { let mut session = Session::new(); session.new_workbook(); // Tall enough that the Formulas section is on screen without scrolling. - let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 1400.0)).build_ui_state( + let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 1400.0)).build_ui_state( |ui, app: &mut SheetApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); app.ui(ui); }, - SheetApp::new(session, Services::default()), + crate::common::prepare(SheetApp::new(session, Services::default())), ); h.run_steps(4); h.state_mut().run("file.options", serde_json::json!({})).unwrap(); h.run_steps(3); let calc = |h: &egui_kittest::Harness<'static, SheetApp>| h.state().session.active().unwrap().wb.calc.clone(); - // The text field on the same row as `label`. - let field = |h: &egui_kittest::Harness<'static, SheetApp>, label: &str| { - let l = h.get_by_label(label).rect(); - h.get_all_by_role(Role::TextInput).map(|n| n.rect()).find(|r| r.left() > l.left() && (r.center().y - l.center().y).abs() < 8.0).unwrap() - }; + // Resolve the field by its accessible name in both the disabled and enabled states. + let field = |h: &egui_kittest::Harness<'static, SheetApp>, label: &str| h.get_by_role_and_label(Role::TextInput, label).rect(); let disabled = |h: &egui_kittest::Harness<'static, SheetApp>, rect: egui::Rect| { h.get_all_by_role(Role::TextInput).find(|n| n.rect() == rect).unwrap().accesskit_node().is_disabled() }; 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..b2ae68916 100644 --- a/crates/ui-egui/tests/it/fill_handle_double_click.rs +++ b/crates/ui-egui/tests/it/fill_handle_double_click.rs @@ -13,15 +13,7 @@ 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( - |ui, app: &mut SheetApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - SheetApp::new(session, Default::default()), - ); - h.run_steps(4); - h + crate::support::stepped_harness(session) } fn handle(h: &egui_kittest::Harness<'_, SheetApp>) -> Pos2 { 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 17599ff53..d8ffbff9a 100644 --- a/crates/ui-egui/tests/it/fill_handle_right_drag.rs +++ b/crates/ui-egui/tests/it/fill_handle_right_drag.rs @@ -15,15 +15,7 @@ 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( - |ui, app: &mut SheetApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - SheetApp::new(session, Default::default()), - ); - h.run_steps(4); - h + crate::support::stepped_harness(session) } /// B1:B2 holds 1, 2 and is selected. diff --git a/crates/ui-egui/tests/it/fill_options_button.rs b/crates/ui-egui/tests/it/fill_options_button.rs index 1e06df8b5..0a784d6c6 100644 --- a/crates/ui-egui/tests/it/fill_options_button.rs +++ b/crates/ui-egui/tests/it/fill_options_button.rs @@ -15,15 +15,7 @@ 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( - |ui, app: &mut SheetApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - SheetApp::new(session, Default::default()), - ); - h.run_steps(4); - h + crate::support::stepped_harness(session) } /// B1:B2 holds 1, 2 and is selected. diff --git a/crates/ui-egui/tests/it/filter_list_scroll.rs b/crates/ui-egui/tests/it/filter_list_scroll.rs index 35406fa89..b3526c579 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 = { @@ -42,6 +42,7 @@ fn filter_menu_on_a_long_list() -> Harness { } #[test] +/// A wheel event over the menu's blank right area scrolls the value list. fn filter_list_scrolls_with_the_pointer_right_of_the_values() { let mut h = filter_menu_on_a_long_list(); let first = h.get_by_label("ITEM-001").rect(); @@ -51,6 +52,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..a774a431b 100644 --- a/crates/ui-egui/tests/it/find_dialog_focus.rs +++ b/crates/ui-egui/tests/it/find_dialog_focus.rs @@ -1,7 +1,6 @@ //! Opening Find and Replace puts the cursor in "Find what", so the user can type at once. use egui::{Event, Key, Modifiers}; -use gridcraft_engine::Session; use gridcraft_ui_egui::SheetApp; use serde_json::json; @@ -11,18 +10,7 @@ type Harness = egui_kittest::Harness<'static, SheetApp>; const CTRL: Modifiers = Modifiers { alt: false, ctrl: true, shift: false, mac_cmd: false, command: true }; 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( - |ui, app: &mut SheetApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - SheetApp::new(session, Default::default()), - ); - h.run_steps(6); - h + crate::support::blank_harness(6) } fn type_after_shortcut(k: Key) -> Option { diff --git a/crates/ui-egui/tests/it/floating_pictures.rs b/crates/ui-egui/tests/it/floating_pictures.rs index f14d40d85..0ecd9ca07 100644 --- a/crates/ui-egui/tests/it/floating_pictures.rs +++ b/crates/ui-egui/tests/it/floating_pictures.rs @@ -2,8 +2,7 @@ use std::io::Cursor; -use egui::{Shape, TextureId, vec2}; -use gridcraft_engine::Session; +use egui::{Shape, TextureId}; use gridcraft_ui_egui::SheetApp; use serde_json::json; @@ -15,17 +14,7 @@ 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( - |ui, app: &mut SheetApp| { - app.logic(&ui.ctx().clone()); - app.ui(ui); - }, - SheetApp::new(session, Default::default()), - ); - h.run_steps(4); - h + crate::support::blank_harness(4) } fn picture_textures(h: &egui_kittest::Harness<'static, SheetApp>) -> Vec { diff --git a/crates/ui-egui/tests/it/font_size_box.rs b/crates/ui-egui/tests/it/font_size_box.rs index 0337543ad..b34b195ff 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 @@ -105,9 +105,10 @@ fn escape_cancels_the_typed_size() { } #[test] +/// The size list has no filter; clicking 16 applies it, and Down then Enter picks 12. 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 +117,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..39eeaa4c4 100644 --- a/crates/ui-egui/tests/it/format_painter.rs +++ b/crates/ui-egui/tests/it/format_painter.rs @@ -18,16 +18,7 @@ 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( - |ui, app: &mut SheetApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - SheetApp::new(session, Default::default()), - ); - h.run_steps(4); - h + crate::support::harness(session, 4) } fn style(h: &Harness, a1: &str) -> Style { diff --git a/crates/ui-egui/tests/it/formula_bar.rs b/crates/ui-egui/tests/it/formula_bar.rs index f20c9e31b..7873332ed 100644 --- a/crates/ui-egui/tests/it/formula_bar.rs +++ b/crates/ui-egui/tests/it/formula_bar.rs @@ -10,13 +10,14 @@ use serde_json::json; type Harness = egui_kittest::Harness<'static, SheetApp>; +/// Builds a stepped 1200x800 harness around the caller's session and settles its frames. fn harness(session: Session) -> Harness { - let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state( + let mut h = crate::common::builder().with_size(vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state( |ui, app: &mut SheetApp| { app.logic(ui.ctx()); app.ui(ui); }, - SheetApp::new(session, Default::default()), + crate::common::app(session), ); settle(&mut h); h @@ -149,6 +150,7 @@ fn height_eases_instead_of_jumping() { assert!((layout(&h).height - (base + 4.0 * rh)).abs() < 0.5); } +/// Opens a multi-line edit in B3, checks the text is kept whole, and returns the painted editor height. fn in_cell_height(lines: usize) -> f32 { let mut s = blank(); s.execute("selection.set", json!({"cell": "B3"})).unwrap(); @@ -157,7 +159,7 @@ fn in_cell_height(lines: usize) -> f32 { h.state_mut().begin_edit(Some(text.clone()), false); settle(&mut h); assert_eq!(h.state().editor.as_ref().unwrap().text, text, "the text is kept whole"); - let rect: Rect = h.ctx.data(|d| d.get_temp(egui::Id::new("gridcraft.cell_editor.rect"))).expect("the in-cell editor painted"); + let rect: Rect = h.ctx.data(|d| d.get_temp(egui::Id::unique("gridcraft.cell_editor.rect"))).expect("the in-cell editor painted"); rect.height() } @@ -171,22 +173,15 @@ fn in_cell_editor_grows_with_every_line() { } #[test] +/// The code editor indents after Enter and preserves indentation across typed lines, with an accessibility check. fn code_editor_indents_and_keeps_indentation() { struct State { text: String, focused: bool, } - let mut h = egui_kittest::Harness::builder().with_size(vec2(400.0, 300.0)).build_ui_state( + let mut h = crate::common::builder().with_size(vec2(400.0, 300.0)).build_ui_state( |ui, s: &mut State| { - if ui.ctx().cumulative_pass_nr() == 0 { - // egui's own fonts stand in for the system ones under the theme's family names. - let mut fonts = egui::FontDefinitions::default(); - let mono = fonts.families.get(&egui::FontFamily::Monospace).cloned().unwrap_or_default(); - fonts.families.insert(egui::FontFamily::Name(theme::MONO.into()), mono); - ui.ctx().set_fonts(fonts); - return; - } - let id = egui::Id::new("code"); + let id = egui::Id::unique("code"); if !s.focused { ui.memory_mut(|m| m.request_focus(id)); s.focused = true; @@ -196,6 +191,8 @@ fn code_editor_indents_and_keeps_indentation() { State { text: String::new(), focused: false }, ); h.run_steps(2); + // The code editor's text field has no visible label; the strict policy checks its name. + h.check_accessibility(); h.input_mut().events.push(Event::Text("if x:".into())); h.run_steps(1); h.input_mut().events.extend(key(Key::Enter, Modifiers::NONE)); diff --git a/crates/ui-egui/tests/it/formula_bar_expand.rs b/crates/ui-egui/tests/it/formula_bar_expand.rs index 95b4368e5..a71a979cd 100644 --- a/crates/ui-egui/tests/it/formula_bar_expand.rs +++ b/crates/ui-egui/tests/it/formula_bar_expand.rs @@ -18,16 +18,7 @@ fn harness() -> Harness { session.new_workbook(); session.execute("cell.set", json!({"cell": "A1", "input": "text"})).unwrap(); session.execute("selection.set", json!({"range": "A1"})).unwrap(); - let mut h = Harness::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()), - ); - h.run_steps(4); - h + crate::support::harness(session, 4) } fn ctrl_shift_u(h: &mut Harness, m: Modifiers) { diff --git a/crates/ui-egui/tests/it/formula_locale.rs b/crates/ui-egui/tests/it/formula_locale.rs index 98ab70d57..78af96a1a 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 } @@ -78,10 +78,11 @@ fn localized_argument_hints_ignore_decimal_commas_arrays_and_nested_calls() { } #[test] +/// Spanish autocomplete offers `SUMA` instead of `SUM`; accepting it inserts `=SUMA(`, and English switches back. 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); @@ -140,6 +141,7 @@ fn locale_preserves_text_cells_and_existing_entry_conveniences() { } #[test] +/// Insert Function and AutoSum use `=SUMA(`, while user-supplied formula text is not translated twice. fn canonical_engine_templates_enter_the_selected_formula_language() { let mut app = app(); app.run("app.language.set", json!({"language":"es"})).unwrap(); @@ -158,7 +160,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..cb2055370 100644 --- a/crates/ui-egui/tests/it/formula_popup.rs +++ b/crates/ui-egui/tests/it/formula_popup.rs @@ -1,6 +1,7 @@ //! Formula helpers must stay in the worksheet while its editor scrolls. use egui::{Context, Id, LayerId, Order, RawInput, Rect, pos2, vec2}; use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; use gridcraft_ui_egui::SheetApp; fn frame(ctx: &Context, app: &mut SheetApp) -> egui::FullOutput { @@ -20,17 +21,20 @@ fn frames(ctx: &Context, app: &mut SheetApp) { } } +/// Reports whether the named autocomplete/helper tooltip layer is visible. 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] +/// Autocomplete and argument help hide when the edited cell scrolls out, the edit is preserved, and both return when it scrolls back. fn cell_formula_helpers_hide_when_scrolled_out_and_return_with_the_editor() { for text in ["=SU", "=SUM("] { let ctx = Context::default(); + ctx.set_fonts(gridcraft_ui_egui::theme::pinned_font_definitions()); 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 +52,53 @@ 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 formula_bar_hint_remains_visible_when_the_worksheet_scrolls() { +fn hidden_row_and_column_hide_the_wrapped_editor_until_unhidden() { let ctx = Context::default(); + ctx.set_fonts(gridcraft_ui_egui::theme::pinned_font_definitions()); let mut session = Session::new(); session.new_workbook(); - let mut app = SheetApp::new(session, Default::default()); + 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] +/// Formula-bar help stays visible while the sheet scrolls. +fn formula_bar_hint_remains_visible_when_the_worksheet_scrolls() { + let ctx = Context::default(); + ctx.set_fonts(gridcraft_ui_egui::theme::pinned_font_definitions()); + let mut session = Session::new(); + session.new_workbook(); + let mut app = crate::common::app(session); frames(&ctx, &mut app); app.begin_edit(Some("=SUM(".into()), true); frames(&ctx, &mut app); @@ -64,11 +109,13 @@ fn formula_bar_hint_remains_visible_when_the_worksheet_scrolls() { } #[test] +/// Cell argument help is clipped to the worksheet instead of painting over the headers. fn cell_argument_hint_is_clipped_to_the_worksheet() { let ctx = Context::default(); + ctx.set_fonts(gridcraft_ui_egui::theme::pinned_font_definitions()); 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); @@ -81,11 +128,13 @@ fn cell_argument_hint_is_clipped_to_the_worksheet() { } #[test] +/// An editor for a scrolled-out cell still handles Esc. fn scrolled_out_cell_editor_still_handles_escape() { let ctx = Context::default(); + ctx.set_fonts(gridcraft_ui_egui::theme::pinned_font_definitions()); 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); @@ -110,13 +159,15 @@ fn scrolled_out_cell_editor_still_handles_escape() { } #[test] +/// The editor and its helper clip below the frozen top row, stay hidden while the cell is under it, and return on scroll-back. fn cell_editor_and_helper_stay_below_the_frozen_top_row() { let ctx = Context::default(); + ctx.set_fonts(gridcraft_ui_egui::theme::pinned_font_definitions()); let mut session = Session::new(); 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 +187,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; @@ -143,13 +206,61 @@ fn cell_editor_and_helper_stay_below_the_frozen_top_row() { assert!(visible(&ctx, "gridcraft.cell_editor"), "scrolling back restores the helper"); } +/// The editor is drawn in screen coordinates, so its frozen-pane quadrant must be mirrored +/// before the visibility test and clip on a right-to-left sheet. Both panes are checked: using +/// the logical quadrant hides the frozen editor, and lets the scrolling editor's clip reach into +/// the frozen pane. #[test] +fn frozen_column_editor_paints_in_both_panes_on_a_right_to_left_sheet() { + for (cell, text) in [("A2", "frozen"), ("B2", "scrolling")] { + let ctx = Context::default(); + ctx.set_fonts(gridcraft_ui_egui::theme::pinned_font_definitions()); + let mut session = Session::new(); + session.new_workbook(); + session.run("view.rightToLeft", serde_json::json!({"on": true})).unwrap(); + session.run("view.freezeFirstColumn", serde_json::json!({})).unwrap(); + session.run("selection.set", serde_json::json!({"cell": cell})).unwrap(); + let mut app = crate::common::app(session); + frames(&ctx, &mut app); + app.begin_edit(Some(text.into()), false); + frames(&ctx, &mut app); + let output = frame(&ctx, &mut app); + let geo = app.grid.geo.as_ref().expect("the grid drew"); + assert!(geo.rtl && geo.fc == 1, "the test needs a right-to-left frozen column"); + let sh = app.session.active().unwrap().wb.active().unwrap(); + let target = CellRef::parse(cell).unwrap(); + let screen_cell = geo.screen_cell_rect(sh, target); + let split = pos2(geo.cells.left() + geo.frozen_w, geo.cells.top() + geo.frozen_h); + let quadrant = Rect::from_min_max( + pos2(if target.col >= geo.fc { split.x } else { geo.cells.left() }, if target.row >= geo.fr { split.y } else { geo.cells.top() }), + pos2(if target.col >= geo.fc { geo.cells.right() } else { split.x }, if target.row >= geo.fr { geo.cells.bottom() } else { split.y }), + ); + let screen_quadrant = geo.mirror_rect(quadrant); + let (clipped, _) = output + .shapes + .iter() + .find_map(|s| { + let egui::Shape::Text(t) = &s.shape else { return None }; + (t.galley.text() == text && s.clip_rect.intersect(t.visual_bounding_rect()).intersect(screen_cell).is_positive()).then_some((s, t)) + }) + .expect("the editor text is painted inside its screen cell"); + assert!( + screen_quadrant.contains_rect(clipped.clip_rect), + "the editor of {cell} must stay inside its screen pane {screen_quadrant:?}, not {:?}", + clipped.clip_rect + ); + } +} + +#[test] +/// The DATEDIF helper paints its signature and a wrapped unit description no wider than 400 points. fn argument_hint_shows_the_function_description() { // #363: inside DATEDIF( the hint lists the units the third argument takes. let ctx = Context::default(); + ctx.set_fonts(gridcraft_ui_egui::theme::pinned_font_definitions()); 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..7bcc81d1a 100644 --- a/crates/ui-egui/tests/it/freeze_handles.rs +++ b/crates/ui-egui/tests/it/freeze_handles.rs @@ -1,6 +1,6 @@ //! Freezing panes by dragging the freeze handles on the header edges (#195). -use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2}; +use egui::{Event, Modifiers, PointerButton, Pos2, pos2}; use gridcraft_engine::Session; use gridcraft_engine::core::CellRef; use gridcraft_ui_egui::SheetApp; @@ -11,15 +11,7 @@ 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( - |ui, app: &mut SheetApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - SheetApp::new(session, Default::default()), - ); - h.run_steps(4); - h + crate::support::stepped_harness(session) } fn geo(h: &Harness) -> Geo { diff --git a/crates/ui-egui/tests/it/go_to_dialog.rs b/crates/ui-egui/tests/it/go_to_dialog.rs index 0d005ad4b..cecaa61e2 100644 --- a/crates/ui-egui/tests/it/go_to_dialog.rs +++ b/crates/ui-egui/tests/it/go_to_dialog.rs @@ -18,12 +18,12 @@ fn harness() -> Harness { for (name, refers, scope) in [("Price", "=Sheet1!$B$2:$B$5", "Workbook"), ("Local", "=Sheet2!$C$3", "Sheet2"), ("Rate", "=0.07", "Workbook")] { session.execute("formulas.defineName", json!({"name": name, "refersTo": refers, "scope": scope})).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/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 431be6d72..a70efa782 100644 --- a/crates/ui-egui/tests/it/interaction.rs +++ b/crates/ui-egui/tests/it/interaction.rs @@ -8,17 +8,9 @@ use gridcraft_ui_egui::i18n::Language; use gridcraft_ui_egui::{SheetApp, grid::Geo}; use serde_json::json; +/// Builds the standard interaction harness around the caller's session with four warm-up passes. 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( - |ui, app: &mut SheetApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - SheetApp::new(session, Default::default()), - ); - h.run_steps(4); - h + crate::support::harness(session, 4) } fn blank() -> Session { @@ -210,12 +202,13 @@ fn space_toggles_the_active_checkbox_without_clicking_the_hovered_cell() { } #[test] +/// Name Box Enter selects A1; the next grid Enter moves down to A2. fn name_box_enter_goes_to_the_typed_cell_not_the_one_below() { // #247: typing A1 in the Name Box and pressing Enter selected A2; the grid replayed the Enter. 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())); @@ -243,13 +236,16 @@ fn name_box_shows_a_name_defined_on_a_sheet_with_an_apostrophe() { } #[test] +/// Typing and Enter commit 42 in A1; `=A1*2` and Tab commit 84 in A2 and move to B2, with accessibility checks. 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()); @@ -412,6 +408,7 @@ fn insert_column_or_bar_menu_inserts_a_hundred_percent_stacked_bar() { } #[test] +/// Every main ribbon tab and dark mode render with accessibility checks. fn renders_every_ribbon_tab_without_panicking() { let mut s = Session::new(); s.execute("file.new", json!({"sample": "sales"})).unwrap(); @@ -419,12 +416,15 @@ 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] +/// Language commands are listed and every available locale renders and persists; CJK locales are omitted when craft fonts are absent. fn language_switch_renders_the_ribbon_and_persists() { let mut h = harness(blank()); // English by default whatever the host's locale: only the desktop app reads the system language. @@ -439,9 +439,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); @@ -451,6 +459,7 @@ fn language_switch_renders_the_ribbon_and_persists() { } #[test] +/// The PivotTable Fields pane and its field menus switch to Spanish. fn pivot_fields_pane_follows_the_interface_language() { // #858: the PivotTable Fields pane and its field menus stayed in English after switching to Spanish. let mut h = harness(blank()); @@ -458,6 +467,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)); @@ -467,6 +477,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)); } @@ -583,6 +594,7 @@ fn recommended_charts_dialog_inserts_the_chosen_chart() { } #[test] +/// Selecting 100% Stacked Area in the All Charts list inserts that chart type. fn all_charts_inserts_a_hundred_percent_stacked_area() { let mut s = blank(); s.execute("range.setValues", json!({"range": "A1", "values": [["Quarter", "North", "South"], ["Q1", 10, 30], ["Q2", 30, 10]]})).unwrap(); @@ -595,6 +607,7 @@ fn all_charts_inserts_a_hundred_percent_stacked_area() { h.input_mut().events.push(Event::PointerMoved(first)); h.input_mut().events.push(Event::MouseWheel { unit: egui::MouseWheelUnit::Line, + source: egui::MouseWheelSource::Unknown, delta: egui::vec2(0.0, -10.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE, @@ -649,6 +662,7 @@ fn select_data_dialog_sets_the_category_axis_labels() { } #[test] +/// Each listed dialog opens, passes accessibility and closes. fn dialogs_open_and_close() { let mut h = harness(blank()); for name in [ @@ -670,6 +684,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..44991bbec 100644 --- a/crates/ui-egui/tests/it/interface_scale.rs +++ b/crates/ui-egui/tests/it/interface_scale.rs @@ -5,15 +5,16 @@ use gridcraft_ui_egui::{SheetApp, UiState}; use serde_json::json; #[test] +/// Interface size applies and persists, clamps to 75-200, accepts `auto`, and rejects missing or non-numeric values. 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..8d3de6fcb 100644 --- a/crates/ui-egui/tests/it/keyboard_shortcuts.rs +++ b/crates/ui-egui/tests/it/keyboard_shortcuts.rs @@ -11,16 +11,7 @@ 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( - |ui, app: &mut SheetApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - SheetApp::new(session, Default::default()), - ); - h.run_steps(4); - h + crate::support::harness(session, 4) } fn book(selected: &str) -> Session { @@ -151,6 +142,7 @@ fn space_shortcuts_per_platform() { } #[test] +/// Alt+Down opens the active header's filter menu in LTR and RTL without editing the workbook; typing goes to the search box and Esc dismisses it. fn alt_down_opens_autofilter_headers_without_changing_the_workbook() { for rtl in [false, true] { for cell in ["A1", "B1"] { @@ -172,7 +164,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 2dbcb8f29..60368239a 100644 --- a/crates/ui-egui/tests/it/keytips_generated.rs +++ b/crates/ui-egui/tests/it/keytips_generated.rs @@ -14,15 +14,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().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()), - ); - h.run_steps(4); - h + crate::common::os_harness(session, OperatingSystem::Nix) } fn blank() -> Session { diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 915016971..53625015e 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; @@ -85,6 +88,7 @@ mod show_objects; mod shrink_to_fit; mod sort_dialog_columns; mod status_bar_menu; +mod support; mod symbol_glyphs; mod system_theme; mod tab_split; 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..b4e56301b 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 @@ -63,6 +63,7 @@ fn more_colors_applies_the_typed_colour_and_remembers_it() { } #[test] +/// A short hex value applies no fill, and Cancel applies nothing and remembers no color. fn a_colour_that_is_not_hex_does_not_apply_and_cancel_applies_nothing() { let mut h = harness(); open_fill_palette(&mut h); @@ -73,7 +74,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..542083df4 100644 --- a/crates/ui-egui/tests/it/native_print.rs +++ b/crates/ui-egui/tests/it/native_print.rs @@ -6,6 +6,7 @@ use serde_json::json; use std::{cell::RefCell, rc::Rc}; #[test] +/// Opening settings and exporting do not spool; a configured native print submits its settings, and a spool error is reported. fn native_print_uses_selected_settings_and_reports_spool_errors() { let mut session = Session::new(); session.new_workbook(); @@ -24,7 +25,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"); @@ -43,15 +44,16 @@ fn native_print_uses_selected_settings_and_reports_spool_errors() { } #[test] +/// Print settings stay visible and accessible in an 800x700 window; toggling Grayscale updates the settings and readiness. 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 +62,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); @@ -77,11 +79,12 @@ fn print_settings_are_visible_in_a_narrow_window() { } #[test] +/// Every malformed print-settings request errors and leaves the prior settings unchanged. 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"}), @@ -102,11 +105,12 @@ fn invalid_print_settings_are_rejected_without_partial_changes() { } #[test] +/// Two fast jobs each surface the same receipt exactly once. 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..e350e4163 100644 --- a/crates/ui-egui/tests/it/open_encrypted.rs +++ b/crates/ui-egui/tests/it/open_encrypted.rs @@ -9,18 +9,7 @@ use serde_json::json; 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( - |ui, app: &mut SheetApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - SheetApp::new(session, services), - ); - h.run_steps(4); - h + crate::support::services_harness(services, 4) } /// A workbook with 42 in A1, saved as `.xlsx` and encrypted with `PW`. diff --git a/crates/ui-egui/tests/it/outline.rs b/crates/ui-egui/tests/it/outline.rs index 57aa7b740..2b075fd26 100644 --- a/crates/ui-egui/tests/it/outline.rs +++ b/crates/ui-egui/tests/it/outline.rs @@ -1,7 +1,7 @@ //! The outline band of grouped rows and columns: − / + and level buttons (#373), and the //! Outline Settings dialog that moves the summary rows above their detail. -use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; +use egui::{Event, Modifiers, PointerButton, Pos2}; use gridcraft_engine::Session; use gridcraft_ui_egui::SheetApp; use gridcraft_ui_egui::outline::{Action, layout}; @@ -12,15 +12,7 @@ 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( - |ui, app: &mut SheetApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - SheetApp::new(session, Default::default()), - ); - h.run_steps(4); - h + crate::support::stepped_harness(session) } fn click(h: &mut Harness, pos: Pos2) { diff --git a/crates/ui-egui/tests/it/page_layout_view.rs b/crates/ui-egui/tests/it/page_layout_view.rs index 863ad2b08..d0306d537 100644 --- a/crates/ui-egui/tests/it/page_layout_view.rs +++ b/crates/ui-egui/tests/it/page_layout_view.rs @@ -32,20 +32,11 @@ 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( - |ui, app: &mut SheetApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - SheetApp::new(session, Default::default()), - ); - h.run_steps(4); - h + crate::common::harness_with( + crate::common::builder().with_size(vec2(1200.0, 900.0)).with_step_dt(1.0 / 60.0).with_os(egui::os::OperatingSystem::Mac), + crate::common::app(session), + 4, + ) } fn geo(h: &Harness) -> Geo { diff --git a/crates/ui-egui/tests/it/page_setup_margins.rs b/crates/ui-egui/tests/it/page_setup_margins.rs index c604d77e3..79b36f897 100644 --- a/crates/ui-egui/tests/it/page_setup_margins.rs +++ b/crates/ui-egui/tests/it/page_setup_margins.rs @@ -8,10 +8,11 @@ fn margins(app: &SheetApp) -> [f32; 6] { app.session.active().and_then(|d| d.wb.active()).map(|s| s.print.margins).unwrap_or_default() } +/// Applies six custom page margins and asserts the engine stored them. 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_names.rs b/crates/ui-egui/tests/it/paste_names.rs index 79ef59936..189e23556 100644 --- a/crates/ui-egui/tests/it/paste_names.rs +++ b/crates/ui-egui/tests/it/paste_names.rs @@ -18,12 +18,12 @@ fn harness(names: bool) -> Harness { session.execute("formulas.defineName", json!({"name": "Sales", "refersTo": "=Sheet1!$A$1:$A$3"})).unwrap(); session.execute("formulas.defineName", json!({"name": "Rate", "refersTo": "=0.07"})).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/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..f24cb3a4b 100644 --- a/crates/ui-egui/tests/it/pivot_pane.rs +++ b/crates/ui-egui/tests/it/pivot_pane.rs @@ -2,9 +2,10 @@ 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; +/// Builds a pivot report at D3 and returns the painted shapes after six frames in the caller's light or dark mode. fn render_pivot_field_buttons(dark: bool) -> Vec { let mut session = Session::new(); session.new_workbook(); @@ -14,7 +15,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 8fcb83c19..52c76d6dd 100644 --- a/crates/ui-egui/tests/it/pivot_show_details.rs +++ b/crates/ui-egui/tests/it/pivot_show_details.rs @@ -2,7 +2,7 @@ //! Show Details) unless its Enable show details option is off; a label of the report still opens //! the cell for editing. -use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; +use egui::{Event, Modifiers, PointerButton, Pos2}; use gridcraft_engine::Session; use gridcraft_engine::core::CellRef; use gridcraft_ui_egui::{SheetApp, grid::Geo}; @@ -17,15 +17,7 @@ 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( - |ui, app: &mut SheetApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - SheetApp::new(session, Default::default()), - ); - h.run_steps(4); - h + crate::support::stepped_harness(session) } fn center(h: &egui_kittest::Harness<'_, SheetApp>, cell: &str) -> Pos2 { diff --git a/crates/ui-egui/tests/it/pivot_show_details_menu.rs b/crates/ui-egui/tests/it/pivot_show_details_menu.rs index 916331194..2747861e6 100644 --- a/crates/ui-egui/tests/it/pivot_show_details_menu.rs +++ b/crates/ui-egui/tests/it/pivot_show_details_menu.rs @@ -1,7 +1,7 @@ //! Right-clicking a PivotTable's value cell offers Show Details in the cell menu (Excel's //! right-click › Show Details); labels of the report and ordinary cells don't offer it. -use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; +use egui::{Event, Modifiers, PointerButton, Pos2}; use egui_kittest::kittest::Queryable as _; use gridcraft_engine::Session; use gridcraft_engine::core::{CellRef, Value}; @@ -17,15 +17,7 @@ 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( - |ui, app: &mut SheetApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - SheetApp::new(session, Default::default()), - ); - h.run_steps(4); - h + crate::support::stepped_harness(session) } fn center(h: &egui_kittest::Harness<'_, SheetApp>, cell: &str) -> Pos2 { 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..052fdcbac 100644 --- a/crates/ui-egui/tests/it/protect_password.rs +++ b/crates/ui-egui/tests/it/protect_password.rs @@ -2,25 +2,13 @@ use egui::accesskit::Role; use egui_kittest::kittest::Queryable as _; -use gridcraft_engine::Session; use gridcraft_ui_egui::SheetApp; use serde_json::json; 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( - |ui, app: &mut SheetApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - SheetApp::new(session, Default::default()), - ); - h.run_steps(4); - h + crate::support::blank_harness(4) } fn type_password(h: &mut egui_kittest::Harness<'static, SheetApp>) { diff --git a/crates/ui-egui/tests/it/ribbon_layout.rs b/crates/ui-egui/tests/it/ribbon_layout.rs index 1033441d6..faa51b15f 100644 --- a/crates/ui-egui/tests/it/ribbon_layout.rs +++ b/crates/ui-egui/tests/it/ribbon_layout.rs @@ -1,21 +1,21 @@ //! Ribbon layout: the big split button, the scroll arrows of an overflowing tab, and every tab //! fitting the card's height. -use egui::{FontDefinitions, FontFamily, vec2}; +use egui::vec2; use egui_kittest::kittest::Queryable as _; use gridcraft_engine::Session; use gridcraft_ui_egui::icons::Icon; use gridcraft_ui_egui::ribbon::{RibbonOverflow, TABS}; -use gridcraft_ui_egui::{SheetApp, theme, widgets}; +use gridcraft_ui_egui::{SheetApp, widgets}; type Harness = egui_kittest::Harness<'static, SheetApp>; fn app_at(width: f32, tab: &str) -> Harness { 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.ribbon_tab = tab.into(); - let mut h = egui_kittest::Harness::builder().with_size(vec2(width, 700.0)).build_ui_state( + let mut h = crate::common::builder().with_size(vec2(width, 700.0)).build_ui_state( |ui, app: &mut SheetApp| { app.logic(ui.ctx()); app.ui(ui); @@ -102,18 +102,10 @@ struct Clicks { } #[test] +/// The main button clicks once and the arrow twice separately; the tooltip keeps the platform shortcut, and the icon sits above the full-width label. fn big_split_button_separates_the_default_action_from_the_menu() { - let mut h = egui_kittest::Harness::builder().with_size(vec2(300.0, 200.0)).build_ui_state( + let mut h = crate::common::builder().with_size(vec2(300.0, 200.0)).build_ui_state( |ui, c: &mut Clicks| { - if ui.ctx().cumulative_pass_nr() == 0 { - // egui's own font stands in for the system UI font; it applies from the next pass. - let mut fonts = FontDefinitions::default(); - let proportional = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default(); - fonts.families.insert(FontFamily::Name(theme::UI.into()), proportional.clone()); - fonts.families.insert(FontFamily::Name(theme::CELL.into()), proportional); - ui.ctx().set_fonts(fonts); - return; - } let (main, arrow) = widgets::big_split_button(ui, Icon::Sum, "Auto\nSum", "AutoSum"); c.main += u32::from(main.clicked()); c.arrow += u32::from(arrow.clicked()); @@ -128,11 +120,13 @@ fn big_split_button_separates_the_default_action_from_the_menu() { h.get_by_label("Auto Sum options").click(); h.run_steps(2); assert_eq!((h.state().main, h.state().arrow), (1, 2)); - // The tooltip carries the shortcut hint, as every ribbon button's does. + // The tooltip carries the shortcut hint, as every ribbon button's does. egui 0.37 waits for + // the pointer to settle before opening it, so remove the hover delay and let it settle. + h.ctx.all_styles_mut(|s| s.interaction.tooltip_delay = 0.0); h.get_by_label("Auto Sum").hover(); - h.run_steps(3); - let hint = if cfg!(target_os = "macos") { "AutoSum (\u{2318}" } else { "AutoSum (Alt+=" }; - assert!(h.query(egui_kittest::kittest::By::new().label_contains(hint)).is_some(), "tooltip with the shortcut"); + h.run_steps(6); + let shortcut = if cfg!(target_os = "macos") { "AutoSum (\u{2318}\u{21e7}T)" } else { "AutoSum (Alt+=)" }; + assert!(h.query_by_value(shortcut).is_some(), "tooltip with the shortcut"); // The icon part sits above the label part, across the full width. let main = h.get_by_label("Auto Sum").rect(); let arrow = h.get_by_label("Auto Sum options").rect(); diff --git a/crates/ui-egui/tests/it/right_to_left.rs b/crates/ui-egui/tests/it/right_to_left.rs index 3be2f3bdb..2754a7679 100644 --- a/crates/ui-egui/tests/it/right_to_left.rs +++ b/crates/ui-egui/tests/it/right_to_left.rs @@ -14,16 +14,7 @@ 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( - |ui, app: &mut SheetApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - SheetApp::new(session, Default::default()), - ); - h.run_steps(4); - h + crate::support::harness(session, 4) } fn geo(h: &Harness) -> Geo { diff --git a/crates/ui-egui/tests/it/scroll_position.rs b/crates/ui-egui/tests/it/scroll_position.rs index bcf62b1ac..520bb1691 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(), gridcraft_engine::Trust::Local)); - 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..acda91ec7 100644 --- a/crates/ui-egui/tests/it/scrollbar_column_fill.rs +++ b/crates/ui-egui/tests/it/scrollbar_column_fill.rs @@ -7,15 +7,16 @@ use gridcraft_ui_egui::{SheetApp, theme::Tokens}; use serde_json::json; #[test] +/// Dark mode paints the tab-bar fill over the strip beside the final column heading. 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 c143d378c..9f669123e 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..2d9c6f0e9 100644 --- a/crates/ui-egui/tests/it/sheet_tabs.rs +++ b/crates/ui-egui/tests/it/sheet_tabs.rs @@ -10,6 +10,7 @@ fn harness(width: f32, active: usize) -> Harness { harness_with(width, 30, active) } +/// Builds a tab strip with the caller's width, sheet count and active sheet, using pinned fonts and an explicit grid rect. fn harness_with(width: f32, sheets: usize, active: usize) -> Harness { let mut s = Session::new(); s.new_workbook(); @@ -19,17 +20,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 @@ -85,6 +86,7 @@ fn navigation_arrows_reveal_the_selected_tab() { } #[test] +/// Horizontal wheel/trackpad browsing leaves the workbook unchanged, does not snap back on idle frames, and activating a sheet still reveals its tab. fn manual_wheel_and_trackpad_browsing_does_not_change_selection_or_snap_back() { for delta in [vec2(0.0, -350.0), vec2(-350.0, 0.0)] { let mut h = harness(700.0, 0); @@ -94,6 +96,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 +105,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 +113,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..b6cbb54f6 100644 --- a/crates/ui-egui/tests/it/sort_dialog_columns.rs +++ b/crates/ui-egui/tests/it/sort_dialog_columns.rs @@ -10,16 +10,7 @@ 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( - |ui, app: &mut SheetApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - SheetApp::new(session, Default::default()), - ); - h.run_steps(4); - h + crate::support::harness(session, 4) } /// Header row + three rows; the user's cursor sits in column B (Revenue). diff --git a/crates/ui-egui/tests/it/status_bar_menu.rs b/crates/ui-egui/tests/it/status_bar_menu.rs index 148824921..3d82333d4 100644 --- a/crates/ui-egui/tests/it/status_bar_menu.rs +++ b/crates/ui-egui/tests/it/status_bar_menu.rs @@ -15,12 +15,12 @@ fn harness() -> Harness { session.execute("cell.set", json!({"cell": cell, "input": input})).unwrap(); } session.execute("selection.set", json!({"range": "A1:A3"})).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 diff --git a/crates/ui-egui/tests/it/support.rs b/crates/ui-egui/tests/it/support.rs new file mode 100644 index 000000000..0136b3d5e --- /dev/null +++ b/crates/ui-egui/tests/it/support.rs @@ -0,0 +1,41 @@ +//! Constructors for the suite's usual harness shapes. +//! +//! The strict font and lifecycle setup stays in `crate::common`; these wrap it at the sizes and +//! step timings the `it` tests share. Keeping them here (rather than in `common`) leaves the +//! `keytips` test binary's copy of `common` free of helpers only this binary uses. + +use crate::common::{Harness, app, builder, harness_with, prepare}; +use gridcraft_engine::Session; +use gridcraft_ui_egui::{Services, SheetApp}; + +/// A blank workbook session, as most integration tests start. +pub fn blank_session() -> Session { + let mut session = Session::new(); + session.new_workbook(); + session +} + +/// The suite's usual 1200×800 harness around `session`, warmed for `steps` frames. +pub fn harness(session: Session, steps: usize) -> Harness { + harness_with(builder().with_size(egui::vec2(1200.0, 800.0)), app(session), steps) +} + +/// A blank workbook in the suite's usual 1200×800 harness. +pub fn blank_harness(steps: usize) -> Harness { + harness(blank_session(), steps) +} + +/// A 1200×800 harness advancing in 1/60 s frames, for drags and hover sequences. +pub fn stepped_harness(session: Session) -> Harness { + harness_with(builder().with_step_dt(1.0 / 60.0).with_size(egui::vec2(1200.0, 800.0)), app(session), 4) +} + +/// A harness around an app built with the caller's `Services` (e.g. a test inbox). +pub fn services_harness(services: Services, steps: usize) -> Harness { + harness_with(builder().with_size(egui::vec2(1200.0, 800.0)), prepare(SheetApp::new(blank_session(), services)), steps) +} + +/// A harness around an already-prepared `app`, at the suite's usual 1200×800 size. +pub fn app_harness(app: SheetApp, steps: usize) -> Harness { + harness_with(builder().with_size(egui::vec2(1200.0, 800.0)), app, steps) +} diff --git a/crates/ui-egui/tests/it/symbol_glyphs.rs b/crates/ui-egui/tests/it/symbol_glyphs.rs index 7f2a55f74..4fb77e0b9 100644 --- a/crates/ui-egui/tests/it/symbol_glyphs.rs +++ b/crates/ui-egui/tests/it/symbol_glyphs.rs @@ -1,30 +1,224 @@ //! 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 +} + +/// Returns the characters in `symbols` that lack a glyph in the given font family. +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() +} + +/// Returns the proportional and cell font families checked by the symbol-glyph tests. +fn families() -> [FontFamily; 2] { + [FontFamily::Proportional, FontFamily::Name(theme::CELL.into())] +} + +#[test] +/// Every required bundled interface symbol resolves in both checked font families. +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] +/// The remaining bundled interface symbols resolve in both checked font families. +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] +/// U+10FFFD is the only tofu probe and does not resolve in either family. +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] +/// `✕`/`✓` map to code icons and `⌃` falls back to `^`; the semantic `×` and `^` resolve. +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] +/// Without craft fonts it prints the optional `✓`/`⌃` gaps as a diagnostic; the strict bundled checks still run separately. +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] +/// With craft fonts, the seven-symbol union, Korean samples and exact permitted faces are checked; a required build without them fails instead of skipping. +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] +/// Advertised script samples resolve in the craft faces; Chinese samples join only when the LXGW face was embedded, and a required build without faces fails. +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/tab_split.rs b/crates/ui-egui/tests/it/tab_split.rs index 21a21c4cb..03ca5e9c5 100644 --- a/crates/ui-egui/tests/it/tab_split.rs +++ b/crates/ui-egui/tests/it/tab_split.rs @@ -15,18 +15,12 @@ fn harness(width: f32, sheets: usize) -> Harness { s.execute("home.insertSheet", json!({"name":format!("Tab{i:02}")})).unwrap(); } s.execute("sheet.activate", json!({"sheet":0})).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()); - 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 diff --git a/crates/ui-egui/tests/it/table_headings.rs b/crates/ui-egui/tests/it/table_headings.rs index 5a2685e01..ec15f876e 100644 --- a/crates/ui-egui/tests/it/table_headings.rs +++ b/crates/ui-egui/tests/it/table_headings.rs @@ -127,16 +127,17 @@ fn click(h: &mut Harness, at: Pos2) { } #[test] +/// The filter button in a replaced heading opens that column's menu without changing the selection; clicking beside it still selects the column. 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..b89a59e2e 100644 --- a/crates/ui-egui/tests/it/text_box_editing.rs +++ b/crates/ui-egui/tests/it/text_box_editing.rs @@ -22,19 +22,11 @@ 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( - |ui, app: &mut SheetApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - SheetApp::new(session, Default::default()), - ); - h.run_steps(4); + let h = crate::common::harness_with( + crate::common::builder().with_size(vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).with_os(egui::os::OperatingSystem::Mac), + crate::common::app(session), + 4, + ); (h, id) } @@ -105,9 +97,11 @@ fn assert_cell_unchanged(h: &Harness) { } #[test] +/// Dragging inside the editor selects text and does not move the box; copy/cut/paste and Enter work, and an outside click commits the multiline text. 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 +123,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..1c4960c7b 100644 --- a/crates/ui-egui/tests/it/touch_pan.rs +++ b/crates/ui-egui/tests/it/touch_pan.rs @@ -6,16 +6,7 @@ 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( - |ui, app: &mut SheetApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - SheetApp::new(session, Default::default()), - ); - h.run_steps(4); - h + crate::support::harness(session, 4) } fn blank() -> Session { diff --git a/crates/ui-egui/tests/it/trace_navigation.rs b/crates/ui-egui/tests/it/trace_navigation.rs index 11f79fcff..b997b291b 100644 --- a/crates/ui-egui/tests/it/trace_navigation.rs +++ b/crates/ui-egui/tests/it/trace_navigation.rs @@ -9,16 +9,7 @@ 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( - |ui, app: &mut SheetApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - SheetApp::new(session, Default::default()), - ); - h.run_steps(4); - h + crate::support::harness(session, 4) } fn active(h: &Harness) -> String { diff --git a/crates/ui-egui/tests/it/translated_form_fields.rs b/crates/ui-egui/tests/it/translated_form_fields.rs index b284bf786..b6464ab30 100644 --- a/crates/ui-egui/tests/it/translated_form_fields.rs +++ b/crates/ui-egui/tests/it/translated_form_fields.rs @@ -2,24 +2,12 @@ //! and OK/Cancel buttons already were (Page Setup showed "Left margin (in):" in every language). use egui_kittest::kittest::Queryable as _; -use gridcraft_engine::Session; use gridcraft_ui_egui::SheetApp; use gridcraft_ui_egui::i18n::Language; 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( - |ui, app: &mut SheetApp| { - let ctx = ui.ctx().clone(); - app.logic(&ctx); - app.ui(ui); - }, - SheetApp::new(session, Default::default()), - ); - h.run_steps(4); - h + crate::support::blank_harness(4) } #[test] diff --git a/crates/ui-egui/tests/it/undo_while_grouping.rs b/crates/ui-egui/tests/it/undo_while_grouping.rs index fdc98ebf9..1797d1c11 100644 --- a/crates/ui-egui/tests/it/undo_while_grouping.rs +++ b/crates/ui-egui/tests/it/undo_while_grouping.rs @@ -15,12 +15,12 @@ fn harness() -> Harness { s.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap(); s.execute("cell.set", json!({"cell": "A1", "input": "2"})).unwrap(); s.execute("edit.undo", json!({})).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/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..634bc68ea 100644 --- a/crates/ui-egui/tests/it/wheel_zoom.rs +++ b/crates/ui-egui/tests/it/wheel_zoom.rs @@ -1,22 +1,28 @@ //! 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>; +/// Builds a mouse-wheel event from its unit, source, phase, delta and modifiers. +fn wheel_event(unit: MouseWheelUnit, source: MouseWheelSource, phase: TouchPhase, delta: egui::Vec2, modifiers: Modifiers) -> Event { + Event::MouseWheel { unit, delta, phase, modifiers, source } +} + +/// Builds a blank stepped harness and leaves the pointer centered over the grid. 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 +46,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 +70,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 +190,89 @@ 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] +/// A 40-point notch follows the same monotonic ladder and reaches 120%. +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] +/// Trackpad and momentum sources follow the same monotonic ladder to 120%. +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] +/// A missing touch End is released by the idle timeout, so the next gesture still reaches 140%. +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] +/// A page-unit scroll moves roughly a viewport, while 40 points scroll less than a page. +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..33ff1bbca 100644 --- a/crates/ui-egui/tests/it/zoom_dialog.rs +++ b/crates/ui-egui/tests/it/zoom_dialog.rs @@ -1,6 +1,5 @@ //! The Zoom dialog stores the Magnification the user picks. -use egui::vec2; use egui_kittest::kittest::Queryable; use gridcraft_engine::Session; use gridcraft_ui_egui::SheetApp; @@ -8,17 +7,7 @@ use gridcraft_ui_egui::control::{Outcome, handle}; 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( - |ui, app: &mut SheetApp| { - app.logic(&ui.ctx().clone()); - app.ui(ui); - }, - SheetApp::new(session, Default::default()), - ); - h.run_steps(6); - h + crate::support::blank_harness(6) } fn zoom(h: &egui_kittest::Harness<'static, SheetApp>) -> u16 { diff --git a/crates/ui-egui/tests/keytips.rs b/crates/ui-egui/tests/keytips.rs index 44f4dc329..b4418018a 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,15 +18,7 @@ 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( - |ui, app: &mut SheetApp| { - app.logic(ui.ctx()); - app.ui(ui); - }, - SheetApp::new(session, Default::default()), - ); - h.run_steps(4); - h + crate::common::os_harness(session, os) } fn blank() -> Session { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0c24e9252..4b886604b 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 @@ -30,18 +33,32 @@ connection; nothing sent after it runs. Blank lines are skipped. | `sheet.read` | `{range?, sheet?, formulas?, formatted?}` | Values (or formulas / displayed text) of a range | | `cell.get` | `{cell?}` | One cell: value, formula, displayed text, style, merge, comment, link | | `ui.inspect` | | UI state: ribbon tab, editor, open dialog, Backstage page, message, grid rect, scroll, perf | -| `ui.click` / `ui.drag` / `ui.move` | `{x, y, toX?, toY?, button?, count?, shift?, cmd?, alt?}` | Real pointer input in window points; refused with an error if it would leave more than 10,000 pointer events queued | -| `ui.cell.click` / `ui.cell.drag` | `{cell}` / `{from, to}` | Pointer input at a cell's centre (works in point mode while editing); same 10,000-event queue limit | -| `ui.key` / `ui.text` | `{key, shift?, cmd?, alt?, ctrl?}` / `{text}` | Keyboard input (typing in the grid starts editing) | +| `ui.click` / `ui.drag` / `ui.move` | `{x, y, toX?, toY?, button?, count?, shift?, cmd?, alt?}` | Real pointer input in window points; refused with an error if it would leave more than 10,000 synthetic events or 8 MiB of synthetic input queued (pointer, key, text and move events share this budget) | +| `ui.cell.click` / `ui.cell.drag` | `{cell}` / `{from, to}` | Pointer input at a cell's centre (works in point mode while editing); same shared queue limits | +| `ui.key` / `ui.text` | `{key, shift?, cmd?, alt?, ctrl?}` / `{text}` | Keyboard input (typing in the grid starts editing); same shared queue limits, and one `ui.text` string may be at most 1 MiB | | `ui.edit.begin` / `ui.edit.text` / `ui.edit.commit` / `ui.edit.cancel` | `{text?}` / `{move?: down\|right\|up\|left\|none}` | Drive the cell editor | | `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; retained images are limited to 128 MiB each and 256 MiB in total (each request reserves the largest image the ±1-pixel rounding rule can retain), one capture's transient encoding working set to 256 MiB, a base64 reply to 64 MiB, and retained images plus one capture's encoding working set to 512 MiB; a request that would exceed a budget is refused without evicting earlier ones. See [Screenshot budgets](#screenshot-budgets). 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) | +### Screenshot budgets + +`ui.screenshot` keeps up to four requests outstanding. Each admitted request reserves the largest +image the ±1-pixel rounding rule can retain: 128 MiB per image and 256 MiB in total across +requests, and a request that would exceed a budget is refused without evicting earlier ones. +Encoding is bounded separately: one capture's transient working set may be 256 MiB (the larger of +the retained image plus its RGBA copy, or, for a base64 reply, the image plus the PNG stream plus +the encoded text), the base64 reply itself may be 64 MiB, and retained images plus one capture's +encoding working set may be 512 MiB. Base64 text counts toward the encoding working set, so a +large pathless capture can be refused there or by its base64 length even when its retained image +fits. `path` writes the PNG to a file instead of returning base64 text and so avoids those two +base64 costs, but it does not bypass the per-image, aggregate, encoding-working-set or peak +limits. + Any other method name that is a command id (e.g. `home.bold`) runs that command. Use `engine.execute` with `{command: "view.theme", params: {mode: "system"}}` 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..2f59c863f --- /dev/null +++ b/docs/egui-037-migration-plan.md @@ -0,0 +1,1337 @@ +# GridCraft — egui 0.37 migration plan + +Status (2026-10-25): the local migration incorporates main +`52fa4406c1ebff8b260a2922472f03aba198b9cf`. Canonical pins are egui +`6d518fc799681571963842f95b55a6b68431b15a`, winit +`a517b3846ee8457d84ee4975110bfda6797c44dc`, craft-ui/craft-print +`25da1314d443171b4adeaad2cd9454b273f35c6f` and craft-fonts +`b076417b5880bd4154bd22f00695b1d28c6bea83`. The regenerated lock SHA256 is +`426a766291a8246573bf57e3ab3f5f17ff812487582254a32ff5a2ec0db39f27` and passed the dependency-source audit. +`egui037-grid-final-main-ci-20261025-0854` passed all ten stages: i18n-report, +fmt, strict workspace all-target clippy, workspace tests, doctest, assets, layers, +WASM, parity and keytips. Actual totals are 2,463 passed, none failed and one ignored across 35 summaries. +Independent review verified the merged main behavior and the final scroll/accessibility fixes. +The local merge preserves both migration history and the reviewed upstream changes; +final code and dependency bytes match the passing CI. Subsequent status edits are prose only. +Main advanced to `17b9f6ff0125209fe707efc919690632ff49cb0b` after that CI run; this tree does +not include it. The pass above therefore does not qualify the later integration, and PR CI must +pass on the combined tree before it is described as validated. +Native/platform/performance and application E21b qualification remain open; canonical +pins and full CI do not imply that every native contract is qualified. +Human review and explicit publication approval are required. Earlier compiler logs, +source anchors, baseline inventories and proposed steps below are historical records; +this status governs current pins and acceptance. Final commit/source/CI bindings are in +`final-landing/grid-publication0865` in the migration 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 canonical6d518fc7; `egui_extras` declared at 0.37 but unused | +| Current winit / wgpu | winit 0.30.13 at canonical a517b384; 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 25da1314; 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 25da1314; 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/env.sh b/packaging/env.sh index 6f36506b6..f204ac7c9 100644 --- a/packaging/env.sh +++ b/packaging/env.sh @@ -49,7 +49,7 @@ warn() { # Copy licence and readme files that exist into a package directory. copy_docs() { local dest="$1" f - for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT; do + for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT NOTICE ATTRIBUTION.md; do if [[ -f "$ROOT/$f" ]]; then cp "$ROOT/$f" "$dest/"; fi done copy_font_licences "$dest" diff --git a/packaging/linux/flatpak/ai.storyteller.gridcraft.yml b/packaging/linux/flatpak/ai.storyteller.gridcraft.yml index 8bde5db6d..81b3cf08f 100644 --- a/packaging/linux/flatpak/ai.storyteller.gridcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.gridcraft.yml @@ -49,6 +49,8 @@ modules: - install -Dm644 packaging/linux/ai.storyteller.gridcraft.desktop /app/share/applications/ai.storyteller.gridcraft.desktop - install -Dm644 packaging/linux/ai.storyteller.gridcraft.mime.xml /app/share/mime/packages/ai.storyteller.gridcraft.xml - mkdir -p /app/share/icons && cp -R assets/app-icon/hicolor /app/share/icons/ + - install -Dm644 NOTICE /app/share/doc/gridcraft/NOTICE + - install -Dm644 ATTRIBUTION.md /app/share/doc/gridcraft/ATTRIBUTION.md - >- version=$(awk '/^\[/{p=($0=="[workspace.package]");next} p&&$1=="version"{gsub(/[" ]/,"",$3);print $3;exit}' Cargo.toml); install -d /app/share/metainfo; 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/macos/package.sh b/packaging/macos/package.sh index b45164ee0..f5551518c 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -117,6 +117,8 @@ mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" # Executable and icon carry the display name (CFBundleExecutable / CFBundleIconFile). cp "$WORK/bin/gridcraft" "$APP/Contents/MacOS/GridCraft" cp "$ROOT/assets/app-icon/gridcraft.icns" "$APP/Contents/Resources/GridCraft.icns" +# Bundled dependency font notices are installed before the app is signed. +cp "$ROOT/NOTICE" "$ROOT/ATTRIBUTION.md" "$APP/Contents/Resources/" # Licences of the embedded craft-fonts fonts (only when built with CRAFT_FONTS_DIR). copy_font_licences "$APP/Contents/Resources" sed -e "s/@VERSION@/$VERSION/g" -e "s/@SHORT_VERSION@/$SHORT_VERSION/g" \ 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..a1c2805b1 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -90,11 +90,27 @@ 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 +# Bundled dependency font notices are required even without optional craft-fonts. +Copy-Item -LiteralPath (Join-Path $Root 'NOTICE'), (Join-Path $Root 'ATTRIBUTION.md') -Destination $Docs +$HasCraftFontLicences = 1 +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. @@ -106,7 +122,7 @@ $Portable = Join-Path $TargetDir "windows-package\gridcraft-$Version-windows-$Ar Remove-Item -Recurse -Force $Portable -ErrorAction SilentlyContinue New-Item -ItemType Directory -Force -Path $Portable | Out-Null Copy-Item (Join-Path $Stage '*.exe') $Portable -foreach ($f in 'README.md', 'LICENSE', 'LICENSE-MIT', 'LICENSE-APACHE') { +foreach ($f in 'README.md', 'LICENSE', 'LICENSE-MIT', 'LICENSE-APACHE', 'NOTICE', 'ATTRIBUTION.md') { $p = Join-Path $Root $f if (Test-Path $p) { Copy-Item $p $Portable } } diff --git a/xtask/src/keytips.rs b/xtask/src/keytips.rs index 3e603addb..5b7137455 100644 --- a/xtask/src/keytips.rs +++ b/xtask/src/keytips.rs @@ -365,18 +365,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() - .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()), - ); - h.run_steps(4); - h + crate::common::os_harness(session, OperatingSystem::Nix) } fn blank() -> Session {