From f2b35e957e208cac1fe72182db214786ad6ffa8b Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sun, 25 Oct 2026 08:48:48 -0600 Subject: [PATCH] Migrate CADCraft to egui 0.37 (#2119) * egui037: migrate CADCraft UI and preserve font notices * M0: clarify screenshot cancellation after issue * Document migration UI contracts and regression assertions --- .github/workflows/ci.yml | 2 +- .github/workflows/freebsd.yml | 2 +- .github/workflows/release.yml | 2 +- ATTRIBUTION.md | 5 + Cargo.lock | 298 ++-- Cargo.toml | 49 +- NOTICE | 34 +- README.md | 3 +- apps/cadcraft-web/Cargo.toml | 3 +- apps/cadcraft-web/src/host.rs | 3 +- apps/cadcraft-web/src/main.rs | 18 +- apps/cadcraft-web/src/upload.rs | 3 +- apps/cadcraft/Cargo.toml | 3 +- apps/cadcraft/src/main.rs | 40 +- apps/cadcraft/src/native_menu.rs | 30 +- crates/engine/src/cmd/file.rs | 10 + crates/engine/src/lib.rs | 14 + crates/engine/tests/it/compare_export.rs | 2 + crates/fonts/src/complex.rs | 10 +- crates/ui-egui/Cargo.toml | 3 + crates/ui-egui/src/aliasdlg.rs | 15 +- crates/ui-egui/src/animation.rs | 12 +- crates/ui-egui/src/autosave.rs | 4 +- crates/ui-egui/src/bedit.rs | 8 +- crates/ui-egui/src/canvas.rs | 4 +- crates/ui-egui/src/canvas/blockedit.rs | 2 +- crates/ui-egui/src/canvas/compare.rs | 2 +- crates/ui-egui/src/capture.rs | 1046 ++++++++++++ crates/ui-egui/src/chrome.rs | 31 +- crates/ui-egui/src/chrome/annoscale.rs | 2 +- crates/ui-egui/src/clipboard.rs | 4 +- crates/ui-egui/src/closing.rs | 2 +- crates/ui-egui/src/cmdline.rs | 7 +- crates/ui-egui/src/context_menu.rs | 4 +- crates/ui-egui/src/control.rs | 191 ++- crates/ui-egui/src/credits.rs | 2 +- crates/ui-egui/src/dialogs.rs | 57 +- crates/ui-egui/src/docking.rs | 5 +- crates/ui-egui/src/dyngrips.rs | 2 +- crates/ui-egui/src/exchange.rs | 10 +- crates/ui-egui/src/hatchdlg.rs | 9 +- crates/ui-egui/src/i18n/tests.rs | 38 +- crates/ui-egui/src/icons.rs | 8 +- crates/ui-egui/src/layers.rs | 32 +- crates/ui-egui/src/lib.rs | 435 +++-- crates/ui-egui/src/managers.rs | 56 +- crates/ui-egui/src/managers/battman.rs | 26 +- crates/ui-egui/src/mtext_editor.rs | 279 +++- crates/ui-egui/src/opening.rs | 2 +- crates/ui-egui/src/options/dialog.rs | 163 +- crates/ui-egui/src/palettes.rs | 53 +- crates/ui-egui/src/palettes/patterns.rs | 7 +- crates/ui-egui/src/palettes/quickcalc.rs | 38 +- crates/ui-egui/src/panel_state.rs | 76 + crates/ui-egui/src/parametric.rs | 53 +- crates/ui-egui/src/plotstyles.rs | 11 +- crates/ui-egui/src/quick.rs | 35 +- crates/ui-egui/src/spacemouse.rs | 3 +- crates/ui-egui/src/theme.rs | 22 +- crates/ui-egui/src/viewcube.rs | 8 +- crates/ui-egui/src/vpscale.rs | 4 +- crates/ui-egui/src/xrefs.rs | 10 +- crates/ui-egui/tests/accessibility_names.rs | 100 ++ crates/ui-egui/tests/it/animation_effects.rs | 11 +- crates/ui-egui/tests/it/docking_workflows.rs | 4 +- .../ui-egui/tests/it/hidden_command_line.rs | 2 +- crates/ui-egui/tests/it/lisp_files.rs | 5 +- crates/ui-egui/tests/it/menubar_hover.rs | 3 +- crates/ui-egui/tests/it/options_dialog.rs | 12 +- crates/ui-egui/tests/it/parameters_rename.rs | 10 +- crates/ui-egui/tests/it/properties_units.rs | 6 +- crates/ui-egui/tests/it/shared_controls.rs | 4 +- crates/ui-egui/tests/options_followups.rs | 9 +- docs/control-protocol.md | 4 +- docs/egui-037-migration-plan.md | 1475 +++++++++++++++++ packaging/env.sh | 2 +- .../linux/flatpak/ai.storyteller.cadcraft.yml | 2 + packaging/linux/package.sh | 1 - packaging/macos/package.sh | 2 + packaging/windows/cadcraft.wxs | 11 + packaging/windows/package.ps1 | 18 +- 81 files changed, 4364 insertions(+), 629 deletions(-) create mode 100644 crates/ui-egui/src/capture.rs create mode 100644 crates/ui-egui/src/panel_state.rs create mode 100644 crates/ui-egui/tests/accessibility_names.rs create mode 100644 docs/egui-037-migration-plan.md 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/ATTRIBUTION.md b/ATTRIBUTION.md index 264c7430f..99990cbab 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -23,6 +23,11 @@ Generated-in-code assets are original and have no file to list: | Asset | Author | Source | Licence | Notes | |---|---|---|---|---| +| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 173cc737](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset; emoji-icon-font.ttf is the full monochrome fallback. Registered by the production default font definitions; dependency assets unmodified here, no font file copied into this repository. | +| `krilla 0.8.2 / icc/sGrey-v2-magic.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 7895f5e vendor/krilla/icc/sGrey-v2-magic.icc](https://github.com/storytold/craft-libs/blob/7895f5ed79da2f1271b10139cb84d61d4cbc50ca/vendor/krilla/icc/sGrey-v2-magic.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `4110169287e5f1b5149ca9f1da1814d32943905bba8030e849d1a3b729a19481`. | +| `krilla 0.8.2 / icc/sGrey-v4.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 7895f5e vendor/krilla/icc/sGrey-v4.icc](https://github.com/storytold/craft-libs/blob/7895f5ed79da2f1271b10139cb84d61d4cbc50ca/vendor/krilla/icc/sGrey-v4.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `00c0f94e09127520a17dc0e1d9264b5702081d96dbdb1549ee88e3631ce42a9d`. | +| `krilla 0.8.2 / icc/sRGB-v2-magic.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 7895f5e vendor/krilla/icc/sRGB-v2-magic.icc](https://github.com/storytold/craft-libs/blob/7895f5ed79da2f1271b10139cb84d61d4cbc50ca/vendor/krilla/icc/sRGB-v2-magic.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `af4efe28f6d311799865f325ba39184a2e978b113ca74124d60af7de22b105f4`. | +| `krilla 0.8.2 / icc/sRGB-v4.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 7895f5e vendor/krilla/icc/sRGB-v4.icc](https://github.com/storytold/craft-libs/blob/7895f5ed79da2f1271b10139cb84d61d4cbc50ca/vendor/krilla/icc/sRGB-v4.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `c56e1685d888f5edb92fe07f2750f387f8fe8e91b32ff8fb0b56bfbbb9458353`. | | `assets/app-icon/hicolor/128x128/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon | | `assets/app-icon/hicolor/16x16/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon | | `assets/app-icon/hicolor/24x24/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon | diff --git a/Cargo.lock b/Cargo.lock index 19fb936c9..6ab372714 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", ] @@ -736,6 +732,7 @@ dependencies = [ "serde", "serde_json", "toml", + "web-time", ] [[package]] @@ -959,12 +956,12 @@ checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" +source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" +source = "git+https://github.com/storytold/craft-libs?rev=7895f5ed79da2f1271b10139cb84d61d4cbc50ca#7895f5ed79da2f1271b10139cb84d61d4cbc50ca" dependencies = [ "hayro-syntax", "krilla", @@ -974,7 +971,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" +source = "git+https://github.com/storytold/craft-libs?rev=650b1c0b058017c4eb3af06d5b39586d6732e07b#650b1c0b058017c4eb3af06d5b39586d6732e07b" dependencies = [ "egui", "log", @@ -1113,9 +1110,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=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "emath", @@ -1124,9 +1120,8 @@ dependencies = [ [[package]] name = "eframe" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1149,6 +1144,7 @@ dependencies = [ "profiling", "raw-window-handle", "ron", + "send_wrapper", "serde", "static_assertions", "wasm-bindgen", @@ -1161,9 +1157,8 @@ dependencies = [ [[package]] name = "egui" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit", "ahash", @@ -1183,9 +1178,8 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1203,32 +1197,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=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit_winit", "arboard", + "block2 0.6.2", "bytemuck", "egui", + "image", "log", "objc2 0.6.5", + "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", + "parking_lot", "profiling", "raw-window-handle", "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=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "egui", @@ -1240,9 +1237,8 @@ dependencies = [ [[package]] name = "egui_kittest" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "dify", "eframe", @@ -1267,9 +1263,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=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "serde", @@ -1329,9 +1324,8 @@ dependencies = [ [[package]] name = "epaint" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1348,16 +1342,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=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" [[package]] name = "equivalent" @@ -1443,6 +1438,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" @@ -1469,6 +1470,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" @@ -1650,12 +1657,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", @@ -1810,6 +1817,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" @@ -1818,7 +1834,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -1827,7 +1843,7 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -1839,7 +1855,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" [[package]] name = "hayro-jbig2" version = "0.3.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" +source = "git+https://github.com/storytold/craft-libs?rev=7895f5ed79da2f1271b10139cb84d61d4cbc50ca#7895f5ed79da2f1271b10139cb84d61d4cbc50ca" dependencies = [ "hayro-ccitt", ] @@ -1853,7 +1869,7 @@ checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1" [[package]] name = "hayro-syntax" version = "0.7.2" -source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" +source = "git+https://github.com/storytold/craft-libs?rev=7895f5ed79da2f1271b10139cb84d61d4cbc50ca#7895f5ed79da2f1271b10139cb84d61d4cbc50ca" dependencies = [ "hayro-jbig2", "hayro-jpeg2000", @@ -2188,18 +2204,18 @@ 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]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" +source = "git+https://github.com/storytold/craft-libs?rev=7895f5ed79da2f1271b10139cb84d61d4cbc50ca#7895f5ed79da2f1271b10139cb84d61d4cbc50ca" dependencies = [ "base64", "bumpalo", @@ -2531,6 +2547,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" @@ -2928,6 +2953,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" @@ -2988,6 +3023,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" @@ -2999,33 +3045,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", @@ -3042,9 +3088,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", ] @@ -3323,7 +3369,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4edc8db0d972de88e53a085b1f01fb3fbdae7bb0cb7a0ce2b2f8285a412fe3ef" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "font-types 0.12.7", "once_cell", "yoke", @@ -3506,6 +3552,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" @@ -4041,6 +4093,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" @@ -4143,9 +4206,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", @@ -4158,9 +4221,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", @@ -4822,8 +4885,7 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winit" version = "0.30.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +source = "git+https://github.com/storytold/winit.git?rev=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc" dependencies = [ "ahash", "android-activity", @@ -4895,6 +4957,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 = "write-fonts" version = "0.48.1" @@ -5261,3 +5341,23 @@ dependencies = [ "syn 3.0.6", "winnow", ] + +[[patch.unused]] +name = "egui_demo_lib" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_extras" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_inspection" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_system_font_provider" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" diff --git a/Cargo.toml b/Cargo.toml index 33b31978b..293a1b367 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = ["crates/*", "apps/*", "xtask"] version = "0.13.0" edition = "2024" license = "MIT OR Apache-2.0" -rust-version = "1.90" +rust-version = "1.95" repository = "https://github.com/storytold/cadcraft" [workspace.dependencies] @@ -25,10 +25,10 @@ cadcraft-engine = { path = "crates/engine" } cadcraft-ui-egui = { path = "crates/ui-egui" } cadcraft-mcp = { path = "crates/mcp" } # Which paths stored in a drawing (xrefs, data links) may be opened. -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } # PDF writing (krilla) for plots, and reading them back in tests. The root patches below pin # craft-pdf's patched backends to the same revision (Cargo ignores dependency-local patches). -craft-pdf = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae", default-features = false } +craft-pdf = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "7895f5ed79da2f1271b10139cb84d61d4cbc50ca", default-features = false } # foundation thiserror = "2" @@ -53,19 +53,18 @@ encoding_rs = "0.8.35" quick-xml = "0.42" # ui -craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae", features = ["serde"] } -egui = "0.36" -egui_kittest = "0.36" -eframe = { version = "0.36", default-features = false } -egui-wgpu = "0.36" +# 650b1c0 is the reviewed craft-libs integration: 25da's egui-0.37 port plus 6e7f's +# shared docking `Workspace`/`DockCommand`. Raw 6e7f is egui 0.36 and is not consumable here. +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "650b1c0b058017c4eb3af06d5b39586d6732e07b", 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 } -[patch.crates-io] -krilla = { version = "=0.8.2", git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } -hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } -hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } - [workspace.lints.rust] unsafe_code = "forbid" @@ -88,3 +87,27 @@ opt-level = 3 lto = "thin" codegen-units = 16 debug = 0 + +# PDF backends for craft-pdf: pinned to the same reviewed craft-libs revision as craft-pdf itself +# (Cargo ignores dependency-local patches). +[patch.crates-io] +krilla = { version = "=0.8.2", git = "https://github.com/storytold/craft-libs", rev = "7895f5ed79da2f1271b10139cb84d61d4cbc50ca" } +hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "7895f5ed79da2f1271b10139cb84d61d4cbc50ca" } +hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "7895f5ed79da2f1271b10139cb84d61d4cbc50ca" } + +# Keep the complete egui family and native event bridge on reviewed fork commits. +ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" } diff --git a/NOTICE b/NOTICE index 22163da72..811da09b1 100644 --- a/NOTICE +++ b/NOTICE @@ -15,10 +15,36 @@ Mozilla Public License 2.0 and is used unmodified as a separate library (crates/ is available from crates.io and the repository above. -None: every bundled asset is original to CADCraft (icons, drafting font, hatch patterns and -linetypes are drawn or defined in code). The per-file list is in ATTRIBUTION.md. Builds made with -the optional craft-fonts input (CRAFT_FONTS_DIR) embed the fonts listed in its ATTRIBUTION.md, -each under the SIL Open Font License 1.1. +CADCraft's icons, drafting font, hatch patterns and linetypes are drawn or defined in code. +The UI also bundles the egui font inputs described below. The per-file list is in ATTRIBUTION.md. +Builds made with the optional craft-fonts input (CRAFT_FONTS_DIR) embed the fonts listed in its +ATTRIBUTION.md, each under the SIL Open Font License 1.1. CADCraft is an independent project and is not affiliated with, sponsored by or endorsed by Autodesk, Inc. AutoCAD and DWG are trademarks of Autodesk, Inc. + + +The PDF writer in the pinned craft-pdf 0.1.0 / Krilla 0.8.2 dependency embeds four Compact ICC +Profiles by Clinton Ingram, under CC0-1.0. They are unmodified dependency assets; no profile file +is copied into this repository. Per-file sources and SHA-256 hashes are in ATTRIBUTION.md. +Source: https://github.com/LaurenzV/krilla/tree/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc +(pinned through https://github.com/storytold/craft-libs/tree/7895f5ed79da2f1271b10139cb84d61d4cbc50ca/vendor/krilla/icc). +CC0 1.0 Universal: https://creativecommons.org/publicdomain/zero/1.0/ +Pinned licence text: https://github.com/storytold/craft-libs/blob/7895f5ed79da2f1271b10139cb84d61d4cbc50ca/vendor/krilla/icc/LICENSE + +The UI's egui-icons.ttf subset and emoji-icon-font.ttf fallback are provided by epaint_default_fonts +and registered through egui's default font definitions. They derive from emoji-icon-font by +John Slegers, under the MIT License. The subset and full face share the following upstream notice. +Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts +Original: https://github.com/jslegers/emoji-icon-font +The following text is reproduced verbatim from emoji-icon-font-mit-license.txt: + +MIT License + +Copyright (c) 2014 John Slegers + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index 72fcdc7f8..dd0abd25d 100644 --- a/README.md +++ b/README.md @@ -123,8 +123,7 @@ numbers): ## Quick start -Desktop and web UI builds require Rust 1.95 or later. The core workspace's declared minimum -remains Rust 1.90. +CADCraft requires Rust 1.95 or later. ```sh git clone https://github.com/storytold/cadcraft 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 035e1d6cc..f69e02d8a 100644 --- a/apps/cadcraft-web/src/main.rs +++ b/apps/cadcraft-web/src/main.rs @@ -41,18 +41,22 @@ mod web { /// document title when it changed (so the tab and an embedding page name the open /// drawing), and reports unsaved changes to that page. 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); let drawing = self.0.session.state().ok(); crate::push_title(&mut self.4, drawing.map(|d| d.title.as_str()), drawing.is_some_and(|d| d.is_dirty()), set_page_title); + // `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); } } + /// Forward raw input to the web `CadApp` and capture Tab at the command line. 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. @@ -63,11 +67,15 @@ mod web { fn persist_egui_memory(&self) -> bool { false } + /// On the first pass consume files dropped on the page, then delegate drawing to `CadApp::ui`. 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 e80b838b1..6c9af80a8 100644 --- a/apps/cadcraft/src/main.rs +++ b/apps/cadcraft/src/main.rs @@ -65,37 +65,47 @@ fn follow_desktop(raw: &mut egui::RawInput, desktop: Option) { } impl eframe::App for App { + /// eframe logic callback: syncs the OS animation setting and runs `CadApp::logic`; when the pass index is 0 (the first pass or a hidden tick) it also installs/polls the macOS native menu, polls the spacemouse and persists the workspace; a quit request closes the viewport. 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); } } + /// Apply the desktop-theme fallback, forward raw input to `CadApp`, and capture Tab at the command line. 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. @@ -232,6 +242,7 @@ fn control_flag(current: Option, value: Option) -> Option { current } +/// Parse CLI options (`--control`, `--sample`, files, `--version`), install logging and I/O hooks, build the services and app, and run eframe. fn main() -> eframe::Result { // First, so every start-up record is captured; see `logging`. let logger = logging::install(); @@ -280,6 +291,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..523d38a52 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, @@ -30,6 +33,7 @@ fn accel(sc: &str) -> Option { } impl NativeMenu { + /// Build and register the macOS menu (app menu, Settings and the translated command tree); returns the state holding item ids and submenus. pub fn install(app: &mut CadApp) -> Self { let menu = Menu::new(); let mut items = HashMap::new(); @@ -66,7 +70,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 @@ -87,6 +91,7 @@ impl NativeMenu { build(&sub, &menus::recent_entries(app), &mut self.items, &mut self.submenus, &mut self.counter, self.language); } + /// Drain queued menu events into commands (Discord opens externally), rebuild Open Recent, retranslate on a language change and refresh enablement when 300 ms have elapsed. pub fn poll(&mut self, app: &mut CadApp, ctx: &egui::Context) { while let Ok(ev) = MenuEvent::receiver().try_recv() { if let Some((cmd, _, _)) = self.items.get(ev.id.as_ref()) { @@ -113,7 +118,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 +129,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 +162,17 @@ fn build( } } } + +#[cfg(test)] +mod tests { + use super::refresh_due; + + /// `refresh_due` treats `NEG_INFINITY` as due at any finite time and `0.0` as due once 300 ms have elapsed; `install` initializes a fresh menu with `last_refresh = NEG_INFINITY`. + #[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 3bb9f870d..391bf1ca9 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -362,6 +362,7 @@ fn run_open(s: &mut Session, p: &Value) -> Result { Ok(opened(s, i, false)) } +/// `close [index]`: cancel the running command, remove the drawing (default: active), keep the active index valid, and bump the document epoch; an unknown index is an error. fn run_close(s: &mut Session, p: &Value) -> Result { let i = p.get("index").and_then(Value::as_u64).map(|i| i as usize).unwrap_or(s.active); if i >= s.docs.len() { @@ -376,13 +377,16 @@ 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() } +/// `closeall`: cancel the running command, remove every drawing, reset the active index to 0 and bump the document epoch. fn run_closeall(s: &mut Session, _p: &Value) -> Result { s.cancel(); s.docs.clear(); s.active = 0; + s.bump_doc_epoch(); ok() } @@ -487,6 +491,7 @@ fn drawing_version(s: &Session, path: &str) -> Version { if is_dxf(path) { v } else { v.max(Version::R2004) } } +/// `document.switch index`: require the index, cancel the running command, activate that drawing and bump the epoch even if it was already active (screenshot identity). fn run_switch(s: &mut Session, p: &Value) -> Result { let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("document.switch", "`index` is required"))? as usize; if i >= s.docs.len() { @@ -494,6 +499,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 e37aadbdd..c1eb5a6cc 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -647,6 +647,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. @@ -727,6 +730,7 @@ impl Session { Session { docs: Vec::new(), active: 0, + doc_epoch: 0, settings: Settings::default(), running: None, in_script: false, @@ -759,6 +763,7 @@ impl Session { hatch_picks: None, } } + /// Append a new untitled drawing (`DrawingN`, metric or imperial default view), end a running command, make it active, bump the document epoch and return its index. pub fn new_drawing(&mut self, metric: bool) -> usize { self.untitled_counter += 1; let d = if metric { Drawing::new_metric() } else { Drawing::new_imperial() }; @@ -767,8 +772,10 @@ impl Session { self.end_running(); self.docs.push(st); self.active = self.docs.len() - 1; + self.bump_doc_epoch(); self.active } + /// Append a loaded drawing, fit the view to its model extents (aspect-corrected, minimum height 1e-6) when non-empty, end a running command, make it active, bump the epoch and return its index. pub fn open_drawing(&mut self, d: Drawing, title: &str, path: Option) -> usize { let mut st = DocState::new(d, title, path); let ext = st.doc.extents(&Space::Model); @@ -781,8 +788,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) { @@ -800,6 +813,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/engine/tests/it/compare_export.rs b/crates/engine/tests/it/compare_export.rs index 79d28737e..1aee1b9db 100644 --- a/crates/engine/tests/it/compare_export.rs +++ b/crates/engine/tests/it/compare_export.rs @@ -86,8 +86,10 @@ const GRAY: Color = Color::Index(8); fn exports_both_drawings_as_colored_groups_in_a_new_drawing() { let mut s = compared(); let before = s.docs.len(); + let before_epoch = s.doc_epoch; let source = s.doc().unwrap().clone(); let r = s.execute("compareexport", &json!({})).unwrap(); + assert!(s.doc_epoch > before_epoch, "opening the exported drawing invalidates queued captures"); assert_eq!(s.docs.len(), before + 1, "{r}"); assert_eq!(r["title"], "compare_B vs Drawing1.dwg", "{r}"); assert_eq!(s.state().unwrap().title, "compare_B vs Drawing1.dwg"); 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..6caded5f8 100644 --- a/crates/ui-egui/src/aliasdlg.rs +++ b/crates/ui-egui/src/aliasdlg.rs @@ -143,6 +143,7 @@ pub fn apply(app: &mut CadApp) -> bool { } } +/// Command Aliases window that edits the in-memory alias set in `app.alias_dlg`; OK/Apply send the differences to ALIASEDIT, read the resulting aliases and source file back, and show any error. pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { if app.alias_dlg.is_none() { crate::aliasdlg::open(app); @@ -151,7 +152,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 +162,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 +227,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 390c40a1f..81c79bbf8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1362,7 +1362,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)); @@ -2846,7 +2846,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 0f1ef4bba..c545a1d28 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..4bca367b4 --- /dev/null +++ b/crates/ui-egui/src/capture.rs @@ -0,0 +1,1046 @@ +//! 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 { + /// `Some(size)` only when both axes are finite and strictly positive; rejects degenerate viewport sizes before they enter a `SurfaceGeometry` snapshot. + 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 }) + } + + /// The pixel dimensions this surface would capture at: viewport points × pixels-per-point, rounded, each axis clamped to `max_texture_side` (0 means unlimited). + /// `None` when the viewport is unknown/non-finite or an axis overflows `usize`. + 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)?]) + } + + /// Raw `Color32` bytes for `expected_size()` (4 bytes per pixel), with checked multiplication; `None` on overflow. + 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 { + /// Pixel dimensions of the bound surface, or `None` when it cannot be estimated. + fn expected_size(self) -> Option<[usize; 2]> { + self.surface.expected_size() + } + + /// Raw image byte count of the bound surface, or `None` when it cannot be estimated. + 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 { + /// True when no queued, in-flight or completed-but-undrained request remains. + pub(crate) fn is_empty(&self) -> bool { + self.requests.is_empty() + } + + /// True when at least one admitted request has not been issued yet; drives the hidden-window repaint request. + pub(crate) fn has_unissued(&self) -> bool { + self.requests.iter().any(|shot| !shot.issued) + } + + /// Saturating sum of the byte reservations of all outstanding requests. + fn reserved(&self) -> usize { + self.requests.iter().fold(0usize, |sum, shot| sum.saturating_add(shot.reserved)) + } + + /// Test-only count of outstanding request records. + #[cfg(test)] + fn len(&self) -> usize { + self.requests.len() + } +} + +/// Advance the per-app request token, wrapping and skipping 0. +fn next_token(shots: &mut Shots) -> u64 { + let mut token = shots.token.wrapping_add(1); + if token == 0 { + token = 1; + } + shots.token = token; + token +} + +/// The JSON rejection envelope `{ok:false,error,request}` used by admission, issuance, drain and cancellation rejections; `control::save_screenshot` builds an identical envelope for its own failures. +fn error_reply(token: u64, message: impl std::fmt::Display) -> Value { + json!({"ok": false, "error": message.to_string(), "request": token}) +} + +/// Send an `error_reply` to `reply`; a closed receiver is ignored so admission never blocks. +fn reject(reply: Sender, token: u64, message: impl std::fmt::Display) { + let _ = reply.send(error_reply(token, message)); +} + +/// UID of the active drawing, or `None` when no drawing is open. +fn active_document(app: &CadApp) -> Option { + app.session.docs.get(app.session.active).map(|doc| doc.uid) +} + +/// The widget egui currently reports as keyboard-focused, if any. +fn current_focus(ctx: &egui::Context) -> Option { + ctx.memory(|memory| memory.focused()) +} + +/// Snapshot the active drawing UID, the monotonic document epoch and the focused widget together. +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(); +} + +/// Human-readable reason when the live document, focus or document epoch differs from the shot's admission identity; `None` while all still match. +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; + + /// An empty `CadApp` over a fresh `Session` for capture tests. + fn app() -> CadApp { + CadApp::new(Session::new(), Services::default()) + } + + /// A visible, focused, 1-ppp surface of `size` with an 8192-pixel texture side. + 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); + } + + /// Seed 800×600 geometry, admit one pathless request and return its reply receiver and token. + 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) + } + + /// Focused `RawInput` with a `width`×`height` screen rect and an 8192-pixel texture side. + 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) + } + + /// Run one real `logic` + `ui` pass at the requested size and drop texture deltas, yielding the frame's output. + 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 + } + + /// Every `ViewportCommand::Screenshot` callback emitted by a frame. + 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) + } + + /// Complete each screenshot callback with a solid red image of `size`. + 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)); + } + } + + /// A completed-but-undrained shot still occupies a `MAX_REQUESTS` slot; the next admission is rejected as "outstanding" and no earlier token is replaced. + #[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())); + } + + /// A completed callback whose deadline has passed is rejected: the reply reports "timeout" and the request is removed from `Shots`; the test does not inspect the filesystem. + #[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()); + } + + /// A drawing switch before issue rejects the queued request as "document changed" and empties the queue. + #[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()); + } + + /// Moving keyboard focus before issue rejects the queued capture as "focus changed" and empties the queue. + #[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()); + } + + /// Two requests issue from one settled frame; each reply carries its own token and a distinct, readable file with a PNG signature. + #[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()); + } + + /// A drawing switch while a capture is in flight rejects its completed callback as "document changed" and reports no result path. + #[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()); + } + + /// Once a callback has completed but the deadline has passed, drain rejects it as "timeout" with no result path. + #[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()); + } + + /// Cancelling all shots replies "cancelled" and wins over an already-completed callback, which is never reported. + #[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()); + } + + /// A 5000×5000 image is rejected by the 64 MiB per-image bound; after two 2500×2500 admissions a third is rejected by the aggregate bound, leaving two queued. + #[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); + } + + /// Seed an arbitrary surface size, admit one pathless request and return the newly queued request's receiver and token. + 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) + } + + /// Every observed surface change, including A→B→A, bumps the surface epoch and retains the latest geometry. + #[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}"); + } + + /// With a screen rect but no inner rect, 640×480 points at 2 ppp yields a `[1280, 960]` image and keeps the focused flag. + #[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 b30a0110f..893379f35 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() { @@ -238,7 +238,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; } @@ -247,7 +248,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); @@ -314,7 +315,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)); @@ -368,7 +369,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); @@ -417,7 +418,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() { @@ -433,7 +434,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() @@ -448,7 +449,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)); } @@ -489,13 +490,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() { @@ -541,7 +542,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() { @@ -555,7 +556,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 bd1d30148..9e4f35ed8 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(crate::tl!("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..e9e0db8c7 100644 --- a/crates/ui-egui/src/cmdline.rs +++ b/crates/ui-egui/src/cmdline.rs @@ -395,6 +395,7 @@ fn visible_lines(app: &CadApp, canvas: Rect, bar: Rect) -> usize { app.ui.history_lines.min(MAX_LINES).min(available) } +/// Draw the floating command bar and its history over the bottom-centre of `canvas`; the bar is 48 % of the canvas width, clamped to 360–760 points and never wider than the canvas minus 8 points. pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) { let t = Tokens::get(); let w = (canvas.width() * 0.48).clamp(360.0, 760.0).min((canvas.width() - 8.0).max(0.0)); @@ -443,7 +444,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 +524,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 +587,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 b66c0919c..2dc1f898a 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -102,8 +102,9 @@ struct Widget { selected: Option, } +/// egui temp-data key holding the widgets observed during the current frame. 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) { @@ -252,6 +253,7 @@ pub fn cmdline_state(app: &CadApp) -> Value { }) } +/// Build the `ui.inspect` snapshot: UI state and theme, animations, focus, canvas/window rects, view, cursor, snap, session summary, close prompt and performance counters. pub fn inspect(app: &CadApp, ctx: &egui::Context) -> Value { let r = ctx.content_rect(); let view = app.session.state().map(|s| s.view()).ok(); @@ -267,7 +269,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 +545,138 @@ 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) +} + +/// Process-wide monotonic counter used to keep pathless screenshot names unique. +fn next_pathless_nonce() -> u64 { + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(0); + NEXT.fetch_add(1, Ordering::Relaxed) +} + +/// Create at most 32 exclusive `cadcraft-shots---` directories in the temp dir; only `AlreadyExists` is retried. +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")) +} + +/// Create `path` with mode 0700 on Unix (plain create elsewhere), failing rather than following or reusing a pre-created entry. +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) +} + +/// True when the metadata has no group/other permission bits (always true on non-Unix). +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 + } +} + +/// Save a pathless screenshot into the private per-process directory: re-validate or recreate it as an owner-only real directory (0700 on Unix; plain create elsewhere), then create up to 64 exclusive files (0600 on Unix) and write a PNG. +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 +732,55 @@ mod tests { assert_eq!(pending.wait(Duration::ZERO), json!({"ok": true})); } + /// Two pathless saves with the same token produce distinct, decodable PNGs; on Unix the files are 0600 and their directory 0700. + #[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); + } + } + + /// `create_new` refuses a pre-existing symlink with `AlreadyExists` and leaves the link target unchanged. + #[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 2a46d7ed7..37de4f197 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -39,10 +39,11 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) { } } +/// Drafting Settings window: snap/grid modes and spacing, isometric snap, polar tracking and object snaps, editing the session settings directly. 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 +53,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 +66,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 +85,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 +163,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) { @@ -250,7 +253,7 @@ fn length_precisions(lunits: i64) -> Vec<(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() @@ -314,11 +317,12 @@ fn units(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { } } +/// About window with About, Contributors and Models tabs, version, licence and project links. 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) @@ -351,16 +355,17 @@ fn about(ctx: &egui::Context, open: &mut bool) { }); } +/// Command Reference window; filters commands by id or label (English or translated) and starts the clicked command after closing the dialog. 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(); @@ -391,9 +396,10 @@ fn commands(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { } } +/// Blocks window listing named (non-anonymous) blocks; clicking one starts INSERT with that block name already supplied. 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() { @@ -422,8 +428,9 @@ fn insert_block(app: &mut CadApp, name: &str) { } } +/// Interface Language combo; selecting a language runs `ui.language` with its code. 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::for_picker() { 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 6b9cedbe9..6c1e624b5 100644 --- a/crates/ui-egui/src/docking.rs +++ b/crates/ui-egui/src/docking.rs @@ -213,6 +213,7 @@ pub fn command(app: &mut crate::CadApp, reset: bool, params: &Value) -> Result(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 7ece54259..48a615003 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..1fff4c627 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); @@ -478,6 +478,7 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { } } +/// Pattern/gradient fill selector and its controls; an origin pick is reported through `action`. fn fill_ui(ui: &mut egui::Ui, f: &mut Fill, action: &mut Action) { ui.horizontal(|ui| { ui.selectable_value(&mut f.gradient, false, crate::tl!("Pattern")); @@ -487,12 +488,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 +854,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..3bb2690a1 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,28 @@ fn korean_ui_fonts_cover_the_language_picker_in_both_families() { }); assert!(missing.is_empty(), "missing Korean picker glyphs: {}", missing.join(", ")); } + +/// The production font chain covers alphanumeric ASCII in proportional and monospace, plus ⌘ ⇧ ↩ … − in proportional and ⇧ in monospace. +#[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..d9bc0d833 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)), @@ -403,6 +405,8 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { } } +/// One Layer Properties Manager row: status/name, on/freeze/lock/plot, colour, linetype, lineweight, transparency, viewport overrides, new-viewport freeze and description. +/// Edits update `st` and push commands into `actions`. #[allow(clippy::too_many_arguments)] fn row( ui: &mut egui::Ui, @@ -439,7 +443,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 +509,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 +530,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 +586,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 +617,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 +638,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 +659,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 +758,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 a627baf08..d3799dc36 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; @@ -220,15 +222,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. @@ -261,6 +276,7 @@ pub struct CadApp { } impl CadApp { + /// Construct `CadApp` from a session and services with default UI state, options, canvas and command line; no control channel, capture geometry or pending shots are installed. pub fn new(session: Session, services: Services) -> Self { CadApp { system_languages: Vec::new(), @@ -276,13 +292,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, @@ -432,6 +452,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. @@ -548,22 +573,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()); @@ -591,59 +715,34 @@ 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(); + } } + /// Draw one UI pass: title/toolbar, optional menu/status bar/file tabs, the standalone tool-set strip when a drawing is shown but Tool Sets is not docked, docking or the start page, and the floating overlays; record `frame_ms`. Runs on every pass, including one egui later discards. fn draw(&mut self, ui: &mut egui::Ui) { let t0 = now_ms(); control::begin_frame(ui.ctx()); @@ -669,6 +768,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()); @@ -677,6 +777,7 @@ impl CadApp { self.frame_ms = now_ms() - t0; } + /// Drain each queued control request at most once (timed-out requests are skipped), dispatch it to `control::handle` or `capture::admit`, note the resulting capture identity and request a repaint. fn drain_control(&mut self, ctx: &egui::Context) { let Some(rx) = self.control_rx.take() else { return }; while let Ok(req) = rx.try_recv() { @@ -689,85 +790,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. @@ -784,13 +831,141 @@ 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}"); + } + + /// A hidden logic-only tick runs no frame-only work: `ui_frames` stays 0 and fonts are not installed; a real `Ui` pass advances both. + #[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()); + } + + /// Repeated hidden logic-only ticks finish a long Lisp routine (`CC-HIDDEN` reaches 400000) while `ui_frames` stays 0. + #[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"); + } + + /// An injected synthetic event batch is delivered by the first raw hook, a second raw hook before a real `Ui` pass adds nothing, and a new batch is accepted after that pass. + #[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..712c02a61 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) { @@ -94,6 +96,7 @@ fn no_drawing(ctx: &egui::Context, title: &str, open: &mut bool) { // ---------- Linetype Manager ---------- +/// Linetype Manager: current linetype and global scale, loaded linetypes, and load/current actions; returns the command to run after drawing. fn linetype(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { let t = Tokens::get(); let Ok(d) = app.session.doc() else { @@ -172,8 +175,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 }))); } }); @@ -240,6 +243,7 @@ fn draw_point(p: &egui::Painter, c: egui::Pos2, r: f32, mode: i64, stroke: Strok } } +/// Point Style dialog for the PDMODE/PDSIZE presets; returns the `ddptype` action. fn point_style(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { let t = Tokens::get(); let Ok(d) = app.session.doc() else { @@ -275,8 +279,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 +419,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 +434,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(""); @@ -466,6 +470,7 @@ fn style_manager(ctx: &egui::Context, open: &mut bool, title: &str, cmd: &'stati // ---------- Page Setup Manager ---------- +/// Page Setup Manager over layouts sorted by tab order; returns the selected setup command. fn page_setup(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { let t = Tokens::get(); let Ok(d) = app.session.doc() else { @@ -502,7 +507,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 +671,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 +688,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 +960,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 +968,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; } @@ -1075,6 +1080,7 @@ fn sheet_sketch(ui: &mut egui::Ui, page: &cadcraft_doc::PageSetup, t: &Tokens) { // ---------- Constraint Settings ---------- +/// Constraint Settings dialog editing the drawing's parametric settings; returns the `constraintsettings` action. fn constraint_settings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { let t = Tokens::get(); let Ok(d) = app.session.doc() else { @@ -1096,8 +1102,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 +1130,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 3bd54a3cd..8258a2986 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(); @@ -186,6 +222,7 @@ fn set_flag(c: &mut CharFmt, f: Flag, on: bool) { } } +/// MTEXT formatting toolbar for the current selection: font, height, bold/italic/underline/overline, colour, alignment, lists, tab, degree/plus-minus and Diameter. Font, bold/italic/underline/overline, ByLayer/palette colour, alignment, list, tab and special-character changes refocus the editor; height and custom RGB colour changes do not. fn toolbar(ui: &mut egui::Ui, st: &mut State, base_h: f64) { let cur = current(st); let range = st.sel.0.min(st.sel.1)..st.sel.0.max(st.sel.1); @@ -194,21 +231,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(ToString::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(ToString::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 +264,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 +291,141 @@ 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, +} + +/// Glyph-only MTEXT toolbar button: paints `icon`, exposes `label` as its accessible name and tooltip, and returns whether it was clicked. +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 +477,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() @@ -374,6 +504,7 @@ fn ruler(ui: &mut egui::Ui, st: &mut State, px: f32, width: f32, base_h: f64) { bg.on_hover_text(crate::tl!("Click the ruler to add a tab stop, right-click to remove one")); } +/// MTEXT editor area with the formatted preview; restores a pending caret position before laying text out with `base_h`, `px`, `width` and the theme tokens. fn text_box(ui: &mut egui::Ui, st: &mut State, base_h: f64, px: f32, width: f32, t: &Tokens) { let id = edit_id(); if let Some(at) = st.caret.take() @@ -436,6 +567,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 +594,94 @@ 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; + /// Clicking Diameter inserts U+2300 into both the formatted text and `to_contents()`, selects it, and leaves no unnamed widget. + #[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()); + } + + /// The ten toolbar controls listed in the test are found by semantic name under the harness's default bundled fonts, and no toolbar widget is unnamed. + #[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 a839850d9..b0b2f8781 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 7c0a22af6..cfc1833f0 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)); @@ -135,36 +136,39 @@ fn preview_square(ui: &mut egui::Ui, side: f32, color: Color32) { p.rect_stroke(egui::Rect::from_center_size(rect.center(), vec2(side, side)), 0.0, Stroke::new(2.0, color), egui::StrokeKind::Middle); } +/// Display tab: interface theme, animation preference and scale, drawing-area colours, crosshair size, history length and the system-cursor option; edits accumulate in `changes`. 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 +176,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(); @@ -204,6 +214,7 @@ fn display(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { hint(ui, crate::tl!("Screen magnifiers, screen readers and recorders follow the system cursor.")); } +/// Files tab: save format and default version, new-drawing units, autosave interval and autosave file location; edits accumulate in `changes`. fn files(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { grid(ui, "options_files", |ui| { ui.label(crate::tl!("Save new drawings as")); @@ -220,14 +231,15 @@ 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())); } @@ -245,20 +257,24 @@ fn files(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { }); 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())); } @@ -275,23 +291,28 @@ fn files(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map) { hint(ui, crate::tl!("Drawings with no file yet are saved in the default version. 0 minutes turns automatic save off.")); } +/// Drafting tab: object-snap marker size and aperture previews plus the Drafting Settings entry; edits accumulate in `changes` and the entry may request a command through `start`. 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); @@ -308,17 +329,20 @@ fn drafting(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map, s }); } +/// Selection tab: pickbox and selection-mode controls, selection preview/area effects and the Visual Effect Settings window; edits accumulate in `changes`. 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); @@ -350,14 +374,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); @@ -370,10 +403,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")); @@ -391,14 +425,15 @@ 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()); +/// Strong (bold) label for section headings in the Options Selection tab and the Visual Effect Settings window. +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 @@ -406,7 +441,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) @@ -421,15 +456,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")); @@ -463,35 +502,41 @@ 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)); } }); } +/// Preferences tab: interface language, largest-array limit and time-sensitive right-click; edits accumulate in `changes`. fn preferences(app: &CadApp, ui: &mut egui::Ui, 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::for_picker() { 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..78e2d7e93 100644 --- a/crates/ui-egui/src/palettes.rs +++ b/crates/ui-egui/src/palettes.rs @@ -139,6 +139,7 @@ fn command_known(id: &str) -> bool { cadcraft_engine::find_command(id).is_some() || crate::menus::is_ui_command(id) } +/// Tool Sets strip with Drafting/Modeling tabs, their command buttons and the collapse control. pub fn toolsets(app: &mut CadApp, ui: &mut egui::Ui) { let t = Tokens::get(); egui::Frame::NONE.fill(t.panel).show(ui, |ui| { @@ -151,7 +152,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 +169,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 +203,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 +330,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() { @@ -338,6 +339,7 @@ pub fn toolsets_strip(app: &mut CadApp, ui: &mut egui::Ui) { }); } +/// Right palette panel (Layers/Blocks/Properties icon strip and collapse control) containing the Layers and Properties sections. pub fn right_palettes(app: &mut CadApp, ui: &mut egui::Ui) { if !app.ui.show_palettes { return; @@ -354,7 +356,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() { @@ -514,15 +516,17 @@ pub(crate) fn layers_section(app: &mut CadApp, ui: &mut egui::Ui) { } } +/// Properties row: a right-aligned translated label in a 106-point layout box that exposes a Label node to accessibility and names the control added by `add`. fn prop_row(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui)) { let t = Tokens::get(); ui.horizontal(|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 +616,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 +643,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 }; @@ -701,6 +705,7 @@ fn properties_header(app: &mut CadApp, ui: &mut egui::Ui) { } } +/// Properties palette: the selection's common properties, or the current defaults when nothing is selected; formats lengths per drawing units and dispatches setter commands. pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { let t = Tokens::get(); properties_header(app, ui); @@ -757,7 +762,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 +784,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 +810,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 +893,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 +929,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 +959,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 +984,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 +992,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 +1043,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 +1053,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 +1089,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 +1128,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 f573f701e..a4cabe1fd 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]`. @@ -208,6 +208,15 @@ 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); +} + +/// Quick Calculator dock body: expression entry with evaluation, variables and unit conversion. 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(); @@ -320,8 +329,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); @@ -365,3 +373,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..39ad79657 --- /dev/null +++ b/crates/ui-egui/src/panel_state.rs @@ -0,0 +1,76 @@ +//! 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::*; + + /// Test panel state `width` points wide and 26 points high. + fn state(width: f32) -> egui::PanelState { + egui::PanelState { outer_rect: egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 26.0)) } + } + + /// `migrate` copies the old root-id panel state into the 0.37 scoped key and leaves the old key in place for rollback. + #[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"); + } + + /// `migrate` never replaces a value already stored under the new scoped key. + #[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 bc3df8b0f..54727162c 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. @@ -41,6 +41,7 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { } } +/// Plot Style Manager window; returns the command (`stylesmanager` or `pagesetup`) to run after drawing. fn window(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { let t = Tokens::get(); let title = crate::tl!("Plot Style Manager"); @@ -65,7 +66,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| { @@ -197,8 +198,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 91f6740ea..6eb7c45b9 100644 --- a/crates/ui-egui/src/quick.rs +++ b/crates/ui-egui/src/quick.rs @@ -59,9 +59,10 @@ fn pretty(k: &str) -> String { out } +/// Quick Select window; caches `qselect.info` per drawing, apply-to choice and object type, then runs the selection with the chosen criteria. 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 +78,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 +142,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(ToString::to_string).collect() @@ -152,7 +153,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| { @@ -228,10 +230,11 @@ struct QpState { at: Option, } +/// Quick Properties numeric row: an expression or distance field labelled by `label`; commits only a finite value under `key` into `set`. 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 +250,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 +302,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 +312,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 +337,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 +372,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 +381,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 +394,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..3a9e2ba32 100644 --- a/crates/ui-egui/src/spacemouse.rs +++ b/crates/ui-egui/src/spacemouse.rs @@ -132,8 +132,9 @@ fn navigate(app: &mut CadApp, params: &Value) -> Result { Ok(json!({"center": [center.x, center.y], "height": height})) } +/// 3D Mouse window showing connection status, detected device/detail and the navigation settings, with a Calibrate action. 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 fe1fd43fb..9aeac31a1 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -307,8 +307,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 @@ -317,6 +319,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"))] @@ -341,6 +344,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; } } @@ -388,6 +396,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..2b5ff8cef --- /dev/null +++ b/crates/ui-egui/tests/accessibility_names.rs @@ -0,0 +1,100 @@ +//! 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; + +/// A 1400×900 kittest harness whose frame runs `logic` and then `ui` on the supplied app. +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() +} + +/// The Options ▸ Files autosave path field is labelled by its visible row label, and no text input in the dialog is unnamed. +#[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 1cffab495..18410ceae 100644 --- a/crates/ui-egui/tests/it/docking_workflows.rs +++ b/crates/ui-egui/tests/it/docking_workflows.rs @@ -325,7 +325,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); @@ -392,7 +392,7 @@ fn group_drag_bar_moves_the_whole_group_beside_the_drawing() { h.state_mut().run("ui.dock", json!({"operation":"move", "panel":"layers", "target":"toolSets", "zone":"center", "before":null})).unwrap(); h.run_steps(4); let bar = |h: &Harness<'_, CadApp>| { - DockArea::::hit_rects(&h.ctx, egui::Id::new("cadcraft-docking")) + DockArea::::hit_rects(&h.ctx, egui::Id::unique("cadcraft-docking")) .into_iter() .find(|(hit, _)| matches!(hit, Hit::DragBar(panel) if *panel == Panel::ToolSets || *panel == Panel::Layers)) .map(|(_, rect)| rect) 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 63e565eb6..1c3f30626 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 or keyboard focus changes while the request is outstanding. A queued request adopts the settled window size when issued. After issue, a change in window size, scale, OS window focus, minimization or occlusion cancels it. 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..9859dfe1a --- /dev/null +++ b/docs/egui-037-migration-plan.md @@ -0,0 +1,1475 @@ +# CADCraft — egui 0.37 migration plan + +Current reconciliation (2026-10-25): the local tree now incorporates main `e86d895ba1086a7efc2a83048348f89098282d08`. Managed run `egui037-cad-maine86-ci-20261025-0825` passed all seven CI stages: 2,515 passing test executions, zero failures and one ignored across 103 summaries. Independent review verified preservation of both parents and dependency pins; the compare-export regression now verifies document-epoch advancement. Earlier validation counts below describe prior candidates. Native/browser GUI, platform and paired performance checks remain open. Publication requires explicit approval; human review and human merge are required. + +**Status (2026-10-25):** reconciled main `b90165666917ec3ac6c308c2ea1c55bb6ea8317d` and passed all 7 mandatory CI stages. Managed job `egui037-cadcraft-main-refresh-ci-20261025-0615` recorded 2,451 passing test executions, zero failures and 1 ignored. The shared-docking adoption uses the reviewed craft-ui revision `650b1c0b058017c4eb3af06d5b39586d6732e07b`; the lock changed only that package and its dependency list. This revision is reachable from the published craft-libs migration branch. Native/platform GUI and IME workflows, final packages and paired app performance remain open. Earlier source identities and counts below are historical. + +Current landing status, 2026-10-25: the implementation integrates main `fcfdf33001cf2462bdafb6e1237becd8ef0ea1cb` and selects egui `6d518fc799681571963842f95b55a6b68431b15a`, winit `a517b3846ee8457d84ee4975110bfda6797c44dc`, shared UI `25da1314d443171b4adeaad2cd9454b273f35c6f` and independent PDF/document-path revisions in the lockfile. Full managed CI0300 passed all seven stages, with 2,448 passing tests, no failures and one ignored across 103 summaries. Fresh review0320 verified the tested source, all seven latest-main paths and all 13 new source files. + +The review also identified inherited upstream issues in MEASUREINIT reset/web startup and PDF font hardening; these are carried explicitly in the review packet. The egui licence-source URL has been updated to the selected revision; the font bytes are unchanged. Final notice validation is pending. Current-source Windows/macOS/FreeBSD, visible GUI, native IME and performance acceptance are not established by the Linux CI result. Human review and human merge are required. + +The original planning and research record follows; its baseline pins and planning-only status are historical. + +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/env.sh b/packaging/env.sh index ab01ce4c5..07d69a068 100644 --- a/packaging/env.sh +++ b/packaging/env.sh @@ -49,7 +49,7 @@ warn() { # Copy licence and readme files that exist into a package directory. copy_docs() { local dest="$1" f - for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT; do + for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT NOTICE ATTRIBUTION.md; do if [[ -f "$ROOT/$f" ]]; then cp "$ROOT/$f" "$dest/"; fi done copy_font_licences "$dest" diff --git a/packaging/linux/flatpak/ai.storyteller.cadcraft.yml b/packaging/linux/flatpak/ai.storyteller.cadcraft.yml index e70d80009..75a6ff690 100644 --- a/packaging/linux/flatpak/ai.storyteller.cadcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.cadcraft.yml @@ -50,6 +50,8 @@ modules: - install -Dm644 packaging/linux/ai.storyteller.cadcraft.desktop /app/share/applications/ai.storyteller.cadcraft.desktop - install -Dm644 packaging/linux/ai.storyteller.cadcraft.mime.xml /app/share/mime/packages/ai.storyteller.cadcraft.xml - mkdir -p /app/share/icons && cp -R assets/app-icon/hicolor /app/share/icons/ + - install -Dm644 NOTICE /app/share/doc/cadcraft/NOTICE + - install -Dm644 ATTRIBUTION.md /app/share/doc/cadcraft/ATTRIBUTION.md - >- version=$(awk '/^\[/{p=($0=="[workspace.package]");next} p&&$1=="version"{gsub(/[" ]/,"",$3);print $3;exit}' Cargo.toml); install -d /app/share/metainfo; diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 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/macos/package.sh b/packaging/macos/package.sh index fd626fbf4..014f91bd9 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -103,6 +103,8 @@ mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" # Executable and icon carry the display name (CFBundleExecutable / CFBundleIconFile). cp "$WORK/bin/cadcraft" "$APP/Contents/MacOS/CADCraft" cp "$ROOT/assets/app-icon/cadcraft.icns" "$APP/Contents/Resources/CADCraft.icns" +# Bundled dependency font notices are installed before the app is signed. +cp "$ROOT/NOTICE" "$ROOT/ATTRIBUTION.md" "$APP/Contents/Resources/" # Licences of the embedded craft-fonts fonts (only when built with CRAFT_FONTS_DIR). copy_font_licences "$APP/Contents/Resources" sed -e "s/@VERSION@/$VERSION/g" -e "s/@SHORT_VERSION@/$SHORT_VERSION/g" \ diff --git a/packaging/windows/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..92b48e134 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -92,11 +92,27 @@ 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 +# Bundled dependency font notices are required even without optional craft-fonts. +Copy-Item -LiteralPath (Join-Path $Root 'NOTICE'), (Join-Path $Root 'ATTRIBUTION.md') -Destination $Docs +$HasCraftFontLicences = 1 +if ($env:CRAFT_FONTS_DIR) { + foreach ($ofl in Get-ChildItem -Path (Join-Path $env:CRAFT_FONTS_DIR 'fonts\*\OFL.txt') -File -ErrorAction SilentlyContinue) { + Copy-Item -LiteralPath $ofl.FullName -Destination (Join-Path $Docs "OFL-$($ofl.Directory.Name).txt") + $HasCraftFontLicences = 1 + } +} +# ---- end font notices for the MSI -------------------------------------------------------------- + # ---- MSI --------------------------------------------------------------------------------------- $Msi = Join-Path $Dist "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. @@ -108,7 +124,7 @@ $Portable = Join-Path $TargetDir "windows-package\cadcraft-$Version-windows-$Arc Remove-Item -Recurse -Force $Portable -ErrorAction SilentlyContinue New-Item -ItemType Directory -Force -Path $Portable | Out-Null Copy-Item (Join-Path $Stage '*.exe') $Portable -foreach ($f in 'README.md', 'LICENSE', 'LICENSE-MIT', 'LICENSE-APACHE') { +foreach ($f in 'README.md', 'LICENSE', 'LICENSE-MIT', 'LICENSE-APACHE', 'NOTICE', 'ATTRIBUTION.md') { $p = Join-Path $Root $f if (Test-Path $p) { Copy-Item $p $Portable } }