diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 64c13bb23..7b3b6bf5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: uses: storytold/craft-common-actions/.github/workflows/gate.yml@main with: # Same craft-fonts commit as release.yml. - craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 timeout-minutes: 90 packages: >- pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 11d44fca4..93be914f2 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: 3d9ab3a132ed0eff457e424226a435a901da30d3 + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6cc35e572..368d4f322 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -67,7 +67,7 @@ jobs: with: app-name: cadcraft app-title: CADCraft - craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 version: ${{ inputs.version }} secrets: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} diff --git a/Cargo.lock b/Cargo.lock index e5076e82e..19818cf12 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,11 +13,11 @@ dependencies = [ "byteorder", "encoding_rs", "flate2", - "foldhash", + "foldhash 0.2.0", "indexmap", "itoa", "memmap2", - "nom", + "nom 7.1.3", "oem_cp", "once_cell", "rayon", @@ -28,9 +28,9 @@ dependencies = [ [[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 = [ "enumn", "serde", @@ -39,57 +39,51 @@ dependencies = [ [[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 0.13.1", + "phf 0.14.0", "serde", "zvariant", ] [[package]] name = "accesskit_consumer" -version = "0.35.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344" dependencies = [ "accesskit", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] -name = "accesskit_consumer" -version = "0.36.0" +name = "accesskit_ios" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937" dependencies = [ "accesskit", - "hashbrown 0.16.1", -] - -[[package]] -name = "accesskit_consumer" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" -dependencies = [ - "accesskit", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", ] [[package]] name = "accesskit_macos" -version = "0.26.3" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4" dependencies = [ "accesskit", - "accesskit_consumer 0.38.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "objc2 0.5.3", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -97,9 +91,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", @@ -115,13 +109,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", @@ -129,11 +123,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", @@ -224,6 +219,7 @@ dependencies = [ "parking_lot", "percent-encoding", "windows-sys 0.61.2", + "wl-clipboard-rs", "x11rb", ] @@ -729,6 +725,7 @@ dependencies = [ "serde", "serde_json", "toml", + "web-time", ] [[package]] @@ -951,7 +948,7 @@ source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09 [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675" +source = "git+https://github.com/storytold/craft-libs?rev=c488caa9fbc2fb1a86e4674887e9b80b598ebbfa#c488caa9fbc2fb1a86e4674887e9b80b598ebbfa" dependencies = [ "egui", "serde", @@ -1086,9 +1083,8 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "ecolor" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "bytemuck", "emath", @@ -1097,9 +1093,8 @@ dependencies = [ [[package]] name = "eframe" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "ahash", "bytemuck", @@ -1122,6 +1117,7 @@ dependencies = [ "profiling", "raw-window-handle", "ron", + "send_wrapper", "serde", "static_assertions", "wasm-bindgen", @@ -1134,9 +1130,8 @@ dependencies = [ [[package]] name = "egui" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "accesskit", "ahash", @@ -1156,9 +1151,8 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "ahash", "bytemuck", @@ -1176,32 +1170,35 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "accesskit_winit", "arboard", + "block2 0.6.2", "bytemuck", "egui", + "image", "log", "objc2 0.6.5", + "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", + "parking_lot", "profiling", "raw-window-handle", "serde", "smithay-clipboard", "web-time", "webbrowser", + "windows-sys 0.61.2", "winit", ] [[package]] name = "egui_glow" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "bytemuck", "egui", @@ -1213,9 +1210,8 @@ dependencies = [ [[package]] name = "egui_kittest" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "dify", "eframe", @@ -1240,9 +1236,8 @@ checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" [[package]] name = "emath" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "bytemuck", "serde", @@ -1302,9 +1297,8 @@ dependencies = [ [[package]] name = "epaint" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "ahash", "bytemuck", @@ -1321,16 +1315,17 @@ dependencies = [ "serde", "skrifa 0.44.0", "smallvec", + "unicode-bidi", "unicode-general-category", + "unicode-properties", "unicode-segmentation", "vello_cpu", ] [[package]] name = "epaint_default_fonts" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" [[package]] name = "equivalent" @@ -1416,6 +1411,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.10" @@ -1433,6 +1434,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -1595,12 +1602,12 @@ 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", + "foldhash 0.2.0", "hashbrown 0.17.1", "log", "peniko", @@ -1755,6 +1762,15 @@ dependencies = [ "byteorder", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -1763,7 +1779,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -1772,7 +1788,7 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -2084,12 +2100,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]] @@ -2401,6 +2417,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2798,6 +2823,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "parking" version = "2.2.1" @@ -2846,6 +2881,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + [[package]] name = "phf" version = "0.11.3" @@ -2857,33 +2903,33 @@ 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 0.13.1", + "phf_shared 0.14.0", "serde", ] [[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 0.13.1", + "phf_shared 0.14.0", ] [[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 0.13.1", + "phf_shared 0.14.0", "proc-macro2", "quote", "syn 2.0.119", @@ -2900,9 +2946,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", ] @@ -3171,7 +3217,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4edc8db0d972de88e53a085b1f01fb3fbdae7bb0cb7a0ce2b2f8285a412fe3ef" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "font-types", "once_cell", "yoke", @@ -3354,6 +3400,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" @@ -3850,6 +3902,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom 8.0.0", + "petgraph", +] + [[package]] name = "type-map" version = "0.5.1" @@ -3894,6 +3957,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" @@ -3937,9 +4006,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", @@ -3952,9 +4021,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", @@ -4616,8 +4685,7 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winit" version = "0.30.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +source = "git+https://github.com/storytold/winit.git?rev=ae48c756a3b81125eb796c0cc6dccf60990d1181#ae48c756a3b81125eb796c0cc6dccf60990d1181" dependencies = [ "ahash", "android-activity", @@ -4689,6 +4757,24 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.5", + "thiserror 2.0.21", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "writeable" version = "0.6.4" @@ -5036,3 +5122,23 @@ dependencies = [ "syn 3.0.6", "winnow", ] + +[[patch.unused]] +name = "egui_demo_lib" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" + +[[patch.unused]] +name = "egui_extras" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" + +[[patch.unused]] +name = "egui_inspection" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" + +[[patch.unused]] +name = "egui_system_font_provider" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" diff --git a/Cargo.toml b/Cargo.toml index 6e1afaa06..2a1903136 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,11 +50,13 @@ encoding_rs = "0.8.35" quick-xml = "0.42" # ui -craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } -egui = "0.36" -egui_kittest = "0.36" -eframe = { version = "0.36", default-features = false } -egui-wgpu = "0.36" +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c488caa9fbc2fb1a86e4674887e9b80b598ebbfa", features = ["serde"] } +egui = { version = "0.37", default-features = false, features = ["default_fonts", "monochrome_emoji_fonts"] } +egui_kittest = "0.37" +eframe = { version = "0.37", default-features = false } +egui-wgpu = "0.37" +# Monotonic time on native and web (performance.now on wasm). +web-time = "1.1" rfd = "0.17" arboard = { version = "3.6", default-features = false } @@ -80,3 +82,21 @@ opt-level = 3 lto = "thin" codegen-units = 16 debug = 0 + +# Keep the complete egui family and native event bridge on reviewed fork commits. +[patch.crates-io] +ecolor = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +emath = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +epaint = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui-winit = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui_glow = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui_extras = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +eframe = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" } +winit = { git = "https://github.com/storytold/winit.git", rev = "ae48c756a3b81125eb796c0cc6dccf60990d1181" } diff --git a/apps/cadcraft-web/Cargo.toml b/apps/cadcraft-web/Cargo.toml index 4fb706d77..e5e86a0e3 100644 --- a/apps/cadcraft-web/Cargo.toml +++ b/apps/cadcraft-web/Cargo.toml @@ -4,6 +4,7 @@ description = "CADCraft in the browser (eframe web runner, WebGPU with WebGL2 fa version.workspace = true edition.workspace = true license.workspace = true +rust-version = "1.95" publish = false [lints] @@ -26,7 +27,7 @@ serde_json = { workspace = true } web-sys = { version = "0.3", features = ["HtmlInputElement", "FileList", "File"] } [target.'cfg(target_arch = "wasm32")'.dependencies] -eframe = { workspace = true, features = ["persistence", "default_fonts", "wgpu", "accesskit"] } +eframe = { workspace = true, features = ["persistence", "wgpu", "accesskit"] } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Location", "Navigator", "Blob", "BlobPropertyBag", "Url", "MessageEvent", "UrlSearchParams", "MediaQueryList"] } diff --git a/apps/cadcraft-web/src/host.rs b/apps/cadcraft-web/src/host.rs index 03fe15b22..c8d6380c6 100644 --- a/apps/cadcraft-web/src/host.rs +++ b/apps/cadcraft-web/src/host.rs @@ -95,7 +95,8 @@ impl Bridge { } } - /// After each frame: tell the parent when unsaved changes appear or go away. + /// After each logic callback (including a hidden tab's): tell the parent when unsaved changes + /// appear or go away. `DirtyReport` suppresses duplicates, so this is idempotent. pub fn report_dirty(&self, app: &CadApp, reported: &DirtyReport) { let dirty = app.session.docs.iter().any(|d| d.is_dirty()); if reported.0.get() != Some(dirty) && self.send(&[("type", "cadcraft:dirty".into()), ("dirty", dirty.into())]) { diff --git a/apps/cadcraft-web/src/main.rs b/apps/cadcraft-web/src/main.rs index 5b4873e72..cf996c466 100644 --- a/apps/cadcraft-web/src/main.rs +++ b/apps/cadcraft-web/src/main.rs @@ -30,16 +30,19 @@ mod web { impl eframe::App for Shell { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - self.1.take_dropped(ctx); + // Background reads are consume-based and run on hidden ticks too. Dropped files are + // input and are read from `ui` only. self.1.drain(&mut self.0); self.0.system_animations = self.3.as_ref().map(|reduced| !reduced.matches()); self.0.logic(ctx); + // `DirtyReport` suppresses duplicates, so reporting after every logic callback + // (including a hidden tab's) is idempotent and keeps the parent's guard current. if let Some((bridge, reported)) = &self.2 { bridge.report_dirty(&self.0, reported); } } fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { - self.0.raw_input_hook(raw); + self.0.raw_input_hook(ctx, raw); cadcraft_ui_egui::cmdline::capture_tab(ctx, raw); } /// Preferences (interface theme and language) survive reloads; egui state is not kept. @@ -51,10 +54,13 @@ mod web { false } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - self.0.ui(ui); - if let Some((bridge, reported)) = &self.2 { - bridge.report_dirty(&self.0, reported); + let first_pass = ui.ctx().current_pass_index() == 0; + if first_pass { + // `take_dropped` consumes the raw list; a second pass or hidden tick cannot + // re-read the same file. + self.1.take_dropped(ui.ctx()); } + self.0.ui(ui); } } diff --git a/apps/cadcraft-web/src/upload.rs b/apps/cadcraft-web/src/upload.rs index 1e5b32706..0b567f8b7 100644 --- a/apps/cadcraft-web/src/upload.rs +++ b/apps/cadcraft-web/src/upload.rs @@ -68,8 +68,9 @@ impl Inbox { } /// Read the files dropped on the page this frame (eframe hands them over unread on the web). + /// The list is taken, not cloned: a later pass or hidden tick must not read the same drop. pub fn take_dropped(&self, ctx: &egui::Context) { - for f in ctx.input(|i| i.raw.dropped_files.clone()) { + for f in ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)) { if let Some(file) = f.web_file() { self.read(file.clone()); } diff --git a/apps/cadcraft/Cargo.toml b/apps/cadcraft/Cargo.toml index 7a46fa9bc..8d9363a67 100644 --- a/apps/cadcraft/Cargo.toml +++ b/apps/cadcraft/Cargo.toml @@ -4,6 +4,7 @@ description = "CADCraft desktop app" version.workspace = true edition.workspace = true license.workspace = true +rust-version = "1.95" publish = false default-run = "cadcraft" @@ -16,7 +17,7 @@ cadcraft-ui-egui = { workspace = true } cadcraft-io = { workspace = true } # For the shared per-session control-token helper (control_server.rs authentication). cadcraft-mcp = { workspace = true } -eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links", "persistence"] } +eframe = { workspace = true, features = ["clipboard", "wgpu", "accesskit", "wayland", "x11", "links", "persistence"] } egui = { workspace = true } serde_json = { workspace = true } log = { workspace = true } diff --git a/apps/cadcraft/src/main.rs b/apps/cadcraft/src/main.rs index 157294ac5..393bfd556 100644 --- a/apps/cadcraft/src/main.rs +++ b/apps/cadcraft/src/main.rs @@ -66,36 +66,44 @@ fn follow_desktop(raw: &mut egui::RawInput, desktop: Option) { impl eframe::App for App { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - #[cfg(target_os = "macos")] - { - if self.native_menu.is_none() && std::env::var_os("CADCRAFT_NO_NATIVE_MENU").is_none() { - self.native_menu = Some(native_menu::NativeMenu::install(&mut self.cad)); - } - if let Some(m) = &mut self.native_menu { - m.poll(&mut self.cad, ctx); - } - } self.cad.system_animations = self.system_animations.current(); + // Consume control/open/screenshot queues and time-based autosave, including hidden ticks. self.cad.logic(ctx); - self.spacemouse.poll(&mut self.cad, ctx); - self.persist_workspace(false); + // Host frame work runs here, once per frame, and so also on a hidden logic-only tick (which + // never reaches `App::ui`): native menus stay current, the spacemouse still navigates and a + // workspace changed while hidden is persisted. `current_pass_index()` is zero on the first + // pass and on every hidden tick, and non-zero on later passes of a multi-pass frame. + if ctx.current_pass_index() == 0 { + #[cfg(target_os = "macos")] + { + if self.native_menu.is_none() && std::env::var_os("CADCRAFT_NO_NATIVE_MENU").is_none() { + self.native_menu = Some(native_menu::NativeMenu::install(&mut self.cad)); + } + if let Some(m) = &mut self.native_menu { + m.poll(&mut self.cad, ctx); + } + } + self.spacemouse.poll(&mut self.cad, ctx); + // After `cad.logic`, so a control command's workspace change is saved in the same frame. + self.persist_workspace(false); + } if std::mem::take(&mut self.cad.quit_requested) { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } } fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { follow_desktop(raw, self.desktop_theme.current()); - self.cad.raw_input_hook(raw); + self.cad.raw_input_hook(ctx, raw); cadcraft_ui_egui::cmdline::capture_tab(ctx, raw); } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { self.cad.ui(ui); - self.persist_workspace(false); } /// A normal exit: the workspace is saved, and this run's autosave files are not needed (a /// crash leaves them for recovery). fn on_exit(&mut self) { self.persist_workspace(true); + self.cad.cancel_screenshots(); self.cad.shutdown_autosave(); } /// Preferences (the interface theme) survive restarts; window and egui state are not kept. @@ -273,6 +281,9 @@ fn main() -> eframe::Result { .with_title_shown(false) .with_app_id(APP_ID), persist_window: false, + // CADCraft installs its own capped font chain (`theme::install_fonts`); never run + // system-font discovery as a hidden fallback. + system_font_fallback: false, // A stencil buffer clips the contents of clipped layout viewports on the GPU. stencil_buffer: STENCIL_BITS, ..Default::default() diff --git a/apps/cadcraft/src/native_menu.rs b/apps/cadcraft/src/native_menu.rs index 727043558..16082a002 100644 --- a/apps/cadcraft/src/native_menu.rs +++ b/apps/cadcraft/src/native_menu.rs @@ -15,6 +15,9 @@ pub struct NativeMenu { submenus: Vec<(String, Submenu)>, predefined: Vec<(&'static str, PredefinedMenuItem)>, language: &'static str, + /// Monotonic time of the last enablement refresh. `NEG_INFINITY` makes the first poll refresh + /// immediately: `now_ms` starts near zero, so the old `0.0` sentinel suppressed the first + /// ~300 ms of menu enablement. last_refresh: f64, /// The recent files File ▸ Open Recent lists now (it is rebuilt when they change). recent: Vec, @@ -66,7 +69,7 @@ impl NativeMenu { submenus.push((title, sub)); } menu.init_for_nsapp(); - Self { _menu: menu, items, submenus, predefined, language, last_refresh: 0.0, recent: app.ui.recent_files.clone(), counter } + Self { _menu: menu, items, submenus, predefined, language, last_refresh: f64::NEG_INFINITY, recent: app.ui.recent_files.clone(), counter } } /// Rebuild File ▸ Open Recent after a drawing was opened or the list was cleared: the native @@ -113,7 +116,7 @@ impl NativeMenu { } } let now = cadcraft_ui_egui::now_ms(); - if now - self.last_refresh < 300.0 { + if !refresh_due(now, self.last_refresh) { return; } self.last_refresh = now; @@ -124,6 +127,13 @@ impl NativeMenu { } } +/// The periodic enablement refresh is due every 300 ms. Split out so the first-poll sentinel is +/// testable without a macOS menu: `now_ms` starts near zero, so a `0.0` initial value would +/// suppress the first refresh for 300 ms. +fn refresh_due(now: f64, last_refresh: f64) -> bool { + now - last_refresh >= 300.0 +} + fn build( parent: &Submenu, entries: &[Entry], @@ -150,3 +160,16 @@ fn build( } } } + +#[cfg(test)] +mod tests { + use super::refresh_due; + + #[test] + fn the_first_poll_refreshes_immediately() { + assert!(refresh_due(0.5, f64::NEG_INFINITY), "a fresh menu refreshes on its first poll"); + assert!(!refresh_due(0.5, 0.0), "the old 0.0 sentinel delayed the first refresh"); + assert!(!refresh_due(299.9, 0.0)); + assert!(refresh_due(300.0, 0.0)); + } +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index b6998f424..c29f8252f 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -375,6 +375,7 @@ fn run_close(s: &mut Session, p: &Value) -> Result { if s.active >= s.docs.len() { s.active = s.docs.len().saturating_sub(1); } + s.bump_doc_epoch(); ok() } @@ -382,6 +383,7 @@ fn run_closeall(s: &mut Session, _p: &Value) -> Result { s.cancel(); s.docs.clear(); s.active = 0; + s.bump_doc_epoch(); ok() } @@ -493,6 +495,11 @@ fn run_switch(s: &mut Session, p: &Value) -> Result { } s.cancel(); s.active = i; + // Bump even when `i` is already active. This is intentionally conservative: the command + // cancels the active command, and a screenshot client can only see observed state, not + // whether the switch was a no-op. Rejecting an outstanding capture is safer than returning + // an image whose queueing identity the client cannot distinguish from a real switch. + s.bump_doc_epoch(); ok() } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1bf8e0059..7c26a0610 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -637,6 +637,9 @@ impl Default for MLeaderOptions { pub struct Session { pub docs: Vec, pub active: usize, + /// Monotonic count of changes to the open drawings and the active one. A screenshot queued + /// across a switch is rejected even when a later switch returns to the same drawing. + pub doc_epoch: u64, pub settings: Settings, pub running: Option, /// A script is running ([`Session::script`]); a SCRIPT command inside it is refused. @@ -717,6 +720,7 @@ impl Session { Session { docs: Vec::new(), active: 0, + doc_epoch: 0, settings: Settings::default(), running: None, in_script: false, @@ -757,6 +761,7 @@ impl Session { self.end_running(); self.docs.push(st); self.active = self.docs.len() - 1; + self.bump_doc_epoch(); self.active } pub fn open_drawing(&mut self, d: Drawing, title: &str, path: Option) -> usize { @@ -771,8 +776,14 @@ impl Session { self.end_running(); self.docs.push(st); self.active = self.docs.len() - 1; + self.bump_doc_epoch(); self.active } + /// Record that which drawing is open or active changed (screenshot binding). + pub fn bump_doc_epoch(&mut self) { + self.doc_epoch = self.doc_epoch.wrapping_add(1); + } + /// A command still waiting for input belongs to the drawing it started in: end it there /// before another drawing becomes current, so its undo step lands in that drawing. fn end_running(&mut self) { @@ -790,6 +801,7 @@ impl Session { Err(_) => { self.docs.truncate(n); self.active = self.active.min(self.docs.len().saturating_sub(1)); + self.bump_doc_epoch(); Err(EngineError::Internal("open".into(), "panic".into())) } } diff --git a/crates/fonts/src/complex.rs b/crates/fonts/src/complex.rs index dafabdc7c..0f13b9bd3 100644 --- a/crates/fonts/src/complex.rs +++ b/crates/fonts/src/complex.rs @@ -8,7 +8,6 @@ use std::ops::Range; use std::panic::{AssertUnwindSafe, catch_unwind}; -use std::sync::Arc; use cadcraft_geom::Vec2; @@ -183,9 +182,9 @@ struct Glyph { /// Shape characters `r` of `cs` (one direction, one face) into glyphs in display order. fn shape_run(face: &Face, cs: &[char], r: Range, rtl: bool) -> Vec { let run = cs.get(r.clone()).unwrap_or_default(); - // Shares the face's bytes instead of copying them. - let bytes: Arc + Send + Sync> = face.bytes.clone(); - let Some(font) = harfrust::Font::new(bytes, face.index) else { return Vec::new() }; + // Keep the shared font bytes: shaping a run must not clone the entire font file. + let source = harfrust::font::Blob::Shared(face.bytes.clone()); + let Some(font) = harfrust::Font::new(source, face.index) else { return Vec::new() }; let shaper = harfrust::ShaperFont::new(&font); let mut buf = harfrust::Buffer::new(); let arabic = run.iter().any(|c| matches!(*c, '\u{0600}'..='\u{08FF}')); @@ -200,11 +199,10 @@ fn shape_run(face: &Face, cs: &[char], r: Range, rtl: bool) -> Vec } buf.set_direction(if rtl { harfrust::Direction::RightToLeft } else { harfrust::Direction::LeftToRight }); buf.guess_segment_properties(); - // Without a plan, shaping fails only on an already shaped buffer or an unset direction. if harfrust::shape(&shaper, &mut buf, harfrust::ShapeOptions::new()).is_err() { return Vec::new(); } - let k = 1000.0 / f64::from(font.units_per_em().max(1)); + let k = 1000.0 / f64::from(shaper.units_per_em().max(1)); buf.glyph_infos() .iter() .zip(buf.glyph_positions()) diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 18721a963..12abfdd99 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -25,8 +25,11 @@ serde_json = { workspace = true } log = { workspace = true } chrono = { workspace = true } image = { workspace = true } +web-time = { workspace = true } [dev-dependencies] +# The workspace `egui` dependency enables `default_fonts` and `monochrome_emoji_fonts` in +# production too; the tests exercise the same installed chain as the app. egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } # Validates the canvas WGSL and its GLSL ES 3.0 (WebGL2) translation in tests (same naga wgpu uses). naga = { version = "30", features = ["wgsl-in", "glsl-out"] } diff --git a/crates/ui-egui/src/aliasdlg.rs b/crates/ui-egui/src/aliasdlg.rs index e643edcfb..7cb91f5f7 100644 --- a/crates/ui-egui/src/aliasdlg.rs +++ b/crates/ui-egui/src/aliasdlg.rs @@ -151,7 +151,7 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { let warn = Tokens::get().warn; let (mut ok, mut cancel, mut apply_clicked) = (false, false, false); let Some(st) = app.alias_dlg.as_mut() else { return }; - let id = egui::Id::new("aliasedit_dialog"); + let id = egui::Id::unique("aliasedit_dialog"); if std::mem::take(&mut st.raise) { ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, id)); } @@ -161,8 +161,8 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { None => ui.label(crate::tl!("No alias file loaded. REINIT replaces these aliases with a file's.")), }; ui.horizontal(|ui| { - ui.label(crate::tl!("Filter")); - ui.text_edit_singleline(&mut st.filter); + let label = ui.label(crate::tl!("Filter")); + ui.text_edit_singleline(&mut st.filter).labelled_by(label.id); }); let f = st.filter.trim().to_ascii_lowercase(); let mut names: Vec<&String> = st.user.keys().chain(built.keys().filter(|a| !st.user.contains_key(*a))).collect(); @@ -226,11 +226,11 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { if let Some(form) = st.form.as_mut() { ui.group(|ui| { egui::Grid::new("alias_form").num_columns(2).show(ui, |ui| { - ui.label(crate::tl!("Alias")); - ui.text_edit_singleline(&mut form.alias); + let alias_label = ui.label(crate::tl!("Alias")); + ui.text_edit_singleline(&mut form.alias).labelled_by(alias_label.id); ui.end_row(); - ui.label(crate::tl!("Command")); - ui.text_edit_singleline(&mut form.command); + let command_label = ui.label(crate::tl!("Command")); + ui.text_edit_singleline(&mut form.command).labelled_by(command_label.id); ui.end_row(); }); // A few commands starting with what was typed, to pick from. diff --git a/crates/ui-egui/src/animation.rs b/crates/ui-egui/src/animation.rs index 75e28a706..3e2f3ad78 100644 --- a/crates/ui-egui/src/animation.rs +++ b/crates/ui-egui/src/animation.rs @@ -75,7 +75,7 @@ struct Wheel { fn wheel(events: &[egui::Event], opts: &egui::InputOptions, page: f32) -> Wheel { let (mut scroll, mut zoom_points) = (egui::Vec2::ZERO, 0.0); for e in events { - let egui::Event::MouseWheel { unit, delta, phase: egui::TouchPhase::Move, modifiers } = e else { continue }; + let egui::Event::MouseWheel { unit, delta, phase: egui::TouchPhase::Move, modifiers, .. } = e else { continue }; let per = match unit { egui::MouseWheelUnit::Point => 1.0, egui::MouseWheelUnit::Line => opts.line_scroll_speed, @@ -142,7 +142,7 @@ mod tests { use super::*; fn notch(unit: egui::MouseWheelUnit, delta: egui::Vec2, modifiers: egui::Modifiers) -> egui::Event { - egui::Event::MouseWheel { unit, delta, phase: egui::TouchPhase::Move, modifiers } + egui::Event::MouseWheel { unit, delta, phase: egui::TouchPhase::Move, source: egui::MouseWheelSource::Unknown, modifiers } } #[test] @@ -194,7 +194,13 @@ mod tests { assert_eq!(zoomed.scroll, egui::Vec2::ZERO); assert!((zoomed.zoom - (o.scroll_zoom_speed * o.line_scroll_speed).exp()).abs() < 1e-6 && zoomed.zoom > 1.0); // A touchpad's begin and end markers carry no scroll. - let ended = egui::Event::MouseWheel { unit: MouseWheelUnit::Point, delta: vec2(0.0, 9.0), phase: egui::TouchPhase::End, modifiers: none }; + let ended = egui::Event::MouseWheel { + unit: MouseWheelUnit::Point, + delta: vec2(0.0, 9.0), + phase: egui::TouchPhase::End, + source: egui::MouseWheelSource::Unknown, + modifiers: none, + }; assert_eq!(wheel(&[ended], &o, 500.0), Wheel { scroll: egui::Vec2::ZERO, zoom: 1.0 }); // Hostile deltas scroll and zoom nothing. for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { diff --git a/crates/ui-egui/src/autosave.rs b/crates/ui-egui/src/autosave.rs index a0db8d0d7..e63bf1cbf 100644 --- a/crates/ui-egui/src/autosave.rs +++ b/crates/ui-egui/src/autosave.rs @@ -150,7 +150,7 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) { let mut open = None; let mut discard = None; let mut later = false; - egui::Modal::new(egui::Id::new("cc_drawing_recovery")).show(ctx, |ui| { + egui::Modal::new(egui::Id::unique("cc_drawing_recovery")).show(ctx, |ui| { ui.set_width(460.0); ui.label(RichText::new("Drawing Recovery").strong()); ui.add_space(4.0); @@ -204,7 +204,7 @@ pub fn gpu_notice(app: &mut CadApp, ctx: &egui::Context) { let t = Tokens::get(); let mut dismiss = false; egui::Window::new("Graphics error") - .id(egui::Id::new("cc_gpu_notice")) + .id(egui::Id::unique("cc_gpu_notice")) .collapsible(false) .resizable(false) .anchor(egui::Align2::CENTER_TOP, vec2(0.0, 48.0)) diff --git a/crates/ui-egui/src/bedit.rs b/crates/ui-egui/src/bedit.rs index 0166e72ae..6137d5ccc 100644 --- a/crates/ui-egui/src/bedit.rs +++ b/crates/ui-egui/src/bedit.rs @@ -51,7 +51,7 @@ pub fn authoring_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) .unwrap_or_else(|| crate::i18n::t("This drawing").to_string()); let mut choice: Option<&str> = None; egui::Window::new(crate::tl!("Open in Block Editor")) - .id(egui::Id::new("Open in Block Editor")) + .id(egui::Id::unique("Open in Block Editor")) .open(open) .collapsible(false) .resizable(false) @@ -165,15 +165,15 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { let description = existing.as_deref().and_then(|n| d.block(n)).map(|b| b.description.clone()).unwrap_or_default(); let (mut ok, mut cancel) = (false, false); egui::Window::new(crate::tl!("Edit Block Definition")) - .id(egui::Id::new("Edit Block Definition")) + .id(egui::Id::unique("Edit Block Definition")) .open(open) .collapsible(false) .resizable(false) .pivot(egui::Align2::CENTER_CENTER) .default_pos(ctx.content_rect().center()) .show(ctx, |ui| { - ui.label(crate::tl!("Block to create or edit")); - let edit = ui.add(egui::TextEdit::singleline(&mut app.bedit.name).desired_width(460.0)); + let name_label = ui.label(crate::tl!("Block to create or edit")); + let edit = ui.add(egui::TextEdit::singleline(&mut app.bedit.name).desired_width(460.0)).labelled_by(name_label.id); if edit.changed() { app.bedit.error = None; } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 983214190..0946f8c3c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1354,7 +1354,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { // draws the others. let tiles = tile_rects(app, full); let rect = tiles.iter().find(|(_, tile)| tile.active).map_or(full, |(r, _)| *r); - let resp = ui.interact(full, ui.id().with("canvas"), Sense::click_and_drag()); + let resp = ui.interact(full, ui.scope_id().with("canvas"), Sense::click_and_drag()); let painter = ui.painter_at(rect); app.canvas.rect = Some(rect); app.session.viewport_px = (f64::from(rect.width()).max(1.0), f64::from(rect.height()).max(1.0)); @@ -2803,7 +2803,7 @@ pub(crate) mod tests { } fn wheel(unit: egui::MouseWheelUnit, dy: f32, modifiers: egui::Modifiers) -> egui::Event { - egui::Event::MouseWheel { unit, delta: vec2(0.0, dy), phase: egui::TouchPhase::Move, modifiers } + egui::Event::MouseWheel { unit, delta: vec2(0.0, dy), phase: egui::TouchPhase::Move, source: egui::MouseWheelSource::Unknown, modifiers } } /// Wheel events convert to points as egui does; ⌘/Ctrl (egui's pinch zoom) and ⇧ (a sideways diff --git a/crates/ui-egui/src/canvas/blockedit.rs b/crates/ui-egui/src/canvas/blockedit.rs index d30d23140..e8d981b74 100644 --- a/crates/ui-egui/src/canvas/blockedit.rs +++ b/crates/ui-egui/src/canvas/blockedit.rs @@ -11,7 +11,7 @@ use crate::CadApp; use crate::theme::Tokens; fn id() -> egui::Id { - egui::Id::new("cadcraft-block-editor-bar") + egui::Id::unique("cadcraft-block-editor-bar") } /// The block the Block Editor is editing in the drawing shown, and whether it has unsaved changes. diff --git a/crates/ui-egui/src/canvas/compare.rs b/crates/ui-egui/src/canvas/compare.rs index aae585caa..8351d0250 100644 --- a/crates/ui-egui/src/canvas/compare.rs +++ b/crates/ui-egui/src/canvas/compare.rs @@ -26,7 +26,7 @@ struct Cache { } fn id() -> egui::Id { - egui::Id::new("cadcraft-compare-overlay") + egui::Id::unique("cadcraft-compare-overlay") } /// Draw the comparison over the canvas when it was made for the drawing and tab shown. diff --git a/crates/ui-egui/src/capture.rs b/crates/ui-egui/src/capture.rs new file mode 100644 index 000000000..557bf60f5 --- /dev/null +++ b/crates/ui-egui/src/capture.rs @@ -0,0 +1,1010 @@ +//! Bounded, one-shot window screenshots for `ui.screenshot`. +//! +//! The request record owns the output path, reply, deadline, document/focus identity and the +//! geometry it was admitted against. The shared `craft_ui::capture::CaptureSlot` owns the strong +//! `egui::Context`; its screenshot callback holds only a weak reference and never writes a file. +//! Files are encoded and written only from the logic drain, after the deadline and every identity +//! check have passed. + +use std::sync::mpsc::Sender; + +use craft_ui::capture::CaptureSlot; +use egui::ViewportCommand; +use serde_json::{Value, json}; +use web_time::{Duration, Instant}; + +use crate::CadApp; + +/// At most this many queued, in-flight or completed-but-not-yet-drained requests. +pub(crate) const MAX_REQUESTS: usize = 4; +/// CADCraft keeps its stricter 64 MiB per-image bound (the suite default is 128 MiB). +pub(crate) const MAX_IMAGE_BYTES: usize = 64 * 1024 * 1024; +/// CADCraft keeps the aggregate reservation at the same 64 MiB bound. Completed but undrained +/// images still own their reservation, so they count against this limit. +pub(crate) const MAX_TOTAL_BYTES: usize = 64 * 1024 * 1024; + +const READY_DELAY: Duration = Duration::from_millis(120); +/// Accepted settled passes (never discarded passes or hidden logic ticks). +const SETTLE_PASSES: u32 = 3; +const DEADLINE: Duration = Duration::from_secs(8); +const REBUILD_WAIT: Duration = Duration::from_secs(6); +const REPAINT_INTERVAL: Duration = Duration::from_millis(16); + +/// The host surface as reported by one `RawInput`. `RawInput` is the fresh source on hidden +/// logic-only ticks; `Context::input`/`viewport_rect` describe the last UI pass and are stale +/// there. +/// +/// The equality used for the capture epoch is deliberately strict: OS focus and visibility +/// (`window_focused`, `minimized`, `occluded`) are part of it even though they are not geometry. +/// A focus or visibility transition can change what the presented frame contains (menu state, +/// active-window styling, a hidden surface), so a capture admitted before it is rejected rather +/// than risking an image from another epoch. Do not narrow this to size/DPI/zoom without a +/// replacement visibility check and a measured acceptance path. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct SurfaceGeometry { + /// Logical content size in points. A minimized window keeps its last known size so a forced + /// hidden paint can still be requested; `None` only before any size was ever reported. + viewport: Option, + /// Native pixels per point times egui's interface zoom. + pixels_per_point: f32, + max_texture_side: usize, + minimized: bool, + occluded: bool, + window_focused: bool, +} + +impl SurfaceGeometry { + fn valid_size(size: egui::Vec2) -> Option { + (size.x.is_finite() && size.y.is_finite() && size.x > 0.0 && size.y > 0.0).then_some(size) + } + + /// The fresh raw hook's surface. Called from `raw_input_hook`, so it runs on both UI and + /// hidden logic-only callbacks and never reads the stale `InputState` rectangle. + fn from_raw(raw: &egui::RawInput, ctx: &egui::Context, previous: Option) -> Option { + let info = raw.viewports.get(&raw.viewport_id).cloned().unwrap_or_default(); + // `InputState::viewport_rect` is `RawInput::screen_rect`, falling back to the previous + // pass's rect; the pass snapshot uses the same source so the two agree bit for bit. The + // screenshot is of the UI content rect (what kittest renders and the plan binds to), so + // remove the safe-area insets here as `InputState::content_rect` does. + let insets = raw.safe_area_insets.unwrap_or_else(|| ctx.input(|input| input.safe_area_insets())); + let rect = raw.screen_rect.map(|rect| rect - insets); + let viewport = rect.and_then(|rect| Self::valid_size(rect.size())).or_else(|| previous.and_then(|previous| previous.viewport)); + let zoom = ctx.zoom_factor(); + if !zoom.is_finite() || zoom <= 0.0 { + return None; + } + let pixels_per_point = info + .native_pixels_per_point + .map(|native| native * zoom) + .filter(|pixels| pixels.is_finite() && *pixels > 0.0) + .or_else(|| previous.map(|previous| previous.pixels_per_point)) + .unwrap_or_else(|| ctx.pixels_per_point()); + if !pixels_per_point.is_finite() || pixels_per_point <= 0.0 { + return None; + } + let max_texture_side = raw.max_texture_side.filter(|side| *side > 0).unwrap_or_else(|| ctx.input(|input| input.max_texture_side)); + let minimized = info.minimized.unwrap_or_else(|| previous.is_some_and(|previous| previous.minimized)); + let occluded = info.occluded.unwrap_or_else(|| previous.is_some_and(|previous| previous.occluded)); + Some(Self { viewport, pixels_per_point, max_texture_side, minimized, occluded, window_focused: raw.focused }) + } + + /// The settled surface of a UI pass: the content rect and ppp this pass renders (and is + /// presented) with. The saved interface zoom takes effect at the start of a pass, so this is + /// the only scale the presented frame can be expected to have. + fn from_pass(ctx: &egui::Context, previous: Option) -> Option { + let content = ctx.input(|input| input.content_rect().size()); + let viewport = Self::valid_size(content).or_else(|| previous.and_then(|previous| previous.viewport)); + let pixels_per_point = ctx.pixels_per_point(); + if !pixels_per_point.is_finite() || pixels_per_point <= 0.0 { + return None; + } + let max_texture_side = ctx.input(|input| input.max_texture_side); + let minimized = ctx.input(|input| input.viewport().minimized).unwrap_or_else(|| previous.is_some_and(|previous| previous.minimized)); + let occluded = ctx.input(|input| input.viewport().occluded).unwrap_or_else(|| previous.is_some_and(|previous| previous.occluded)); + let window_focused = ctx.input(|input| input.focused); + Some(Self { viewport, pixels_per_point, max_texture_side, minimized, occluded, window_focused }) + } + + fn expected_size(self) -> Option<[usize; 2]> { + let viewport = self.viewport?; + if !viewport.x.is_finite() || !viewport.y.is_finite() || viewport.x <= 0.0 || viewport.y <= 0.0 { + return None; + } + let max = if self.max_texture_side == 0 { usize::MAX } else { self.max_texture_side }; + let axis = |points: f32| -> Option { + let pixels = (f64::from(points) * f64::from(self.pixels_per_point)).round(); + if !pixels.is_finite() || pixels <= 0.0 || pixels > usize::MAX as f64 { + return None; + } + Some((pixels as usize).min(max)) + }; + Some([axis(viewport.x)?, axis(viewport.y)?]) + } + + fn expected_bytes(self) -> Option { + let [width, height] = self.expected_size()?; + width.checked_mul(height)?.checked_mul(std::mem::size_of::()) + } +} + +/// The immutable document and widget-focus identity a capture is bound to. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct CaptureIdentity { + document: Option, + /// Monotonic count of drawing switches in the engine, so A→B→A is still a change. + document_epoch: u64, + focus: Option, +} + +/// Viewport geometry plus the immutable document and widget-focus identity a capture is bound to. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct CaptureGeometry { + surface: SurfaceGeometry, + identity: CaptureIdentity, +} + +impl CaptureGeometry { + fn expected_size(self) -> Option<[usize; 2]> { + self.surface.expected_size() + } + + fn expected_bytes(self) -> Option { + self.surface.expected_bytes() + } +} + +struct Shot { + token: u64, + path: Option, + reply: Sender, + deadline: Instant, + ready_at: Instant, + frames: u32, + issued: bool, + identity: CaptureIdentity, + identity_epoch: u64, + /// The surface epoch at issue; a later change invalidates the in-flight image. + surface_epoch: u64, + geometry: SurfaceGeometry, + expected: [usize; 2], + reserved: usize, + slot: Option, + callback: Option, +} + +/// All admitted screenshots. `requests` includes queued, issued and completed-but-undrained +/// captures; nothing is removed before it has been replied to or timed out. +#[derive(Default)] +pub(crate) struct Shots { + token: u64, + requests: Vec, +} + +impl Shots { + pub(crate) fn is_empty(&self) -> bool { + self.requests.is_empty() + } + + pub(crate) fn has_unissued(&self) -> bool { + self.requests.iter().any(|shot| !shot.issued) + } + + fn reserved(&self) -> usize { + self.requests.iter().fold(0usize, |sum, shot| sum.saturating_add(shot.reserved)) + } + + #[cfg(test)] + fn len(&self) -> usize { + self.requests.len() + } +} + +fn next_token(shots: &mut Shots) -> u64 { + let mut token = shots.token.wrapping_add(1); + if token == 0 { + token = 1; + } + shots.token = token; + token +} + +fn error_reply(token: u64, message: impl std::fmt::Display) -> Value { + json!({"ok": false, "error": message.to_string(), "request": token}) +} + +fn reject(reply: Sender, token: u64, message: impl std::fmt::Display) { + let _ = reply.send(error_reply(token, message)); +} + +fn active_document(app: &CadApp) -> Option { + app.session.docs.get(app.session.active).map(|doc| doc.uid) +} + +fn current_focus(ctx: &egui::Context) -> Option { + ctx.memory(|memory| memory.focused()) +} + +fn observed_identity(app: &CadApp, ctx: &egui::Context) -> CaptureIdentity { + CaptureIdentity { document: active_document(app), document_epoch: app.session.doc_epoch, focus: current_focus(ctx) } +} + +/// Store a fresh observation. The surface and identity epochs are independent: a surface change +/// re-baselines a queued request at issue, while any identity change rejects it, including one +/// that returns to the same document or focused widget. +fn observe(app: &mut CadApp, surface: SurfaceGeometry, identity: CaptureIdentity) { + let previous = app.capture_geometry; + let surface_changed = previous.is_none_or(|previous| previous.surface != surface); + let identity_changed = previous.is_none_or(|previous| previous.identity != identity); + if surface_changed || identity_changed { + app.capture_geometry = Some(CaptureGeometry { surface, identity }); + } + if surface_changed { + app.capture_epoch = app.capture_epoch.wrapping_add(1); + } + if identity_changed { + app.capture_identity_epoch = app.capture_identity_epoch.wrapping_add(1); + } +} + +/// Refresh the fresh raw-input surface/identity snapshot. Called from `raw_input_hook`, so it runs +/// on both UI and hidden logic-only callbacks. +pub(crate) fn note_raw_geometry(app: &mut CadApp, ctx: &egui::Context, raw: &egui::RawInput) { + let previous = app.capture_geometry.map(|geometry| geometry.surface); + let Some(surface) = SurfaceGeometry::from_raw(raw, ctx, previous) else { + if app.capture_geometry.is_some() { + app.capture_geometry = None; + app.capture_epoch = app.capture_epoch.wrapping_add(1); + app.capture_identity_epoch = app.capture_identity_epoch.wrapping_add(1); + } + return; + }; + let identity = observed_identity(app, ctx); + observe(app, surface, identity); +} + +/// Refresh the settled geometry of a UI pass (content rect and ppp), from every pass so a pass +/// that applies the saved interface zoom is the one admission and issuance use. +/// +/// An unusable pass snapshot invalidates the retained surface exactly like the raw path: the +/// previous geometry must not survive a pass whose pixels-per-point is invalid, or a later +/// admission could be bound to a surface no current pass renders. Both epochs grow so queued and +/// in-flight captures are re-based or rejected. +pub(crate) fn note_pass_geometry(app: &mut CadApp, ctx: &egui::Context) { + let previous = app.capture_geometry.map(|geometry| geometry.surface); + let Some(surface) = SurfaceGeometry::from_pass(ctx, previous) else { + if app.capture_geometry.is_some() { + app.capture_geometry = None; + app.capture_epoch = app.capture_epoch.wrapping_add(1); + app.capture_identity_epoch = app.capture_identity_epoch.wrapping_add(1); + } + return; + }; + let identity = observed_identity(app, ctx); + observe(app, surface, identity); +} + +/// Refresh only the binding identity. Call after anything that can change the drawing or the +/// keyboard focus within one callback (each control command, opening completion), so an +/// intervening A→B→A transition is still seen. +pub(crate) fn note_identity(app: &mut CadApp, ctx: &egui::Context) { + let Some(geometry) = app.capture_geometry else { return }; + let identity = observed_identity(app, ctx); + observe(app, geometry.surface, identity); +} + +/// Admit one `ui.screenshot` request without issuing it. Counts queued, in-flight and +/// completed-but-undrained requests and reserves the expected raw image bytes before any capture. +/// The reservation is the admission estimate; issuance re-bases it on the settled pass. +pub(crate) fn admit(app: &mut CadApp, ctx: &egui::Context, path: Option, reply: Sender) { + note_identity(app, ctx); + let token = next_token(&mut app.shots); + let Some(geometry) = app.capture_geometry else { + return reject(reply, token, "screenshot failed: the window geometry is not known yet"); + }; + let Some(expected) = geometry.expected_size() else { + return reject(reply, token, "screenshot failed: the window has no capturable size yet"); + }; + let Some(reserved) = geometry.expected_bytes() else { + return reject(reply, token, "screenshot failed: the window size overflows the screenshot budget"); + }; + if reserved > MAX_IMAGE_BYTES { + return reject( + reply, + token, + format!( + "screenshot budget exceeded: {reserved} bytes for one {}×{} image, over the {MAX_IMAGE_BYTES}-byte per-image limit", + expected[0], expected[1] + ), + ); + } + if app.shots.requests.len() >= MAX_REQUESTS { + return reject(reply, token, format!("screenshot budget exceeded: {MAX_REQUESTS} requests are already outstanding")); + } + let total = app.shots.reserved().saturating_add(reserved); + if total > MAX_TOTAL_BYTES { + return reject( + reply, + token, + format!("screenshot budget exceeded: {total} bytes reserved by outstanding requests, over the {MAX_TOTAL_BYTES}-byte limit"), + ); + } + let (slot, callback) = CaptureSlot::new(ctx, ctx.viewport_id(), MAX_IMAGE_BYTES); + let now = Instant::now(); + app.shots.requests.push(Shot { + token, + path, + reply, + deadline: now + DEADLINE, + ready_at: now + READY_DELAY, + frames: 0, + issued: false, + identity: geometry.identity, + identity_epoch: app.capture_identity_epoch, + surface_epoch: app.capture_epoch, + geometry: geometry.surface, + expected, + reserved, + slot: Some(slot), + callback: Some(callback), + }); + ctx.request_repaint(); +} + +fn identity_reason(app: &CadApp, ctx: &egui::Context, shot: &Shot) -> Option<&'static str> { + if active_document(app) != shot.identity.document { + Some("the document changed while the capture was queued") + } else if current_focus(ctx) != shot.identity.focus { + Some("the keyboard focus changed while the capture was queued") + } else if app.session.doc_epoch != shot.identity.document_epoch { + Some("the drawing changed while the capture was queued") + } else { + None + } +} + +/// Any observed identity change since admission, including a transition that returned to the +/// same document or widget (`capture_identity_epoch` only ever grows). +fn identity_change(app: &CadApp, ctx: &egui::Context, shot: &Shot) -> Option<&'static str> { + if app.capture_identity_epoch == shot.identity_epoch { + return None; + } + Some(identity_reason(app, ctx, shot).unwrap_or("the drawing or keyboard focus changed while the capture was queued")) +} + +/// Issue settled requests from the first non-discarded UI pass. +/// +/// egui discards a pass only when `will_discard()` says so, and the final pass of a multi-pass +/// frame reports false; a hidden `run_logic` tick never reaches `issue` at all. +pub(crate) fn issue(app: &mut CadApp, ctx: &egui::Context) { + if ctx.will_discard() { + return; + } + // The draw may have moved the focus or switched drawings: observe that before judging. + note_identity(app, ctx); + let now = Instant::now(); + let building = app.canvas.building(); + let mut failures: Vec<(usize, String)> = Vec::new(); + for i in 0..app.shots.requests.len() { + if app.shots.requests[i].issued { + continue; + } + app.shots.requests[i].frames = app.shots.requests[i].frames.saturating_add(1); + let shot = &app.shots.requests[i]; + if now < shot.ready_at || shot.frames < SETTLE_PASSES || (building && now < shot.ready_at + REBUILD_WAIT) { + continue; + } + if let Some(reason) = identity_change(app, ctx, shot) { + failures.push((i, reason.to_string())); + continue; + } + // Re-base the expected image on the settled pass. The admission estimate can predate the + // saved interface zoom, which egui applies at the start of a later pass. + let Some(current) = app.capture_geometry else { + failures.push((i, "screenshot failed: the window geometry is no longer known".into())); + continue; + }; + let Some(expected) = current.expected_size() else { + failures.push((i, "screenshot failed: the window has no capturable size".into())); + continue; + }; + let Some(reserved) = current.expected_bytes() else { + failures.push((i, "screenshot failed: the window size overflows the screenshot budget".into())); + continue; + }; + if reserved > MAX_IMAGE_BYTES { + failures.push(( + i, + format!( + "screenshot budget exceeded: {reserved} bytes for one {}×{} image, over the {MAX_IMAGE_BYTES}-byte per-image limit", + expected[0], expected[1] + ), + )); + continue; + } + let total = app.shots.reserved().saturating_sub(shot.reserved).saturating_add(reserved); + if total > MAX_TOTAL_BYTES { + failures.push(( + i, + format!("screenshot budget exceeded: {total} bytes reserved by outstanding requests, over the {MAX_TOTAL_BYTES}-byte limit"), + )); + continue; + } + let shot = &mut app.shots.requests[i]; + let Some(callback) = shot.callback.take() else { + failures.push((i, "screenshot failed: the capture callback was already consumed".into())); + continue; + }; + ctx.send_viewport_cmd_to(ctx.viewport_id(), ViewportCommand::Screenshot(callback)); + shot.issued = true; + shot.identity = current.identity; + shot.geometry = current.surface; + shot.expected = expected; + shot.reserved = reserved; + shot.surface_epoch = app.capture_epoch; + } + for (i, message) in failures.into_iter().rev() { + let shot = app.shots.requests.remove(i); + if let Some(slot) = shot.slot { + slot.cancel(); + } + reject(shot.reply, shot.token, message); + } + if !app.shots.is_empty() { + ctx.request_repaint_after(REPAINT_INTERVAL); + } +} + +/// Drain completed slots and expire timed-out requests. Call from every logic callback (including +/// hidden ones). The deadline is checked before taking or writing an image. +pub(crate) fn drain(app: &mut CadApp, ctx: &egui::Context) { + note_identity(app, ctx); + let mut i = 0; + while i < app.shots.requests.len() { + let now = Instant::now(); + if now >= app.shots.requests[i].deadline { + let shot = app.shots.requests.remove(i); + if let Some(slot) = shot.slot { + slot.cancel(); + } + reject(shot.reply, shot.token, "no frame was presented before the screenshot timeout"); + continue; + } + if let Some(reason) = identity_change(app, ctx, &app.shots.requests[i]) { + let shot = app.shots.requests.remove(i); + if let Some(slot) = shot.slot { + slot.cancel(); + } + reject(shot.reply, shot.token, format!("screenshot failed: {reason}")); + continue; + } + if !app.shots.requests[i].issued { + // A queued request re-bases its geometry on the settled pass at issue. + i += 1; + continue; + } + if app.capture_epoch != app.shots.requests[i].surface_epoch + || app.capture_geometry.map(|geometry| geometry.surface) != Some(app.shots.requests[i].geometry) + { + let shot = app.shots.requests.remove(i); + if let Some(slot) = shot.slot { + slot.cancel(); + } + reject(shot.reply, shot.token, "screenshot failed: the window geometry changed while the capture was queued"); + continue; + } + let Some(result) = app.shots.requests[i].slot.as_ref().and_then(CaptureSlot::take) else { + i += 1; + continue; + }; + let shot = app.shots.requests.remove(i); + if let Some(slot) = shot.slot { + slot.cancel(); + } + match result { + Ok(image) => { + let reply = if image.size != shot.expected { + error_reply( + shot.token, + format!( + "screenshot is {}×{} pixels, expected {}×{}: the window changed size while it was captured", + image.size[0], image.size[1], shot.expected[0], shot.expected[1] + ), + ) + } else { + crate::control::save_screenshot(&image, shot.path.as_deref(), shot.token) + }; + let _ = shot.reply.send(reply); + } + Err(error) => { + let _ = shot.reply.send(error_reply(shot.token, error)); + } + } + } + if !app.shots.is_empty() { + ctx.request_repaint_after(REPAINT_INTERVAL); + } +} + +/// Cancel every queued/in-flight capture (app exit). Late callbacks see a dropped slot and write +/// nothing; clients receive one error reply instead of hanging past the transport timeout. +pub(crate) fn cancel_all(app: &mut CadApp) { + for shot in app.shots.requests.drain(..) { + if let Some(slot) = shot.slot { + slot.cancel(); + } + let _ = shot.reply.send(error_reply(shot.token, "screenshot cancelled: the app is closing")); + } +} + +/// Ask for a painted frame while a request is still queued, and on hidden logic ticks when a +/// capture is waiting for settlement. Native eframe honours `RequestPaintWhileHidden`; the web +/// runner currently logs it as unhandled, so web only schedules a repaint and hidden web captures +/// still reach their timeout (a documented limitation, not a success path). +pub(crate) fn request_hidden_paint(app: &CadApp, ctx: &egui::Context) { + if app.shots.has_unissued() { + #[cfg(not(target_arch = "wasm32"))] + ctx.send_viewport_cmd(ViewportCommand::RequestPaintWhileHidden); + ctx.request_repaint(); + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use cadcraft_engine::Session; + use egui::{Color32, ColorImage, ViewportId, ViewportInfo}; + use serde_json::json; + use std::sync::mpsc::{Receiver, channel}; + + use crate::Services; + + fn app() -> CadApp { + CadApp::new(Session::new(), Services::default()) + } + + fn surface(size: egui::Vec2) -> SurfaceGeometry { + SurfaceGeometry { + viewport: Some(size), + pixels_per_point: 1.0, + max_texture_side: 8192, + minimized: false, + occluded: false, + window_focused: true, + } + } + + /// Seed the admission snapshot a raw hook would have taken, then bump both epochs as an + /// observation of a change would. + fn set_geometry(app: &mut CadApp, ctx: &egui::Context, size: egui::Vec2) { + app.capture_geometry = Some(CaptureGeometry { surface: surface(size), identity: observed_identity(app, ctx) }); + app.capture_epoch = app.capture_epoch.wrapping_add(1); + app.capture_identity_epoch = app.capture_identity_epoch.wrapping_add(1); + } + + fn admit_one(app: &mut CadApp, ctx: &egui::Context) -> (Receiver, u64) { + set_geometry(app, ctx, egui::vec2(800.0, 600.0)); + let (tx, rx) = channel(); + admit(app, ctx, None, tx); + let token = app.shots.requests.first().map(|shot| shot.token).unwrap_or_default(); + (rx, token) + } + + fn raw(width: f32, height: f32) -> egui::RawInput { + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, height))), + focused: true, + max_texture_side: Some(8192), + ..Default::default() + } + } + + /// One real UI pass, with a presented frame the screenshot commands can be taken from. + fn ui_frame(app: &mut CadApp, ctx: &egui::Context) -> egui::FullOutput { + ui_frame_sized(app, ctx, 800.0, 600.0) + } + + fn ui_frame_sized(app: &mut CadApp, ctx: &egui::Context, width: f32, height: f32) -> egui::FullOutput { + let raw = raw(width, height); + note_raw_geometry(app, ctx, &raw); + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + out + } + + fn screenshot_callbacks(out: &egui::FullOutput) -> Vec { + out.viewport_output + .values() + .flat_map(|viewport| viewport.commands.iter()) + .filter_map(|command| match command { + egui::ViewportCommand::Screenshot(callback) => Some(callback.clone()), + _ => None, + }) + .collect() + } + + /// Drive the settling passes so `issue` sends the real `ViewportCommand::Screenshot`. + fn issue_after_settling(app: &mut CadApp, ctx: &egui::Context) -> (egui::FullOutput, Vec) { + for shot in &mut app.shots.requests { + // Exercise the settled-frame path without sleeping for either wall-clock delay. + // A scale change can also start an asynchronous canvas rebuild. + shot.ready_at = Instant::now() - REBUILD_WAIT - Duration::from_millis(1); + } + ui_frame(app, ctx).drop_without_applying_deltas(); + ui_frame(app, ctx).drop_without_applying_deltas(); + let out = ui_frame(app, ctx); + let callbacks = screenshot_callbacks(&out); + (out, callbacks) + } + + fn complete(callbacks: Vec, size: [usize; 2]) { + let image = Arc::new(ColorImage::filled(size, Color32::RED)); + for callback in callbacks { + callback.complete(Arc::clone(&image)); + } + } + + #[test] + fn admission_counts_completed_but_undrained_and_never_evicts() { + let ctx = egui::Context::default(); + let mut app = app(); + let _receivers: Vec<_> = (0..MAX_REQUESTS).map(|_| admit_one(&mut app, &ctx)).collect(); + assert_eq!(app.shots.len(), MAX_REQUESTS); + // Complete the first slot without draining it: it still owns its reservation and request. + let image = Arc::new(ColorImage::filled([800, 600], Color32::RED)); + app.shots.requests[0].callback.take().expect("a callback").complete(image); + let (tx, rx) = channel(); + admit(&mut app, &ctx, None, tx); + assert_eq!(app.shots.len(), MAX_REQUESTS, "the fifth request is rejected, not admitted"); + let reply = rx.recv().expect("a rejection reply"); + assert_eq!(reply["ok"], false); + assert!(reply["error"].as_str().unwrap_or_default().contains("outstanding"), "{reply}"); + assert!(app.shots.requests.iter().all(|shot| shot.token != reply["request"].as_u64().unwrap_or_default())); + } + + #[test] + fn deadline_is_checked_before_writing_a_completed_image() { + let ctx = egui::Context::default(); + let mut app = app(); + let (rx, _) = admit_one(&mut app, &ctx); + app.shots.requests[0].issued = true; + app.shots.requests[0].surface_epoch = app.capture_epoch; + app.shots.requests[0].deadline = Instant::now() - Duration::from_millis(1); + let image = Arc::new(ColorImage::filled([800, 600], Color32::RED)); + app.shots.requests[0].callback.take().expect("a callback").complete(image); + drain(&mut app, &ctx); + let reply = rx.recv().expect("a timeout reply"); + assert_eq!(reply["ok"], false); + assert!(reply["error"].as_str().unwrap_or_default().contains("timeout"), "{reply}"); + assert!(app.shots.is_empty()); + } + + #[test] + fn document_change_cancels_before_issuing() { + let ctx = egui::Context::default(); + let mut app = app(); + let (rx, _) = admit_one(&mut app, &ctx); + app.session.new_drawing(false); + drain(&mut app, &ctx); + let reply = rx.recv().expect("a document-change reply"); + assert!(reply["error"].as_str().unwrap_or_default().contains("document changed"), "{reply}"); + assert!(app.shots.is_empty()); + } + + #[test] + fn focus_change_cancels_an_unissued_capture() { + let ctx = egui::Context::default(); + let focus_a = egui::Id::unique("focus-a"); + let focus_b = egui::Id::unique("focus-b"); + ctx.memory_mut(|memory| memory.request_focus(focus_a)); + let mut app = app(); + set_geometry(&mut app, &ctx, egui::vec2(800.0, 600.0)); + let (tx, rx) = channel(); + admit(&mut app, &ctx, None, tx); + assert_eq!(app.shots.len(), 1); + ctx.memory_mut(|memory| memory.request_focus(focus_b)); + drain(&mut app, &ctx); + let reply = rx.recv().expect("a focus-change reply"); + assert!(reply["error"].as_str().unwrap_or_default().contains("focus changed"), "{reply}"); + assert!(app.shots.is_empty()); + } + + #[test] + fn issued_callbacks_reply_once_with_matching_tokens_and_distinct_pathless_files() { + let ctx = egui::Context::default(); + let mut app = app(); + set_geometry(&mut app, &ctx, egui::vec2(800.0, 600.0)); + let (tx1, rx1) = channel(); + let (tx2, rx2) = channel(); + admit(&mut app, &ctx, None, tx1); + admit(&mut app, &ctx, None, tx2); + let tokens: Vec<_> = app.shots.requests.iter().map(|shot| shot.token).collect(); + let (_, callbacks) = issue_after_settling(&mut app, &ctx); + assert_eq!(callbacks.len(), 2, "both requests issue in the same settled frame"); + complete(callbacks, [800, 600]); + drain(&mut app, &ctx); + let first = rx1.recv().expect("the first reply"); + let second = rx2.recv().expect("the second reply"); + assert_eq!((first["ok"].clone(), second["ok"].clone()), (json!(true), json!(true))); + assert_eq!(first["request"].as_u64(), tokens.first().copied()); + assert_eq!(second["request"].as_u64(), tokens.get(1).copied()); + let a = first["result"]["path"].as_str().expect("the first file").to_string(); + let b = second["result"]["path"].as_str().expect("the second file").to_string(); + assert_ne!(a, b, "one file per request"); + for path in [&a, &b] { + assert!(std::fs::read(path).is_ok_and(|bytes| bytes.starts_with(b"\x89PNG\r\n\x1a\n")), "{path}"); + let _ = std::fs::remove_file(path); + } + assert!(app.shots.is_empty()); + } + + #[test] + fn document_switch_after_issue_rejects_the_completed_callback_without_writing() { + let ctx = egui::Context::default(); + let mut app = app(); + let (rx, token) = admit_one(&mut app, &ctx); + let (_, callbacks) = issue_after_settling(&mut app, &ctx); + assert_eq!(callbacks.len(), 1); + complete(callbacks, [800, 600]); + app.session.new_drawing(false); + drain(&mut app, &ctx); + let reply = rx.recv().expect("a document-change reply"); + assert_eq!(reply["request"].as_u64(), Some(token)); + assert!(reply["error"].as_str().unwrap_or_default().contains("document changed"), "{reply}"); + assert!(reply.get("result").is_none(), "a rejected capture must not report or write a file"); + assert!(app.shots.is_empty()); + } + + #[test] + fn completed_callback_after_timeout_writes_nothing() { + let ctx = egui::Context::default(); + let mut app = app(); + let (rx, _) = admit_one(&mut app, &ctx); + let (_, callbacks) = issue_after_settling(&mut app, &ctx); + complete(callbacks, [800, 600]); + app.shots.requests[0].deadline = Instant::now() - Duration::from_millis(1); + drain(&mut app, &ctx); + let reply = rx.recv().expect("a timeout reply"); + assert!(reply["error"].as_str().unwrap_or_default().contains("timeout"), "{reply}"); + assert!(reply.get("result").is_none(), "a timed-out capture must not write a file"); + assert!(app.shots.is_empty()); + } + + #[test] + fn callback_completed_before_cancel_all_is_inert() { + let ctx = egui::Context::default(); + let mut app = app(); + let (rx, token) = admit_one(&mut app, &ctx); + let (_, callbacks) = issue_after_settling(&mut app, &ctx); + complete(callbacks, [800, 600]); + cancel_all(&mut app); + let reply = rx.recv().expect("a cancellation reply"); + assert_eq!(reply["request"].as_u64(), Some(token)); + assert!(reply["error"].as_str().unwrap_or_default().contains("cancelled"), "{reply}"); + assert!(reply.get("result").is_none(), "cancellation wins over a late completion"); + assert!(app.shots.is_empty()); + } + + #[test] + fn per_image_and_aggregate_budgets_reject_before_issue() { + let ctx = egui::Context::default(); + let mut app = app(); + // 5000×5000×4 bytes = 100 MB, over the 64 MiB per-image bound. + set_geometry(&mut app, &ctx, egui::vec2(5000.0, 5000.0)); + let (tx, rx) = channel(); + admit(&mut app, &ctx, None, tx); + let reply = rx.recv().expect("per-image rejection"); + assert!(reply["error"].as_str().unwrap_or_default().contains("per-image"), "{reply}"); + assert!(app.shots.is_empty()); + + // 2500×2500×4 bytes = 25 MB each: two fit, the third exceeds the 64 MiB aggregate bound. + set_geometry(&mut app, &ctx, egui::vec2(2500.0, 2500.0)); + let _first = admit_one_at(&mut app, &ctx, egui::vec2(2500.0, 2500.0)); + let _second = admit_one_at(&mut app, &ctx, egui::vec2(2500.0, 2500.0)); + let (tx, rx) = channel(); + admit(&mut app, &ctx, None, tx); + let reply = rx.recv().expect("aggregate rejection"); + assert!(reply["error"].as_str().unwrap_or_default().contains("outstanding requests"), "{reply}"); + assert_eq!(app.shots.len(), 2); + } + + fn admit_one_at(app: &mut CadApp, ctx: &egui::Context, size: egui::Vec2) -> (Receiver, u64) { + set_geometry(app, ctx, size); + let (tx, rx) = channel(); + admit(app, ctx, None, tx); + let token = app.shots.requests.last().map(|shot| shot.token).unwrap_or_default(); + (rx, token) + } + + #[test] + fn every_observed_geometry_change_bumps_the_generation() { + let ctx = egui::Context::default(); + let mut app = app(); + note_raw_geometry(&mut app, &ctx, &raw(800.0, 600.0)); + let first = app.capture_epoch; + note_raw_geometry(&mut app, &ctx, &raw(900.0, 600.0)); + let second = app.capture_epoch; + note_raw_geometry(&mut app, &ctx, &raw(800.0, 600.0)); + let third = app.capture_epoch; + assert!(first < second && second < third, "A→B→A bumps the generation at each observed change"); + assert_eq!(app.capture_geometry.and_then(|g| g.expected_size()), Some([800, 600])); + } + + /// An invalid settled pass must not leave the previous surface retained: the raw path clears + /// it, and the pass path has to invalidate it identically so a later admission cannot reuse a + /// surface no current pass renders. + #[test] + fn an_invalid_pass_invalidates_the_retained_surface() { + let ctx = egui::Context::default(); + let mut app = app(); + note_raw_geometry(&mut app, &ctx, &raw(800.0, 600.0)); + assert!(app.capture_geometry.is_some(), "the raw snapshot was retained"); + let surface_epoch = app.capture_epoch; + let identity_epoch = app.capture_identity_epoch; + let mut invalid = raw(800.0, 600.0); + invalid.viewports.insert(ViewportId::ROOT, ViewportInfo { native_pixels_per_point: Some(0.0), ..Default::default() }); + let mut out = ctx.run_ui(invalid, |_| {}); + out.textures_delta.clear(); + assert_eq!(ctx.pixels_per_point(), 0.0, "the pass pixels-per-point is invalid"); + note_pass_geometry(&mut app, &ctx); + assert!(app.capture_geometry.is_none(), "an invalid pass must not keep the previous surface"); + assert!(app.capture_epoch > surface_epoch, "the surface epoch changes"); + assert!(app.capture_identity_epoch > identity_epoch, "the identity epoch changes with the surface"); + let (tx, rx) = channel(); + admit(&mut app, &ctx, None, tx); + let reply = rx.recv().expect("an admission rejection"); + assert_eq!(reply["ok"], false); + assert!(reply["error"].as_str().unwrap_or_default().contains("not known"), "{reply}"); + } + + #[test] + fn raw_geometry_uses_screen_rect_when_inner_rect_is_absent() { + let ctx = egui::Context::default(); + let mut app = app(); + let mut raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(640.0, 480.0))), + focused: true, + max_texture_side: Some(4096), + ..Default::default() + }; + raw.viewports.insert(ViewportId::ROOT, ViewportInfo { native_pixels_per_point: Some(2.0), ..Default::default() }); + note_raw_geometry(&mut app, &ctx, &raw); + let geometry = app.capture_geometry.expect("fresh geometry"); + assert_eq!(geometry.expected_size(), Some([1280, 960])); + assert!(geometry.surface.window_focused); + } + + /// The producer regression for the pass-lifecycle defect: a first pass that egui discards must + /// not issue, and the settled second pass must issue exactly once. + #[test] + fn a_discarded_pass_does_not_issue_and_the_settled_pass_does_once() { + let ctx = egui::Context::default(); + let mut app = app(); + let (rx, _) = admit_one(&mut app, &ctx); + for shot in &mut app.shots.requests { + shot.ready_at = Instant::now() - Duration::from_millis(1); + shot.frames = SETTLE_PASSES - 1; + } + let raw = raw(800.0, 600.0); + note_raw_geometry(&mut app, &ctx, &raw); + let mut discard = true; + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + if std::mem::take(&mut discard) { + ui.ctx().request_discard("test: force a second pass"); + } + app.ui(ui); + }); + out.textures_delta.clear(); + let callbacks = screenshot_callbacks(&out); + assert_eq!(callbacks.len(), 1, "the settled pass issues once; the discarded pass does not"); + assert_eq!(app.ui_frames, 1, "one settled frame, not one per pass"); + complete(callbacks, [800, 600]); + drain(&mut app, &ctx); + assert_eq!(rx.recv().expect("a reply")["ok"], true); + } + + /// Issuance re-bases the expected image on the settled pass, so the saved interface zoom + /// (applied by egui at the start of a later pass) cannot leave a stale admission estimate. + #[test] + fn issuance_uses_the_settled_interface_scale_not_the_admission_estimate() { + let ctx = egui::Context::default(); + let mut app = app(); + app.options.interface_scale = 2.0; + let raw = raw(800.0, 600.0); + note_raw_geometry(&mut app, &ctx, &raw); + let (tx, rx) = channel(); + admit(&mut app, &ctx, None, tx); + assert_eq!(app.shots.requests[0].expected, [800, 600], "the admission estimate predates the zoom"); + let (_, callbacks) = issue_after_settling(&mut app, &ctx); + assert_eq!(callbacks.len(), 1); + assert_eq!(app.shots.requests[0].expected, [1600, 1200], "issued against the settled pass scale"); + complete(callbacks, [1600, 1200]); + drain(&mut app, &ctx); + let reply = rx.recv().expect("a reply"); + assert_eq!(reply["ok"], true, "{reply}"); + let path = reply["result"]["path"].as_str().expect("a file").to_string(); + let _ = std::fs::remove_file(path); + } + + /// A→B→A: a switch that returns to the same drawing still invalidates the queued request. + #[test] + fn a_document_switch_that_returns_still_rejects_the_queued_capture() { + let ctx = egui::Context::default(); + let mut app = app(); + app.session.new_drawing(false); + let (rx, _) = admit_one(&mut app, &ctx); + let before = active_document(&app); + app.run("document.switch", json!({ "index": 0 })).expect("switch to the first drawing"); + note_identity(&mut app, &ctx); + app.run("document.switch", json!({ "index": 1 })).expect("switch back"); + note_identity(&mut app, &ctx); + assert_eq!(active_document(&app), before, "the drawing and focus are back where they were"); + let (_, callbacks) = issue_after_settling(&mut app, &ctx); + assert!(callbacks.is_empty(), "the request is rejected, not issued"); + let reply = rx.recv().expect("a rejection reply"); + assert!(reply["error"].as_str().unwrap_or_default().contains("changed"), "{reply}"); + assert!(reply.get("result").is_none()); + } + + /// A drawing created and closed inside one callback never reaches the same observed values, but + /// the engine's monotonic document epoch still records it. + #[test] + fn a_drawing_created_and_closed_between_observations_still_bumps_the_identity_epoch() { + let ctx = egui::Context::default(); + let mut app = app(); + set_geometry(&mut app, &ctx, egui::vec2(800.0, 600.0)); + let before_epoch = app.capture_identity_epoch; + let before_doc = active_document(&app); + app.session.new_drawing(false); + app.run("close", json!({})).expect("close the new drawing"); + assert_eq!(active_document(&app), before_doc, "the same drawing is active again"); + note_identity(&mut app, &ctx); + assert!(app.capture_identity_epoch > before_epoch, "the intervening switch is still observed"); + } + + /// Raw-hook and pass geometry must agree, or every frame would re-baseline the surface and a + /// capture in flight could never settle. + #[test] + fn a_settled_window_does_not_keep_changing_the_surface_epoch() { + let ctx = egui::Context::default(); + let mut app = app(); + ui_frame(&mut app, &ctx).drop_without_applying_deltas(); + let epoch = app.capture_epoch; + let identity = app.capture_identity_epoch; + for _ in 0..4 { + ui_frame(&mut app, &ctx).drop_without_applying_deltas(); + } + assert_eq!(app.capture_epoch, epoch, "the raw hook and the pass agree on the surface"); + assert_eq!(app.capture_identity_epoch, identity, "nothing changed the drawing or focus"); + assert_eq!(app.capture_geometry.and_then(|geometry| geometry.expected_size()), Some([800, 600])); + } + + /// A window resized while the request is still settling is issued against the new size. + #[test] + fn a_resize_while_settling_is_absorbed_at_issue() { + let ctx = egui::Context::default(); + let mut app = app(); + let (rx, _) = admit_one(&mut app, &ctx); + for shot in &mut app.shots.requests { + shot.ready_at = Instant::now() - Duration::from_millis(1); + } + ui_frame_sized(&mut app, &ctx, 900.0, 600.0).drop_without_applying_deltas(); + ui_frame_sized(&mut app, &ctx, 900.0, 600.0).drop_without_applying_deltas(); + let out = ui_frame_sized(&mut app, &ctx, 900.0, 600.0); + let callbacks = screenshot_callbacks(&out); + out.drop_without_applying_deltas(); + assert_eq!(callbacks.len(), 1); + assert_eq!(app.shots.requests[0].expected, [900, 600], "issued against the settled size"); + complete(callbacks, [900, 600]); + drain(&mut app, &ctx); + let reply = rx.recv().expect("a reply"); + assert_eq!(reply["ok"], true, "{reply}"); + let path = reply["result"]["path"].as_str().expect("a file").to_string(); + let _ = std::fs::remove_file(path); + } +} diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 032445d8c..1a4968182 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -80,7 +80,7 @@ pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) { ui.painter().galley(pos2(x, r.top() + (title_h - galley.size().y) / 2.0), galley, t.text_dim); // Allow dragging the window by the title row. let tr = Rect::from_min_size(r.min, vec2(r.width(), title_h)); - let resp = ui.interact(tr, ui.id().with("titledrag"), Sense::CLICK | Sense::DRAG); + let resp = ui.interact(tr, ui.scope_id().with("titledrag"), Sense::CLICK | Sense::DRAG); if resp.drag_started() { ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); } @@ -122,7 +122,7 @@ pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) { let Some(group) = TOOLBAR.get(*index) else { continue }; for (icon, cmd, label, shortcut) in group.iter() { let br = Rect::from_min_size(pos2(x, row.center().y - size / 2.0), vec2(size, size)); - let resp = ui.interact(br, ui.id().with(("tb", *cmd)), Sense::click()); + let resp = ui.interact(br, ui.scope_id().with(("tb", *cmd)), Sense::click()); if resp.hovered() { ui.painter().rect_filled(br, 3.0, t.control_hover.gamma_multiply(0.6)); } @@ -141,7 +141,7 @@ pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) { } if !fit.overflow.is_empty() { let br = Rect::from_min_size(pos2(x, row.center().y - size / 2.0), vec2(fit.overflow_width, size)); - let response = ui.interact(br, ui.id().with("toolbar_more"), Sense::click()); + let response = ui.interact(br, ui.scope_id().with("toolbar_more"), Sense::click()); if response.hovered() { ui.painter().rect_filled(br, 3.0, t.control_hover.gamma_multiply(0.6)); } @@ -184,7 +184,7 @@ pub fn file_tabs(app: &mut CadApp, ui: &mut egui::Ui) { icons::paint(ui.painter(), ib.shrink(1.0), Icon::Switcher, false); x += 24.0; let plus = Rect::from_min_size(pos2(x, r.top() + 4.0), vec2(18.0, 18.0)); - let presp = ui.interact(plus, ui.id().with("tab+"), Sense::click()); + let presp = ui.interact(plus, ui.scope_id().with("tab+"), Sense::click()); icons::paint(ui.painter(), plus.shrink(2.0), Icon::Plus, false); icons::describe_control(ui, &presp, "New Drawing", None, false); if presp.on_hover_text(crate::tl!("New Drawing")).clicked() { @@ -235,7 +235,8 @@ pub fn file_tabs(app: &mut CadApp, ui: &mut egui::Ui) { x += w; (resp.clicked(), close, resp) }; - let (c, _, _) = tab(ui, crate::tl!("Start"), false, app.ui.start_tab || app.session.docs.is_empty(), false, 110.0, ui.id().with("tab_start")); + let (c, _, _) = + tab(ui, crate::tl!("Start"), false, app.ui.start_tab || app.session.docs.is_empty(), false, 110.0, ui.scope_id().with("tab_start")); if c { app.ui.start_tab = true; } @@ -244,7 +245,7 @@ pub fn file_tabs(app: &mut CadApp, ui: &mut egui::Ui) { let titles: Vec<(usize, String, bool, bool)> = app.session.docs.iter().enumerate().map(|(i, d)| (i, d.title.clone(), d.is_dirty(), i == app.session.active)).collect(); for (i, title, dirty, active) in titles { - let (c, x, resp) = tab(ui, &title, dirty, active && !app.ui.start_tab, true, 170.0, ui.id().with(("tab", i))); + let (c, x, resp) = tab(ui, &title, dirty, active && !app.ui.start_tab, true, 170.0, ui.scope_id().with(("tab", i))); crate::shortcut_menus::attach(app, &resp, |app| crate::shortcut_menus::drawing_tab_entries(app, i)); if c { switch_to = Some(i); @@ -311,7 +312,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) { let mut list_button = None; for (icon, tip) in [(Icon::Plus, "New layout"), (Icon::Menu, "Layout list")] { let br = Rect::from_min_size(pos2(x, r.top() + 4.0), vec2(18.0, 18.0)); - let resp = ui.interact(br, ui.id().with(("sb", tip)), Sense::click()); + let resp = ui.interact(br, ui.scope_id().with(("sb", tip)), Sense::click()); icons::paint(&p, br.shrink(2.0), icon, false); icons::describe_control(ui, &resp, tip, None, false); let resp = resp.on_hover_text(crate::i18n::t(tip)); @@ -365,7 +366,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) { let model = app.session.state().is_ok_and(|st| st.mspace.is_some()); let label = if model { "MODEL" } else { "PAPER" }; let br = Rect::from_min_max(pos2(rx - 60.0, r.center().y - 9.0), pos2(rx - 6.0, r.center().y + 9.0)); - let resp = ui.interact(br, ui.id().with("mspace_toggle"), Sense::click()); + let resp = ui.interact(br, ui.scope_id().with("mspace_toggle"), Sense::click()); p.rect_filled(br, 3.0, if resp.hovered() { t.control_hover } else { t.toggle_on.gamma_multiply(0.25) }); p.text(br.center(), egui::Align2::CENTER_CENTER, crate::i18n::t(label), crate::theme::small(), t.text); icons::describe_control(ui, &resp, label, None, false); @@ -414,7 +415,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) { let w = g.size().x + 26.0; let tr = Rect::from_min_size(pos2(x, r.top() + 1.0), vec2(w, r.height() - 2.0)).intersect(clip); let active = active_tab == Some(i); - let resp = ui.interact(tr, ui.id().with(("lt", i)), Sense::click()); + let resp = ui.interact(tr, ui.scope_id().with(("lt", i)), Sense::click()); if active { tp.rect_filled(tr, 2.0, t.tab_active); } else if resp.hovered() { @@ -430,7 +431,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) { x += w + 2.0; if i == 0 { let pr = Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(16.0, 16.0)); - let presp = ui.interact(pr, ui.id().with("layout_plus"), Sense::click()); + let presp = ui.interact(pr, ui.scope_id().with("layout_plus"), Sense::click()); icons::paint(&p, pr.shrink(2.0), Icon::Plus, false); icons::describe_control(ui, &presp, "New layout", None, false); if presp.on_hover_text(crate::tl!("New layout")).clicked() @@ -445,7 +446,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) { // The layouts that didn't fit, behind a button after the last tab shown. let more = hidden.then(|| { let mr = Rect::from_min_size(pos2(x + 2.0, r.top() + 5.0), vec2(16.0, 16.0)); - let resp = ui.interact(mr, ui.id().with("layout_more"), Sense::click()); + let resp = ui.interact(mr, ui.scope_id().with("layout_more"), Sense::click()); if resp.hovered() { p.rect_filled(mr, 3.0, t.control_hover.gamma_multiply(0.5)); } @@ -486,13 +487,13 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) { let rect = Rect::from_min_max(pos2(left, r.top()), pos2(coord_right, r.bottom())); let text_x = if message.is_some() { left } else { coord_right - galley.size().x }; p.with_clip_rect(rect).galley(pos2(text_x, r.center().y - galley.size().y / 2.0), galley, color); - ui.interact(rect, ui.id().with("status_readout"), Sense::hover()).on_hover_text(text); + ui.interact(rect, ui.scope_id().with("status_readout"), Sense::hover()).on_hover_text(text); } } let mut toggle_cmd = None; for (icon, on, cmd, tip) in toggles { let br = Rect::from_min_size(pos2(rx, r.center().y - size / 2.0), vec2(size, size)); - let resp = ui.interact(br, ui.id().with(("tg", tip)), Sense::click()); + let resp = ui.interact(br, ui.scope_id().with(("tg", tip)), Sense::click()); if on { p.rect_filled(br, 3.0, t.toggle_on.gamma_multiply(0.35)); } else if resp.hovered() { @@ -538,7 +539,7 @@ pub fn start_page(app: &mut CadApp, ui: &mut egui::Ui) { let mut action = None; for (label, cmd) in [("Open...", "open"), ("New", "new"), ("New (metric)", "new.metric"), ("Open sample drawing", "sample")] { let br = Rect::from_min_size(pos2(left.left() + 34.0, y), vec2(220.0, 34.0)); - let resp = ui.interact(br, ui.id().with(("start", cmd)), Sense::click()); + let resp = ui.interact(br, ui.scope_id().with(("start", cmd)), Sense::click()); p.rect_stroke(br, 2.0, Stroke::new(1.0, if resp.hovered() { t.text } else { t.text_dim }), egui::StrokeKind::Inside); p.text(pos2(br.left() + 16.0, br.center().y), egui::Align2::LEFT_CENTER, crate::i18n::t(label), crate::theme::body(), t.text); if resp.clicked() { @@ -552,7 +553,7 @@ pub fn start_page(app: &mut CadApp, ui: &mut egui::Ui) { let mut y = main.top() + 44.0; for (i, path) in app.ui.recent_files.iter().enumerate() { let row = Rect::from_min_size(pos2(main.left(), y), vec2((r.right() - main.left() - 36.0).clamp(0.0, 640.0), 42.0)); - let resp = ui.interact(row, ui.id().with(("recent", i)), Sense::click()); + let resp = ui.interact(row, ui.scope_id().with(("recent", i)), Sense::click()); if resp.hovered() { p.rect_filled(row, 2.0, t.control_hover.gamma_multiply(0.5)); } diff --git a/crates/ui-egui/src/chrome/annoscale.rs b/crates/ui-egui/src/chrome/annoscale.rs index 4aa9a2613..b18db6e19 100644 --- a/crates/ui-egui/src/chrome/annoscale.rs +++ b/crates/ui-egui/src/chrome/annoscale.rs @@ -22,7 +22,7 @@ pub fn status(app: &mut CadApp, ui: &mut egui::Ui, right: f32, cy: f32) -> f32 { let g = p.layout_no_wrap(current.clone(), crate::theme::small(), t.text); let w = g.size().x.min(160.0) + 22.0; let r = Rect::from_min_size(pos2(right - w, cy - 9.0), vec2(w, 18.0)); - let resp = ui.interact(r, ui.id().with("anno_scale"), Sense::click()); + let resp = ui.interact(r, ui.scope_id().with("anno_scale"), Sense::click()); p.rect_filled(r, 3.0, if resp.hovered() { t.control_hover } else { t.control.gamma_multiply(0.5) }); p.with_clip_rect(r.shrink2(vec2(4.0, 0.0))).galley(pos2(r.left() + 6.0, r.center().y - g.size().y / 2.0), g, t.text); let (ax, ay) = (r.right() - 9.0, r.center().y); diff --git a/crates/ui-egui/src/clipboard.rs b/crates/ui-egui/src/clipboard.rs index af612aba7..69070f4fb 100644 --- a/crates/ui-egui/src/clipboard.rs +++ b/crates/ui-egui/src/clipboard.rs @@ -13,10 +13,10 @@ use crate::CadApp; /// egui memory: the clipboard copy last mirrored, and the hash of the text it became. fn serial_id() -> egui::Id { - egui::Id::new("cc_clipboard_serial") + egui::Id::unique("cc_clipboard_serial") } fn text_id() -> egui::Id { - egui::Id::new("cc_clipboard_text") + egui::Id::unique("cc_clipboard_text") } /// Line endings normalised (pasted text arrives with `\n`), so our own text is recognised. diff --git a/crates/ui-egui/src/closing.rs b/crates/ui-egui/src/closing.rs index d03fafa4b..0601ddd6d 100644 --- a/crates/ui-egui/src/closing.rs +++ b/crates/ui-egui/src/closing.rs @@ -204,7 +204,7 @@ pub fn prompt(app: &mut CadApp, ctx: &egui::Context) { }; let t = Tokens::get(); let mut choice = None; - egui::Modal::new(egui::Id::new("cc_close_prompt")).show(ctx, |ui| { + egui::Modal::new(egui::Id::unique("cc_close_prompt")).show(ctx, |ui| { ui.set_width(360.0); ui.label(RichText::new("CADCraft").strong()); ui.add_space(4.0); diff --git a/crates/ui-egui/src/cmdline.rs b/crates/ui-egui/src/cmdline.rs index c136b7032..57ad5355c 100644 --- a/crates/ui-egui/src/cmdline.rs +++ b/crates/ui-egui/src/cmdline.rs @@ -443,7 +443,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) { for (i, k) in kws.iter().enumerate() { let g = p.layout_no_wrap(k.clone(), font.clone(), t.cmd_keyword); let r = Rect::from_min_size(pos2(x, bar.top() + 3.0), vec2(g.size().x, h - 6.0)); - let resp = ui.interact(r, ui.id().with(("kw", i)), Sense::click()); + let resp = ui.interact(r, ui.scope_id().with(("kw", i)), Sense::click()); if resp.hovered() { p.rect_filled(r, 2.0, t.cmd_keyword_hover); } @@ -523,7 +523,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) { p.rect_stroke(lr, 3.0, Stroke::new(1.0, t.cmd_border), egui::StrokeKind::Inside); for (i, s) in sug.iter().enumerate() { let r = Rect::from_min_size(pos2(lr.left(), lr.top() + rh * i as f32), vec2(lr.width(), rh)); - let resp = ui.interact(r, ui.id().with(("sug", i)), Sense::click()); + let resp = ui.interact(r, ui.scope_id().with(("sug", i)), Sense::click()); if resp.hovered() || i == 0 { p.rect_filled(r.shrink(1.0), 2.0, if resp.hovered() { t.cmd_keyword_hover } else { t.list_row }); } @@ -586,7 +586,7 @@ fn zones(app: &CadApp, canvas: Rect, bar: Rect) -> Zones { /// the top edge sets how many lines show (`ui.cmdline.lines`). fn history(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect, bar: Rect) { let z = zones(app, canvas, bar); - let resp = ui.interact(z.all, ui.id().with("cmdline"), Sense::click_and_drag()); + let resp = ui.interact(z.all, ui.scope_id().with("cmdline"), Sense::click_and_drag()); let at = resp.hover_pos().or(resp.interact_pointer_pos()); let on_grip = |p: Pos2| z.grip.contains(p) && z.window.is_none(); let on_toggle = |p: Pos2| z.chevron.contains(p) || z.lines.is_some_and(|r| r.contains(p)); diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index 68f427679..bbaf48df3 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -313,8 +313,8 @@ struct Press(f64, egui::Pos2); /// the menu while it is open. pub fn in_command(app: &mut CadApp, ui: &egui::Ui, inside: bool, right_clicked: bool) -> bool { let ctx = ui.ctx().clone(); - let press_id = egui::Id::new("cc_command_menu_press"); - let popup_id = egui::Id::new("cc_command_menu"); + let press_id = egui::Id::unique("cc_command_menu_press"); + let popup_id = egui::Id::unique("cc_command_menu"); let secondary = egui::PointerButton::Secondary; let (pressed, released, now, pos) = ctx.input(|i| (i.pointer.button_pressed(secondary), i.pointer.button_released(secondary), i.time, i.pointer.interact_pos())); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index bc6d23ef5..db2e54012 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -103,7 +103,7 @@ struct Widget { } fn widgets_id() -> egui::Id { - egui::Id::new("cadcraft-observed-widgets") + egui::Id::unique("cadcraft-observed-widgets") } pub(crate) fn begin_frame(ctx: &egui::Context) { @@ -267,7 +267,7 @@ pub fn inspect(app: &CadApp, ctx: &egui::Context) -> Value { "snap": app.canvas.snap.map(|s| json!({"point": [s.point.x, s.point.y], "mode": s.name})), "session": app.session.summary(), "closePrompt": app.close_prompt(), - "perf": {"frameMs": app.frame_ms, "buildMs": app.canvas.build_ms, "drawMs": app.canvas.draw_ms, "meshMs": app.canvas.mesh_ms, "building": app.canvas.building(), "renderer": if app.canvas.gpu.is_some() { "gpu" } else { "cpu" }, "prims": app.canvas.list.as_ref().map(|l| l.prims.len())}, + "perf": {"frameMs": app.frame_ms, "buildMs": app.canvas.build_ms, "drawMs": app.canvas.draw_ms, "meshMs": app.canvas.mesh_ms, "building": app.canvas.building(), "renderer": if app.canvas.gpu.is_some() { "gpu" } else { "cpu" }, "prims": app.canvas.list.as_ref().map(|l| l.prims.len()), "uiFrames": app.ui_frames, "passIndex": ctx.current_pass_index()}, }) } @@ -543,18 +543,133 @@ fn render_reply(png: &[u8], w: u32, h: u32, path: Option<&str>) -> Value { v } -/// Save a screenshot PNG. -pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>) -> Value { +/// A private, per-process screenshot directory for pathless `ui.screenshot` requests. +/// +/// The directory is created with owner-only permissions (`0700` on Unix) under a name that +/// includes the process id, a monotonic nonce and a sequence number. `create_dir` fails rather +/// than following a pre-created symlink, so another local user cannot redirect the write. +fn private_shot_dir() -> std::io::Result { + use std::sync::OnceLock; + static DIR: OnceLock> = OnceLock::new(); + DIR.get_or_init(|| create_private_shot_dir().map_err(|e| e.to_string())).clone().map_err(std::io::Error::other) +} + +fn next_pathless_nonce() -> u64 { + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(0); + NEXT.fetch_add(1, Ordering::Relaxed) +} + +fn create_private_shot_dir() -> std::io::Result { + let base = std::env::temp_dir(); + let stamp = crate::now_ms().to_bits(); + for _ in 0..32 { + let nonce = next_pathless_nonce(); + let dir = base.join(format!("cadcraft-shots-{}-{stamp:x}-{nonce}", std::process::id())); + match create_private_dir(&dir) { + Ok(()) => return Ok(dir), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), + } + } + Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "could not create a private screenshot directory")) +} + +fn create_private_dir(path: &std::path::Path) -> std::io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt as _; + std::fs::DirBuilder::new().mode(0o700).create(path) + } + #[cfg(not(unix))] + { + std::fs::create_dir(path) + } +} + +/// Create a new file with owner-only permissions. `create_new` uses `O_EXCL` on Unix and refuses +/// an existing path, including a symlink, so a pre-created link cannot redirect the screenshot. +fn create_private_file(path: &std::path::Path) -> std::io::Result { + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + options.open(path) +} + +fn is_private_shot_dir(meta: &std::fs::Metadata) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + meta.permissions().mode() & 0o077 == 0 + } + #[cfg(not(unix))] + { + let _ = meta; + true + } +} + +fn save_pathless_screenshot(img: &image::RgbaImage, token: u64) -> std::io::Result { + use std::io::Write as _; + let dir = private_shot_dir()?; + // Tests and users may remove the private directory between requests; recreate it exclusively + // rather than following anything that replaced it. + if !std::fs::symlink_metadata(&dir).is_ok_and(|m| m.file_type().is_dir() && !m.file_type().is_symlink() && is_private_shot_dir(&m)) { + match create_private_dir(&dir) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + let meta = std::fs::symlink_metadata(&dir)?; + if !meta.file_type().is_dir() || meta.file_type().is_symlink() || !is_private_shot_dir(&meta) { + return Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "the screenshot directory was replaced")); + } + } + Err(e) => return Err(e), + } + } + for _ in 0..64 { + let path = dir.join(format!("cadcraft-shot-{token}-{}.png", next_pathless_nonce())); + match create_private_file(&path) { + Ok(mut file) => { + img.write_to(&mut file, image::ImageFormat::Png).map_err(std::io::Error::other)?; + file.flush()?; + return Ok(path); + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), + } + } + Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "could not allocate a unique screenshot file")) +} + +/// Save a screenshot PNG. Only called from the logic drain, never from the screenshot callback. +/// +/// An explicit `path` is written as before. Without one, the file gets a unique name inside a +/// private per-process directory, created exclusively with owner-only permissions; concurrent +/// requests (even from two app instances with the same per-process token) cannot collide or +/// follow a pre-created symlink. +pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>, token: u64) -> Value { let [w, h] = image.size; - let mut buf = Vec::with_capacity(w * h * 4); + let Some(len) = w.checked_mul(h).and_then(|pixels| pixels.checked_mul(4)) else { + return json!({"ok": false, "error": "screenshot dimensions overflow", "request": token}); + }; + let mut buf = Vec::with_capacity(len); for c in &image.pixels { buf.extend_from_slice(&c.to_array()); } - let Some(img) = image::RgbaImage::from_raw(w as u32, h as u32, buf) else { return json!({"ok": false, "error": "bad image"}) }; - let path = path.map(str::to_string).unwrap_or_else(|| std::env::temp_dir().join("cadcraft-shot.png").to_string_lossy().to_string()); - match img.save(&path) { - Ok(()) => json!({"ok": true, "result": {"path": path, "width": w, "height": h}}), - Err(e) => json!({"ok": false, "error": e.to_string()}), + let Some(img) = image::RgbaImage::from_raw(w as u32, h as u32, buf) else { + return json!({"ok": false, "error": "bad image", "request": token}); + }; + let saved = match path { + Some(path) => img.save(path).map(|()| path.to_string()).map_err(std::io::Error::other), + None => save_pathless_screenshot(&img, token).map(|path| path.to_string_lossy().into_owned()), + }; + match saved { + Ok(path) => json!({"ok": true, "request": token, "result": {"path": path, "width": w, "height": h}}), + Err(e) => json!({"ok": false, "error": e.to_string(), "request": token}), } } @@ -610,6 +725,53 @@ mod tests { assert_eq!(pending.wait(Duration::ZERO), json!({"ok": true})); } + #[test] + fn pathless_screenshots_are_unique_private_png_files() { + let img = egui::ColorImage::filled([4, 3], egui::Color32::RED); + let first = save_screenshot(&img, None, 1); + let second = save_screenshot(&img, None, 1); + assert_eq!(first["ok"], true, "{first}"); + assert_eq!(second["ok"], true, "{second}"); + let a = std::path::PathBuf::from(first["result"]["path"].as_str().unwrap_or_default()); + let b = std::path::PathBuf::from(second["result"]["path"].as_str().unwrap_or_default()); + assert_ne!(a, b, "the same per-process token must not reuse a pathless file"); + for path in [&a, &b] { + let bytes = std::fs::read(path).expect("the screenshot file exists"); + assert!(bytes.starts_with(b"\x89PNG\r\n\x1a\n"), "{path:?} is a PNG"); + image::load_from_memory(&bytes).expect("decodable PNG"); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + for path in [&a, &b] { + let mode = std::fs::metadata(path).expect("metadata").permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "screenshot {path:?} is owner-only"); + } + let dir = a.parent().expect("the file has a private directory"); + let dir_mode = std::fs::metadata(dir).expect("directory metadata").permissions().mode() & 0o777; + assert_eq!(dir_mode, 0o700, "the screenshot directory is owner-only"); + } + for path in [&a, &b] { + let _ = std::fs::remove_file(path); + } + } + + #[cfg(unix)] + #[test] + fn private_screenshot_file_refuses_a_preexisting_symlink() { + let dir = std::env::temp_dir().join(format!("cadcraft-shot-symlink-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + create_private_dir(&dir).expect("private test directory"); + let target = dir.join("target.txt"); + std::fs::write(&target, b"do not overwrite").expect("target file"); + let link = dir.join("shot.png"); + std::os::unix::fs::symlink(&target, &link).expect("symlink"); + let err = create_private_file(&link).expect_err("an existing symlink is refused"); + assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists); + assert_eq!(std::fs::read(&target).expect("target unchanged"), b"do not overwrite"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn render_reply_has_base64_width_height_and_keeps_old_keys() { let r = render_reply(b"abc", 7, 5, Some("x.png")); diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index 2385c61b5..6c3276c91 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(crate::tl!("Show")); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 12a7d8d09..83b5a7cf6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -42,7 +42,7 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) { fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { let settings = app.session.drafting_settings(); let (mut snapunit, mut gridunit) = (settings.snapunit, settings.gridunit); - egui::Window::new(crate::tl!("Drafting Settings")).id(egui::Id::new("Drafting Settings")).open(open).resizable(false).show(ctx, |ui| { + egui::Window::new(crate::tl!("Drafting Settings")).id(egui::Id::unique("Drafting Settings")).open(open).resizable(false).show(ctx, |ui| { let s = &mut app.session.settings; ui.heading(crate::tl!("Snap and Grid")); ui.checkbox(&mut s.snapmode, crate::tl!("Snap On (F9)")); @@ -52,10 +52,10 @@ fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { ui.radio_value(&mut s.isodraft, true, crate::tl!("Isometric snap")); }); ui.horizontal(|ui| { - ui.label(crate::tl!("Snap X spacing")); - ui.add(egui::DragValue::new(&mut snapunit.x).speed(0.05).range(0.0001..=1e6).clamp_existing_to_range(false)); - ui.label(crate::tl!("Y")); - ui.add(egui::DragValue::new(&mut snapunit.y).speed(0.05).range(0.0001..=1e6).clamp_existing_to_range(false)); + let x_label = ui.label(crate::tl!("Snap X spacing")); + ui.add(egui::DragValue::new(&mut snapunit.x).speed(0.05).range(0.0001..=1e6).clamp_existing_to_range(false)).labelled_by(x_label.id); + let y_label = ui.label(crate::tl!("Y")); + ui.add(egui::DragValue::new(&mut snapunit.y).speed(0.05).range(0.0001..=1e6).clamp_existing_to_range(false)).labelled_by(y_label.id); }); // SNAPTYPE and POLARDIST. ui.horizontal(|ui| { @@ -65,16 +65,16 @@ fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { }); ui.add_enabled_ui(s.polarsnap, |ui| { ui.horizontal(|ui| { - ui.label(crate::tl!("Polar distance")); - ui.add(egui::DragValue::new(&mut s.polardist).speed(0.05).range(0.0..=1e6)); + let label = ui.label(crate::tl!("Polar distance")); + ui.add(egui::DragValue::new(&mut s.polardist).speed(0.05).range(0.0..=1e6)).labelled_by(label.id); }); }); ui.checkbox(&mut s.gridmode, crate::tl!("Grid On (F7)")); ui.horizontal(|ui| { - ui.label(crate::tl!("Grid spacing")); - ui.add(egui::DragValue::new(&mut gridunit.x).speed(0.05).range(0.0..=1e6).clamp_existing_to_range(false)); - ui.label(crate::tl!("Major line every")); - ui.add(egui::DragValue::new(&mut s.gridmajor).range(1..=100)); + let grid_label = ui.label(crate::tl!("Grid spacing")); + ui.add(egui::DragValue::new(&mut gridunit.x).speed(0.05).range(0.0..=1e6).clamp_existing_to_range(false)).labelled_by(grid_label.id); + let major_label = ui.label(crate::tl!("Major line every")); + ui.add(egui::DragValue::new(&mut s.gridmajor).range(1..=100)).labelled_by(major_label.id); }); ui.separator(); ui.heading(crate::tl!("Polar Tracking")); @@ -84,12 +84,13 @@ fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { let current = (s.polarang.to_degrees() * 1e6).round() / 1e6; let mut deg = current; ui.horizontal(|ui| { - ui.label(crate::tl!("Increment angle")); - egui::ComboBox::from_id_salt("polarang").selected_text(format!("{deg}")).show_ui(ui, |ui| { + let label = ui.label(crate::tl!("Increment angle")); + let combo = egui::ComboBox::from_id_salt("polarang").selected_text(format!("{deg}")).show_ui(ui, |ui| { for a in [90.0, 45.0, 30.0, 22.5, 18.0, 15.0, 10.0, 5.0] { ui.selectable_value(&mut deg, a, format!("{a}")); } }); + combo.response.labelled_by(label.id); }); if deg != current { s.polarang = deg.to_radians(); @@ -161,14 +162,15 @@ fn tracking_options(ui: &mut egui::Ui, s: &mut cadcraft_engine::Settings) { } let mut additional = s.polar_flags & polarmode::ADDITIONAL != 0; ui.horizontal(|ui| { - if ui.checkbox(&mut additional, crate::tl!("Additional angles")).changed() { + let additional_label = ui.checkbox(&mut additional, crate::tl!("Additional angles")); + if additional_label.changed() { s.polar_flags ^= polarmode::ADDITIONAL; } // The list as typed (degrees separated by `;`), kept while it doesn't parse yet. - let id = ui.id().with("polaraddang"); + let id = ui.scope_id().with("polaraddang"); let shown = cadcraft_engine::sysvars::format_angle_list(&s.polaraddang); let mut text = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_else(|| shown.clone()); - let resp = ui.add_enabled(additional, egui::TextEdit::singleline(&mut text).desired_width(140.0)); + let resp = ui.add_enabled(additional, egui::TextEdit::singleline(&mut text).desired_width(140.0)).labelled_by(additional_label.id); if resp.changed() && let Some(angles) = cadcraft_engine::sysvars::parse_angle_list(&text) { @@ -239,7 +241,7 @@ fn pick(ui: &mut egui::Ui, salt: &str, value: &mut i64, options: &[(i64, String) /// through the `units` command on OK. fn units(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { use cadcraft_engine::units::{format_angle, format_distance}; - let id = egui::Id::new("units_dialog_values"); + let id = egui::Id::unique("units_dialog_values"); let mut v = ctx.data_mut(|d| d.get_temp::<[i64; 5]>(id)).unwrap_or_else(|| { app.session .doc() @@ -305,9 +307,9 @@ fn units(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { fn about(ctx: &egui::Context, open: &mut bool) { let t = Tokens::get(); - let tab_id = egui::Id::new("about_tab"); + let tab_id = egui::Id::unique("about_tab"); egui::Window::new(crate::tl!("About CADCraft")) - .id(egui::Id::new("About CADCraft")) + .id(egui::Id::unique("About CADCraft")) .open(open) .default_size(vec2(640.0, 420.0)) .collapsible(false) @@ -342,14 +344,14 @@ fn about(ctx: &egui::Context, open: &mut bool) { fn commands(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { let mut start = None; - egui::Window::new(crate::tl!("Command Reference")).id(egui::Id::new("Command Reference")).open(open).default_size(vec2(640.0, 480.0)).show( + egui::Window::new(crate::tl!("Command Reference")).id(egui::Id::unique("Command Reference")).open(open).default_size(vec2(640.0, 480.0)).show( ctx, |ui| { - let id = ui.id().with("cmdfilter"); + let id = ui.scope_id().with("cmdfilter"); let mut filter = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_default(); ui.horizontal(|ui| { - ui.label(crate::tl!("Filter")); - ui.text_edit_singleline(&mut filter); + let label = ui.label(crate::tl!("Filter")); + ui.text_edit_singleline(&mut filter).labelled_by(label.id); }); ui.data_mut(|d| d.insert_temp(id, filter.clone())); let f = filter.to_lowercase(); @@ -382,7 +384,7 @@ fn commands(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { fn blocks(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { let mut pick = None; - egui::Window::new(crate::tl!("Blocks")).id(egui::Id::new("Blocks")).open(open).default_size(vec2(320.0, 360.0)).show(ctx, |ui| { + egui::Window::new(crate::tl!("Blocks")).id(egui::Id::unique("Blocks")).open(open).default_size(vec2(320.0, 360.0)).show(ctx, |ui| { let Ok(d) = app.session.doc() else { return }; let names: Vec<&String> = d.blocks.keys().filter(|k| !k.starts_with('*')).collect(); if names.is_empty() { @@ -412,7 +414,7 @@ fn insert_block(app: &mut CadApp, name: &str) { } fn language(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { - egui::Window::new(crate::tl!("Interface Language")).id(egui::Id::new("interface_language")).open(open).resizable(false).show(ctx, |ui| { + egui::Window::new(crate::tl!("Interface Language")).id(egui::Id::unique("interface_language")).open(open).resizable(false).show(ctx, |ui| { egui::ComboBox::from_id_salt("interface_language_choice").selected_text(app.ui.interface_language.name()).show_ui(ui, |ui| { for preference in crate::i18n::Preference::ALL { if ui.selectable_label(app.ui.interface_language == preference, preference.name()).clicked() { diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs index c44f8d1cf..3ff6f3c5a 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -336,7 +336,7 @@ pub fn show(app: &mut crate::CadApp, ui: &mut egui::Ui) { style.border = egui::Stroke::new(1.0, t.border); style.accent = t.accent; style.font = crate::theme::body(); - let output = DockArea::new(egui::Id::new("cadcraft-docking")).show_with_limits( + let output = DockArea::new(egui::Id::unique("cadcraft-docking")).show_with_limits( ui, &workspace.layout, &style, @@ -372,7 +372,7 @@ pub fn show(app: &mut crate::CadApp, ui: &mut egui::Ui) { let _ = sync(&mut workspace, app); let stale = workspace.layout != rendered.layout || workspace.hidden != rendered.hidden; if stale { - craft_ui::docking::cancel_drag::(ui.ctx(), egui::Id::new("cadcraft-docking")); + craft_ui::docking::cancel_drag::(ui.ctx(), egui::Id::unique("cadcraft-docking")); } publish_visibility(app, &workspace); app.ui.docking = workspace; diff --git a/crates/ui-egui/src/dyngrips.rs b/crates/ui-egui/src/dyngrips.rs index f38f19466..7c30447c4 100644 --- a/crates/ui-egui/src/dyngrips.rs +++ b/crates/ui-egui/src/dyngrips.rs @@ -131,7 +131,7 @@ pub(crate) fn menu(app: &mut CadApp, ctx: &egui::Context, xf: &Xf) { let gs = app.session.settings.gripsize as f32; let at = xf.to_screen(g.at) + vec2(-gs, gs * 1.5); let mut chosen: Option = None; - let area = egui::Area::new(egui::Id::new(("dyn-grip-list", h, g.node))).order(egui::Order::Foreground).fixed_pos(at).show(ctx, |ui| { + let area = egui::Area::new(egui::Id::unique(("dyn-grip-list", h, g.node))).order(egui::Order::Foreground).fixed_pos(at).show(ctx, |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { for c in &g.choices { if ui.selectable_label(c.eq_ignore_ascii_case(¤t), c.as_str()).clicked() { diff --git a/crates/ui-egui/src/exchange.rs b/crates/ui-egui/src/exchange.rs index d78464b58..94b8212ef 100644 --- a/crates/ui-egui/src/exchange.rs +++ b/crates/ui-egui/src/exchange.rs @@ -72,14 +72,14 @@ const FROM_PLOT: &str = "cc_plot_preview_from_plot"; /// return to the dialog. pub(crate) fn preview_plot(app: &mut CadApp, ctx: &egui::Context, params: Value, info: String) { PREVIEW_GEN.fetch_add(1, Ordering::Relaxed); - ctx.data_mut(|d| d.insert_temp(egui::Id::new(FROM_PLOT), FromPlot { params, info })); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique(FROM_PLOT), FromPlot { params, info })); app.ui.dialog = Some("preview".into()); } /// Render the plot preview of the current layout (or model), or of the Plot dialog's settings, /// as a texture. fn render(app: &mut CadApp, ctx: &egui::Context) -> Result<(TextureHandle, String), String> { - if let Some(f) = ctx.data(|d| d.get_temp::(egui::Id::new(FROM_PLOT))) { + if let Some(f) = ctx.data(|d| d.get_temp::(egui::Id::unique(FROM_PLOT))) { let r = app.session.execute("plot", &f.params).map_err(|e| e.to_string())?; let png = r["data"].as_str().and_then(cadcraft_engine::cmd::file::base64_decode).ok_or("the preview has no image")?; let img = image::load_from_memory_with_format(&png, image::ImageFormat::Png).map_err(|e| e.to_string())?.to_rgba8(); @@ -101,7 +101,7 @@ fn render(app: &mut CadApp, ctx: &egui::Context) -> Result<(TextureHandle, Strin /// The Plot Preview window: the sheet as it plots, with Print and Close. pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { - let id = egui::Id::new("cc_plot_preview"); + let id = egui::Id::unique("cc_plot_preview"); let generation = PREVIEW_GEN.load(Ordering::Relaxed); let cached = ctx.data(|d| d.get_temp::(id)).filter(|p| p.generation == generation); let preview = match cached { @@ -137,11 +137,11 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { if close || print { *open = false; } - let from_plot = ctx.data(|d| d.get_temp::(egui::Id::new(FROM_PLOT))).is_some(); + let from_plot = ctx.data(|d| d.get_temp::(egui::Id::unique(FROM_PLOT))).is_some(); if !*open { ctx.data_mut(|d| { d.remove::(id); - d.remove::(egui::Id::new(FROM_PLOT)); + d.remove::(egui::Id::unique(FROM_PLOT)); }); } if from_plot && !*open { diff --git a/crates/ui-egui/src/hatchdlg.rs b/crates/ui-egui/src/hatchdlg.rs index 6a0ad2206..b31f56b1b 100644 --- a/crates/ui-egui/src/hatchdlg.rs +++ b/crates/ui-egui/src/hatchdlg.rs @@ -430,7 +430,7 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { }; let title = if st.edit.is_empty() { crate::tl!("Hatch and Gradient") } else { crate::tl!("Hatch Edit") }; let mut action = Action::None; - egui::Window::new(title).id(egui::Id::new("hatch_dialog")).open(open).collapsible(false).resizable(false).show(ctx, |ui| { + egui::Window::new(title).id(egui::Id::unique("hatch_dialog")).open(open).collapsible(false).resizable(false).show(ctx, |ui| { ui.horizontal_top(|ui| { ui.vertical(|ui| { ui.set_width(340.0); @@ -487,12 +487,12 @@ fn fill_ui(ui: &mut egui::Ui, f: &mut Fill, action: &mut Action) { if f.gradient { egui::Grid::new("hatch_gradient").num_columns(2).spacing(vec2(12.0, 6.0)).show(ui, |ui| { ui.label(crate::tl!("Color 1")); - if let Some(c) = crate::layers::color_button(ui, egui::Id::new("hatch_color1"), f.color1, false, 160.0) { + if let Some(c) = crate::layers::color_button(ui, egui::Id::unique("hatch_color1"), f.color1, false, 160.0) { f.color1 = c; } ui.end_row(); ui.label(crate::tl!("Color 2")); - if let Some(c) = crate::layers::color_button(ui, egui::Id::new("hatch_color2"), f.color2, false, 160.0) { + if let Some(c) = crate::layers::color_button(ui, egui::Id::unique("hatch_color2"), f.color2, false, 160.0) { f.color2 = c; } ui.end_row(); @@ -853,7 +853,7 @@ mod tests { let (mut app, _) = app_with_rect(); open_create(&mut app, gradient); let ctx = frames(&mut app); - let r = ctx.memory(|m| m.area_rect(egui::Id::new("hatch_dialog"))).unwrap(); + let r = ctx.memory(|m| m.area_rect(egui::Id::unique("hatch_dialog"))).unwrap(); // The screen is 1000 points high: the window keeps to its content. assert!(r.height() < 700.0, "gradient {gradient}: {r:?}"); } diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 7044017d3..85856c30e 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -323,8 +323,9 @@ fn live_switch_translates_the_shell_without_mutating_drawing_or_automation() { // Without a font for its script, a language stays chosen and the interface shows English. let shown = app.language(); assert_eq!(shown, if crate::theme::can_show(language.code) { language.code } else { "en" }); - assert_eq!(app.fonts_language, Some(shown)); let text = render(&mut app, &ctx); + // Fonts are a frame-only concern: the render above installs them. + assert_eq!(app.fonts_language, Some(shown)); for source in ["Properties", "File"] { assert!(text.contains(tr(shown, source)), "{}: {source}: {text}", language.code); } @@ -345,8 +346,8 @@ fn live_switch_translates_the_shell_without_mutating_drawing_or_automation() { let _ = crate::control::handle(&mut app, &ctx, &request); assert_eq!(app.ui.interface_language, Preference::parse(code).unwrap()); app.logic(&ctx); - assert_eq!(app.fonts_language, Some(app.language())); render(&mut app, &ctx); + assert_eq!(app.fonts_language, Some(app.language())); } assert!(render(&mut app, &ctx).contains("Properties")); // The menu tree used by the control channel keeps its original labels and IDs. @@ -409,9 +410,9 @@ fn interface_fonts_cover_every_letter_in_each_catalog() { for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] { // In egui 0.36, has_glyph can report a false negative for glyphs in the same // face as the replacement character. Check the family's actual character maps. - let mut font = view.fonts.font(&family); + let chars = view.characters(&family); for c in &letters { - if !font.characters().contains_key(c) { + if !chars.contains_key(c) { missing.push(format!("{family:?}: {c} (U+{:04X})", u32::from(*c))); } } @@ -470,9 +471,9 @@ fn korean_ui_fonts_cover_the_language_picker_in_both_families() { let missing = ctx.fonts_mut(|view| { let mut missing = Vec::new(); for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] { - let mut font = view.fonts.font(&family); + let chars = view.characters(&family); for c in "한국어".chars() { - if !font.characters().contains_key(&c) { + if !chars.contains_key(&c) { missing.push(format!("{family:?}: {c}")); } } @@ -481,3 +482,27 @@ fn korean_ui_fonts_cover_the_language_picker_in_both_families() { }); assert!(missing.is_empty(), "missing Korean picker glyphs: {}", missing.join(", ")); } + +#[test] +fn production_font_chain_covers_latin_and_shortcut_symbols() { + // Exercise the same chain the app installs: the workspace egui dependency enables + // `default_fonts` and `monochrome_emoji_fonts` in production, so web and native builds + // always start with bounded Latin and monochrome icon faces instead of an empty family. + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, "en"); + let mut output = ctx.run_ui(Default::default(), |_| {}); + output.textures_delta.clear(); + ctx.fonts_mut(|view| { + for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] { + for c in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".chars() { + assert!(view.has_glyph(&egui::FontId::new(12.0, family.clone()), c), "Latin {c:?} missing in {family:?}"); + } + } + // Proportional shortcut labels (`Button::shortcut_text` and the translated menus) use + // these symbols: ⌘ from the monochrome icon face, ⇧ from Hack as a bounded fallback. + for c in ['\u{2318}', '\u{21e7}', '\u{21a9}', '\u{2026}', '\u{2212}'] { + assert!(view.has_glyph(&egui::FontId::proportional(12.0), c), "U+{:04X} missing in Proportional", c as u32); + } + assert!(view.has_glyph(&egui::FontId::monospace(12.0), '\u{21e7}'), "U+21E7 shift symbol missing in Monospace"); + }); +} diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index df2e31184..1f018df2e 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -683,7 +683,9 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, dim: bool) { LayerFreeze => pen.text(x, y, "❄", 9.0, Color32::from_rgb(0x9f, 0xd4, 0xff)), LayerLock => pen.rect(x - 3.0, y - 1.0, 6.0, 5.0, Color32::from_rgb(0xe6, 0xb4, 0x3c)), LayerMatch => pen.la(x - 4.0, y, x + 4.0, y), - MakeCurrent => pen.text(x, y, "✓", 9.0, Color32::from_rgb(0x6f, 0xe0, 0x6f)), + // Drawn rather than a "✓" glyph: no bundled font has U+2713, and a missing + // glyph panics under epaint's strict policy when no system font supplies one. + MakeCurrent => pen.poly(&[(x - 3.0, y), (x - 1.0, y + 2.0), (x + 3.0, y - 2.5)], false, Color32::from_rgb(0x6f, 0xe0, 0x6f)), _ => { pen.arc(x, y, 3.5, 90.0, 360.0, a); } @@ -967,8 +969,8 @@ pub fn button(ui: &mut egui::Ui, icon: Icon, size: f32, tooltip: &str, selected: pub fn describe_control(ui: &egui::Ui, response: &egui::Response, label: &str, selected: Option, tab: bool) { let label = crate::i18n::t(label); response.widget_info(|| match selected { - Some(selected) => egui::WidgetInfo::selected(egui::WidgetType::Button, response.enabled(), selected, label), - None => egui::WidgetInfo::labeled(egui::WidgetType::Button, response.enabled(), label), + Some(selected) => egui::WidgetInfo::selected(egui::Role::Button, response.enabled(), selected, label), + None => egui::WidgetInfo::labeled(egui::Role::Button, response.enabled(), label), }); if tab { ui.ctx().accesskit_node_builder(response.id, |node| { diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 6cadfeac1..201b5f570 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -218,7 +218,7 @@ fn natural_lower(s: &str) -> String { /// The Layer Properties Manager window. pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { let t = Tokens::get(); - let sid = egui::Id::new("lpm_state"); + let sid = egui::Id::unique("lpm_state"); let mut st: LpmState = ctx.data_mut(|d| d.get_temp(sid)).unwrap_or_default(); let mut actions: Vec<(&str, Value)> = Vec::new(); let Ok(dst) = app.session.state() else { @@ -249,7 +249,7 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { None => "Layer Properties Manager", }; egui::Window::new(crate::i18n::t(title)) - .id(egui::Id::new("lpm_window")) + .id(egui::Id::unique("lpm_window")) .open(open) .default_size(vec2(1180.0, 440.0)) .min_width(640.0) @@ -302,7 +302,8 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { { actions.push(("layerstate.delete", json!({ "name": s }))); } - ui.add(egui::TextEdit::singleline(&mut st.state_name).hint_text(crate::tl!("New state name")).desired_width(120.0)); + ui.add(egui::TextEdit::singleline(&mut st.state_name).hint_text(crate::tl!("New state name")).desired_width(120.0)) + .on_hover_text(crate::tl!("New state name")); if ui.add_enabled(!st.state_name.trim().is_empty(), egui::Button::new(crate::tl!("Save State"))).clicked() { let n = st.state_name.trim().to_string(); actions.push(("layerstate.save", json!({ "name": n }))); @@ -310,7 +311,8 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { st.state_name.clear(); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.add(egui::TextEdit::singleline(&mut st.search).hint_text(crate::tl!("Search for layer")).desired_width(160.0)); + ui.add(egui::TextEdit::singleline(&mut st.search).hint_text(crate::tl!("Search for layer")).desired_width(160.0)) + .on_hover_text(crate::tl!("Search for layer")); icons::paint( ui.painter(), Rect::from_center_size(ui.cursor().right_center() - vec2(8.0, 0.0), vec2(14.0, 14.0)), @@ -439,7 +441,9 @@ fn row( // Name (double-click or F2-style rename). match st.renaming.clone() { Some((n, mut buf)) if n.eq_ignore_ascii_case(&name) => { - let r = ui.add(egui::TextEdit::singleline(&mut buf).desired_width(150.0).id(egui::Id::new(("lpm_rename", &name)))); + let r = ui + .add(egui::TextEdit::singleline(&mut buf).desired_width(150.0).id(egui::Id::unique(("lpm_rename", &name)))) + .accessible_name(crate::tl!("Name")); if !r.has_focus() && !r.lost_focus() { r.request_focus(); } @@ -503,7 +507,7 @@ fn row( actions.push(("layer.set", json!({ "name": name, "plot": !l.plot }))); } // Color. - if let Some(c) = color_button(ui, egui::Id::new(("lpm_color", &name)), l.color, false, 96.0) { + if let Some(c) = color_button(ui, egui::Id::unique(("lpm_color", &name)), l.color, false, 96.0) { actions.push(("layer.set", json!({ "name": name, "color": c.name() }))); } // Linetype. @@ -524,7 +528,7 @@ fn row( }) .inner; egui::Popup::from_toggle_button_response(<_resp) - .id(egui::Id::new(("lpm_lt", &name))) + .id(egui::Id::unique(("lpm_lt", &name))) .width(240.0) .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) .show(|ui| { @@ -580,7 +584,7 @@ fn row( }) .inner; egui::Popup::from_toggle_button_response(&lw_resp) - .id(egui::Id::new(("lpm_lw", &name))) + .id(egui::Id::unique(("lpm_lw", &name))) .width(170.0) .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) .show(|ui| { @@ -611,7 +615,7 @@ fn row( }); }); // Transparency (commits when the drag or edit ends). - let tid = egui::Id::new(("lpm_tr", &name)); + let tid = egui::Id::unique(("lpm_tr", &name)); let mut tr = ui.data_mut(|d| d.get_temp::(tid)).unwrap_or(l.transparency); let r = ui.add(egui::DragValue::new(&mut tr).range(0..=90).speed(0.5)); if r.dragged() || r.has_focus() { @@ -632,7 +636,7 @@ fn row( } let vc = v.layer_colors.iter().find(|(n, _)| n.eq_ignore_ascii_case(&name)).map(|(_, c)| *c); ui.horizontal(|ui| { - if let Some(c) = color_button(ui, egui::Id::new(("lpm_vpcolor", &name)), vc.unwrap_or(l.color), false, 96.0) { + if let Some(c) = color_button(ui, egui::Id::unique(("lpm_vpcolor", &name)), vc.unwrap_or(l.color), false, 96.0) { actions.push(("vplayer", json!({ "handle": h.hex(), "colors": { name.clone(): c.name() } }))); } if vc.is_some() && ui.small_button("×").on_hover_text(crate::tl!("Remove the viewport override")).clicked() { @@ -653,7 +657,9 @@ fn row( // Description (double-click to edit). match st.describing.clone() { Some((n, mut buf)) if n.eq_ignore_ascii_case(&name) => { - let r = ui.add(egui::TextEdit::singleline(&mut buf).desired_width(200.0).id(egui::Id::new(("lpm_desc", &name)))); + let r = ui + .add(egui::TextEdit::singleline(&mut buf).desired_width(200.0).id(egui::Id::unique(("lpm_desc", &name)))) + .accessible_name(crate::tl!("Description")); if !r.has_focus() && !r.lost_focus() { r.request_focus(); } @@ -750,7 +756,7 @@ mod tests { dialog(&mut app, ui.ctx(), &mut open); }); out.textures_delta.clear(); - let id = egui::LayerId::new(egui::Order::Middle, egui::Id::new("lpm_window")); + let id = egui::LayerId::new(egui::Order::Middle, egui::Id::unique("lpm_window")); heights.push(ctx.memory(|m| m.area_rect(id.id)).map_or(0.0, |r| r.height())); } let (first, last) = (heights[5], heights[29]); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9f3204b55..bb01d5605 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -13,6 +13,7 @@ pub mod animation; pub mod autosave; pub mod bedit; pub mod canvas; +pub mod capture; pub mod chrome; pub mod clipboard; pub mod closing; @@ -24,6 +25,7 @@ pub mod dialogs; pub mod docking; pub mod hatchdlg; mod mtext_editor; +mod panel_state; pub mod spacemouse; // The External References palette. pub mod xrefs; @@ -210,15 +212,28 @@ pub struct CadApp { pub opening: opening::Opening, pub integrated_titlebar: bool, control_rx: Option>, - pending_shots: Vec<(u64, Option, std::sync::mpsc::Sender, f64)>, - queued_shots: Vec<(u64, f64, u32)>, - shot_token: u64, + /// Fresh surface and identity snapshot (see [`capture`]). + pub(crate) capture_geometry: Option, + /// Monotonic surface epoch, bumped whenever the window/viewport geometry changes. + pub(crate) capture_epoch: u64, + /// Monotonic identity epoch, bumped whenever the drawing or focused widget changes (even + /// when a later change returns to the same value). + pub(crate) capture_identity_epoch: u64, + /// Admitted `ui.screenshot` requests, including completed-but-undrained ones. + pub(crate) shots: capture::Shots, pub synthetic: Vec, + /// One synthetic batch has been put into `RawInput` and is waiting for a real `App::ui` call. + synthetic_injected: bool, + /// Counts settled UI passes (one per painted frame; never hidden logic-only ticks or + /// discarded passes). + pub ui_frames: u64, /// Caps the frame rate when presenting without vsync (see [`gpu::FrameCap`]). #[cfg(not(target_arch = "wasm32"))] pub frame_cap: Option, /// Language whose interface fonts are installed (`None` until the first frame). fonts_language: Option<&'static str>, + /// Language last given to egui (and so to the AccessKit root; `None` until the first frame). + shown_language: Option<&'static str>, /// The theme last installed into egui (`None` until the first frame). shown_theme: Option, /// Whether the last applied theme was handed to egui as the System preference. @@ -266,13 +281,17 @@ impl CadApp { opening: opening::Opening::default(), integrated_titlebar: false, control_rx: None, - pending_shots: Vec::new(), - queued_shots: Vec::new(), - shot_token: 0, + capture_geometry: None, + capture_epoch: 0, + capture_identity_epoch: 0, + shots: capture::Shots::default(), synthetic: Vec::new(), + synthetic_injected: false, + ui_frames: 0, #[cfg(not(target_arch = "wasm32"))] frame_cap: None, fonts_language: None, + shown_language: None, shown_theme: None, shown_follows_system: false, shown_animations: None, @@ -416,6 +435,11 @@ impl CadApp { self.status = Some((s.into(), now_ms())); } + /// Cancel queued/in-flight screenshots (host exit). Late callbacks write nothing. + pub fn cancel_screenshots(&mut self) { + capture::cancel_all(self); + } + /// Open the drawing at `path`. Natively the file is read on a worker thread while an /// "Opening ..." card shows ([`opening`]); [`Self::finish_opening`] adds it when ready. On the /// web it opens at once. @@ -532,22 +556,121 @@ impl CadApp { recent.truncate(RECENT_FILES); } - /// Per-frame logic before layout. + /// Consume asynchronous work on every logic callback, including hidden `run_logic` ticks. + /// + /// Consume-based queues and screenshot settlement run on **every** call, whichever pass + /// delivers it. Frame work (LISP progress, autosave, close requests, screenshot issuance + /// pacing, hidden repaints) runs once per host frame: eframe calls `App::logic` inside every + /// pass of a multi-pass frame, and also on hidden logic-only ticks where no pass runs and the + /// pass index is zero. `current_pass_index() == 0` is therefore the frame guard: true on the + /// first pass and every hidden tick, false on later passes. pub fn logic(&mut self, ctx: &egui::Context) { - // A long LISP routine runs a slice per frame, so the window stays live (and Esc stops it). - self.session.lisp_tick(); - if self.session.lisp_busy() { - ctx.request_repaint(); + // Consume-based queues: each item is handled once, whichever callback delivers it. A + // reply arriving during a later pass is never starved until the next frame. + self.drain_control(ctx); + self.finish_opening(); + // Finishing an open switches to the new drawing. + capture::note_identity(self, ctx); + capture::drain(self, ctx); + self.gpu_fault_tick(ctx); + managers::plot_window_resume(self, ctx); + hatchdlg::resume(self); + // What follows is frame work, once per layout frame or hidden tick. + if ctx.current_pass_index() != 0 { + return; } - // Closing the window (title bar, File ▸ Exit, Cmd+Q) with unsaved changes asks first. + // Closing the window with unsaved changes asks first. `run_logic` refreshes viewport + // close/focus state, so this must also work while hidden. if ctx.input(|i| i.viewport().close_requested()) && self.request_quit() { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); } + // A long AutoLISP routine runs one bounded slice per frame, including hidden web/native + // ticks; it must not depend on a paint or `RequestPaintWhileHidden`. + self.session.lisp_tick(); + // Time-based and idempotent by its own deadline; keeps autosave alive while hidden. + self.autosave_tick(ctx); + if !self.synthetic.is_empty() || self.synthetic_injected { + request_paint_while_hidden(ctx); + } + if self.session.lisp_busy() || self.opening.busy() { + request_paint_while_hidden(ctx); + } + capture::request_hidden_paint(self, ctx); + } + + /// Input hook: refresh the raw surface snapshot and release at most one synthetic batch. + /// + /// Eframe keeps the unused `RawInput` across hidden logic-only ticks, so a batch already in + /// the buffer is never injected again; the next batch waits for a real `ui` pass to consume + /// the previous one. + pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + capture::note_raw_geometry(self, ctx, raw); + if self.synthetic_injected || self.synthetic.is_empty() { + return; + } + let n = match self.synthetic.first() { + Some(egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. }) => 1, + _ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1), + }; + if let Some(egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() { + raw.events.push(egui::Event::PointerMoved(*p)); + } + // A synthetic click holds its Shift/Ctrl (Shift+click a grip); the next move lets go. + match self.synthetic.first() { + Some(egui::Event::PointerButton { modifiers, .. }) => raw.events.push(egui::Event::ModifiersChanged(*modifiers)), + Some(egui::Event::PointerMoved(_)) => raw.events.push(egui::Event::ModifiersChanged(egui::Modifiers::default())), + _ => {} + } + let n = n.min(self.synthetic.len()); + raw.events.extend(self.synthetic.drain(..n)); + self.synthetic_injected = true; + } + + /// Lay out the whole window. This is the only place with a real UI pass. + pub fn ui(&mut self, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + let first_pass = ctx.current_pass_index() == 0; + if first_pass { + self.ui_frame_begin(ui); + } else { + // A second pass must render the state the first pass produced. egui's raw input is + // empty there, but the smoothed scroll delta can persist; never pan/zoom twice. + ctx.input_mut(|input| { + input.smooth_scroll_delta = egui::Vec2::ZERO; + input.raw.events.retain(|event| !matches!(event, egui::Event::MouseWheel { .. } | egui::Event::Zoom(_))); + }); + } + // The settled geometry and ppp of this pass, before drawing and after the frame's saved + // interface zoom was applied (egui applies a new zoom at the start of a pass). + capture::note_pass_geometry(self, &ctx); + i18n::with_language(self.language(), || self.draw(ui)); + if first_pass { + // The batch reached an actual Ui callback; a discarded pass still consumed it. + self.synthetic_injected = false; + } + // `will_discard()` is false on the settled (final) pass of a frame, whether the frame is + // single-pass or the second pass after a layout discard; a discarded pass is skipped. + if !ctx.will_discard() { + self.ui_frames = self.ui_frames.saturating_add(1); + capture::issue(self, &ctx); + } + } + + /// Frame-only work, run from the first real UI pass (never from a hidden logic tick). + fn ui_frame_begin(&mut self, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + let ctx = &ctx; let language = self.language(); if self.fonts_language != Some(language) { theme::install_fonts(ctx, language); self.fonts_language = Some(language); } + // The AccessKit root carries the language the interface is actually shown in; egui copies + // it onto the root node at the end of the pass. + if self.shown_language != Some(language) { + ctx.set_language(Some(language.to_string())); + self.shown_language = Some(language); + } // Resolved every frame, so a System choice follows the OS appearance live (winit and the // browser report changes as input); the saved choice itself never changes here. let shown = self.ui.theme.resolve(ctx.system_theme()); @@ -575,57 +698,31 @@ impl CadApp { ctx.set_zoom_factor(scale); self.shown_interface_scale = Some(scale); } - self.drain_control(ctx); - self.finish_opening(); - self.opening.show(ctx); - if !self.synthetic.is_empty() { - ctx.request_repaint(); - } - self.collect_screenshots(ctx); - self.issue_screenshots(ctx); - self.gpu_fault_tick(ctx); - managers::plot_window_resume(self, ctx); - hatchdlg::resume(self); - self.autosave_tick(ctx); - menus::shortcuts(self, ctx); - #[cfg(not(target_arch = "wasm32"))] - for f in ctx.input(|i| i.raw.dropped_files.clone()) { - let p = f.path().to_string_lossy().to_string(); - if !p.is_empty() { - self.open_path(&p); - } - } - } - - /// Wait for the frame cap (if any), then inject synthetic events (one pointer event per frame). - pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { + // Wait for the frame cap on a painted pass, where a wait cannot delay a hidden tick. #[cfg(not(target_arch = "wasm32"))] if let Some(cap) = &mut self.frame_cap { cap.wait(); } - if self.synthetic.is_empty() { - return; - } - let n = match self.synthetic.first() { - Some(egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. }) => 1, - _ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1), - }; - if let Some(egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() { - raw.events.push(egui::Event::PointerMoved(*p)); - } - // A synthetic click holds its Shift/Ctrl (Shift+click a grip); the next move lets go. - match self.synthetic.first() { - Some(egui::Event::PointerButton { modifiers, .. }) => raw.events.push(egui::Event::ModifiersChanged(*modifiers)), - Some(egui::Event::PointerMoved(_)) => raw.events.push(egui::Event::ModifiersChanged(egui::Modifiers::default())), - _ => {} - } - let n = n.min(self.synthetic.len()); - raw.events.extend(self.synthetic.drain(..n)); + panel_state::migrate(ui); + self.ui_input(ctx); } - /// Lay out the whole window. - pub fn ui(&mut self, ui: &mut egui::Ui) { - i18n::with_language(self.language(), || self.draw(ui)); + /// Input-derived work, once per layout frame. + fn ui_input(&mut self, ctx: &egui::Context) { + menus::shortcuts(self, ctx); + #[cfg(not(target_arch = "wasm32"))] + { + let dropped = ctx.input_mut(|input| std::mem::take(&mut input.raw.dropped_files)); + for file in dropped { + let path = file.path().to_string_lossy().to_string(); + if !path.is_empty() { + self.open_path(&path); + } + } + } + if !self.synthetic.is_empty() { + ctx.request_repaint(); + } } fn draw(&mut self, ui: &mut egui::Ui) { @@ -653,6 +750,7 @@ impl CadApp { chrome::start_page(self, ui); } }); + self.opening.show(ui.ctx()); dialogs::show(self, ui.ctx()); palettes::patterns::prompt_picker(self, ui.ctx()); closing::prompt(self, ui.ctx()); @@ -673,85 +771,31 @@ impl CadApp { control::Outcome::Done(v) => { let _ = reply.send(v); } - control::Outcome::Screenshot { path } => { - self.shot_token += 1; - let token = self.shot_token; - self.queued_shots.push((token, now_ms() + 120.0, 0)); - self.pending_shots.push((token, path, reply, now_ms() + 8000.0)); - } + control::Outcome::Screenshot { path } => capture::admit(self, ctx, path, reply), } + // A command can switch drawings or move the focus; a screenshot queued before it must + // see that transition even if a later command returns to the same drawing. + capture::note_identity(self, ctx); ctx.request_repaint(); } self.control_rx = Some(rx); } - - fn issue_screenshots(&mut self, ctx: &egui::Context) { - let now = now_ms(); - // Wait for a background rebuild for the current view, so the shot shows the drawing at - // that view, but not past the reply's deadline. - let building = self.canvas.building(); - self.queued_shots.retain_mut(|(token, at, frames)| { - *frames += 1; - if now >= *at && *frames >= 3 && (!building || now >= *at + SHOT_REBUILD_WAIT_MS) { - ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(*token))); - false - } else { - true - } - }); - if !self.queued_shots.is_empty() || !self.pending_shots.is_empty() { - ctx.request_repaint_after(std::time::Duration::from_millis(16)); - } - } - - fn collect_screenshots(&mut self, ctx: &egui::Context) { - if self.pending_shots.is_empty() { - return; - } - let events: Vec<_> = ctx.input(|i| { - i.raw - .events - .iter() - .filter_map(|e| match e { - egui::Event::Screenshot { user_data, image, .. } => { - let token = user_data.data.as_ref().and_then(|d| d.downcast_ref::()).copied()?; - Some((token, image.clone())) - } - _ => None, - }) - .collect() - }); - for (token, image) in events { - if let Some(i) = self.pending_shots.iter().position(|(t, ..)| *t == token) { - let (_, path, reply, _) = self.pending_shots.remove(i); - let _ = reply.send(control::save_screenshot(&image, path.as_deref())); - } - } - let now = now_ms(); - self.pending_shots.retain(|(_, _, reply, deadline)| { - if now < *deadline { - return true; - } - let _ = reply.send(json!({"ok": false, "error": "no frame was presented (screen locked or window hidden); use ui.render"})); - false - }); - } } -/// How long a control-channel screenshot waits for a background rebuild before it is taken -/// anyway (the reply itself times out after 8 s). -const SHOT_REBUILD_WAIT_MS: f64 = 6000.0; - -pub fn now_ms() -> f64 { +/// Ask for a painted frame while no frame is being painted. Native eframe honours the viewport +/// command; the pinned web runner logs it as unhandled, so web only schedules a repaint (hidden +/// work still advances from `logic`). +fn request_paint_while_hidden(ctx: &egui::Context) { #[cfg(not(target_arch = "wasm32"))] - { - use std::time::{SystemTime, UNIX_EPOCH}; - SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0) - } - #[cfg(target_arch = "wasm32")] - { - 0.0 - } + ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden); + ctx.request_repaint(); +} + +/// Monotonic milliseconds since the first call, on native and web (`performance.now` on wasm). +/// Durations (screenshot deadlines, status expiry, frame timings) must not follow the wall clock. +pub fn now_ms() -> f64 { + static START: std::sync::OnceLock = std::sync::OnceLock::new(); + START.get_or_init(web_time::Instant::now).elapsed().as_secs_f64() * 1000.0 } /// A LISP source file, by its name. @@ -768,13 +812,138 @@ mod tests { CadApp::new(Session::empty(), Services::default()) } - /// One frame of `logic` with the OS reporting `system` as its appearance. + /// One real UI frame, with the OS reporting `system` as its appearance. fn frame(app: &mut CadApp, ctx: &egui::Context, system: Option) { let raw = egui::RawInput { system_theme: system, ..Default::default() }; - let mut out = ctx.run_ui(raw, |ui| app.logic(ui.ctx())); + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); out.textures_delta.clear(); } + /// One real UI frame whose first pass asks egui for a second layout pass, as a popup or a + /// changed layout does. + fn two_pass_frame(app: &mut CadApp, ctx: &egui::Context) { + let mut discard = true; + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + app.logic(ui.ctx()); + if std::mem::take(&mut discard) { + ui.ctx().request_discard("test: force a second pass"); + } + app.ui(ui); + }); + out.textures_delta.clear(); + } + + /// Eframe runs `App::logic` inside every pass of a multi-pass frame. Frame work (here one + /// AutoLISP slice) belongs to the frame, not to a pass. + #[test] + fn a_two_pass_frame_runs_frame_logic_once() { + use cadcraft_engine::lisp::value::Value; + + let routine = "(setq cc-two 0) (while (< cc-two 400000) (setq cc-two (1+ cc-two)))"; + let start = |app: &mut CadApp| { + let started = app.session.lisp_eval(routine, false, false, Vec::new()).expect("the routine starts"); + assert!(started.is_none(), "a non-blocking routine yields before finishing"); + assert!(app.session.lisp_busy()); + }; + let mut single = CadApp::new(Session::new(), Services::default()); + let mut twice = CadApp::new(Session::new(), Services::default()); + start(&mut single); + start(&mut twice); + let ctx = egui::Context::default(); + frame(&mut single, &ctx, None); + two_pass_frame(&mut twice, &ctx); + let counter = |app: &CadApp| match app.session.lisp_var("CC-TWO") { + Value::Int(n) => n, + other => panic!("CC-TWO is {other:?}, not an integer"), + }; + assert!(counter(&single) > 0, "the frame ran the first slice"); + assert_eq!(counter(&twice), counter(&single), "the second pass did not run a second slice"); + assert_eq!(twice.session.lisp_busy(), single.session.lisp_busy(), "the same amount of the routine ran"); + } + + /// A control request is consumed once per frame, not once per pass. + #[test] + fn a_two_pass_frame_executes_a_control_request_once() { + use std::time::Duration; + + use crate::control::ControlRequest; + + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = CadApp::new(Session::new(), Services::default()).with_control(rx); + let (request, reply) = ControlRequest::new("engine.execute", json!({ "command": "line", "params": { "points": [[0.0, 0.0], [10.0, 0.0]] } })); + tx.send(request).expect("the request is queued"); + let ctx = egui::Context::default(); + two_pass_frame(&mut app, &ctx); + let entities = app.session.doc().map(cadcraft_doc::Drawing::entity_count).unwrap_or_default(); + assert_eq!(entities, 1, "one command ran once, in the first pass"); + let reply = reply.wait(Duration::from_secs(1)); + assert_eq!(reply["ok"], true, "{reply}"); + } + + #[test] + fn hidden_logic_does_not_run_frame_only_work() { + let mut a = app(); + let ctx = egui::Context::default(); + // A logic-only call (what eframe runs for a hidden window) reports pass index zero too. + a.logic(&ctx); + assert_eq!(a.ui_frames, 0, "no real Ui callback ran"); + assert_eq!(a.fonts_language, None, "fonts are a frame-only concern"); + frame(&mut a, &ctx, None); + assert_eq!(a.ui_frames, 1); + assert!(a.fonts_language.is_some()); + } + + #[test] + fn hidden_logic_advances_a_long_lisp_routine_without_a_ui_pass() { + use cadcraft_engine::lisp::value::Value; + + let mut a = CadApp::new(Session::new(), Services::default()); + let ctx = egui::Context::default(); + let started = a + .session + .lisp_eval("(setq cc-hidden 0) (while (< cc-hidden 400000) (setq cc-hidden (1+ cc-hidden)))", false, false, Vec::new()) + .expect("the routine starts"); + assert!(started.is_none(), "a non-blocking routine yields before finishing"); + assert!(a.session.lisp_busy(), "the first slice reached the yield point"); + for _ in 0..2_000 { + if !a.session.lisp_busy() { + break; + } + // What eframe runs for a hidden window: logic only, no `Ui` callback. + a.logic(&ctx); + } + assert!(!a.session.lisp_busy(), "hidden logic ticks carried the routine to the end"); + assert!(matches!(a.session.lisp_var("CC-HIDDEN"), Value::Int(400_000))); + assert_eq!(a.ui_frames, 0, "no frame was painted"); + } + + #[test] + fn one_synthetic_batch_waits_for_a_real_ui_pass() { + let mut a = app(); + let ctx = egui::Context::default(); + a.synthetic.push(egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }); + let mut first = egui::RawInput::default(); + a.raw_input_hook(&ctx, &mut first); + assert!(first.events.iter().any(|event| matches!(event, egui::Event::Key { key: egui::Key::A, .. }))); + // A hidden tick (or a second raw hook before the UI) must not append another batch. + let mut second = egui::RawInput::default(); + a.raw_input_hook(&ctx, &mut second); + assert!(second.events.is_empty(), "the injected batch is still pending"); + let mut out = ctx.run_ui(first, |ui| { + a.logic(ui.ctx()); + a.ui(ui); + }); + out.textures_delta.clear(); + // The batch was consumed by a real Ui callback; a new synthetic event can now be injected. + a.synthetic.push(egui::Event::Key { key: egui::Key::B, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }); + let mut third = egui::RawInput::default(); + a.raw_input_hook(&ctx, &mut third); + assert!(third.events.iter().any(|event| matches!(event, egui::Event::Key { key: egui::Key::B, .. }))); + } + #[test] fn theme_choice_persists_and_drives_tokens() { let mut a = app(); diff --git a/crates/ui-egui/src/managers.rs b/crates/ui-egui/src/managers.rs index 4e008ddda..98924cb32 100644 --- a/crates/ui-egui/src/managers.rs +++ b/crates/ui-egui/src/managers.rs @@ -74,16 +74,18 @@ fn fmt_num(v: f64) -> String { } /// A number field that commits on Enter or focus loss. -fn num_field(ui: &mut egui::Ui, id: impl egui::AsId, v: f64) -> Option { - expr_field(ui, egui::Id::new(id), &fmt_num(v)).and_then(|t| t.parse::().ok()).filter(|v| v.is_finite()) +/// +/// `label` is the visible label's widget id (see [`expr_field`]). +fn num_field(ui: &mut egui::Ui, id: impl egui::AsIdSalt, v: f64, label: Option) -> Option { + expr_field(ui, ui.make_persistent_id(id), &fmt_num(v), label).and_then(|t| t.parse::().ok()).filter(|v| v.is_finite()) } fn temp(ctx: &egui::Context, id: &str) -> Option { - ctx.data_mut(|d| d.get_temp::(egui::Id::new(id))) + ctx.data_mut(|d| d.get_temp::(egui::Id::unique(id))) } fn set_temp(ctx: &egui::Context, id: &str, v: T) { - ctx.data_mut(|d| d.insert_temp(egui::Id::new(id), v)); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique(id), v)); } fn no_drawing(ctx: &egui::Context, title: &str, open: &mut bool) { @@ -172,8 +174,8 @@ fn linetype(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { }); ui.separator(); ui.horizontal(|ui| { - ui.label("Global scale factor"); - if let Some(v) = num_field(ui, "ltmgr_scale", ltscale).filter(|v| *v > 0.0) { + let label = ui.label("Global scale factor"); + if let Some(v) = num_field(ui, "ltmgr_scale", ltscale, Some(label.id)).filter(|v| *v > 0.0) { action = Some(("ltscale", json!({ "scale": v }))); } }); @@ -275,8 +277,8 @@ fn point_style(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action }); ui.add_space(6.0); ui.horizontal(|ui| { - ui.label("Point Size"); - if let Some(v) = num_field(ui, "ptype_size", size).filter(|v| *v > 0.0) { + let label = ui.label("Point Size"); + if let Some(v) = num_field(ui, "ptype_size", size, Some(label.id)).filter(|v| *v > 0.0) { action = set(mode, v, rel); } ui.label(if rel { "%" } else { "units" }); @@ -415,7 +417,7 @@ fn style_manager(ctx: &egui::Context, open: &mut bool, title: &str, cmd: &'stati action = Some((cmd, json!({ "name": sel }))); } left.horizontal(|ui| { - ui.add(egui::TextEdit::singleline(&mut new_name).hint_text("New style name").desired_width(120.0)); + ui.add(egui::TextEdit::singleline(&mut new_name).hint_text("New style name").desired_width(120.0)).on_hover_text("New style name"); let name = new_name.trim().to_string(); let ok = !name.is_empty() && !styles.iter().any(|s| s.name.eq_ignore_ascii_case(&name)); if ui.add_enabled(ok, egui::Button::new("New")).clicked() { @@ -430,8 +432,8 @@ fn style_manager(ctx: &egui::Context, open: &mut bool, title: &str, cmd: &'stati for f in &style.fields { let change = match f { Field::Num(key, label, v) => { - ui.label(*label); - num_field(ui, (cmd, &style.name, *key), *v).filter(|v| *v >= 0.0).map(|v| (*key, json!(v))) + let field_label = ui.label(*label); + num_field(ui, (cmd, &style.name, *key), *v, Some(field_label.id)).filter(|v| *v >= 0.0).map(|v| (*key, json!(v))) } Field::Bool(key, label, v) => { ui.label(""); @@ -502,7 +504,7 @@ fn page_setup(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action // Layout plotting always prints the whole sheet, so only that area is offered. let rows = PageRows { areas: &["layout"], scale: true, center: true }; egui::Grid::new("pagesetup_grid").num_columns(2).spacing(vec2(10.0, 6.0)).show(right, |ui| { - if let Some((k, v)) = page_rows(ui, egui::Id::new(("pagesetup", name)), page, &rows, &choices, None) { + if let Some((k, v)) = page_rows(ui, egui::Id::unique(("pagesetup", name)), page, &rows, &choices, None) { action = Some(("pagesetup", json!({ "layout": name, k: v }))); } }); @@ -666,9 +668,9 @@ fn page_rows( }); ui.end_row(); for (i, label) in [(0, "X offset (mm)"), (1, "Y offset (mm)")] { - ui.label(label); + let field_label = ui.label(label); ui.add_enabled_ui(supported && !*center, |ui| { - if let Some(v) = num_field(ui, ("plot_offset", i), offset_mm[i]) { + if let Some(v) = num_field(ui, ("plot_offset", i), offset_mm[i], Some(field_label.id)) { offset_mm[i] = v; } }) @@ -683,9 +685,9 @@ fn page_rows( } } if rows.scale { - ui.label("Scale"); + let label = ui.label("Scale"); ui.add_enabled_ui(!page.scale_to_fit, |ui| { - if let Some(v) = expr_field(ui, salt.with("scale"), &fmt_num(page.scale)).and_then(|t| parse_ratio(&t)) { + if let Some(v) = expr_field(ui, salt.with("scale"), &fmt_num(page.scale), Some(label.id)).and_then(|t| parse_ratio(&t)) { change = Some(("scale", json!(v))); } }) @@ -955,7 +957,7 @@ fn plot(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { PageRows { areas: &["layout"], scale: false, center: false } }; let placement = model.then_some((&mut st.center, &mut st.offset_mm, offset_supported)); - if let Some((k, v)) = page_rows(ui, egui::Id::new(("plot", st.target.as_str())), &st.page, &rows, &choices, placement) { + if let Some((k, v)) = page_rows(ui, egui::Id::unique(("plot", st.target.as_str())), &st.page, &rows, &choices, placement) { apply_page_change(&mut st.page, k, &v); } if model && st.page.plot_area == "window" { @@ -963,10 +965,10 @@ fn plot(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { ui.label(label); ui.horizontal(|ui| { for (j, axis) in ["X", "Y"].iter().enumerate() { - ui.label(*axis); + let axis_label = ui.label(*axis); let k = i + j; if let Some(slot) = st.window.get_mut(k) - && let Some(v) = num_field(ui, ("plot_window", k), *slot) + && let Some(v) = num_field(ui, ("plot_window", k), *slot, Some(axis_label.id)) { *slot = v; } @@ -1096,8 +1098,8 @@ fn constraint_settings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) - action = set("barsVisible", json!(bars)); } ui.horizontal(|ui| { - ui.label("Constraint bar transparency (%)"); - if let Some(v) = num_field(ui, "csettings_transp", f64::from(s.bar_transparency)) { + let label = ui.label("Constraint bar transparency (%)"); + if let Some(v) = num_field(ui, "csettings_transp", f64::from(s.bar_transparency), Some(label.id)) { action = set("barTransparency", json!(v)); } }); @@ -1124,13 +1126,13 @@ fn constraint_settings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) - } }); egui::Grid::new("csettings_tol").num_columns(2).show(ui, |ui| { - ui.label("Distance tolerance"); - if let Some(v) = num_field(ui, "csettings_dist", s.distance_tolerance) { + let label = ui.label("Distance tolerance"); + if let Some(v) = num_field(ui, "csettings_dist", s.distance_tolerance, Some(label.id)) { action = set("distanceTolerance", json!(v)); } ui.end_row(); - ui.label("Angle tolerance (degrees)"); - if let Some(v) = num_field(ui, "csettings_angle", s.angle_tolerance) { + let label = ui.label("Angle tolerance (degrees)"); + if let Some(v) = num_field(ui, "csettings_angle", s.angle_tolerance, Some(label.id)) { action = set("angleTolerance", json!(v)); } ui.end_row(); diff --git a/crates/ui-egui/src/managers/battman.rs b/crates/ui-egui/src/managers/battman.rs index 2c92bb219..7ec42e414 100644 --- a/crates/ui-egui/src/managers/battman.rs +++ b/crates/ui-egui/src/managers/battman.rs @@ -21,17 +21,19 @@ struct Row { } fn temp(ctx: &egui::Context, id: &str) -> Option { - ctx.data_mut(|d| d.get_temp::(egui::Id::new(id))) + ctx.data_mut(|d| d.get_temp::(egui::Id::unique(id))) } fn set_temp(ctx: &egui::Context, id: &str, v: T) { - ctx.data_mut(|d| d.insert_temp(egui::Id::new(id), v)); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique(id), v)); } /// A text field committing on Enter / focus loss when changed (an empty value too). -fn text_field(ui: &mut egui::Ui, id: egui::Id, value: &str) -> Option { +/// +/// `label` is the visible row label's widget id, so the field has an accessible name. +fn text_field(ui: &mut egui::Ui, id: egui::Id, value: &str, label: egui::Id) -> Option { let mut buf = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_else(|| value.to_string()); - let resp = ui.add(egui::TextEdit::singleline(&mut buf).id(id.with("te")).desired_width(180.0)); + let resp = ui.add(egui::TextEdit::singleline(&mut buf).id(id.with("te")).desired_width(180.0)).labelled_by(label); if resp.has_focus() { ui.data_mut(|d| d.insert_temp(id, buf.clone())); } else { @@ -153,19 +155,19 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Option< ui.label(RichText::new(format!("Edit attribute {}", r.tag)).strong()); let edit = |key: &str, v: Value| json!({ "name": block, "action": "edit", "tag": r.tag, key: v }); egui::Grid::new("battman_edit").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { - ui.label("Tag"); - if let Some(v) = text_field(ui, egui::Id::new(("battman_tag", &block, &r.tag)), &r.tag) { + let label = ui.label("Tag"); + if let Some(v) = text_field(ui, egui::Id::unique(("battman_tag", &block, &r.tag)), &r.tag, label.id) { action = act(edit("newTag", json!(v)), apply); sel = Some(v.trim().to_ascii_uppercase()); } ui.end_row(); - ui.label("Prompt"); - if let Some(v) = text_field(ui, egui::Id::new(("battman_prompt", &block, &r.tag)), &r.prompt) { + let label = ui.label("Prompt"); + if let Some(v) = text_field(ui, egui::Id::unique(("battman_prompt", &block, &r.tag)), &r.prompt, label.id) { action = act(edit("prompt", json!(v)), apply); } ui.end_row(); - ui.label("Default"); - if let Some(v) = text_field(ui, egui::Id::new(("battman_default", &block, &r.tag)), &r.default) { + let label = ui.label("Default"); + if let Some(v) = text_field(ui, egui::Id::unique(("battman_default", &block, &r.tag)), &r.default, label.id) { action = act(edit("default", json!(v)), apply); } ui.end_row(); @@ -180,8 +182,8 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Option< } }); ui.end_row(); - ui.label("Height"); - let height = text_field(ui, egui::Id::new(("battman_height", &block, &r.tag)), &fmt_num(r.height)); + let label = ui.label("Height"); + let height = text_field(ui, egui::Id::unique(("battman_height", &block, &r.tag)), &fmt_num(r.height), label.id); if let Some(h) = height.and_then(|v| v.trim().parse::().ok()).filter(|h| h.is_finite() && *h > 0.0) { action = act(edit("height", json!(h)), apply); } diff --git a/crates/ui-egui/src/mtext_editor.rs b/crates/ui-egui/src/mtext_editor.rs index 9e7db5803..9faea19a0 100644 --- a/crates/ui-egui/src/mtext_editor.rs +++ b/crates/ui-egui/src/mtext_editor.rs @@ -23,6 +23,42 @@ const SWATCHES: [u16; 7] = [1, 2, 3, 4, 5, 6, 7]; /// Ruler snap, in text heights. const SNAP: f64 = 0.25; +/// Keep the actual diameter character in editable text even without optional fonts. +/// This original circle-and-slash rasterizer is a last fallback, after installed fonts. +pub(crate) fn install_diameter_glyph(ctx: &egui::Context) { + ctx.add_glyph_rasterizer(egui::GlyphRasterizer::new("cadcraft::diameter", |request| { + if request.cluster != "⌀" || !request.font_size_px.is_finite() || !(1.0..=1024.0).contains(&request.font_size_px) { + return None; + } + let size = request.font_size_px; + let side = (size * 0.8).ceil() as usize + 2; + let center = vec2(1.0 + size * 0.4, 1.0 + size * 0.4); + let radius = size * 0.3; + let half_stroke = (size * 0.05).max(1.0) * 0.5; + let start = center + vec2(-radius, radius); + let segment = vec2(2.0 * radius, -2.0 * radius); + let mut pixels = Vec::with_capacity(side * side); + for y in 0..side { + for x in 0..side { + let point = vec2(x as f32 + 0.5, y as f32 + 0.5); + let ring_distance = ((point - center).length() - radius).abs(); + let along = ((point - start).dot(segment) / segment.length_sq()).clamp(0.0, 1.0); + let slash_distance = (point - (start + segment * along)).length(); + let alpha = ((half_stroke + 0.5 - ring_distance.min(slash_distance)).clamp(0.0, 1.0) * 255.0).round() as u8; + pixels.push(Color32::from_white_alpha(alpha)); + } + } + Some(egui::RasterizedGlyph { + bitmap: egui::GlyphBitmap { + image: egui::ColorImage::new([side, side], pixels), + offset_px: vec2(0.0, -size * 0.8 - 1.0), + is_color: false, + }, + advance_px: size * 0.85 + 2.0, + }) + })); +} + #[derive(Clone)] struct State { fmt: Formatted, @@ -39,11 +75,11 @@ struct State { } fn state_id() -> egui::Id { - egui::Id::new("mtext_editor_state") + egui::Id::unique("mtext_editor_state") } fn edit_id() -> egui::Id { - egui::Id::new("mtext_editor_text") + egui::Id::unique("mtext_editor_text") } /// The ACI index or true colour as a screen colour. @@ -89,7 +125,7 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) { let mut done = Done::No; let top_start = pos.y - st.top; - egui::Area::new(egui::Id::new("mtext_editor_area")).order(egui::Order::Foreground).fixed_pos(pos2(pos.x, top_start)).show(ctx, |ui| { + egui::Area::new(egui::Id::unique("mtext_editor_area")).order(egui::Order::Foreground).fixed_pos(pos2(pos.x, top_start)).show(ctx, |ui| { egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.0, t.accent)).inner_margin(egui::Margin::same(0)).show(ui, |ui| { ui.set_width(width); let y0 = ui.cursor().top(); @@ -194,21 +230,22 @@ fn toolbar(ui: &mut egui::Ui, st: &mut State, base_h: f64) { ui.add_space(4.0); // Font let shown = cur.font.clone().unwrap_or_else(|| st.fmt.base_font.clone()); - egui::ComboBox::from_id_salt("mtext_font").width(120.0).selected_text(shown.clone()).show_ui(ui, |ui| { - for fam in FAMILIES.iter().map(|s| s.to_string()).chain(cur.font.clone().filter(|f| !FAMILIES.contains(&f.as_str()))) { - if ui.selectable_label(shown == fam, &fam).clicked() { - change(st, |c| c.font = Some(fam.clone())); - refocus = true; + egui::ComboBox::from_id_salt("mtext_font") + .width(120.0) + .selected_text(shown.clone()) + .show_ui(ui, |ui| { + for fam in FAMILIES.iter().map(|s| s.to_string()).chain(cur.font.clone().filter(|f| !FAMILIES.contains(&f.as_str()))) { + if ui.selectable_label(shown == fam, &fam).clicked() { + change(st, |c| c.font = Some(fam.clone())); + refocus = true; + } } - } - }); + }) + .response + .accessible_name(crate::tl!("Font")); // Height let mut h = cur.height.unwrap_or(base_h); - if ui - .add(egui::DragValue::new(&mut h).speed(base_h * 0.02).range(base_h * 0.05..=base_h * 50.0).max_decimals(4)) - .on_hover_text(crate::tl!("Text height")) - .changed() - { + if height_field(ui, &mut h, base_h) { let none = (h - base_h).abs() < 1e-9; change(st, |c| c.height = (!none).then_some(h)); } @@ -226,7 +263,7 @@ fn toolbar(ui: &mut egui::Ui, st: &mut State, base_h: f64) { } // Colour let swatch = cur.color.map_or(ui.visuals().text_color(), color32); - ui.menu_button(RichText::new("A▾").color(swatch).strong(), |ui| { + ui.menu_button(RichText::new("A").color(swatch).strong(), |ui| { if ui.button(crate::tl!("ByLayer")).clicked() { change(st, |c| c.color = None); refocus = true; @@ -253,49 +290,140 @@ fn toolbar(ui: &mut egui::Ui, st: &mut State, base_h: f64) { } }) .response + .accessible_name(crate::tl!("Text color")) .on_hover_text(crate::tl!("Text color")); ui.separator(); // Paragraph alignment let para = st.fmt.para(st.fmt.paragraph_of(range.start)); - for (al, glyph, tip) in [ - (ParaAlign::Left, "⇤", crate::tl!("Left")), - (ParaAlign::Center, "↔", crate::tl!("Center")), - (ParaAlign::Right, "⇥", crate::tl!("Right")), - (ParaAlign::Justify, "≡", crate::tl!("Justify")), + for (al, tip) in [ + (ParaAlign::Left, crate::tl!("Left")), + (ParaAlign::Center, crate::tl!("Center")), + (ParaAlign::Right, crate::tl!("Right")), + (ParaAlign::Justify, crate::tl!("Justify")), ] { let on = para.align == al; - if ui.selectable_label(on, glyph).on_hover_text(tip).clicked() { + if formatting_button(ui, FormattingIcon::Align(al), tip, on) { st.fmt.edit_paras(range.clone(), |p| p.align = if on { ParaAlign::Default } else { al }); refocus = true; } } ui.separator(); // Lists and special characters - if ui.button("•≡").on_hover_text(crate::tl!("Bulleted list")).clicked() { + if formatting_button(ui, FormattingIcon::Bullets, crate::tl!("Bulleted list"), false) { st.fmt.make_list(range.clone(), ListStyle::Bullet, 3.0 * base_h); refocus = true; } - if ui.button("1≡").on_hover_text(crate::tl!("Numbered list")).clicked() { + if formatting_button(ui, FormattingIcon::Numbers, crate::tl!("Numbered list"), false) { st.fmt.make_list(range.clone(), ListStyle::Number, 3.0 * base_h); refocus = true; } - for (glyph, tip, code) in [ - ("⇥|", crate::tl!("Insert tab"), "\t"), - ("°", crate::tl!("Degree"), "°"), - ("±", crate::tl!("Plus/minus"), "±"), - ("⌀", crate::tl!("Diameter"), "⌀"), - ] { + if formatting_button(ui, FormattingIcon::Tab, crate::tl!("Insert tab"), false) { + insert(st, "\t"); + refocus = true; + } + for (glyph, tip, code) in [("°", crate::tl!("Degree"), "°"), ("±", crate::tl!("Plus/minus"), "±")] { if ui.button(glyph).on_hover_text(tip).clicked() { insert(st, code); refocus = true; } } + if diameter_button(ui) { + insert(st, "⌀"); + refocus = true; + } }); if refocus { ui.ctx().memory_mut(|m| m.request_focus(edit_id())); } } +/// Formatting marks use geometry so the toolbar works with the bundled font stack on web too. +enum FormattingIcon { + Align(ParaAlign), + Bullets, + Numbers, + Tab, +} + +fn formatting_button(ui: &mut egui::Ui, icon: FormattingIcon, label: &str, selected: bool) -> bool { + let response = ui + .add(egui::Button::new(" ").selected(selected).min_size(vec2(22.0, ui.spacing().interact_size.y))) + .accessible_name(label) + .on_hover_text(label); + let painter = ui.painter(); + let c = response.rect.center(); + let color = if response.enabled() { ui.visuals().text_color() } else { ui.visuals().weak_text_color() }; + let stroke = Stroke::new(1.2, color); + match icon { + FormattingIcon::Align(align) => { + for row in 0..3 { + let width = if row == 1 && align != ParaAlign::Justify { 8.0 } else { 14.0 }; + let x = match align { + ParaAlign::Center => -width / 2.0, + ParaAlign::Right => 7.0 - width, + _ => -7.0, + }; + let y = (row as f32 - 1.0) * 4.0; + painter.line_segment([c + vec2(x, y), c + vec2(x + width, y)], stroke); + } + } + FormattingIcon::Bullets | FormattingIcon::Numbers => { + for row in 0..3 { + let y = (row as f32 - 1.0) * 5.0; + if matches!(icon, FormattingIcon::Bullets) { + painter.circle_filled(c + vec2(-6.0, y), 1.2, color); + } else { + painter.text(c + vec2(-6.0, y), Align2::CENTER_CENTER, (row + 1).to_string(), FontId::proportional(7.0), color); + } + painter.line_segment([c + vec2(-2.0, y), c + vec2(7.0, y)], stroke); + } + } + FormattingIcon::Tab => { + for [a, b] in [ + [vec2(-7.0, 0.0), vec2(4.0, 0.0)], + [vec2(0.0, -4.0), vec2(4.0, 0.0)], + [vec2(0.0, 4.0), vec2(4.0, 0.0)], + [vec2(7.0, -5.0), vec2(7.0, 5.0)], + ] { + painter.line_segment([c + a, c + b], stroke); + } + } + } + response.clicked() +} + +/// The MTEXT toolbar's height field. It has no visible caption, so the AccessKit label carries +/// the meaning of its hover tooltip; a tooltip alone leaves an unnamed spin button. +fn height_field(ui: &mut egui::Ui, height: &mut f64, base_h: f64) -> bool { + ui.add(egui::DragValue::new(height).speed(base_h * 0.02).range(base_h * 0.05..=base_h * 50.0).max_decimals(4)) + .on_hover_text(crate::tl!("Text height")) + .accessible_name(crate::tl!("Text height")) + .changed() +} + +/// The MTEXT Diameter button. U+2300 is absent from every bundled face (including the +/// monochrome and emoji fallbacks) and the web build has no system fonts, so the sign is painted +/// rather than shaped from text. The semantic button name keeps the glyph-only control +/// discoverable to accessibility tools. +fn diameter_button(ui: &mut egui::Ui) -> bool { + let min_size = vec2(22.0, ui.spacing().interact_size.y); + let resp = ui.add(egui::Button::new(" ").min_size(min_size)).on_hover_text(crate::tl!("Diameter")); + let color = if resp.enabled() { ui.visuals().text_color() } else { ui.visuals().weak_text_color() }; + let stroke = Stroke::new(1.4, color); + let center = resp.rect.center(); + let radius = resp.rect.height() * 0.3; + let diagonal = radius * std::f32::consts::FRAC_1_SQRT_2; + let painter = ui.painter(); + painter.circle_stroke(center, radius, stroke); + painter.line_segment([center + vec2(-diagonal, diagonal), center + vec2(diagonal, -diagonal)], stroke); + // Name the node directly as well as through `WidgetInfo`: on a clicked frame `widget_info` + // emits an output event and does not update the AccessKit node, which would leave the + // glyph-only button unnamed for that frame. + let resp = resp.accessible_name(crate::tl!("Diameter")); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, resp.enabled(), crate::tl!("Diameter"))); + resp.clicked() +} + /// Insert `s` at the caret (over the selection). fn insert(st: &mut State, s: &str) { let (a, b) = (st.sel.0.min(st.sel.1), st.sel.0.max(st.sel.1)); @@ -347,7 +475,7 @@ fn ruler(ui: &mut egui::Ui, st: &mut State, px: f32, width: f32, base_h: f64) { let y = if up { rect.bottom() - 1.0 } else { rect.top() + 1.0 }; let dir = if up { -1.0 } else { 1.0 }; let hit = Rect::from_center_size(pos2(x, y + dir * 4.0), vec2(10.0, 10.0)); - let r = ui.interact(hit, ui.id().with(("mtext_indent", k)), Sense::drag()); + let r = ui.interact(hit, ui.scope_id().with(("mtext_indent", k)), Sense::drag()); let col = if r.hovered() || r.dragged() { t.accent } else { t.icon }; painter.add(Shape::convex_polygon(vec![pos2(x - 4.0, y), pos2(x + 4.0, y), pos2(x, y + dir * 8.0)], col, Stroke::NONE)); if r.dragged() @@ -436,6 +564,7 @@ fn text_box(ui: &mut egui::Ui, st: &mut State, base_h: f64, px: f32, width: f32, .char_limit(cadcraft_engine::mtext_edit::MAX_CHARS) .layouter(&mut layouter) .show(ui); + out.response.response.clone().accessible_name(crate::tl!("Text")); if !out.response.has_focus() && buf.is_empty() && st.fmt.is_empty() { out.response.request_focus(); } @@ -462,9 +591,92 @@ fn text_box(ui: &mut egui::Ui, st: &mut State, base_h: f64, px: f32, width: f32, mod tests { use super::*; use cadcraft_engine::Session; + use egui::accesskit::Role; + use egui_kittest::Harness; + use egui_kittest::kittest::{NodeT, Queryable}; use crate::Services; + #[test] + fn diameter_insertion_keeps_real_text_and_renders_without_optional_fonts() { + let state = State { fmt: Formatted::default(), typing: CharFmt::default(), typing_at: 0, top: 70.0, sel: (0, 0), caret: None }; + let mut harness = Harness::builder().with_size(vec2(1100.0, 300.0)).with_context_setup(install_diameter_glyph).build_ui_state( + |ui, state| { + toolbar(ui, state, 2.5); + text_box(ui, state, 2.5, 20.0, 1000.0, &Tokens::get()); + }, + state, + ); + harness.run(); + harness.get_by_role_and_label(Role::Button, "Diameter").click(); + harness.run(); + assert_eq!(harness.state().fmt.text(), "⌀"); + assert_eq!(harness.state().fmt.to_contents(), "⌀"); + assert_eq!(harness.state().sel, (1, 1)); + assert!(harness.unnamed_widgets().is_empty()); + } + + #[test] + fn the_whole_toolbar_uses_bundled_glyphs_and_names_its_controls() { + let mut state = State { fmt: Formatted::default(), typing: CharFmt::default(), typing_at: 0, top: 70.0, sel: (0, 0), caret: None }; + let mut harness = Harness::builder().with_size(vec2(1100.0, 120.0)).build_ui(move |ui| toolbar(ui, &mut state, 2.5)); + harness.run(); + for name in ["Left", "Center", "Right", "Justify", "Bulleted list", "Numbered list", "Insert tab", "Diameter", "Text color", "Font"] { + assert!(harness.query_by_label(name).is_some(), "missing semantic control {name}"); + } + assert!(harness.unnamed_widgets().is_empty(), "unnamed controls: {:?}", harness.unnamed_widgets()); + } + + /// Strict bundled-font and accessibility regression for the glyph-only Diameter button: the + /// test context uses egui's bundled faces only (no `theme::install_fonts` system prepend), + /// and the harness panics on a missing glyph by default. The sign must be painted, not + /// shaped from U+2300. + #[test] + fn diameter_button_is_painted_and_named_under_bundled_fonts() { + let ctx = egui::Context::default(); + ctx.set_missing_glyph_policy(egui::MissingGlyphPolicy::Panic); + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(120.0, 60.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + diameter_button(ui); + }); + out.textures_delta.clear(); + let mut text = String::new(); + let (mut circle, mut slash) = (false, false); + for shape in &out.shapes { + match &shape.shape { + Shape::Circle(_) => circle = true, + Shape::LineSegment { .. } => slash = true, + Shape::Text(t) => text.push_str(&t.galley.job.text), + _ => {} + } + } + assert!(circle, "the diameter sign's circle is painted"); + assert!(slash, "the diameter sign's slash is painted"); + assert!(!text.contains('\u{2300}'), "U+2300 must not be shaped from a font: {text:?}"); + + let mut harness = Harness::builder().with_size(vec2(240.0, 80.0)).build_ui(|ui| { + diameter_button(ui); + }); + harness.run(); + let button = harness.get_by_label("Diameter"); + assert_eq!(button.accesskit_node().role(), Role::Button); + assert!(harness.unnamed_widgets().is_empty(), "unnamed controls: {:?}", harness.unnamed_widgets()); + } + + /// The height DragValue has no visible caption, so its AccessKit label must be set explicitly. + /// Bundled fonts only: no system face can mask a missing glyph in the harness. + #[test] + fn height_field_is_named_for_accessibility_under_bundled_fonts() { + let mut harness = Harness::builder().with_size(vec2(360.0, 80.0)).build_ui(|ui| { + let mut height = 2.5; + height_field(ui, &mut height, 2.5); + }); + harness.run(); + let field = harness.get_by_role_and_label(Role::SpinButton, "Text height"); + assert_eq!(field.accesskit_node().label().as_deref(), Some("Text height")); + assert!(harness.unnamed_widgets().is_empty(), "unnamed controls: {:?}", harness.unnamed_widgets()); + } + #[test] fn editor_opens_only_for_mtext_text_entry_and_finishes_with_formatted_contents() { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/opening.rs b/crates/ui-egui/src/opening.rs index 4db230c5c..e35583378 100644 --- a/crates/ui-egui/src/opening.rs +++ b/crates/ui-egui/src/opening.rs @@ -77,7 +77,7 @@ impl Opening { pub fn show(&self, ctx: &egui::Context) { let Some(label) = self.label() else { return }; let t = Tokens::get(); - egui::Area::new(egui::Id::new("cc_opening")) + egui::Area::new(egui::Id::unique("cc_opening")) .order(egui::Order::Foreground) .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) .interactable(false) diff --git a/crates/ui-egui/src/options/dialog.rs b/crates/ui-egui/src/options/dialog.rs index aba073118..9456e0a86 100644 --- a/crates/ui-egui/src/options/dialog.rs +++ b/crates/ui-egui/src/options/dialog.rs @@ -33,7 +33,7 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, name: &str, open: &mut bool let mut close = false; let center = ctx.content_rect().center(); egui::Window::new(crate::tl!("Options")) - .id(egui::Id::new("cc_options")) + .id(egui::Id::unique("cc_options")) .open(open) .resizable(false) .collapsible(false) @@ -110,11 +110,12 @@ fn animations_label(a: AnimationPref) -> &'static str { } /// A colour button, its hex value and a Default button; a change goes into `changes` under `key`. -fn color_row(ui: &mut egui::Ui, current: Option, default: Color32, key: &str, changes: &mut Map) { +/// `label` is the visible row label, so a screen reader names the colour well by it. +fn color_row(ui: &mut egui::Ui, current: Option, default: Color32, key: &str, changes: &mut Map, label: egui::Id) { ui.horizontal(|ui| { let shown = current.unwrap_or(default); let mut rgb = [shown.r(), shown.g(), shown.b()]; - if ui.color_edit_button_srgb(&mut rgb).changed() { + if ui.color_edit_button_srgb(&mut rgb).labelled_by(label).changed() { changes.insert(key.into(), json!(super::hex(Color32::from_rgb(rgb[0], rgb[1], rgb[2])))); } ui.monospace(super::hex(shown)); @@ -137,34 +138,36 @@ fn preview_square(ui: &mut egui::Ui, side: f32, color: Color32) { fn display(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { grid(ui, "options_display", |ui| { - ui.label(crate::tl!("Interface theme")); + let theme_caption = ui.label(crate::tl!("Interface theme")); let mut theme = app.ui.theme; - egui::ComboBox::from_id_salt("options_theme").selected_text(theme_label(theme)).show_ui(ui, |ui| { + let theme_combo = egui::ComboBox::from_id_salt("options_theme").selected_text(theme_label(theme)).show_ui(ui, |ui| { for t in ThemePref::ALL { ui.selectable_value(&mut theme, t, theme_label(t)); } }); + theme_combo.response.labelled_by(theme_caption.id); if theme != app.ui.theme { changes.insert("theme".into(), json!(theme.as_str())); } ui.end_row(); - ui.label(crate::tl!("Animation effects")); + let animations_caption = ui.label(crate::tl!("Animation effects")); let mut animations = app.ui.animations; - egui::ComboBox::from_id_salt("options_animations").selected_text(animations_label(animations)).show_ui(ui, |ui| { + let animations_combo = egui::ComboBox::from_id_salt("options_animations").selected_text(animations_label(animations)).show_ui(ui, |ui| { for a in AnimationPref::ALL { ui.selectable_value(&mut animations, a, animations_label(a)); } }); + animations_combo.response.labelled_by(animations_caption.id); if animations != app.ui.animations { changes.insert("animations".into(), json!(animations.as_str())); } ui.end_row(); - ui.label(crate::tl!("Interface scale")); + let scale_caption = ui.label(crate::tl!("Interface scale")); let scale = app.options.interface_scale; let percent = |s: f32| format!("{} %", (s * 100.0).round()); - egui::ComboBox::from_id_salt("options_interface_scale").selected_text(percent(scale)).show_ui(ui, |ui| { + let scale_combo = egui::ComboBox::from_id_salt("options_interface_scale").selected_text(percent(scale)).show_ui(ui, |ui| { for n in [75u16, 100, 125, 150, 175, 200, 250, 300] { let s = f32::from(n) / 100.0; if ui.selectable_label((scale - s).abs() < 1e-4, format!("{n} %")).clicked() && (scale - s).abs() >= 1e-4 { @@ -172,26 +175,32 @@ fn display(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { } } }); + scale_combo.response.labelled_by(scale_caption.id); ui.end_row(); - ui.label(crate::tl!("Model background")); - color_row(ui, app.options.model_background, Tokens::DARK.canvas, "modelBackground", changes); + let background_caption = ui.label(crate::tl!("Model background")); + color_row(ui, app.options.model_background, Tokens::DARK.canvas, "modelBackground", changes, background_caption.id); ui.end_row(); - ui.label(crate::tl!("Layout paper")); - color_row(ui, app.options.paper_background, Tokens::DARK.paper, "paperBackground", changes); + let paper_caption = ui.label(crate::tl!("Layout paper")); + color_row(ui, app.options.paper_background, Tokens::DARK.paper, "paperBackground", changes, paper_caption.id); ui.end_row(); - ui.label(crate::tl!("Crosshair size")); - let mut size = app.session.settings.cursorsize; - if ui.add(egui::Slider::new(&mut size, 1.0..=100.0).integer().suffix(" %")).changed() { - changes.insert("CURSORSIZE".into(), json!(size)); - } + let crosshair_caption = ui.label(crate::tl!("Crosshair size")); + ui.horizontal(|ui| { + let mut size = app.session.settings.cursorsize; + // The slider's own value box is a separate spin button: name both halves with the row label. + let slider = ui.add(egui::Slider::new(&mut size, 1.0..=100.0).integer().show_value(false)).labelled_by(crosshair_caption.id); + let field = ui.add(egui::DragValue::new(&mut size).range(1.0..=100.0).max_decimals(0).suffix(" %")).labelled_by(crosshair_caption.id); + if slider.changed() || field.changed() { + changes.insert("CURSORSIZE".into(), json!(size)); + } + }); ui.end_row(); - ui.label(crate::tl!("Command line history")); + let history_caption = ui.label(crate::tl!("Command line history")); let mut lines = app.ui.history_lines; - if ui.add(egui::DragValue::new(&mut lines).range(0..=crate::cmdline::MAX_LINES)).changed() { + if ui.add(egui::DragValue::new(&mut lines).range(0..=crate::cmdline::MAX_LINES)).labelled_by(history_caption.id).changed() { changes.insert("historyLines".into(), json!(lines)); } ui.end_row(); @@ -220,33 +229,38 @@ fn files(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { }); ui.end_row(); - ui.label(crate::tl!("Default save version")); + let version_caption = ui.label(crate::tl!("Default save version")); let current = crate::SaveVersion::parse(&app.ui.save_version).unwrap_or_default(); let mut version = current; - egui::ComboBox::from_id_salt("options_save_version").selected_text(version.year()).show_ui(ui, |ui| { + let version_combo = egui::ComboBox::from_id_salt("options_save_version").selected_text(version.year()).show_ui(ui, |ui| { for v in crate::SaveVersion::ALL { ui.selectable_value(&mut version, v, v.year()); } }); + version_combo.response.labelled_by(version_caption.id); if version != current { changes.insert("saveVersion".into(), json!(version.year())); } ui.end_row(); - ui.label(crate::tl!("Automatic save every")); + let minutes_caption = ui.label(crate::tl!("Automatic save every")); let mut minutes = cadcraft_engine::autosave::savetime(); let max = cadcraft_engine::autosave::MAX_SAVETIME; - if ui.add(egui::DragValue::new(&mut minutes).range(0..=max).suffix(format!(" {}", crate::tl!("min")))).changed() { + if ui + .add(egui::DragValue::new(&mut minutes).range(0..=max).suffix(format!(" {}", crate::tl!("min")))) + .labelled_by(minutes_caption.id) + .changed() + { changes.insert("SAVETIME".into(), json!(minutes)); } ui.end_row(); - ui.label(crate::tl!("Automatic save file location")); + let savefile_label = ui.label(crate::tl!("Automatic save file location")); // Edited as text; the folder changes when the field loses focus. - let id = egui::Id::new("cc_options_savefilepath"); + let id = egui::Id::unique("cc_options_savefilepath"); let current = cadcraft_engine::autosave::savefilepath(); let mut path = ui.data(|d| d.get_temp::(id)).unwrap_or_else(|| current.clone()); - let r = ui.add(egui::TextEdit::singleline(&mut path).desired_width(330.0)); + let r = ui.add(egui::TextEdit::singleline(&mut path).desired_width(330.0)).labelled_by(savefile_label.id); if r.changed() { ui.data_mut(|d| d.insert_temp(id, path.clone())); } @@ -265,20 +279,24 @@ fn files(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { fn drafting(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map, start: &mut Option<&'static str>) { let t = Tokens::get(); grid(ui, "options_drafting", |ui| { - ui.label(crate::tl!("Object snap marker size")); + let marker_caption = ui.label(crate::tl!("Object snap marker size")); ui.horizontal(|ui| { let mut size = app.options.snap_marker_size; - if ui.add(egui::Slider::new(&mut size, super::SNAP_MARKER_SIZES).integer()).changed() { + let slider = ui.add(egui::Slider::new(&mut size, super::SNAP_MARKER_SIZES).integer().show_value(false)).labelled_by(marker_caption.id); + let field = ui.add(egui::DragValue::new(&mut size).range(super::SNAP_MARKER_SIZES).max_decimals(0)).labelled_by(marker_caption.id); + if slider.changed() || field.changed() { changes.insert("snapMarkerSize".into(), json!(size)); } preview_square(ui, size, t.snap); }); ui.end_row(); - ui.label(crate::tl!("Object snap aperture")); + let aperture_caption = ui.label(crate::tl!("Object snap aperture")); ui.horizontal(|ui| { let mut aperture = app.session.settings.aperture; - if ui.add(egui::Slider::new(&mut aperture, 1.0..=50.0).integer().suffix(" px")).changed() { + let slider = ui.add(egui::Slider::new(&mut aperture, 1.0..=50.0).integer().show_value(false)).labelled_by(aperture_caption.id); + let field = ui.add(egui::DragValue::new(&mut aperture).range(1.0..=50.0).max_decimals(0).suffix(" px")).labelled_by(aperture_caption.id); + if slider.changed() || field.changed() { changes.insert("APERTURE".into(), json!(aperture)); } preview_square(ui, aperture as f32, t.crosshair); @@ -298,14 +316,16 @@ fn drafting(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map, s fn selection(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { let t = Tokens::get(); let st = &app.session.settings; - let mut effects = ui.ctx().data(|d| d.get_temp::(egui::Id::new(VISUAL_EFFECTS)).unwrap_or(false)); + let mut effects = ui.ctx().data(|d| d.get_temp::(egui::Id::unique(VISUAL_EFFECTS)).unwrap_or(false)); ui.columns(2, |cols| { let [left, right] = cols else { return }; // Left: how objects are picked. - heading(left, crate::tl!("Pickbox size")); + let pickbox_caption = heading(left, crate::tl!("Pickbox size")); left.horizontal(|ui| { let mut pickbox = st.pickbox; - if ui.add(egui::Slider::new(&mut pickbox, 0.0..=50.0).integer().suffix(" px")).changed() { + let slider = ui.add(egui::Slider::new(&mut pickbox, 0.0..=50.0).integer().show_value(false)).labelled_by(pickbox_caption.id); + let field = ui.add(egui::DragValue::new(&mut pickbox).range(0.0..=50.0).max_decimals(0).suffix(" px")).labelled_by(pickbox_caption.id); + if slider.changed() || field.changed() { changes.insert("PICKBOX".into(), json!(pickbox)); } preview_square(ui, (pickbox * 3.0) as f32, t.crosshair); @@ -337,14 +357,23 @@ fn selection(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) changes.insert("QPMODE".into(), json!(i32::from(qp))); } left.add_space(4.0); - left.label(crate::tl!("Selection effect color")); - color_row(left, app.options.selection_color, crate::theme::Tokens::of(crate::theme::active()).selection, "selectionColor", changes); + let selection_caption = left.label(crate::tl!("Selection effect color")); + color_row( + left, + app.options.selection_color, + crate::theme::Tokens::of(crate::theme::active()).selection, + "selectionColor", + changes, + selection_caption.id, + ); // Right: grips and the preview under the cursor. - heading(right, crate::tl!("Grip size")); + let grip_caption = heading(right, crate::tl!("Grip size")); right.horizontal(|ui| { let mut grip = st.gripsize; - if ui.add(egui::Slider::new(&mut grip, 1.0..=20.0).integer().suffix(" px")).changed() { + let slider = ui.add(egui::Slider::new(&mut grip, 1.0..=20.0).integer().show_value(false)).labelled_by(grip_caption.id); + let field = ui.add(egui::DragValue::new(&mut grip).range(1.0..=20.0).max_decimals(0).suffix(" px")).labelled_by(grip_caption.id); + if slider.changed() || field.changed() { changes.insert("GRIPSIZE".into(), json!(grip)); } preview_square(ui, (grip * 2.0) as f32, t.grip); @@ -357,10 +386,11 @@ fn selection(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) } right.horizontal(|ui| { let mut limit = st.gripobjlimit; - if ui.add_enabled(st.grips > 0, egui::DragValue::new(&mut limit).range(0..=32_767)).changed() { + let field = ui.add_enabled(st.grips > 0, egui::DragValue::new(&mut limit).range(0..=32_767)); + let caption = ui.label(crate::tl!("Object selection limit for display of grips")); + if field.labelled_by(caption.id).changed() { changes.insert("GRIPOBJLIMIT".into(), json!(limit)); } - ui.label(crate::tl!("Object selection limit for display of grips")); }); right.add_space(8.0); heading(right, crate::tl!("Preview")); @@ -378,14 +408,14 @@ fn selection(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) if effects { visual_effects(ui.ctx(), st, &mut effects, changes); } - ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new(VISUAL_EFFECTS), effects)); + ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique(VISUAL_EFFECTS), effects)); } /// Whether the Visual Effect Settings window is open (egui temp data). const VISUAL_EFFECTS: &str = "cc_options_visual_effects"; -fn heading(ui: &mut egui::Ui, text: &str) { - ui.label(RichText::new(text).strong()); +fn heading(ui: &mut egui::Ui, text: &str) -> egui::Response { + ui.label(RichText::new(text).strong()) } /// Visual Effect Settings: the selection area (window and crossing fills) and the objects the @@ -393,7 +423,7 @@ fn heading(ui: &mut egui::Ui, text: &str) { fn visual_effects(ctx: &egui::Context, st: &cadcraft_engine::Settings, open: &mut bool, changes: &mut Map) { let mut close = false; egui::Window::new(crate::tl!("Visual Effect Settings")) - .id(egui::Id::new("cc_visual_effects")) + .id(egui::Id::unique("cc_visual_effects")) .open(open) .resizable(false) .collapsible(false) @@ -408,15 +438,19 @@ fn visual_effects(ctx: &egui::Context, st: &cadcraft_engine::Settings, open: &mu changes.insert("SELECTIONAREA".into(), json!(i32::from(area))); } left.add_enabled_ui(st.selectionarea, |ui| { - ui.label(crate::tl!("Window selection color:")); - aci_row(ui, st.windowareacolor, "WINDOWAREACOLOR", changes); - ui.label(crate::tl!("Crossing selection color:")); - aci_row(ui, st.crossingareacolor, "CROSSINGAREACOLOR", changes); - ui.label(crate::tl!("Selection area opacity:")); - let mut opacity = st.selectionareaopacity; - if ui.add(egui::Slider::new(&mut opacity, 0..=100)).changed() { - changes.insert("SELECTIONAREAOPACITY".into(), json!(opacity)); - } + let window_caption = ui.label(crate::tl!("Window selection color:")); + aci_row(ui, st.windowareacolor, "WINDOWAREACOLOR", changes, window_caption.id); + let crossing_caption = ui.label(crate::tl!("Crossing selection color:")); + aci_row(ui, st.crossingareacolor, "CROSSINGAREACOLOR", changes, crossing_caption.id); + let opacity_caption = ui.label(crate::tl!("Selection area opacity:")); + ui.horizontal(|ui| { + let mut opacity = st.selectionareaopacity; + let slider = ui.add(egui::Slider::new(&mut opacity, 0..=100).show_value(false)).labelled_by(opacity_caption.id); + let field = ui.add(egui::DragValue::new(&mut opacity).range(0..=100)).labelled_by(opacity_caption.id); + if slider.changed() || field.changed() { + changes.insert("SELECTIONAREAOPACITY".into(), json!(opacity)); + } + }); }); heading(right, crate::tl!("Selection Preview Filter")); @@ -450,13 +484,13 @@ fn visual_effects(ctx: &egui::Context, st: &cadcraft_engine::Settings, open: &mu } /// A colour by its index (ACI 1-255): a swatch and the number; a change goes into `changes`. -fn aci_row(ui: &mut egui::Ui, current: u8, key: &str, changes: &mut Map) { +fn aci_row(ui: &mut egui::Ui, current: u8, key: &str, changes: &mut Map, label: egui::Id) { ui.horizontal(|ui| { let c = cadcraft_color::aci_rgb(current); let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), egui::Sense::hover()); ui.painter().rect_filled(r, 2.0, Color32::from_rgb(c.0, c.1, c.2)); let mut v = current; - if ui.add(egui::DragValue::new(&mut v).range(1..=255).prefix(format!("{} ", crate::tl!("Color")))).changed() { + if ui.add(egui::DragValue::new(&mut v).range(1..=255).prefix(format!("{} ", crate::tl!("Color")))).labelled_by(label).changed() { changes.insert(key.into(), json!(v)); } }); @@ -464,21 +498,26 @@ fn aci_row(ui: &mut egui::Ui, current: u8, key: &str, changes: &mut Map) { grid(ui, "options_preferences", |ui| { - ui.label(crate::tl!("Interface language")); + let language_caption = ui.label(crate::tl!("Interface language")); let mut lang = app.ui.interface_language; - egui::ComboBox::from_id_salt("options_language").selected_text(lang.name()).show_ui(ui, |ui| { + let language_combo = egui::ComboBox::from_id_salt("options_language").selected_text(lang.name()).show_ui(ui, |ui| { for p in crate::i18n::Preference::ALL { ui.selectable_value(&mut lang, p, p.name()); } }); + language_combo.response.labelled_by(language_caption.id); if lang != app.ui.interface_language { changes.insert("interfaceLanguage".into(), json!(lang.code())); } ui.end_row(); - ui.label(crate::tl!("Largest array")); + let array_caption = ui.label(crate::tl!("Largest array")); let mut max = app.session.settings.maxarray; - if ui.add(egui::DragValue::new(&mut max).range(100..=10_000_000).speed(1000.0).suffix(format!(" {}", crate::tl!("objects")))).changed() { + if ui + .add(egui::DragValue::new(&mut max).range(100..=10_000_000).speed(1000.0).suffix(format!(" {}", crate::tl!("objects")))) + .labelled_by(array_caption.id) + .changed() + { changes.insert("MAXARRAY".into(), json!(max)); } ui.end_row(); diff --git a/crates/ui-egui/src/palettes.rs b/crates/ui-egui/src/palettes.rs index 29bedf630..28799ed44 100644 --- a/crates/ui-egui/src/palettes.rs +++ b/crates/ui-egui/src/palettes.rs @@ -151,7 +151,7 @@ pub fn toolsets(app: &mut CadApp, ui: &mut egui::Ui) { let w = 92.0; let tr = Rect::from_min_size(pos2(x, r.top()), vec2(w, tab_h)); let active = app.ui.toolset_tab == name; - let resp = ui.interact(tr, ui.id().with(("ts", name)), Sense::click()); + let resp = ui.interact(tr, ui.scope_id().with(("ts", name)), Sense::click()); p.rect_filled(tr, 0.0, if active { t.tab_active } else { t.chrome }); p.text( tr.center(), @@ -168,7 +168,7 @@ pub fn toolsets(app: &mut CadApp, ui: &mut egui::Ui) { } p.rect_filled(Rect::from_min_max(pos2(x, r.top()), pos2(r.right(), r.top() + tab_h)), 0.0, t.chrome); let cr = Rect::from_center_size(pos2(r.right() - 14.0, r.top() + tab_h / 2.0), vec2(14.0, 14.0)); - let cresp = ui.interact(cr, ui.id().with("ts_collapse"), Sense::click()); + let cresp = ui.interact(cr, ui.scope_id().with("ts_collapse"), Sense::click()); icons::paint(&p, cr, Icon::ChevronLeft, false); icons::describe_control(ui, &cresp, "Collapse Tool Sets", None, false); if cresp.on_hover_text(crate::tl!("Collapse Tool Sets")).clicked() { @@ -202,7 +202,7 @@ pub fn toolsets(app: &mut CadApp, ui: &mut egui::Ui) { t.text, ); let gear = Rect::from_center_size(pos2(hr.right() - 12.0, hr.center().y), vec2(13.0, 13.0)); - let gresp = ui.interact(gear, ui.id().with(("ts_gear", name)), Sense::click()); + let gresp = ui.interact(gear, ui.scope_id().with(("ts_gear", name)), Sense::click()); icons::paint(pp, Rect::from_center_size(gear.center(), vec2(11.0, 11.0)), Icon::Gear, false); icons::describe_control(ui, &hresp, name, Some(!collapsed), false); icons::describe_control(ui, &gresp, &format!("{name} options"), None, false); @@ -329,7 +329,7 @@ pub fn toolsets_strip(app: &mut CadApp, ui: &mut egui::Ui) { let t = Tokens::get(); egui::Panel::left("cc_toolsets_strip").exact_size(22.0).resizable(false).frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| { let r = ui.max_rect(); - let resp = ui.interact(r, ui.id().with("ts_expand"), Sense::click()); + let resp = ui.interact(r, ui.scope_id().with("ts_expand"), Sense::click()); icons::paint(ui.painter(), Rect::from_center_size(pos2(r.center().x, r.top() + 14.0), vec2(14.0, 14.0)), Icon::ChevronRight, false); icons::describe_control(ui, &resp, "Expand Tool Sets", None, false); if resp.on_hover_text(crate::tl!("Tool Sets")).clicked() { @@ -354,7 +354,7 @@ pub fn right_palettes(app: &mut CadApp, ui: &mut egui::Ui) { icons::paint(&p, br, *icon, false); } let cr = Rect::from_center_size(pos2(r.right() - 14.0, r.top() + tab_h / 2.0), vec2(14.0, 14.0)); - let cresp = ui.interact(cr, ui.id().with("palettes_collapse"), Sense::click()); + let cresp = ui.interact(cr, ui.scope_id().with("palettes_collapse"), Sense::click()); icons::paint(&p, cr, Icon::ChevronRight, false); icons::describe_control(ui, &cresp, "Collapse Palettes", None, false); if cresp.on_hover_text(crate::tl!("Collapse Palettes")).clicked() { @@ -520,9 +520,10 @@ fn prop_row(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui)) { let mut job = egui::text::LayoutJob::simple(crate::i18n::t(label).to_string(), crate::theme::body(), t.text_dim, 106.0); job.halign = egui::Align::RIGHT; let galley = ui.painter().layout_job(job); - let (lr, _) = ui.allocate_exact_size(vec2(118.0, galley.size().y.max(22.0)), Sense::hover()); + let (lr, label_response) = ui.allocate_exact_size(vec2(118.0, galley.size().y.max(22.0)), Sense::hover()); + label_response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, ui.is_enabled(), crate::i18n::t(label))); ui.painter().galley(pos2(lr.right() - 6.0, lr.center().y - galley.size().y / 2.0), galley, t.text_dim); - add(ui); + ui.label_inputs_by(label_response.id, add); }); } @@ -612,7 +613,7 @@ type Custom = Option<(String, Vec Custom { let st = app.session.state().ok()?; - let id = egui::Id::new(("dynamic-block-custom", st.uid, st.revision, h)); + let id = egui::Id::unique(("dynamic-block-custom", st.uid, st.revision, h)); if let Some(c) = ui.data(|d| d.get_temp::(id)) { return c; } @@ -639,7 +640,7 @@ fn properties_header(app: &mut CadApp, ui: &mut egui::Ui) { ui.painter().rect_filled(if app.ui.properties_all { half } else { my }, 3.0, t.control); ui.painter().text(half.center(), egui::Align2::CENTER_CENTER, crate::tl!("All"), crate::theme::small(), t.text); ui.painter().text(my.center(), egui::Align2::CENTER_CENTER, crate::tl!("My"), crate::theme::small(), t.text_faint); - ui.interact(my, ui.id().with("prop_my"), Sense::hover()).on_hover_text(crate::tl!("My properties: not available yet")); + ui.interact(my, ui.scope_id().with("prop_my"), Sense::hover()).on_hover_text(crate::tl!("My properties: not available yet")); let sel = app.session.selection(); let pickadd = app.session.settings.pickadd; let Ok(d) = app.session.doc() else { return }; @@ -757,7 +758,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { } }); prop_row(ui, "Linetype scale", |ui| { - if let Some(f) = number(edit_field(ui, ui.id().with("cur_celtscale"), format!("{:.4}", h.f64("CELTSCALE", 1.0))), &mut bad) { + if let Some(f) = number(edit_field(ui, ui.scope_id().with("cur_celtscale"), format!("{:.4}", h.f64("CELTSCALE", 1.0))), &mut bad) { act("setvar", json!({ "name": "CELTSCALE", "value": f })); } }); @@ -779,7 +780,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { }); prop_row(ui, "Transparency", |ui| unavailable(ui, "ByLayer")); prop_row(ui, "Thickness", |ui| { - if let Some(f) = number(edit_field(ui, ui.id().with("cur_thickness"), len(h.f64("THICKNESS", 0.0))), &mut bad) { + if let Some(f) = number(edit_field(ui, ui.scope_id().with("cur_thickness"), len(h.f64("THICKNESS", 0.0))), &mut bad) { act("setvar", json!({ "name": "THICKNESS", "value": f })); } }); @@ -805,7 +806,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { }); prop_row(ui, "Annotation scale", |ui| unavailable(ui, "1:1")); prop_row(ui, "Text height", |ui| { - if let Some(f) = number(edit_field(ui, ui.id().with("cur_textsize"), len(h.f64("TEXTSIZE", 0.2))), &mut bad) { + if let Some(f) = number(edit_field(ui, ui.scope_id().with("cur_textsize"), len(h.f64("TEXTSIZE", 0.2))), &mut bad) { act("setvar", json!({ "name": "TEXTSIZE", "value": f })); } }); @@ -888,7 +889,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { let width = (ui.available_width() - 10.0).max(0.0); if let Some(c) = crate::layers::color_button_resolved( ui, - egui::Id::new(("properties_color", &ids)), + egui::Id::unique(("properties_color", &ids)), selected_color.unwrap_or(Color::ByLayer), resolved_color, &color, @@ -924,7 +925,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { }); }); prop_row(ui, "Linetype scale", |ui| { - if let Some(f) = number(edit_field(ui, ui.id().with(("lts", &ids)), lts.clone()), &mut bad) { + if let Some(f) = number(edit_field(ui, ui.scope_id().with(("lts", &ids)), lts.clone()), &mut bad) { set = Some(json!({ "handles": ids, "ltscale": f })); } }); @@ -954,7 +955,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { } ui.separator(); // A fixed percentage, committed when the drag or edit ends. - let tid = egui::Id::new(("prop_tr", &ids)); + let tid = egui::Id::unique(("prop_tr", &ids)); let cur = tr.parse::().ok(); let mut pct = ui.data_mut(|d| d.get_temp::(tid)).or(cur).unwrap_or(0); ui.horizontal(|ui| { @@ -979,7 +980,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { let angles = app.session.angle_settings(); let num = |ui: &mut egui::Ui, label: &str, key: &str, shown: String, set: &mut Option, bad: &mut Option, ids: &Vec| { prop_row(ui, label, |ui| { - if let Some(f) = number(edit_field(ui, ui.id().with((key, ids)), shown), bad) { + if let Some(f) = number(edit_field(ui, ui.scope_id().with((key, ids)), shown), bad) { *set = Some(json!({ "handles": ids, key: f })); } }); @@ -987,22 +988,22 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { match &e.kind { EntityKind::Line(l) => { prop_row(ui, "Start X", |ui| { - if let Some(f) = number(edit_field(ui, ui.id().with(("sx", &ids)), len(l.a.x)), &mut bad) { + if let Some(f) = number(edit_field(ui, ui.scope_id().with(("sx", &ids)), len(l.a.x)), &mut bad) { set = Some(json!({ "handles": ids, "start": [f, l.a.y] })); } }); prop_row(ui, "Start Y", |ui| { - if let Some(f) = number(edit_field(ui, ui.id().with(("sy", &ids)), len(l.a.y)), &mut bad) { + if let Some(f) = number(edit_field(ui, ui.scope_id().with(("sy", &ids)), len(l.a.y)), &mut bad) { set = Some(json!({ "handles": ids, "start": [l.a.x, f] })); } }); prop_row(ui, "End X", |ui| { - if let Some(f) = number(edit_field(ui, ui.id().with(("ex", &ids)), len(l.b.x)), &mut bad) { + if let Some(f) = number(edit_field(ui, ui.scope_id().with(("ex", &ids)), len(l.b.x)), &mut bad) { set = Some(json!({ "handles": ids, "end": [f, l.b.y] })); } }); prop_row(ui, "End Y", |ui| { - if let Some(f) = number(edit_field(ui, ui.id().with(("ey", &ids)), len(l.b.y)), &mut bad) { + if let Some(f) = number(edit_field(ui, ui.scope_id().with(("ey", &ids)), len(l.b.y)), &mut bad) { set = Some(json!({ "handles": ids, "end": [l.b.x, f] })); } }); @@ -1038,7 +1039,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { } EntityKind::Text(tx) => { prop_row(ui, "Contents", |ui| { - if let Some(s) = edit_field(ui, ui.id().with(("txt", &ids)), tx.value.clone()) { + if let Some(s) = edit_field(ui, ui.scope_id().with(("txt", &ids)), tx.value.clone()) { set = Some(json!({ "handles": ids, "text": s })); } }); @@ -1048,7 +1049,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { } EntityKind::MText(tx) => { prop_row(ui, "Contents", |ui| { - if let Some(s) = edit_field(ui, ui.id().with(("mtxt", &ids)), tx.contents.clone()) { + if let Some(s) = edit_field(ui, ui.scope_id().with(("mtxt", &ids)), tx.contents.clone()) { set = Some(json!({ "handles": ids, "text": s })); } }); @@ -1084,7 +1085,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { } EntityKind::Dimension(dm) => { prop_row(ui, "Text override", |ui| { - if let Some(s) = edit_field(ui, ui.id().with(("dimtxt", &ids)), dm.text.clone()) { + if let Some(s) = edit_field(ui, ui.scope_id().with(("dimtxt", &ids)), dm.text.clone()) { set = Some(json!({ "handles": ids, "textOverride": s })); } }); @@ -1123,7 +1124,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { prop_row(ui, &p.name, |ui| { let picked = match (p.editable, p.choices.is_empty()) { (true, false) => pick_menu(ui, &p.value, p.choices.iter().cloned()), - (true, true) => edit_field(ui, ui.id().with(("dynprop", e.handle, &p.name)), p.value.clone()), + (true, true) => edit_field(ui, ui.scope_id().with(("dynprop", e.handle, &p.name)), p.value.clone()), (false, _) => { read_only(ui, &p.value); None diff --git a/crates/ui-egui/src/palettes/patterns.rs b/crates/ui-egui/src/palettes/patterns.rs index 6d628dc26..746cbd6a1 100644 --- a/crates/ui-egui/src/palettes/patterns.rs +++ b/crates/ui-egui/src/palettes/patterns.rs @@ -124,18 +124,19 @@ pub fn menu(ui: &mut egui::Ui, current: &str) -> Option { /// HATCHEDIT's pattern prompt): a click enters the pattern's name at the prompt. pub fn prompt_picker(app: &mut CadApp, ctx: &egui::Context) { let Some(Picker::HatchPattern(current)) = app.session.current_prompt().and_then(|p| p.picker) else { return }; - let filter_id = egui::Id::new("hatch_pattern_filter"); + let filter_id = egui::Id::unique("hatch_pattern_filter"); let mut filter: String = ctx.data_mut(|d| d.get_temp(filter_id)).unwrap_or_default(); let mut chosen = None; let area = ctx.content_rect(); egui::Window::new(crate::tl!("Hatch Pattern")) - .id(egui::Id::new("Hatch Pattern")) + .id(egui::Id::unique("Hatch Pattern")) .collapsible(true) .resizable(true) .default_size(vec2(5.0 * (CELL + GAP) + 12.0, 5.0 * (CELL + LABEL_H + GAP) + 40.0)) .default_pos(pos2(area.max.x - 5.0 * (CELL + GAP) - 330.0, area.min.y + 120.0)) .show(ctx, |ui| { - ui.add(egui::TextEdit::singleline(&mut filter).hint_text(crate::tl!("Search for pattern")).desired_width(f32::INFINITY)); + ui.add(egui::TextEdit::singleline(&mut filter).hint_text(crate::tl!("Search for pattern")).desired_width(f32::INFINITY)) + .on_hover_text(crate::tl!("Search for pattern")); ui.add_space(4.0); egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { chosen = grid(ui, ¤t, &filter); diff --git a/crates/ui-egui/src/palettes/quickcalc.rs b/crates/ui-egui/src/palettes/quickcalc.rs index e81026793..f64af9b69 100644 --- a/crates/ui-egui/src/palettes/quickcalc.rs +++ b/crates/ui-egui/src/palettes/quickcalc.rs @@ -40,7 +40,7 @@ struct State { } fn id() -> egui::Id { - egui::Id::new("cadcraft-quickcalc") + egui::Id::unique("cadcraft-quickcalc") } /// A variable's value as typed into an expression: a number, or a point as `[x,y]`. @@ -197,6 +197,14 @@ pub fn open(app: &mut CadApp) -> Result { crate::docking::command(app, false, &json!({ "operation": "float", "panel": panel.id(), "rect": rect })) } +/// The unit-conversion value row. The visible "Value" label names the numeric field (its +/// AccessKit label becomes "Value"), and the placeholder repeats the meaning instead of the +/// unexplained "1" example. +fn value_row(ui: &mut egui::Ui, value: &mut String) { + let label = ui.label(crate::tl!("Value")); + ui.add(egui::TextEdit::singleline(value).hint_text(crate::tl!("Value")).desired_width(120.0)).labelled_by(label.id); +} + pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { let t = Tokens::get(); let mut st: State = ui.ctx().data_mut(|d| d.get_temp(id())).unwrap_or_default(); @@ -309,8 +317,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { }); ui.end_row(); } - ui.label(crate::tl!("Value")); - ui.add(egui::TextEdit::singleline(&mut st.conv_value).hint_text("1").desired_width(120.0)); + value_row(ui, &mut st.conv_value); ui.end_row(); if ui.button(crate::tl!("Convert")).clicked() { st.convert(app); @@ -354,3 +361,27 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { }); ui.ctx().data_mut(|d| d.insert_temp(id(), st)); } + +#[cfg(test)] +mod tests { + use egui::accesskit::Role; + use egui_kittest::Harness; + use egui_kittest::kittest::{NodeT, Queryable}; + + use super::value_row; + + /// The conversion value field is named by its visible label under bundled fonts only, so a + /// system face cannot hide a missing glyph and a bare "1" placeholder cannot pass as a name. + #[test] + fn value_field_is_named_by_its_visible_label() { + let mut harness = Harness::builder().with_size(egui::vec2(320.0, 80.0)).build_ui(|ui| { + let mut value = String::new(); + value_row(ui, &mut value); + }); + harness.run(); + let field = harness.get_by_role_and_label(Role::TextInput, "Value"); + assert!(field.accesskit_node().labelled_by().next().is_some(), "named by the visible Value label"); + assert_eq!(field.accesskit_node().placeholder(), Some("Value")); + assert!(harness.unnamed_widgets().is_empty(), "unnamed controls: {:?}", harness.unnamed_widgets()); + } +} diff --git a/crates/ui-egui/src/panel_state.rs b/crates/ui-egui/src/panel_state.rs new file mode 100644 index 000000000..f683e7d3c --- /dev/null +++ b/crates/ui-egui/src/panel_state.rs @@ -0,0 +1,73 @@ +//! Additive migration of persisted panel sizes from the 0.36 root-id key to 0.37's +//! `parent_ui.scope_id().with_salt(name)` key. +//! +//! CADCraft disables egui memory persistence today, but the app's own saved workspace or a host +//! that enables persistence must not lose a resized panel once. The old key is retained for +//! rollback; a value already stored under the new key always wins. + +/// The top-level panel id salts used by CADCraft. Keep these strings stable: they are names in +/// saved test fixtures and in `ui.elements` expectations, not disposable implementation details. +pub(crate) const PANEL_IDS: &[&str] = &["cc_title_toolbar", "cc_menubar", "cc_status", "cc_file_tabs", "cc_toolsets_strip", "cc_palettes"]; + +/// Copy an old-key `PanelState` to the 0.37 key before panels are shown. Call once on the first +/// pass, while the root `Ui` scope id is available. +pub(crate) fn migrate(ui: &egui::Ui) { + let ctx = ui.ctx(); + let parent = ui.scope_id(); + for name in PANEL_IDS { + let old = egui::Id::unique(name); + let new = parent.with_salt(egui::IdSalt::new(name)); + if old == new { + continue; + } + let Some(state) = ctx.data_mut(|data| data.get_persisted::(old)) else { + continue; + }; + ctx.data_mut(|data| { + if data.get_persisted::(new).is_none() { + data.insert_persisted(new, state); + } + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn state(width: f32) -> egui::PanelState { + egui::PanelState { outer_rect: egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 26.0)) } + } + + #[test] + fn old_key_is_copied_to_the_scoped_key_once() { + let ctx = egui::Context::default(); + let old = egui::Id::unique("cc_status"); + ctx.data_mut(|data| data.insert_persisted(old, state(41.0))); + let mut new_key = None; + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + migrate(ui); + let new = ui.scope_id().with_salt(egui::IdSalt::new("cc_status")); + new_key = Some(new); + assert_eq!(egui::PanelState::load(ui.ctx(), new).map(|state| state.size().x), Some(41.0)); + }); + output.textures_delta.clear(); + let new_key = new_key.expect("a root Ui"); + assert_eq!(egui::PanelState::load(&ctx, new_key).map(|state| state.size().x), Some(41.0)); + assert_eq!(egui::PanelState::load(&ctx, old).map(|state| state.size().x), Some(41.0), "the old key is kept for rollback"); + } + + #[test] + fn a_new_key_is_never_overwritten() { + let ctx = egui::Context::default(); + let old = egui::Id::unique("cc_status"); + ctx.data_mut(|data| data.insert_persisted(old, state(41.0))); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + let new = ui.scope_id().with_salt(egui::IdSalt::new("cc_status")); + ctx.data_mut(|data| data.insert_persisted(new, state(64.0))); + migrate(ui); + assert_eq!(egui::PanelState::load(ui.ctx(), new).map(|state| state.size().x), Some(64.0)); + }); + output.textures_delta.clear(); + } +} diff --git a/crates/ui-egui/src/parametric.rs b/crates/ui-egui/src/parametric.rs index 83a4328bd..ade7a39fa 100644 --- a/crates/ui-egui/src/parametric.rs +++ b/crates/ui-egui/src/parametric.rs @@ -392,7 +392,7 @@ pub fn draw_overlay(app: &mut CadApp, ui: &mut egui::Ui, painter: &egui::Painter painter.rect_filled(r, 2.0, t.canvas.gamma_multiply(0.85)); lock(painter, Rect::from_min_size(r.min + vec2(3.0, (r.height() - 13.0) / 2.0), vec2(13.0, 13.0)), col); painter.galley(r.min + vec2(19.0, 2.0), galley, col); - let resp = ui.interact(r, ui.id().with(("dimcon", c.id)), Sense::click()); + let resp = ui.interact(r, ui.scope_id().with(("dimcon", c.id)), Sense::click()); if resp.double_clicked() { act = Some(Act::Edit(c.name.clone(), c.expr.clone(), r.left_center())); } @@ -464,7 +464,7 @@ pub fn draw_overlay(app: &mut CadApp, ui: &mut egui::Ui, painter: &egui::Painter painter.rect_stroke(r, 4.0, Stroke::new(1.0, Color32::from_rgb(0x6e, 0x75, 0x80).gamma_multiply(alpha)), egui::StrokeKind::Inside); for (i, (kind, id)) in kinds.iter().take(16).enumerate() { let cr = Rect::from_min_size(r.min + vec2(2.0 + i as f32 * cell, 2.0), vec2(cell, cell)); - let resp = ui.interact(cr, ui.id().with(("cbar", h.0, *id)), Sense::hover()); + let resp = ui.interact(cr, ui.scope_id().with(("cbar", h.0, *id)), Sense::hover()); if resp.hovered() { painter.rect_filled(cr.shrink(1.0), 3.0, Color32::from_rgb(0xf2, 0xf4, 0xf7)); } @@ -475,7 +475,7 @@ pub fn draw_overlay(app: &mut CadApp, ui: &mut egui::Ui, painter: &egui::Painter if let Some((text, col)) = status { let sr = Rect::from_min_max(pos2(r.right() - strip - 1.0, r.top() + 3.0), pos2(r.right() - 2.0, r.bottom() - 3.0)); painter.rect_filled(sr, 2.0, col.gamma_multiply(alpha.max(0.5))); - ui.interact(sr.expand(2.0), ui.id().with(("cbar_dof", h.0)), Sense::hover()).on_hover_text(text); + ui.interact(sr.expand(2.0), ui.scope_id().with(("cbar_dof", h.0)), Sense::hover()).on_hover_text(text); } } // The drawing's constraint state. @@ -498,11 +498,12 @@ fn act_take(app: &mut CadApp, act: Option, ui: &mut egui::Ui) -> Option<()> // The inline expression editor. let (name, mut buf, at) = app.canvas.param.editing.clone()?; let mut done: Option = None; - egui::Area::new(egui::Id::new("dimcon_edit")).order(egui::Order::Foreground).fixed_pos(at - vec2(0.0, 12.0)).show(ui.ctx(), |ui| { + egui::Area::new(egui::Id::unique("dimcon_edit")).order(egui::Order::Foreground).fixed_pos(at - vec2(0.0, 12.0)).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.horizontal(|ui| { - ui.label(format!("{name} =")); - let r = ui.add(egui::TextEdit::singleline(&mut buf).desired_width(140.0).id(egui::Id::new("dimcon_edit_te"))); + let label = ui.label(format!("{name} =")); + let r = + ui.add(egui::TextEdit::singleline(&mut buf).desired_width(140.0).id(egui::Id::unique("dimcon_edit_te"))).labelled_by(label.id); if !r.has_focus() && !r.lost_focus() { r.request_focus(); } @@ -571,14 +572,14 @@ pub fn parameters_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) .and_then(|v| v.get("parameters").cloned()) .and_then(|v| serde_json::from_value(v).ok()) .unwrap_or_default(); - let filter_id = egui::Id::new("par_filter"); + let filter_id = egui::Id::unique("par_filter"); let mut filter = ctx.data_mut(|d| d.get_temp::(filter_id)).unwrap_or_default(); let mut action: Option = None; // The name being renamed in place: (old name, text typed so far). - let rename_id = egui::Id::new("par_rename"); + let rename_id = egui::Id::unique("par_rename"); let mut renaming = ctx.data_mut(|d| d.get_temp::<(String, String)>(rename_id)); egui::Window::new(crate::tl!("Parameters Manager")) - .id(egui::Id::new("Parameters Manager")) + .id(egui::Id::unique("Parameters Manager")) .open(open) .default_size(vec2(560.0, 300.0)) .resizable(true) @@ -592,7 +593,8 @@ pub fn parameters_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) action = Some(json!({ "name": format!("user{n}"), "expr": "1" })); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.add(egui::TextEdit::singleline(&mut filter).hint_text(crate::tl!("Search for parameter")).desired_width(170.0)); + ui.add(egui::TextEdit::singleline(&mut filter).hint_text(crate::tl!("Search for parameter")).desired_width(170.0)) + .accessible_name(crate::tl!("Search for parameter")); }); }); ui.separator(); @@ -609,6 +611,7 @@ pub fn parameters_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) ui.label(egui::RichText::new(crate::i18n::t(title)).color(t.text_dim).small()); ui.end_row(); for r in group { + let mut name_label = None; if let Some((old, buf)) = renaming.as_mut().filter(|(old, _)| *old == r.name) { if let Some(name) = rename_field(ui, rename_id.with("te"), old, buf) { if !name.is_empty() && name != *old { @@ -616,11 +619,15 @@ pub fn parameters_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) } renaming = None; } - } else if ui.add(egui::Label::new(&r.name).selectable(false).sense(egui::Sense::click())).double_clicked() { - renaming = Some((r.name.clone(), r.name.clone())); + } else { + let label = ui.add(egui::Label::new(&r.name).selectable(false).sense(egui::Sense::click())); + if label.double_clicked() { + renaming = Some((r.name.clone(), r.name.clone())); + } + name_label = Some(label.id); } - let id = egui::Id::new(("par_expr", &r.name)); - if let Some(v) = expr_field(ui, id, &r.expr) { + let id = egui::Id::unique(("par_expr", &r.name)); + if let Some(v) = expr_field(ui, id, &r.expr, name_label) { action = Some(json!({ "name": r.name, "expr": v })); } match (&r.value, &r.error) { @@ -662,8 +669,11 @@ pub fn parameters_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) /// The in-place name editor: focused with the old name selected when it opens, so typing goes /// here and not to the command line. `Some` when it closes: the new name on Enter, `""` otherwise. fn rename_field(ui: &mut egui::Ui, id: egui::Id, old: &str, buf: &mut String) -> Option { + // The id must be the same on every frame; `Id::unique` hashes its source deterministically. + // A `TextEdit` stores its state while it renders, so after requesting focus here the + // select-all range is loaded from that state and takes effect on the next frame. let opening = egui::TextEdit::load_state(ui.ctx(), id).is_none(); - let resp = ui.add(egui::TextEdit::singleline(buf).id(id).desired_width(120.0)); + let resp = ui.add(egui::TextEdit::singleline(buf).id(id).desired_width(120.0)).accessible_name(crate::tl!("Name")); if opening { resp.request_focus(); if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), id) { @@ -680,9 +690,16 @@ fn rename_field(ui: &mut egui::Ui, id: egui::Id, old: &str, buf: &mut String) -> } /// A text field committing on Enter / focus loss when changed. -pub fn expr_field(ui: &mut egui::Ui, id: egui::Id, value: &str) -> Option { +/// +/// `label` is the id of the visible label beside the field, so a screen reader reads the field as +/// that label instead of an unnamed text box. +pub fn expr_field(ui: &mut egui::Ui, id: egui::Id, value: &str, label: Option) -> Option { let mut buf = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_else(|| value.to_string()); let resp = ui.add(egui::TextEdit::singleline(&mut buf).id(id.with("te")).desired_width(150.0)); + let resp = match label { + Some(label) => resp.labelled_by(label), + None => resp.accessible_name(crate::tl!("Value")), + }; // Focus from memory: `Response::has_focus` is false while the OS window is unfocused, // which dropped the pending edit (matches `palettes::edit_field`). if ui.memory(|m| m.has_focus(id.with("te"))) { @@ -713,14 +730,14 @@ mod tests { fn frame(ctx: &egui::Context, focused: bool, events: Vec) -> Option { let mut out = None; let input = egui::RawInput { focused, events, ..Default::default() }; - ctx.run_ui(input, |ui| out = expr_field(ui, egui::Id::new("e"), "1")).drop_without_applying_deltas(); + ctx.run_ui(input, |ui| out = expr_field(ui, egui::Id::unique("e"), "1", None)).drop_without_applying_deltas(); out } #[test] fn pending_expression_survives_window_blur() { let ctx = egui::Context::default(); - let id = egui::Id::new("e"); + let id = egui::Id::unique("e"); frame(&ctx, true, vec![]); ctx.memory_mut(|m| m.request_focus(id.with("te"))); frame(&ctx, true, vec![egui::Event::Text("+2".into())]); diff --git a/crates/ui-egui/src/plotstyles.rs b/crates/ui-egui/src/plotstyles.rs index 614e7293c..1370ef50d 100644 --- a/crates/ui-egui/src/plotstyles.rs +++ b/crates/ui-egui/src/plotstyles.rs @@ -20,11 +20,11 @@ pub fn route(app: &mut CadApp, id: &str, params: &Value) -> Option(ctx: &egui::Context, id: &str) -> Option { - ctx.data_mut(|d| d.get_temp::(egui::Id::new(id))) + ctx.data_mut(|d| d.get_temp::(egui::Id::unique(id))) } fn set_temp(ctx: &egui::Context, id: &str, v: T) { - ctx.data_mut(|d| d.insert_temp(egui::Id::new(id), v)); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique(id), v)); } /// Lineweights offered for a style, in millimetres. @@ -65,7 +65,7 @@ fn window(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { let mut new_style = temp::(ctx, "pstyle_new").unwrap_or_default(); let mut action: Action = None; let set = |table: &str, style: Value| Some(("stylesmanager", json!({ "action": "set", "name": table, "style": style }))); - egui::Window::new(title).id(egui::Id::new("Plot Style Manager")).open(open).default_size(vec2(760.0, 460.0)).resizable(true).show(ctx, |ui| { + egui::Window::new(title).id(egui::Id::unique("Plot Style Manager")).open(open).default_size(vec2(760.0, 460.0)).resizable(true).show(ctx, |ui| { ui.horizontal(|ui| { ui.label(crate::tl!("Plot style table")); egui::ComboBox::from_id_salt("pstyle_table").selected_text(sel.as_str()).width(200.0).show_ui(ui, |ui| { @@ -193,8 +193,8 @@ fn window(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { if named { ui.separator(); ui.horizontal(|ui| { - ui.label(crate::tl!("New style")); - ui.add(egui::TextEdit::singleline(&mut new_style).desired_width(160.0)); + let label = ui.label(crate::tl!("New style")); + ui.add(egui::TextEdit::singleline(&mut new_style).desired_width(160.0)).labelled_by(label.id); if ui.add_enabled(!new_style.trim().is_empty(), egui::Button::new(crate::tl!("Add"))).clicked() { action = set(&table.name, json!({ "name": new_style.trim() })); new_style.clear(); diff --git a/crates/ui-egui/src/quick.rs b/crates/ui-egui/src/quick.rs index 5effd59cf..301be9d08 100644 --- a/crates/ui-egui/src/quick.rs +++ b/crates/ui-egui/src/quick.rs @@ -61,7 +61,7 @@ fn pretty(k: &str) -> String { pub fn qselect_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { let t = Tokens::get(); - let sid = egui::Id::new("qselect_state"); + let sid = egui::Id::unique("qselect_state"); let mut st: QsState = ctx.data_mut(|d| d.get_temp(sid)).unwrap_or_default(); let key = app.session.state().map(|s| std::sync::Arc::as_ptr(&s.doc) as usize).unwrap_or(0); let want = (key, st.apply_selection, st.ty.clone()); @@ -77,7 +77,7 @@ pub fn qselect_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { let mut run: Option = None; let mut close = false; egui::Window::new(crate::tl!("Quick Select")) - .id(egui::Id::new("Quick Select")) + .id(egui::Id::unique("Quick Select")) .open(open) .collapsible(false) .resizable(false) @@ -141,7 +141,7 @@ pub fn qselect_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { } }); ui.end_row(); - ui.label(crate::tl!("Value:")); + let value_label = ui.label(crate::tl!("Value:")); let choices: Vec = match st.property.as_str() { "color" => { ["ByLayer", "ByBlock", "Red", "Yellow", "Green", "Cyan", "Blue", "Magenta", "White"].iter().map(|s| s.to_string()).collect() @@ -152,7 +152,8 @@ pub fn qselect_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { _ => Vec::new(), }; ui.horizontal(|ui| { - ui.add(egui::TextEdit::singleline(&mut st.value).desired_width(if choices.is_empty() { 220.0 } else { 190.0 })); + ui.add(egui::TextEdit::singleline(&mut st.value).desired_width(if choices.is_empty() { 220.0 } else { 190.0 })) + .labelled_by(value_label.id); if !choices.is_empty() { ui.menu_button("▼", |ui| { egui::ScrollArea::vertical().max_height(240.0).show(ui, |ui| { @@ -229,9 +230,9 @@ struct QpState { } fn num_row(ui: &mut egui::Ui, label: &str, id: egui::Id, v: f64, set: &mut Option<(String, Value)>, key: &str) { - ui.label(RichText::new(crate::i18n::t(label)).color(Tokens::get().text_dim)); + let label = ui.label(RichText::new(crate::i18n::t(label)).color(Tokens::get().text_dim)); let s = format!("{v:.4}"); - if let Some(n) = crate::parametric::expr_field(ui, id, &s) + if let Some(n) = crate::parametric::expr_field(ui, id, &s, Some(label.id)) && let Some(f) = cadcraft_engine::units::parse_distance(&n).or_else(|| n.parse::().ok()).filter(|f| f.is_finite()) { *set = Some((key.to_string(), json!(f))); @@ -247,7 +248,7 @@ fn ro_row(ui: &mut egui::Ui, label: &str, v: String) { /// The Quick Properties palette, near where the selection was made. pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) { - let sid = egui::Id::new("qp_state"); + let sid = egui::Id::unique("qp_state"); let sel = app.session.selection(); let active = app.session.settings.qpmode && !sel.is_empty() @@ -299,7 +300,7 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) { let angles = app.session.angle_settings(); let mut close = false; egui::Window::new(title) - .id(egui::Id::new(("quick_properties", &st.sel))) + .id(egui::Id::unique(("quick_properties", &st.sel))) .default_pos(at) .collapsible(false) .resizable(false) @@ -309,7 +310,7 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) { egui::Grid::new("qp_grid").num_columns(2).spacing(vec2(10.0, 4.0)).min_col_width(80.0).show(ui, |ui| { ui.label(RichText::new(crate::tl!("Color")).color(Tokens::get().text_dim)); let cur = color.unwrap_or(cadcraft_color::Color::ByLayer); - if let Some(c) = crate::layers::color_button(ui, egui::Id::new(("qp_color", &ids)), cur, true, 150.0) { + if let Some(c) = crate::layers::color_button(ui, egui::Id::unique(("qp_color", &ids)), cur, true, 150.0) { set = Some(("color".into(), json!(c.name()))); } ui.end_row(); @@ -334,7 +335,7 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) { } }); ui.end_row(); - let id = |k: &str| egui::Id::new(("qp", k, &ids)); + let id = |k: &str| egui::Id::unique(("qp", k, &ids)); match &kind { Some(EntityKind::Line(l)) => { let dl = l.b.xy() - l.a.xy(); @@ -369,8 +370,8 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) { num_row(ui, "Global width", id("w"), p.const_width, &mut set, "width"); } Some(EntityKind::Text(tx)) => { - ui.label(RichText::new(crate::tl!("Contents")).color(Tokens::get().text_dim)); - if let Some(v) = crate::parametric::expr_field(ui, id("txt"), &tx.value) { + let label = ui.label(RichText::new(crate::tl!("Contents")).color(Tokens::get().text_dim)); + if let Some(v) = crate::parametric::expr_field(ui, id("txt"), &tx.value, Some(label.id)) { set = Some(("text".into(), json!(v))); } ui.end_row(); @@ -378,8 +379,8 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) { num_row(ui, "Rotation", id("rot"), angles.shown(tx.rotation).to_degrees(), &mut set, "rotation"); } Some(EntityKind::MText(tx)) => { - ui.label(RichText::new(crate::tl!("Contents")).color(Tokens::get().text_dim)); - if let Some(v) = crate::parametric::expr_field(ui, id("mtxt"), &tx.contents) { + let label = ui.label(RichText::new(crate::tl!("Contents")).color(Tokens::get().text_dim)); + if let Some(v) = crate::parametric::expr_field(ui, id("mtxt"), &tx.contents, Some(label.id)) { set = Some(("text".into(), json!(v))); } ui.end_row(); @@ -391,8 +392,8 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) { num_row(ui, "Scale", id("sc"), i.scale.x, &mut set, "scale"); } Some(EntityKind::Dimension(dm)) => { - ui.label(RichText::new(crate::tl!("Text override")).color(Tokens::get().text_dim)); - if let Some(v) = crate::parametric::expr_field(ui, id("dt"), &dm.text) { + let label = ui.label(RichText::new(crate::tl!("Text override")).color(Tokens::get().text_dim)); + if let Some(v) = crate::parametric::expr_field(ui, id("dt"), &dm.text, Some(label.id)) { set = Some(("textOverride".into(), json!(v))); } ui.end_row(); diff --git a/crates/ui-egui/src/spacemouse.rs b/crates/ui-egui/src/spacemouse.rs index 8f2c90e12..b27911c12 100644 --- a/crates/ui-egui/src/spacemouse.rs +++ b/crates/ui-egui/src/spacemouse.rs @@ -133,7 +133,7 @@ fn navigate(app: &mut CadApp, params: &Value) -> Result { } pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { - egui::Window::new(crate::tl!("3D Mouse")).id(egui::Id::new("spacemouse")).open(open).resizable(false).show(ctx, |ui| { + egui::Window::new(crate::tl!("3D Mouse")).id(egui::Id::unique("spacemouse")).open(open).resizable(false).show(ctx, |ui| { ui.label(crate::i18n::t(app.spacemouse.status.message())); if let Some(device) = &app.spacemouse.device { ui.label(device); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 6c8dd83d0..f8738a64e 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -302,8 +302,10 @@ pub fn mono() -> FontId { FontId::new(12.0, FontFamily::Monospace) } -/// Load a system UI font at run time when one is installed (not bundled), else keep egui's; -/// then CJK fonts after the Latin ones, so Latin text keeps its look, in every interface +/// Load a system UI font at run time when one is installed (not bundled), else keep egui's +/// bundled bounded Latin/icon faces (`default_fonts` + `monochrome_emoji_fonts`, enabled in the +/// workspace manifest for native and web); then CJK fonts after the Latin ones, so Latin text +/// keeps its look, in every interface /// language: file, layout and layer names can be Japanese or Chinese whatever the interface /// shows (#513). In a Chinese interface (`language` "zh…") the Chinese font comes first, else a /// Japanese one, so names take their own glyph forms; in an Arabic interface an installed @@ -312,6 +314,7 @@ pub fn mono() -> FontId { /// interface adds a Hangul font only when the CJK font found doesn't cover Hangul /// ([`korean_font`]). pub fn install_fonts(ctx: &egui::Context, language: &str) { + crate::mtext_editor::install_diameter_glyph(ctx); let mut fonts = egui::FontDefinitions::default(); let mut added: Vec<(String, std::sync::Arc)> = Vec::new(); #[cfg(not(target_arch = "wasm32"))] @@ -336,6 +339,11 @@ pub fn install_fonts(ctx: &egui::Context, language: &str) { if let Some(f) = fonts.families.get_mut(&FontFamily::Proportional) { f.insert(0, "system-ui".into()); } + // Keep Hack first in Monospace, then use the system face for glyphs Hack lacks. + if let Some(f) = fonts.families.get_mut(&FontFamily::Monospace) { + let at = f.iter().position(|name| name == "Hack").map_or(0, |i| i + 1); + f.insert(at, "system-ui".into()); + } break; } } @@ -383,6 +391,16 @@ pub fn install_fonts(ctx: &egui::Context, language: &str) { } } } + // `default_fonts` supplies Ubuntu-Light (Proportional) and Hack (Monospace). Hack also + // carries the modifier symbols (⇧) that proportional shortcut labels use; keep it as a + // bounded late fallback when Ubuntu-Light lacks one. The monochrome emoji face (enabled by + // `monochrome_emoji_fonts`) supplies ⌘ in both families. + if fonts.font_data.contains_key("Hack") + && let Some(f) = fonts.families.get_mut(&FontFamily::Proportional) + && !f.iter().any(|name| name == "Hack") + { + f.push("Hack".into()); + } ctx.set_fonts(fonts); } diff --git a/crates/ui-egui/src/viewcube.rs b/crates/ui-egui/src/viewcube.rs index 6f3ec0a3d..8f96e82fe 100644 --- a/crates/ui-egui/src/viewcube.rs +++ b/crates/ui-egui/src/viewcube.rs @@ -80,7 +80,7 @@ impl Part { /// Paint the ViewCube's ring, compass letters, Top face and Home button, and handle the pointer. /// The canvas paints the WCS pill itself; this returns the pointer's part for its highlight. pub fn show(app: &mut CadApp, ui: &mut egui::Ui, l: &Layout) { - let resp = ui.interact(l.bounds, ui.id().with("viewcube"), Sense::click_and_drag()); + let resp = ui.interact(l.bounds, ui.scope_id().with("viewcube"), Sense::click_and_drag()); let part = resp.hover_pos().and_then(|p| l.part_at(p)); let p = ui.painter_at(l.bounds.union(l.pill)); let ring_col = if part == Some(Part::Ring) { Color32::from_rgb(0x5c, 0x67, 0x7c) } else { Color32::from_rgb(0x48, 0x50, 0x5c) }; @@ -132,7 +132,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, l: &Layout) { let _ = app.run(id, params); } // The WCS pill (painted by the canvas) opens the coordinate-system menu. - let pill = ui.interact(l.pill, ui.id().with("viewcube_wcs"), Sense::click_and_drag()).on_hover_cursor(CursorIcon::PointingHand); + let pill = ui.interact(l.pill, ui.scope_id().with("viewcube_wcs"), Sense::click_and_drag()).on_hover_cursor(CursorIcon::PointingHand); let pill = pill.on_hover_text("Coordinate system"); ucs_menu(app, egui::Popup::menu(&pill)); } @@ -164,8 +164,8 @@ pub fn ucs_arms(canvas: Rect) -> [Rect; 2] { pub fn ucs_icon(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) { let t = Tokens::get(); let [x, y] = ucs_arms(canvas); - let rx = ui.interact(x, ui.id().with("ucs_x"), Sense::click_and_drag()); - let ry = ui.interact(y, ui.id().with("ucs_y"), Sense::click_and_drag()); + let rx = ui.interact(x, ui.scope_id().with("ucs_x"), Sense::click_and_drag()); + let ry = ui.interact(y, ui.scope_id().with("ucs_y"), Sense::click_and_drag()); let resp = (rx | ry).on_hover_cursor(CursorIcon::PointingHand).on_hover_text("UCS icon: World Coordinate System"); if resp.hovered() { let o = pos2(x.left() + 8.0, x.center().y); diff --git a/crates/ui-egui/src/vpscale.rs b/crates/ui-egui/src/vpscale.rs index 8f19c85e3..304797c19 100644 --- a/crates/ui-egui/src/vpscale.rs +++ b/crates/ui-egui/src/vpscale.rs @@ -120,7 +120,7 @@ pub fn status(app: &mut CadApp, ui: &mut egui::Ui, right: f32, cy: f32) -> f32 { let p = ui.painter().clone(); // Lock toggle. let lr = Rect::from_min_size(pos2(right - 20.0, cy - 9.0), vec2(18.0, 18.0)); - let lresp = ui.interact(lr, ui.id().with("vp_lock"), Sense::click()); + let lresp = ui.interact(lr, ui.scope_id().with("vp_lock"), Sense::click()); if lresp.hovered() { p.rect_filled(lr, 3.0, t.control_hover.gamma_multiply(0.5)); } @@ -138,7 +138,7 @@ pub fn status(app: &mut CadApp, ui: &mut egui::Ui, right: f32, cy: f32) -> f32 { let g = p.layout_no_wrap(text, crate::theme::small(), if vp.locked { t.text_dim } else { t.text }); let w = g.size().x + 22.0; let sr = Rect::from_min_max(pos2(lr.left() - 4.0 - w, cy - 9.0), pos2(lr.left() - 4.0, cy + 9.0)); - let sresp = ui.interact(sr, ui.id().with("vp_scale"), Sense::click()); + let sresp = ui.interact(sr, ui.scope_id().with("vp_scale"), Sense::click()); p.rect_filled(sr, 3.0, if sresp.hovered() { t.control_hover } else { t.control.gamma_multiply(0.5) }); p.galley(pos2(sr.left() + 6.0, sr.center().y - g.size().y / 2.0), g, t.text); let (ax, ay) = (sr.right() - 9.0, sr.center().y); diff --git a/crates/ui-egui/src/xrefs.rs b/crates/ui-egui/src/xrefs.rs index fb9cb9ef1..f142ab1f2 100644 --- a/crates/ui-egui/src/xrefs.rs +++ b/crates/ui-egui/src/xrefs.rs @@ -77,7 +77,7 @@ struct Row { fn rows(app: &CadApp, ui: &egui::Ui) -> Vec { let Ok(st) = app.session.state() else { return Vec::new() }; let key = (st.uid, st.revision); - let id = egui::Id::new("xref-rows"); + let id = egui::Id::unique("xref-rows"); if let Some((k, rows)) = ui.data(|d| d.get_temp::<((u64, u64), Vec)>(id)) && k == key { @@ -129,8 +129,8 @@ enum Act { pub fn section(app: &mut CadApp, ui: &mut egui::Ui) { let t = crate::theme::Tokens::get(); let rows = rows(app, ui); - let sel_id = egui::Id::new("xref-selected"); - let path_id = egui::Id::new("xref-path-edit"); + let sel_id = egui::Id::unique("xref-selected"); + let path_id = egui::Id::unique("xref-path-edit"); let mut selected: Option = ui.data(|d| d.get_temp(sel_id)); if !selected.as_ref().is_some_and(|s| rows.iter().any(|r| &r.name == s)) { selected = rows.first().map(|r| r.name.clone()); @@ -245,10 +245,10 @@ fn details(ui: &mut egui::Ui, r: &Row, path_id: egui::Id, act: &mut Option) ui.add_space(4.0); ui.horizontal(|ui| { ui.add_space(8.0); - ui.label(crate::tl!("Saved Path")); + let label = ui.label(crate::tl!("Saved Path")); let mut text: String = ui.data(|d| d.get_temp(path_id)).unwrap_or_else(|| r.path.clone()); let w = (ui.available_width() - 70.0).max(60.0); - let edit = ui.add(egui::TextEdit::singleline(&mut text).desired_width(w)); + let edit = ui.add(egui::TextEdit::singleline(&mut text).desired_width(w)).labelled_by(label.id); let apply = ui.add_enabled(!text.trim().is_empty() && text != r.path, egui::Button::new(crate::tl!("Apply"))).clicked() || (edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && text != r.path); if apply { diff --git a/crates/ui-egui/tests/accessibility_names.rs b/crates/ui-egui/tests/accessibility_names.rs new file mode 100644 index 000000000..48a92293b --- /dev/null +++ b/crates/ui-egui/tests/accessibility_names.rs @@ -0,0 +1,98 @@ +//! Semantic accessibility: controls expose an accessible name, not only a placeholder, and the +//! AccessKit root carries the language the interface is shown in. +//! +//! `hint_text` becomes an AccessKit placeholder, so a screen reader sees an unnamed text input +//! unless the widget is associated with a visible label (`labelled_by`) or given a name. + +use cadcraft_engine::Session; +use cadcraft_ui_egui::{CadApp, Services, i18n, menus}; +use egui::accesskit::Role; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +fn harness(app: CadApp) -> Harness<'static, CadApp> { + Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state( + |ui, app: &mut CadApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ) +} + +/// Every editable text input must advertise a non-empty accessible name: its own label or the +/// visible label it is associated with. A placeholder alone is not a name. +fn unnamed_text_inputs(h: &Harness<'_, CadApp>) -> Vec { + let text_inputs = h.query_all_by_role(Role::TextInput).chain(h.query_all_by_role(Role::MultilineTextInput)); + text_inputs + .filter(|node| { + let node = node.accesskit_node(); + node.label().as_deref().is_none_or(str::is_empty) && node.labelled_by().next().is_none() + }) + .map(|node| { + let value = node.accesskit_node().value().unwrap_or_default().to_string(); + let placeholder = node.accesskit_node().placeholder().unwrap_or_default().to_string(); + format!("value={value:?} placeholder={placeholder:?}") + }) + .collect() +} + +#[test] +fn options_autosave_path_has_a_semantic_label() { + let mut app = CadApp::new(Session::new(), Services::default()); + menus::activate(&mut app, "options"); + // The path field lives on Open and Save, not on the default Display tab. + app.ui.dialog = Some("options:files".into()); + let h = harness(app); + let name = "Automatic save file location"; + let field = h.get_by_role_and_label(Role::TextInput, name); + let labelled_by: Vec<_> = field.accesskit_node().labelled_by().map(|node| node.id()).collect(); + assert_eq!(labelled_by.len(), 1, "the field is labelled by the visible row label"); + let label = h.get_all_by_role(Role::Label).find(|node| node.accesskit_node().id() == labelled_by[0]).expect("the referenced label node"); + assert_eq!(label.accesskit_node().value().as_deref(), Some(name)); + let unnamed = unnamed_text_inputs(&h); + assert!(unnamed.is_empty(), "unnamed text inputs in OPTIONS: {unnamed:?}"); +} + +/// Every interactive control on every Options tab is named by the visible label beside it. The +/// harness's own strict accessibility check already runs while it settles; this test makes the +/// tab-by-tab coverage explicit and reports the offending tab in its own assertion. +#[test] +fn every_options_tab_names_its_controls() { + for tab in ["display", "files", "drafting", "selection", "preferences", "profiles"] { + let mut app = CadApp::new(Session::new(), Services::default()); + menus::activate(&mut app, "options"); + app.ui.dialog = Some(format!("options:{tab}")); + let h = harness(app); + let unnamed = h.unnamed_widgets(); + assert!(unnamed.is_empty(), "unnamed controls on Options {tab}: {unnamed:?}"); + } +} + +/// The Parameters Manager's expression fields are the concrete unnamed fields the review found; +/// each one is named by the parameter's name label in the same row. +#[test] +fn parameters_manager_fields_have_a_semantic_label() { + let mut app = CadApp::new(Session::new(), Services::default()); + app.run("parameters", json!({ "name": "user1", "expr": "4" })).unwrap(); + app.run("parameters", json!({ "name": "user2", "expr": "user1*2" })).unwrap(); + app.ui.dialog = Some("parameters".into()); + let h = harness(app); + let unnamed = unnamed_text_inputs(&h); + assert!(unnamed.is_empty(), "unnamed text inputs in the Parameters Manager: {unnamed:?}"); + let labels: Vec = h.query_all_by_role(Role::TextInput).filter_map(|node| node.accesskit_node().label()).collect(); + assert!(labels.iter().any(|label| label == "user1"), "the first expression field is named by its parameter: {labels:?}"); + assert!(labels.iter().any(|label| label == "user2"), "the second expression field is named by its parameter: {labels:?}"); +} + +/// The AccessKit root names the language the interface is actually shown in, so a screen reader +/// switches voice instead of reading a translated interface with the OS default. +#[test] +fn accesskit_root_carries_the_interface_language() { + let mut app = CadApp::new(Session::new(), Services::default()); + app.ui.interface_language = i18n::Preference::German; + let h = harness(app); + assert_eq!(h.state().language(), "de"); + assert_eq!(h.root().accesskit_node().data().language(), Some("de"), "the root node carries the shown language"); +} diff --git a/crates/ui-egui/tests/it/animation_effects.rs b/crates/ui-egui/tests/it/animation_effects.rs index 926b084e5..ba18afb72 100644 --- a/crates/ui-egui/tests/it/animation_effects.rs +++ b/crates/ui-egui/tests/it/animation_effects.rs @@ -23,8 +23,8 @@ fn frame( let mut seen = None; let mut out = ctx.run_ui(raw, |ui| { app.logic(ui.ctx()); - seen = Some(look(ui.ctx())); app.ui(ui); + seen = Some(look(ui.ctx())); }); out.textures_delta.clear(); // no renderer here (seen.unwrap(), out.shapes) @@ -101,6 +101,7 @@ fn wheel_scrolling_glides_only_while_animation_effects_show() { let notches = || { vec![egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, + source: egui::MouseWheelSource::Unknown, delta: egui::vec2(0.0, -3.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, @@ -130,7 +131,13 @@ fn wheel_zoom(app: &mut CadApp, modifiers: egui::Modifiers) -> Vec { frame(app, &ctx, vec![egui::Event::PointerMoved(over)], |_| ()); } let before = height(app); - let notch = egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: egui::vec2(0.0, 1.0), phase: egui::TouchPhase::Move, modifiers }; + let notch = egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, 1.0), + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Unknown, + modifiers, + }; let mut events = vec![notch]; (0..40) .map(|_| { diff --git a/crates/ui-egui/tests/it/docking_workflows.rs b/crates/ui-egui/tests/it/docking_workflows.rs index 1e3ee118c..212a83511 100644 --- a/crates/ui-egui/tests/it/docking_workflows.rs +++ b/crates/ui-egui/tests/it/docking_workflows.rs @@ -300,7 +300,7 @@ fn canvas_reset_during_splitter_drag_discards_old_resize_and_ends_gesture() { .unwrap(); h.run_steps(3); let drawing = h.state().session.doc().unwrap().clone(); - let splitter = egui::Id::new("cadcraft-docking").with(("split", Vec::::new())); + let splitter = egui::Id::unique("cadcraft-docking").with(("split", Vec::::new())); let from = h.ctx.read_response(splitter).expect("rendered root divider").rect.center(); h.event(Event::PointerMoved(from)); h.run_steps(1); diff --git a/crates/ui-egui/tests/it/hidden_command_line.rs b/crates/ui-egui/tests/it/hidden_command_line.rs index 58427f625..0da43aa42 100644 --- a/crates/ui-egui/tests/it/hidden_command_line.rs +++ b/crates/ui-egui/tests/it/hidden_command_line.rs @@ -18,7 +18,7 @@ fn call(app: &mut CadApp, ctx: &egui::Context, method: &str, params: Value) -> V fn frame(app: &mut CadApp, ctx: &egui::Context) { let mut raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 800.0))), ..Default::default() }; - app.raw_input_hook(&mut raw); + app.raw_input_hook(ctx, &mut raw); cadcraft_ui_egui::cmdline::capture_tab(ctx, &mut raw); let mut out = ctx.run_ui(raw, |ui| { app.logic(ui.ctx()); diff --git a/crates/ui-egui/tests/it/lisp_files.rs b/crates/ui-egui/tests/it/lisp_files.rs index abd7f6a81..42d6d6e7e 100644 --- a/crates/ui-egui/tests/it/lisp_files.rs +++ b/crates/ui-egui/tests/it/lisp_files.rs @@ -23,7 +23,10 @@ fn a_long_routine_runs_across_frames() { let ctx = egui::Context::default(); let mut frames = 0; while app.session.lisp_running() && frames < 1000 { - let mut out = ctx.run_ui(egui::RawInput::default(), |ui| app.logic(ui.ctx())); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); out.textures_delta.clear(); // no renderer here frames += 1; } diff --git a/crates/ui-egui/tests/it/menubar_hover.rs b/crates/ui-egui/tests/it/menubar_hover.rs index 2a0ec4da9..798776268 100644 --- a/crates/ui-egui/tests/it/menubar_hover.rs +++ b/crates/ui-egui/tests/it/menubar_hover.rs @@ -100,7 +100,8 @@ fn clicking_the_open_title_closes_it() { fn submenus_open_and_close_with_their_menu() { let mut harness = harness(); click_title(&mut harness, "View"); - harness.get_by_label("Zoom ⏵").hover(); + // egui 0.37 names the submenu button "Zoom"; the arrow is painted, not part of the label. + harness.get_by_role_and_label(Role::Button, "Zoom").hover(); harness.run_steps(3); assert!(shown(&harness, IN_ZOOM), "pointing at Zoom opens its submenu"); diff --git a/crates/ui-egui/tests/it/options_dialog.rs b/crates/ui-egui/tests/it/options_dialog.rs index eb9cdeaea..be6869e4a 100644 --- a/crates/ui-egui/tests/it/options_dialog.rs +++ b/crates/ui-egui/tests/it/options_dialog.rs @@ -150,10 +150,14 @@ fn hostile_saved_profiles_are_ignored() { fn model_background_reaches_the_drawing_area_tokens() { let mut a = app(); let ctx = egui::Context::default(); - a.logic(&ctx); + let frame = |a: &mut CadApp| { + let mut out = ctx.run_ui(Default::default(), |ui| a.ui(ui)); + out.textures_delta.clear(); + }; + frame(&mut a); assert_eq!(Tokens::get().canvas, Tokens::DARK.canvas); a.run("options", json!({"modelBackground": "#ffffff", "paperBackground": "#202020"})).unwrap(); - a.logic(&ctx); + frame(&mut a); let t = Tokens::get(); assert_eq!(t.canvas, Color32::WHITE); assert_eq!(t.paper, Color32::from_gray(0x20)); @@ -161,7 +165,7 @@ fn model_background_reaches_the_drawing_area_tokens() { assert_ne!(t.grid_minor, Tokens::DARK.grid_minor); assert_eq!(t.chrome, Tokens::DARK.chrome, "the interface keeps its theme"); a.run("options", json!({"reset": true})).unwrap(); - a.logic(&ctx); + frame(&mut a); assert_eq!(Tokens::get(), Tokens::DARK); } @@ -234,7 +238,7 @@ fn selection_highlight_settings_set_save_and_show_in_the_selection_tab() { assert!(!text.contains(label), "{label} offered"); } // Visual Effect Settings: the selection area and the preview filter. - ctx.data_mut(|d| d.insert_temp(egui::Id::new("cc_options_visual_effects"), true)); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique("cc_options_visual_effects"), true)); let text = render(&mut a, &ctx); for label in [ "Selection Area Effect", diff --git a/crates/ui-egui/tests/it/parameters_rename.rs b/crates/ui-egui/tests/it/parameters_rename.rs index 0754bbcb0..9530bbd17 100644 --- a/crates/ui-egui/tests/it/parameters_rename.rs +++ b/crates/ui-egui/tests/it/parameters_rename.rs @@ -3,9 +3,10 @@ use cadcraft_engine::Session; use cadcraft_ui_egui::{CadApp, Services}; +use egui::accesskit::Role; use egui::{Event, Modifiers, PointerButton, vec2}; use egui_kittest::Harness; -use egui_kittest::kittest::Queryable; +use egui_kittest::kittest::{By, Queryable}; use serde_json::json; fn harness() -> Harness<'static, CadApp> { @@ -26,7 +27,11 @@ fn harness() -> Harness<'static, CadApp> { } fn double_click_label(h: &mut Harness<'_, CadApp>, label: &str) { - let pos = h.get_by_label(label).rect().center(); + // Click the visible name label itself. The expression field beside it is `labelled_by` that + // label, and kittest's `get_by_label` deliberately returns the labelled widget (the field), + // so a plain `get_by_label` would double-click the wrong node. `include_labels` plus the + // Label role selects the row's name text, keeping this a real pointer/keyboard test. + let pos = h.get(By::new().role(Role::Label).label(label).include_labels()).rect().center(); h.event(Event::PointerMoved(pos)); h.run_steps(2); for _ in 0..2 { @@ -47,6 +52,7 @@ fn parameters(h: &Harness<'_, CadApp>) -> Vec<(String, String)> { fn double_click_types_a_new_name_and_enter_renames() { let mut h = harness(); double_click_label(&mut h, "user1"); + assert!(h.get_by_role_and_label(Role::TextInput, "Name").is_focused(), "the rename field has the keyboard focus"); h.event(Event::Text("width".into())); h.run_steps(2); assert_eq!(h.state().cmd.buffer, "", "the command line got the keys"); diff --git a/crates/ui-egui/tests/it/properties_units.rs b/crates/ui-egui/tests/it/properties_units.rs index 5b9eb31c9..596af84c3 100644 --- a/crates/ui-egui/tests/it/properties_units.rs +++ b/crates/ui-egui/tests/it/properties_units.rs @@ -4,7 +4,7 @@ use cadcraft_engine::Session; use cadcraft_engine::doc::EntityKind; -use cadcraft_ui_egui::{CadApp, Services}; +use cadcraft_ui_egui::{CadApp, Services, theme}; use egui::{Key, vec2}; use egui_kittest::Harness; use egui_kittest::kittest::{Queryable, by}; @@ -16,7 +16,9 @@ fn palettes(lunits: i64) -> Harness<'static, CadApp> { app.run("setvar", json!({ "name": "LUNITS", "value": lunits })).unwrap(); let h = app.run("text", json!({ "at": [0, 0], "text": "Note", "height": 2.5 })).unwrap(); app.run("select", json!({ "handles": [h["handle"]] })).unwrap(); - let mut h = Harness::builder().with_size(vec2(400.0, 1400.0)).build_ui_state( + // Install the production font chain before the first pass; the fixture draws palettes + // directly and would otherwise fall back to egui's fonts instead of the app's chain. + let mut h = Harness::builder().with_size(vec2(400.0, 1400.0)).with_context_setup(|ctx| theme::install_fonts(ctx, "en")).build_ui_state( |ui, app: &mut CadApp| { app.logic(&ui.ctx().clone()); cadcraft_ui_egui::palettes::right_palettes(app, ui); diff --git a/crates/ui-egui/tests/it/shared_controls.rs b/crates/ui-egui/tests/it/shared_controls.rs index 2c6bc2ab8..76cb38979 100644 --- a/crates/ui-egui/tests/it/shared_controls.rs +++ b/crates/ui-egui/tests/it/shared_controls.rs @@ -116,7 +116,9 @@ fn drafting_palette_pointer_and_keyboard_start_the_same_commands() { #[test] fn right_palettes_collapse_arrow_hides_menu() { - let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + // Install the production font chain before the first pass; the fixture draws the palette + // directly and would otherwise fall back to egui's fonts instead of the app's chain. + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).with_context_setup(|ctx| theme::install_fonts(ctx, "en")).build_ui_state( |ui, app: &mut CadApp| { app.logic(&ui.ctx().clone()); cadcraft_ui_egui::palettes::right_palettes(app, ui); diff --git a/crates/ui-egui/tests/options_followups.rs b/crates/ui-egui/tests/options_followups.rs index 0f470baeb..9c7327905 100644 --- a/crates/ui-egui/tests/options_followups.rs +++ b/crates/ui-egui/tests/options_followups.rs @@ -117,6 +117,8 @@ fn interface_scale_in_options() { assert_eq!(r["interfaceScale"], json!(1.5)); let ctx = egui::Context::default(); frame(&mut a, &ctx, Vec::new(), 0.0); + // egui applies a newly requested zoom factor at the start of the next pass. + frame(&mut a, &ctx, Vec::new(), 0.1); assert_eq!(ctx.zoom_factor(), 1.5, "applied as egui's zoom factor"); // Refused all or nothing. @@ -143,13 +145,14 @@ fn interface_scale_in_options() { // Reset brings back the system's own size. assert_eq!(a.run("options", json!({"reset": true})).unwrap()["interfaceScale"], json!(1.0)); - frame(&mut a, &ctx, Vec::new(), 0.1); + frame(&mut a, &ctx, Vec::new(), 0.2); + frame(&mut a, &ctx, Vec::new(), 0.3); assert_eq!(ctx.zoom_factor(), 1.0); // The Display tab shows it. a.run("options", json!({"interfaceScale": 1.25})).unwrap(); a.ui.dialog = Some("options:display".into()); - frame(&mut a, &ctx, Vec::new(), 0.2); - let text = frame(&mut a, &ctx, Vec::new(), 0.3); + frame(&mut a, &ctx, Vec::new(), 0.4); + let text = frame(&mut a, &ctx, Vec::new(), 0.5); assert!(text.contains("Interface scale") && text.contains("125 %"), "{text}"); } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index b1e91a52c..5b1c1c7ee 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -50,7 +50,7 @@ field. `engine.commands` returns a separate command catalog, not a list of addit | `engine.execute` | `{command, params}` | Run any command with JSON parameters. Never opens a dialog. `command` is an alias of this method, with the same parameters and result. | | `engine.commands` | | Every command: id, label, menu path, shortcut, aliases, params doc, enabled. | | `drawing.inspect` | `{entities?, limit?}` | Drawing summary (counts, extents, layers, styles, blocks, layouts, selection, undo) and entities with handles and geometry. | -| `ui.inspect` | | UI state, canvas rect, view, cursor and snap, performance counters; `closePrompt`: the drawing a "Save changes?" prompt is asking about (null when none); and actual egui keyboard focus (`focus.id`, `focus.label`, `focus.role`). | +| `ui.inspect` | | UI state, canvas rect, view, cursor and snap, performance counters (`uiFrames`, `passIndex` included); `closePrompt`: the drawing a "Save changes?" prompt is asking about (null when none); and actual egui keyboard focus (`focus.id`, `focus.label`, `focus.role`). | | `ui.elements` | `{label?, role?, focused?}` | Observed toolbar, tab, drafting and layer-palette controls from the latest UI frame. Filters match exactly. Each entry has its real egui `id`, label, role, rectangle `[x,y,width,height]`, enabled/visible/selected state and current focus. | | `ui.menu.list` / `ui.menu.invoke` | `{command}` | The menu tree; invoke an item like a click (interactive). | | `ui.pointer` | `{x, y, space?: "world"\|"screen", button?, action?: "click"\|"move"}` | Click in the drawing area (world coordinates by default). Right button = Enter. | @@ -60,7 +60,7 @@ field. `engine.commands` returns a separate command catalog, not a list of addit | `ui.set` | UiState fields | Show/hide palettes, toolbars, command line, ViewCube…; `theme: "system"\|"light"\|"dark"` (also `engine.execute` with `ui.theme {theme}`); `ui.inspect` reports the theme shown (`theme`: light/dark). `animations: "system"\|"on"\|"off"` (also `ui.animations {animations}`) shows or hides animation effects (dialog and menu fades, smoothed scrolling), `system` following the operating system's setting; `ui.inspect` reports whether they show (`animations`: true/false). `saveFormat: "dxf"\|"dwg"` (also `ui.saveformat {format}`) is the format Save As suggests for new drawings; `saveVersion: "2000"`…`"2018"` (also `ui.saveversion {version}`) the version it saves them in (SAVEAS also takes `version` and `binary` directly). | | `ui.resize` | `{width, height}` | Resize the window. | | `ui.focus` | | Activate the native window. Does not assign focus to a widget. | -| `ui.screenshot` | `{path?}` | PNG of the window (needs a presented frame). | +| `ui.screenshot` | `{path?}` | PNG of the window (needs a presented frame). Bounded: at most 4 queued/in-flight/completed requests, 64 MiB per image and 64 MiB total (completed but undrained images still count), an 8 s deadline, and cancellation when the document/keyboard focus or window geometry changes. Every reply carries `request`; failures include `ok:false,error`. Files are written only after the deadline and identity checks pass. Without `path`, each request gets a unique PNG in a private owner-only per-process directory under the temp dir (created exclusively; an existing symlink is never followed), so concurrent app instances cannot collide; the caller owns those files and should remove them when done. | | `ui.render` | `{path?, width?, height?, fit?}` | Headless render. `fit` (default true) frames the whole drawing; `false` uses the current view. Replies `{pngBase64, width, height, bytes, path?}`. | | `app.open` / `app.save` / `app.quit` | `{path}` | File operations. | | `control.methods` | | Every method this server answers: `{method, params, description}`. Headless (`cadcraft-cli mcp`) lists only the methods it serves. | diff --git a/docs/egui-037-migration-plan.md b/docs/egui-037-migration-plan.md new file mode 100644 index 000000000..297468544 --- /dev/null +++ b/docs/egui-037-migration-plan.md @@ -0,0 +1,1465 @@ +# CADCraft — egui 0.37 migration plan + +Status: **planning only**. This document changes no production code, manifest, lockfile, +branch, tag, PR or release. No build, test or GUI run was performed while writing it. +Every future push or new PR requires fresh, explicit human approval after the exact +contents and validation results are shown; nothing in this document grants that approval. +Every future host build/test and owned-VM command goes through +`maintenance/task.py run/status/finish` with its own task, source and evidence. + +Scope: the `storytold/cadcraft` checkout at +`/home/codex/work/CraftApps/egui037-migration-20261022/cadcraft` only. The shared +`storytold/egui` fork, `storytold/winit`, `storytold/craft-libs` and the sibling apps +are dependencies and hand-off points, not repositories changed by this plan. + +Claims are written to be checked against the named source. Historical compiler output +and measurements are clues, never current acceptance. + +Capture acceptance follows the suite contract: a queued-not-issued hidden request and +each settling frame must request `RequestPaintWhileHidden` from logic; only an issued +screenshot command forces paint itself. Callback arrival has no implicit wake, so the +live request owns the viewport waker. Record known issuance UI bounds and a geometry +epoch, reject/retry any intervening resize/DPI/zoom/surface change even if it changes +back, and derive pixel scale from returned dimensions only under an unchanged epoch. +Default limits are 4 requests, 128 MiB/image and 256 MiB aggregate; CADCraft's explicit +64 MiB limits remain stricter. Count reserved in-flight and completed images together. + + +## 1. Decision in one page + +CADCraft's committed dependency state does not currently build as written: +`Cargo.toml:52-57` asks for egui/eframe/egui-wgpu/egui_kittest 0.37 while `craft-ui` +is pinned to `5c874d51…`, whose egui dependency is 0.36.2. The lock therefore resolves +two incompatible egui families, and `DockArea::show` receives a 0.37 `Ui` from a +0.36-typed API. This is a reconciliation task, not a version bump. + +Target state: + +1. One immutable egui family from the **final reviewed `storytold/egui` revision**. + That revision is not known or pinned by this plan; it exists only after the approved + publication of the fork's reviewed patch series. The aiongg candidate + `a4f9a17f64a6bae61350fb85804c9df0d9865916` and upstream `841a328b` are comparison + inputs, never production pins. +2. One immutable winit from the **final reviewed `storytold/winit` revision** containing + the W1-W15 base, W17-W27 language/IME series and the new **W15P** PRIMARY path. The + aiongg candidate `8b35388bd719e8cc86a95971ba0bc026c9a8f058` contains W17-W27 but no + PRIMARY and is only a comparison input. +3. `craft-ui` and `craft-docpath` from one **final reviewed `storytold/craft-libs` + revision** that is current `main` (`49190884…`) plus only the egui-API hunks ported + from PR #199's `8614e4a…`. The PR head `b68de8c…` and audit tree `ec8840a…` are + stale unsafe trees and are never merged or checked out as the target. +4. CADCraft source changes limited to: the real compile-error classes + (`UserData`/`Event::Screenshot`, `MouseWheel.source`, private `FontsView.fonts`), the + deprecations `clippy -D warnings` rejects, panel-id/`PanelState` migration, the strict + kittest names/glyphs needed for a green first compatibility PR, and the deliberate + lifecycle/screenshot/PRIMARY/IME changes below. There is no `update`→`ui` rewrite: + `eframe::App::ui` and a root `Ui` are already used (`apps/cadcraft/src/main.rs:91`, + `crates/ui-egui/src/lib.rs:627`). +5. A lifecycle split that is correct under the **actual pinned integration**: both native + and web call `App::logic` and `App::ui` inside every `Context::run_ui` pass. A + completed `run_ui` leaves `current_pass_index() == 0`; a logic-only tick also reports + zero. Visibility is not a phase. Frame-once work uses the zero-pass-index guard; + input-dependent work moves into `App::ui`; consume-based queues run on every logic + callback. This is a correction of an existing 0.36/0.37 hazard, not a new 0.37 + lifecycle. +6. Screenshot completion uses the shared craft-ui CL-2 design: one bounded, cancelable + completion slot per admitted request, a callback holding only a **weak** reference to + that slot, capture-time viewport/geometry/DPI binding, and aggregate byte bounds. The + callback never holds a strong `Context` (which would create a Context→command→callback + cycle) and never writes files or encodes PNGs. +7. One atomic compatibility PR (S2), then behaviour PRs as in §12. S2 must already be + green under strict kittest accessibility and missing-glyph policy, so it owns the + accessible-name and `default_fonts`/`monochrome_emoji_fonts` work; S4 then owns the + broader language, AccessKit-language and Arabic checks. + +## 2. Baselines, exact revisions and evidence + +### 2.1 Checkouts and revisions + +| Role | Revision | Notes | +|---|---|---| +| Assigned CADCraft checkout | `1977a493c7d8340249185126744c5143f096dc86` | `HEAD`; branch `codex/egui-037-plan-20261022`; tracked tree clean; this plan is currently untracked | +| Last coherent 0.36.2 CADCraft | `a08fd7c1d1d7c16f40dc31c25f815370c136a1d4` | Parent of the Dependabot bump that moved direct UI deps to 0.37 | +| craft-libs `main` inspected | `49190884278f16c9a9f5823d7698a8ffb5deb062` | Baseline for the S1 rebase | +| craft-libs PR #199 API commit | `8614e4a04fae516fba16c22b31ff1008cb6be10b` | Source of the port hunks only | +| craft-libs PR #199 head | `b68de8c5f4f1c060904d211a7b1320aa0f7bce78` | Stale tree; never a target | +| craft-libs audit merge | `ec8840af8c5fda695cf195cdd05e5ae078415c07` | Stale audit tree; evidence only | +| Upstream egui 0.37.0 | `841a328ba2dd8f6996beb7189ebb7fc00e668a2a` | Stock control and API source | +| aiongg egui candidate | `a4f9a17f64a6bae61350fb85804c9df0d9865916` | Comparison input; not a production pin | +| winit published base | `ffae5198770d7730e718043f035a772b7dbc3b1b` | `craft/0.30.13`, W1-W15 | +| aiongg winit candidate | `8b35388bd719e8cc86a95971ba0bc026c9a8f058` | W17-W27; no W15P; comparison input | +| Published egui 0.36.2 | `767b1cad294120689a514bb4bd68c077e46679b9` | Rollback patch inventory | + +**Final production pins are unresolved until approved publication.** The manifest edits +below use ``, `` +and ``. A candidate SHA may not be copied into a +production manifest, and a moving branch is never a pin. + +Governing instructions: repository `AGENTS.md`, suite `PLANNING-CONTRACT.md` and the +server instructions. `.agents/` and `.codex/` are empty; there is no nested `AGENTS.md`. +`AGENTS.md` (68 lines) and `CLAUDE.md` (49 lines) are **not** the same text: `CLAUDE.md` +lacks the chrono rule and the contributor-credit section. `AGENTS.md` governs. + +### 2.2 Dependency facts in the committed lockfile + +`Cargo.lock` currently contains: + +* `egui` 0.36.2 **and** 0.37.0; likewise `emath`, `ecolor`, `epaint`. +* `accesskit` 0.24.1 and 0.25.1 (one per egui family). +* `craft-ui` at `git+…?rev=5c874d51…#5c874d51…`, depending on `egui 0.36.2`. +* `craft-docpath` at `git+…?rev=82a24b0…#82a24b0…` (different craft-libs revision, + no egui dependency). +* `eframe`, `egui-wgpu`, `egui-winit`, `egui_kittest` at 0.37.0, and `winit` 0.30.13 + from crates.io. +* `arboard` 3.7.0 is present in the lockfile, but `cargo tree -i arboard --locked` + on the native graph prints nothing: the 0.37 `eframe` feature list + (`apps/cadcraft/Cargo.toml:19`) does not enable `eframe/clipboard`, which forwards + to `egui-winit/clipboard` (0.36's eframe enabled it unconditionally). **Without + adding `clipboard`, copy/paste silently stops reaching the OS clipboard**; + `crates/ui-egui/src/clipboard.rs:29-60` depends on `ctx.copy_text` and + `Event::Paste`. This is a verified graph fact, not a warning from the old audit. +* No root `[patch.crates-io]`, no `vendor/` and no **external/local path patch** in + this repository. Internal workspace members are path dependencies by design. +* Read-only `cargo tree -d --locked --offline` shows the 0.36 family reachable only + through `craft-ui`, and the 0.37 family through CADCraft/eframe/egui-wgpu/kittest. + This is graph evidence, not a build. +* The candidate fork's own `a4f9a17f/Cargo.toml:178-179` still has + `[patch.crates-io] winit = { …, branch = "craft/0.30.13-lang" }`. Consumers' patches + override it, but the fork's own build is not immutable. The final storytold fork/tag + must replace that branch with the reviewed rev before publication. + +### 2.3 Historical compiler clues (not acceptance) + +`/home/codex/work/ssd2/task-evidence/egui037-audit-20261022/fork/cadcraft.log` was +produced by a path-patched trial, not by the committed manifests. It reports: + +* 3 error classes (16 diagnostics in the `stock` run): `lib.rs:696` (`egui::UserData`), + `lib.rs:716` (`Event::Screenshot`), `animation.rs:78` (`MouseWheel` pattern lacks + `source`). The `stock` run additionally reports `Event::MouseWheel` initializers at + `animation.rs:145,197`, `canvas.rs:2806`, and private `FontsView.fonts` at + `i18n/tests.rs:407,423,442,557,607,803,912,1017,1125,1168`. +* Warnings include 92 `egui::Id::new` calls (90 in `crates/ui-egui/src`, 2 in tests: + `tests/docking_workflows.rs:303`, `tests/options_dialog.rs:237`), 51 `ui.id()` calls, + 2 `WidgetType`, 1 deprecated `AsId`, and private-font/dependency warnings. +* The trial patched 11 crates.io crates and both craft-libs crates by path; those path + patches are evidence of the API delta and must never be committed. + +These are clues to re-check on the migration tip, not a fixed work list. + +## 3. Current implementation inventory + +### 3.1 Manifests and feature targets + +| File | Facts that matter here | +|---|---| +| `Cargo.toml:6-11` | workspace version 0.12.0, edition 2024, `rust-version = "1.90"` | +| `Cargo.toml:52-59` | `craft-ui` git rev `5c874d51…`; `egui`, `egui_kittest`, `eframe`, `egui-wgpu` set to `0.37`; unused `arboard` workspace entry | +| `crates/ui-egui/Cargo.toml:7` | UI crate already declares `rust-version = "1.95"` | +| `crates/ui-egui/Cargo.toml:20-32` | `egui`, `egui-wgpu`; dev `egui_kittest` with `wgpu,snapshot`; dev `naga 30` | +| `apps/cadcraft/Cargo.toml:19` | `eframe` features: `default_fonts,wgpu,accesskit,wayland,x11,links,persistence` — **missing `clipboard`** | +| `apps/cadcraft/Cargo.toml:29` | direct `winit 0.30` (x11) for the XWayland event-loop choice | +| `apps/cadcraft-web/Cargo.toml:29` | `eframe` web features: `persistence,default_fonts,wgpu,accesskit` | +| `apps/cadcraft-web/Cargo.toml:26-32` | `web-sys` for file input; `upload.rs` uses `DroppedFile::web_file` | +| `crates/render/Cargo.toml` | rayon only off wasm; not UI | +| `xtask/Cargo.toml` | no egui dependency | + +Platform cfgs that must keep compiling: + +| Cfgs | Where | What they gate | +|---|---|---| +| `cfg(target_arch = "wasm32")` | `ui-egui/lib.rs` (`now_ms`, `FrameCap`, autosave, dropped files), `ui-egui/theme.rs` (system-font probes), `render`, `dwg`, `apps/cadcraft-web` | no threads/system fonts/files on web; `now_ms` is 0 on wasm; `web_file` is wasm-only | +| `cfg(not(target_arch = "wasm32"))` | `apps/cadcraft` `rfd`, `sys-locale`; `ui-egui` autosave and frame cap | native-only dialogs/locale/autosave | +| `cfg(unix)` / `cfg(all(unix, not(macos)))` | `apps/cadcraft` `winit` x11, `zbus`; `desktop_theme.rs` | XWayland choice, portal theme | +| `cfg(target_os = "macos")` | `muda`, `objc2-app-kit`, `native_menu.rs`, `system_animations.rs`, integrated titlebar | native menu, reduce-motion, traffic-light layout | +| `cfg(target_os = "windows")` | `hidapi` windows-native, `windows` `UI_ViewManagement`, `graphics.rs`, `windows_subsystem` | DX12/FXC startup, animation settings | +| `cfg(target_os = "linux")` | `hidapi` linux-native-basic-udev | spacemouse HID | + +`crates/fonts/build.rs` (`CRAFT_FONTS_DIR`) and `crates/ui-egui/build.rs` +(`contributors/*`) are build inputs unrelated to egui; do not change their contracts. + +Packages/binaries: `cadcraft` (desktop, eframe), `cadcraft-web` (wasm, eframe web +runner), `cadcraft-cli` (headless, no egui), `cadcraft-ui-egui` (library, kittest), +`xtask` (layers/wasm/parity). There are no examples besides `examples/apartment.dxf` +(48,769 bytes, tracked), no benches, no committed screenshot fixtures. + +### 3.2 Entry points + +* Native: `apps/cadcraft/src/main.rs:228` `main`; `NativeOptions` at `:262-283`; + Windows DX12/FXC at `:285` via `graphics::configure`; wgpu buffer-limit workaround + at `:289-297`; XWayland event loop at `:301-306`; `run_native` at `:307`; + `set_wgpu(rs, depth_format_from_bits(0, 8))` at `:347`. +* `eframe::App` at `apps/cadcraft/src/main.rs:67-110`: `logic`, `raw_input_hook`, + `ui`, `on_exit`, `save`, `persist_egui_memory = false`. +* Web: `apps/cadcraft-web/src/main.rs:31-58` `Shell` (same `logic`/`ui` split); + `WebRunner::start` at `:114`; `?cpu`/`?webgl` at `:108-113`; + `CadApp::set_wgpu(rs, None)` for web (no stencil). +* Headless: `cadcraft-cli` and `cadcraft-mcp` never construct egui; `ui.render` + is the CPU raster path in `crates/ui-egui/src/control.rs:470-545`. +* Control channel: `apps/cadcraft/src/control_server.rs`; methods in + `crates/ui-egui/src/control.rs:195-215` and `docs/control-protocol.md`, enforced + by `tests/control_methods.rs`. + +### 3.3 Actual frame lifecycle and the pinned integration + +Verified against native `crates/eframe/src/native/epi_integration.rs:338-365` and +`native/run.rs:235-281`, web `crates/eframe/src/web/app_runner.rs:280-325`, and egui +`context.rs:1099-1158,1190-1218,2124-2129`: + +* **Both native and web call `App::logic` and `App::ui` inside the `run_ui` closure, + once per pass.** Native `update` calls `app.logic(ui.ctx(), frame)` then `app.ui(ui, + frame)`; web does the same. On a second discard pass the same two calls happen again + with empty input. This is already true on 0.36.2 (`767b1cad` has the same order); it + is not a 0.37 introduction. +* A hidden root window runs `ctx.run_logic` → `App::logic` only. Native reaches this + through `PassMode::LogicOnly` when a redraw is overdue (`native/run.rs:267-281`); + web through `run_logic` when the tab viewport is not visible (`app_runner.rs:318-328`). + No egui pass runs, so `current_pass_index()` stays 0 and input/layout remain from the + last pass. +* `current_pass_index()` is `0` during pass 1 and during a logic-only call, `1+` during + later passes. After a completed `run_ui`, `end_pass` takes the viewport output + (`mem::take`), so it is **0** again; do not expect a nonzero index after completion. +* `ViewportInfo::visible()` is derived from `minimized`/`occluded` + (`egui/src/data/input/viewport_info.rs:98-104`); it is `None` when either flag is + unknown and `Some(true)` only when both say visible. A compositor can withhold a + frame without setting either flag, so a logic-only overdue tick can still see + `None` or `Some(true)`, and a hidden window can still run a full `run_ui` + when a screenshot or `RequestPaintWhileHidden` asks for pixels + (`native/wgpu_integration.rs:695-727`). **Visibility cannot distinguish a logic-only + tick from a forced hidden UI pass.** +* `App::raw_input_hook` is called by `prepare_raw_input` for both `update` and + `update_logic_only` (`epi_integration.rs:338-344,385-425`) and before the visibility + branch on web (`app_runner.rs:284`). It must not assume that a UI pass follows. +* `Context::run_ui` re-runs its closure for each pass requested by + `Context::request_discard` (default `max_passes = 2`). `Context::root_ui` panics while + the root `Ui` is borrowed (#8535); CADCraft keeps the handed-in `&mut Ui`. +* Screenshots are callback based: `ViewportCommand::Screenshot(ScreenshotCallback)` / + `Context::request_screenshot` (#8489); the callback receives only + `Arc`, runs at most once, and does **not** request a repaint. +* #8748 is a **closed, unmerged** PR (“run the ui of an overdue window without + painting”) and is not in the pinned 0.37 source. The pinned source has + `PassMode::LogicOnly`, not `PassMode::Overdue`; its body confirms that such a window + “only runs `App::logic`”. Do not use #8748 to justify current behavior. + +### 3.4 UI state, commands, undo and persistence + +* UI state is `UiState` (`lib.rs:70-112`) and is persisted by the desktop + `apps/cadcraft/src/workspace.rs` as JSON (`docking`, panel visibility, theme, language, + recent files), version 1, bounded at 64 KiB. +* `persist_egui_memory` is false (`apps/cadcraft/src/main.rs:107`), so eframe does not + save egui `Memory`. The app's own `Workspace` JSON does not store egui ids or + `PanelState`. +* All user-visible edits go through `CadApp::run` / `start` / `cmdline` + (`lib.rs:315-359`) or menus; engine undo is the single transaction owner. +* Docking is `craft_ui::docking::Layout` (`docking.rs:1-130`), serialized + without egui ids; the surface is created with `egui::Id::new("cadcraft-docking")` + and `cancel_drag` with the same id (`docking.rs:339,375`). +* Top-level panels are `exact_size(...)` and non-resizable: `chrome.rs:69,180,300`, + `menus.rs:573`, `palettes.rs:330,346`. + +### 3.5 GPU, control and screenshots + +* `crates/ui-egui/src/gpu.rs:681` `install` registers error/device-lost handlers and + inserts `Resources` into `egui_wgpu::Renderer::callback_resources`. +* `impl egui_wgpu::CallbackTrait for CanvasCallback` (`gpu.rs:693-780`) uses + `ScreenDescriptor`/`PaintCallbackInfo` fields unchanged in 0.37. +* `main.rs:188` asks for an 8-bit stencil buffer; clipped paper-space viewports are + drawn through `Stencil::Mask`/`Stencil::Inside`. +* `ui.screenshot` currently queues `(token, deadline, frames)`, then requests a + viewport screenshot and matches `Event::Screenshot` by token (`lib.rs:688-740`); + replies are correlated by `shot_token` only. `save_screenshot` + (`control.rs:547-559`) writes RGBA PNG. +* `ui.render`, `ui.inspect` and `ui.elements` are CPU/state paths and do not depend + on a presented frame. `ui.inspect` currently exposes `frameMs`, `buildMs`, `drawMs`, + `meshMs`, `prims`, `building`, `renderer` (`control.rs:270`) but **not** + `cumulative_frame_nr`, pass index or `cpu_usage`. +* There is no `view.set` control method. View changes use `cmdline.input` (ZOOM/PAN), + `engine.execute`, `ui.pointer`, `ui.click`/`ui.move`/`ui.drag`, or `ui.set` for + `UiState` fields. + +### 3.6 Text, fonts, language and accessibility + +* `theme::install_fonts` (`theme.rs:314-387`) starts from + `egui::FontDefinitions::default()`, prepends an installed system UI face, appends + CJK/Japanese/Arabic/Hangul faces and embedded craft-fonts faces. Tests inspect the + installed character maps through `ctx.fonts_mut(|view| view.fonts.font(&family))` + (`i18n/tests.rs:407,423,442,557,607,803,912,1017,1125,1168`). In 0.37 + `FontsView.fonts` is private; use `view.characters(&family)`/`view.has_glyph`. +* The interface catalog has **16** languages (`i18n/mod.rs:173`), including Arabic + (`ar.tsv` 1235 lines). `theme::can_show` (`theme.rs:640-647`) gates Arabic, Chinese + and Korean on an installed or embedded face. Bundled epaint fonts cover Latin/Greek/ + Cyrillic but not Arabic, CJK or Hangul. +* Shortcut labels and catalogs contain `⌘` U+2318, `⇧` U+21E7, `↩` U+21A9, `−` + U+2212, `…` and `°`. In 0.37 `⌘` is only in `emoji-icon-font`, included only with + `monochrome_emoji_fonts`; `⇧` is only in Hack (Monospace). U+22EE is absent from both + 0.36.2 and 0.37 bundled fonts, so its absence is not a 0.37 change. +* Accessibility metadata is built with `egui::WidgetInfo` in `icons.rs:965-989`, + `craft_ui` buttons, `egui::accesskit_node_builder` for tab roles, and control-channel + role strings (`control.rs:113-180`). There is no explicit UI language in AccessKit. +* There are 27 `TextEdit`, 18 `DragValue`, 32 `ComboBox` and 8 `Slider` sites. CADCraft + currently has no password field and no custom `IMEOutput` producer: its editors use + the built-in `egui::TextEdit` (27 sites), including the MTEXT editor and dialog/layer + rename fields. + +### 3.7 Tests, gates and CI + +* `cargo xtask ci` = fmt, clippy `-D warnings`, workspace tests, assets, layers, wasm + (`xtask/src/main.rs:151-205`). The wasm step covers L0-L4 plus `ui-egui`, **not** + `cadcraft-web`. +* `apps/cadcraft` and `apps/cadcraft-web` are app packages; the web runner needs its + own `cargo check --target wasm32-unknown-unknown` and `trunk build --release`. +* `crates/ui-egui/tests` has **43** `*.rs` files. The affected inventory includes + `alias_dialog.rs` (an unnamed filter `TextEdit`, §8.3), `animation_effects.rs` + (two more `MouseWheel` constructors), `chrome_geometry.rs` (status text at **1200×760 + and 900×560**, `tests/chrome_geometry.rs:58`), `docking_workflows.rs`, + `workspace_focus.rs`, `hidden_command_line.rs`, `pickdrag.rs`, + `gpu_viewport_stencil.rs`, `gpu_faults.rs`, `shared_controls.rs`, + `control_methods.rs`, `menubar_hover.rs`, `context_menu.rs`/`context_menus.rs`, + `style_dialogs.rs`, `dialog_dispatch.rs`, `text_double_click.rs`, `save_version.rs`, + `rapid_clicks.rs`, `drawing_recovery.rs`, `options_dialog.rs`, `options_followups.rs`, + and `src/i18n/tests.rs`. There are no golden images; the only PNG output is opt-in + (`CRAFT_UI_SUITE_SHOTS`, `CRAFT_UI_VISUAL_DIR`). +* There are ~68 direct `CadApp::logic` call sites, many in tests. After the split, a + direct call on a bare `Context` still reports pass index 0, so it keeps running + frame work; tests that need multi-pass semantics must use the `run_ui` helper shape + used by `hidden_command_line.rs:19-30`. + +## 4. Upstream 0.37 compile incompatibilities + +These remain **after** `craft-ui` is moved to the same egui 0.37 source; before that, +the craft-ui type mismatches dominate. Re-count on the migration tip. + +| # | Anchor | Symptom | Replacement and semantics | +|---|---|---|---| +| C1 | `lib.rs:688-706` (`issue_screenshots`), `:707-740` (`collect_screenshots`) | `egui::UserData` gone; `Event::Screenshot` gone | Use `ctx.request_screenshot(move |image: Arc| …)` (or `ViewportCommand::Screenshot(ScreenshotCallback::new(...))`). Keep the token, path, reply and deadline in CADCraft; the callback hands `(token, image)` to the request's completion slot. Call `ctx.request_repaint()` from the frame tick after completion, not from a callback holding a strong `Context` (§6.5). | +| C2 | `animation.rs:78` pattern; `animation.rs:145,197`, `canvas.rs:2806`, **and tests `animation_effects.rs:102,133`** initializers | `MouseWheel` gained `source: MouseWheelSource` | Add `source: _` to the pattern (or `..`); construct test events with `MouseWheelSource::Unknown` (the platform value on X11/Wayland/Windows/web) or `Wheel`/`Trackpad` where the test is about macOS momentum. Prefer a local test helper so a future field does not break every constructor. | +| C3 | `i18n/tests.rs:407,423,442,557,607,803,912,1017,1125,1168` | `FontsView.fonts` is private | Replace `view.fonts.font(&family).characters()` with the public `view.characters(&family)` or `view.has_glyph(&FontId, c)`. Keep the test intent: the family's actual character map, not a fallback glyph. | +| C4 | `icons.rs:965-989` (`describe_control`), all `WidgetType::Button` | `WidgetType` is a deprecated alias of `accesskit::Role` | Use `egui::Role::Button`; `craft-ui` uses `Role::CheckBox`. Assert the role in a semantic test. | +| C5 | 92 `egui::Id::new(...)` calls (90 src, 2 tests: `docking_workflows.rs:303`, `options_dialog.rs:237`), e.g. `palettes.rs:615,891,957`, `exchange.rs:75-144`, `docking.rs:339,375` | Deprecated; `clippy -D warnings` fails | `egui::Id::unique(...)` (same hash; `Id::new` now calls `Id::unique`), or `ui.make_persistent_id(salt)`/`IdSalt::new` where a scope-local id is wanted. `docking.rs` must keep the exact same id string for `DockArea` and `cancel_drag`. | +| C6 | 51 `ui.id()` calls (e.g. `chrome.rs:83`, `palettes.rs:154`, `cmdline.rs:446`) | Deprecated | `ui.scope_id()`. No value change. | +| C7 | `managers.rs:77` `num_field(ui, id: impl egui::AsId, …)` | `AsId` is a **deprecated blanket trait**, not removed (`egui/src/id.rs:7-12`, exported at `lib.rs:550`); clippy `-D warnings` rejects its use | Take `impl egui::AsIdSalt` and use `ui.make_persistent_id(id)`; the temporary field id becomes scope-local. | +| C8 | `egui::Panel::top/left/right/bottom("…")` (`chrome.rs:69,180,300`, `palettes.rs:330,346`, `menus.rs:573`) | 0.37 panel constructors take `impl AsIdSalt`; `From<&str> for Id` is gone; the panel id becomes `parent_ui.scope_id().with_salt(...)` (`panel.rs:974-976`) instead of 0.36's `Id::new("…")` | Keep the literal names `cc_title_toolbar`, `cc_menubar`, `cc_status`, `cc_file_tabs`, `cc_toolsets_strip`, `cc_palettes`; do not hard-code ids in tests. The first compatibility PR includes the additive `PanelState` key migration in §7. | +| C9 | `UiBuilder::id` inside `craft-ui` | Deprecated | `scope_id`; handled in craft-libs S1, not here. | + +No other 0.37 removals appear in CADCraft source: no `AsId` removal, no +`WidgetType::TextEdit`, no `global_theme_preference_switch`, no `show_inside`, +no `ImeEvent` matching, no `ScrollArea::id_source`. + +## 5. Runtime and semantic changes CADCraft must decide + +| Area | 0.37 change (primary source) | CADCraft decision | +|---|---|---| +| Layout/frame | `Context::run_ui` multi-pass; **native and web call `App::logic` per pass**; hidden root runs `run_logic` only; completed `run_ui` reports pass index 0 | Use `current_pass_index() == 0` for frame-once work; move input to `App::ui`; never use `visible()` as a phase (§6). | +| Screenshot | callback-based (#8489) | Bounded, cancelable per-request completion slot; weak callback; capture-time binding; deadline; one file/reply (§6.5). | +| Clipboard | `eframe` gains optional `clipboard` (#8392) | Add `"clipboard"` to the desktop `eframe` features. On web, use the browser path; verify paste/copy in a real browser. | +| Fonts | `default_fonts` no longer default (#8583); eframe `system_fonts` default (#8512) | Enable `default_fonts` and `monochrome_emoji_fonts` in **S2**; keep `system_fonts` off and set `NativeOptions::system_font_fallback = false`. | +| Glyphs | kittest panics on a missing glyph by default (#8601); U+22EE absent from both 0.36.2 and 0.37 bundled fonts | Enable the monochrome emoji face; run a strict glyph sweep in **S2**; replace uncovered UI symbols with code-drawn art/text; never set a global `MissingGlyphPolicy::Tofu` for the suite. | +| TextEdit | clicking another widget collapses the selection before the button runs (#8763; unmerged #8764) | Assert the **exact selected range** is formatted by the MTEXT Bold/Italic/Underline/Overline toolbar and any dialog action; pointer and keyboard paths. CADCraft's MTEXT toolbar is drawn before the `TextEdit` and uses its own `st.sel`, which may make it immune; prove it, do not assume it. If a regression is real, preserve the range in CadCraft or adopt the fork fix; do not patch by disabling selection. | +| Menus | arrow-key navigation within open menus (#8548), popup sizing passes | Re-run `menubar_hover`/`context_menu(s)`; add keyboard traversal. | +| Accessibility | `WidgetType`→`Role`, unnamed inputs fail kittest (#8555/#8589/#8611), `label_inputs_by`/`name_inputs`/`accessible_language` | Name every input reached by tests in S2; add the systematic unnamed-widget audit; put the UI language on the AccessKit root via fork E17 in S4. | +| Clipboard/PRIMARY | 0.37 supports X11/Wayland PRIMARY (#8428); Wayland clipboard #8657/E4; **W15P is new** | Require winit W15P + egui E4/P2b in the final pins; test the native matrix; do not default to a PRIMARY-only fallback (§9). | +| Web | `DroppedFile::web_file` opt-in (#8669) | eframe enables it on wasm; assert the feature is in the **wasm** graph and add `cargo check -p cadcraft-web`. | +| Scroll/animations | kinetic scrolling (#8662/#8663), repaint-burst fix (#8595), style sizing (#8617/#8445) | Re-run animation on/off tests; apply wheel/zoom once and reuse the resulting view for later passes (§6); measure idle frames; keep explicit spacing overrides. | +| Hidden window | pinned eframe runs `PassMode::LogicOnly` for an overdue root; **#8748 is closed and unmerged**; no deferred viewports in CADCraft | Keep control/LISP/autosave/screenshot deadlines alive on logic ticks; handle input only in a known UI pass. Do not claim #8748 behavior. A fork that later adopts an overdue full pass must pin that exact revision and re-run the hidden tests. | + +## 6. Root `Ui`, multi-pass and logic-only semantics + +### 6.1 Rules + +1. `CadApp::ui(&mut self, ui: &mut egui::Ui)` remains the only root-`Ui` entry. + Panels are added in the existing order; dialogs/windows/areas continue to use + `ui.ctx()`. No code calls `Context::root_ui` inside `ui` (it would panic, #8535). +2. `frame_tick(ctx) := ctx.current_pass_index() == 0`. It is true on pass 1 and on + every logic-only tick, and false on later passes. It is the only frame-once guard. + Do **not** use `cumulative_frame_nr` for this: `run_logic` does not increment it, so + a hidden tick and the next full frame share a number and would duplicate work. +3. Input-dependent work (shortcuts, clipboard mirror, dropped files, canvas zoom + from wheel events) runs in `App::ui` on the first pass, never in `App::logic`. + `run_logic` leaves events/time stale; reading `dropped_files` or `Event::Paste` + there replays a user action on every hidden tick. +4. Consume-based background work runs on **every** `App::logic` call: the control + mpsc drain, opening-job receiver, screenshot completion receiver, GPU-fault + `take`, and the Plot/Hatch resume flags. These cannot duplicate because each item + is consumed once. They must not be hidden behind the pass-index guard, or a fresh + control reply, opening result or GPU fault arriving during a later pass would + starve until the next frame. +5. Deadline/time-based work (LISP slice, autosave, queued-screenshot issue, workspace + persistence, spacemouse poll, native-menu poll) runs on `frame_tick`. Autosave and + the screenshot queue are additionally idempotent by their own deadlines; the guard + keeps pacing deterministic rather than relying on that. +6. UI is never shown from `logic`. The opening spinner currently drawn at + `lib.rs:580` moves into `draw()` as a foreground `Area`; `opening::show` keeps its + rendering code but is called from `ui`. +7. Per-pass handlers that do run twice must be idempotent by a saved value, a + `shown_*` field or a consumed queue. `control::begin_frame` still clears the + observed-widget list at the start of each UI pass. +8. Input events are removed only after the app handled them: `raw.dropped_files` + always; `Copy`/`Cut`/`Paste`/`MiddleClickPaste` only when + `!ctx.egui_wants_keyboard_input()` (the guard `menus::shortcuts` uses). A focused + `TextEdit` still receives them. Do not clear `Text`/`Key` before `CadApp::ui` + because the command line reads them there. + +### 6.2 Split (design, not code) + +```rust +fn frame_tick(ctx: &egui::Context) -> bool { + ctx.current_pass_index() == 0 // pass 1 or a logic-only tick; 0 after completion +} + +pub fn logic(&mut self, ctx: &egui::Context) { + // Consume-based: every call, never behind the guard. + self.drain_control(ctx); + self.finish_opening(); + self.collect_screenshots(ctx); + self.gpu_fault_tick(ctx); + managers::plot_window_resume(self, ctx); + hatchdlg::resume(self); + + if frame_tick(ctx) { + // Once per layout frame or hidden tick. + self.session.lisp_tick(); + if self.session.lisp_busy() { ctx.request_repaint(); } + self.close_request_frame(ctx); + self.apply_theme_fonts_zoom(ctx); + self.issue_screenshots(ctx); + self.autosave_tick(ctx); + if !self.synthetic.is_empty() { ctx.request_repaint(); } + } +} + +pub fn ui(&mut self, ui: &mut egui::Ui) { + let ctx = ui.ctx(); + if frame_tick(ctx) { + self.ui_input(ctx); // shortcuts, clipboard mirror, dropped files + self.frame_wheel(ctx); // apply fresh wheel/zoom exactly once + self.frame_cap_wait(); // pacing belongs to a painted pass + } // later passes draw the already-updated state; never re-inject consumed wheel input + i18n::with_language(self.language(), || self.draw(ui)); +} +``` + +`ui_input` clears `raw.dropped_files` after opening them, and calls +`menus::shortcuts` (which consumes shortcuts and clipboard events). `frame_wheel` +runs `animation::unsmooth_scroll` when animations are off and applies the frame's +wheel scroll/zoom once. Later passes render the resulting view; they neither re-inject +events nor reapply a cached delta in `canvas.rs`. `frame_cap_wait` is moved out of `raw_input_hook` because the hook also +runs on logic-only ticks where no paint will happen. + +`raw_input_hook` keeps only work that is safe without a pass: the desktop theme +override, `cmdline::capture_tab`, and synthetic-event injection. Injection drains +`self.synthetic` once into `RawInput`; eframe preserves that input in +`pending_raw_input` across logic-only ticks, so repeated ticks do not duplicate the +same event. Add a test that a queued synthetic event is applied once after a hidden +tick followed by a shown frame. + +### 6.3 Side-effect placement + +| Work | Anchor | Class | Why / test | +|---|---|---|---| +| LISP slice and repaint while busy | `lib.rs:538-541` | frame tick | One slice per layout frame; a hidden window keeps it alive. Test a LISP routine and a two-pass frame plus a minimized tick. | +| Close-request / CancelClose | `lib.rs:542-545` | frame tick | `run_logic` updates viewport close/focus; cancel must work while hidden. | +| Font install, theme/animation/zoom | `lib.rs:546-577` | frame tick (guarded `shown_*`) | Applied before pass 1; repeated calls are no-ops. Tests call `logic` repeatedly and expect a theme change. | +| Control queue | `lib.rs:664-687` | every call (consume) | Requests/replies must not duplicate; each mpsc item is handled once. Test one command in a two-pass frame mutates once, and a request queued between passes is answered without waiting for the next frame. | +| Opening jobs + spinner | `lib.rs:579-580`, `opening.rs:77-97` | receiver every call; spinner in `ui` | Worker results are consumed once; drawing is UI. Test the spinner shape appears in a `run_ui` output and no `Area` is created from `logic`. | +| Synthetic repaint | `lib.rs:581-583` | frame tick | Synthetic events are drained in the hook; request a repaint while they remain. | +| Screenshot collection/issue | `lib.rs:584-585`, `:688-740` | collection every call; issue frame tick | Correlation/deadline survive multi-pass; one request → one callback → one reply. | +| GPU faults | `lib.rs:586`, `autosave.rs:94-103` | every call (`take`) | Device errors arrive between frames; `save_now` runs once per fault. | +| Plot/Hatch resumes | `lib.rs:587-588` | every call (flags/take) | State-machine continuation; a second pass must not re-open a dialog twice. | +| Autosave | `lib.rs:589`, `autosave.rs:85-90` | frame tick | `Autosaver::tick` is time-based; after scheduling a write it records `written`, so it is idempotent, but one tick per frame keeps pacing deterministic. (SAVETIME 0 disables autosave; it does not write twice.) | +| Shortcuts + clipboard mirror | `lib.rs:590`, `menus.rs:629-700`, `clipboard.rs:29-60` | `ui` first pass | Input-derived; a hidden `run_logic` must not replay stale `Event::Paste` or copy twice. | +| Dropped files | `lib.rs:591-597` | `ui` first pass, clear after | **Current hazard**: stale `dropped_files` would re-open on every hidden tick and twice in a two-pass frame. Test one drop, then two hidden ticks. | +| Wheel/scroll/zoom input | `lib.rs:568-570`, `animation.rs:104-130`, `canvas.rs:1334,1396` | `ui` first pass only | Apply the input once; later passes draw the updated view/ScrollArea state without re-injection. Assert exactly one notch equals one view/scroll step under forced two-pass layout, animations on/off. | +| Spacemouse poll (host) | `main.rs:80`, `spacemouse.rs:61-80` | frame tick | `poll` copies `shared.motion` and re-applies it per call; one poll per frame. Test with a counter. | +| Native menu poll (macOS) | `main.rs:74-76` | frame tick | Rebuilds enablement a few times per second; not per pass. | +| Workspace persistence (host) | `main.rs:81,93` | frame tick (logic only) | `save_if_changed` is idempotent but must not run per pass; `save_on_exit` stays in `on_exit`. Remove the duplicate call from `App::ui`. | +| Web inbox drain / dirty bridge | `apps/cadcraft-web/src/main.rs:33-44` | drain every call; dropped files in `ui`; dirty report frame tick | DOM reporting is idempotent; dropped files are input and must be read once. | +| `raw_input_hook` frame cap/synthetics | `lib.rs:601-625` | per logic callback | Runs in `update` and `update_logic_only`; move the frame-cap wait to `ui` pass 1; synthetic injection is drain-based and preserved in pending input. | + +### 6.3.1 Host and web frame token + +The host `eframe::App::logic` implementations are outside `CadApp`, so they use the +same public token rather than a new scheduler: + +* native `apps/cadcraft/src/main.rs:68-84`: call `self.cad.logic(ctx)` on every + `App::logic` invocation; wrap `native_menu.poll`, `spacemouse.poll`, + `persist_workspace(false)` and the `quit_requested` check in + `if ctx.current_pass_index() == 0`. Remove `persist_workspace` from `App::ui` + (`main.rs:91-94`), which currently runs it a second time. `spacemouse.poll` copies + `shared.motion` rather than taking it (`spacemouse.rs:61-80`), so without the guard a + second pass applies the same motion twice. +* web `apps/cadcraft-web/src/main.rs:31-44`: `Inbox::drain` (background results) runs + on every logic call; `Bridge::report_dirty` runs on `frame_tick`; `take_dropped` + moves to `Shell::ui` and clears `raw.dropped_files` after reading them. Remove the + duplicate `report_dirty` from `Shell::ui`. `Inbox::take_dropped` currently clones + `raw.dropped_files` without consuming them (`upload.rs:71-77`), so it would read the + same files twice in a two-pass frame and on every hidden tab tick. +* `raw_input_hook` remains per logic callback on both hosts (see §3.3); only the frame + cap moves to the first painted pass. + +Add host/web counters in S3 tests: one spacemouse poll per frame, one native-menu +poll per frame, one dirty report per frame, and one dropped-file read per event. + +### 6.4 Screenshot callback correlation and bounds + +State per admitted request (owned by `CadApp`, with paths/replies/deadlines kept in +the app): + +``` +ShotRequest { + token: u64, path: Option, reply: Sender, deadline: f64, + viewport: ViewportId, session_generation: u64, + expected_px: (u32, u32), expected_ppp: f32, + slot: Arc, // one completion slot per request +} +ShotSlot { state: Mutex, wake: egui::Context } +ShotState { canceled: bool, result: Option, String>>, bytes: usize } +``` + +`issue_screenshots` waits for `canvas.building()` and at least three accepted UI frames (an intentional settling unit, never three discarded passes), then +calls `ctx.request_screenshot` with a closure holding **`Weak` only**: + +```rust +let slot = Arc::downgrade(&request.slot); +ctx.request_screenshot(move |image: Arc| { + if let Some(slot) = slot.upgrade() { + let wake = slot.complete(image); // stores image/result, releases the lock + if let Some(ctx) = wake { ctx.request_repaint(); } // Context lives in the slot + } +}); +``` + +The callback must not capture a `Context` strongly: the callback is itself stored in +that context's viewport commands, so a strong `Context` would form a cycle. It must +not encode PNGs, write files, run commands, or hold a lock while waking. + +`collect_screenshots` drains the slot results on every logic call, checks the token +and request/session binding, and replies with `save_screenshot(&image, path)`. Binding: + +* CADCraft has ROOT only: bind to an explicit CadApp session/document generation. + The callback supplies pixels only; viewport identity is request-owned, not callback + metadata. Reject generation changes/close and never reuse an old result for a new document; +* At issuance refresh expected geometry from the target UI content rect and ppp, + after the settling wait. If no fresh UI geometry exists, request a paint before issue. + Revalidate a monotonic resize/DPI/zoom/surface epoch before saving; derive scale from + returned pixels and unchanged logical bounds, including E2 fitting. A mismatch is: + reply `{"ok":false,"error":"window geometry changed during capture"}` and release + the image rather than silently pairing it with a newer request; +* a late callback whose slot is canceled or dropped releases the image without a + file or reply; a timed-out request leaves no file and cannot later write one; +* admission limits: at most 4 pending/queued requests and at most 64 MiB aggregate + reserved in-flight plus completed image bytes (64 MiB per-image maximum as well). + Reserve checked expected width×height×4 bytes at admission, adjust before issue after + a geometry change only if capacity permits, otherwise reject. Requests over the + limit are rejected immediately with an error reply. `pending_bytes` is decremented + on drain, timeout, cancellation and dropped slots; +* cancel all slots on close/viewport loss and in `on_exit`; cancellation is a flag, + not a process kill. `now_ms` is 0 on wasm, so the deadline uses + `web_time::Instant` on web, never stale InputState::time; do not leave an infinite + web deadline. + +CADCraft adopts the shared craft-ui CL-2 completion-slot API (the eleven existing craft-ui creative consumers' +approach; DeckCraft, FigCraft and ArtCraft Native keep local adapters). +Request records, paths, PNG serialization, timeouts and the reply schema stay in +CADCraft. If the provisional CL-2 API is unavailable, S3 is blocked rather than +growing a second divergent helper. + +Regression assertions: + +* two requests in one frame, distinct paths, both files have the harness's pixel size + and each reply names its own path; +* a request while a popup forces a second pass produces exactly one file; +* a timed-out request leaves no file and cannot later write one; +* a minimized/hidden window reports the documented timeout, or succeeds through a + forced hidden paint (`RequestPaintWhileHidden`/screenshot), never hangs; +* admission overflow returns an error and does not grow memory; +* a completion after resize/DPI change returns the geometry-mismatch error; +* a late callback after close writes nothing. + +kittest fulfils `ViewportCommand::Screenshot` in `Harness::handle_screenshots` +(0.37 `egui_kittest/src/lib.rs:909-947`), so routing/bounds are testable without a +real window. + +### 6.5 Multi-pass and logic-only tests + +* `two_pass_frame_runs_frame_logic_once`: force a discard pass (open a ComboBox or + menu); assert one non-idempotent counter/LISP slice and one control execution. + Mutation: move the LISP slice into an unguarded call; the test must fail. +* `logic_tick_with_unknown_visibility`: call `run_logic` with + `minimized=None, occluded=None`; queue a control `ping`, a screenshot deadline and + one LISP slice; assert all progress and no UI pass. +* `logic_tick_with_visible_true`: same with `minimized=Some(false), occluded=None`; + this is the case the old `visible()` predicate got wrong. +* `hidden_painted_two_pass_frame`: with `minimized=Some(true)`, run a full `run_ui` + with a forced second pass; assert frame work once, input once, and the final pass + renders the once-updated view without applying wheel movement again. +* `hidden_logic_ticks_do_not_reopen_dropped_files`: one shown frame with a dropped + file, then two `run_logic` ticks; the file opens once. Mutation: move drop handling + into `logic`; the test must fail. +* `hidden_logic_ticks_do_not_paste_twice`: one `Event::Paste`, a shown frame, then + two `run_logic` ticks; the paste command runs once. +* `fresh_control_between_passes`: request a discard in pass 1, enqueue a control + request before pass 2; it is answered in pass 2, not starved. +* `control_queue_answers_while_minimized`: `ping`/`ui.inspect` replies arrive on + `run_logic` ticks and no UI pass occurs. +* `opening_spinner_is_drawn_in_ui`: the spinner `Shape` appears in a `run_ui` output; + no `Area` is created from `logic`. +* `spacemouse_one_poll_per_frame` and `web_drop_once`: counters across a multi-pass + frame and a hidden tick. +* `hidden_child_only_if_adopted`: CADCraft has no deferred viewports, so assert that + (no `show_viewport_deferred` call) and record #8748 as not applicable. If a later + fork pin adopts an overdue full pass, add a hidden-parent/deferred-child + open/update/close test with overdue redraw and unknown visibility before claiming it. +* `undo_and_save_reopen_still_single_step`: command → undo → redo → save → reopen via + control methods; assert entity counts and DXF bytes, not just `ok`. +* `direct_logic_tests_do_not_silently_skip_frame_work`: audit the ~68 direct + `CadApp::logic` call sites; tests whose intent is a frame use the `run_ui` helper. + +## 7. Panels, docking, IDs and persistence + +* Keep the panel order from §3.3. The root `Ui` is full-window; panels consume space + before `CentralPanel`, so a panel id or separator/clip change moves the canvas rect. + Assert `canvasRect` equals the window minus the top/bottom/left chrome at + **1600×1000** (new test) and keep the existing status-bar geometry at + **1200×760 and 900×560** (`chrome_geometry.rs:58`). +* Keep the panel id strings `cc_title_toolbar`, `cc_menubar`, `cc_status`, + `cc_file_tabs`, `cc_toolsets_strip`, `cc_palettes`. Do not rename them during the + `AsIdSalt` move; tests and `ui.elements` refer to behaviour around them. +* **Panel `PanelState` migration (first compatibility PR).** 0.36 keyed a panel's + `PanelState` by `Id::new("cc_x")`; 0.37 keys it by + `parent_ui.scope_id().with_salt(IdSalt::new("cc_x"))` (`panel.rs:974-976`). CADCraft + currently sets `persist_egui_memory=false` and its top-level panels are + `exact_size(...)`/non-resizable, so no `PanelState` is currently file-persisted. The + first compatibility PR nevertheless owns the saved-key migration and performs it + additively before the first UI pass: if the new key has no `egui::PanelState` and + the old `Id::unique("cc_x")` key does, + copy the old value to the new key; keep the old key for rollback. Seed a + `Context` with an old-key `PanelState` in a test and assert the new key sees it. + This prevents a one-time width reset if eframe memory persistence is enabled or a + panel becomes resizable, and covers saved test fixtures/other consumers. +* `DockArea::new(egui::Id::new("cadcraft-docking"))` and + `cancel_drag::(ctx, egui::Id::new("cadcraft-docking"))` must migrate together + to `Id::unique` with the same string. A mismatch leaves a drag active after a + workspace replacement; `docking_workflows.rs` already tests host cancel. +* `DockArea::direction` is an **explicit opt-in visual policy** from craft-libs + §7.7/CL-3. CADCraft passes `DockDirection::LeftToRight` by default and + `RightToLeft` only in S5b if chrome mirroring is implemented. Never reverse saved + tab order to simulate RTL. Preserve serialized split first/second, axes, ratios, + floating rectangles and logical tab order; painting, hit tests, drag insertion, + accessibility order and trailing resize grips must use the same computed + rectangles; physical splitter direction is unchanged. Test LTR/RTL click, drag, + reorder, resize, keyboard focus, protected floating groups and an unchanged + serialized workspace; deliberately mirror paint without hit coordinates to prove + detection. +* Docking layout persistence is `Workspace` JSON (`docking.rs:74-130`), not egui ids. + Assert `ui.dock`, `ui.set`, save/reload with floating groups, stacks and hidden + panels after the craft-ui update; changing direction must not rewrite the JSON. +* Narrow-window behavior remains: toolbar overflow (`chrome.rs:96-120`) keeps every + command reachable; `chrome_geometry.rs` must keep passing at 1×/2×. +* There are no secondary native viewports (`show_viewport_deferred`/immediate viewport + use), so fork E1 (`ViewportHooks`) is unused. “Viewport” in CADCraft code + (`canvas.rs`, `vpscale.rs`, `render/lib.rs`) means a paper-space layout viewport. + +## 8. Fonts, glyphs, language, RTL and accessibility + +### 8.1 Fonts and glyph coverage + +* S2 enables `default_fonts` and `monochrome_emoji_fonts` on the workspace `egui` + dependency. Without the latter, `⌘` (U+2318) has no bundled face (it was in + `emoji-icon-font`, previously unconditional). This is a visible glyph change and + therefore part of the compatibility PR, not a later “no behaviour change” stage. +* `⇧` (U+21E7) is only in Hack (Monospace), while `Button::shortcut_text` uses the + Button text style (Proportional body in CADCraft). Decide in S2: draw the macOS + modifier symbols in code (the WCS chevron already is, `canvas.rs:2558`), keep `⇧` + only in monospaced contexts, or use text on macOS. Do not silently enable tofu. +* Split the coverage test by source, because bundled fonts cannot cover every catalog: + * bundled-font sweep: every non-ASCII character in `i18n/*.tsv` that is not in a + `can_show`-gated script, plus `shortcut_label` outputs, must have a glyph in the + Proportional or Monospace family under `default_fonts` + `monochrome_emoji_fonts`, + without the system-font prepend (deterministic on CI/web); + * pinned-fixture sweep: Arabic/Thai/CJK/Japanese/Hebrew/color-emoji fixtures from + §8.4 loaded into a test `Context` cover the gated languages’ catalog characters. + * `theme::can_show` (`theme.rs:640-647`) remains the gate for `ar`/`zh*`/`ko`; + record which languages are gated and which fixtures were installed. +* Keep `theme::install_fonts`' caps (`MAX_UI_FONT_BYTES`) and file reads unchanged. + Do not adopt the 0.37 `GlyphRasterizer`/`FontProvider` APIs; set + `NativeOptions::system_font_fallback = false` explicitly and do not enable eframe's + `system_fonts` feature. This keeps CADCraft's capped app-controlled chain + authoritative and avoids the #8512 system-font discovery cost. +* Keep `FontsView` access test-only through `view.characters(...)`/`view.has_glyph`. + +### 8.2 Language and RTL + +* In `CadApp` (frame tick or first UI pass, before the AccessKit tree is built), call + `ctx.set_language(Some(language.to_owned()))` when the resolved language changes + (fork E17). This places the BCP 47 tag on the AccessKit root; the web runner's + speech/hidden-input language uses it through E18. +* Do not adopt `craft-i18n`; CADCraft owns its TSV catalog and `with_language`. +* Arabic: keep Arabic **text** rendering (bidi + shaping, E5/E12/E13) and verify + `ar.tsv` screens. `Style::right_to_left` (E9) mirrors egui menus, scroll bars, + checkboxes, combo boxes and popups, but not CADCraft's custom chrome or docking. + S4 keeps `right_to_left = false` (Arabic UI stays LTR with correctly shaped text, + documented in `docs/localization-parity.md`). S5b may enable it only with app-owned + chrome mirroring and craft-ui `DockArea::direction`; until then do not claim an RTL UI. +* Logical text is authoritative. `crates/fonts/src/bidi.rs`/`mtext.rs` compute visual + runs for rendering from logical text (`visual_runs`, `char_levels`); clipboard, + DXF/DWG save, accessibility and search continue to use logical strings. Never feed + display-bidi (visual-order) text into shaping, the clipboard or `IMEOutput`. + Test an Arabic MTEXT: copy, save/reopen and compare the logical string; no doubled + bidi and no reordered clipboard. +* AccessKit language test: after switching to `pt-BR`/`ar`, assert the root node's + language and `Options::language`; text-run direction assertions belong to fork E17. + +### 8.3 Accessible names and roles (S2, not S4) + +* kittest 0.37 defaults `check_accessibility = true` and + `missing_glyph_policy = MissingGlyphPolicy::Panic` + (`egui_kittest/src/builder.rs:50-51`); `check_accessibility()` runs after every + `run`/`run_steps` (`lib.rs:519-520`). Existing `Harness` tests therefore cannot be + green if an input is unnamed or a glyph is missing. +* Known failure: `alias_dialog.rs:35-37` renders the Alias dialog; its filter is + `ui.text_edit_singleline(&mut st.filter)` (`aliasdlg.rs:164-165`) after a separate + `ui.label("Filter")` with no `labelled_by`/placeholder. Name it in S2. +* Use `Response::labelled_by(label_response.id)` or + `ui.label_inputs_by(label.id, |ui| …)` for rows whose label CADCraft draws; + `hint_text` is acceptable for placeholder-labelled search fields, not numeric entry. +* Keep `icons::describe_control` labels/tab roles; migrate `WidgetType` to + `Role::Button` and assert `get_by_role`/`get_by_label` for a toolbar button, a toolset + tab and a dialog input. +* Add one test per dialog group that opens the dialog and asserts + `harness.unnamed_widgets()` is empty. Do not disable `check_accessibility` globally. + If a third-party widget cannot be named, name it at the call site and record why; a + per-test `with_accessibility_check(false)` requires an explicit enumerated exception, + a linked issue and a removal condition, not a suite-wide switch. +* S2 runs the full `cargo xtask ci` plus `cargo test -p cadcraft-ui-egui --all-targets` + and keeps `MissingGlyphPolicy::Panic`; S4 adds broader language/AccessKit-language + coverage, not the first green gate. + +### 8.4 Pinned licensed font fixtures + +Use the fixtures pinned by the egui plan §10.6; fetch them into the task evidence +directory, never commit them into CADCraft, and retain adjacent licences. CADCraft’s +own policy remains: optional central `craft-fonts` via `CRAFT_FONTS_DIR`, no font +binaries in this repository, no new `ATTRIBUTION.md` row for test-only external files. + +| Fixture | Source/path | SHA256 | License | +|---|---|---|---| +| Arabic | craft-fonts `2481a8a324b986a45954e90045ed1f8f2efaaf3c` `fonts/noto-sans-arabic/NotoSansArabic.ttf` | `63111b5b2e074dd48cc67692e0a2726d86ee94c1c37fe8598257b7b4e87e869e` | OFL-1.1 | +| Thai | craft-fonts `fonts/noto-sans-thai/NotoSansThai.ttf` | `5a1c559bb539583c8a1fd99d1c5b9491e5e14478c9cd2bd0970d5c3096cc9ef8` | OFL-1.1 | +| CJK SC/Latin | craft-fonts `fonts/noto-sans-cjk-sc/NotoSansCJKsc-Regular.otf` | `2c76254f6fc379fddfce0a7e84fb5385bb135d3e399294f6eeb6680d0365b74b` | OFL-1.1 | +| Japanese | craft-fonts `fonts/biz-ud-pgothic/BIZUDPGothic-Regular.ttf` | `258d7156c165f2ff774b6efee637c22c3b950de0d8a10e501137061bc8085d01` | OFL-1.1 | +| Hebrew | google/fonts `8b0a1d0f5983c89bc2b93f1b5fb55f9e252744b5` `ofl/notosanshebrew/NotoSansHebrew[wdth,wght].ttf` | `7ef36a2c3593758cdb622e1bdef4f84523e92fbc3ccc667438dd80ff54c2de88` | OFL-1.1 | +| Color emoji | googlefonts/noto-emoji `22e564626297b4df0a40570ad81d6c05cc7c38bd` `fonts/NotoColorEmoji.ttf` | `39ee3c587e10e89669b9ff32703261d10d5f9c4dd5ad147b6b5a1c5200591817` | OFL-1.1 | + +Bundled Latin/emoji comparisons use the exact `epaint_default_fonts` bytes from the +pinned baseline/candidate: `Ubuntu-Light.ttf` +`80307b8da7649aa4ee4d484b232140e3ce1ec0ca093073d3c53c8f5a5ced7a70` and +`NotoEmoji-Regular.ttf` +`415dc6290378574135b64c808dc640c1df7531973290c4970c51fdeb849cb0c5` (record licence +files). Fix the Hebrew variable axes to weight 400/width 100. All fixture hashes and +licences enter the evidence manifest; no script may silently skip for a missing fixture. + +## 9. Clipboard, PRIMARY, IME and keyboard + +* Add `"clipboard"` to the desktop `eframe` features and assert + `cargo tree -i arboard` reaches `egui-winit`. Regression: Copy in CADCraft, paste + into another app; copy text elsewhere, paste into CADCraft (the DXF-text mirror + remains in `clipboard.rs`). The feature-graph assertion is the detector; a kittest + copy/paste assertion cannot prove the OS clipboard because kittest does not run + eframe's OS clipboard. +* **Required PRIMARY design.** The final winit pin must contain W15P (PRIMARY on the + existing connection/event queue, per-seat lifecycle, SCTK 0.19.2 + `PrimarySelectionManagerState`/device/source/offer, no second regular + `wl_data_device`), and the final egui pin must contain E4/P2b routing + `get_primary_text`/`set_primary_text` through it. The current candidate winit + `8b35388b` has no PRIMARY path, and the current candidate egui `a4f9a17f` lacks the + later P2b integration; neither is acceptable as the final pin. +* PRIMARY acceptance (native, on wlroots/Hyprland, KWin, Mutter, plus X11): + pointer- and keyboard-triggered ownership (including keyboard-only focus), serial freshness, owner/seat changes, empty/absent offers, + self-paste (marker MIME, no deadlock), canceled/slow transfers, unchanged selection + not reclaimed, password selection never offered. CADCraft defaults to XWayland when + `DISPLAY` is set (`main.rs:301-306`); `CADCRAFT_WAYLAND=1` is the native Wayland + exposure. A PRIMARY-only fallback is allowed only as a **measured** fallback with + explicit owner acceptance, not as the default limitation. +* Middle-click: `Event::MiddleClickPaste { pos, text }` exists in 0.37. CADCraft's + command line is custom-painted, not a `TextEdit`, so decide in S5a: handle it only + when the pointer is in the command-line bar rect (stored state), insert at the buffer + end, and never start a command or duplicate a `Paste`; otherwise document the gap. + `Event::PasteImage` is ignored (no image paste). +* **E21b/P7b is a new prerequisite, not existing IME behavior.** Winit W27 exposes + `set_ime_surrounding_text`, but candidate egui-winit never calls it and + `IMEOutput` has no logical text. The final egui pin must contain E21b/P7b: an + optional transient bounded payload (≤4000 UTF-8 bytes, UTF-8-boundary safe, + preedit excluded, byte cursor/anchor remapped), a built-in `TextEdit` producer, and + a Wayland adapter sending it on text/selection/IME-owner change; clear on password, + focus loss, no IME, unavailable context and reset. Keep the final IME descriptor and + payload together across passes. It is transient and excluded from serialized debug/ + persistence output. CADCraft has no custom `IMEOutput` producer (all editors use + built-in `TextEdit`; the MTEXT editor and rename fields inherit it), so no app + model adapter is required unless one is added later. The suite inventory's custom + producers (DesignCraft/FigCraft/WordCraft/VectorCraft/EffectCraft/FilmCraft/ + DeckCraft/PhotoCraft) do not apply here: CADCraft's custom-painted command line + receives IME commits as `Event::Text` and produces no `IMEOutput`. If a custom + producer is added, + it must supply logical model text and convert its own cursor coordinates to UTF-8 + bytes; `None` placeholders are not a port. +* IME tests: `Event::Ime(ImeEvent::Commit)` into a layer-rename field and the MTEXT + editor; outgoing surrounding text updates immediately after delete+commit and + deletion-only batches; scalar/grapheme vs byte indices; supplementary emoji, + combining marks and RTL logical order; preedit exclusion and caret movement; a + 4000-byte/long-selection bound; same-window field switch and two windows; focus + loss/reset/late serial; password/no-context clear; multi-pass final-output selection. + Native Wayland Vietnamese/predictive IME verifies the protocol trace and editor + content/undo. macOS NSRange/W26 remains a separate unqualified row. +* Keep `cmdline::capture_tab` (`cmdline.rs:56-74`) and synthetic injection. Tab capture + only when `egui_wants_keyboard_input()` is false; Escape/Enter still reach the + command line (`hidden_command_line.rs`). +* Non-Latin layouts: fork E7/E19 and winit W25 report Latin letters for shortcuts; test + Cmd/Ctrl shortcuts with a Russian/Arabic layout sequence and the winit unit tests. +* Clipboard `ctx.copy_text` is an output side effect: keep the serial-guarded mirror in + the first UI pass so a two-pass frame cannot copy twice. + +## 10. GPU, wgpu, stencil and Windows + +* CADCraft uses `egui-wgpu` paint callbacks, not a custom renderer; the callback trait + is unchanged. Keep `gpu::install` error/device-lost handlers and the stencil check. +* The canvas draws at `info.screen_size_px` and relies on egui's scissor for clipping + (`gpu.rs:731-780`). Fork E2+E3 fits an oversized surface to + `max_texture_dimension_2d` and returns configure/render errors instead of panicking; + CADCraft consumes it through eframe. Add a fork `surface_fit` unit test; a real + oversized window cannot be produced in the available environment, so record it as + an unresolved hardware gate. +* Windows: `graphics::configure` forces DX12 + FXC and excludes GL. Upstream #8736 + reports FXC compile cost for 0.37's predictable-filter shader (~57 ms vs 14 ms, + about +40 ms startup). Measure CADCraft start-to-first-`ping` on the Windows VM + (fresh processes) and compare 0.36/stock 0.37/fork; report renderer construction + separately from first frame. Do not hide a regression by changing CADCraft's FXC + choice, and do not report compile duration as UI performance. +* Keep `NativeOptions.stencil_buffer = 8` (`main.rs:188,277`). The fork's surface fit + must preserve the matching stencil texture size. `gpu_viewport_stencil.rs` stays. +* Keep `WgpuConfiguration::default().with_surface_config(surface_config(...))` + (`gpu.rs:53-65`) and the `max_buffer_size` adapter workaround (`main.rs:289-297`). +* Web: `?cpu` bypasses the GPU canvas; `?webgl` forces GL; the web runner passes + `None` for stencil (`apps/cadcraft-web/src/main.rs:133-137`). Do not change that + assumption without a WebGL2/browser test. +* Environment wording: this server has **no inherited display session and no running + compositor**. The parent can access an i915 PCI `8086:3E96` render node + (`/dev/dri/renderD128` readable/writable, `graphics-device-inspection.json`), but a + worker sandbox may hide `/dev/dri`, and access alone is not a qualified Vulkan/ + OpenGL/real-GPU result. Windows remains the Microsoft Basic Render Driver + (software D3D12). Report software and real-GPU tables separately. + +## 11. Dependencies, fork patches and vendors + +### 11.1 Target pins (final revisions unresolved) + +Before pinning, the fork and winit revisions must be reachable from the organisation +repositories; Cargo fetches a repository by `rev`, so a commit that exists only in +`aiongg/*` is not publishable as `storytold/*`. Publication is a separate human +approval. The final revisions are placeholders until that approval: + +```toml +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "", features = ["serde"] } +craft-docpath= { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "" } +egui = { version = "0.37.0", features = ["default_fonts", "monochrome_emoji_fonts"] } +egui_kittest = "0.37.0" +eframe = { version = "0.37.0", default-features = false } +egui-wgpu = "0.37.0" +``` + +and one root redirect for exactly the crates in the graph: + +```toml +[patch.crates-io] +egui = { git = "https://github.com/storytold/egui", rev = "" } +epaint = { git = "https://github.com/storytold/egui", rev = "" } +emath = { git = "https://github.com/storytold/egui", rev = "" } +ecolor = { git = "https://github.com/storytold/egui", rev = "" } +epaint_default_fonts = { git = "https://github.com/storytold/egui", rev = "" } +egui-wgpu = { git = "https://github.com/storytold/egui", rev = "" } +egui-winit = { git = "https://github.com/storytold/egui", rev = "" } +eframe = { git = "https://github.com/storytold/egui", rev = "" } +egui_kittest = { git = "https://github.com/storytold/egui", rev = "" } +winit = { git = "https://github.com/storytold/winit", rev = "" } +``` + +Trim any crate not in the graph; an unused `[patch]` entry is a Cargo **warning**, not +a build error, but it is still removed. `egui_extras` is only a dev-dependency of +`craft-ui` and is not in CADCraft's graph. No `branch` and no `path` may appear for a +published pin; the audit's local path patches are evidence only. The final fork/tag +must also replace the fork's own root `[patch] winit = { branch = ... }` with the +reviewed winit rev, or document an immutable equivalent. + +`rust-version`: keep the workspace at `1.90` for engine crates and add +`rust-version = "1.95"` to `apps/cadcraft` and `apps/cadcraft-web`, matching +`crates/ui-egui/Cargo.toml:7` and `README.md:126-127`. + +### 11.2 craft-libs PR #199: port hunks, never the tree + +`git diff --numstat 49190884 b68de8c -- crates/ui` is **78 insertions, 222 deletions** +(14 files). The stale trees `b68de8c`/`ec8840a` are missing current `craft-platform` +and revert docking/printing/security fixes (`c67b1e1` floating-group permission, +`4f85efb` bounded `shown_names`, and related settings/printer hardening). Never merge +or check them out as the target. + +S1 creates a fresh branch from current `main` `49190884` and applies only the egui-API +hunks from `8614e4a` (plus the relevant README change from `1764bca`): + +* `WidgetType`→`Role`; +* `UiBuilder::id`/`Ui::id`→`scope_id`; +* `Id::new`→`Id::unique`; +* dev-deps on the final egui 0.37 revision (also bump workspace egui and UI egui_extras/egui_kittest dev-dependencies together); +* the docking fixture's U+22EE→`…` workaround; +* the CL-2 capture-slot prototype and CL-3 opt-in `DockArea::direction` API. + +Preserve the current docking-permission and print-name hardening. Run +`cargo test -p craft-ui --all-features`, the workspace tests, strict clippy and the +all-feature wasm check; merge; publish an immutable commit; then pin CADCraft to it. +`craft-docpath` is unchanged between `82a24b0` and `49190884`, so both CADCraft +craft-libs dependencies can use the same new revision. + +### 11.3 Language work #198 + +Consume #198 through the final fork/winit pins (E5/E12-E21, W17-W27, plus E21b/W27); +do not add a `craft-i18n` dependency or adopt its UI model. CADCraft keeps its TSV +catalog and `with_language`, and uses only `Context::set_language` and +`Style::right_to_left` from the fork. Any app-side #198 adoption is separate. + +### 11.4 Fork patch disposition for CADCraft + +Provenance: `egui/PATCHES.md` on the candidate and commits `0.37.0..a4f9a17f`. The +final pin must carry the whole reviewed series; the table records what CADCraft +exercises and what would catch removal. + +| Patch | What it does | CADCraft use | Keep / remove condition | Test | +|---|---|---|---|---| +| E1 | eframe `ViewportHooks` | Not used (no secondary windows) | Keep for craft-tablet; remove if upstream adds it | fork unit test | +| E2+E3 | fit oversized wgpu surface; setup errors | via eframe wgpu | Keep while emilk/egui#8361 open | fork `surface_fit`; CADCraft GPU gate unresolved | +| E4 | Wayland clipboard via winit | `CADCRAFT_WAYLAND=1` | Keep while #8657 draft; **requires W15 and W15P PRIMARY** | regular + PRIMARY native matrix | +| E5/E12/E13 | bidi shaping/order/marks/caret, graphemes | Arabic UI/text | Keep while #8577 open | fork text tests; Arabic UI label/MTEXT | +| E6 | IME focus not interrupted | Text fields | Keep | kittest IME commit | +| E7 | Windows VK letter keys | Non-Latin shortcuts | Keep; needs W25 | Windows VM | +| E8 | TextEdit glyph overshoot | Thai/Vietnamese/Arabic input | Keep | tall-glyph TextEdit | +| E9 | `Style::right_to_left` chrome | §8.2 decision | Keep; activation staged | fork RTL chrome; CADCraft opt-in only | +| E10 | web key/lang | Browser keys | Keep | browser/manual | +| E11 | vello_cpu single-thread raster | Not used | Keep | fork | +| E14/E15/E16 | fallback baseline, line breaker, cursor affinity | CJK/Indic editing | Keep | fork; CJK screens | +| E17 | `Context::set_language`, AccessKit language, bidi runs | Used in S4 | Keep until upstream | root-language test | +| E18 | eframe `Frame::set_ui_language`/web fallback | Web speech/lang | Keep | web/manual + language test | +| E19 | Latin layout chars for shortcuts | Non-Latin layouts | Keep; needs W25 | Windows/Linux manual | +| E20 | widget text in Ui language | All translated widgets | Keep | language render test | +| E21 | IME delete-surrounding bytes | macOS/Wayland IME | Keep; needs W26/W27 | fork + native IME | +| E21b | bounded outgoing IME surrounding text + TextEdit producer + Wayland adapter | All built-in `TextEdit` editors | **Required new prerequisite**; keep with W27 state/serial | outgoing-state/focus/password/UTF-8/4000-byte tests | +| P2b | E4 PRIMARY integration | Wayland PRIMARY | Required with W15P | native PRIMARY matrix | + +Each “remove when” is a review condition: remove a patch only after the replacement is +verified in the same consumer graph and the table is updated. + +### 11.5 winit + +CADCraft needs the winit fork because E4/E19/E21/E21b call APIs in W15/W15P/W25/W26/ +W27 not in crates.io 0.30.13. Patch winit to the **final reviewed rev**, not a branch; +keep `winit` 0.30.13-compatible `dpi`. `apps/cadcraft/Cargo.toml:29` keeps its direct +winit dependency for `EventLoopBuilderExtX11`; the patch makes it and eframe's winit one +package. + +Do not delete W1-W15 or W17-W27 tests (`tests/ime_patches.rs`, +`tests/pointer_patches.rs`). W15P needs pure seat/offer/serial/owned-marker tests, a +`WAYLAND_DEBUG=1`/test-compositor protocol trace, and the native Hyprland/KWin/Mutter +matrix. W16 (pinch/rotation) remains outside this cut. + +### 11.6 Vendors and lockfile checks (native and wasm) + +* CADCraft has no vendor directory and no external path patch. Nothing is deleted. +* craft-libs' `vendor/krilla`, `vendor/hayro*` are PDF-only and unrelated; leave them. +* pdfcraft's vendored `egui-wgpu`/winit are another repository's responsibility; do not + remove them here or copy their patches into CADCraft. +* After regeneration, assert on both target feature sets: + * native: `cargo tree -d`, `cargo tree -i egui`, `cargo tree -i winit`, + `cargo tree -i arboard`, `cargo metadata --format-version 1`; + * wasm: `cargo tree --target wasm32-unknown-unknown -d`, + `cargo tree -i egui --target wasm32-unknown-unknown`, + `cargo tree -e features --target wasm32-unknown-unknown -p cadcraft-web`, + and `cargo metadata` filtered/interpreted for `apps/cadcraft-web`; + * no duplicate egui/emath/ecolor/epaint/accesskit 0.36/0.37 pairs; + * every egui-family source is `git+https://github.com/storytold/egui?rev=`; + winit is the storytold fork; craft-libs crates share one final rev; + * `harfrust` may legitimately appear at 0.12 (egui) and 0.14 (`cadcraft-fonts`); + * `arboard` is present through `egui-winit` because `clipboard` is on; + * the wasm graph has `egui/web_file`, eframe wgpu/accesskit and no native-only + `arboard`, X11 or Wayland packages; assert these, and test a negative fixture with + a missing family patch or a moved branch (it must fail the graph gate); + * no manifest contains `path = "/home/…"` or a `branch =` for a published pin; + * `cargo check --locked` succeeds on the final native and wasm locks. +* Keep the old coherent lock (`a08fd7c`) in evidence for rollback; do not delete the old + dependency set from review notes. + +Shared handoff requirement: consume complete egui P8 including P7c's selected #8763/#8748 +fixes and P7 key-release pairing. MTEXT selection-to-format assertions and ordinary held-key +layout/focus changes are S5/S6 gates. Built-in TextEdit's E21b producer inherits P7b's owner +identity even when unavailable, Available-empty distinction, committed W27 clear, latest-pass +None, stale-epoch invalidation and current-epoch serial-mismatch behavior. Add a native test +contrasting stale epoch with current-epoch mismatch; do not implement fork internals locally. + +## 12. Staged implementation PRs + +Publication order assumes each repository has its own approval. No stage is pushed, +tagged, merged or posted by this plan. + +``` +storytold/winit final rev (bounded W15/W15P, W17-W27 lifecycle) + -> storytold/egui final P8 (P2b/P7/P7b/P7c included) + -> craft-libs S1: current main + PR199 API hunks + CL-2F/CL-3 + -> published craft-libs final rev + -> cadcraft S2 (atomic compatibility; strict names/glyphs; clipboard/fonts; + PanelState migration; native+wasm graph) + -> S3 lifecycle/screenshot/host + -> S4 language/AccessKit/RTL text + -> S5a PRIMARY/clipboard matrix (required for parity) + -> S5b RTL activation (optional, only with chrome mirroring) + -> S6 docs + acceptance evidence +``` + +* **S1 craft-libs** — fresh branch from `49190884`; apply only the `8614e4a` API + hunks; preserve docking-permission/printing hardening; add CL-2 capture slot and + CL-3 opt-in direction API; tests/clippy/wasm; merge; publish a final rev. +* **S2 CADCraft compatibility** — final pins; `clipboard`, `default_fonts`, + `monochrome_emoji_fonts`; app `rust-version`; C1-C8 mechanical migrations; panel + `PanelState` migration; accessible names and glyph sweep; lockfile regeneration; + shared CL-2F callback migration including basic drain/timeout/reply handling and + native **and wasm** graph assertions. It is atomic and must compile and pass + `cargo xtask ci` (and `cargo test -p cadcraft-ui-egui --all-targets`). It is **not** + a “no behaviour change” stage: the emoji font, accessible names and panel keys change. +* **S3 CADCraft lifecycle** — `frame_tick` split, opening spinner move, host/web frame + token, once-per-frame wheel application, shared capture lifecycle stress, dropped-file fix, + bounded counters, tests and mutation probes. +* **S4 CADCraft text/accessibility/language** — broader glyph coverage via pinned + fixtures, `Context::set_language`, AccessKit language, Arabic shaping checks, docs + row for RTL status. +* **S5a CADCraft clipboard/PRIMARY (required for parity)** — middle-click decision and + the native Wayland/X11 PRIMARY matrix against W15P/E4/P2b. A measured fallback is + recorded only if native evidence proves the design cannot meet a required platform. +* **S5b CADCraft RTL activation (optional)** — `DockArea::direction`, + `Style::right_to_left` and app-owned chrome mirroring; otherwise document the gap. +* **S6 evidence and docs** — performance report, screenshots, before/after matrices, + docs revision history, remaining gates. + +Rollback: keep the `a08fd7c` source/lock pair as the last coherent 0.36.2 state. S2 is +the compatibility boundary; after it, revert the stack (source + lock) rather than the +lock alone. S3, S4, S5a and S5b are independent commits and can be reverted without touching the pins. +No partial rollback to 0.36 is promised after S2 until the stack is reverted as a unit. + +## 13. Verification plan + +### 13.1 Compilation and repository gates (through `task.py run/status/finish`) + +* `cargo xtask ci` (fmt, clippy -D warnings, workspace tests, assets, layers, wasm). +* `cargo check --workspace --all-targets --locked`. +* `cargo test -p cadcraft-ui-egui --all-targets` **alone** (UI crate dev/test font + features self-sufficient). +* `cargo check -p cadcraft-web --target wasm32-unknown-unknown` and + `trunk build --release` from `apps/cadcraft-web`; the wasm32 target is installed, + but `trunk`/`wasm-bindgen` and a browser are not, so the bundle/browser run remain + blocking unknowns. +* Native **and** wasm graph checks from §11.6. +* Optional features: `cargo test -p cadcraft-ui-egui --features …` where the crate has + features (currently none); craft-libs all-features/craft-ui all-features in S1. +* Windows: a task-specific craft-windows clone, separate target/evidence, native + `cargo test --workspace --locked`, `cargo build --release -p cadcraft`, then the GUI + workflow in §13.5. Do not reuse the baseline executable as new-source evidence. + +### 13.2 Behavioural regressions + +| Behaviour | Assertion | Anchor | +|---|---|---| +| Multi-pass frame side effects | one non-idempotent counter/LISP slice/control execution per frame with a popup open | §6.5 | +| Hidden logic | control replies, autosave deadlines and screenshot timeouts while minimized/unknown visibility; dropped file opens once | §6.5 | +| Fresh queues | a control request queued between passes is answered without waiting for the next frame | §6.5 | +| Hidden child (conditional) | if a later fork pin adopts an overdue full pass, parent occluded/overdue plus child open/update/close; CADCraft currently has no child viewports, so record N/A rather than claim #8748 | §6.5, §14.5 | +| Screenshot correlation | two tokens/two paths; timeout/cancel/admission/geometry mismatch | §6.4 | +| Command/undo | create → undo → redo → entity count | engine + UI control test | +| Save/reopen | `examples/apartment.dxf` opened, edited, saved to temp, reopened; entity count and file bytes | `apps/cadcraft`/CLI | +| Theme/language | light/dark/System, `en`/`de`/`zh`/`ar`, no text loss; AccessKit language | `lib.rs`/`i18n` tests | +| Panels/docking | canvas rect, tab order, floating group move, workspace reload, unchanged direction JSON | `docking_workflows`, `chrome_geometry` | +| Keyboard/focus | Tab/Shift-Tab, Enter/Space activation, Escape/Enter command line, IME commit | `workspace_focus`, `hidden_command_line`, new IME | +| Clipboard | Copy/Cut/Paste across apps, DXF mirror, X11/Wayland PRIMARY matrix | `clipboard.rs`, native | +| Selection | MTEXT toolbar formats the exact selected range; pointer and keyboard paths | `#8763`/`#8764` test | +| GPU/stencil | clipped viewport pixels, surface/stencil sizes, device-loss notice | `gpu_viewport_stencil`, `gpu_faults` | +| Fonts/glyphs | bundled sweep + pinned fixtures; no tofu/clipped combining marks | §8.1/§8.4 | +| Web | `?cpu`, `?webgl`, open/download/iframe host | browser matrix | + +### 13.3 Screenshots, geometry and semantic accessibility + +* Capture before/after PNGs for 1600×1000 and 900×560, 1.0/1.25/2.0 scale, dark/light, + Start tab and a drawing with Layers/Properties/Tool Sets open, an in-window menu, the + command line with keywords, MTEXT editor, Layer Properties Manager and Options. +* Compare geometry semantically first (panel rects, canvas rect, text bounding boxes, + no overlap), then pixels with stated tolerances: exact for flat fills/sampled + backgrounds; changed-pixel ratio ≤0.5% per window and no text box moving more than + 2 px unless the change is the intended 0.37 style adjustment. Record both images and + diff counts; never update a golden without a stated intent. +* Accessibility: `unnamed_widgets()` empty for every opened dialog, expected + role/label/value for representative inputs, tab traversal order, focus visibility and + AccessKit root language after a switch. +* kittest `with_context_setup` is the supported way to install deterministic fonts/ + options before the first pass (#8686); do not use `Context::run_logic` to initialise + fonts or read input twice. + +### 13.4 Mutation probes + +Break the implementation once per feature and record the failure: +1. move only the deliberately non-idempotent LISP slice/counter into a second-pass + call; the two-pass test must fail (the other queue work is consume-based and would + still run once, so it is not a valid probe); +2. map two screenshot tokens to one path; the correlation test must fail; +3. remove the `clipboard` feature; the **`cargo tree -i arboard`** assertion must fail, + and a manual OS-clipboard check must fail (a kittest copy/paste assertion is not a + detector); +4. put dropped-file handling back in `logic`; the hidden-tick/reopen test must fail; +5. remove an accessible name; `unnamed_widgets` must fail; +6. change `Role::Button` to `Role::Unknown`; the role test must fail; +7. remove `monochrome_emoji_fonts`; the `⌘` glyph test must fail; +8. leave `DockArea` and `cancel_drag` ids different; the drag-cancel test must fail; +9. skip the panel `PanelState` migration; the seeded old-key test must fail; +10. reapply the wheel delta in pass two; the two-pass view-position assertion must fail; +11. send a screenshot completion after resize/DPI change; the mismatch test must fail; +12. remove the 4000-byte IME bound/preedit exclusion; the IME tests must fail. + +A probe is not a deliverable; record the failure output in evidence. + +### 13.5 Platform matrix + +| Platform | What can be qualified here | Limits / required preparation | +|---|---|---| +| Linux host, headless | compile, clippy, tests, kittest, `cadcraft-cli perf`, wasm compile | no inherited `DISPLAY`/`WAYLAND_DISPLAY`, no compositor/Xvfb; not native focus/IME/picker/GPU evidence | +| Linux X11 | functional GUI and focus/clipboard after provisioning Xvfb + a window manager; record software renderer/versions | Xvfb/manager absent at inspection; must be provisioned as an isolated managed job; results labelled virtual-display/software | +| Linux Wayland | W15/W15P/PRIMARY, W27/E21b IME after provisioning Weston/Sway/wlroots/KWin/Mutter | compositors absent; XWayland default path is separate; native capability evidence required | +| Linux GPU | parent-visible i915 `8086:3E96` renderD128; qualify Vulkan/OpenGL/wgpu via a managed probe | worker sandbox may hide `/dev/dri`; access alone is not GPU acceptance; software and real-GPU tables separate | +| Windows craft-windows VM | native compile/tests, GUI launch, screenshots, control channel, Basic Render Driver | software D3D12; IME/keyboard layouts only if the guest has them; no real-GPU claim | +| Browser | `cargo check`/`trunk build`; interactive run needs a pinned browser and local server | no browser/trunk installed; WebGPU/WebGL backend must be recorded | +| macOS | release CI only, no local hardware | native focus/IME/picker/traffic-light layout unresolved | +| FreeBSD | CI workflow/VM only | no local evidence | + +Native focus/IME/file-picker behavior and real-GPU performance are not established by +headless rendering or the software renderer. macOS/browser rows remain unresolved unless +real evidence is produced. + +## 14. Performance plan + +### 14.1 Environment and identities + +Record for every run: exact CADCraft SHA, `Cargo.lock` SHA256, feature set, +`CRAFT_FONTS_DIR` (or absent), fixture (`examples/apartment.dxf`, synthetic counts), +OS/kernel, Rust version, GPU or software renderer/driver, display scale, viewport size, +build profile (`release`), and whether the canvas is GPU or `?cpu`. On Linux record +`lscpu`, `uname -a`, DRM device identities and `vulkaninfo --summary` when available, +plus the Xvfb/compositor/window-manager versions. On Windows record the guest adapter +and `dxdiag` Basic Render Driver. Never mix software and real-GPU tables. + +### 14.2 Baselines and attribution + +* **B0**: `a08fd7c` with the coherent 0.36.2 egui family and craft-ui `5c874d51`. +* **B0u**: B0 in a measurement-only worktree with `frame_nr`/pass/CPU counters and + idle-frame instrumentation backported (never committed). +* **B0e**: `1977a493` engine/format dependency state measured with `cadcraft-cli perf` + (engine-only, no UI) to attribute tiny-skia/skrifa/harfrust/quick-xml changes. +* **C0**: the S2 compatibility commit. It includes the clipboard/font/accessibility/ + panel-key changes, so it is not a pure dependency baseline. +* **C0u**: C0 with the same measurement-only counters. +* **Cs**: C0's source compiled against **stock egui 0.37 `841a328b`** (and stock + craft-ui API port) where it compiles; if a fork-only API is already used, use the + same operation in a pinned minimal adapter/harness and explain the semantic + difference. Record B0/Cs/C0 source and lock identities. +* **C1..Cn**: each later stage, compared with C0 for that stage. + +Do not compare B0 directly with S4 as if only the UI changed. Use B0 vs B0e for the +non-UI delta, C0 vs Cs for fork-vs-stock, and B0 vs C0 for the total compatibility +delta. `ui.inspect` does not expose `cumulative_frame_nr`; C0u/B0u add the counter in +the measurement worktree, and S3 may add bounded production fields. `cpu_usage` is +available only from the host `eframe::Frame`, so the host records it; `CadApp::logic` +does not receive a `Frame`. + +### 14.3 Matched operations and sample counts + +Use the suite minimums: **≥10 warmups**, **≥30 steady samples** for discrete +operations, **≥20 fresh startup processes**, and **≥1,000 frames** before reporting a +frame p99. Interleave baseline/candidate (ABAB) on the same machine and lock; report raw +samples, median, p95, p99 (only with ≥1,000 frames), peak RSS, allocations/texture +uploads and idle CPU/repaint count. Exclude the first run after a cold shader/font cache; +record cold vs warm separately. + +1. **Engine control**: `cadcraft-cli perf 200000` and + `cadcraft-cli perf examples/apartment.dxf`; repeat the single-shot command in a + driver script (the CLI has no repeat flag), ≥10 warmups + ≥30 samples, parse the + timing table. +2. **Idle**: open the sample, no input, 60 s; frames from the measurement counter, + process CPU (`/proc//stat`, `Get-Process` CPU), peak working set; animations on + and off; ≥1,000 frames. +3. **Pan/zoom**: 200 view steps across five zoom bands via `cmdline.input` ZOOM/PAN or + `ui.drag` (there is no `view.set`), a redraw each; `frameMs`, `buildMs`, `drawMs`, + `meshMs`, `prims`; ≥1,000 frames. +4. **Selection/properties**: 10,000 synthetic entities, open Properties, pan, re-select; + `drawMs`/`frameMs`; include the `#8763`/`#8764` selection action. +5. **Command line/menus**: 200 commands and 50 menu opens; frame time and idle repaint. +6. **Dialogs**: open/close Layers, Options, Plot, MTEXT 20 times; frame time and + allocation/upload counters; ≥1,000 frames for the p99 claim. +7. **Save/reopen**: open `examples/apartment.dxf`, edit, save to temp, reopen; wall + time and `drawMs`. +8. **#8731 text layout/tessellation**: 120 ASCII labels + 120 rounded-rect controls + adapted to CADCraft's chrome; measure tessellation CPU/instruction cost per frame on + B0/Cs/C0; test mixed/color rows and keep color-emoji/wrap semantics. +9. **#8736 Windows startup**: fresh process, poll the control port until `ping` + succeeds; renderer construction separate from first frame; ≥20 fresh processes on + B0/Cs/C0. Record the absolute +ms for FXC. +10. **#8512 fonts**: measure CADCraft startup with `system_font_fallback=false` and + record that no system-font discovery is performed; compare a stock-eframe default + only as a separate row. Do not enable `system_fonts` to “fix” a number. + +### 14.4 Thresholds and noise policy + +Predefine and freeze before candidate measurement: + +* investigate a repeatable **>5% median** or **>10% p95** latency regression; +* investigate **>5% peak-memory** increase and any new idle busy-loop/audio underrun; +* for sub-millisecond measurements require the relative increase to exceed **0.05 ms** + or corroborate with stable instruction/allocation counts; +* app-specific gates may be stricter, never looser. CADCraft engine operations + ≤ +5% median and UI `frameMs`/`cpuMs` p95 ≤ +10% are the floor. Startup uses the same + median/p95 rule; **no “+100 ms” allowance** hides the ~+40 ms FXC cost. If +40 ms is + accepted, it is a documented tradeoff with owner review, not a threshold change. +* Same Rust/toolchain, `release`, features/fonts/fixture, GPU/driver/DPI/window size; + no concurrent builds/noisy workload; rerun noisy comparisons; never expand a + tolerance after seeing a failure; never report compile duration as UI performance. +* Screenshots use the §13.3 pixel policy; a 0.37 style change (#8617/#8445) is documented + with a before/after crop before acceptance. + +### 14.5 Upstream-reported issues (reported, not measured here) + +* #8731: plain-text tessellation ≈+60% in 0.37, color-glyph branch in + `Tessellator::tessellate_text`; measure operation 8 on B0/Cs/C0; a row-level fast path + is a fork decision, not a CADCraft workaround. +* #8736: `egui-wgpu` 0.37 `wrap_texel_coord` makes the DX12/FXC pipeline ~4× slower to + compile, ~+40 ms startup; CADCraft defaults to FXC. Measure operation 9; do not + change the FXC choice to hide it. Clamp-only is not a valid Repeat/MirroredRepeat fix. +* #8512: system-font startup cost; CADCraft disables the provider, so record absence of + that cost rather than adding it. +* #8595: repaint-burst fix may reduce idle CPU; measure operation 2. +* #8528: cross-viewport galley cache; CADCraft has one viewport, not applicable. +* #8763/unmerged #8764: selection loss before a clicked widget. Test the MTEXT toolbar + and dialog actions with pointer and keyboard; the fork owns any selected fix, and an + app workaround must preserve the range and undo semantics with a removal condition. +* #8748: **closed and unmerged**; not behavior in the pin. Test root hidden/logic-only + behavior; a deferred-child overdue full pass exists only if a later fork pin adopts + it, with that exact revision and native evidence. Do not infer phase from `visible()`. + +## 15. Documentation updates + +* `docs/ui-parity.md`: Accessibility row — AccessKit roles/names/language tested in + kittest; native screen-reader coverage still not established. +* `docs/localization-parity.md`: Arabic row — shaping/RTL status, whether the UI is + mirrored, glyph coverage; add a revision-history line. +* `docs/control-protocol.md`: new `ui.inspect.perf` fields (frame/pass counters) and + unchanged screenshot client semantics; note screenshot admission/timeout errors. +* `README.md`: UI builds require Rust 1.95; keep the core 1.90 minimum. +* No `ATTRIBUTION.md`, contributor or asset changes are part of this migration. Keep + `@skylartaylor` username-only credit. + +## 16. Risks, blocking unknowns and acceptance + +Blocking before calling the migration complete: + +1. **Final pins/publication reachability**: only the approved final `storytold/egui`, + `storytold/winit` and `storytold/craft-libs` revisions are production pins. The + aiongg candidates are comparison inputs. The final fork/tag must replace the fork's + own moving winit branch patch. +2. **W15P/E4 PRIMARY**: the final winit rev must include W15P and the final egui rev + E4/P2b, with native wlroots/KWin/Mutter/X11 evidence. A PRIMARY-only fallback is a + measured, explicitly accepted exception. +3. **E21b/P7b IME**: the final egui rev must include the bounded outgoing surrounding + text producer/adapter; otherwise IME is not complete and the limitation is explicit. +4. **craft-libs S1**: port only `8614e4a` hunks onto `49190884`; never merge + `b68de8c`/`ec8840a`; preserve `c67b1e1`/`4f85efb`/`craft-platform` and README. +5. **Toolchain**: egui 0.37 requires Rust 1.95; apps must state it. The server's + default `rustc` is 1.99.0 and 1.95.0/1.96.1/1.97.1 are installed; `trunk`, a browser + and a compositor/Xvfb are absent. +6. **Display/renderer**: no inherited display; parent-visible i915 renderD128 is + unqualified and may be hidden from a worker; Windows is software D3D12. Native focus, + IME, picker and real-GPU performance remain unresolved until managed evidence. +7. **Web**: `cargo check -p cadcraft-web` and a wasm graph gate are required; `trunk` + build and a real browser run are blocking unknowns. +8. **Windows/macOS native**: Basic Render Driver only; focus/IME/picker/traffic-light + layout and real-GPU behavior unqualified; macOS hardware unavailable. +9. **Fonts**: monochrome emoji changes `⌘`; the `⇧` decision may change macOS labels; + pinned fixtures are external and must keep their licences. `can_show` gating remains. +10. **RTL**: `Style::right_to_left` alone does not mirror CADCraft chrome; use craft-ui + `DockArea::direction` only with app-owned mirroring; do not claim an RTL UI earlier. +11. **Screenshot on web**: `now_ms` is 0 on wasm; the new slot deadline must use a web + clock, and a browser screenshot remains a separate gate. +12. **Oversized surface**: no environment can produce a window larger than + `max_texture_dimension_2d`; E2+E3 is covered by the fork unit test only. +13. **Review/evidence**: a fresh independent adversarial review of this exact document + SHA256 is required before implementation; each implementation stage needs a fresh + adversarial review at its exact candidate revision, with regressions and measured + before/after performance. The baseline is captured before production edits. + +Acceptance criteria (all evidenced, not inferred): S2 compiles with one egui family and +one winit; `cargo xtask ci` passes with strict kittest names/glyphs; native and wasm +graph assertions pass; S3 lifecycle/screenshot/bounds tests and mutation probes pass; +S4 language/AccessKit/Arabic tests pass; S5a PRIMARY is evidenced or recorded +as an explicit fallback; before/after screenshots and performance samples meet §14.4 or +have a reviewed explanation; remaining platform gates are listed with the exact +unverified behaviors. + +## References (primary sources inspected) + +* egui 0.37.0 changelog (`CHANGELOG.md` at `841a328b`) and crate sources: + `Context::run_ui`/`run_logic`/`end_pass`/`current_pass_index`, `PanelState`/`Panel::id`, + `ScreenshotCallback`, `MouseWheelSource`, `Role`, `Id`/`AsId`/`AsIdSalt`, + `FontDefinitions`, `MissingGlyphPolicy`, kittest `builder.rs`/`accessibility.rs`. +* Native/web eframe integrations: `native/epi_integration.rs`, `native/run.rs`, + `native/wgpu_integration.rs`, `web/app_runner.rs` at `a4f9a17f` and `767b1cad`. +* Candidate fork `PATCHES.md` and commits `0.37.0..a4f9a17f`; winit plan W15P and + `craft/0.30.13-lang`; craft-libs `49190884`, `8614e4a`, `b68de8c`, `ec8840a`. +* `/home/codex/work/ssd2/task-evidence/egui037-migration-20261022/upstream-discussions.json` + (#8731, #8736, #8763, #8748, #8535, #8489, #8555, #8583, #8512, #8428, #8657, + #8577, #8641), `lifecycle-source-audit.md`, `ime-surrounding-gap.md`, + `graphics-device-inspection.json`, and `craft-libs/docs/egui-037-suite-rollout.md`. + These are reported findings/history, not fresh measurements. + +## Appendix A — source anchors + +Manifests: `Cargo.toml:6-11,52-59`; `crates/ui-egui/Cargo.toml:7,20-32`; +`apps/cadcraft/Cargo.toml:19,29`; `apps/cadcraft-web/Cargo.toml:20,26-32`. + +App shell: `apps/cadcraft/src/main.rs:67-110` (App impl), `:185,188` (app id/stencil), +`:228-307` (options/run), `:343-348` (set_wgpu), `:301-306` (XWayland). +`apps/cadcraft-web/src/main.rs:31-58` (Shell), `:108-130` (web renderer/stencil), +`apps/cadcraft-web/src/upload.rs:70-77` (`take_dropped`). +`apps/cadcraft/src/workspace.rs:149-170` (save_if_changed/save_on_exit). + +UI lifecycle: `crates/ui-egui/src/lib.rs:195-267` (CadApp fields), `:444-455` +(finish_opening), `:536-598` (logic), `:601-625` (raw_input_hook), `:627-662` +(ui/draw), `:664-687` (drain_control), `:688-740` (screenshots), `:745-755` (now_ms). +`crates/ui-egui/src/opening.rs:77-97` (spinner Area). + +UI structure: `chrome.rs:69,180,300` (panels), `menus.rs:569-589` (menu bar), +`docking.rs:74-130,318-399` (Workspace/DockArea), `palettes.rs:330,346` (panels), +`theme.rs:314-387` (fonts), `:640-647` (can_show), `:697-713` (theme apply), +`animation.rs:75-130` (wheel/unsmooth), `icons.rs:948-989` (buttons/roles), +`cmdline.rs:56-74` (Tab), `:251-330` (keyboard), `clipboard.rs:29-60` (mirror/paste), +`mtext_editor.rs:146-164,217-224,420-455` (formatting toolbar/selection). + +GPU/control: `gpu.rs:53-65` (surface config), `:681-780` (install/callback), +`control.rs:195-215` (methods), `:255-272` (inspect), `:547-559` (save_screenshot), +`tests/gpu_viewport_stencil.rs`, `tests/gpu_faults.rs`. + +Tests: 43 files under `crates/ui-egui/tests`; see §3.7; `tests/chrome_geometry.rs:58` +uses 1200×760/900×560; `tests/hidden_command_line.rs:19-30` is the `run_ui` helper shape. + +## Appendix B — commands proposed for the parent to run (not run here) + +All build/test commands must go through `maintenance/task.py run/status/finish` with one +unique task and evidence path, e.g.: + +```sh +python3 /home/codex/work/maintenance/task.py run \ + --job-id egui037-cadcraft-ci-01 --kind build --task egui037-cadcraft \ + --source /home/codex/work/CraftApps/egui037-migration-20261022/cadcraft \ + --evidence /home/codex/work/ssd2/task-evidence/egui037-migration-20261022/cadcraft \ + -- bash -c 'cargo xtask ci' +``` + +and equivalents for: + +```sh +cargo check --workspace --all-targets --locked +cargo test -p cadcraft-ui-egui --all-targets +cargo check -p cadcraft-web --target wasm32-unknown-unknown +(cd apps/cadcraft-web && trunk build --release) +cargo tree -d +cargo tree -i egui +cargo tree --target wasm32-unknown-unknown -d +cargo tree -i egui --target wasm32-unknown-unknown +cargo tree -e features --target wasm32-unknown-unknown -p cadcraft-web +cargo metadata --format-version 1 +cargo run -p cadcraft-cli -- perf 200000 +cargo run -p cadcraft-cli -- perf examples/apartment.dxf +``` + +Use the craft-windows clone helper for the Windows stage, a fresh target/evidence +directory, `guest-exec.py`/build equivalents appropriate to CADCraft, and the existing +`task.py`/`vm.py` sequence. Do not reuse the baseline's built binary as new-source +evidence, and do not run these commands in this planning task. + + +## 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/cadcraft` `main` now resolves to `f043540d13dac03ce895a96c78a8349f3e40bd5b`. The owned checkout was fast-forwarded to this revision. Earlier baseline SHAs and line anchors above are retained as historical review evidence; use this new revision and its exact lockfile for current-main integration and paired measurements. The original refs remain reachable. + +Preserve browser page zoom; test canvas pinch routing because this page does not add DesignCraft touch-action:none. + +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. + +## Local implementation update — 2026-10-24 (upstream review v9 resolutions) + +The v9 source review found no high-severity migration regression in the native UI +library/integration gate and listed four conservative-behaviour notes plus three +strict-glyph/accessibility gaps. This update records how the concrete findings are +resolved without widening the migration contract: + +* **Conservative capture identity stays.** `SurfaceGeometry` equality keeps + `window_focused`, `minimized` and `occluded` alongside viewport, pixels-per-point + and texture limits. Any observed focus/visibility transition can change the + presented frame, so an admitted capture is rejected rather than re-used; the + struct's documentation now states this intent instead of narrowing the epoch. +* **No-op `document.switch` stays strict.** `run_switch` bumps the document epoch + even when the requested index is already active, because the command cancels the + active command and an external client cannot prove the switch was unobservable. + The source records that the conservative bump is deliberate. +* **Invalid pass geometry invalidates the retained surface.** `note_pass_geometry` + now mirrors `note_raw_geometry`: a pass that cannot report a finite positive + pixels-per-point clears `capture_geometry` and bumps both epochs. Previously the + previous surface survived and a later admission could bind to stale geometry. +* **MTEXT Diameter is painted, not shaped.** U+2300 is absent from egui's bundled + Proportional/Monospace/emoji faces and from the web font set. The toolbar button + now paints CADCraft's own circle-and-slash glyph and carries the semantic name + "Diameter"; the document text still uses U+2300, which the drafting font covers. +* **Affected controls are named.** The MTEXT height `DragValue` gets the AccessKit + name "Text height" (its tooltip alone is not a name), and QuickCalc's conversion + `Value` field is associated with its visible label and a meaningful placeholder + instead of the unexplained "1". +* **Strict bundled-font regressions.** Focused kittest regressions render the + Diameter button, MTEXT height field and QuickCalc value row with bundled + `FontDefinitions` only (no `theme::install_fonts` system prepend), the default + `MissingGlyphPolicy::Panic` and accessibility checks. No global tofu policy or + accessibility opt-out is introduced. + +The first macOS native-menu poll now refreshes enablement immediately by +initializing that timer to `f64::NEG_INFINITY`; `now_ms` starts near zero, so the +former `0.0` sentinel suppressed the first ~300 ms of enablement. + +This amendment is source-only: it does not claim a cargo, fmt, clippy, test, wasm, +Windows/macOS or GUI result. Publication still requires fresh explicit approval +after the parent's own validation. + +### Full MTEXT toolbar font regression + +The bundled-font check also covers the entire formatting toolbar: alignment, list and tab +marks are drawn with original geometry, and the color button relies on egui’s menu affordance +instead of an uncovered arrow character. The font and color controls publish semantic names. +The whole-toolbar harness retains strict missing-glyph and accessible-name checks, so a system +font cannot hide an unsupported toolbar character. Native visual review remains required. + + +## Native MTEXT insertion follow-up (2026-10-24) + +Native Wayland job29 exercised real keyboard circle creation and MTEXT entry, the +Diameter button, callback screenshot, save, normal quit, restart and reopen. The +new entities and exact `Migration 0.37 ⌀` text survive DXF reopening. The editor +showed tofu for U+2300 even though its toolbar icon was already painted correctly. +A bounded original circle/slash `GlyphRasterizer` now supplies only U+2300 when no +installed font covers it; it keeps the stored Unicode character and cursor indexes +unchanged and works without optional font files on native/web. Font setup installs +it idempotently. A real toolbar-click/TextEdit test uses the strict bundled-font +harness and checks inserted contents and caret. Job30 validates this follow-up; +fresh native visual confirmation and review remain pending. diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 36b69cecb..7ad0c4016 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -103,7 +103,6 @@ fi if has appimage; then APPDIR="$WORK/CADCraft.AppDir" cp -R "$STAGE" "$APPDIR" - mv "$APPDIR/usr/share/doc" "$WORK/doc-unused" ln -s usr/bin/cadcraft "$APPDIR/AppRun" cp "$HERE/$APP_ID.desktop" "$APPDIR/$APP_ID.desktop" cp "$ROOT/assets/app-icon/hicolor/256x256/apps/$APP_ID.png" "$APPDIR/$APP_ID.png" diff --git a/packaging/windows/cadcraft.wxs b/packaging/windows/cadcraft.wxs index 50f82f6ff..d59cc41b3 100644 --- a/packaging/windows/cadcraft.wxs +++ b/packaging/windows/cadcraft.wxs @@ -14,6 +14,9 @@ unless DESKTOPSHORTCUT=1 or DESKTOPSHORTCUT=0 is given. --> + + + + + + + + + + + diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index a6b8cf23e..406227f7c 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -92,11 +92,25 @@ Copy-Item (Join-Path $Bin 'cadcraft.exe'), (Join-Path $Bin 'cadcraft-cli.exe') $ & (Join-Path $PSScriptRoot 'sign.ps1') (Join-Path $Stage 'cadcraft.exe') (Join-Path $Stage 'cadcraft-cli.exe') +# ---- font notices for the MSI ------------------------------------------------------------------ +# The portable ZIP already carries these notices; stage the complete files before MSI creation too. +$Docs = Join-Path $Stage 'font-licences' +New-Item -ItemType Directory -Force -Path $Docs | Out-Null +$HasCraftFontLicences = 0 +if ($env:CRAFT_FONTS_DIR) { + foreach ($ofl in Get-ChildItem -Path (Join-Path $env:CRAFT_FONTS_DIR 'fonts\*\OFL.txt') -File -ErrorAction SilentlyContinue) { + Copy-Item -LiteralPath $ofl.FullName -Destination (Join-Path $Docs "OFL-$($ofl.Directory.Name).txt") + $HasCraftFontLicences = 1 + } +} +# ---- end font notices for the MSI -------------------------------------------------------------- + # ---- MSI --------------------------------------------------------------------------------------- $Msi = Join-Path $Dist "cadcraft-$Version-windows-$Arch.msi" Invoke-Native 'wix build' { wix build (Join-Path $PSScriptRoot 'cadcraft.wxs') -arch $Arch -ext WixToolset.UI.wixext ` -d "Version=$MsiVersion" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root 'assets\app-icon\cadcraft.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.