egui037: migrate CADCraft UI and preserve font notices

This commit is contained in:
skylartaylor
2026-10-24 06:30:17 +02:00
parent 7bf5905ac9
commit 4a082b4c85
74 changed files with 4192 additions and 607 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 }}
Generated
+200 -94
View File
@@ -13,11 +13,11 @@ dependencies = [
"byteorder",
"encoding_rs",
"flate2",
"foldhash",
"foldhash 0.2.0",
"indexmap",
"itoa",
"memmap2",
"nom",
"nom 7.1.3",
"oem_cp",
"once_cell",
"rayon",
@@ -28,9 +28,9 @@ dependencies = [
[[package]]
name = "accesskit"
version = "0.24.1"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320"
checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932"
dependencies = [
"enumn",
"serde",
@@ -39,57 +39,51 @@ dependencies = [
[[package]]
name = "accesskit_atspi_common"
version = "0.18.1"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954"
checksum = "52c182f9c282ac9c5638d876d551d15e5f7d397ec263349a0c6a2b61595dd5e4"
dependencies = [
"accesskit",
"accesskit_consumer 0.36.0",
"accesskit_consumer",
"atspi-common",
"phf 0.13.1",
"phf 0.14.0",
"serde",
"zvariant",
]
[[package]]
name = "accesskit_consumer"
version = "0.35.0"
version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10"
checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344"
dependencies = [
"accesskit",
"hashbrown 0.16.1",
"hashbrown 0.17.1",
]
[[package]]
name = "accesskit_consumer"
version = "0.36.0"
name = "accesskit_ios"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60"
checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937"
dependencies = [
"accesskit",
"hashbrown 0.16.1",
]
[[package]]
name = "accesskit_consumer"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245"
dependencies = [
"accesskit",
"hashbrown 0.16.1",
"accesskit_consumer",
"hashbrown 0.17.1",
"objc2 0.5.3",
"objc2-foundation 0.2.2",
"objc2-ui-kit 0.2.2",
]
[[package]]
name = "accesskit_macos"
version = "0.26.3"
version = "0.27.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076"
checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4"
dependencies = [
"accesskit",
"accesskit_consumer 0.38.0",
"hashbrown 0.16.1",
"accesskit_consumer",
"hashbrown 0.17.1",
"objc2 0.5.3",
"objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2",
@@ -97,9 +91,9 @@ dependencies = [
[[package]]
name = "accesskit_unix"
version = "0.21.1"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c"
checksum = "202f24df034a7476d07b7f74284de84f6d62aabd858dbe7ee9cad3b7ad6f8f9d"
dependencies = [
"accesskit",
"accesskit_atspi_common",
@@ -115,13 +109,13 @@ dependencies = [
[[package]]
name = "accesskit_windows"
version = "0.32.1"
version = "0.35.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21"
checksum = "ce63f35d6bdcf59f26b76b3379063f738e6412cef46999cc772d46aa3de35adb"
dependencies = [
"accesskit",
"accesskit_consumer 0.35.0",
"hashbrown 0.16.1",
"accesskit_consumer",
"hashbrown 0.17.1",
"static_assertions",
"windows",
"windows-core",
@@ -129,11 +123,12 @@ dependencies = [
[[package]]
name = "accesskit_winit"
version = "0.32.2"
version = "0.34.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313"
checksum = "ee96dd150375c05912d8a473f98c2fb155fb3234fac88292d396548a2460fa77"
dependencies = [
"accesskit",
"accesskit_ios",
"accesskit_macos",
"accesskit_unix",
"accesskit_windows",
@@ -224,6 +219,7 @@ dependencies = [
"parking_lot",
"percent-encoding",
"windows-sys 0.61.2",
"wl-clipboard-rs",
"x11rb",
]
@@ -729,6 +725,7 @@ dependencies = [
"serde",
"serde_json",
"toml",
"web-time",
]
[[package]]
@@ -951,7 +948,7 @@ source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
source = "git+https://github.com/storytold/craft-libs?rev=c488caa9fbc2fb1a86e4674887e9b80b598ebbfa#c488caa9fbc2fb1a86e4674887e9b80b598ebbfa"
dependencies = [
"egui",
"serde",
@@ -1086,9 +1083,8 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]]
name = "ecolor"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
dependencies = [
"bytemuck",
"emath",
@@ -1097,9 +1093,8 @@ dependencies = [
[[package]]
name = "eframe"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
dependencies = [
"ahash",
"bytemuck",
@@ -1122,6 +1117,7 @@ dependencies = [
"profiling",
"raw-window-handle",
"ron",
"send_wrapper",
"serde",
"static_assertions",
"wasm-bindgen",
@@ -1134,9 +1130,8 @@ dependencies = [
[[package]]
name = "egui"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
dependencies = [
"accesskit",
"ahash",
@@ -1156,9 +1151,8 @@ dependencies = [
[[package]]
name = "egui-wgpu"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
dependencies = [
"ahash",
"bytemuck",
@@ -1176,32 +1170,35 @@ dependencies = [
[[package]]
name = "egui-winit"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
dependencies = [
"accesskit_winit",
"arboard",
"block2 0.6.2",
"bytemuck",
"egui",
"image",
"log",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"objc2-ui-kit 0.3.2",
"parking_lot",
"profiling",
"raw-window-handle",
"serde",
"smithay-clipboard",
"web-time",
"webbrowser",
"windows-sys 0.61.2",
"winit",
]
[[package]]
name = "egui_glow"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
dependencies = [
"bytemuck",
"egui",
@@ -1213,9 +1210,8 @@ dependencies = [
[[package]]
name = "egui_kittest"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
dependencies = [
"dify",
"eframe",
@@ -1240,9 +1236,8 @@ checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
[[package]]
name = "emath"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
dependencies = [
"bytemuck",
"serde",
@@ -1302,9 +1297,8 @@ dependencies = [
[[package]]
name = "epaint"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
dependencies = [
"ahash",
"bytemuck",
@@ -1321,16 +1315,17 @@ dependencies = [
"serde",
"skrifa 0.44.0",
"smallvec",
"unicode-bidi",
"unicode-general-category",
"unicode-properties",
"unicode-segmentation",
"vello_cpu",
]
[[package]]
name = "epaint_default_fonts"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
[[package]]
name = "equivalent"
@@ -1416,6 +1411,12 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flate2"
version = "1.1.10"
@@ -1433,6 +1434,12 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
@@ -1595,12 +1602,12 @@ dependencies = [
[[package]]
name = "glifo"
version = "0.2.0"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a"
checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be"
dependencies = [
"bytemuck",
"foldhash",
"foldhash 0.2.0",
"hashbrown 0.17.1",
"log",
"peniko",
@@ -1755,6 +1762,15 @@ dependencies = [
"byteorder",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash 0.1.5",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
@@ -1763,7 +1779,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
"foldhash 0.2.0",
]
[[package]]
@@ -1772,7 +1788,7 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
"foldhash 0.2.0",
]
[[package]]
@@ -2084,12 +2100,12 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "kittest"
version = "0.4.0"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5"
checksum = "0bb81a721ba76aa095b7f45deb180504f786904e56ca4b8e141f66eb89a78b75"
dependencies = [
"accesskit",
"accesskit_consumer 0.35.0",
"accesskit_consumer",
]
[[package]]
@@ -2401,6 +2417,15 @@ dependencies = [
"minimal-lexical",
]
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -2798,6 +2823,16 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "os_pipe"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "parking"
version = "2.2.1"
@@ -2846,6 +2881,17 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "petgraph"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
dependencies = [
"fixedbitset",
"hashbrown 0.15.5",
"indexmap",
]
[[package]]
name = "phf"
version = "0.11.3"
@@ -2857,33 +2903,33 @@ dependencies = [
[[package]]
name = "phf"
version = "0.13.1"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6"
dependencies = [
"phf_macros",
"phf_shared 0.13.1",
"phf_shared 0.14.0",
"serde",
]
[[package]]
name = "phf_generator"
version = "0.13.1"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100"
dependencies = [
"fastrand",
"phf_shared 0.13.1",
"phf_shared 0.14.0",
]
[[package]]
name = "phf_macros"
version = "0.13.1"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085"
dependencies = [
"phf_generator",
"phf_shared 0.13.1",
"phf_shared 0.14.0",
"proc-macro2",
"quote",
"syn 2.0.119",
@@ -2900,9 +2946,9 @@ dependencies = [
[[package]]
name = "phf_shared"
version = "0.13.1"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89"
dependencies = [
"siphasher",
]
@@ -3171,7 +3217,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4edc8db0d972de88e53a085b1f01fb3fbdae7bb0cb7a0ce2b2f8285a412fe3ef"
dependencies = [
"bytemuck",
"foldhash",
"foldhash 0.2.0",
"font-types",
"once_cell",
"yoke",
@@ -3354,6 +3400,12 @@ version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "send_wrapper"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
[[package]]
name = "serde"
version = "1.0.229"
@@ -3850,6 +3902,17 @@ dependencies = [
"once_cell",
]
[[package]]
name = "tree_magic_mini"
version = "3.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
dependencies = [
"memchr",
"nom 8.0.0",
"petgraph",
]
[[package]]
name = "type-map"
version = "0.5.1"
@@ -3894,6 +3957,12 @@ version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "unicode-properties"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
[[package]]
name = "unicode-segmentation"
version = "1.13.3"
@@ -3937,9 +4006,9 @@ dependencies = [
[[package]]
name = "vello_common"
version = "0.1.0"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2"
checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd"
dependencies = [
"bytemuck",
"fearless_simd",
@@ -3952,9 +4021,9 @@ dependencies = [
[[package]]
name = "vello_cpu"
version = "0.1.0"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea"
checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9"
dependencies = [
"bytemuck",
"glifo",
@@ -4616,8 +4685,7 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winit"
version = "0.30.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d"
source = "git+https://github.com/storytold/winit.git?rev=ae48c756a3b81125eb796c0cc6dccf60990d1181#ae48c756a3b81125eb796c0cc6dccf60990d1181"
dependencies = [
"ahash",
"android-activity",
@@ -4689,6 +4757,24 @@ version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wl-clipboard-rs"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1"
dependencies = [
"libc",
"log",
"os_pipe",
"rustix 1.1.5",
"thiserror 2.0.21",
"tree_magic_mini",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-protocols-wlr",
]
[[package]]
name = "writeable"
version = "0.6.4"
@@ -5036,3 +5122,23 @@ dependencies = [
"syn 3.0.6",
"winnow",
]
[[patch.unused]]
name = "egui_demo_lib"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
[[patch.unused]]
name = "egui_extras"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
[[patch.unused]]
name = "egui_inspection"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
[[patch.unused]]
name = "egui_system_font_provider"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1"
+25 -5
View File
@@ -50,11 +50,13 @@ encoding_rs = "0.8.35"
quick-xml = "0.42"
# ui
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
egui = "0.36"
egui_kittest = "0.36"
eframe = { version = "0.36", default-features = false }
egui-wgpu = "0.36"
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c488caa9fbc2fb1a86e4674887e9b80b598ebbfa", features = ["serde"] }
egui = { version = "0.37", default-features = false, features = ["default_fonts", "monochrome_emoji_fonts"] }
egui_kittest = "0.37"
eframe = { version = "0.37", default-features = false }
egui-wgpu = "0.37"
# Monotonic time on native and web (performance.now on wasm).
web-time = "1.1"
rfd = "0.17"
arboard = { version = "3.6", default-features = false }
@@ -80,3 +82,21 @@ opt-level = 3
lto = "thin"
codegen-units = 16
debug = 0
# Keep the complete egui family and native event bridge on reviewed fork commits.
[patch.crates-io]
ecolor = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
emath = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
eframe = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" }
winit = { git = "https://github.com/storytold/winit.git", rev = "ae48c756a3b81125eb796c0cc6dccf60990d1181" }
+2 -1
View File
@@ -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"] }
+2 -1
View File
@@ -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())]) {
+11 -5
View File
@@ -30,16 +30,19 @@ mod web {
impl eframe::App for Shell {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.1.take_dropped(ctx);
// Background reads are consume-based and run on hidden ticks too. Dropped files are
// input and are read from `ui` only.
self.1.drain(&mut self.0);
self.0.system_animations = self.3.as_ref().map(|reduced| !reduced.matches());
self.0.logic(ctx);
// `DirtyReport` suppresses duplicates, so reporting after every logic callback
// (including a hidden tab's) is idempotent and keeps the parent's guard current.
if let Some((bridge, reported)) = &self.2 {
bridge.report_dirty(&self.0, reported);
}
}
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(raw);
self.0.raw_input_hook(ctx, raw);
cadcraft_ui_egui::cmdline::capture_tab(ctx, raw);
}
/// Preferences (interface theme and language) survive reloads; egui state is not kept.
@@ -51,10 +54,13 @@ mod web {
false
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.0.ui(ui);
if let Some((bridge, reported)) = &self.2 {
bridge.report_dirty(&self.0, reported);
let first_pass = ui.ctx().current_pass_index() == 0;
if first_pass {
// `take_dropped` consumes the raw list; a second pass or hidden tick cannot
// re-read the same file.
self.1.take_dropped(ui.ctx());
}
self.0.ui(ui);
}
}
+2 -1
View File
@@ -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());
}
+2 -1
View File
@@ -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 }
+24 -13
View File
@@ -66,36 +66,44 @@ fn follow_desktop(raw: &mut egui::RawInput, desktop: Option<egui::Theme>) {
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
#[cfg(target_os = "macos")]
{
if self.native_menu.is_none() && std::env::var_os("CADCRAFT_NO_NATIVE_MENU").is_none() {
self.native_menu = Some(native_menu::NativeMenu::install(&mut self.cad));
}
if let Some(m) = &mut self.native_menu {
m.poll(&mut self.cad, ctx);
}
}
self.cad.system_animations = self.system_animations.current();
// Consume control/open/screenshot queues and time-based autosave, including hidden ticks.
self.cad.logic(ctx);
self.spacemouse.poll(&mut self.cad, ctx);
self.persist_workspace(false);
// Host frame work runs here, once per frame, and so also on a hidden logic-only tick (which
// never reaches `App::ui`): native menus stay current, the spacemouse still navigates and a
// workspace changed while hidden is persisted. `current_pass_index()` is zero on the first
// pass and on every hidden tick, and non-zero on later passes of a multi-pass frame.
if ctx.current_pass_index() == 0 {
#[cfg(target_os = "macos")]
{
if self.native_menu.is_none() && std::env::var_os("CADCRAFT_NO_NATIVE_MENU").is_none() {
self.native_menu = Some(native_menu::NativeMenu::install(&mut self.cad));
}
if let Some(m) = &mut self.native_menu {
m.poll(&mut self.cad, ctx);
}
}
self.spacemouse.poll(&mut self.cad, ctx);
// After `cad.logic`, so a control command's workspace change is saved in the same frame.
self.persist_workspace(false);
}
if std::mem::take(&mut self.cad.quit_requested) {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
follow_desktop(raw, self.desktop_theme.current());
self.cad.raw_input_hook(raw);
self.cad.raw_input_hook(ctx, raw);
cadcraft_ui_egui::cmdline::capture_tab(ctx, raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.cad.ui(ui);
self.persist_workspace(false);
}
/// A normal exit: the workspace is saved, and this run's autosave files are not needed (a
/// crash leaves them for recovery).
fn on_exit(&mut self) {
self.persist_workspace(true);
self.cad.cancel_screenshots();
self.cad.shutdown_autosave();
}
/// Preferences (the interface theme) survive restarts; window and egui state are not kept.
@@ -273,6 +281,9 @@ fn main() -> eframe::Result {
.with_title_shown(false)
.with_app_id(APP_ID),
persist_window: false,
// CADCraft installs its own capped font chain (`theme::install_fonts`); never run
// system-font discovery as a hidden fallback.
system_font_fallback: false,
// A stencil buffer clips the contents of clipped layout viewports on the GPU.
stencil_buffer: STENCIL_BITS,
..Default::default()
+25 -2
View File
@@ -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>,
@@ -66,7 +69,7 @@ impl NativeMenu {
submenus.push((title, sub));
}
menu.init_for_nsapp();
Self { _menu: menu, items, submenus, predefined, language, last_refresh: 0.0, recent: app.ui.recent_files.clone(), counter }
Self { _menu: menu, items, submenus, predefined, language, last_refresh: f64::NEG_INFINITY, recent: app.ui.recent_files.clone(), counter }
}
/// Rebuild File ▸ Open Recent after a drawing was opened or the list was cleared: the native
@@ -113,7 +116,7 @@ impl NativeMenu {
}
}
let now = cadcraft_ui_egui::now_ms();
if now - self.last_refresh < 300.0 {
if !refresh_due(now, self.last_refresh) {
return;
}
self.last_refresh = now;
@@ -124,6 +127,13 @@ impl NativeMenu {
}
}
/// The periodic enablement refresh is due every 300 ms. Split out so the first-poll sentinel is
/// testable without a macOS menu: `now_ms` starts near zero, so a `0.0` initial value would
/// suppress the first refresh for 300 ms.
fn refresh_due(now: f64, last_refresh: f64) -> bool {
now - last_refresh >= 300.0
}
fn build(
parent: &Submenu,
entries: &[Entry],
@@ -150,3 +160,16 @@ fn build(
}
}
}
#[cfg(test)]
mod tests {
use super::refresh_due;
#[test]
fn the_first_poll_refreshes_immediately() {
assert!(refresh_due(0.5, f64::NEG_INFINITY), "a fresh menu refreshes on its first poll");
assert!(!refresh_due(0.5, 0.0), "the old 0.0 sentinel delayed the first refresh");
assert!(!refresh_due(299.9, 0.0));
assert!(refresh_due(300.0, 0.0));
}
}
+7
View File
@@ -375,6 +375,7 @@ 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()
}
@@ -382,6 +383,7 @@ fn run_closeall(s: &mut Session, _p: &Value) -> Result<Value> {
s.cancel();
s.docs.clear();
s.active = 0;
s.bump_doc_epoch();
ok()
}
@@ -493,6 +495,11 @@ fn run_switch(s: &mut Session, p: &Value) -> Result<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()
}
+12
View File
@@ -637,6 +637,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.
@@ -717,6 +720,7 @@ impl Session {
Session {
docs: Vec::new(),
active: 0,
doc_epoch: 0,
settings: Settings::default(),
running: None,
in_script: false,
@@ -757,6 +761,7 @@ impl Session {
self.end_running();
self.docs.push(st);
self.active = self.docs.len() - 1;
self.bump_doc_epoch();
self.active
}
pub fn open_drawing(&mut self, d: Drawing, title: &str, path: Option<String>) -> usize {
@@ -771,8 +776,14 @@ impl Session {
self.end_running();
self.docs.push(st);
self.active = self.docs.len() - 1;
self.bump_doc_epoch();
self.active
}
/// Record that which drawing is open or active changed (screenshot binding).
pub fn bump_doc_epoch(&mut self) {
self.doc_epoch = self.doc_epoch.wrapping_add(1);
}
/// A command still waiting for input belongs to the drawing it started in: end it there
/// before another drawing becomes current, so its undo step lands in that drawing.
fn end_running(&mut self) {
@@ -790,6 +801,7 @@ impl Session {
Err(_) => {
self.docs.truncate(n);
self.active = self.active.min(self.docs.len().saturating_sub(1));
self.bump_doc_epoch();
Err(EngineError::Internal("open".into(), "panic".into()))
}
}
+4 -6
View File
@@ -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())
+3
View File
@@ -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"] }
+7 -7
View File
@@ -151,7 +151,7 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
let warn = Tokens::get().warn;
let (mut ok, mut cancel, mut apply_clicked) = (false, false, false);
let Some(st) = app.alias_dlg.as_mut() else { return };
let id = egui::Id::new("aliasedit_dialog");
let id = egui::Id::unique("aliasedit_dialog");
if std::mem::take(&mut st.raise) {
ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, id));
}
@@ -161,8 +161,8 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
None => ui.label(crate::tl!("No alias file loaded. REINIT replaces these aliases with a file's.")),
};
ui.horizontal(|ui| {
ui.label(crate::tl!("Filter"));
ui.text_edit_singleline(&mut st.filter);
let label = ui.label(crate::tl!("Filter"));
ui.text_edit_singleline(&mut st.filter).labelled_by(label.id);
});
let f = st.filter.trim().to_ascii_lowercase();
let mut names: Vec<&String> = st.user.keys().chain(built.keys().filter(|a| !st.user.contains_key(*a))).collect();
@@ -226,11 +226,11 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
if let Some(form) = st.form.as_mut() {
ui.group(|ui| {
egui::Grid::new("alias_form").num_columns(2).show(ui, |ui| {
ui.label(crate::tl!("Alias"));
ui.text_edit_singleline(&mut form.alias);
let alias_label = ui.label(crate::tl!("Alias"));
ui.text_edit_singleline(&mut form.alias).labelled_by(alias_label.id);
ui.end_row();
ui.label(crate::tl!("Command"));
ui.text_edit_singleline(&mut form.command);
let command_label = ui.label(crate::tl!("Command"));
ui.text_edit_singleline(&mut form.command).labelled_by(command_label.id);
ui.end_row();
});
// A few commands starting with what was typed, to pick from.
+9 -3
View File
@@ -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] {
+2 -2
View File
@@ -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))
+4 -4
View File
@@ -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;
}
+2 -2
View File
@@ -1354,7 +1354,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
// draws the others.
let tiles = tile_rects(app, full);
let rect = tiles.iter().find(|(_, tile)| tile.active).map_or(full, |(r, _)| *r);
let resp = ui.interact(full, ui.id().with("canvas"), Sense::click_and_drag());
let resp = ui.interact(full, ui.scope_id().with("canvas"), Sense::click_and_drag());
let painter = ui.painter_at(rect);
app.canvas.rect = Some(rect);
app.session.viewport_px = (f64::from(rect.width()).max(1.0), f64::from(rect.height()).max(1.0));
@@ -2803,7 +2803,7 @@ pub(crate) mod tests {
}
fn wheel(unit: egui::MouseWheelUnit, dy: f32, modifiers: egui::Modifiers) -> egui::Event {
egui::Event::MouseWheel { unit, delta: vec2(0.0, dy), phase: egui::TouchPhase::Move, modifiers }
egui::Event::MouseWheel { unit, delta: vec2(0.0, dy), phase: egui::TouchPhase::Move, source: egui::MouseWheelSource::Unknown, modifiers }
}
/// Wheel events convert to points as egui does; ⌘/Ctrl (egui's pinch zoom) and ⇧ (a sideways
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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
+16 -15
View File
@@ -80,7 +80,7 @@ pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) {
ui.painter().galley(pos2(x, r.top() + (title_h - galley.size().y) / 2.0), galley, t.text_dim);
// Allow dragging the window by the title row.
let tr = Rect::from_min_size(r.min, vec2(r.width(), title_h));
let resp = ui.interact(tr, ui.id().with("titledrag"), Sense::CLICK | Sense::DRAG);
let resp = ui.interact(tr, ui.scope_id().with("titledrag"), Sense::CLICK | Sense::DRAG);
if resp.drag_started() {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
}
@@ -122,7 +122,7 @@ pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) {
let Some(group) = TOOLBAR.get(*index) else { continue };
for (icon, cmd, label, shortcut) in group.iter() {
let br = Rect::from_min_size(pos2(x, row.center().y - size / 2.0), vec2(size, size));
let resp = ui.interact(br, ui.id().with(("tb", *cmd)), Sense::click());
let resp = ui.interact(br, ui.scope_id().with(("tb", *cmd)), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(br, 3.0, t.control_hover.gamma_multiply(0.6));
}
@@ -141,7 +141,7 @@ pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) {
}
if !fit.overflow.is_empty() {
let br = Rect::from_min_size(pos2(x, row.center().y - size / 2.0), vec2(fit.overflow_width, size));
let response = ui.interact(br, ui.id().with("toolbar_more"), Sense::click());
let response = ui.interact(br, ui.scope_id().with("toolbar_more"), Sense::click());
if response.hovered() {
ui.painter().rect_filled(br, 3.0, t.control_hover.gamma_multiply(0.6));
}
@@ -184,7 +184,7 @@ pub fn file_tabs(app: &mut CadApp, ui: &mut egui::Ui) {
icons::paint(ui.painter(), ib.shrink(1.0), Icon::Switcher, false);
x += 24.0;
let plus = Rect::from_min_size(pos2(x, r.top() + 4.0), vec2(18.0, 18.0));
let presp = ui.interact(plus, ui.id().with("tab+"), Sense::click());
let presp = ui.interact(plus, ui.scope_id().with("tab+"), Sense::click());
icons::paint(ui.painter(), plus.shrink(2.0), Icon::Plus, false);
icons::describe_control(ui, &presp, "New Drawing", None, false);
if presp.on_hover_text(crate::tl!("New Drawing")).clicked() {
@@ -235,7 +235,8 @@ pub fn file_tabs(app: &mut CadApp, ui: &mut egui::Ui) {
x += w;
(resp.clicked(), close, resp)
};
let (c, _, _) = tab(ui, crate::tl!("Start"), false, app.ui.start_tab || app.session.docs.is_empty(), false, 110.0, ui.id().with("tab_start"));
let (c, _, _) =
tab(ui, crate::tl!("Start"), false, app.ui.start_tab || app.session.docs.is_empty(), false, 110.0, ui.scope_id().with("tab_start"));
if c {
app.ui.start_tab = true;
}
@@ -244,7 +245,7 @@ pub fn file_tabs(app: &mut CadApp, ui: &mut egui::Ui) {
let titles: Vec<(usize, String, bool, bool)> =
app.session.docs.iter().enumerate().map(|(i, d)| (i, d.title.clone(), d.is_dirty(), i == app.session.active)).collect();
for (i, title, dirty, active) in titles {
let (c, x, resp) = tab(ui, &title, dirty, active && !app.ui.start_tab, true, 170.0, ui.id().with(("tab", i)));
let (c, x, resp) = tab(ui, &title, dirty, active && !app.ui.start_tab, true, 170.0, ui.scope_id().with(("tab", i)));
crate::shortcut_menus::attach(app, &resp, |app| crate::shortcut_menus::drawing_tab_entries(app, i));
if c {
switch_to = Some(i);
@@ -311,7 +312,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) {
let mut list_button = None;
for (icon, tip) in [(Icon::Plus, "New layout"), (Icon::Menu, "Layout list")] {
let br = Rect::from_min_size(pos2(x, r.top() + 4.0), vec2(18.0, 18.0));
let resp = ui.interact(br, ui.id().with(("sb", tip)), Sense::click());
let resp = ui.interact(br, ui.scope_id().with(("sb", tip)), Sense::click());
icons::paint(&p, br.shrink(2.0), icon, false);
icons::describe_control(ui, &resp, tip, None, false);
let resp = resp.on_hover_text(crate::i18n::t(tip));
@@ -365,7 +366,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) {
let model = app.session.state().is_ok_and(|st| st.mspace.is_some());
let label = if model { "MODEL" } else { "PAPER" };
let br = Rect::from_min_max(pos2(rx - 60.0, r.center().y - 9.0), pos2(rx - 6.0, r.center().y + 9.0));
let resp = ui.interact(br, ui.id().with("mspace_toggle"), Sense::click());
let resp = ui.interact(br, ui.scope_id().with("mspace_toggle"), Sense::click());
p.rect_filled(br, 3.0, if resp.hovered() { t.control_hover } else { t.toggle_on.gamma_multiply(0.25) });
p.text(br.center(), egui::Align2::CENTER_CENTER, crate::i18n::t(label), crate::theme::small(), t.text);
icons::describe_control(ui, &resp, label, None, false);
@@ -414,7 +415,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) {
let w = g.size().x + 26.0;
let tr = Rect::from_min_size(pos2(x, r.top() + 1.0), vec2(w, r.height() - 2.0)).intersect(clip);
let active = active_tab == Some(i);
let resp = ui.interact(tr, ui.id().with(("lt", i)), Sense::click());
let resp = ui.interact(tr, ui.scope_id().with(("lt", i)), Sense::click());
if active {
tp.rect_filled(tr, 2.0, t.tab_active);
} else if resp.hovered() {
@@ -430,7 +431,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) {
x += w + 2.0;
if i == 0 {
let pr = Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(16.0, 16.0));
let presp = ui.interact(pr, ui.id().with("layout_plus"), Sense::click());
let presp = ui.interact(pr, ui.scope_id().with("layout_plus"), Sense::click());
icons::paint(&p, pr.shrink(2.0), Icon::Plus, false);
icons::describe_control(ui, &presp, "New layout", None, false);
if presp.on_hover_text(crate::tl!("New layout")).clicked()
@@ -445,7 +446,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) {
// The layouts that didn't fit, behind a button after the last tab shown.
let more = hidden.then(|| {
let mr = Rect::from_min_size(pos2(x + 2.0, r.top() + 5.0), vec2(16.0, 16.0));
let resp = ui.interact(mr, ui.id().with("layout_more"), Sense::click());
let resp = ui.interact(mr, ui.scope_id().with("layout_more"), Sense::click());
if resp.hovered() {
p.rect_filled(mr, 3.0, t.control_hover.gamma_multiply(0.5));
}
@@ -486,13 +487,13 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) {
let rect = Rect::from_min_max(pos2(left, r.top()), pos2(coord_right, r.bottom()));
let text_x = if message.is_some() { left } else { coord_right - galley.size().x };
p.with_clip_rect(rect).galley(pos2(text_x, r.center().y - galley.size().y / 2.0), galley, color);
ui.interact(rect, ui.id().with("status_readout"), Sense::hover()).on_hover_text(text);
ui.interact(rect, ui.scope_id().with("status_readout"), Sense::hover()).on_hover_text(text);
}
}
let mut toggle_cmd = None;
for (icon, on, cmd, tip) in toggles {
let br = Rect::from_min_size(pos2(rx, r.center().y - size / 2.0), vec2(size, size));
let resp = ui.interact(br, ui.id().with(("tg", tip)), Sense::click());
let resp = ui.interact(br, ui.scope_id().with(("tg", tip)), Sense::click());
if on {
p.rect_filled(br, 3.0, t.toggle_on.gamma_multiply(0.35));
} else if resp.hovered() {
@@ -538,7 +539,7 @@ pub fn start_page(app: &mut CadApp, ui: &mut egui::Ui) {
let mut action = None;
for (label, cmd) in [("Open...", "open"), ("New", "new"), ("New (metric)", "new.metric"), ("Open sample drawing", "sample")] {
let br = Rect::from_min_size(pos2(left.left() + 34.0, y), vec2(220.0, 34.0));
let resp = ui.interact(br, ui.id().with(("start", cmd)), Sense::click());
let resp = ui.interact(br, ui.scope_id().with(("start", cmd)), Sense::click());
p.rect_stroke(br, 2.0, Stroke::new(1.0, if resp.hovered() { t.text } else { t.text_dim }), egui::StrokeKind::Inside);
p.text(pos2(br.left() + 16.0, br.center().y), egui::Align2::LEFT_CENTER, crate::i18n::t(label), crate::theme::body(), t.text);
if resp.clicked() {
@@ -552,7 +553,7 @@ pub fn start_page(app: &mut CadApp, ui: &mut egui::Ui) {
let mut y = main.top() + 44.0;
for (i, path) in app.ui.recent_files.iter().enumerate() {
let row = Rect::from_min_size(pos2(main.left(), y), vec2((r.right() - main.left() - 36.0).clamp(0.0, 640.0), 42.0));
let resp = ui.interact(row, ui.id().with(("recent", i)), Sense::click());
let resp = ui.interact(row, ui.scope_id().with(("recent", i)), Sense::click());
if resp.hovered() {
p.rect_filled(row, 2.0, t.control_hover.gamma_multiply(0.5));
}
+1 -1
View File
@@ -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);
+2 -2
View File
@@ -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.
+1 -1
View File
@@ -204,7 +204,7 @@ pub fn prompt(app: &mut CadApp, ctx: &egui::Context) {
};
let t = Tokens::get();
let mut choice = None;
egui::Modal::new(egui::Id::new("cc_close_prompt")).show(ctx, |ui| {
egui::Modal::new(egui::Id::unique("cc_close_prompt")).show(ctx, |ui| {
ui.set_width(360.0);
ui.label(RichText::new("CADCraft").strong());
ui.add_space(4.0);
+3 -3
View File
@@ -443,7 +443,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) {
for (i, k) in kws.iter().enumerate() {
let g = p.layout_no_wrap(k.clone(), font.clone(), t.cmd_keyword);
let r = Rect::from_min_size(pos2(x, bar.top() + 3.0), vec2(g.size().x, h - 6.0));
let resp = ui.interact(r, ui.id().with(("kw", i)), Sense::click());
let resp = ui.interact(r, ui.scope_id().with(("kw", i)), Sense::click());
if resp.hovered() {
p.rect_filled(r, 2.0, t.cmd_keyword_hover);
}
@@ -523,7 +523,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) {
p.rect_stroke(lr, 3.0, Stroke::new(1.0, t.cmd_border), egui::StrokeKind::Inside);
for (i, s) in sug.iter().enumerate() {
let r = Rect::from_min_size(pos2(lr.left(), lr.top() + rh * i as f32), vec2(lr.width(), rh));
let resp = ui.interact(r, ui.id().with(("sug", i)), Sense::click());
let resp = ui.interact(r, ui.scope_id().with(("sug", i)), Sense::click());
if resp.hovered() || i == 0 {
p.rect_filled(r.shrink(1.0), 2.0, if resp.hovered() { t.cmd_keyword_hover } else { t.list_row });
}
@@ -586,7 +586,7 @@ fn zones(app: &CadApp, canvas: Rect, bar: Rect) -> Zones {
/// the top edge sets how many lines show (`ui.cmdline.lines`).
fn history(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect, bar: Rect) {
let z = zones(app, canvas, bar);
let resp = ui.interact(z.all, ui.id().with("cmdline"), Sense::click_and_drag());
let resp = ui.interact(z.all, ui.scope_id().with("cmdline"), Sense::click_and_drag());
let at = resp.hover_pos().or(resp.interact_pointer_pos());
let on_grip = |p: Pos2| z.grip.contains(p) && z.window.is_none();
let on_toggle = |p: Pos2| z.chevron.contains(p) || z.lines.is_some_and(|r| r.contains(p));
+2 -2
View File
@@ -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()));
+172 -10
View File
@@ -103,7 +103,7 @@ struct Widget {
}
fn widgets_id() -> egui::Id {
egui::Id::new("cadcraft-observed-widgets")
egui::Id::unique("cadcraft-observed-widgets")
}
pub(crate) fn begin_frame(ctx: &egui::Context) {
@@ -267,7 +267,7 @@ pub fn inspect(app: &CadApp, ctx: &egui::Context) -> Value {
"snap": app.canvas.snap.map(|s| json!({"point": [s.point.x, s.point.y], "mode": s.name})),
"session": app.session.summary(),
"closePrompt": app.close_prompt(),
"perf": {"frameMs": app.frame_ms, "buildMs": app.canvas.build_ms, "drawMs": app.canvas.draw_ms, "meshMs": app.canvas.mesh_ms, "building": app.canvas.building(), "renderer": if app.canvas.gpu.is_some() { "gpu" } else { "cpu" }, "prims": app.canvas.list.as_ref().map(|l| l.prims.len())},
"perf": {"frameMs": app.frame_ms, "buildMs": app.canvas.build_ms, "drawMs": app.canvas.draw_ms, "meshMs": app.canvas.mesh_ms, "building": app.canvas.building(), "renderer": if app.canvas.gpu.is_some() { "gpu" } else { "cpu" }, "prims": app.canvas.list.as_ref().map(|l| l.prims.len()), "uiFrames": app.ui_frames, "passIndex": ctx.current_pass_index()},
})
}
@@ -543,18 +543,133 @@ fn render_reply(png: &[u8], w: u32, h: u32, path: Option<&str>) -> Value {
v
}
/// Save a screenshot PNG.
pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>) -> Value {
/// A private, per-process screenshot directory for pathless `ui.screenshot` requests.
///
/// The directory is created with owner-only permissions (`0700` on Unix) under a name that
/// includes the process id, a monotonic nonce and a sequence number. `create_dir` fails rather
/// than following a pre-created symlink, so another local user cannot redirect the write.
fn private_shot_dir() -> std::io::Result<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)
}
fn next_pathless_nonce() -> u64 {
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT: AtomicU64 = AtomicU64::new(0);
NEXT.fetch_add(1, Ordering::Relaxed)
}
fn create_private_shot_dir() -> std::io::Result<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"))
}
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)
}
fn is_private_shot_dir(meta: &std::fs::Metadata) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
meta.permissions().mode() & 0o077 == 0
}
#[cfg(not(unix))]
{
let _ = meta;
true
}
}
fn save_pathless_screenshot(img: &image::RgbaImage, token: u64) -> std::io::Result<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 +725,53 @@ mod tests {
assert_eq!(pending.wait(Duration::ZERO), json!({"ok": true}));
}
#[test]
fn pathless_screenshots_are_unique_private_png_files() {
let img = egui::ColorImage::filled([4, 3], egui::Color32::RED);
let first = save_screenshot(&img, None, 1);
let second = save_screenshot(&img, None, 1);
assert_eq!(first["ok"], true, "{first}");
assert_eq!(second["ok"], true, "{second}");
let a = std::path::PathBuf::from(first["result"]["path"].as_str().unwrap_or_default());
let b = std::path::PathBuf::from(second["result"]["path"].as_str().unwrap_or_default());
assert_ne!(a, b, "the same per-process token must not reuse a pathless file");
for path in [&a, &b] {
let bytes = std::fs::read(path).expect("the screenshot file exists");
assert!(bytes.starts_with(b"\x89PNG\r\n\x1a\n"), "{path:?} is a PNG");
image::load_from_memory(&bytes).expect("decodable PNG");
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
for path in [&a, &b] {
let mode = std::fs::metadata(path).expect("metadata").permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "screenshot {path:?} is owner-only");
}
let dir = a.parent().expect("the file has a private directory");
let dir_mode = std::fs::metadata(dir).expect("directory metadata").permissions().mode() & 0o777;
assert_eq!(dir_mode, 0o700, "the screenshot directory is owner-only");
}
for path in [&a, &b] {
let _ = std::fs::remove_file(path);
}
}
#[cfg(unix)]
#[test]
fn private_screenshot_file_refuses_a_preexisting_symlink() {
let dir = std::env::temp_dir().join(format!("cadcraft-shot-symlink-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
create_private_dir(&dir).expect("private test directory");
let target = dir.join("target.txt");
std::fs::write(&target, b"do not overwrite").expect("target file");
let link = dir.join("shot.png");
std::os::unix::fs::symlink(&target, &link).expect("symlink");
let err = create_private_file(&link).expect_err("an existing symlink is refused");
assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists);
assert_eq!(std::fs::read(&target).expect("target unchanged"), b"do not overwrite");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn render_reply_has_base64_width_height_and_keeps_old_keys() {
let r = render_reply(b"abc", 7, 5, Some("x.png"));
+1 -1
View File
@@ -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"));
+27 -25
View File
@@ -42,7 +42,7 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) {
fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
let settings = app.session.drafting_settings();
let (mut snapunit, mut gridunit) = (settings.snapunit, settings.gridunit);
egui::Window::new(crate::tl!("Drafting Settings")).id(egui::Id::new("Drafting Settings")).open(open).resizable(false).show(ctx, |ui| {
egui::Window::new(crate::tl!("Drafting Settings")).id(egui::Id::unique("Drafting Settings")).open(open).resizable(false).show(ctx, |ui| {
let s = &mut app.session.settings;
ui.heading(crate::tl!("Snap and Grid"));
ui.checkbox(&mut s.snapmode, crate::tl!("Snap On (F9)"));
@@ -52,10 +52,10 @@ fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
ui.radio_value(&mut s.isodraft, true, crate::tl!("Isometric snap"));
});
ui.horizontal(|ui| {
ui.label(crate::tl!("Snap X spacing"));
ui.add(egui::DragValue::new(&mut snapunit.x).speed(0.05).range(0.0001..=1e6).clamp_existing_to_range(false));
ui.label(crate::tl!("Y"));
ui.add(egui::DragValue::new(&mut snapunit.y).speed(0.05).range(0.0001..=1e6).clamp_existing_to_range(false));
let x_label = ui.label(crate::tl!("Snap X spacing"));
ui.add(egui::DragValue::new(&mut snapunit.x).speed(0.05).range(0.0001..=1e6).clamp_existing_to_range(false)).labelled_by(x_label.id);
let y_label = ui.label(crate::tl!("Y"));
ui.add(egui::DragValue::new(&mut snapunit.y).speed(0.05).range(0.0001..=1e6).clamp_existing_to_range(false)).labelled_by(y_label.id);
});
// SNAPTYPE and POLARDIST.
ui.horizontal(|ui| {
@@ -65,16 +65,16 @@ fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
});
ui.add_enabled_ui(s.polarsnap, |ui| {
ui.horizontal(|ui| {
ui.label(crate::tl!("Polar distance"));
ui.add(egui::DragValue::new(&mut s.polardist).speed(0.05).range(0.0..=1e6));
let label = ui.label(crate::tl!("Polar distance"));
ui.add(egui::DragValue::new(&mut s.polardist).speed(0.05).range(0.0..=1e6)).labelled_by(label.id);
});
});
ui.checkbox(&mut s.gridmode, crate::tl!("Grid On (F7)"));
ui.horizontal(|ui| {
ui.label(crate::tl!("Grid spacing"));
ui.add(egui::DragValue::new(&mut gridunit.x).speed(0.05).range(0.0..=1e6).clamp_existing_to_range(false));
ui.label(crate::tl!("Major line every"));
ui.add(egui::DragValue::new(&mut s.gridmajor).range(1..=100));
let grid_label = ui.label(crate::tl!("Grid spacing"));
ui.add(egui::DragValue::new(&mut gridunit.x).speed(0.05).range(0.0..=1e6).clamp_existing_to_range(false)).labelled_by(grid_label.id);
let major_label = ui.label(crate::tl!("Major line every"));
ui.add(egui::DragValue::new(&mut s.gridmajor).range(1..=100)).labelled_by(major_label.id);
});
ui.separator();
ui.heading(crate::tl!("Polar Tracking"));
@@ -84,12 +84,13 @@ fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
let current = (s.polarang.to_degrees() * 1e6).round() / 1e6;
let mut deg = current;
ui.horizontal(|ui| {
ui.label(crate::tl!("Increment angle"));
egui::ComboBox::from_id_salt("polarang").selected_text(format!("{deg}")).show_ui(ui, |ui| {
let label = ui.label(crate::tl!("Increment angle"));
let combo = egui::ComboBox::from_id_salt("polarang").selected_text(format!("{deg}")).show_ui(ui, |ui| {
for a in [90.0, 45.0, 30.0, 22.5, 18.0, 15.0, 10.0, 5.0] {
ui.selectable_value(&mut deg, a, format!("{a}"));
}
});
combo.response.labelled_by(label.id);
});
if deg != current {
s.polarang = deg.to_radians();
@@ -161,14 +162,15 @@ fn tracking_options(ui: &mut egui::Ui, s: &mut cadcraft_engine::Settings) {
}
let mut additional = s.polar_flags & polarmode::ADDITIONAL != 0;
ui.horizontal(|ui| {
if ui.checkbox(&mut additional, crate::tl!("Additional angles")).changed() {
let additional_label = ui.checkbox(&mut additional, crate::tl!("Additional angles"));
if additional_label.changed() {
s.polar_flags ^= polarmode::ADDITIONAL;
}
// The list as typed (degrees separated by `;`), kept while it doesn't parse yet.
let id = ui.id().with("polaraddang");
let id = ui.scope_id().with("polaraddang");
let shown = cadcraft_engine::sysvars::format_angle_list(&s.polaraddang);
let mut text = ui.data_mut(|d| d.get_temp::<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)
{
@@ -239,7 +241,7 @@ fn pick(ui: &mut egui::Ui, salt: &str, value: &mut i64, options: &[(i64, String)
/// through the `units` command on OK.
fn units(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
use cadcraft_engine::units::{format_angle, format_distance};
let id = egui::Id::new("units_dialog_values");
let id = egui::Id::unique("units_dialog_values");
let mut v = ctx.data_mut(|d| d.get_temp::<[i64; 5]>(id)).unwrap_or_else(|| {
app.session
.doc()
@@ -305,9 +307,9 @@ fn units(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
fn about(ctx: &egui::Context, open: &mut bool) {
let t = Tokens::get();
let tab_id = egui::Id::new("about_tab");
let tab_id = egui::Id::unique("about_tab");
egui::Window::new(crate::tl!("About CADCraft"))
.id(egui::Id::new("About CADCraft"))
.id(egui::Id::unique("About CADCraft"))
.open(open)
.default_size(vec2(640.0, 420.0))
.collapsible(false)
@@ -342,14 +344,14 @@ fn about(ctx: &egui::Context, open: &mut bool) {
fn commands(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
let mut start = None;
egui::Window::new(crate::tl!("Command Reference")).id(egui::Id::new("Command Reference")).open(open).default_size(vec2(640.0, 480.0)).show(
egui::Window::new(crate::tl!("Command Reference")).id(egui::Id::unique("Command Reference")).open(open).default_size(vec2(640.0, 480.0)).show(
ctx,
|ui| {
let id = ui.id().with("cmdfilter");
let id = ui.scope_id().with("cmdfilter");
let mut filter = ui.data_mut(|d| d.get_temp::<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();
@@ -382,7 +384,7 @@ fn commands(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
fn blocks(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
let mut pick = None;
egui::Window::new(crate::tl!("Blocks")).id(egui::Id::new("Blocks")).open(open).default_size(vec2(320.0, 360.0)).show(ctx, |ui| {
egui::Window::new(crate::tl!("Blocks")).id(egui::Id::unique("Blocks")).open(open).default_size(vec2(320.0, 360.0)).show(ctx, |ui| {
let Ok(d) = app.session.doc() else { return };
let names: Vec<&String> = d.blocks.keys().filter(|k| !k.starts_with('*')).collect();
if names.is_empty() {
@@ -412,7 +414,7 @@ fn insert_block(app: &mut CadApp, name: &str) {
}
fn language(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
egui::Window::new(crate::tl!("Interface Language")).id(egui::Id::new("interface_language")).open(open).resizable(false).show(ctx, |ui| {
egui::Window::new(crate::tl!("Interface Language")).id(egui::Id::unique("interface_language")).open(open).resizable(false).show(ctx, |ui| {
egui::ComboBox::from_id_salt("interface_language_choice").selected_text(app.ui.interface_language.name()).show_ui(ui, |ui| {
for preference in crate::i18n::Preference::ALL {
if ui.selectable_label(app.ui.interface_language == preference, preference.name()).clicked() {
+2 -2
View File
@@ -336,7 +336,7 @@ pub fn show(app: &mut crate::CadApp, ui: &mut egui::Ui) {
style.border = egui::Stroke::new(1.0, t.border);
style.accent = t.accent;
style.font = crate::theme::body();
let output = DockArea::new(egui::Id::new("cadcraft-docking")).show_with_limits(
let output = DockArea::new(egui::Id::unique("cadcraft-docking")).show_with_limits(
ui,
&workspace.layout,
&style,
@@ -372,7 +372,7 @@ pub fn show(app: &mut crate::CadApp, ui: &mut egui::Ui) {
let _ = sync(&mut workspace, app);
let stale = workspace.layout != rendered.layout || workspace.hidden != rendered.hidden;
if stale {
craft_ui::docking::cancel_drag::<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;
+1 -1
View File
@@ -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(&current), c.as_str()).clicked() {
+5 -5
View File
@@ -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 {
+4 -4
View File
@@ -430,7 +430,7 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
};
let title = if st.edit.is_empty() { crate::tl!("Hatch and Gradient") } else { crate::tl!("Hatch Edit") };
let mut action = Action::None;
egui::Window::new(title).id(egui::Id::new("hatch_dialog")).open(open).collapsible(false).resizable(false).show(ctx, |ui| {
egui::Window::new(title).id(egui::Id::unique("hatch_dialog")).open(open).collapsible(false).resizable(false).show(ctx, |ui| {
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
ui.set_width(340.0);
@@ -487,12 +487,12 @@ fn fill_ui(ui: &mut egui::Ui, f: &mut Fill, action: &mut Action) {
if f.gradient {
egui::Grid::new("hatch_gradient").num_columns(2).spacing(vec2(12.0, 6.0)).show(ui, |ui| {
ui.label(crate::tl!("Color 1"));
if let Some(c) = crate::layers::color_button(ui, egui::Id::new("hatch_color1"), f.color1, false, 160.0) {
if let Some(c) = crate::layers::color_button(ui, egui::Id::unique("hatch_color1"), f.color1, false, 160.0) {
f.color1 = c;
}
ui.end_row();
ui.label(crate::tl!("Color 2"));
if let Some(c) = crate::layers::color_button(ui, egui::Id::new("hatch_color2"), f.color2, false, 160.0) {
if let Some(c) = crate::layers::color_button(ui, egui::Id::unique("hatch_color2"), f.color2, false, 160.0) {
f.color2 = c;
}
ui.end_row();
@@ -853,7 +853,7 @@ mod tests {
let (mut app, _) = app_with_rect();
open_create(&mut app, gradient);
let ctx = frames(&mut app);
let r = ctx.memory(|m| m.area_rect(egui::Id::new("hatch_dialog"))).unwrap();
let r = ctx.memory(|m| m.area_rect(egui::Id::unique("hatch_dialog"))).unwrap();
// The screen is 1000 points high: the window keeps to its content.
assert!(r.height() < 700.0, "gradient {gradient}: {r:?}");
}
+31 -6
View File
@@ -323,8 +323,9 @@ fn live_switch_translates_the_shell_without_mutating_drawing_or_automation() {
// Without a font for its script, a language stays chosen and the interface shows English.
let shown = app.language();
assert_eq!(shown, if crate::theme::can_show(language.code) { language.code } else { "en" });
assert_eq!(app.fonts_language, Some(shown));
let text = render(&mut app, &ctx);
// Fonts are a frame-only concern: the render above installs them.
assert_eq!(app.fonts_language, Some(shown));
for source in ["Properties", "File"] {
assert!(text.contains(tr(shown, source)), "{}: {source}: {text}", language.code);
}
@@ -345,8 +346,8 @@ fn live_switch_translates_the_shell_without_mutating_drawing_or_automation() {
let _ = crate::control::handle(&mut app, &ctx, &request);
assert_eq!(app.ui.interface_language, Preference::parse(code).unwrap());
app.logic(&ctx);
assert_eq!(app.fonts_language, Some(app.language()));
render(&mut app, &ctx);
assert_eq!(app.fonts_language, Some(app.language()));
}
assert!(render(&mut app, &ctx).contains("Properties"));
// The menu tree used by the control channel keeps its original labels and IDs.
@@ -409,9 +410,9 @@ fn interface_fonts_cover_every_letter_in_each_catalog() {
for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] {
// In egui 0.36, has_glyph can report a false negative for glyphs in the same
// face as the replacement character. Check the family's actual character maps.
let mut font = view.fonts.font(&family);
let chars = view.characters(&family);
for c in &letters {
if !font.characters().contains_key(c) {
if !chars.contains_key(c) {
missing.push(format!("{family:?}: {c} (U+{:04X})", u32::from(*c)));
}
}
@@ -470,9 +471,9 @@ fn korean_ui_fonts_cover_the_language_picker_in_both_families() {
let missing = ctx.fonts_mut(|view| {
let mut missing = Vec::new();
for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] {
let mut font = view.fonts.font(&family);
let chars = view.characters(&family);
for c in "한국어".chars() {
if !font.characters().contains_key(&c) {
if !chars.contains_key(&c) {
missing.push(format!("{family:?}: {c}"));
}
}
@@ -481,3 +482,27 @@ fn korean_ui_fonts_cover_the_language_picker_in_both_families() {
});
assert!(missing.is_empty(), "missing Korean picker glyphs: {}", missing.join(", "));
}
#[test]
fn production_font_chain_covers_latin_and_shortcut_symbols() {
// Exercise the same chain the app installs: the workspace egui dependency enables
// `default_fonts` and `monochrome_emoji_fonts` in production, so web and native builds
// always start with bounded Latin and monochrome icon faces instead of an empty family.
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx, "en");
let mut output = ctx.run_ui(Default::default(), |_| {});
output.textures_delta.clear();
ctx.fonts_mut(|view| {
for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] {
for c in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".chars() {
assert!(view.has_glyph(&egui::FontId::new(12.0, family.clone()), c), "Latin {c:?} missing in {family:?}");
}
}
// Proportional shortcut labels (`Button::shortcut_text` and the translated menus) use
// these symbols: ⌘ from the monochrome icon face, ⇧ from Hack as a bounded fallback.
for c in ['\u{2318}', '\u{21e7}', '\u{21a9}', '\u{2026}', '\u{2212}'] {
assert!(view.has_glyph(&egui::FontId::proportional(12.0), c), "U+{:04X} missing in Proportional", c as u32);
}
assert!(view.has_glyph(&egui::FontId::monospace(12.0), '\u{21e7}'), "U+21E7 shift symbol missing in Monospace");
});
}
+5 -3
View File
@@ -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| {
+18 -12
View File
@@ -218,7 +218,7 @@ fn natural_lower(s: &str) -> String {
/// The Layer Properties Manager window.
pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
let t = Tokens::get();
let sid = egui::Id::new("lpm_state");
let sid = egui::Id::unique("lpm_state");
let mut st: LpmState = ctx.data_mut(|d| d.get_temp(sid)).unwrap_or_default();
let mut actions: Vec<(&str, Value)> = Vec::new();
let Ok(dst) = app.session.state() else {
@@ -249,7 +249,7 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
None => "Layer Properties Manager",
};
egui::Window::new(crate::i18n::t(title))
.id(egui::Id::new("lpm_window"))
.id(egui::Id::unique("lpm_window"))
.open(open)
.default_size(vec2(1180.0, 440.0))
.min_width(640.0)
@@ -302,7 +302,8 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
{
actions.push(("layerstate.delete", json!({ "name": s })));
}
ui.add(egui::TextEdit::singleline(&mut st.state_name).hint_text(crate::tl!("New state name")).desired_width(120.0));
ui.add(egui::TextEdit::singleline(&mut st.state_name).hint_text(crate::tl!("New state name")).desired_width(120.0))
.on_hover_text(crate::tl!("New state name"));
if ui.add_enabled(!st.state_name.trim().is_empty(), egui::Button::new(crate::tl!("Save State"))).clicked() {
let n = st.state_name.trim().to_string();
actions.push(("layerstate.save", json!({ "name": n })));
@@ -310,7 +311,8 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
st.state_name.clear();
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.add(egui::TextEdit::singleline(&mut st.search).hint_text(crate::tl!("Search for layer")).desired_width(160.0));
ui.add(egui::TextEdit::singleline(&mut st.search).hint_text(crate::tl!("Search for layer")).desired_width(160.0))
.on_hover_text(crate::tl!("Search for layer"));
icons::paint(
ui.painter(),
Rect::from_center_size(ui.cursor().right_center() - vec2(8.0, 0.0), vec2(14.0, 14.0)),
@@ -439,7 +441,9 @@ fn row(
// Name (double-click or F2-style rename).
match st.renaming.clone() {
Some((n, mut buf)) if n.eq_ignore_ascii_case(&name) => {
let r = ui.add(egui::TextEdit::singleline(&mut buf).desired_width(150.0).id(egui::Id::new(("lpm_rename", &name))));
let r = ui
.add(egui::TextEdit::singleline(&mut buf).desired_width(150.0).id(egui::Id::unique(("lpm_rename", &name))))
.accessible_name(crate::tl!("Name"));
if !r.has_focus() && !r.lost_focus() {
r.request_focus();
}
@@ -503,7 +507,7 @@ fn row(
actions.push(("layer.set", json!({ "name": name, "plot": !l.plot })));
}
// Color.
if let Some(c) = color_button(ui, egui::Id::new(("lpm_color", &name)), l.color, false, 96.0) {
if let Some(c) = color_button(ui, egui::Id::unique(("lpm_color", &name)), l.color, false, 96.0) {
actions.push(("layer.set", json!({ "name": name, "color": c.name() })));
}
// Linetype.
@@ -524,7 +528,7 @@ fn row(
})
.inner;
egui::Popup::from_toggle_button_response(&lt_resp)
.id(egui::Id::new(("lpm_lt", &name)))
.id(egui::Id::unique(("lpm_lt", &name)))
.width(240.0)
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
.show(|ui| {
@@ -580,7 +584,7 @@ fn row(
})
.inner;
egui::Popup::from_toggle_button_response(&lw_resp)
.id(egui::Id::new(("lpm_lw", &name)))
.id(egui::Id::unique(("lpm_lw", &name)))
.width(170.0)
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
.show(|ui| {
@@ -611,7 +615,7 @@ fn row(
});
});
// Transparency (commits when the drag or edit ends).
let tid = egui::Id::new(("lpm_tr", &name));
let tid = egui::Id::unique(("lpm_tr", &name));
let mut tr = ui.data_mut(|d| d.get_temp::<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 +636,7 @@ fn row(
}
let vc = v.layer_colors.iter().find(|(n, _)| n.eq_ignore_ascii_case(&name)).map(|(_, c)| *c);
ui.horizontal(|ui| {
if let Some(c) = color_button(ui, egui::Id::new(("lpm_vpcolor", &name)), vc.unwrap_or(l.color), false, 96.0) {
if let Some(c) = color_button(ui, egui::Id::unique(("lpm_vpcolor", &name)), vc.unwrap_or(l.color), false, 96.0) {
actions.push(("vplayer", json!({ "handle": h.hex(), "colors": { name.clone(): c.name() } })));
}
if vc.is_some() && ui.small_button("×").on_hover_text(crate::tl!("Remove the viewport override")).clicked() {
@@ -653,7 +657,9 @@ fn row(
// Description (double-click to edit).
match st.describing.clone() {
Some((n, mut buf)) if n.eq_ignore_ascii_case(&name) => {
let r = ui.add(egui::TextEdit::singleline(&mut buf).desired_width(200.0).id(egui::Id::new(("lpm_desc", &name))));
let r = ui
.add(egui::TextEdit::singleline(&mut buf).desired_width(200.0).id(egui::Id::unique(("lpm_desc", &name))))
.accessible_name(crate::tl!("Description"));
if !r.has_focus() && !r.lost_focus() {
r.request_focus();
}
@@ -750,7 +756,7 @@ mod tests {
dialog(&mut app, ui.ctx(), &mut open);
});
out.textures_delta.clear();
let id = egui::LayerId::new(egui::Order::Middle, egui::Id::new("lpm_window"));
let id = egui::LayerId::new(egui::Order::Middle, egui::Id::unique("lpm_window"));
heights.push(ctx.memory(|m| m.area_rect(id.id)).map_or(0.0, |r| r.height()));
}
let (first, last) = (heights[5], heights[29]);
+299 -130
View File
@@ -13,6 +13,7 @@ pub mod animation;
pub mod autosave;
pub mod bedit;
pub mod canvas;
pub mod capture;
pub mod chrome;
pub mod clipboard;
pub mod closing;
@@ -24,6 +25,7 @@ pub mod dialogs;
pub mod docking;
pub mod hatchdlg;
mod mtext_editor;
mod panel_state;
pub mod spacemouse;
// The External References palette.
pub mod xrefs;
@@ -210,15 +212,28 @@ pub struct CadApp {
pub opening: opening::Opening,
pub integrated_titlebar: bool,
control_rx: Option<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.
@@ -266,13 +281,17 @@ impl CadApp {
opening: opening::Opening::default(),
integrated_titlebar: false,
control_rx: None,
pending_shots: Vec::new(),
queued_shots: Vec::new(),
shot_token: 0,
capture_geometry: None,
capture_epoch: 0,
capture_identity_epoch: 0,
shots: capture::Shots::default(),
synthetic: Vec::new(),
synthetic_injected: false,
ui_frames: 0,
#[cfg(not(target_arch = "wasm32"))]
frame_cap: None,
fonts_language: None,
shown_language: None,
shown_theme: None,
shown_follows_system: false,
shown_animations: None,
@@ -416,6 +435,11 @@ impl CadApp {
self.status = Some((s.into(), now_ms()));
}
/// Cancel queued/in-flight screenshots (host exit). Late callbacks write nothing.
pub fn cancel_screenshots(&mut self) {
capture::cancel_all(self);
}
/// Open the drawing at `path`. Natively the file is read on a worker thread while an
/// "Opening ..." card shows ([`opening`]); [`Self::finish_opening`] adds it when ready. On the
/// web it opens at once.
@@ -532,22 +556,121 @@ impl CadApp {
recent.truncate(RECENT_FILES);
}
/// Per-frame logic before layout.
/// Consume asynchronous work on every logic callback, including hidden `run_logic` ticks.
///
/// Consume-based queues and screenshot settlement run on **every** call, whichever pass
/// delivers it. Frame work (LISP progress, autosave, close requests, screenshot issuance
/// pacing, hidden repaints) runs once per host frame: eframe calls `App::logic` inside every
/// pass of a multi-pass frame, and also on hidden logic-only ticks where no pass runs and the
/// pass index is zero. `current_pass_index() == 0` is therefore the frame guard: true on the
/// first pass and every hidden tick, false on later passes.
pub fn logic(&mut self, ctx: &egui::Context) {
// A long LISP routine runs a slice per frame, so the window stays live (and Esc stops it).
self.session.lisp_tick();
if self.session.lisp_busy() {
ctx.request_repaint();
// Consume-based queues: each item is handled once, whichever callback delivers it. A
// reply arriving during a later pass is never starved until the next frame.
self.drain_control(ctx);
self.finish_opening();
// Finishing an open switches to the new drawing.
capture::note_identity(self, ctx);
capture::drain(self, ctx);
self.gpu_fault_tick(ctx);
managers::plot_window_resume(self, ctx);
hatchdlg::resume(self);
// What follows is frame work, once per layout frame or hidden tick.
if ctx.current_pass_index() != 0 {
return;
}
// Closing the window (title bar, File ▸ Exit, Cmd+Q) with unsaved changes asks first.
// Closing the window with unsaved changes asks first. `run_logic` refreshes viewport
// close/focus state, so this must also work while hidden.
if ctx.input(|i| i.viewport().close_requested()) && self.request_quit() {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
}
// A long AutoLISP routine runs one bounded slice per frame, including hidden web/native
// ticks; it must not depend on a paint or `RequestPaintWhileHidden`.
self.session.lisp_tick();
// Time-based and idempotent by its own deadline; keeps autosave alive while hidden.
self.autosave_tick(ctx);
if !self.synthetic.is_empty() || self.synthetic_injected {
request_paint_while_hidden(ctx);
}
if self.session.lisp_busy() || self.opening.busy() {
request_paint_while_hidden(ctx);
}
capture::request_hidden_paint(self, ctx);
}
/// Input hook: refresh the raw surface snapshot and release at most one synthetic batch.
///
/// Eframe keeps the unused `RawInput` across hidden logic-only ticks, so a batch already in
/// the buffer is never injected again; the next batch waits for a real `ui` pass to consume
/// the previous one.
pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
capture::note_raw_geometry(self, ctx, raw);
if self.synthetic_injected || self.synthetic.is_empty() {
return;
}
let n = match self.synthetic.first() {
Some(egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. }) => 1,
_ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1),
};
if let Some(egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() {
raw.events.push(egui::Event::PointerMoved(*p));
}
// A synthetic click holds its Shift/Ctrl (Shift+click a grip); the next move lets go.
match self.synthetic.first() {
Some(egui::Event::PointerButton { modifiers, .. }) => raw.events.push(egui::Event::ModifiersChanged(*modifiers)),
Some(egui::Event::PointerMoved(_)) => raw.events.push(egui::Event::ModifiersChanged(egui::Modifiers::default())),
_ => {}
}
let n = n.min(self.synthetic.len());
raw.events.extend(self.synthetic.drain(..n));
self.synthetic_injected = true;
}
/// Lay out the whole window. This is the only place with a real UI pass.
pub fn ui(&mut self, ui: &mut egui::Ui) {
let ctx = ui.ctx().clone();
let first_pass = ctx.current_pass_index() == 0;
if first_pass {
self.ui_frame_begin(ui);
} else {
// A second pass must render the state the first pass produced. egui's raw input is
// empty there, but the smoothed scroll delta can persist; never pan/zoom twice.
ctx.input_mut(|input| {
input.smooth_scroll_delta = egui::Vec2::ZERO;
input.raw.events.retain(|event| !matches!(event, egui::Event::MouseWheel { .. } | egui::Event::Zoom(_)));
});
}
// The settled geometry and ppp of this pass, before drawing and after the frame's saved
// interface zoom was applied (egui applies a new zoom at the start of a pass).
capture::note_pass_geometry(self, &ctx);
i18n::with_language(self.language(), || self.draw(ui));
if first_pass {
// The batch reached an actual Ui callback; a discarded pass still consumed it.
self.synthetic_injected = false;
}
// `will_discard()` is false on the settled (final) pass of a frame, whether the frame is
// single-pass or the second pass after a layout discard; a discarded pass is skipped.
if !ctx.will_discard() {
self.ui_frames = self.ui_frames.saturating_add(1);
capture::issue(self, &ctx);
}
}
/// Frame-only work, run from the first real UI pass (never from a hidden logic tick).
fn ui_frame_begin(&mut self, ui: &mut egui::Ui) {
let ctx = ui.ctx().clone();
let ctx = &ctx;
let language = self.language();
if self.fonts_language != Some(language) {
theme::install_fonts(ctx, language);
self.fonts_language = Some(language);
}
// The AccessKit root carries the language the interface is actually shown in; egui copies
// it onto the root node at the end of the pass.
if self.shown_language != Some(language) {
ctx.set_language(Some(language.to_string()));
self.shown_language = Some(language);
}
// Resolved every frame, so a System choice follows the OS appearance live (winit and the
// browser report changes as input); the saved choice itself never changes here.
let shown = self.ui.theme.resolve(ctx.system_theme());
@@ -575,57 +698,31 @@ impl CadApp {
ctx.set_zoom_factor(scale);
self.shown_interface_scale = Some(scale);
}
self.drain_control(ctx);
self.finish_opening();
self.opening.show(ctx);
if !self.synthetic.is_empty() {
ctx.request_repaint();
}
self.collect_screenshots(ctx);
self.issue_screenshots(ctx);
self.gpu_fault_tick(ctx);
managers::plot_window_resume(self, ctx);
hatchdlg::resume(self);
self.autosave_tick(ctx);
menus::shortcuts(self, ctx);
#[cfg(not(target_arch = "wasm32"))]
for f in ctx.input(|i| i.raw.dropped_files.clone()) {
let p = f.path().to_string_lossy().to_string();
if !p.is_empty() {
self.open_path(&p);
}
}
}
/// Wait for the frame cap (if any), then inject synthetic events (one pointer event per frame).
pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) {
// Wait for the frame cap on a painted pass, where a wait cannot delay a hidden tick.
#[cfg(not(target_arch = "wasm32"))]
if let Some(cap) = &mut self.frame_cap {
cap.wait();
}
if self.synthetic.is_empty() {
return;
}
let n = match self.synthetic.first() {
Some(egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. }) => 1,
_ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1),
};
if let Some(egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() {
raw.events.push(egui::Event::PointerMoved(*p));
}
// A synthetic click holds its Shift/Ctrl (Shift+click a grip); the next move lets go.
match self.synthetic.first() {
Some(egui::Event::PointerButton { modifiers, .. }) => raw.events.push(egui::Event::ModifiersChanged(*modifiers)),
Some(egui::Event::PointerMoved(_)) => raw.events.push(egui::Event::ModifiersChanged(egui::Modifiers::default())),
_ => {}
}
let n = n.min(self.synthetic.len());
raw.events.extend(self.synthetic.drain(..n));
panel_state::migrate(ui);
self.ui_input(ctx);
}
/// Lay out the whole window.
pub fn ui(&mut self, ui: &mut egui::Ui) {
i18n::with_language(self.language(), || self.draw(ui));
/// Input-derived work, once per layout frame.
fn ui_input(&mut self, ctx: &egui::Context) {
menus::shortcuts(self, ctx);
#[cfg(not(target_arch = "wasm32"))]
{
let dropped = ctx.input_mut(|input| std::mem::take(&mut input.raw.dropped_files));
for file in dropped {
let path = file.path().to_string_lossy().to_string();
if !path.is_empty() {
self.open_path(&path);
}
}
}
if !self.synthetic.is_empty() {
ctx.request_repaint();
}
}
fn draw(&mut self, ui: &mut egui::Ui) {
@@ -653,6 +750,7 @@ impl CadApp {
chrome::start_page(self, ui);
}
});
self.opening.show(ui.ctx());
dialogs::show(self, ui.ctx());
palettes::patterns::prompt_picker(self, ui.ctx());
closing::prompt(self, ui.ctx());
@@ -673,85 +771,31 @@ impl CadApp {
control::Outcome::Done(v) => {
let _ = reply.send(v);
}
control::Outcome::Screenshot { path } => {
self.shot_token += 1;
let token = self.shot_token;
self.queued_shots.push((token, now_ms() + 120.0, 0));
self.pending_shots.push((token, path, reply, now_ms() + 8000.0));
}
control::Outcome::Screenshot { path } => capture::admit(self, ctx, path, reply),
}
// A command can switch drawings or move the focus; a screenshot queued before it must
// see that transition even if a later command returns to the same drawing.
capture::note_identity(self, ctx);
ctx.request_repaint();
}
self.control_rx = Some(rx);
}
fn issue_screenshots(&mut self, ctx: &egui::Context) {
let now = now_ms();
// Wait for a background rebuild for the current view, so the shot shows the drawing at
// that view, but not past the reply's deadline.
let building = self.canvas.building();
self.queued_shots.retain_mut(|(token, at, frames)| {
*frames += 1;
if now >= *at && *frames >= 3 && (!building || now >= *at + SHOT_REBUILD_WAIT_MS) {
ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(*token)));
false
} else {
true
}
});
if !self.queued_shots.is_empty() || !self.pending_shots.is_empty() {
ctx.request_repaint_after(std::time::Duration::from_millis(16));
}
}
fn collect_screenshots(&mut self, ctx: &egui::Context) {
if self.pending_shots.is_empty() {
return;
}
let events: Vec<_> = ctx.input(|i| {
i.raw
.events
.iter()
.filter_map(|e| match e {
egui::Event::Screenshot { user_data, image, .. } => {
let token = user_data.data.as_ref().and_then(|d| d.downcast_ref::<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.
@@ -768,13 +812,138 @@ mod tests {
CadApp::new(Session::empty(), Services::default())
}
/// One frame of `logic` with the OS reporting `system` as its appearance.
/// One real UI frame, with the OS reporting `system` as its appearance.
fn frame(app: &mut CadApp, ctx: &egui::Context, system: Option<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}");
}
#[test]
fn hidden_logic_does_not_run_frame_only_work() {
let mut a = app();
let ctx = egui::Context::default();
// A logic-only call (what eframe runs for a hidden window) reports pass index zero too.
a.logic(&ctx);
assert_eq!(a.ui_frames, 0, "no real Ui callback ran");
assert_eq!(a.fonts_language, None, "fonts are a frame-only concern");
frame(&mut a, &ctx, None);
assert_eq!(a.ui_frames, 1);
assert!(a.fonts_language.is_some());
}
#[test]
fn hidden_logic_advances_a_long_lisp_routine_without_a_ui_pass() {
use cadcraft_engine::lisp::value::Value;
let mut a = CadApp::new(Session::new(), Services::default());
let ctx = egui::Context::default();
let started = a
.session
.lisp_eval("(setq cc-hidden 0) (while (< cc-hidden 400000) (setq cc-hidden (1+ cc-hidden)))", false, false, Vec::new())
.expect("the routine starts");
assert!(started.is_none(), "a non-blocking routine yields before finishing");
assert!(a.session.lisp_busy(), "the first slice reached the yield point");
for _ in 0..2_000 {
if !a.session.lisp_busy() {
break;
}
// What eframe runs for a hidden window: logic only, no `Ui` callback.
a.logic(&ctx);
}
assert!(!a.session.lisp_busy(), "hidden logic ticks carried the routine to the end");
assert!(matches!(a.session.lisp_var("CC-HIDDEN"), Value::Int(400_000)));
assert_eq!(a.ui_frames, 0, "no frame was painted");
}
#[test]
fn one_synthetic_batch_waits_for_a_real_ui_pass() {
let mut a = app();
let ctx = egui::Context::default();
a.synthetic.push(egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE });
let mut first = egui::RawInput::default();
a.raw_input_hook(&ctx, &mut first);
assert!(first.events.iter().any(|event| matches!(event, egui::Event::Key { key: egui::Key::A, .. })));
// A hidden tick (or a second raw hook before the UI) must not append another batch.
let mut second = egui::RawInput::default();
a.raw_input_hook(&ctx, &mut second);
assert!(second.events.is_empty(), "the injected batch is still pending");
let mut out = ctx.run_ui(first, |ui| {
a.logic(ui.ctx());
a.ui(ui);
});
out.textures_delta.clear();
// The batch was consumed by a real Ui callback; a new synthetic event can now be injected.
a.synthetic.push(egui::Event::Key { key: egui::Key::B, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE });
let mut third = egui::RawInput::default();
a.raw_input_hook(&ctx, &mut third);
assert!(third.events.iter().any(|event| matches!(event, egui::Event::Key { key: egui::Key::B, .. })));
}
#[test]
fn theme_choice_persists_and_drives_tokens() {
let mut a = app();
+27 -25
View File
@@ -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) {
@@ -172,8 +174,8 @@ fn linetype(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {
});
ui.separator();
ui.horizontal(|ui| {
ui.label("Global scale factor");
if let Some(v) = num_field(ui, "ltmgr_scale", ltscale).filter(|v| *v > 0.0) {
let label = ui.label("Global scale factor");
if let Some(v) = num_field(ui, "ltmgr_scale", ltscale, Some(label.id)).filter(|v| *v > 0.0) {
action = Some(("ltscale", json!({ "scale": v })));
}
});
@@ -275,8 +277,8 @@ fn point_style(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action
});
ui.add_space(6.0);
ui.horizontal(|ui| {
ui.label("Point Size");
if let Some(v) = num_field(ui, "ptype_size", size).filter(|v| *v > 0.0) {
let label = ui.label("Point Size");
if let Some(v) = num_field(ui, "ptype_size", size, Some(label.id)).filter(|v| *v > 0.0) {
action = set(mode, v, rel);
}
ui.label(if rel { "%" } else { "units" });
@@ -415,7 +417,7 @@ fn style_manager(ctx: &egui::Context, open: &mut bool, title: &str, cmd: &'stati
action = Some((cmd, json!({ "name": sel })));
}
left.horizontal(|ui| {
ui.add(egui::TextEdit::singleline(&mut new_name).hint_text("New style name").desired_width(120.0));
ui.add(egui::TextEdit::singleline(&mut new_name).hint_text("New style name").desired_width(120.0)).on_hover_text("New style name");
let name = new_name.trim().to_string();
let ok = !name.is_empty() && !styles.iter().any(|s| s.name.eq_ignore_ascii_case(&name));
if ui.add_enabled(ok, egui::Button::new("New")).clicked() {
@@ -430,8 +432,8 @@ fn style_manager(ctx: &egui::Context, open: &mut bool, title: &str, cmd: &'stati
for f in &style.fields {
let change = match f {
Field::Num(key, label, v) => {
ui.label(*label);
num_field(ui, (cmd, &style.name, *key), *v).filter(|v| *v >= 0.0).map(|v| (*key, json!(v)))
let field_label = ui.label(*label);
num_field(ui, (cmd, &style.name, *key), *v, Some(field_label.id)).filter(|v| *v >= 0.0).map(|v| (*key, json!(v)))
}
Field::Bool(key, label, v) => {
ui.label("");
@@ -502,7 +504,7 @@ fn page_setup(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action
// Layout plotting always prints the whole sheet, so only that area is offered.
let rows = PageRows { areas: &["layout"], scale: true, center: true };
egui::Grid::new("pagesetup_grid").num_columns(2).spacing(vec2(10.0, 6.0)).show(right, |ui| {
if let Some((k, v)) = page_rows(ui, egui::Id::new(("pagesetup", name)), page, &rows, &choices, None) {
if let Some((k, v)) = page_rows(ui, egui::Id::unique(("pagesetup", name)), page, &rows, &choices, None) {
action = Some(("pagesetup", json!({ "layout": name, k: v })));
}
});
@@ -666,9 +668,9 @@ fn page_rows(
});
ui.end_row();
for (i, label) in [(0, "X offset (mm)"), (1, "Y offset (mm)")] {
ui.label(label);
let field_label = ui.label(label);
ui.add_enabled_ui(supported && !*center, |ui| {
if let Some(v) = num_field(ui, ("plot_offset", i), offset_mm[i]) {
if let Some(v) = num_field(ui, ("plot_offset", i), offset_mm[i], Some(field_label.id)) {
offset_mm[i] = v;
}
})
@@ -683,9 +685,9 @@ fn page_rows(
}
}
if rows.scale {
ui.label("Scale");
let label = ui.label("Scale");
ui.add_enabled_ui(!page.scale_to_fit, |ui| {
if let Some(v) = expr_field(ui, salt.with("scale"), &fmt_num(page.scale)).and_then(|t| parse_ratio(&t)) {
if let Some(v) = expr_field(ui, salt.with("scale"), &fmt_num(page.scale), Some(label.id)).and_then(|t| parse_ratio(&t)) {
change = Some(("scale", json!(v)));
}
})
@@ -955,7 +957,7 @@ fn plot(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {
PageRows { areas: &["layout"], scale: false, center: false }
};
let placement = model.then_some((&mut st.center, &mut st.offset_mm, offset_supported));
if let Some((k, v)) = page_rows(ui, egui::Id::new(("plot", st.target.as_str())), &st.page, &rows, &choices, placement) {
if let Some((k, v)) = page_rows(ui, egui::Id::unique(("plot", st.target.as_str())), &st.page, &rows, &choices, placement) {
apply_page_change(&mut st.page, k, &v);
}
if model && st.page.plot_area == "window" {
@@ -963,10 +965,10 @@ fn plot(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {
ui.label(label);
ui.horizontal(|ui| {
for (j, axis) in ["X", "Y"].iter().enumerate() {
ui.label(*axis);
let axis_label = ui.label(*axis);
let k = i + j;
if let Some(slot) = st.window.get_mut(k)
&& let Some(v) = num_field(ui, ("plot_window", k), *slot)
&& let Some(v) = num_field(ui, ("plot_window", k), *slot, Some(axis_label.id))
{
*slot = v;
}
@@ -1096,8 +1098,8 @@ fn constraint_settings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -
action = set("barsVisible", json!(bars));
}
ui.horizontal(|ui| {
ui.label("Constraint bar transparency (%)");
if let Some(v) = num_field(ui, "csettings_transp", f64::from(s.bar_transparency)) {
let label = ui.label("Constraint bar transparency (%)");
if let Some(v) = num_field(ui, "csettings_transp", f64::from(s.bar_transparency), Some(label.id)) {
action = set("barTransparency", json!(v));
}
});
@@ -1124,13 +1126,13 @@ fn constraint_settings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -
}
});
egui::Grid::new("csettings_tol").num_columns(2).show(ui, |ui| {
ui.label("Distance tolerance");
if let Some(v) = num_field(ui, "csettings_dist", s.distance_tolerance) {
let label = ui.label("Distance tolerance");
if let Some(v) = num_field(ui, "csettings_dist", s.distance_tolerance, Some(label.id)) {
action = set("distanceTolerance", json!(v));
}
ui.end_row();
ui.label("Angle tolerance (degrees)");
if let Some(v) = num_field(ui, "csettings_angle", s.angle_tolerance) {
let label = ui.label("Angle tolerance (degrees)");
if let Some(v) = num_field(ui, "csettings_angle", s.angle_tolerance, Some(label.id)) {
action = set("angleTolerance", json!(v));
}
ui.end_row();
+14 -12
View File
@@ -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);
}
+243 -31
View File
@@ -23,6 +23,42 @@ const SWATCHES: [u16; 7] = [1, 2, 3, 4, 5, 6, 7];
/// Ruler snap, in text heights.
const SNAP: f64 = 0.25;
/// Keep the actual diameter character in editable text even without optional fonts.
/// This original circle-and-slash rasterizer is a last fallback, after installed fonts.
pub(crate) fn install_diameter_glyph(ctx: &egui::Context) {
ctx.add_glyph_rasterizer(egui::GlyphRasterizer::new("cadcraft::diameter", |request| {
if request.cluster != "⌀" || !request.font_size_px.is_finite() || !(1.0..=1024.0).contains(&request.font_size_px) {
return None;
}
let size = request.font_size_px;
let side = (size * 0.8).ceil() as usize + 2;
let center = vec2(1.0 + size * 0.4, 1.0 + size * 0.4);
let radius = size * 0.3;
let half_stroke = (size * 0.05).max(1.0) * 0.5;
let start = center + vec2(-radius, radius);
let segment = vec2(2.0 * radius, -2.0 * radius);
let mut pixels = Vec::with_capacity(side * side);
for y in 0..side {
for x in 0..side {
let point = vec2(x as f32 + 0.5, y as f32 + 0.5);
let ring_distance = ((point - center).length() - radius).abs();
let along = ((point - start).dot(segment) / segment.length_sq()).clamp(0.0, 1.0);
let slash_distance = (point - (start + segment * along)).length();
let alpha = ((half_stroke + 0.5 - ring_distance.min(slash_distance)).clamp(0.0, 1.0) * 255.0).round() as u8;
pixels.push(Color32::from_white_alpha(alpha));
}
}
Some(egui::RasterizedGlyph {
bitmap: egui::GlyphBitmap {
image: egui::ColorImage::new([side, side], pixels),
offset_px: vec2(0.0, -size * 0.8 - 1.0),
is_color: false,
},
advance_px: size * 0.85 + 2.0,
})
}));
}
#[derive(Clone)]
struct State {
fmt: Formatted,
@@ -39,11 +75,11 @@ struct State {
}
fn state_id() -> egui::Id {
egui::Id::new("mtext_editor_state")
egui::Id::unique("mtext_editor_state")
}
fn edit_id() -> egui::Id {
egui::Id::new("mtext_editor_text")
egui::Id::unique("mtext_editor_text")
}
/// The ACI index or true colour as a screen colour.
@@ -89,7 +125,7 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) {
let mut done = Done::No;
let top_start = pos.y - st.top;
egui::Area::new(egui::Id::new("mtext_editor_area")).order(egui::Order::Foreground).fixed_pos(pos2(pos.x, top_start)).show(ctx, |ui| {
egui::Area::new(egui::Id::unique("mtext_editor_area")).order(egui::Order::Foreground).fixed_pos(pos2(pos.x, top_start)).show(ctx, |ui| {
egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.0, t.accent)).inner_margin(egui::Margin::same(0)).show(ui, |ui| {
ui.set_width(width);
let y0 = ui.cursor().top();
@@ -194,21 +230,22 @@ fn toolbar(ui: &mut egui::Ui, st: &mut State, base_h: f64) {
ui.add_space(4.0);
// Font
let shown = cur.font.clone().unwrap_or_else(|| st.fmt.base_font.clone());
egui::ComboBox::from_id_salt("mtext_font").width(120.0).selected_text(shown.clone()).show_ui(ui, |ui| {
for fam in FAMILIES.iter().map(|s| s.to_string()).chain(cur.font.clone().filter(|f| !FAMILIES.contains(&f.as_str()))) {
if ui.selectable_label(shown == fam, &fam).clicked() {
change(st, |c| c.font = Some(fam.clone()));
refocus = true;
egui::ComboBox::from_id_salt("mtext_font")
.width(120.0)
.selected_text(shown.clone())
.show_ui(ui, |ui| {
for fam in FAMILIES.iter().map(|s| s.to_string()).chain(cur.font.clone().filter(|f| !FAMILIES.contains(&f.as_str()))) {
if ui.selectable_label(shown == fam, &fam).clicked() {
change(st, |c| c.font = Some(fam.clone()));
refocus = true;
}
}
}
});
})
.response
.accessible_name(crate::tl!("Font"));
// Height
let mut h = cur.height.unwrap_or(base_h);
if ui
.add(egui::DragValue::new(&mut h).speed(base_h * 0.02).range(base_h * 0.05..=base_h * 50.0).max_decimals(4))
.on_hover_text(crate::tl!("Text height"))
.changed()
{
if height_field(ui, &mut h, base_h) {
let none = (h - base_h).abs() < 1e-9;
change(st, |c| c.height = (!none).then_some(h));
}
@@ -226,7 +263,7 @@ fn toolbar(ui: &mut egui::Ui, st: &mut State, base_h: f64) {
}
// Colour
let swatch = cur.color.map_or(ui.visuals().text_color(), color32);
ui.menu_button(RichText::new("A▾").color(swatch).strong(), |ui| {
ui.menu_button(RichText::new("A").color(swatch).strong(), |ui| {
if ui.button(crate::tl!("ByLayer")).clicked() {
change(st, |c| c.color = None);
refocus = true;
@@ -253,49 +290,140 @@ fn toolbar(ui: &mut egui::Ui, st: &mut State, base_h: f64) {
}
})
.response
.accessible_name(crate::tl!("Text color"))
.on_hover_text(crate::tl!("Text color"));
ui.separator();
// Paragraph alignment
let para = st.fmt.para(st.fmt.paragraph_of(range.start));
for (al, glyph, tip) in [
(ParaAlign::Left, "⇤", crate::tl!("Left")),
(ParaAlign::Center, "↔", crate::tl!("Center")),
(ParaAlign::Right, "⇥", crate::tl!("Right")),
(ParaAlign::Justify, "≡", crate::tl!("Justify")),
for (al, tip) in [
(ParaAlign::Left, crate::tl!("Left")),
(ParaAlign::Center, crate::tl!("Center")),
(ParaAlign::Right, crate::tl!("Right")),
(ParaAlign::Justify, crate::tl!("Justify")),
] {
let on = para.align == al;
if ui.selectable_label(on, glyph).on_hover_text(tip).clicked() {
if formatting_button(ui, FormattingIcon::Align(al), tip, on) {
st.fmt.edit_paras(range.clone(), |p| p.align = if on { ParaAlign::Default } else { al });
refocus = true;
}
}
ui.separator();
// Lists and special characters
if ui.button("•≡").on_hover_text(crate::tl!("Bulleted list")).clicked() {
if formatting_button(ui, FormattingIcon::Bullets, crate::tl!("Bulleted list"), false) {
st.fmt.make_list(range.clone(), ListStyle::Bullet, 3.0 * base_h);
refocus = true;
}
if ui.button("1≡").on_hover_text(crate::tl!("Numbered list")).clicked() {
if formatting_button(ui, FormattingIcon::Numbers, crate::tl!("Numbered list"), false) {
st.fmt.make_list(range.clone(), ListStyle::Number, 3.0 * base_h);
refocus = true;
}
for (glyph, tip, code) in [
("⇥|", crate::tl!("Insert tab"), "\t"),
("°", crate::tl!("Degree"), "°"),
("±", crate::tl!("Plus/minus"), "±"),
("⌀", crate::tl!("Diameter"), "⌀"),
] {
if formatting_button(ui, FormattingIcon::Tab, crate::tl!("Insert tab"), false) {
insert(st, "\t");
refocus = true;
}
for (glyph, tip, code) in [("°", crate::tl!("Degree"), "°"), ("±", crate::tl!("Plus/minus"), "±")] {
if ui.button(glyph).on_hover_text(tip).clicked() {
insert(st, code);
refocus = true;
}
}
if diameter_button(ui) {
insert(st, "⌀");
refocus = true;
}
});
if refocus {
ui.ctx().memory_mut(|m| m.request_focus(edit_id()));
}
}
/// Formatting marks use geometry so the toolbar works with the bundled font stack on web too.
enum FormattingIcon {
Align(ParaAlign),
Bullets,
Numbers,
Tab,
}
fn formatting_button(ui: &mut egui::Ui, icon: FormattingIcon, label: &str, selected: bool) -> bool {
let response = ui
.add(egui::Button::new(" ").selected(selected).min_size(vec2(22.0, ui.spacing().interact_size.y)))
.accessible_name(label)
.on_hover_text(label);
let painter = ui.painter();
let c = response.rect.center();
let color = if response.enabled() { ui.visuals().text_color() } else { ui.visuals().weak_text_color() };
let stroke = Stroke::new(1.2, color);
match icon {
FormattingIcon::Align(align) => {
for row in 0..3 {
let width = if row == 1 && align != ParaAlign::Justify { 8.0 } else { 14.0 };
let x = match align {
ParaAlign::Center => -width / 2.0,
ParaAlign::Right => 7.0 - width,
_ => -7.0,
};
let y = (row as f32 - 1.0) * 4.0;
painter.line_segment([c + vec2(x, y), c + vec2(x + width, y)], stroke);
}
}
FormattingIcon::Bullets | FormattingIcon::Numbers => {
for row in 0..3 {
let y = (row as f32 - 1.0) * 5.0;
if matches!(icon, FormattingIcon::Bullets) {
painter.circle_filled(c + vec2(-6.0, y), 1.2, color);
} else {
painter.text(c + vec2(-6.0, y), Align2::CENTER_CENTER, (row + 1).to_string(), FontId::proportional(7.0), color);
}
painter.line_segment([c + vec2(-2.0, y), c + vec2(7.0, y)], stroke);
}
}
FormattingIcon::Tab => {
for [a, b] in [
[vec2(-7.0, 0.0), vec2(4.0, 0.0)],
[vec2(0.0, -4.0), vec2(4.0, 0.0)],
[vec2(0.0, 4.0), vec2(4.0, 0.0)],
[vec2(7.0, -5.0), vec2(7.0, 5.0)],
] {
painter.line_segment([c + a, c + b], stroke);
}
}
}
response.clicked()
}
/// The MTEXT toolbar's height field. It has no visible caption, so the AccessKit label carries
/// the meaning of its hover tooltip; a tooltip alone leaves an unnamed spin button.
fn height_field(ui: &mut egui::Ui, height: &mut f64, base_h: f64) -> bool {
ui.add(egui::DragValue::new(height).speed(base_h * 0.02).range(base_h * 0.05..=base_h * 50.0).max_decimals(4))
.on_hover_text(crate::tl!("Text height"))
.accessible_name(crate::tl!("Text height"))
.changed()
}
/// The MTEXT Diameter button. U+2300 is absent from every bundled face (including the
/// monochrome and emoji fallbacks) and the web build has no system fonts, so the sign is painted
/// rather than shaped from text. The semantic button name keeps the glyph-only control
/// discoverable to accessibility tools.
fn diameter_button(ui: &mut egui::Ui) -> bool {
let min_size = vec2(22.0, ui.spacing().interact_size.y);
let resp = ui.add(egui::Button::new(" ").min_size(min_size)).on_hover_text(crate::tl!("Diameter"));
let color = if resp.enabled() { ui.visuals().text_color() } else { ui.visuals().weak_text_color() };
let stroke = Stroke::new(1.4, color);
let center = resp.rect.center();
let radius = resp.rect.height() * 0.3;
let diagonal = radius * std::f32::consts::FRAC_1_SQRT_2;
let painter = ui.painter();
painter.circle_stroke(center, radius, stroke);
painter.line_segment([center + vec2(-diagonal, diagonal), center + vec2(diagonal, -diagonal)], stroke);
// Name the node directly as well as through `WidgetInfo`: on a clicked frame `widget_info`
// emits an output event and does not update the AccessKit node, which would leave the
// glyph-only button unnamed for that frame.
let resp = resp.accessible_name(crate::tl!("Diameter"));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, resp.enabled(), crate::tl!("Diameter")));
resp.clicked()
}
/// Insert `s` at the caret (over the selection).
fn insert(st: &mut State, s: &str) {
let (a, b) = (st.sel.0.min(st.sel.1), st.sel.0.max(st.sel.1));
@@ -347,7 +475,7 @@ fn ruler(ui: &mut egui::Ui, st: &mut State, px: f32, width: f32, base_h: f64) {
let y = if up { rect.bottom() - 1.0 } else { rect.top() + 1.0 };
let dir = if up { -1.0 } else { 1.0 };
let hit = Rect::from_center_size(pos2(x, y + dir * 4.0), vec2(10.0, 10.0));
let r = ui.interact(hit, ui.id().with(("mtext_indent", k)), Sense::drag());
let r = ui.interact(hit, ui.scope_id().with(("mtext_indent", k)), Sense::drag());
let col = if r.hovered() || r.dragged() { t.accent } else { t.icon };
painter.add(Shape::convex_polygon(vec![pos2(x - 4.0, y), pos2(x + 4.0, y), pos2(x, y + dir * 8.0)], col, Stroke::NONE));
if r.dragged()
@@ -436,6 +564,7 @@ fn text_box(ui: &mut egui::Ui, st: &mut State, base_h: f64, px: f32, width: f32,
.char_limit(cadcraft_engine::mtext_edit::MAX_CHARS)
.layouter(&mut layouter)
.show(ui);
out.response.response.clone().accessible_name(crate::tl!("Text"));
if !out.response.has_focus() && buf.is_empty() && st.fmt.is_empty() {
out.response.request_focus();
}
@@ -462,9 +591,92 @@ fn text_box(ui: &mut egui::Ui, st: &mut State, base_h: f64, px: f32, width: f32,
mod tests {
use super::*;
use cadcraft_engine::Session;
use egui::accesskit::Role;
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
use crate::Services;
#[test]
fn diameter_insertion_keeps_real_text_and_renders_without_optional_fonts() {
let state = State { fmt: Formatted::default(), typing: CharFmt::default(), typing_at: 0, top: 70.0, sel: (0, 0), caret: None };
let mut harness = Harness::builder().with_size(vec2(1100.0, 300.0)).with_context_setup(install_diameter_glyph).build_ui_state(
|ui, state| {
toolbar(ui, state, 2.5);
text_box(ui, state, 2.5, 20.0, 1000.0, &Tokens::get());
},
state,
);
harness.run();
harness.get_by_role_and_label(Role::Button, "Diameter").click();
harness.run();
assert_eq!(harness.state().fmt.text(), "⌀");
assert_eq!(harness.state().fmt.to_contents(), "⌀");
assert_eq!(harness.state().sel, (1, 1));
assert!(harness.unnamed_widgets().is_empty());
}
#[test]
fn the_whole_toolbar_uses_bundled_glyphs_and_names_its_controls() {
let mut state = State { fmt: Formatted::default(), typing: CharFmt::default(), typing_at: 0, top: 70.0, sel: (0, 0), caret: None };
let mut harness = Harness::builder().with_size(vec2(1100.0, 120.0)).build_ui(move |ui| toolbar(ui, &mut state, 2.5));
harness.run();
for name in ["Left", "Center", "Right", "Justify", "Bulleted list", "Numbered list", "Insert tab", "Diameter", "Text color", "Font"] {
assert!(harness.query_by_label(name).is_some(), "missing semantic control {name}");
}
assert!(harness.unnamed_widgets().is_empty(), "unnamed controls: {:?}", harness.unnamed_widgets());
}
/// Strict bundled-font and accessibility regression for the glyph-only Diameter button: the
/// test context uses egui's bundled faces only (no `theme::install_fonts` system prepend),
/// and the harness panics on a missing glyph by default. The sign must be painted, not
/// shaped from U+2300.
#[test]
fn diameter_button_is_painted_and_named_under_bundled_fonts() {
let ctx = egui::Context::default();
ctx.set_missing_glyph_policy(egui::MissingGlyphPolicy::Panic);
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(120.0, 60.0))), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
diameter_button(ui);
});
out.textures_delta.clear();
let mut text = String::new();
let (mut circle, mut slash) = (false, false);
for shape in &out.shapes {
match &shape.shape {
Shape::Circle(_) => circle = true,
Shape::LineSegment { .. } => slash = true,
Shape::Text(t) => text.push_str(&t.galley.job.text),
_ => {}
}
}
assert!(circle, "the diameter sign's circle is painted");
assert!(slash, "the diameter sign's slash is painted");
assert!(!text.contains('\u{2300}'), "U+2300 must not be shaped from a font: {text:?}");
let mut harness = Harness::builder().with_size(vec2(240.0, 80.0)).build_ui(|ui| {
diameter_button(ui);
});
harness.run();
let button = harness.get_by_label("Diameter");
assert_eq!(button.accesskit_node().role(), Role::Button);
assert!(harness.unnamed_widgets().is_empty(), "unnamed controls: {:?}", harness.unnamed_widgets());
}
/// The height DragValue has no visible caption, so its AccessKit label must be set explicitly.
/// Bundled fonts only: no system face can mask a missing glyph in the harness.
#[test]
fn height_field_is_named_for_accessibility_under_bundled_fonts() {
let mut harness = Harness::builder().with_size(vec2(360.0, 80.0)).build_ui(|ui| {
let mut height = 2.5;
height_field(ui, &mut height, 2.5);
});
harness.run();
let field = harness.get_by_role_and_label(Role::SpinButton, "Text height");
assert_eq!(field.accesskit_node().label().as_deref(), Some("Text height"));
assert!(harness.unnamed_widgets().is_empty(), "unnamed controls: {:?}", harness.unnamed_widgets());
}
#[test]
fn editor_opens_only_for_mtext_text_entry_and_finishes_with_formatted_contents() {
let ctx = egui::Context::default();
+1 -1
View File
@@ -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)
+98 -59
View File
@@ -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));
@@ -137,34 +138,36 @@ fn preview_square(ui: &mut egui::Ui, side: f32, color: Color32) {
fn display(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map<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 +175,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();
@@ -220,33 +229,38 @@ 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()));
}
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()));
}
@@ -265,20 +279,24 @@ fn files(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map<String, Value>) {
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);
@@ -298,14 +316,16 @@ fn drafting(app: &CadApp, ui: &mut egui::Ui, changes: &mut Map<String, Value>, s
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);
@@ -337,14 +357,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);
@@ -357,10 +386,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"));
@@ -378,14 +408,14 @@ 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());
fn heading(ui: &mut egui::Ui, text: &str) -> egui::Response {
ui.label(RichText::new(text).strong())
}
/// Visual Effect Settings: the selection area (window and crossing fills) and the objects the
@@ -393,7 +423,7 @@ fn heading(ui: &mut egui::Ui, text: &str) {
fn visual_effects(ctx: &egui::Context, st: &cadcraft_engine::Settings, open: &mut bool, changes: &mut Map<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)
@@ -408,15 +438,19 @@ fn visual_effects(ctx: &egui::Context, st: &cadcraft_engine::Settings, open: &mu
changes.insert("SELECTIONAREA".into(), json!(i32::from(area)));
}
left.add_enabled_ui(st.selectionarea, |ui| {
ui.label(crate::tl!("Window selection color:"));
aci_row(ui, st.windowareacolor, "WINDOWAREACOLOR", changes);
ui.label(crate::tl!("Crossing selection color:"));
aci_row(ui, st.crossingareacolor, "CROSSINGAREACOLOR", changes);
ui.label(crate::tl!("Selection area opacity:"));
let mut opacity = st.selectionareaopacity;
if ui.add(egui::Slider::new(&mut opacity, 0..=100)).changed() {
changes.insert("SELECTIONAREAOPACITY".into(), json!(opacity));
}
let window_caption = ui.label(crate::tl!("Window selection color:"));
aci_row(ui, st.windowareacolor, "WINDOWAREACOLOR", changes, window_caption.id);
let crossing_caption = ui.label(crate::tl!("Crossing selection color:"));
aci_row(ui, st.crossingareacolor, "CROSSINGAREACOLOR", changes, crossing_caption.id);
let opacity_caption = ui.label(crate::tl!("Selection area opacity:"));
ui.horizontal(|ui| {
let mut opacity = st.selectionareaopacity;
let slider = ui.add(egui::Slider::new(&mut opacity, 0..=100).show_value(false)).labelled_by(opacity_caption.id);
let field = ui.add(egui::DragValue::new(&mut opacity).range(0..=100)).labelled_by(opacity_caption.id);
if slider.changed() || field.changed() {
changes.insert("SELECTIONAREAOPACITY".into(), json!(opacity));
}
});
});
heading(right, crate::tl!("Selection Preview Filter"));
@@ -450,13 +484,13 @@ fn visual_effects(ctx: &egui::Context, st: &cadcraft_engine::Settings, open: &mu
}
/// A colour by its index (ACI 1-255): a swatch and the number; a change goes into `changes`.
fn aci_row(ui: &mut egui::Ui, current: u8, key: &str, changes: &mut Map<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));
}
});
@@ -464,21 +498,26 @@ fn aci_row(ui: &mut egui::Ui, current: u8, key: &str, changes: &mut Map<String,
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::ALL {
ui.selectable_value(&mut lang, p, p.name());
}
});
language_combo.response.labelled_by(language_caption.id);
if lang != app.ui.interface_language {
changes.insert("interfaceLanguage".into(), json!(lang.code()));
}
ui.end_row();
ui.label(crate::tl!("Largest array"));
let array_caption = ui.label(crate::tl!("Largest array"));
let mut max = app.session.settings.maxarray;
if ui.add(egui::DragValue::new(&mut max).range(100..=10_000_000).speed(1000.0).suffix(format!(" {}", crate::tl!("objects")))).changed() {
if ui
.add(egui::DragValue::new(&mut max).range(100..=10_000_000).speed(1000.0).suffix(format!(" {}", crate::tl!("objects"))))
.labelled_by(array_caption.id)
.changed()
{
changes.insert("MAXARRAY".into(), json!(max));
}
ui.end_row();
+25 -24
View File
@@ -151,7 +151,7 @@ pub fn toolsets(app: &mut CadApp, ui: &mut egui::Ui) {
let w = 92.0;
let tr = Rect::from_min_size(pos2(x, r.top()), vec2(w, tab_h));
let active = app.ui.toolset_tab == name;
let resp = ui.interact(tr, ui.id().with(("ts", name)), Sense::click());
let resp = ui.interact(tr, ui.scope_id().with(("ts", name)), Sense::click());
p.rect_filled(tr, 0.0, if active { t.tab_active } else { t.chrome });
p.text(
tr.center(),
@@ -168,7 +168,7 @@ pub fn toolsets(app: &mut CadApp, ui: &mut egui::Ui) {
}
p.rect_filled(Rect::from_min_max(pos2(x, r.top()), pos2(r.right(), r.top() + tab_h)), 0.0, t.chrome);
let cr = Rect::from_center_size(pos2(r.right() - 14.0, r.top() + tab_h / 2.0), vec2(14.0, 14.0));
let cresp = ui.interact(cr, ui.id().with("ts_collapse"), Sense::click());
let cresp = ui.interact(cr, ui.scope_id().with("ts_collapse"), Sense::click());
icons::paint(&p, cr, Icon::ChevronLeft, false);
icons::describe_control(ui, &cresp, "Collapse Tool Sets", None, false);
if cresp.on_hover_text(crate::tl!("Collapse Tool Sets")).clicked() {
@@ -202,7 +202,7 @@ pub fn toolsets(app: &mut CadApp, ui: &mut egui::Ui) {
t.text,
);
let gear = Rect::from_center_size(pos2(hr.right() - 12.0, hr.center().y), vec2(13.0, 13.0));
let gresp = ui.interact(gear, ui.id().with(("ts_gear", name)), Sense::click());
let gresp = ui.interact(gear, ui.scope_id().with(("ts_gear", name)), Sense::click());
icons::paint(pp, Rect::from_center_size(gear.center(), vec2(11.0, 11.0)), Icon::Gear, false);
icons::describe_control(ui, &hresp, name, Some(!collapsed), false);
icons::describe_control(ui, &gresp, &format!("{name} options"), None, false);
@@ -329,7 +329,7 @@ pub fn toolsets_strip(app: &mut CadApp, ui: &mut egui::Ui) {
let t = Tokens::get();
egui::Panel::left("cc_toolsets_strip").exact_size(22.0).resizable(false).frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| {
let r = ui.max_rect();
let resp = ui.interact(r, ui.id().with("ts_expand"), Sense::click());
let resp = ui.interact(r, ui.scope_id().with("ts_expand"), Sense::click());
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.center().x, r.top() + 14.0), vec2(14.0, 14.0)), Icon::ChevronRight, false);
icons::describe_control(ui, &resp, "Expand Tool Sets", None, false);
if resp.on_hover_text(crate::tl!("Tool Sets")).clicked() {
@@ -354,7 +354,7 @@ pub fn right_palettes(app: &mut CadApp, ui: &mut egui::Ui) {
icons::paint(&p, br, *icon, false);
}
let cr = Rect::from_center_size(pos2(r.right() - 14.0, r.top() + tab_h / 2.0), vec2(14.0, 14.0));
let cresp = ui.interact(cr, ui.id().with("palettes_collapse"), Sense::click());
let cresp = ui.interact(cr, ui.scope_id().with("palettes_collapse"), Sense::click());
icons::paint(&p, cr, Icon::ChevronRight, false);
icons::describe_control(ui, &cresp, "Collapse Palettes", None, false);
if cresp.on_hover_text(crate::tl!("Collapse Palettes")).clicked() {
@@ -520,9 +520,10 @@ fn prop_row(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui)) {
let mut job = egui::text::LayoutJob::simple(crate::i18n::t(label).to_string(), crate::theme::body(), t.text_dim, 106.0);
job.halign = egui::Align::RIGHT;
let galley = ui.painter().layout_job(job);
let (lr, _) = ui.allocate_exact_size(vec2(118.0, galley.size().y.max(22.0)), Sense::hover());
let (lr, label_response) = ui.allocate_exact_size(vec2(118.0, galley.size().y.max(22.0)), Sense::hover());
label_response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, ui.is_enabled(), crate::i18n::t(label)));
ui.painter().galley(pos2(lr.right() - 6.0, lr.center().y - galley.size().y / 2.0), galley, t.text_dim);
add(ui);
ui.label_inputs_by(label_response.id, add);
});
}
@@ -612,7 +613,7 @@ type Custom = Option<(String, Vec<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 +640,7 @@ fn properties_header(app: &mut CadApp, ui: &mut egui::Ui) {
ui.painter().rect_filled(if app.ui.properties_all { half } else { my }, 3.0, t.control);
ui.painter().text(half.center(), egui::Align2::CENTER_CENTER, crate::tl!("All"), crate::theme::small(), t.text);
ui.painter().text(my.center(), egui::Align2::CENTER_CENTER, crate::tl!("My"), crate::theme::small(), t.text_faint);
ui.interact(my, ui.id().with("prop_my"), Sense::hover()).on_hover_text(crate::tl!("My properties: not available yet"));
ui.interact(my, ui.scope_id().with("prop_my"), Sense::hover()).on_hover_text(crate::tl!("My properties: not available yet"));
let sel = app.session.selection();
let pickadd = app.session.settings.pickadd;
let Ok(d) = app.session.doc() else { return };
@@ -757,7 +758,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
}
});
prop_row(ui, "Linetype scale", |ui| {
if let Some(f) = number(edit_field(ui, ui.id().with("cur_celtscale"), format!("{:.4}", h.f64("CELTSCALE", 1.0))), &mut bad) {
if let Some(f) = number(edit_field(ui, ui.scope_id().with("cur_celtscale"), format!("{:.4}", h.f64("CELTSCALE", 1.0))), &mut bad) {
act("setvar", json!({ "name": "CELTSCALE", "value": f }));
}
});
@@ -779,7 +780,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
});
prop_row(ui, "Transparency", |ui| unavailable(ui, "ByLayer"));
prop_row(ui, "Thickness", |ui| {
if let Some(f) = number(edit_field(ui, ui.id().with("cur_thickness"), len(h.f64("THICKNESS", 0.0))), &mut bad) {
if let Some(f) = number(edit_field(ui, ui.scope_id().with("cur_thickness"), len(h.f64("THICKNESS", 0.0))), &mut bad) {
act("setvar", json!({ "name": "THICKNESS", "value": f }));
}
});
@@ -805,7 +806,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
});
prop_row(ui, "Annotation scale", |ui| unavailable(ui, "1:1"));
prop_row(ui, "Text height", |ui| {
if let Some(f) = number(edit_field(ui, ui.id().with("cur_textsize"), len(h.f64("TEXTSIZE", 0.2))), &mut bad) {
if let Some(f) = number(edit_field(ui, ui.scope_id().with("cur_textsize"), len(h.f64("TEXTSIZE", 0.2))), &mut bad) {
act("setvar", json!({ "name": "TEXTSIZE", "value": f }));
}
});
@@ -888,7 +889,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
let width = (ui.available_width() - 10.0).max(0.0);
if let Some(c) = crate::layers::color_button_resolved(
ui,
egui::Id::new(("properties_color", &ids)),
egui::Id::unique(("properties_color", &ids)),
selected_color.unwrap_or(Color::ByLayer),
resolved_color,
&color,
@@ -924,7 +925,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
});
});
prop_row(ui, "Linetype scale", |ui| {
if let Some(f) = number(edit_field(ui, ui.id().with(("lts", &ids)), lts.clone()), &mut bad) {
if let Some(f) = number(edit_field(ui, ui.scope_id().with(("lts", &ids)), lts.clone()), &mut bad) {
set = Some(json!({ "handles": ids, "ltscale": f }));
}
});
@@ -954,7 +955,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
}
ui.separator();
// A fixed percentage, committed when the drag or edit ends.
let tid = egui::Id::new(("prop_tr", &ids));
let tid = egui::Id::unique(("prop_tr", &ids));
let cur = tr.parse::<u8>().ok();
let mut pct = ui.data_mut(|d| d.get_temp::<u8>(tid)).or(cur).unwrap_or(0);
ui.horizontal(|ui| {
@@ -979,7 +980,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
let angles = app.session.angle_settings();
let num = |ui: &mut egui::Ui, label: &str, key: &str, shown: String, set: &mut Option<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 +988,22 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
match &e.kind {
EntityKind::Line(l) => {
prop_row(ui, "Start X", |ui| {
if let Some(f) = number(edit_field(ui, ui.id().with(("sx", &ids)), len(l.a.x)), &mut bad) {
if let Some(f) = number(edit_field(ui, ui.scope_id().with(("sx", &ids)), len(l.a.x)), &mut bad) {
set = Some(json!({ "handles": ids, "start": [f, l.a.y] }));
}
});
prop_row(ui, "Start Y", |ui| {
if let Some(f) = number(edit_field(ui, ui.id().with(("sy", &ids)), len(l.a.y)), &mut bad) {
if let Some(f) = number(edit_field(ui, ui.scope_id().with(("sy", &ids)), len(l.a.y)), &mut bad) {
set = Some(json!({ "handles": ids, "start": [l.a.x, f] }));
}
});
prop_row(ui, "End X", |ui| {
if let Some(f) = number(edit_field(ui, ui.id().with(("ex", &ids)), len(l.b.x)), &mut bad) {
if let Some(f) = number(edit_field(ui, ui.scope_id().with(("ex", &ids)), len(l.b.x)), &mut bad) {
set = Some(json!({ "handles": ids, "end": [f, l.b.y] }));
}
});
prop_row(ui, "End Y", |ui| {
if let Some(f) = number(edit_field(ui, ui.id().with(("ey", &ids)), len(l.b.y)), &mut bad) {
if let Some(f) = number(edit_field(ui, ui.scope_id().with(("ey", &ids)), len(l.b.y)), &mut bad) {
set = Some(json!({ "handles": ids, "end": [l.b.x, f] }));
}
});
@@ -1038,7 +1039,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
}
EntityKind::Text(tx) => {
prop_row(ui, "Contents", |ui| {
if let Some(s) = edit_field(ui, ui.id().with(("txt", &ids)), tx.value.clone()) {
if let Some(s) = edit_field(ui, ui.scope_id().with(("txt", &ids)), tx.value.clone()) {
set = Some(json!({ "handles": ids, "text": s }));
}
});
@@ -1048,7 +1049,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
}
EntityKind::MText(tx) => {
prop_row(ui, "Contents", |ui| {
if let Some(s) = edit_field(ui, ui.id().with(("mtxt", &ids)), tx.contents.clone()) {
if let Some(s) = edit_field(ui, ui.scope_id().with(("mtxt", &ids)), tx.contents.clone()) {
set = Some(json!({ "handles": ids, "text": s }));
}
});
@@ -1084,7 +1085,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
}
EntityKind::Dimension(dm) => {
prop_row(ui, "Text override", |ui| {
if let Some(s) = edit_field(ui, ui.id().with(("dimtxt", &ids)), dm.text.clone()) {
if let Some(s) = edit_field(ui, ui.scope_id().with(("dimtxt", &ids)), dm.text.clone()) {
set = Some(json!({ "handles": ids, "textOverride": s }));
}
});
@@ -1123,7 +1124,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
prop_row(ui, &p.name, |ui| {
let picked = match (p.editable, p.choices.is_empty()) {
(true, false) => pick_menu(ui, &p.value, p.choices.iter().cloned()),
(true, true) => edit_field(ui, ui.id().with(("dynprop", e.handle, &p.name)), p.value.clone()),
(true, true) => edit_field(ui, ui.scope_id().with(("dynprop", e.handle, &p.name)), p.value.clone()),
(false, _) => {
read_only(ui, &p.value);
None
+4 -3
View File
@@ -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, &current, &filter);
+34 -3
View File
@@ -40,7 +40,7 @@ struct State {
}
fn id() -> egui::Id {
egui::Id::new("cadcraft-quickcalc")
egui::Id::unique("cadcraft-quickcalc")
}
/// A variable's value as typed into an expression: a number, or a point as `[x,y]`.
@@ -197,6 +197,14 @@ pub fn open(app: &mut CadApp) -> Result<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);
}
pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
let t = Tokens::get();
let mut st: State = ui.ctx().data_mut(|d| d.get_temp(id())).unwrap_or_default();
@@ -309,8 +317,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
});
ui.end_row();
}
ui.label(crate::tl!("Value"));
ui.add(egui::TextEdit::singleline(&mut st.conv_value).hint_text("1").desired_width(120.0));
value_row(ui, &mut st.conv_value);
ui.end_row();
if ui.button(crate::tl!("Convert")).clicked() {
st.convert(app);
@@ -354,3 +361,27 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
});
ui.ctx().data_mut(|d| d.insert_temp(id(), st));
}
#[cfg(test)]
mod tests {
use egui::accesskit::Role;
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
use super::value_row;
/// The conversion value field is named by its visible label under bundled fonts only, so a
/// system face cannot hide a missing glyph and a bare "1" placeholder cannot pass as a name.
#[test]
fn value_field_is_named_by_its_visible_label() {
let mut harness = Harness::builder().with_size(egui::vec2(320.0, 80.0)).build_ui(|ui| {
let mut value = String::new();
value_row(ui, &mut value);
});
harness.run();
let field = harness.get_by_role_and_label(Role::TextInput, "Value");
assert!(field.accesskit_node().labelled_by().next().is_some(), "named by the visible Value label");
assert_eq!(field.accesskit_node().placeholder(), Some("Value"));
assert!(harness.unnamed_widgets().is_empty(), "unnamed controls: {:?}", harness.unnamed_widgets());
}
}
+73
View File
@@ -0,0 +1,73 @@
//! Additive migration of persisted panel sizes from the 0.36 root-id key to 0.37's
//! `parent_ui.scope_id().with_salt(name)` key.
//!
//! CADCraft disables egui memory persistence today, but the app's own saved workspace or a host
//! that enables persistence must not lose a resized panel once. The old key is retained for
//! rollback; a value already stored under the new key always wins.
/// The top-level panel id salts used by CADCraft. Keep these strings stable: they are names in
/// saved test fixtures and in `ui.elements` expectations, not disposable implementation details.
pub(crate) const PANEL_IDS: &[&str] = &["cc_title_toolbar", "cc_menubar", "cc_status", "cc_file_tabs", "cc_toolsets_strip", "cc_palettes"];
/// Copy an old-key `PanelState` to the 0.37 key before panels are shown. Call once on the first
/// pass, while the root `Ui` scope id is available.
pub(crate) fn migrate(ui: &egui::Ui) {
let ctx = ui.ctx();
let parent = ui.scope_id();
for name in PANEL_IDS {
let old = egui::Id::unique(name);
let new = parent.with_salt(egui::IdSalt::new(name));
if old == new {
continue;
}
let Some(state) = ctx.data_mut(|data| data.get_persisted::<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::*;
fn state(width: f32) -> egui::PanelState {
egui::PanelState { outer_rect: egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 26.0)) }
}
#[test]
fn old_key_is_copied_to_the_scoped_key_once() {
let ctx = egui::Context::default();
let old = egui::Id::unique("cc_status");
ctx.data_mut(|data| data.insert_persisted(old, state(41.0)));
let mut new_key = None;
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
migrate(ui);
let new = ui.scope_id().with_salt(egui::IdSalt::new("cc_status"));
new_key = Some(new);
assert_eq!(egui::PanelState::load(ui.ctx(), new).map(|state| state.size().x), Some(41.0));
});
output.textures_delta.clear();
let new_key = new_key.expect("a root Ui");
assert_eq!(egui::PanelState::load(&ctx, new_key).map(|state| state.size().x), Some(41.0));
assert_eq!(egui::PanelState::load(&ctx, old).map(|state| state.size().x), Some(41.0), "the old key is kept for rollback");
}
#[test]
fn a_new_key_is_never_overwritten() {
let ctx = egui::Context::default();
let old = egui::Id::unique("cc_status");
ctx.data_mut(|data| data.insert_persisted(old, state(41.0)));
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
let new = ui.scope_id().with_salt(egui::IdSalt::new("cc_status"));
ctx.data_mut(|data| data.insert_persisted(new, state(64.0)));
migrate(ui);
assert_eq!(egui::PanelState::load(ui.ctx(), new).map(|state| state.size().x), Some(64.0));
});
output.textures_delta.clear();
}
}
+35 -18
View File
@@ -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())]);
+5 -5
View File
@@ -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.
@@ -65,7 +65,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| {
@@ -193,8 +193,8 @@ fn window(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {
if named {
ui.separator();
ui.horizontal(|ui| {
ui.label(crate::tl!("New style"));
ui.add(egui::TextEdit::singleline(&mut new_style).desired_width(160.0));
let label = ui.label(crate::tl!("New style"));
ui.add(egui::TextEdit::singleline(&mut new_style).desired_width(160.0)).labelled_by(label.id);
if ui.add_enabled(!new_style.trim().is_empty(), egui::Button::new(crate::tl!("Add"))).clicked() {
action = set(&table.name, json!({ "name": new_style.trim() }));
new_style.clear();
+17 -16
View File
@@ -61,7 +61,7 @@ fn pretty(k: &str) -> String {
pub fn qselect_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
let t = Tokens::get();
let sid = egui::Id::new("qselect_state");
let sid = egui::Id::unique("qselect_state");
let mut st: QsState = ctx.data_mut(|d| d.get_temp(sid)).unwrap_or_default();
let key = app.session.state().map(|s| std::sync::Arc::as_ptr(&s.doc) as usize).unwrap_or(0);
let want = (key, st.apply_selection, st.ty.clone());
@@ -77,7 +77,7 @@ pub fn qselect_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
let mut run: Option<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 +141,7 @@ pub fn qselect_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
}
});
ui.end_row();
ui.label(crate::tl!("Value:"));
let value_label = ui.label(crate::tl!("Value:"));
let choices: Vec<String> = match st.property.as_str() {
"color" => {
["ByLayer", "ByBlock", "Red", "Yellow", "Green", "Cyan", "Blue", "Magenta", "White"].iter().map(|s| s.to_string()).collect()
@@ -152,7 +152,8 @@ pub fn qselect_dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
_ => Vec::new(),
};
ui.horizontal(|ui| {
ui.add(egui::TextEdit::singleline(&mut st.value).desired_width(if choices.is_empty() { 220.0 } else { 190.0 }));
ui.add(egui::TextEdit::singleline(&mut st.value).desired_width(if choices.is_empty() { 220.0 } else { 190.0 }))
.labelled_by(value_label.id);
if !choices.is_empty() {
ui.menu_button("▼", |ui| {
egui::ScrollArea::vertical().max_height(240.0).show(ui, |ui| {
@@ -229,9 +230,9 @@ struct QpState {
}
fn num_row(ui: &mut egui::Ui, label: &str, id: egui::Id, v: f64, set: &mut Option<(String, Value)>, key: &str) {
ui.label(RichText::new(crate::i18n::t(label)).color(Tokens::get().text_dim));
let label = ui.label(RichText::new(crate::i18n::t(label)).color(Tokens::get().text_dim));
let s = format!("{v:.4}");
if let Some(n) = crate::parametric::expr_field(ui, id, &s)
if let Some(n) = crate::parametric::expr_field(ui, id, &s, Some(label.id))
&& let Some(f) = cadcraft_engine::units::parse_distance(&n).or_else(|| n.parse::<f64>().ok()).filter(|f| f.is_finite())
{
*set = Some((key.to_string(), json!(f)));
@@ -247,7 +248,7 @@ fn ro_row(ui: &mut egui::Ui, label: &str, v: String) {
/// The Quick Properties palette, near where the selection was made.
pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) {
let sid = egui::Id::new("qp_state");
let sid = egui::Id::unique("qp_state");
let sel = app.session.selection();
let active = app.session.settings.qpmode
&& !sel.is_empty()
@@ -299,7 +300,7 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) {
let angles = app.session.angle_settings();
let mut close = false;
egui::Window::new(title)
.id(egui::Id::new(("quick_properties", &st.sel)))
.id(egui::Id::unique(("quick_properties", &st.sel)))
.default_pos(at)
.collapsible(false)
.resizable(false)
@@ -309,7 +310,7 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) {
egui::Grid::new("qp_grid").num_columns(2).spacing(vec2(10.0, 4.0)).min_col_width(80.0).show(ui, |ui| {
ui.label(RichText::new(crate::tl!("Color")).color(Tokens::get().text_dim));
let cur = color.unwrap_or(cadcraft_color::Color::ByLayer);
if let Some(c) = crate::layers::color_button(ui, egui::Id::new(("qp_color", &ids)), cur, true, 150.0) {
if let Some(c) = crate::layers::color_button(ui, egui::Id::unique(("qp_color", &ids)), cur, true, 150.0) {
set = Some(("color".into(), json!(c.name())));
}
ui.end_row();
@@ -334,7 +335,7 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) {
}
});
ui.end_row();
let id = |k: &str| egui::Id::new(("qp", k, &ids));
let id = |k: &str| egui::Id::unique(("qp", k, &ids));
match &kind {
Some(EntityKind::Line(l)) => {
let dl = l.b.xy() - l.a.xy();
@@ -369,8 +370,8 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) {
num_row(ui, "Global width", id("w"), p.const_width, &mut set, "width");
}
Some(EntityKind::Text(tx)) => {
ui.label(RichText::new(crate::tl!("Contents")).color(Tokens::get().text_dim));
if let Some(v) = crate::parametric::expr_field(ui, id("txt"), &tx.value) {
let label = ui.label(RichText::new(crate::tl!("Contents")).color(Tokens::get().text_dim));
if let Some(v) = crate::parametric::expr_field(ui, id("txt"), &tx.value, Some(label.id)) {
set = Some(("text".into(), json!(v)));
}
ui.end_row();
@@ -378,8 +379,8 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) {
num_row(ui, "Rotation", id("rot"), angles.shown(tx.rotation).to_degrees(), &mut set, "rotation");
}
Some(EntityKind::MText(tx)) => {
ui.label(RichText::new(crate::tl!("Contents")).color(Tokens::get().text_dim));
if let Some(v) = crate::parametric::expr_field(ui, id("mtxt"), &tx.contents) {
let label = ui.label(RichText::new(crate::tl!("Contents")).color(Tokens::get().text_dim));
if let Some(v) = crate::parametric::expr_field(ui, id("mtxt"), &tx.contents, Some(label.id)) {
set = Some(("text".into(), json!(v)));
}
ui.end_row();
@@ -391,8 +392,8 @@ pub fn quick_properties(app: &mut CadApp, ctx: &egui::Context) {
num_row(ui, "Scale", id("sc"), i.scale.x, &mut set, "scale");
}
Some(EntityKind::Dimension(dm)) => {
ui.label(RichText::new(crate::tl!("Text override")).color(Tokens::get().text_dim));
if let Some(v) = crate::parametric::expr_field(ui, id("dt"), &dm.text) {
let label = ui.label(RichText::new(crate::tl!("Text override")).color(Tokens::get().text_dim));
if let Some(v) = crate::parametric::expr_field(ui, id("dt"), &dm.text, Some(label.id)) {
set = Some(("textOverride".into(), json!(v)));
}
ui.end_row();
+1 -1
View File
@@ -133,7 +133,7 @@ fn navigate(app: &mut CadApp, params: &Value) -> Result<Value, String> {
}
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);
+20 -2
View File
@@ -302,8 +302,10 @@ pub fn mono() -> FontId {
FontId::new(12.0, FontFamily::Monospace)
}
/// Load a system UI font at run time when one is installed (not bundled), else keep egui's;
/// then CJK fonts after the Latin ones, so Latin text keeps its look, in every interface
/// Load a system UI font at run time when one is installed (not bundled), else keep egui's
/// bundled bounded Latin/icon faces (`default_fonts` + `monochrome_emoji_fonts`, enabled in the
/// workspace manifest for native and web); then CJK fonts after the Latin ones, so Latin text
/// keeps its look, in every interface
/// language: file, layout and layer names can be Japanese or Chinese whatever the interface
/// shows (#513). In a Chinese interface (`language` "zh…") the Chinese font comes first, else a
/// Japanese one, so names take their own glyph forms; in an Arabic interface an installed
@@ -312,6 +314,7 @@ pub fn mono() -> FontId {
/// interface adds a Hangul font only when the CJK font found doesn't cover Hangul
/// ([`korean_font`]).
pub fn install_fonts(ctx: &egui::Context, language: &str) {
crate::mtext_editor::install_diameter_glyph(ctx);
let mut fonts = egui::FontDefinitions::default();
let mut added: Vec<(String, std::sync::Arc<egui::FontData>)> = Vec::new();
#[cfg(not(target_arch = "wasm32"))]
@@ -336,6 +339,11 @@ pub fn install_fonts(ctx: &egui::Context, language: &str) {
if let Some(f) = fonts.families.get_mut(&FontFamily::Proportional) {
f.insert(0, "system-ui".into());
}
// Keep Hack first in Monospace, then use the system face for glyphs Hack lacks.
if let Some(f) = fonts.families.get_mut(&FontFamily::Monospace) {
let at = f.iter().position(|name| name == "Hack").map_or(0, |i| i + 1);
f.insert(at, "system-ui".into());
}
break;
}
}
@@ -383,6 +391,16 @@ pub fn install_fonts(ctx: &egui::Context, language: &str) {
}
}
}
// `default_fonts` supplies Ubuntu-Light (Proportional) and Hack (Monospace). Hack also
// carries the modifier symbols (⇧) that proportional shortcut labels use; keep it as a
// bounded late fallback when Ubuntu-Light lacks one. The monochrome emoji face (enabled by
// `monochrome_emoji_fonts`) supplies ⌘ in both families.
if fonts.font_data.contains_key("Hack")
&& let Some(f) = fonts.families.get_mut(&FontFamily::Proportional)
&& !f.iter().any(|name| name == "Hack")
{
f.push("Hack".into());
}
ctx.set_fonts(fonts);
}
+4 -4
View File
@@ -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);
+2 -2
View File
@@ -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);
+5 -5
View File
@@ -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,98 @@
//! Semantic accessibility: controls expose an accessible name, not only a placeholder, and the
//! AccessKit root carries the language the interface is shown in.
//!
//! `hint_text` becomes an AccessKit placeholder, so a screen reader sees an unnamed text input
//! unless the widget is associated with a visible label (`labelled_by`) or given a name.
use cadcraft_engine::Session;
use cadcraft_ui_egui::{CadApp, Services, i18n, menus};
use egui::accesskit::Role;
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
use serde_json::json;
fn harness(app: CadApp) -> Harness<'static, CadApp> {
Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_ui_state(
|ui, app: &mut CadApp| {
app.logic(ui.ctx());
app.ui(ui);
},
app,
)
}
/// Every editable text input must advertise a non-empty accessible name: its own label or the
/// visible label it is associated with. A placeholder alone is not a name.
fn unnamed_text_inputs(h: &Harness<'_, CadApp>) -> Vec<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()
}
#[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");
}
+9 -2
View File
@@ -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(|_| {
+1 -1
View File
@@ -300,7 +300,7 @@ fn canvas_reset_during_splitter_drag_discards_old_resize_and_ends_gesture() {
.unwrap();
h.run_steps(3);
let drawing = h.state().session.doc().unwrap().clone();
let splitter = egui::Id::new("cadcraft-docking").with(("split", Vec::<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);
@@ -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());
+4 -1
View File
@@ -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;
}
+2 -1
View File
@@ -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");
+8 -4
View File
@@ -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",
+8 -2
View File
@@ -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 -2
View File
@@ -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);
+3 -1
View File
@@ -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);
+6 -3
View File
@@ -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}");
}
+2 -2
View File
@@ -50,7 +50,7 @@ field. `engine.commands` returns a separate command catalog, not a list of addit
| `engine.execute` | `{command, params}` | Run any command with JSON parameters. Never opens a dialog. `command` is an alias of this method, with the same parameters and result. |
| `engine.commands` | | Every command: id, label, menu path, shortcut, aliases, params doc, enabled. |
| `drawing.inspect` | `{entities?, limit?}` | Drawing summary (counts, extents, layers, styles, blocks, layouts, selection, undo) and entities with handles and geometry. |
| `ui.inspect` | | UI state, canvas rect, view, cursor and snap, performance counters; `closePrompt`: the drawing a "Save changes?" prompt is asking about (null when none); and actual egui keyboard focus (`focus.id`, `focus.label`, `focus.role`). |
| `ui.inspect` | | UI state, canvas rect, view, cursor and snap, performance counters (`uiFrames`, `passIndex` included); `closePrompt`: the drawing a "Save changes?" prompt is asking about (null when none); and actual egui keyboard focus (`focus.id`, `focus.label`, `focus.role`). |
| `ui.elements` | `{label?, role?, focused?}` | Observed toolbar, tab, drafting and layer-palette controls from the latest UI frame. Filters match exactly. Each entry has its real egui `id`, label, role, rectangle `[x,y,width,height]`, enabled/visible/selected state and current focus. |
| `ui.menu.list` / `ui.menu.invoke` | `{command}` | The menu tree; invoke an item like a click (interactive). |
| `ui.pointer` | `{x, y, space?: "world"\|"screen", button?, action?: "click"\|"move"}` | Click in the drawing area (world coordinates by default). Right button = Enter. |
@@ -60,7 +60,7 @@ field. `engine.commands` returns a separate command catalog, not a list of addit
| `ui.set` | UiState fields | Show/hide palettes, toolbars, command line, ViewCube…; `theme: "system"\|"light"\|"dark"` (also `engine.execute` with `ui.theme {theme}`); `ui.inspect` reports the theme shown (`theme`: light/dark). `animations: "system"\|"on"\|"off"` (also `ui.animations {animations}`) shows or hides animation effects (dialog and menu fades, smoothed scrolling), `system` following the operating system's setting; `ui.inspect` reports whether they show (`animations`: true/false). `saveFormat: "dxf"\|"dwg"` (also `ui.saveformat {format}`) is the format Save As suggests for new drawings; `saveVersion: "2000"`…`"2018"` (also `ui.saveversion {version}`) the version it saves them in (SAVEAS also takes `version` and `binary` directly). |
| `ui.resize` | `{width, height}` | Resize the window. |
| `ui.focus` | | Activate the native window. Does not assign focus to a widget. |
| `ui.screenshot` | `{path?}` | PNG of the window (needs a presented frame). |
| `ui.screenshot` | `{path?}` | PNG of the window (needs a presented frame). Bounded: at most 4 queued/in-flight/completed requests, 64 MiB per image and 64 MiB total (completed but undrained images still count), an 8 s deadline, and cancellation when the document/keyboard focus or window geometry changes. Every reply carries `request`; failures include `ok:false,error`. Files are written only after the deadline and identity checks pass. Without `path`, each request gets a unique PNG in a private owner-only per-process directory under the temp dir (created exclusively; an existing symlink is never followed), so concurrent app instances cannot collide; the caller owns those files and should remove them when done. |
| `ui.render` | `{path?, width?, height?, fit?}` | Headless render. `fit` (default true) frames the whole drawing; `false` uses the current view. Replies `{pngBase64, width, height, bytes, path?}`. |
| `app.open` / `app.save` / `app.quit` | `{path}` | File operations. |
| `control.methods` | | Every method this server answers: `{method, params, description}`. Headless (`cadcraft-cli mcp`) lists only the methods it serves. |
File diff suppressed because it is too large Load Diff
-1
View File
@@ -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"
+11
View File
@@ -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">
+14
View File
@@ -92,11 +92,25 @@ Copy-Item (Join-Path $Bin 'cadcraft.exe'), (Join-Path $Bin 'cadcraft-cli.exe') $
& (Join-Path $PSScriptRoot 'sign.ps1') (Join-Path $Stage 'cadcraft.exe') (Join-Path $Stage 'cadcraft-cli.exe')
# ---- font notices for the MSI ------------------------------------------------------------------
# The portable ZIP already carries these notices; stage the complete files before MSI creation too.
$Docs = Join-Path $Stage 'font-licences'
New-Item -ItemType Directory -Force -Path $Docs | Out-Null
$HasCraftFontLicences = 0
if ($env:CRAFT_FONTS_DIR) {
foreach ($ofl in Get-ChildItem -Path (Join-Path $env:CRAFT_FONTS_DIR 'fonts\*\OFL.txt') -File -ErrorAction SilentlyContinue) {
Copy-Item -LiteralPath $ofl.FullName -Destination (Join-Path $Docs "OFL-$($ofl.Directory.Name).txt")
$HasCraftFontLicences = 1
}
}
# ---- end font notices for the MSI --------------------------------------------------------------
# ---- MSI ---------------------------------------------------------------------------------------
$Msi = Join-Path $Dist "cadcraft-$Version-windows-$Arch.msi"
Invoke-Native 'wix build' {
wix build (Join-Path $PSScriptRoot 'cadcraft.wxs') -arch $Arch -ext WixToolset.UI.wixext `
-d "Version=$MsiVersion" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root 'assets\app-icon\cadcraft.ico')" `
-d "DocsDir=$Docs" -d "HasCraftFontLicences=$HasCraftFontLicences" `
-o $Msi
}
# wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets.