mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-26 20:06:50 -06:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
877a9b1c48 | ||
|
|
c569cd3514 | ||
|
|
5631cdb5fb | ||
|
|
a58bdedb69 | ||
|
|
46ad6cd7af | ||
|
|
2cf5c8ad66 | ||
|
|
4a082b4c85 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Generated
+199
-99
@@ -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"
|
||||
|
||||
+36
-13
@@ -7,7 +7,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
version = "0.12.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" }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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())]) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
+27
-13
@@ -65,37 +65,47 @@ fn follow_desktop(raw: &mut egui::RawInput, desktop: Option<egui::Theme>) {
|
||||
}
|
||||
|
||||
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<u16>, value: Option<String>) -> Option<u16> {
|
||||
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()
|
||||
|
||||
@@ -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<String>,
|
||||
@@ -30,6 +33,7 @@ fn accel(sc: &str) -> Option<Accelerator> {
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,6 +362,7 @@ fn run_open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
@@ -647,6 +647,9 @@ impl Default for MLeaderOptions {
|
||||
pub struct Session {
|
||||
pub docs: Vec<DocState>,
|
||||
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<Running>,
|
||||
/// 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<String>) -> 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()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<usize>, rtl: bool) -> Vec<Glyph> {
|
||||
let run = cs.get(r.clone()).unwrap_or_default();
|
||||
// Shares the face's bytes instead of copying them.
|
||||
let bytes: Arc<dyn AsRef<[u8]> + 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<usize>, rtl: bool) -> Vec<Glyph>
|
||||
}
|
||||
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())
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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()));
|
||||
|
||||
+181
-10
@@ -102,8 +102,9 @@ struct Widget {
|
||||
selected: Option<bool>,
|
||||
}
|
||||
|
||||
/// 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<std::path::PathBuf> {
|
||||
use std::sync::OnceLock;
|
||||
static DIR: OnceLock<Result<std::path::PathBuf, String>> = 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-<pid>-<stamp>-<nonce>` directories in the temp dir; only `AlreadyExists` is retried.
|
||||
fn create_private_shot_dir() -> std::io::Result<std::path::PathBuf> {
|
||||
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<std::fs::File> {
|
||||
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<std::path::PathBuf> {
|
||||
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"));
|
||||
|
||||
@@ -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::<View>(id)).unwrap_or_default();
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(crate::tl!("Show"));
|
||||
|
||||
@@ -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::<String>(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::<String>(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() {
|
||||
|
||||
@@ -213,6 +213,7 @@ pub fn command(app: &mut crate::CadApp, reset: bool, params: &Value) -> Result<V
|
||||
Ok(json!({"docking": app.ui.docking}))
|
||||
}
|
||||
|
||||
/// Validate and repair the stored workspace, sync it with the app, and render the dock panels; a failed restore falls back to the default layout with a status message.
|
||||
pub fn show(app: &mut crate::CadApp, ui: &mut egui::Ui) {
|
||||
let t = crate::theme::Tokens::get();
|
||||
let mut workspace = app.ui.docking.clone();
|
||||
@@ -237,7 +238,7 @@ pub fn show(app: &mut crate::CadApp, ui: &mut egui::Ui) {
|
||||
// Panels arrange in a split tree around the drawing; floating title bars only move windows.
|
||||
style.new_dock_zones = false;
|
||||
style.title_bar_docks = false;
|
||||
let output = DockArea::new(egui::Id::new("cadcraft-docking")).show_with_limits(
|
||||
let output = DockArea::new(egui::Id::unique("cadcraft-docking")).show_with_limits(
|
||||
ui,
|
||||
&workspace.layout,
|
||||
&style,
|
||||
@@ -273,7 +274,7 @@ pub fn show(app: &mut crate::CadApp, ui: &mut egui::Ui) {
|
||||
let _ = sync(&mut workspace, app);
|
||||
let stale = workspace != rendered;
|
||||
if stale {
|
||||
craft_ui::docking::cancel_drag::<Panel>(ui.ctx(), egui::Id::new("cadcraft-docking"));
|
||||
craft_ui::docking::cancel_drag::<Panel>(ui.ctx(), egui::Id::unique("cadcraft-docking"));
|
||||
}
|
||||
publish_visibility(app, &workspace);
|
||||
app.ui.docking = workspace;
|
||||
|
||||
@@ -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<String> = 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() {
|
||||
|
||||
@@ -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::<FromPlot>(egui::Id::new(FROM_PLOT))) {
|
||||
if let Some(f) = ctx.data(|d| d.get_temp::<FromPlot>(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::<Preview>(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::<FromPlot>(egui::Id::new(FROM_PLOT))).is_some();
|
||||
let from_plot = ctx.data(|d| d.get_temp::<FromPlot>(egui::Id::unique(FROM_PLOT))).is_some();
|
||||
if !*open {
|
||||
ctx.data_mut(|d| {
|
||||
d.remove::<Preview>(id);
|
||||
d.remove::<FromPlot>(egui::Id::new(FROM_PLOT));
|
||||
d.remove::<FromPlot>(egui::Id::unique(FROM_PLOT));
|
||||
});
|
||||
}
|
||||
if from_plot && !*open {
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<bool>, 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| {
|
||||
|
||||
@@ -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::<u8>(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]);
|
||||
|
||||
+305
-130
@@ -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<Receiver<ControlRequest>>,
|
||||
pending_shots: Vec<(u64, Option<String>, std::sync::mpsc::Sender<Value>, f64)>,
|
||||
queued_shots: Vec<(u64, f64, u32)>,
|
||||
shot_token: u64,
|
||||
/// Fresh surface and identity snapshot (see [`capture`]).
|
||||
pub(crate) capture_geometry: Option<capture::CaptureGeometry>,
|
||||
/// 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<egui::Event>,
|
||||
/// 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<gpu::FrameCap>,
|
||||
/// 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<egui::Theme>,
|
||||
/// 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::<u64>()).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<web_time::Instant> = 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<egui::Theme>) {
|
||||
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();
|
||||
|
||||
@@ -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<f64> {
|
||||
expr_field(ui, egui::Id::new(id), &fmt_num(v)).and_then(|t| t.parse::<f64>().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<egui::Id>) -> Option<f64> {
|
||||
expr_field(ui, ui.make_persistent_id(id), &fmt_num(v), label).and_then(|t| t.parse::<f64>().ok()).filter(|v| v.is_finite())
|
||||
}
|
||||
|
||||
fn temp<T: Clone + Send + Sync + 'static>(ctx: &egui::Context, id: &str) -> Option<T> {
|
||||
ctx.data_mut(|d| d.get_temp::<T>(egui::Id::new(id)))
|
||||
ctx.data_mut(|d| d.get_temp::<T>(egui::Id::unique(id)))
|
||||
}
|
||||
|
||||
fn set_temp<T: Clone + Send + Sync + 'static>(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();
|
||||
|
||||
@@ -21,17 +21,19 @@ struct Row {
|
||||
}
|
||||
|
||||
fn temp<T: Clone + Send + Sync + 'static>(ctx: &egui::Context, id: &str) -> Option<T> {
|
||||
ctx.data_mut(|d| d.get_temp::<T>(egui::Id::new(id)))
|
||||
ctx.data_mut(|d| d.get_temp::<T>(egui::Id::unique(id)))
|
||||
}
|
||||
|
||||
fn set_temp<T: Clone + Send + Sync + 'static>(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<String> {
|
||||
///
|
||||
/// `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<String> {
|
||||
let mut buf = ui.data_mut(|d| d.get_temp::<String>(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::<f64>().ok()).filter(|h| h.is_finite() && *h > 0.0) {
|
||||
action = act(edit("height", json!(h)), apply);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<Color32>, default: Color32, key: &str, changes: &mut Map<String, Value>) {
|
||||
/// `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<Color32>, default: Color32, key: &str, changes: &mut Map<String, Value>, 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<String, Value>) {
|
||||
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<String, Value>) {
|
||||
}
|
||||
}
|
||||
});
|
||||
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<String, Value>) {
|
||||
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<String, Value>) {
|
||||
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<String, Value>) {
|
||||
});
|
||||
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<String, Value>) {
|
||||
});
|
||||
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::<String>(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<String, Value>) {
|
||||
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<String, Value>, 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<String, Value>, 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<String, Value>) {
|
||||
let t = Tokens::get();
|
||||
let st = &app.session.settings;
|
||||
let mut effects = ui.ctx().data(|d| d.get_temp::<bool>(egui::Id::new(VISUAL_EFFECTS)).unwrap_or(false));
|
||||
let mut effects = ui.ctx().data(|d| d.get_temp::<bool>(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<String, Value>)
|
||||
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<String, Value>)
|
||||
}
|
||||
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<String, Value>)
|
||||
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<String, Value>) {
|
||||
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<String, Value>) {
|
||||
fn aci_row(ui: &mut egui::Ui, current: u8, key: &str, changes: &mut Map<String, Value>, 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<String, Value>) {
|
||||
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();
|
||||
|
||||
@@ -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<cadcraft_engine::doc::dynblock::CustomProperty
|
||||
/// drawing revision.
|
||||
fn custom_properties(ui: &egui::Ui, app: &CadApp, h: cadcraft_engine::doc::Handle) -> 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::<Custom>(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::<u8>().ok();
|
||||
let mut pct = ui.data_mut(|d| d.get_temp::<u8>(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<Value>, bad: &mut Option<String>, ids: &Vec<String>| {
|
||||
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
|
||||
|
||||
@@ -124,18 +124,19 @@ pub fn menu(ui: &mut egui::Ui, current: &str) -> Option<String> {
|
||||
/// 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);
|
||||
|
||||
@@ -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<Value, String> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<egui::PanelState>(old)) else {
|
||||
continue;
|
||||
};
|
||||
ctx.data_mut(|data| {
|
||||
if data.get_persisted::<egui::PanelState>(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();
|
||||
}
|
||||
}
|
||||
@@ -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<Act>, ui: &mut egui::Ui) -> Option<()>
|
||||
// The inline expression editor.
|
||||
let (name, mut buf, at) = app.canvas.param.editing.clone()?;
|
||||
let mut done: Option<bool> = 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::<String>(filter_id)).unwrap_or_default();
|
||||
let mut action: Option<Value> = 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<String> {
|
||||
// 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<String> {
|
||||
///
|
||||
/// `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<egui::Id>) -> Option<String> {
|
||||
let mut buf = ui.data_mut(|d| d.get_temp::<String>(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<egui::Event>) -> Option<String> {
|
||||
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())]);
|
||||
|
||||
@@ -20,11 +20,11 @@ pub fn route(app: &mut CadApp, id: &str, params: &Value) -> Option<Result<Value,
|
||||
}
|
||||
|
||||
fn temp<T: Clone + Send + Sync + 'static>(ctx: &egui::Context, id: &str) -> Option<T> {
|
||||
ctx.data_mut(|d| d.get_temp::<T>(egui::Id::new(id)))
|
||||
ctx.data_mut(|d| d.get_temp::<T>(egui::Id::unique(id)))
|
||||
}
|
||||
|
||||
fn set_temp<T: Clone + Send + Sync + 'static>(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::<String>(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();
|
||||
|
||||
+19
-16
@@ -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<Value> = 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<String> = 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<Pos2>,
|
||||
}
|
||||
|
||||
/// 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::<f64>().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();
|
||||
|
||||
@@ -132,8 +132,9 @@ fn navigate(app: &mut CadApp, params: &Value) -> Result<Value, String> {
|
||||
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);
|
||||
|
||||
@@ -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<egui::FontData>)> = 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -77,7 +77,7 @@ struct Row {
|
||||
fn rows(app: &CadApp, ui: &egui::Ui) -> Vec<Row> {
|
||||
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<Row>)>(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<String> = 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<Act>)
|
||||
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 {
|
||||
|
||||
@@ -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<String> {
|
||||
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<String> = 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");
|
||||
}
|
||||
@@ -23,8 +23,8 @@ fn frame<R>(
|
||||
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<f64> {
|
||||
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(|_| {
|
||||
|
||||
@@ -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::<bool>::new()));
|
||||
let splitter = egui::Id::unique("cadcraft-docking").with(("split", Vec::<bool>::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::<Panel>::hit_rects(&h.ctx, egui::Id::new("cadcraft-docking"))
|
||||
DockArea::<Panel>::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)
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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. |
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
unless DESKTOPSHORTCUT=1 or DESKTOPSHORTCUT=0 is given.
|
||||
-->
|
||||
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
|
||||
<?ifndef HasCraftFontLicences ?>
|
||||
<?define HasCraftFontLicences=0 ?>
|
||||
<?endif ?>
|
||||
<Package
|
||||
Name="CADCraft"
|
||||
Manufacturer="Learning Machines LLC"
|
||||
@@ -59,11 +62,19 @@
|
||||
<UIRef Id="CadcraftUI" />
|
||||
|
||||
<Feature Id="Main" Title="CADCraft" Level="1">
|
||||
<?if $(var.HasCraftFontLicences) = 1 ?>
|
||||
<ComponentGroupRef Id="CraftFontLicences" />
|
||||
<?endif ?>
|
||||
<ComponentGroupRef Id="CadcraftFiles" />
|
||||
</Feature>
|
||||
</Package>
|
||||
|
||||
<Fragment>
|
||||
<?if $(var.HasCraftFontLicences) = 1 ?>
|
||||
<ComponentGroup Id="CraftFontLicences" Directory="INSTALLFOLDER">
|
||||
<Files Include="$(var.DocsDir)\**" />
|
||||
</ComponentGroup>
|
||||
<?endif ?>
|
||||
<ComponentGroup Id="CadcraftFiles" Directory="INSTALLFOLDER">
|
||||
<Component Id="CadcraftApp" Guid="2AC18056-48DE-4079-AB79-4FDEF4FBB075">
|
||||
<File Id="CadcraftExe" Source="$(var.BinDir)\cadcraft.exe" KeyPath="yes">
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user