diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1968076f..7abae66a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: # crates/text/build.rs fails a build that requires craft-fonts without it. Only craft-fonts' # pr/latin-presentation-fonts has it; its main has Noto Sans Georgian but no Inter. Re-pin # once one commit has both (storytold/craft-fonts#42). - craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104 + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 packages: >- libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev libgtk-3-dev diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 3b03a99c..e030b167 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -32,4 +32,4 @@ jobs: uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main with: run-package-check: true - craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dd2aefdd..8b37ee39 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,7 @@ jobs: # Needs fonts/latin-manifest.txt (Inter): crates/text/build.rs fails a release without it. Only # craft-fonts' pr/latin-presentation-fonts has it; its main has Noto Sans Georgian but no Inter. # Re-pin once one commit has both (storytold/craft-fonts#42). - craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104 + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 has-server: true # The browser has no system fonts: without craft-fonts the web build can't draw text (#885). # crates/text/build.rs keeps the web build to Inter. diff --git a/Cargo.lock b/Cargo.lock index 27cb0d4e..d9a01532 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,21 +4,21 @@ version = 4 [[package]] name = "accesskit" -version = "0.24.1" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932" dependencies = [ "uuid", ] [[package]] name = "accesskit_atspi_common" -version = "0.18.1" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +checksum = "52c182f9c282ac9c5638d876d551d15e5f7d397ec263349a0c6a2b61595dd5e4" dependencies = [ "accesskit", - "accesskit_consumer 0.36.0", + "accesskit_consumer", "atspi-common", "phf", "serde", @@ -27,43 +27,37 @@ dependencies = [ [[package]] name = "accesskit_consumer" -version = "0.35.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344" dependencies = [ "accesskit", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] -name = "accesskit_consumer" -version = "0.36.0" +name = "accesskit_ios" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937" dependencies = [ "accesskit", - "hashbrown 0.16.1", -] - -[[package]] -name = "accesskit_consumer" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" -dependencies = [ - "accesskit", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", ] [[package]] name = "accesskit_macos" -version = "0.26.3" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4" dependencies = [ "accesskit", - "accesskit_consumer 0.38.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "objc2 0.5.3", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -71,9 +65,9 @@ dependencies = [ [[package]] name = "accesskit_unix" -version = "0.21.1" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +checksum = "202f24df034a7476d07b7f74284de84f6d62aabd858dbe7ee9cad3b7ad6f8f9d" dependencies = [ "accesskit", "accesskit_atspi_common", @@ -89,13 +83,13 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.32.1" +version = "0.35.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +checksum = "ce63f35d6bdcf59f26b76b3379063f738e6412cef46999cc772d46aa3de35adb" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "static_assertions", "windows", "windows-core", @@ -103,11 +97,12 @@ dependencies = [ [[package]] name = "accesskit_winit" -version = "0.32.2" +version = "0.34.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +checksum = "ee96dd150375c05912d8a473f98c2fb155fb3234fac88292d396548a2460fa77" dependencies = [ "accesskit", + "accesskit_ios", "accesskit_macos", "accesskit_unix", "accesskit_windows", @@ -907,9 +902,8 @@ source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09 [[package]] name = "craft-window" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=1f805378d454dac48b5c214029d254055fdf961b#1f805378d454dac48b5c214029d254055fdf961b" +source = "git+https://github.com/storytold/craft-libs?rev=c488caa9fbc2fb1a86e4674887e9b80b598ebbfa#c488caa9fbc2fb1a86e4674887e9b80b598ebbfa" dependencies = [ - "egui", "wayland-client", ] @@ -1125,9 +1119,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", @@ -1135,9 +1128,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", @@ -1159,6 +1151,7 @@ dependencies = [ "pollster 1.0.1", "profiling", "raw-window-handle", + "send_wrapper", "static_assertions", "wasm-bindgen", "web-sys", @@ -1170,9 +1163,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", @@ -1190,9 +1182,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", @@ -1210,30 +1201,33 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=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", "smithay-clipboard", "web-time", + "windows-sys 0.61.2", "winit", ] [[package]] name = "egui_extras" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05cbdc785020861553f230f23d330ba1fb72613b9d8e0e457f3bdc8ba6db6dcf" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" dependencies = [ "ahash", "egui", @@ -1245,9 +1239,8 @@ dependencies = [ [[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", @@ -1265,9 +1258,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", ] @@ -1292,22 +1284,22 @@ checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" [[package]] name = "enum-map" -version = "2.7.3" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" +checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06" dependencies = [ "enum-map-derive", ] [[package]] name = "enum-map-derive" -version = "0.17.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" +checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] @@ -1356,9 +1348,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", @@ -1374,16 +1365,17 @@ dependencies = [ "self_cell", "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" @@ -2061,9 +2053,9 @@ dependencies = [ [[package]] name = "glifo" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" +checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" dependencies = [ "bytemuck", "foldhash 0.2.0", @@ -2613,8 +2605,7 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "krilla" version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27da593198b20eeba65caeb73c2bbeec3e53ab08fa549898312ce81c4fce5e33" +source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" dependencies = [ "base64 0.22.1", "bumpalo", @@ -3365,9 +3356,9 @@ dependencies = [ [[package]] name = "phf" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ "phf_macros", "phf_shared", @@ -3376,9 +3367,9 @@ dependencies = [ [[package]] name = "phf_generator" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" dependencies = [ "fastrand", "phf_shared", @@ -3386,9 +3377,9 @@ dependencies = [ [[package]] name = "phf_macros" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" dependencies = [ "phf_generator", "phf_shared", @@ -3399,9 +3390,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", ] @@ -4072,6 +4063,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" @@ -4877,9 +4874,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", @@ -4892,9 +4889,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", @@ -5694,8 +5691,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", @@ -6191,3 +6187,23 @@ dependencies = [ "syn 3.0.9", "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_inspection" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" + +[[patch.unused]] +name = "egui_kittest" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" + +[[patch.unused]] +name = "egui_system_font_provider" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=173cc7375d35dc5fbc5b5d0994fdf787f547c5d1#173cc7375d35dc5fbc5b5d0994fdf787f547c5d1" diff --git a/Cargo.toml b/Cargo.toml index 90297f29..8738e9af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,8 +35,11 @@ figcraft-plugin = { path = "crates/plugin" } # Which paths stored in a document may be followed (craft-libs). craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } # Who draws the window frame on Linux — the compositor or the app — so every Craft app shows the -# same native titlebar per desktop (craft-libs). The `egui` feature adds `decorate`/`decorations`. -craft-window = { git = "https://github.com/storytold/craft-libs", rev = "1f805378d454dac48b5c214029d254055fdf961b", features = ["egui"] } +# same native titlebar per desktop (craft-libs). FigCraft only reads the probe result and maps it +# to its own `TitleBar`, so it does not enable craft-window's optional `egui` helpers (which would +# pull a second egui family version). The rev is the last merged one; the released 0.37 pin is +# supplied by the migration's reviewed craft-libs revision. +craft-window = { git = "https://github.com/storytold/craft-libs", rev = "c488caa9fbc2fb1a86e4674887e9b80b598ebbfa" } # foundation kurbo = { version = "0.13", features = ["serde"] } @@ -79,9 +82,9 @@ wasmi = { version = "2", default-features = false, features = ["std", "stable", wat = "1.261" # ui -egui = "0.36" -egui_extras = { version = "0.36", default-features = false } -eframe = { version = "0.36", default-features = false } +egui = "0.37" +egui_extras = { version = "0.37", default-features = false } +eframe = { version = "0.37", default-features = false } rfd = "0.17" arboard = { version = "3.6", default-features = false } webbrowser = "1" @@ -128,3 +131,25 @@ codegen-units = 1 debug = false strip = true panic = "abort" + +# 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" } + +# craft-libs' vendored krilla 0.8.2: decoded PNGs are capped at 1 GiB and reserved fallibly, and a +# panic while drawing unwinds without aborting. +krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" } diff --git a/ROADMAP.md b/ROADMAP.md index 6d5de4ad..05b7ed65 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (boards: a board area row and progress entry; `fill`/`stroke` props accept `"none"` and `null` as no paint (#1036); community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -141,6 +141,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). - **2026-10-15** · Zoom previews (M8, #312): when renders from scratch are slow, a new zoom shows the whole view coarsely first, then refines it. Numbers unchanged. - **2026-10-15** · Web clipboard (M1, #375): copy and paste in the browser go through the system clipboard. Numbers unchanged. - **2026-10-15** · Alt-drag copies snap to their original and Duplicate (Cmd+D) repeats the last copy's offset (M1, #283). Numbers unchanged. +- **2026-10-22** · `fill` and `stroke` props accept `"none"` and `null` (M1, #1036): `node.create`, `node.set` and the other prop appliers map them to an empty fills/strokes list, as `fills: []` already did, so an outline-only shape is one call (agents write `"none"` for no paint; the empty-list form was undocumented). The parameter docs (`KEYS`, shown by `list_commands` and the CLI) and `docs/mcp.md` say so, and the error message names the accepted forms. `fill_none_tests` covers create, set, undo, text and the error. Numbers unchanged. - **2026-10-17** · Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. - **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. - **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. @@ -377,6 +378,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | 2026-10-15 | minor | Linux packaging (#346, #328): the window id matches the desktop entry (FreeBSD keeps its class); the RPM requires glibc 2.35. Numbers unchanged | | 2026-10-15 | minor | Tests (#472, #471, #339): the rename shortcut test, the hanging punctuation test and the font-scanning tests no longer depend on the platform, font or test order. Numbers unchanged | | 2026-10-15 | minor | Languages (#257, #263, #469, #477, #288): Russian (with one/few/many plural forms) and Turkish catalogs, reviewed by native speakers; Czech and Indonesian drafts; the Japanese draft completed. 2,177 entries now (1,587 UI strings, 559 command labels, 31 menu wordings); strings added since a catalog was written read English there. Localization 39% and 29–51 h unchanged at this rounding (Indonesian is now a draft, while the five older drafts lack 88 newer entries) | +| 2026-10-22 | minor | Type settings keeps its width (#1029): in languages whose labels run past the 110 px label column (French "Retrait de paragraphe", "Espacement de liste"), the List spacing, Paragraph indent, Max lines and decoration rows were wider than the popover, which then widened every frame while open. Those fields now take the width left after their label; English is unchanged. Regression test in `type_settings_tests`. Numbers unchanged. | +| 2026-10-22 | minor | `fill`/`stroke` props accept `"none"` (either case) and `null` as no paint (M1, #1036): mapped to an empty fills/strokes list, as `fills: []` already did (undocumented), so an outline-only shape is one call; `KEYS` and `docs/mcp.md` document it, the error names the accepted forms, `fill_none_tests` covers create, set, undo, text and the error. Numbers unchanged | | 2026-10-20 | minor | A document dropped on the canvas opens (#916): a `.figcraft`, `.fig` or file JSON dropped on the canvas opens as a file of its own, as on the home screen, in the browser too; it used to add its pages to the file in front. Images and SVG still import at the drop point, File ▸ Import still adds pages, and dragging only documents over the canvas says "Drop to open" (not in the browser, which names no file until the drop). Numbers unchanged | | 2026-10-20 | minor | Duplicating a main component makes an instance (#892): Duplicate and Alt-drag copied a main component as a second main; within its file the copy is now an instance of it, as Copy then Paste already was (a variant duplicated in its set stays a variant). Numbers unchanged. | | 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged | diff --git a/apps/figcraft-cli/src/args.rs b/apps/figcraft-cli/src/args.rs index 4c162dbb..3fe17e80 100644 --- a/apps/figcraft-cli/src/args.rs +++ b/apps/figcraft-cli/src/args.rs @@ -1,6 +1,6 @@ //! Minimal std-only argument parsing: positionals, `--key value` / `--key=value` / `-o value` //! options (given again: the last counts, or all with [`Args::values`]), and boolean `--flag`s. -//! Unknown options are usage errors. +//! Unknown options are usage errors; `--help` or `-h` is [`CliError::Help`]. use std::collections::BTreeMap; @@ -16,6 +16,8 @@ pub struct Args { impl Args { pub fn parse(argv: &[String], valued: &[&str], flags: &[&str]) -> CliResult { let mut a = Args::default(); + // The first bad option, reported once the rest of the line is checked for `--help`. + let mut bad: Option = None; let mut it = argv.iter(); while let Some(arg) = it.next() { let name = if let Some(n) = arg.strip_prefix("--") { @@ -30,19 +32,27 @@ impl Args { Some((n, v)) => (n, Some(v.to_string())), None => (name, None), }; + // `--help` / `-h` anywhere among the options (not as an option's value) asks for help. + if inline.is_none() && matches!(name, "help" | "h") { + return Err(CliError::Help); + } if valued.contains(&name) { - let v = match inline { - Some(v) => v, - None => it.next().cloned().ok_or_else(|| CliError::Usage(format!("`{arg}` needs a value")))?, - }; - a.values.entry(name.to_string()).or_default().push(v); + match inline.or_else(|| it.next().cloned()) { + Some(v) => a.values.entry(name.to_string()).or_default().push(v), + None => { + bad.get_or_insert(CliError::Usage(format!("`{arg}` needs a value"))); + } + } } else if flags.contains(&name) && inline.is_none() { a.flags.push(name.to_string()); } else { - return Err(CliError::Usage(format!("unknown option `{arg}`"))); + bad.get_or_insert(CliError::Usage(format!("unknown option `{arg}`"))); } } - Ok(a) + match bad { + Some(e) => Err(e), + None => Ok(a), + } } pub fn value(&self, k: &str) -> Option { @@ -135,4 +145,18 @@ mod tests { let a = Args::parse(&v(&["-5"]), &[], &[]).unwrap(); assert_eq!(a.positional, ["-5"]); } + + #[test] + fn help_is_asked_for_anywhere_but_as_a_value() { + // Also after a bad option: help wins over the usage error (#1161 review). + for argv in + [&["--help"][..], &["-h"], &["in.figcraft", "-o", "out", "--help"], &["--json", "-h"], &["--bogus", "--help"], &["--json=1", "-h"]] + { + assert!(matches!(Args::parse(&v(argv), &["o"], &["json"]), Err(CliError::Help)), "{argv:?}"); + } + let a = Args::parse(&v(&["--filter", "--help", "-o", "-h"]), &["filter", "o"], &[]).unwrap(); + assert_eq!((a.value("filter").as_deref(), a.value("o").as_deref()), (Some("--help"), Some("-h"))); + // `--help=x` is no request for help, just an unknown option. + assert!(matches!(Args::parse(&v(&["--help=x"]), &[], &[]), Err(CliError::Usage(_)))); + } } diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 46215599..b87aa431 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -1,5 +1,6 @@ //! The file subcommands: info, export, convert, render and run. +use std::ffi::OsStr; use std::path::Path; use std::process::ExitCode; @@ -19,7 +20,7 @@ fn load_noted(path: &str) -> CliResult<(Document, Vec)> { } fn ext(path: &str) -> String { - Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase() + Path::new(path).extension().and_then(OsStr::to_str).unwrap_or("").to_ascii_lowercase() } fn image_format(e: &str) -> Option { @@ -207,10 +208,13 @@ pub fn info(path: &str, as_json: bool) -> CliResult { Ok(ExitCode::SUCCESS) } +/// The file name a layer exports to: the app's rule ([`figcraft_engine::cmd::file::safe_file_name`], +/// so `/` becomes `_` too), without leading or trailing spaces and dots, at most 100 characters +/// and 200 bytes (file systems allow 255, and a number and the extension still fit). fn sanitize(name: &str) -> String { - let s: String = name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '-' | '_' | ' ' | '.') { c } else { '_' }).collect(); - let s = s.trim().trim_matches('.').to_string(); - if s.is_empty() { "layer".into() } else { s.chars().take(100).collect() } + let s = figcraft_engine::cmd::file::safe_file_name(name); + let s = s.trim_matches(|c: char| c == '.' || c.is_whitespace()); + if s.is_empty() { "layer".into() } else { s.char_indices().take(100).take_while(|(i, c)| i + c.len_utf8() <= 200).map(|(_, c)| c).collect() } } /// What `export` exports and how. @@ -411,6 +415,14 @@ mod tests { assert_eq!(sanitize("Home / Hero"), "Home _ Hero"); assert_eq!(sanitize("../.."), "_"); assert_eq!(sanitize(""), "layer"); + // The same characters the app's export keeps (#1154): only what file systems refuse goes. + assert_eq!(sanitize("Homepage - desktop (live + verbeteringen)"), "Homepage - desktop (live + verbeteringen)"); + assert_eq!(sanitize("Q&A, 50% #1 [v2] {x} ~é"), "Q&A, 50% #1 [v2] {x} ~é"); + assert_eq!(sanitize("a\\b:c*d?e\"fh|i\tj"), "a_b_c_d_e_f_g_h_i_j"); + assert_eq!(sanitize(" .hidden. "), "hidden"); + assert_eq!(sanitize(" . .name. . "), "name", "spaces and dots at the edges, mixed"); + assert_eq!(sanitize(&"x".repeat(300)).chars().count(), 100); + assert_eq!(sanitize(&"\u{1F600}".repeat(100)), "\u{1F600}".repeat(50), "at most 200 bytes, whole characters"); } #[test] diff --git a/apps/figcraft-cli/src/main.rs b/apps/figcraft-cli/src/main.rs index badc5bc3..66627673 100644 --- a/apps/figcraft-cli/src/main.rs +++ b/apps/figcraft-cli/src/main.rs @@ -27,7 +27,7 @@ USAGE: figcraft-cli mcp [--attach PORT] [--automation-read-root DIR]... [--automation-write-root DIR]... figcraft-cli plugin publish --server ws://host:port/[?key=KEY] figcraft-cli perf [--layers N[,N...]] [--instances N[,N...]] - figcraft-cli help | --help | --version + figcraft-cli help | --help | --version (a subcommand followed by --help or -h prints this too) Every subcommand takes --no-system-fonts (or set FIGCRAFT_NO_SYSTEM_FONTS=1): text uses only the fonts built into FigCraft, for renders that match on every machine. Otherwise installed fonts are @@ -46,10 +46,11 @@ the FIGCRAFT_SERVER_KEY environment variable (which keeps it out of the shell hi `--automation-read-root DIR` / `--automation-write-root DIR` (each may be repeated; the folders must exist) confine the files a subcommand touches: reads only inside a read or write root, writes only inside a write root, everything else refused -- so giving no write root denies writing -altogether. They are taken by `mcp`, to confine the agent's commands, and by `info`, `export`, -`convert`, `render` and `run`, to confine that one run; without them a subcommand reads and writes -as the user who started it, as before. A running app confines itself: start it with -`figcraft --control PORT` and the same flags (`--attach` refuses them). +altogether. They reach the headless side only: `mcp` takes them to confine the agent's commands, +and `info`, `export`, `convert`, `render` and `run` to confine that one run; without them a +subcommand reads and writes as the user who started it, as before. A `mcp --attach` server is +confined by the app it attaches to, not by flags passed to the CLI: start that app with +`figcraft --control PORT` and the same roots (`--attach` refuses them). See docs/cli.md and docs/mcp.md."; /// The automation roots a file subcommand was given, if any. Omitting both leaves the run @@ -74,6 +75,10 @@ fn main() -> ExitCode { let argv: Vec = std::env::args().skip(1).collect(); match run(&argv) { Ok(code) => code, + Err(CliError::Help) => { + println!("{USAGE}"); + ExitCode::SUCCESS + } Err(CliError::Usage(msg)) => { eprintln!("error: {msg}\n\n{USAGE}"); ExitCode::from(2) @@ -87,6 +92,8 @@ fn main() -> ExitCode { #[derive(Debug)] pub enum CliError { + /// `--help` or `-h` after a subcommand: usage on stdout, exit code 0. + Help, /// Bad arguments: usage is printed, exit code 2. Usage(String), /// The operation failed: exit code 1. diff --git a/apps/figcraft-cli/tests/it/cli.rs b/apps/figcraft-cli/tests/it/cli.rs index 4af73d69..b1aeb54a 100644 --- a/apps/figcraft-cli/tests/it/cli.rs +++ b/apps/figcraft-cli/tests/it/cli.rs @@ -39,6 +39,40 @@ fn bad_arguments_exit_2_without_panicking() { assert_eq!(out.status.code(), Some(1)); } +/// `--help` / `-h` after a subcommand prints the usage on stdout and exits 0, as it does first +/// (#1155), whatever else is on the line; nothing runs. +#[test] +fn help_after_a_subcommand_prints_the_usage() { + for args in [ + &["--help"][..], + &["-h"], + &["help"], + &["mcp", "--help"], + &["mcp", "-h"], + &["info", "--help"], + &["export", "--help"], + &["export", "x.figcraft", "-o", "out", "--help"], + &["convert", "-h"], + &["render", "--help"], + &["run", "--help"], + &["commands", "--help"], + &["plugin", "publish", "--help"], + &["perf", "--help"], + &["mcp", "--attach", "notaport", "--help"], + &["mcp", "--bogus", "--help"], + ] { + let out = bin().args(args).stdin(Stdio::null()).output().unwrap(); + assert_eq!(out.status.code(), Some(0), "{args:?}: {}", String::from_utf8_lossy(&out.stderr)); + let text = String::from_utf8_lossy(&out.stdout); + assert!(text.contains("USAGE") && text.contains("figcraft-cli mcp"), "{args:?}: {text}"); + assert!(out.stderr.is_empty(), "{args:?}: nothing on stderr, got {}", String::from_utf8_lossy(&out.stderr)); + } + // A value that happens to be `-h` is still a value, not a request for help. + let out = bin().args(["commands", "--filter", "--help"]).output().unwrap(); + assert_eq!(out.status.code(), Some(0)); + assert!(!String::from_utf8_lossy(&out.stdout).contains("USAGE"), "--filter took `--help` as its text"); +} + #[test] fn run_export_info_convert() { let d = tmp("run"); @@ -80,6 +114,30 @@ fn run_export_info_convert() { std::fs::remove_dir_all(&d).ok(); } +/// `export` into a folder names the files as the app's export does (#1154): brackets, `+`, `&`, +/// `%`, `#` and commas stay, what file systems refuse becomes `_`. +#[test] +fn export_file_names_keep_what_the_app_keeps() { + let d = tmp("names"); + let script = d.join("s.json"); + let names = ["Homepage - desktop (live + verbeteringen)", "Q&A, 50% #1 [v2]", "a:b*c?d|e"]; + let steps: Vec = names + .iter() + .enumerate() + .map(|(i, n)| serde_json::json!({"command": "frame.create", "params": {"x": i * 200, "y": 0, "width": 20, "height": 20, "name": n}})) + .collect(); + std::fs::write(&script, serde_json::to_string(&steps).unwrap()).unwrap(); + let file = d.join("doc.figcraft"); + assert!(bin().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap().status.success()); + let dir = d.join("out"); + let out = bin().arg("export").arg(&file).arg("-o").arg(&dir).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + for want in ["Homepage - desktop (live + verbeteringen).png", "Q&A, 50% #1 [v2].png", "a_b_c_d_e.png"] { + assert!(dir.join(want).is_file(), "{want} in {:?}", std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name()).collect::>()); + } + std::fs::remove_dir_all(&d).ok(); +} + #[test] fn run_takes_the_documented_gradient_shorthand() { // #252: only MCP used to accept it; `run` said "`fills` must be paints or colours". diff --git a/apps/figcraft-server/src/cli_tests.rs b/apps/figcraft-server/src/cli_tests.rs index 7b77f71e..7d6a6b9e 100644 --- a/apps/figcraft-server/src/cli_tests.rs +++ b/apps/figcraft-server/src/cli_tests.rs @@ -11,7 +11,7 @@ use crate::store::Store; use crate::test_util::{TempDir, config}; fn args(s: &[&str]) -> Vec { - s.iter().map(|a| a.to_string()).collect() + s.iter().map(ToString::to_string).collect() } fn run(dir: &std::path::Path, command: Command) -> Result { diff --git a/apps/figcraft-server/src/disk.rs b/apps/figcraft-server/src/disk.rs index af6e0f99..d32a1747 100644 --- a/apps/figcraft-server/src/disk.rs +++ b/apps/figcraft-server/src/disk.rs @@ -13,6 +13,7 @@ //! store replays such a log anyway and reports any change the snapshot lacked. A log ends at its //! first damaged record (a write cut short). +use std::ffi::OsStr; use std::fs::{self, File}; use std::io::{self, Read, Write}; use std::path::{Path, PathBuf}; @@ -49,7 +50,7 @@ pub fn free_bytes(path: &Path) -> Option { /// `path` with `.tmp` added: where its next version is written first. pub fn temp_of(path: &Path) -> PathBuf { - let mut name = path.file_name().map(|n| n.to_os_string()).unwrap_or_default(); + let mut name = path.file_name().map(OsStr::to_os_string).unwrap_or_default(); name.push(".tmp"); path.with_file_name(name) } diff --git a/apps/figcraft-server/src/hardening_tests.rs b/apps/figcraft-server/src/hardening_tests.rs index 086539ca..92b17710 100644 --- a/apps/figcraft-server/src/hardening_tests.rs +++ b/apps/figcraft-server/src/hardening_tests.rs @@ -838,3 +838,25 @@ fn a_library_publish_that_fails_keeps_serving_the_old_version() { assert_eq!(served(fetch(&mut store)), (2, v2)); assert!(!files.join(crate::store::library_name(1)).exists(), "the old version is deleted"); } + +#[test] +fn a_library_publish_cannot_claim_the_highest_version() { + let dir = TempDir::new("libmax"); + let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap(); + let (file, ..) = store.publish("Library", &snapshot("Library")).unwrap(); + let mut s = Session::new(); + let f = s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 20, "name": "Button"})).unwrap()["id"].as_u64().unwrap(); + s.execute("component.create", &json!({"ids": [f]})).unwrap(); + s.execute("library.publish", &json!({})).unwrap(); + let mut lib = s.doc().unwrap().published.clone().unwrap().as_ref().clone(); + let mut archive = |version: u64| { + lib.manifest.version = version; + figcraft_engine::library_server::archive(&lib).unwrap() + }; + let high = 1u64 << 32; + assert!(store.publish_library(&file.id, &archive(u64::MAX)).is_err(), "a first publish can't claim the highest version"); + assert_eq!(store.publish_library(&file.id, &archive(high)).unwrap(), high, "the highest accepted first version goes in"); + assert_eq!(store.publish_library(&file.id, &archive(high + 1)).unwrap(), high + 1, "and a newer one still follows it"); + assert!(store.publish_library(&file.id, &archive(u64::MAX)).is_err(), "a jump to the highest version is refused"); + assert_eq!(store.publish_library(&file.id, &archive(high + 2)).unwrap(), high + 2, "later versions still go in"); +} diff --git a/apps/figcraft-server/src/public_tests.rs b/apps/figcraft-server/src/public_tests.rs index 978d9e8e..dc86d123 100644 --- a/apps/figcraft-server/src/public_tests.rs +++ b/apps/figcraft-server/src/public_tests.rs @@ -164,6 +164,64 @@ fn caps_per_address_and_for_everyone() { assert_eq!(store.publish_open(ip("198.51.100.8"), "No", &snapshot("No")).unwrap_err(), KEY_ONLY); } +#[test] +fn caps_per_address_count_the_wider_ipv6_blocks() { + let dir = TempDir::new("block-caps"); + let mut c = open(dir.path()); + c.max_files_per_ip = 1; + c.publishes_per_hour = 1000; + let mut store = Store::open(dir.path(), c.store()).unwrap(); + // A /48 keeps 4 times one address's files: /64s in different /56s of it. + for i in 0..4 { + store.publish_open(ip(&format!("2001:db8:1:{:x}::1", i << 8)), "One", &snapshot("One")).unwrap(); + } + let e = store.publish_open(ip("2001:db8:1:400::1"), "Five", &snapshot("Five")).unwrap_err(); + assert!(e.contains("files on this server already"), "{e}"); + // A /56 keeps twice one address's files. + store.publish_open(ip("2001:db8:2:1::1"), "One", &snapshot("One")).unwrap(); + store.publish_open(ip("2001:db8:2:2::1"), "Two", &snapshot("Two")).unwrap(); + let e = store.publish_open(ip("2001:db8:2:3::1"), "Three", &snapshot("Three")).unwrap_err(); + assert!(e.contains("files on this server already"), "{e}"); + drop(store); + // Bytes: one address keeps one file's worth, so a /48 keeps 4 files. + let probe = TempDir::new("block-caps-probe"); + let mut p = Store::open(probe.path(), open(probe.path()).store()).unwrap(); + let size = p.publish_open(ip("203.0.113.1"), "One", &snapshot("One")).unwrap().0.bytes; + let dir = TempDir::new("block-caps-bytes"); + let mut s = open(dir.path()).store(); + s.hosting.max_files_per_ip = 100; + s.hosting.max_bytes_per_ip = size; + let mut store = Store::open(dir.path(), s).unwrap(); + for i in 0..4 { + store.publish_open(ip(&format!("2001:db8:3:{:x}::1", i << 8)), "One", &snapshot("One")).unwrap(); + } + let e = store.publish_open(ip("2001:db8:3:400::1"), "One", &snapshot("One")).unwrap_err(); + assert!(e.contains("MiB it may keep"), "{e}"); +} + +#[test] +fn edits_count_the_wider_ipv6_blocks_too() { + use figcraft_collab_net::hub::Backend; + let probe = TempDir::new("block-room-probe"); + let mut p = Store::open(probe.path(), open(probe.path()).store()).unwrap(); + let size = p.publish_open(ip("203.0.113.1"), "One", &snapshot("One")).unwrap().0.bytes; + let dir = TempDir::new("block-room"); + let mut s = open(dir.path()).store(); + s.hosting.max_files_per_ip = 100; + // One address keeps 2.25 files' worth, so its /56 keeps 4.5. + s.hosting.max_bytes_per_ip = size * 2 + size / 4; + let mut store = Store::open(dir.path(), s).unwrap(); + let mut ids = Vec::new(); + for i in 0..4 { + ids.push(store.publish_open(ip(&format!("2001:db8:4:{i:x}::1")), "One", &snapshot("One")).unwrap().0.id); + } + let server = Backend::open(&mut store, &ids[0]).unwrap(); + // Its own /64 has room for this, but its /56 doesn't. + let e = Backend::room(&mut store, &ids[0], &server, (size * 3 / 4) as usize).unwrap_err(); + assert!(e.contains("have reached their"), "{e}"); + Backend::room(&mut store, &ids[0], &server, (size / 4) as usize).unwrap(); +} + #[test] fn idle_files_published_without_the_key_expire() { let dir = TempDir::new("expiry"); @@ -196,6 +254,22 @@ fn idle_files_published_without_the_key_expire() { assert!(store.listing(&|_| None).iter().all(|r| r.info.id != fresh.id)); } +#[test] +fn offline_commands_do_not_sweep_idle_files() { + let dir = TempDir::new("offline-sweep"); + let mut c = open(dir.path()); + c.idle_days = 30.0; + let mut store = Store::open(dir.path(), c.store()).unwrap(); + let (public, ..) = store.publish_open(ip("203.0.113.9"), "Public", &snapshot("Public")).unwrap(); + store.age(&public.id, 31 * 86_400); + drop(store); + let store = Store::try_open(dir.path(), c.store()).unwrap().unwrap(); + drop(store); + assert!(dir.path().join("files").join(&public.id).exists(), "a command deleted an idle file"); + let _serving = Store::open(dir.path(), c.store()).unwrap(); + assert!(!dir.path().join("files").join(&public.id).exists()); +} + #[test] fn rate_limits_and_blocks_refuse_with_a_reason() { let dir = TempDir::new("limits"); diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index ea9bc626..ab65c2d8 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -42,7 +42,7 @@ use figcraft_collab::message::{ PluginReply, PluginReq, ReportInfo, Usage, }; use figcraft_collab::{Change, Replica, Server, same_secret}; -use figcraft_collab_net::hub::{Admission, Backend, Closing, Handled, OpenFile, ip_key}; +use figcraft_collab_net::hub::{Admission, Backend, Closing, Handled, OpenFile, ip_blocks, ip_key}; use figcraft_collab_net::token; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -291,7 +291,18 @@ fn valid_id(s: &str) -> bool { /// A name others may see: trimmed, no control characters, at most 256 characters. fn clean_name(s: &str) -> String { - s.trim().chars().filter(|c| !c.is_control()).take(256).collect() + s.trim().chars().filter(|c| !c.is_control() && !is_bidi_control(*c)).take(256).collect() +} + +/// A report's reason: trimmed, control and bidi characters turned into spaces, at most `MAX_REASON` characters. +fn clean_reason(s: &str) -> String { + let s: String = s.chars().map(|c| if c.is_control() || is_bidi_control(c) { ' ' } else { c }).collect(); + s.trim().chars().take(MAX_REASON).collect() +} + +/// Bidirectional format characters, which could reorder what a terminal shows. +fn is_bidi_control(c: char) -> bool { + matches!(c, '\u{61C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}') } /// Take the data directory's lock: `None` when someone else holds it. @@ -302,6 +313,10 @@ pub fn lock(root: &Path) -> Result, String> { #[cfg(unix)] std::os::unix::fs::DirBuilderExt::mode(&mut dirs, 0o700); dirs.create(root).map_err(|e| format!("can't create the data directory {}: {e}", root.display()))?; + // `DirBuilder` only applies the mode to directories it creates; tighten an existing one too. + #[cfg(unix)] + fs::set_permissions(root, std::os::unix::fs::PermissionsExt::from_mode(0o700)) + .map_err(|e| format!("can't restrict the data directory {}: {e}", root.display()))?; let f = OpenOptions::new() .create(true) .truncate(false) @@ -334,16 +349,27 @@ fn write_key(root: &Path) -> Result { Ok(key) } +/// The highest version a file's first library publish may claim. Publishing counts up by one (a +/// library carried over from another server keeps its number), so no real history gets near it. +/// Later publishes may go one past the version on the server, so there is always a next one. +const MAX_FIRST_LIBRARY_VERSION: u64 = 1 << 32; + impl Store { /// Use the data directory `root` (made if missing). Fails when another server or command holds /// it. Files that can't be read are skipped and listed in [`problems`](Self::problems). pub fn open(root: &Path, cfg: StoreConfig) -> Result { - Self::try_open(root, cfg)?.ok_or_else(|| format!("another figcraft-server (or a command) is using {}", root.display())) + let mut store = Self::try_open(root, cfg)?.ok_or_else(|| format!("another figcraft-server (or a command) is using {}", root.display()))?; + store.sweep(); + Ok(store) } - /// [`open`](Self::open), or `None` when someone else holds the directory. + /// [`open`](Self::open) without the expiry sweep (for commands that only look or edit), or + /// `None` when someone else holds the directory. pub fn try_open(root: &Path, cfg: StoreConfig) -> Result, String> { let Some(lock) = lock(root)? else { return Ok(None) }; + for name in [KEY, BLOCKED, REPORTS] { + let _ = fs::remove_file(disk::temp_of(&root.join(name))); + } let key = load_key(root)?; let dir = root.join(FILES); fs::create_dir_all(&dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?; @@ -374,7 +400,6 @@ impl Store { store.scan(&dir); store.blocked = store.read_list::(BLOCKED).iter().filter_map(|s| s.parse().ok()).map(ip_key).collect(); store.reports = store.read_list(REPORTS); - store.sweep(); Ok(Some(store)) } @@ -425,12 +450,20 @@ impl Store { /// A file folder's description and links, after checking its snapshot and log. Leftover /// temporary files (writes cut short) are removed. fn check(&self, path: &Path) -> Result { - for name in [SNAPSHOT, LOG, META, LINKS] { + for name in [SNAPSHOT, LOG, META, LINKS, USAGE] { let tmp = disk::temp_of(&path.join(name)); if tmp.exists() { let _ = fs::remove_file(&tmp); } } + if let Ok(entries) = fs::read_dir(path) { + for e in entries.flatten() { + let name = e.file_name().to_string_lossy().into_owned(); + if name.starts_with("library-") && name.ends_with(".bin.tmp") { + let _ = fs::remove_file(e.path()); + } + } + } let meta: Meta = serde_json::from_slice(&disk::read_capped(&path.join(META), 1 << 20)?).map_err(|e| format!("{META} is damaged ({e})"))?; let links: Vec = serde_json::from_slice(&disk::read_capped(&path.join(LINKS), 16 << 20)?).map_err(|e| format!("{LINKS} is damaged ({e})"))?; @@ -514,6 +547,34 @@ impl Store { self.keep(name, snapshot, None, &|_| Ok(())).map(|(info, edit, view, _)| (info, edit, view)) } + /// For each block the address `ip` counts in ([`ip_blocks`]), how many times one address's + /// caps it gets, and the files and bytes kept by the files published from addresses in it. An + /// address that doesn't parse counts as itself only, ×1, by its exact text. + fn per_block(&self, ip: &str) -> Vec<(u32, usize, u64)> { + let blocks_of = |text: &str| text.parse::().map(|a| ip_blocks(ip_key(a))).unwrap_or_default(); + let kept: Vec<(&str, Vec<_>, u64)> = self + .files + .iter() + .filter_map(|(id, e)| { + let from = e.meta.ip.as_deref()?; + Some((from, blocks_of(from).into_iter().map(|(b, _)| b).collect(), self.bytes(id))) + }) + .collect(); + let sum = |mine: &mut dyn Iterator, u64)>| mine.fold((0, 0u64), |(f, b), (_, _, n)| (f + 1, b.saturating_add(*n))); + let blocks = blocks_of(ip); + if blocks.is_empty() { + let (files, bytes) = sum(&mut kept.iter().filter(|(from, _, _)| *from == ip)); + return vec![(1, files, bytes)]; + } + blocks + .into_iter() + .map(|(block, times)| { + let (files, bytes) = sum(&mut kept.iter().filter(|(_, blocks, _)| blocks.contains(&block))); + (times, files, bytes) + }) + .collect() + } + /// Keep a document published without the key from `ip`, within the caps on what one address /// and everyone may keep: the file, its links and its owner token. pub fn publish_open(&mut self, ip: IpAddr, name: &str, snapshot: &[Change]) -> Result<(FileInfo, NewLink, NewLink, String), String> { @@ -525,19 +586,21 @@ impl Store { if !self.abuse.allow(ip, Action::Publish, Instant::now()) { return Err("too many files published from your address lately: try again in an hour".into()); } - let ip_text = ip.to_string(); - let mine: Vec<&String> = self.files.iter().filter(|(_, e)| e.meta.ip.as_deref() == Some(ip_text.as_str())).map(|(id, _)| id).collect(); - if mine.len() >= max_files { - return Err(format!( - "your address keeps {} files on this server already, the most it may: remove one (with its owner token) or wait until one expires", - mine.len() - )); + let blocks = self.per_block(&ip.to_string()); + for &(times, files, _) in &blocks { + if files >= max_files.saturating_mul(times as usize) { + return Err(format!( + "your address keeps {files} files on this server already, the most it may: remove one (with its owner token) or wait until one expires" + )); + } } - let used_ip: u64 = mine.iter().map(|id| self.bytes(id)).sum(); let used_all: u64 = self.files.keys().map(|id| self.bytes(id)).sum(); let fits = |n: u64| { - if used_ip.saturating_add(n) > max_ip { - return Err(format!("your address's files would take more than the {} MiB it may keep here: remove one first", max_ip >> 20)); + for &(times, _, used) in &blocks { + let cap = max_ip.saturating_mul(u64::from(times)); + if used.saturating_add(n) > cap { + return Err(format!("your address's files would take more than the {} MiB it may keep here: remove one first", cap >> 20)); + } } if used_all.saturating_add(n) > max_all { return Err("this server is full: it takes no more files for now (try again later, or another server)".into()); @@ -748,7 +811,7 @@ impl Store { /// Keep a report about `file` (from someone who joined it with `link`, at `ip`). pub fn keep_report(&mut self, file: &str, link: Option<&str>, ip: IpAddr, reason: &str) -> Result<(), String> { - let reason: String = reason.trim().chars().map(|c| if c.is_control() { ' ' } else { c }).take(MAX_REASON).collect(); + let reason = clean_reason(reason); if reason.is_empty() { return Err("say what is wrong with the file".into()); } @@ -973,6 +1036,12 @@ impl Store { if m.version <= old.version { return Err(format!("version {} is on the server already: publish again to follow it", old.version)); } + // A jump would let a client use up the numbers and leave no newer version to publish. + if m.version > old.version.saturating_add(1) { + return Err(format!("version {} is too high: the next one after {} is {}", m.version, old.version, old.version.saturating_add(1))); + } + } else if m.version > MAX_FIRST_LIBRARY_VERSION { + return Err(format!("version {} is too high: a first publish goes up to {MAX_FIRST_LIBRARY_VERSION}", m.version)); } // Each version has a file of its own, written before the description names it: a failed // description write or a crash in between leaves the previous version served whole, never @@ -1090,9 +1159,11 @@ impl Store { let h = &self.cfg.hosting; let Some(ip) = self.files.get(file).filter(|e| e.meta.owner.is_some()).and_then(|e| e.meta.ip.clone()) else { return Ok(()) }; let (max_ip, max_all) = (h.max_bytes_per_ip, h.max_total_bytes); - let used_ip: u64 = self.files.iter().filter(|(_, e)| e.meta.ip.as_deref() == Some(ip.as_str())).map(|(id, _)| self.bytes(id)).sum(); - if used_ip.saturating_add(n) > max_ip { - return Err(format!("the files published from this file's address have reached their {} MiB: that change wasn't kept", max_ip >> 20)); + for (times, _, used) in self.per_block(&ip) { + let cap = max_ip.saturating_mul(u64::from(times)); + if used.saturating_add(n) > cap { + return Err(format!("the files published from this file's address have reached their {} MiB: that change wasn't kept", cap >> 20)); + } } let used_all: u64 = self.files.keys().map(|id| self.bytes(id)).sum(); if used_all.saturating_add(n) > max_all { @@ -1110,7 +1181,24 @@ impl Store { let mut issues = Vec::new(); let mut fresh_log = true; let mut kept = 0; - match disk::read_capped(&dir.join(LOG), self.cfg.max_file_bytes).and_then(|b| disk::read_log(&b)) { + let log_path = dir.join(LOG); + // A log that's there but can't be read (an IO error, or over a lowered cap) may hold + // confirmed edits: the file isn't opened, and the log is left as it is for another try. + let read = match disk::read_capped(&log_path, self.cfg.max_file_bytes) { + Err(why) if log_path.exists() => return Err(format!("its log can't be read ({why}); it was left as it is")), + r => r, + }; + // One that isn't a log is moved aside before a new one replaces it (or the file isn't opened). + let read = match read.map(|b| disk::read_log(&b)) { + Ok(Err(why)) => { + let aside = dir.join(format!("{LOG}.unreadable-{generation}-{}", now())); + fs::rename(&log_path, &aside).map_err(|e| format!("{why}, and it couldn't be moved aside ({e})"))?; + Err(format!("{why}; it was moved aside to {}", aside.display())) + } + Ok(Ok(log)) => Ok(log), + Err(why) => Err(why), + }; + match read { Ok(log) if log.generation == generation => { fresh_log = false; for entry in log.entries { @@ -1167,7 +1255,10 @@ impl Store { Log { generation, file: f, pending: Vec::new(), entries: kept, bytes, dirty: kept > 0 } } // A new snapshot and log of the next generation, so appends start clean. - _ => self.rewrite(file, generation + 1, &server).map_err(|e| e.why)?, + _ => { + let next = generation.checked_add(1).ok_or("the file's generation can't go any higher")?; + self.rewrite(file, next, &server).map_err(|e| e.why)? + } }; self.entry(file)?.log = Some(log); Ok(server) @@ -1243,7 +1334,11 @@ impl Store { fn compact(&mut self, file: &str, server: &Server) { self.compactions += 1; let Some(generation) = self.files.get(file).and_then(|e| e.log.as_ref()).map(|l| l.generation) else { return }; - match self.rewrite(file, generation + 1, server) { + let Some(next) = generation.checked_add(1) else { + log::error!("file {file}: compaction failed: the file's generation can't go any higher"); + return; + }; + match self.rewrite(file, next, server) { Ok(log) => { if let Ok(e) = self.entry(file) { e.log = Some(log); diff --git a/apps/figcraft-server/src/store_tests.rs b/apps/figcraft-server/src/store_tests.rs index f380568c..d8c5b915 100644 --- a/apps/figcraft-server/src/store_tests.rs +++ b/apps/figcraft-server/src/store_tests.rs @@ -229,6 +229,17 @@ fn one_server_per_data_directory() { assert_eq!(again.key(), key, "the key is kept"); } +#[cfg(unix)] +#[test] +fn an_existing_data_directory_is_made_private() { + use std::os::unix::fs::PermissionsExt; + let dir = TempDir::new("perms"); + fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o755)).unwrap(); + assert!(crate::store::lock(dir.path()).unwrap().is_some()); + let mode = fs::metadata(dir.path()).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o700); +} + #[test] fn temporary_directories_are_removed() { let path = { @@ -313,3 +324,129 @@ fn edits_confirmed_after_a_compaction_cut_short_survive_a_restart() { let name = page_name(&restored); assert!(at(&name) >= at(&confirmed), "restored {name:?}, confirmed {confirmed:?}"); } + +#[test] +fn a_log_that_cant_be_read_is_never_overwritten() { + let dir = TempDir::new("unreadlog"); + let c = StoreConfig { compact_after: 1000, ..cfg(dir.path()) }; + let log_path = dir.path().join(FILES); + let last = "Confirmed page name number 39 with some padding"; + let id = { + let mut store = Store::open(dir.path(), c.clone()).unwrap(); + let (id, _, _) = publish(&mut store, "Busy"); + let mut server = store.open(&id).unwrap(); + let mut a = join(&mut server, Access::Edit); + store.changed(&id, &mut server); + for i in 0..40 { + rename(&mut server, &mut a, &format!("Confirmed page name number {i} with some padding")); + store.changed(&id, &mut server); + } + assert!(store.flush().is_empty()); + id + }; + let log_path = log_path.join(&id).join(LOG); + let before = fs::read(&log_path).unwrap(); + // The cap lowered just under the log: it can't be read now, but it must still be there after. + let lowered = StoreConfig { max_file_bytes: before.len() as u64 - 1, ..c.clone() }; + { + let mut store = Store::open(dir.path(), lowered).unwrap(); + let opened = store.open(&id); + assert!(opened.is_err(), "the file isn't opened without its log"); + assert!(!store.problems().is_empty(), "the problem is reported"); + assert_eq!(fs::read(&log_path).unwrap(), before, "the log is left as it was"); + } + let mut store = Store::open(dir.path(), c).unwrap(); + let server = store.open(&id).unwrap(); + assert_eq!(page_name(&server), last, "every confirmed edit comes back"); +} + +#[test] +fn a_log_that_isnt_one_is_moved_aside() { + let dir = TempDir::new("badlog"); + let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap(); + let (id, _, _) = publish(&mut store, "Busy"); + let folder = dir.path().join(FILES).join(&id); + let junk = b"not a log at all, but maybe worth keeping".to_vec(); + fs::write(folder.join(LOG), &junk).unwrap(); + drop(store); + let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap(); + store.open(&id).unwrap(); + assert!(store.problems().iter().any(|p| p.contains("moved aside")), "{:?}", store.problems()); + let kept = fs::read_dir(&folder).unwrap().filter_map(|e| e.ok()).any(|e| fs::read(e.path()).is_ok_and(|b| b == junk)); + assert!(kept, "the old log's bytes are kept beside the file"); +} + +const BIDI: [char; 12] = + ['\u{61C}', '\u{200E}', '\u{200F}', '\u{202A}', '\u{202B}', '\u{202C}', '\u{202D}', '\u{202E}', '\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}']; + +#[test] +fn names_and_report_reasons_lose_bidi_controls() { + let dir = TempDir::new("bidi"); + let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap(); + for (n, m) in BIDI.into_iter().enumerate() { + let ip: std::net::IpAddr = format!("192.0.2.{}", n + 1).parse().unwrap(); + let (id, _, _) = publish(&mut store, &format!("a{m}b")); + assert_eq!(store.list(&|_| None)[0].name, "ab", "name U+{:04X}", m as u32); + store.keep_report(&id, None, ip, &format!("a{m}b")).unwrap(); + assert_eq!(store.reports().last().unwrap().reason, "a b", "reason U+{:04X}", m as u32); + store.remove(&id).unwrap(); + } + // A reason that is only control characters says nothing. + let (id, _, _) = publish(&mut store, "blank"); + for blank in ["\0", "\u{202E}", " \0\t\u{2066} "] { + assert!(store.keep_report(&id, None, "192.0.2.99".parse().unwrap(), blank).is_err(), "{blank:?}"); + } +} + +#[test] +fn a_maxed_out_generation_does_not_wrap() { + let dir = TempDir::new("maxgen"); + let c = StoreConfig { compact_after: 3, ..cfg(dir.path()) }; + let mut store = Store::open(dir.path(), c.clone()).unwrap(); + let (id, _, _) = publish(&mut store, "Max"); + let path = dir.path().join(FILES).join(&id); + let (_, state) = disk::decode_snapshot(&fs::read(path.join(SNAPSHOT)).unwrap()).unwrap(); + // Loading: no log goes with the snapshot, so the next generation would overflow. + fs::write(path.join(SNAPSHOT), disk::encode_snapshot(u64::MAX, &state).unwrap()).unwrap(); + fs::remove_file(path.join(LOG)).unwrap(); + assert!(store.open(&id).is_err()); + // Compacting: the open log is at the largest generation. + fs::write(path.join(SNAPSHOT), disk::encode_snapshot(u64::MAX - 1, &state).unwrap()).unwrap(); + let _ = fs::remove_file(path.join(LOG)); + let mut server = store.open(&id).unwrap(); + let mut a = join(&mut server, Access::Edit); + store.changed(&id, &mut server); + for i in 0..4 { + rename(&mut server, &mut a, &format!("P{i}")); + store.changed(&id, &mut server); + store.flush(); + } + let snap = disk::check_snapshot(&fs::read(path.join(SNAPSHOT)).unwrap()).unwrap(); + assert_eq!(snap, u64::MAX); +} + +#[test] +fn every_leftover_temporary_file_is_removed_at_startup() { + let dir = TempDir::new("leftovers"); + let id = { + let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap(); + let id = publish(&mut store, "A").0; + store.flush(); + id + }; + let folder = dir.path().join(FILES).join(&id); + let leftovers = [ + dir.path().join("blocked.json.tmp"), + dir.path().join("reports.json.tmp"), + dir.path().join("server.key.tmp"), + folder.join("usage.json.tmp"), + folder.join("library-1.bin.tmp"), + ]; + for path in &leftovers { + fs::write(path, b"half").unwrap(); + } + let _store = Store::open(dir.path(), cfg(dir.path())).unwrap(); + for path in &leftovers { + assert!(!path.exists(), "leftover not cleaned at startup: {}", path.display()); + } +} diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index f08ca90f..f1d7ce21 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -69,7 +69,12 @@ struct WebShell { } impl eframe::App for WebShell { - // `FigApp::ui` runs its own per-frame logic first (fonts, theme), like the desktop app. + // eframe runs `logic` for visible tabs and, with only `run_logic`, for hidden ones: keep + // autosave/live/control deadlines moving without a paint. `FigApp::ui` does not repeat it. + fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + figcraft_ui_egui::windows::guard(&mut self.fig, ctx, |fig| fig.background_tick(ctx)); + } + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { self.fig.ui(ui); } diff --git a/apps/figcraft/Cargo.toml b/apps/figcraft/Cargo.toml index 6196d08d..141b9517 100644 --- a/apps/figcraft/Cargo.toml +++ b/apps/figcraft/Cargo.toml @@ -21,7 +21,7 @@ figcraft-text = { workspace = true } # craft-libs#217 branch and must be repointed to a merged `rev` before this PR is mergeable. craft-window = { workspace = true } egui = { workspace = true } -eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] } +eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "wayland", "x11"] } serde_json = { workspace = true } log = { workspace = true } rfd = { workspace = true } diff --git a/apps/figcraft/src/args_tests.rs b/apps/figcraft/src/args_tests.rs index b274beda..04229e51 100644 --- a/apps/figcraft/src/args_tests.rs +++ b/apps/figcraft/src/args_tests.rs @@ -3,7 +3,7 @@ use crate::args::{Args, file_url_path, is_invite, parse}; fn args(a: &[&str]) -> Vec { - a.iter().map(|s| s.to_string()).collect() + a.iter().map(ToString::to_string).collect() } #[test] diff --git a/apps/figcraft/src/control_server.rs b/apps/figcraft/src/control_server.rs index 1c91840f..ff9c2fbe 100644 --- a/apps/figcraft/src/control_server.rs +++ b/apps/figcraft/src/control_server.rs @@ -16,6 +16,10 @@ const MAX_LINE: usize = 32 * 1024 * 1024; const MAX_CONNECTIONS: usize = 8; const MAX_QUEUED_REQUESTS: usize = 8; const TIMEOUT: Duration = Duration::from_secs(60); +/// How long the accepting thread waits after a failed accept. Accepting can keep failing, for +/// example while the process has no file descriptors left, and retrying at once would keep a +/// core busy until it recovers. +const ACCEPT_RETRY: Duration = Duration::from_millis(100); /// Deferred requests for a hidden window must not make the UI's queue grow without a bound. pub fn drain_queue(queue: &Queue, target: &mut Vec) { @@ -38,7 +42,7 @@ pub fn start(port: u16, queue: Queue, ctx: CtxSlot) { eprintln!("FigCraft control server listening on 127.0.0.1:{port}"); let spawned = std::thread::Builder::new().name("figcraft-control".into()).spawn(move || { let mut workers = Vec::new(); - for stream in listener.incoming().flatten() { + for stream in accepted(listener.incoming(), || std::thread::sleep(ACCEPT_RETRY)) { spawn_connection(stream, queue.clone(), ctx.clone(), &mut workers, TIMEOUT); } }); @@ -47,6 +51,11 @@ pub fn start(port: u16, queue: Queue, ctx: CtxSlot) { } } +/// The connections `incoming` accepts, calling `wait` after each failed accept. +fn accepted(incoming: impl Iterator>, mut wait: impl FnMut()) -> impl Iterator { + incoming.filter_map(move |stream| stream.map_err(|_| wait()).ok()) +} + fn spawn_connection(stream: TcpStream, queue: Queue, ctx: CtxSlot, workers: &mut Vec>, timeout: Duration) { workers.retain(|worker| !worker.is_finished()); if workers.len() >= MAX_CONNECTIONS { diff --git a/apps/figcraft/src/control_server_tests.rs b/apps/figcraft/src/control_server_tests.rs index 008170fb..011b0613 100644 --- a/apps/figcraft/src/control_server_tests.rs +++ b/apps/figcraft/src/control_server_tests.rs @@ -131,6 +131,18 @@ fn idle_and_partial_frame_connections_expire() { } } +/// A failed accept is followed by a wait before the next one, so a listener that keeps failing +/// does not spin, and the connections around the failures still come through. +#[test] +fn a_failed_accept_waits_before_the_next() { + let failed = || Err(std::io::Error::other("no file descriptors left")); + let incoming = vec![failed(), Ok(1), failed(), failed(), Ok(2)]; + let mut waits = 0; + let connections: Vec = accepted(incoming.into_iter(), || waits += 1).collect(); + assert_eq!(connections, [1, 2]); + assert_eq!(waits, 3, "one wait per failed accept"); +} + #[test] fn connection_limit_rejects_excess_clients_and_reuses_finished_slots() { let pending = queue(); diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index 0b71149a..be380c60 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -71,7 +71,9 @@ impl eframe::App for App { } let title = with_app(|fig| { control_server::drain_queue(&self.queue, &mut fig.control_queue); - figcraft_ui_egui::control::drain_hidden(fig, ctx); + // Phase-safe background work: control queues, autosave/recovery deadlines, live polling. + // It runs on logic-only callbacks too, so it never reads UI input. + figcraft_ui_egui::windows::guard(fig, ctx, |fig| fig.background_tick(ctx)); #[cfg(target_os = "macos")] apple_events::poll(&self.apple_events, fig, ctx); // Closing the window asks about unsaved changes (and keeps the app running while other @@ -86,8 +88,14 @@ impl eframe::App for App { ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone())); self.title = title; } - let now = ctx.input(|i| i.time); - if let Some(wait) = with_app(|fig| recovery::lock(&self.recovery).as_mut().and_then(|r| r.tick(&fig.session, now))).flatten() { + // Recovery deadlines use the same monotonic clock as the app's background work: a + // logic-only tick leaves `ctx.input(i.time)` stale. + if let Some(wait) = with_app(|fig| { + let now = fig.now_secs(); + recovery::lock(&self.recovery).as_mut().and_then(|r| r.tick(&fig.session, now)) + }) + .flatten() + { ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait)); } } diff --git a/contributors/people.toml b/contributors/people.toml index 27307935..6a92a2a9 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -71,3 +71,8 @@ sync_github_name = true [people.AganzeFelicite] sync_github_name = true + +[people.switch87] +real_name = "Gert Pellin" +display_name = "Gert Pellin" +sync_github_name = false diff --git a/crates/collab-net/src/barred_tests.rs b/crates/collab-net/src/barred_tests.rs index c9355a5f..7942e766 100644 --- a/crates/collab-net/src/barred_tests.rs +++ b/crates/collab-net/src/barred_tests.rs @@ -51,3 +51,47 @@ fn a_closed_file_forgets_whom_it_barred() { assert!(!hub.barred.contains_key("f"), "and its barred addresses went with it"); assert!(hub.barred.contains_key("other"), "another file's stay"); } + +#[test] +fn a_kicked_connection_with_a_full_queue_is_shut() { + use std::io::Read; + use std::net::TcpListener; + use std::sync::Arc; + use std::sync::atomic::AtomicUsize; + use std::sync::mpsc::sync_channel; + use std::time::Duration; + + use super::{Conn, Kind, Line, Out}; + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let mut client = std::net::TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + let (server_side, _) = listener.accept().unwrap(); + client.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + // Stands in for the writer thread's handle: it keeps the socket open after the Conn is gone. + let _writer = server_side.try_clone().unwrap(); + let (tx, _rx) = sync_channel(2); + while tx.try_send(Out::Bytes(Default::default(), None)).is_ok() {} + let out = Line { + tx, + queued: Arc::default(), + block: Arc::new(AtomicUsize::new(0)), + all: Arc::new(AtomicUsize::new(0)), + max: 1 << 20, + max_block: usize::MAX, + max_all: usize::MAX, + }; + let ip = IpAddr::V4(Ipv4Addr::LOCALHOST); + let mut hub = Hub { + in_flight: InFlight(Default::default()), + backend: Closes, + rooms: HashMap::new(), + conns: HashMap::from([(1, Conn { out, sock: server_side, kind: Kind::Managing, ip })]), + local: None, + outbox: Vec::new(), + barred: HashMap::new(), + }; + hub.kick(&[1], "kicked"); + assert!(hub.conns.is_empty()); + let mut buf = [0u8; 1]; + assert_eq!(client.read(&mut buf).ok(), Some(0), "the kicked socket must be shut (client sees EOF)"); +} diff --git a/crates/collab-net/src/hub.rs b/crates/collab-net/src/hub.rs index 5cf3e519..c2202919 100644 --- a/crates/collab-net/src/hub.rs +++ b/crates/collab-net/src/hub.rs @@ -402,6 +402,8 @@ enum HubIn { sock: TcpStream, /// Its size (in flight until handled). bytes: usize, + /// Told when the hub takes the connection in (dropped unsent when it doesn't). + admitted: SyncSender<()>, }, Msg { conn: u64, @@ -811,17 +813,22 @@ fn encode(msg: &ServerMsg) -> Option { /// Queue `Refused {reason}` and a close for one connection (small: not counted against its caps). fn refuse(out: &Line, why: &str) { + refused(out, why); +} + +/// [`refuse`], and whether the close was queued (it isn't when the queue is full). +fn refused(out: &Line, why: &str) -> bool { if let Some(b) = encode(&ServerMsg::Refused { reason: why.to_string() }) { let _ = out.tx.try_send(Out::Bytes(b, None)); } - let _ = out.tx.try_send(Out::Close(CloseCode::Policy, reason(why))); + out.tx.try_send(Out::Close(CloseCode::Policy, reason(why))).is_ok() } /// What the server tells a client when it is too busy to take more from it. const BUSY: &str = "the server is busy: try again in a moment"; -/// Read the next binary message (skipping pings) and its size, or why the connection should end. -fn next_message(ws: &mut WebSocket, max: usize) -> Result, String> { +/// Read the next binary message (skipping pings, until `deadline`) and its size, or why the connection should end. +fn next_message(ws: &mut WebSocket, max: usize, deadline: Option) -> Result, String> { loop { return match ws.read() { Ok(Message::Binary(b)) => { @@ -830,6 +837,13 @@ fn next_message(ws: &mut WebSocket, max: usize) -> Result(&b, max).map(|m| Some((m, b.len()))).map_err(|_| "that message couldn't be read".to_string()) } Ok(Message::Text(_)) => Err("only binary messages are spoken here".into()), + // Pings keep the per-read timeout alive, but not the overall deadline (slowloris), and + // a message trickled in is cut off by it (`Duplex::read_until`). + Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) | Err(tungstenite::Error::Io(_)) + if deadline.is_some_and(|d| Instant::now() >= d) => + { + Err("no hello in time".into()) + } Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) => continue, Ok(Message::Close(_)) => Ok(None), Err(tungstenite::Error::Capacity(_)) => Err(too_large(max)), @@ -851,6 +865,7 @@ fn too_large(max: usize) -> String { /// One connection's reader (the writer runs on its own thread). fn serve(mut stream: TcpStream, conn: u64, hub: &Sender, limits: NetLimits, open: Open, shared: &Shared) { let _ = stream.set_nodelay(true); + let hello_deadline = Instant::now() + limits.hello_timeout; if stream.set_read_timeout(Some(limits.hello_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() { return; } @@ -859,7 +874,9 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender, limits: NetLimit } let Ok(peer) = stream.peer_addr().map(|a| a.ip().to_canonical()) else { return }; let proxied = limits.trusted_proxy.is_some_and(|p| p.to_canonical() == peer); - let Ok((rd, wr, sock)) = Duplex::split(stream, None) else { return }; + let Ok((mut rd, wr, sock)) = Duplex::split(stream, None) else { return }; + // The handshake and the hello must be in by the deadline, however slowly they trickle in. + rd.read_until(Some(hello_deadline)); let cfg = config(limits.max_client_message); let mut forwarded = None; // The handshake callback's type is tungstenite's (its error is an HTTP response). @@ -923,7 +940,10 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender, limits: NetLimit log::info!("event=refused ip={ip} reason=connections_per_address"); end(Some("too many connections from your address: close some and try again")); } else { - match next_message(&mut ws, limits.max_hello) { + let hello = next_message(&mut ws, limits.max_hello, Some(hello_deadline)); + // In time or not, the hello's deadline is over: what's read next has timeouts of its own. + ws.get_mut().read_until(None); + match hello { Ok(Some(( first @ (ClientMsg::Hello(_) | ClientMsg::Manage(_) @@ -946,9 +966,13 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender, limits: NetLimit if reserved || !shared.in_flight.take(bytes, limits.max_in_flight) { end(Some(BUSY)); } else { - ws.set_config(|c| *c = config(max)); - let opened = sock.try_clone().is_ok_and(|s| hub.send(HubIn::Open { conn, ip, first, out: out.clone(), sock: s, bytes }).is_ok()); - if opened && ws.get_ref().set_read_timeout(Some(idle)).is_ok() { + let (admitted, verdict) = sync_channel(1); + let opened = + sock.try_clone().is_ok_and(|s| hub.send(HubIn::Open { conn, ip, first, out: out.clone(), sock: s, bytes, admitted }).is_ok()); + // Nothing more is read until the hub took the connection in (its token checked): + // only then may it send messages of the full size. + if opened && verdict.recv().is_ok() && ws.get_ref().set_read_timeout(Some(idle)).is_ok() { + ws.set_config(|c| *c = config(max)); read_loop(&mut ws, conn, hub, limits, max, &shared.in_flight, &end); let _ = hub.send(HubIn::Gone { conn }); } @@ -962,6 +986,7 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender, limits: NetLimit drop(counted); // The block's counters go with the last handle on them (the table forgets it then). drop(block); + ws.get_mut().read_until(None); linger(&mut ws); drop(out); // The writer closes the socket once it has sent what's queued (a refusal, a close). @@ -981,7 +1006,7 @@ fn read_loop( let mut bucket = Bucket::new(limits.rate, limits.burst); let mut signals = Bucket::new(SIGNAL_RATE, SIGNAL_BURST); loop { - match next_message(ws, max) { + match next_message(ws, max, None) { Ok(Some((msg, bytes))) => { let now = Instant::now(); if !bucket.take(now) { @@ -1074,6 +1099,8 @@ struct Pending { out: Line, sock: TcpStream, ip: IpAddr, + /// Told when it is taken in. + admitted: SyncSender<()>, } /// A connection the hub took in. @@ -1195,7 +1222,7 @@ impl Hub { fn handle(&mut self, m: HubIn) { match m { - HubIn::Open { conn, ip, first, out, sock, bytes } => { + HubIn::Open { conn, ip, first, out, sock, bytes, admitted } => { self.in_flight.give(bytes); // The operator (with the server key) gets in from anywhere; everyone else is // checked (blocked addresses, connection rates). @@ -1204,7 +1231,7 @@ impl Hub { log::info!("event=refused ip={ip} reason={why:?}"); return refuse(&out, &why); } - let c = Pending { out, sock, ip }; + let c = Pending { out, sock, ip, admitted }; match first { ClientMsg::Hello(hello) => self.join(conn, hello, c), ClientMsg::Manage(m) => self.manage(conn, &m, c), @@ -1399,6 +1426,7 @@ impl Hub { if let Some(c) = old.and_then(|old| self.conns.remove(&old)) { shutdown(&c.sock); } + let _ = c.admitted.try_send(()); self.conns .insert(conn, Conn { out: c.out, sock: c.sock, kind: Kind::Joined { file: file.clone(), id, link: admission.link }, ip: c.ip }); self.queue(&file, more); @@ -1429,7 +1457,9 @@ impl Hub { barred.insert(c.ip); } log::info!("event=removed file={file} client={id} ip={}", c.ip); - let _ = c.out.tx.try_send(Out::Close(CloseCode::Away, REMOVED.into())); + if c.out.tx.try_send(Out::Close(CloseCode::Away, REMOVED.into())).is_err() { + shutdown(&c.sock); + } self.gone(conn); Ok(()) } @@ -1444,6 +1474,7 @@ impl Hub { /// Take in connection `c` as `kind` and send it `first`. fn take_in(&mut self, conn: u64, c: Pending, kind: Kind, first: &ServerMsg) { + let _ = c.admitted.try_send(()); self.conns.insert(conn, Conn { out: c.out, sock: c.sock, kind, ip: c.ip }); self.answer(conn, first); } @@ -1543,8 +1574,10 @@ impl Hub { // People in the file are told the session is over for them, as when a host stops (they // keep their copy; nothing retries); a library request is refused. for conn in &people { - if let Some(c) = self.conns.get(conn) { - let _ = c.out.tx.try_send(Out::Close(CloseCode::Away, STOPPED.into())); + if let Some(c) = self.conns.get(conn) + && c.out.tx.try_send(Out::Close(CloseCode::Away, STOPPED.into())).is_err() + { + shutdown(&c.sock); } self.gone(*conn); } @@ -1593,8 +1626,10 @@ impl Hub { /// Refuse connections with `why` and drop them at once (nothing more they send counts). fn kick(&mut self, conns: &[u64], why: &str) { for conn in conns { - if let Some(c) = self.conns.get(conn) { - refuse(&c.out, why); + if let Some(c) = self.conns.get(conn) + && !refused(&c.out, why) + { + shutdown(&c.sock); } self.gone(*conn); } @@ -1747,3 +1782,7 @@ mod queue_tests; #[cfg(test)] #[path = "barred_tests.rs"] mod barred_tests; + +#[cfg(test)] +#[path = "preauth_tests.rs"] +mod preauth_tests; diff --git a/crates/collab-net/src/inbox.rs b/crates/collab-net/src/inbox.rs index fb505a0a..1ec00e67 100644 --- a/crates/collab-net/src/inbox.rs +++ b/crates/collab-net/src/inbox.rs @@ -11,11 +11,18 @@ use figcraft_engine::live::{Event, Presence}; pub type Waker = Arc; /// Most discrete events kept between two drains (joins, leaves, status); the oldest go first. +/// Document sync events don't count and are never dropped: the sync client treats a missing +/// batch as a fatal sequence gap, so losing one would end the session. const MAX_EVENTS: usize = 10_000; #[derive(Default)] struct State { - events: VecDeque, + /// Arrival number of the next event, so [`Inbox::drain`] can merge the two queues in order. + next: u64, + /// [`Event::Sync`] events, never dropped. + syncs: VecDeque<(u64, Event)>, + /// Every other event, capped at [`MAX_EVENTS`]. + others: VecDeque<(u64, Event)>, presence: BTreeMap, } @@ -43,11 +50,17 @@ impl Inbox { if let Event::PeerLeft(id) = &e { s.presence.remove(id); } - if s.events.len() >= MAX_EVENTS { - log::warn!("live session: {MAX_EVENTS} events not taken; dropping the oldest"); - s.events.pop_front(); + let n = s.next; + s.next += 1; + if matches!(e, Event::Sync(_)) { + s.syncs.push_back((n, e)); + return; } - s.events.push_back(e); + if s.others.len() >= MAX_EVENTS { + log::warn!("live session: {MAX_EVENTS} events not taken; dropping the oldest"); + s.others.pop_front(); + } + s.others.push_back((n, e)); }); } @@ -62,8 +75,70 @@ impl Inbox { /// peer. pub fn drain(&self) -> Vec { let mut s = self.state.lock().unwrap_or_else(PoisonError::into_inner); - let mut out: Vec = s.events.drain(..).collect(); + let mut syncs = std::mem::take(&mut s.syncs).into_iter().peekable(); + let mut others = std::mem::take(&mut s.others).into_iter().peekable(); + let mut out = Vec::with_capacity(syncs.len() + others.len() + s.presence.len()); + loop { + let sync_first = match (syncs.peek(), others.peek()) { + (Some((a, _)), Some((b, _))) => a < b, + (Some(_), None) => true, + (None, Some(_)) => false, + (None, None) => break, + }; + let next = if sync_first { syncs.next() } else { others.next() }; + out.extend(next.map(|(_, e)| e)); + } out.extend(std::mem::take(&mut s.presence).into_iter().map(|(id, p)| Event::Presence(id, p))); out } } + +#[cfg(test)] +mod tests { + use super::*; + use figcraft_collab::message::ServerMsg; + + fn reject(batch_id: u64) -> Event { + Event::Sync(ServerMsg::Reject { batch_id, reason: String::new(), fixes: Vec::new() }) + } + + fn sync_ids(events: &[Event]) -> Vec { + events + .iter() + .filter_map(|e| match e { + Event::Sync(ServerMsg::Reject { batch_id, .. }) => Some(*batch_id), + _ => None, + }) + .collect() + } + + #[test] + fn a_full_inbox_never_drops_sync_events() { + let inbox = Inbox::new(Arc::new(|| {})); + inbox.push(reject(1)); + for _ in 0..MAX_EVENTS { + inbox.push(Event::PeerLeft(7)); + } + inbox.push(reject(2)); + for _ in 0..MAX_EVENTS { + inbox.push(Event::PeerLeft(7)); + } + let out = inbox.drain(); + assert_eq!(sync_ids(&out), vec![1, 2]); + assert!(out.len() <= MAX_EVENTS + 2, "{} events kept", out.len()); + } + + #[test] + fn sync_events_past_the_cap_are_all_kept_in_order() { + let inbox = Inbox::new(Arc::new(|| {})); + let n = MAX_EVENTS as u64 + 5; + for id in 0..n { + inbox.push(reject(id)); + } + inbox.push(Event::PeerLeft(7)); + let out = inbox.drain(); + let ids = sync_ids(&out); + assert_eq!(ids.len() as u64, n, "Sync events dropped"); + assert!(ids.iter().copied().eq(0..n), "Sync events out of order"); + } +} diff --git a/crates/collab-net/src/loopback_tests.rs b/crates/collab-net/src/loopback_tests.rs index a043d8c7..d5a38d9d 100644 --- a/crates/collab-net/src/loopback_tests.rs +++ b/crates/collab-net/src/loopback_tests.rs @@ -2,7 +2,7 @@ //! refusals, presence relay and throttling, leaving and dropping, limits, malformed frames, and //! reconnecting. Every test stops its host and closes its sockets. -use std::io::Read; +use std::io::{Read, Write}; use std::net::{SocketAddr, TcpStream}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -670,3 +670,49 @@ fn a_welcome_over_the_size_limit_says_the_file_is_too_large() { assert!(!welcomed(&gp.events)); host.stop(); } + +/// How long a connection that never says hello stays open while `poke` sends it something every +/// 300 ms (told whether it's the first time); `None`: still open after 6 s. +fn pre_hello_lifetime(mut poke: impl FnMut(&mut Raw, bool) -> bool) -> Option { + let (mut host, _hp, invite) = start_with(NetLimits { hello_timeout: Duration::from_secs(1), ..fast_limits() }, Limits::default()); + let addr: SocketAddr = invite.server.host.parse().unwrap(); + let mut r = Raw::connect(addr); + let t0 = Instant::now(); + let (mut closed, mut first) = (None, true); + while t0.elapsed() < Duration::from_secs(6) { + if !poke(&mut r, std::mem::take(&mut first)) { + closed = Some(t0.elapsed()); + break; + } + std::thread::sleep(Duration::from_millis(300)); + // Drain what the host sent back (pongs, the close); an error means it hung up. + r.0.get_mut().set_read_timeout(Some(Duration::from_millis(10))).unwrap(); + match r.0.read() { + Ok(Message::Close(_)) | Err(tungstenite::Error::ConnectionClosed | tungstenite::Error::AlreadyClosed) => { + closed = Some(t0.elapsed()); + break; + } + Err(tungstenite::Error::Io(e)) if !matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => { + closed = Some(t0.elapsed()); + break; + } + _ => {} + } + } + host.stop(); + closed +} + +#[test] +fn pings_do_not_keep_a_connection_without_a_hello_alive() { + let closed = pre_hello_lifetime(|r, _| r.0.send(Message::Ping(Bytes::new())).is_ok() && r.0.flush().is_ok()); + assert!(closed.is_some_and(|t| t < Duration::from_secs(3)), "pinged pre-hello connection survived: {closed:?}"); +} + +#[test] +fn a_hello_trickled_in_fragments_does_not_outlast_the_deadline() { + // A binary message begun and never finished: one masked byte per frame, each in time for the + // read timeout. + let closed = pre_hello_lifetime(|r, first| r.0.get_mut().write_all(&[if first { 0x02 } else { 0x00 }, 0x81, 0, 0, 0, 0, b'x']).is_ok()); + assert!(closed.is_some_and(|t| t < Duration::from_secs(3)), "trickled pre-hello connection survived: {closed:?}"); +} diff --git a/crates/collab-net/src/manage.rs b/crates/collab-net/src/manage.rs index 6e995bdb..2f0683c7 100644 --- a/crates/collab-net/src/manage.rs +++ b/crates/collab-net/src/manage.rs @@ -206,9 +206,15 @@ fn connect(server: &Endpoint, limits: &NetLimits, timeout: Duration) -> Result(ws: &mut Ws, limits: &NetLimits, pick: impl Fn(ServerMsg) -> Option< }; } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Whether `close` returns within 3 s against a server that never answers the close and does + /// `serve` instead, with reads waiting up to `idle`. + fn closes_in_time(idle: Duration, serve: impl FnOnce(WebSocket) + Send + 'static) -> bool { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + std::thread::spawn(move || { + let (s, _) = listener.accept().unwrap(); + if let Ok(ws) = tungstenite::accept(s) { + serve(ws); + } + }); + let limits = NetLimits { idle_timeout: idle, ..NetLimits::default() }; + let server = Endpoint { host: addr.to_string(), path: "/".into(), secure: false }; + let ws = connect(&server, &limits, Duration::from_secs(2)).unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + close(ws); + let _ = tx.send(()); + }); + rx.recv_timeout(Duration::from_secs(3)).is_ok() + } + + /// A server that keeps pinging and never answers the close must not pin `close` forever. + #[test] + fn close_returns_against_a_server_that_keeps_pinging() { + let pinging = closes_in_time(Duration::from_millis(300), |mut ws| { + while ws.send(Message::Ping(Bytes::new())).is_ok() { + std::thread::sleep(Duration::from_millis(10)); + } + }); + assert!(pinging, "close() kept draining a pinging server"); + } + + /// Nor one that goes quiet, however long the idle timeout. + #[test] + fn close_returns_against_a_silent_server() { + let silent = closes_in_time(Duration::from_secs(10), |ws| { + std::thread::sleep(Duration::from_secs(5)); + drop(ws); + }); + assert!(silent, "close() waited out the idle timeout"); + } +} diff --git a/crates/collab-net/src/net.rs b/crates/collab-net/src/net.rs index 8c841e9c..c6628662 100644 --- a/crates/collab-net/src/net.rs +++ b/crates/collab-net/src/net.rs @@ -33,14 +33,16 @@ pub struct Duplex { tls: Option, /// What the bytes read count against (a server's reader), if anything. meter: Option, + /// When reading must be over, however slowly the bytes come ([`Duplex::read_until`]). + deadline: Option, } impl Duplex { /// Two handles on `stream` (for the reader and the writer) and one to shut it down with. pub fn split(stream: TcpStream, tls: Option) -> io::Result<(Duplex, Duplex, TcpStream)> { let write = Arc::new(Mutex::new(stream.try_clone()?)); - let a = Duplex { read: stream.try_clone()?, write: write.clone(), tls: tls.clone(), meter: None }; - let b = Duplex { read: stream.try_clone()?, write, tls, meter: None }; + let a = Duplex { read: stream.try_clone()?, write: write.clone(), tls: tls.clone(), meter: None, deadline: None }; + let b = Duplex { read: stream.try_clone()?, write, tls, meter: None, deadline: None }; Ok((a, b, stream)) } @@ -49,6 +51,12 @@ impl Duplex { self.meter = Some(meter); } + /// Reads on this handle fail once `deadline` passes, even when each one gets a few bytes in + /// time (a message trickled in can't outlast it). `None`: only the read timeout applies. + pub fn read_until(&mut self, deadline: Option) { + self.deadline = deadline; + } + /// What was read so far became a message (handed on): it no longer counts as being read. pub fn read_done(&mut self) { if let Some(m) = self.meter.as_mut() { @@ -187,6 +195,13 @@ impl Duplex { impl Read for Duplex { fn read(&mut self, buf: &mut [u8]) -> io::Result { + if let Some(d) = self.deadline { + let left = d.saturating_duration_since(Instant::now()); + if left.is_zero() { + return Err(io::ErrorKind::TimedOut.into()); + } + self.read.set_read_timeout(Some(left))?; + } let n = match &self.tls { Some(t) => tls::read(t, &mut self.read, &self.write, buf), None => self.read.read(buf), diff --git a/crates/collab-net/src/preauth_tests.rs b/crates/collab-net/src/preauth_tests.rs new file mode 100644 index 00000000..5ae91665 --- /dev/null +++ b/crates/collab-net/src/preauth_tests.rs @@ -0,0 +1,72 @@ +//! A connection whose hello the backend hasn't admitted yet is held to the hello's size limit: it +//! can't have a message of the full size read while its token is unchecked. + +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::sync::mpsc::{Receiver, channel}; +use std::time::{Duration, Instant}; + +use figcraft_collab::message::{self, ClientMsg, ServerMsg, Signal}; +use figcraft_collab::{Client, Server}; +use tungstenite::{Bytes, Message}; + +use super::{Admission, Backend, Closing, Hosting}; +use crate::net::NetLimits; + +/// A backend that refuses every hello, once it is told to. +struct Slow(Receiver<()>); + +impl Backend for Slow { + fn admit(&mut self, _file: &str, _token: &str) -> Result { + let _ = self.0.recv(); + Err("wrong token".into()) + } + + fn open(&mut self, _file: &str) -> Result { + Err("no".into()) + } + + fn closing(&self) -> Closing { + Closing::Ended + } +} + +fn connect(addr: SocketAddr) -> tungstenite::WebSocket { + let s = TcpStream::connect(addr).unwrap(); + s.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); + tungstenite::client::client(format!("ws://{addr}/"), s).unwrap().0 +} + +#[test] +fn an_unadmitted_connection_cant_send_a_large_message() { + let limits = NetLimits { hello_timeout: Duration::from_secs(5), ..NetLimits::default() }; + let (release, wait) = channel(); + let mut hosting = Hosting::serve(TcpListener::bind("127.0.0.1:0").unwrap(), Slow(wait), limits).unwrap(); + let addr = hosting.addr(); + let sender = std::thread::spawn(move || { + let mut ws = connect(addr); + let hello = message::encode(&Client::hello("0123456789abcdef", "bad", "x", "")).unwrap(); + ws.send(Message::Binary(Bytes::from(hello))).unwrap(); + let big = ClientMsg::Signal(Signal { kind: Signal::CHAT.into(), text: "x".repeat(1 << 20) }); + let _ = ws.send(Message::Binary(Bytes::from(message::encode(&big).unwrap()))); + let mut refused = None; + while let Ok(m) = ws.read() { + if let Message::Binary(b) = m + && let Ok(ServerMsg::Refused { reason }) = message::decode(&b, 64 << 20) + { + refused = Some(reason); + } + } + refused + }); + let mut peak = 0; + let until = Instant::now() + Duration::from_secs(2); + while Instant::now() < until { + peak = peak.max(hosting.in_flight_bytes()); + std::thread::sleep(Duration::from_millis(2)); + } + release.send(()).unwrap(); + let refused = sender.join().unwrap(); + hosting.stop(); + assert!(peak <= limits.max_hello, "a message of {peak} bytes was read before the token was checked"); + assert_eq!(refused.as_deref(), Some("wrong token")); +} diff --git a/crates/color/src/blend.rs b/crates/color/src/blend.rs index 734759ee..250e8ab0 100644 --- a/crates/color/src/blend.rs +++ b/crates/color/src/blend.rs @@ -103,7 +103,7 @@ impl BlendMode { /// The mode named by its label or CSS keyword, ignoring case, spaces and dashes. Labels come /// first: "normal" is Normal, not pass-through (whose CSS keyword is also `normal`). pub fn from_name(s: &str) -> Option { - let key: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::().to_ascii_lowercase(); + let key: String = s.chars().filter(char::is_ascii_alphanumeric).collect::().to_ascii_lowercase(); Self::ALL .into_iter() .find(|m| m.label().replace(' ', "").to_ascii_lowercase() == key) diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index fdad0fa7..1fff7667 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -549,7 +549,7 @@ pub enum PaintKind { } /// One fill or stroke paint. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct Paint { #[serde(flatten)] pub kind: PaintKind, @@ -559,19 +559,41 @@ pub struct Paint { pub visible: bool, #[serde(default)] pub blend: BlendMode, + /// The part of the opacity that is the alpha of a typed solid colour (see [`Paint::solid`]), + /// the rest being an opacity set on its own: a colour variable bound to the paint replaces + /// this part only. 1: all of it is the paint's own. + #[serde(default = "one", rename = "colorAlpha", skip_serializing_if = "is_one")] + pub color_alpha: f32, +} + +/// Paints compare by what they paint; where the opacity came from doesn't matter. +impl PartialEq for Paint { + fn eq(&self, other: &Self) -> bool { + self.kind == other.kind && self.opacity == other.opacity && self.visible == other.visible && self.blend == other.blend + } } fn yes() -> bool { true } +fn is_one(x: &f32) -> bool { + *x == 1.0 +} + impl Paint { pub fn solid(color: Color) -> Self { - Self { kind: PaintKind::Solid { color: color.with_alpha(1.0) }, opacity: color.a, visible: true, blend: BlendMode::Normal } + Self { + kind: PaintKind::Solid { color: color.with_alpha(1.0) }, + opacity: color.a, + visible: true, + blend: BlendMode::Normal, + color_alpha: color.a, + } } pub fn gradient(g: Gradient) -> Self { - Self { kind: PaintKind::Gradient(g), opacity: 1.0, visible: true, blend: BlendMode::Normal } + Self { kind: PaintKind::Gradient(g), opacity: 1.0, visible: true, blend: BlendMode::Normal, color_alpha: 1.0 } } pub fn image(hash: impl Into, mode: ImageScaleMode) -> Self { @@ -587,12 +609,13 @@ impl Paint { opacity: 1.0, visible: true, blend: BlendMode::Normal, + color_alpha: 1.0, } } /// Layer `source` (a node id) tiled at its own size. pub fn pattern(source: u64) -> Self { - Self { kind: PaintKind::Pattern(PatternPaint::new(source)), opacity: 1.0, visible: true, blend: BlendMode::Normal } + Self { kind: PaintKind::Pattern(PatternPaint::new(source)), opacity: 1.0, visible: true, blend: BlendMode::Normal, color_alpha: 1.0 } } /// The solid colour (with the paint opacity folded into alpha), if solid. diff --git a/crates/doc/src/geometry.rs b/crates/doc/src/geometry.rs index d0ed4f2b..b4f89dbc 100644 --- a/crates/doc/src/geometry.rs +++ b/crates/doc/src/geometry.rs @@ -72,7 +72,16 @@ fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, o NodeKind::Frame | NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. } | NodeKind::Section { .. } => { n.local_rect().inflate(tol_l, tol_l).contains(lp) } - _ => shape_path(n).is_some_and(|path| hits_path(n, &path, lp, tol_l, !outline && is_closed(n) && path.winding(lp) != 0)), + _ => shape_path(n).is_some_and(|path| hits_path(n, &path, lp, tol_l, !outline && is_closed(n) && fills_point(n, &path, lp))), + } +} + +/// Whether the fill of `n`, drawn as `path`, covers `lp` (vectors follow the rule of +/// [`VectorNetwork::fill_contains`](crate::VectorNetwork::fill_contains): even-odd when any region asks for it). +fn fills_point(n: &Node, path: &BezPath, lp: Point) -> bool { + match &n.kind { + NodeKind::Vector { network } => network.fill_contains(lp), + _ => path.winding(lp) != 0, } } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 3097181c..d4c556b9 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -275,7 +275,16 @@ impl Document { /// A fresh unused id (in this document's client space during a collaboration session; see /// [`set_client_id`](Self::set_client_id)). pub fn alloc_id(&mut self) -> NodeId { - self.next_id.alloc() + // A file's saved `next_id` can be stale (behind ids already in use): skip taken ids. + let mut id = self.next_id.alloc(); + while self.nodes.contains_key(&id) { + let next = self.next_id.alloc(); + if next == id { + break; + } + id = next; + } + id } /// The id [`alloc_id`](Self::alloc_id) returns next. @@ -459,8 +468,9 @@ impl Document { return true; } guard += 1; - if guard > 10_000 { - return false; + // More hops than nodes means the parent chain loops; fail closed so callers reject the move. + if guard > self.nodes.len() { + return true; } cur = self.node(c).and_then(|n| n.parent); } @@ -827,6 +837,11 @@ impl Document { return Err(DocError::NotContainer(parent)); } let new_root = *map.get(&root).ok_or(DocError::NoNode(root))?; + // Never overwrite an existing node, or another copy, with a copy. + let mut copied = std::collections::BTreeSet::new(); + if let Some(taken) = nodes.iter().filter_map(|n| map.get(&n.id)).find(|i| self.nodes.contains_key(*i) || !copied.insert(**i)) { + return Err(DocError::Invalid(format!("node {taken} already exists"))); + } for n in nodes { let Some(new_id) = map.get(&n.id) else { continue }; let mut c = n.clone(); @@ -994,39 +1009,46 @@ impl Document { /// Check structural invariants (used by tests and after loading untrusted files). pub fn validate(&self) -> Vec { + // Hostile files can break an invariant per child; keep only the first few reports. + const MAX_ISSUES: usize = 100; + fn note(issues: &mut Vec, msg: impl FnOnce() -> String) { + if issues.len() < MAX_ISSUES { + issues.push(msg()); + } + } let mut issues = Vec::new(); for (id, n) in self.nodes.iter() { if n.id != *id { - issues.push(format!("node key {id} has id {}", n.id)); + note(&mut issues, || format!("node key {id} has id {}", n.id)); } let mut seen = std::collections::BTreeSet::new(); for c in &n.children { if !seen.insert(*c) { - issues.push(format!("child {c} listed twice in {id}")); + note(&mut issues, || format!("child {c} listed twice in {id}")); } match self.node(*c) { Some(cn) if cn.parent == Some(*id) => {} - Some(_) => issues.push(format!("child {c} of {id} has another parent")), - None => issues.push(format!("child {c} of {id} missing")), + Some(_) => note(&mut issues, || format!("child {c} of {id} has another parent")), + None => note(&mut issues, || format!("child {c} of {id} missing")), } } if let Some(p) = n.parent && !self.node(p).is_some_and(|pn| pn.children.contains(id)) { - issues.push(format!("{id} not listed in parent {p}")); + note(&mut issues, || format!("{id} not listed in parent {p}")); } // The library store's root is a page that is never listed. let listed = self.pages.contains(id) || (n.is_page() && self.libraries.store == Some(*id)); if n.is_page() != listed { - issues.push(format!("page list mismatch for {id}")); + note(&mut issues, || format!("page list mismatch for {id}")); } if n.is_page() && n.parent.is_some() { - issues.push(format!("page {id} has a parent")); + note(&mut issues, || format!("page {id} has a parent")); } } let reachable = self.reachable(); for id in self.nodes.keys().filter(|k| !reachable.contains(*k)) { - issues.push(format!("{id} is not under a page (cycle or detached)")); + note(&mut issues, || format!("{id} is not under a page (cycle or detached)")); } issues } diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index 4f6491c1..a646ae62 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -475,7 +475,7 @@ impl WidthPreset { } pub fn from_id(s: &str) -> Option { - let s: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect(); + let s: String = s.chars().filter(char::is_ascii_alphanumeric).collect(); WidthPreset::ALL .into_iter() .find(|p| p.id().eq_ignore_ascii_case(&s) || p.label().replace(' ', "").eq_ignore_ascii_case(&s)) diff --git a/crates/doc/src/proto_expr.rs b/crates/doc/src/proto_expr.rs index f4404dc7..d5441b28 100644 --- a/crates/doc/src/proto_expr.rs +++ b/crates/doc/src/proto_expr.rs @@ -169,7 +169,12 @@ impl Expr { }) .collect(); match (op, parts.as_slice()) { - (ExprOp::Not, [x]) => format!("not {x}"), + // `not done` reads as a variable named "not done" if there is one: then + // `not (done)`. + (ExprOp::Not, [x]) => { + let bare = format!("not {x}"); + if x.starts_with('(') || Expr::parse(&bare, vars).is_ok_and(|e| e == *self) { bare } else { format!("not ({x})") } + } (ExprOp::Negate, [x]) => format!("-{x}"), _ => parts.join(&format!(" {} ", op.symbol())), } diff --git a/crates/doc/src/proto_expr_parse.rs b/crates/doc/src/proto_expr_parse.rs index 5214a2d6..54615ee0 100644 --- a/crates/doc/src/proto_expr_parse.rs +++ b/crates/doc/src/proto_expr_parse.rs @@ -459,16 +459,19 @@ impl Parser<'_> { if self.at_keyword("mode") && self.next_after(4) == Some('(') { return self.mode_of(); } - if let Some((id, n)) = self.variable_here() { - self.i += n; - return self.in_mode(id); - } + let var = self.variable_here(); + // A bare `true`/`false` is the literal, also when a variable has that name (it reads + // as `{true}`); longer names that start with it ("true value") stay variables. for (word, v) in [("true", true), ("false", false)] { - if self.at_keyword(word) { + if self.at_keyword(word) && var.as_ref().is_none_or(|(_, n)| *n <= word.len()) { self.i += word.len(); return Ok(Expr::Value(VariableValue::Boolean(v))); } } + if let Some((id, n)) = var { + self.i += n; + return self.in_mode(id); + } let word: String = self.s.iter().skip(self.i).take_while(|c| ident(**c)).collect(); if word.is_empty() { Err(self.err(format!("Unexpected {c}"))) } else { Err(self.err(format!("No variable `{word}`"))) } } diff --git a/crates/doc/src/proto_expr_parse_tests.rs b/crates/doc/src/proto_expr_parse_tests.rs index e106121d..1cac9f53 100644 --- a/crates/doc/src/proto_expr_parse_tests.rs +++ b/crates/doc/src/proto_expr_parse_tests.rs @@ -195,3 +195,49 @@ fn names_that_look_like_literals_read_back_as_variables() { assert_eq!(parse("1"), n(1.0)); assert_eq!(parse("\"q\""), Expr::Value(VariableValue::String("q".into()))); } + +#[test] +fn not_of_a_variable_reads_back_when_a_name_starts_with_not() { + use ExprOp::*; + let mut v = vars(); + let b = v.variables.iter().find(|x| x.id == "b").unwrap().clone(); + for (id, name) in [("done", "done"), ("nd", "not done")] { + let mut x = b.clone(); + x.id = id.into(); + x.name = name.into(); + v.variables.push(x); + } + let not_done = Expr::op(Not, vec![Expr::var("done")]); + for e in [ + not_done.clone(), + Expr::op(And, vec![Expr::var("b"), not_done.clone()]), + Expr::op(Equals, vec![not_done.clone(), Expr::Value(VariableValue::Boolean(true))]), + Expr::op(Not, vec![not_done.clone()]), + ] { + let shown = e.display(&v); + assert_eq!(Expr::parse(&shown, &v), Ok(e.clone()), "shown as {shown:?}"); + } + assert_eq!(Expr::op(Not, vec![Expr::var("b")]).display(&vars()), "not b-name", "plain names stay bare"); +} + +#[test] +fn literal_true_false_survive_variables_named_true_false() { + use ExprOp::*; + let lit = |x: bool| Expr::Value(VariableValue::Boolean(x)); + for name in ["true", "false", "TRUE"] { + let mut v = vars(); + if let Some(var) = v.variables.iter_mut().find(|x| x.id == "b") { + var.name = name.into(); + } + for e in [ + Expr::op(Equals, vec![Expr::var("b"), lit(true)]), + Expr::op(Equals, vec![Expr::var("b"), lit(false)]), + Expr::op(Not, vec![lit(false)]), + Expr::var("b"), + ] { + let shown = e.display(&v); + assert_eq!(Expr::parse(&shown, &v), Ok(e.clone()), "{name:?} shown as {shown:?}"); + } + assert_eq!(Expr::parse(&format!("{{{name}}}"), &v), Ok(Expr::var("b"))); + } +} diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index a5ead3b7..1a006c26 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -326,6 +326,20 @@ fn repair_drops_cycles_and_repeated_children() { assert_eq!(bad.subtree(p), vec![p, r]); } +#[test] +fn validate_caps_the_issue_list() { + let mut d = Document::new("x"); + let p = page(&d); + let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap(); + let g = d.create(f, NodeKind::Frame, "G", 0.0, 0.0, 10.0, 10.0).unwrap(); + let mut v = serde_json::to_value(&d).unwrap(); + v["nodes"][f.to_string()]["children"] = serde_json::json!(vec![g; 5000]); + let bad: Document = serde_json::from_value(v).unwrap(); + let issues = bad.validate(); + assert!(!issues.is_empty()); + assert!(issues.len() <= 100, "{}", issues.len()); +} + #[test] fn auto_layout_loads_files_without_grid_tracks() { // Saved before grid tracks, cells and spans existed. @@ -464,3 +478,84 @@ fn text_style_empty_range_links_and_unlinks_all() { e.unlink_text_style(0, 0); assert!(e.style.style_id.is_none()); } + +#[test] +fn stale_next_id_never_overwrites_existing_nodes() { + let mut d = Document::new("x"); + let p = page(&d); + let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap(); + let a = d.create(f, NodeKind::Rectangle, "A", 0.0, 0.0, 10.0, 10.0).unwrap(); + let mut v = serde_json::to_value(&d).unwrap(); + v["next_id"] = serde_json::json!(1); + let mut bad: Document = serde_json::from_value(v).unwrap(); + let before: Vec<(NodeId, String, Option)> = + [p, f, a].iter().map(|i| (*i, bad.node(*i).unwrap().name.clone(), bad.node(*i).unwrap().parent)).collect(); + let n = bad.node_count(); + let copy = bad.duplicate_into(f, p, None).unwrap(); + assert!(![p, f, a].contains(©)); + assert_eq!(bad.node_count(), n + 2); + let copies = bad.duplicate_all_into(&[f], p).unwrap(); + assert_eq!(bad.node_count(), n + 4); + assert!(copies.iter().all(|c| ![p, f, a, copy].contains(c))); + for (i, name, parent) in &before { + let node = bad.node(*i).unwrap(); + assert_eq!((&node.name, node.parent), (name, *parent)); + } + assert!(bad.validate().is_empty(), "{:?}", bad.validate()); + + // A paste whose id map points at an existing node fails and changes nothing. + let nodes: Vec = bad.subtree(f).into_iter().filter_map(|i| bad.node(i).cloned()).collect(); + let mut map: BTreeMap = nodes.iter().map(|n| (n.id, bad.alloc_id())).collect(); + map.insert(a, p); + let snapshot = bad.clone(); + assert!(bad.paste_nodes(&nodes, &map, f, p, None).is_err()); + assert_eq!(bad, snapshot); + assert_eq!(bad.node(p).unwrap().name, snapshot.node(p).unwrap().name); + assert!(bad.validate().is_empty(), "{:?}", bad.validate()); + + // So does one that maps two nodes to the same new id. + let mut map: BTreeMap = nodes.iter().map(|n| (n.id, bad.alloc_id())).collect(); + map.insert(a, map[&f]); + assert!(bad.paste_nodes(&nodes, &map, f, p, None).is_err()); + assert_eq!(bad, snapshot); +} + +#[test] +fn hit_test_even_odd_vector_hole_misses() { + let ring = |rule| { + let sq = |o: f64, s: f64, base: usize| { + ( + [(o, o), (o + s, o), (o + s, o + s), (o, o + s)].map(|(x, y)| Vertex::new(x, y)), + [Segment::line(base, base + 1), Segment::line(base + 1, base + 2), Segment::line(base + 2, base + 3), Segment::line(base + 3, base)], + ) + }; + let (v1, s1) = sq(0.0, 200.0, 0); + let (v2, s2) = sq(60.0, 80.0, 4); + VectorNetwork { + vertices: v1.into_iter().chain(v2).collect(), + segments: s1.into_iter().chain(s2).collect(), + regions: vec![Region { loops: vec![vec![0, 1, 2, 3], vec![4, 5, 6, 7]], winding: rule }], + } + }; + let mut d = Document::new("x"); + let p = page(&d); + let eo = d.create(p, NodeKind::Vector { network: ring(WindingRule::EvenOdd) }, "V", 0.0, 0.0, 200.0, 200.0).unwrap(); + assert_eq!(d.hit_test(p, Point::new(20.0, 100.0), 0.0, None), Some(eo)); + assert_eq!(d.hit_test(p, Point::new(100.0, 100.0), 0.0, None), None, "the hole is not filled"); + let mut d = Document::new("x"); + let p = page(&d); + let nz = d.create(p, NodeKind::Vector { network: ring(WindingRule::NonZero) }, "V", 0.0, 0.0, 200.0, 200.0).unwrap(); + assert_eq!(d.hit_test(p, Point::new(100.0, 100.0), 0.0, None), Some(nz)); +} + +#[test] +fn is_ancestor_holds_for_very_deep_chains() { + let mut d = Document::new("x"); + let root = d.create(page(&d), NodeKind::Frame, "root", 0.0, 0.0, 1.0, 1.0).unwrap(); + let mut leaf = root; + for _ in 0..10_005 { + leaf = d.create(leaf, NodeKind::Frame, "n", 0.0, 0.0, 1.0, 1.0).unwrap(); + } + assert!(d.is_ancestor(root, leaf)); + assert!(matches!(d.reparent(root, leaf, None), Err(DocError::Cycle(..)))); +} diff --git a/crates/engine/src/binding_lists_tests.rs b/crates/engine/src/binding_lists_tests.rs index 9db9975a..8b221efd 100644 --- a/crates/engine/src/binding_lists_tests.rs +++ b/crates/engine/src/binding_lists_tests.rs @@ -46,7 +46,7 @@ fn a_bound_fill_colour_keeps_the_other_fills() { let fills = s.doc().unwrap().node(r).unwrap().fills.clone(); assert_eq!(fills.len(), 2, "the second fill stays"); assert_eq!(fills[0].kind, PaintKind::Solid { color: Color::from_rgb8_const(0xFF, 0, 0) }); - assert_eq!(fills[0].opacity, 1.0, "the variable brings the whole colour, alpha included"); + assert_eq!(fills[0].opacity, 0.5, "the bound paint keeps its opacity"); assert_eq!(fills[1].kind, PaintKind::Solid { color: Color::from_rgb8_const(0, 0xFF, 0) }); // Removing the bound (first) fill takes the binding with it. ex(&mut s, "fill.remove", json!({"ids": [r], "index": 0})); diff --git a/crates/engine/src/bound_alpha_tests.rs b/crates/engine/src/bound_alpha_tests.rs index b7210f0f..fb15425d 100644 --- a/crates/engine/src/bound_alpha_tests.rs +++ b/crates/engine/src/bound_alpha_tests.rs @@ -129,3 +129,36 @@ fn code_gives_a_translucent_stroke_its_alpha_once() { let code = s.execute("inspect.code", &json!({"id": r.0})).unwrap()["code"].as_str().unwrap_or_default().to_string(); assert!(code.contains("solid rgba(255, 0, 0, 0.50)"), "{code}"); } + +#[test] +fn binding_keeps_an_opacity_set_on_the_paint() { + // A 15% tint whose colour comes from a variable (#923): the bind replaces the colour only. + let mut s = Session::new(); + ex(&mut s, "variable.createCollection", json!({"name": "Tokens", "modes": ["Light"]})); + ex(&mut s, "variable.create", json!({"collection": "Tokens", "name": "red", "type": "color", "value": "#FF0000"})); + let props = json!({"fills": [{"type": "solid", "color": "#000000", "opacity": 0.15}]}); + let r = s.execute("node.create", &json!({"type": "rectangle", "x": 0, "y": 0, "width": 10, "height": 10, "props": props})).unwrap(); + let r = NodeId(r["id"].as_u64().unwrap()); + ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "red"})); + let p = s.doc().unwrap().node(r).unwrap().fills[0].clone(); + assert_eq!(p.kind, figcraft_color::PaintKind::Solid { color: figcraft_color::Color::from_rgb8_const(0xFF, 0, 0) }); + assert!(near(p.opacity, 0.15), "{}", p.opacity); + ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": null})); + assert!(near(s.doc().unwrap().node(r).unwrap().fills[0].opacity, 0.15)); + // An opacity set on a `#RRGGBBAA` fill is the user's too, and stays. + let (mut s, r) = setup("#FF0000", "#FF0000"); + ex(&mut s, "fill.set", json!({"ids": [r.0], "index": 0, "opacity": 0.25})); + ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"})); + assert!(near(alpha(&s, r), 0.25), "{}", alpha(&s, r)); +} + +#[test] +fn an_unbind_and_rebind_keeps_the_opacity_set_on_the_paint() { + // A paint at 50% bound to a half-transparent colour shows a quarter, unbound or bound again. + let (mut s, r) = setup("#FF000080", "#FF000080"); + ex(&mut s, "fill.set", json!({"ids": [r.0], "index": 0, "opacity": 0.5})); + for variable in [json!("tint"), json!(null), json!("tint"), json!(null)] { + ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": variable})); + assert!(near(alpha(&s, r), HALF / 2.0), "{variable}: {}", alpha(&s, r)); + } +} diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index 6a5b1012..78609771 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -165,7 +165,7 @@ pub fn specs() -> Vec { spec!( "component.resetOverride", "Reset override", - "{id?, group? (fills|strokes|effects|opacity|visible|name|corners|characters|textStyle|padding|gap|alignment|grids|exports|interactions|size|position|component|props|slot; all when omitted)}", + "{id?, group? (fills|strokes|effects|opacity|visible|name|corners|characters|textStyle|padding|gap|alignment|grids|exports|interactions|size|position|modes|component|props|slot; all when omitted)}", true, reset_override ), @@ -786,6 +786,38 @@ fn push_changes(s: &mut Session, p: &Value) -> Result { Ok(json!({"layers": pushed})) } +fn restore_prop_defs(doc: &Document, props: &BTreeMap) -> BTreeMap { + let mut defs = BTreeMap::new(); + for (k, v) in props { + let def = match v { + Value::Bool(b) => PropDef::Boolean { default: *b }, + Value::String(t) => PropDef::Text { default: t.clone() }, + other => match id_of(other) { + Some(c) if doc.contains(c) => PropDef::InstanceSwap { default: c, preferred: Vec::new() }, + _ => continue, + }, + }; + defs.insert(k.clone(), def); + } + defs +} + +fn restore_id_map(doc: &mut Document, i: NodeId, component: NodeId, sub: &[NodeId]) -> BTreeMap { + let mut map: BTreeMap = BTreeMap::new(); + let mut used: BTreeSet = BTreeSet::new(); + map.insert(i, component); + used.insert(component); + for d in sub.iter().filter(|d| **d != i) { + let src = doc.node(*d).and_then(|n| n.source); + let nid = match src { + Some(s) if !doc.contains(s) && used.insert(s) => s, + _ => doc.alloc_id(), + }; + map.insert(*d, nid); + } + map +} + fn restore_main(s: &mut Session, p: &Value) -> Result { let cmd = "component.restoreMain"; let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select an instance"))?; @@ -804,35 +836,13 @@ fn restore_main(s: &mut Session, p: &Value) -> Result { // The main comes back with its old id (other instances point to it) and its layers with the // ids their copies mirror, so every instance re-links by source. let sub = doc.subtree(i); - let mut map: BTreeMap = BTreeMap::new(); - let mut used: BTreeSet = BTreeSet::new(); - map.insert(i, component); - used.insert(component); - for d in sub.iter().filter(|d| **d != i) { - let src = doc.node(*d).and_then(|n| n.source); - let nid = match src { - Some(s) if !doc.contains(s) && used.insert(s) => s, - _ => doc.alloc_id(), - }; - map.insert(*d, nid); - } + let map = restore_id_map(doc, i, component, &sub); let mut nodes = Vec::new(); let mut nested = Vec::new(); for d in &sub { let Some(mut n) = doc.node(*d).cloned() else { continue }; if *d == i { - let mut defs = BTreeMap::new(); - for (k, v) in &props { - let def = match v { - Value::Bool(b) => PropDef::Boolean { default: *b }, - Value::String(t) => PropDef::Text { default: t.clone() }, - other => match id_of(other) { - Some(c) if doc.contains(c) => PropDef::InstanceSwap { default: c, preferred: Vec::new() }, - _ => continue, - }, - }; - defs.insert(k.clone(), def); - } + let defs = restore_prop_defs(doc, &props); n.kind = NodeKind::Component { description: String::new(), props: defs }; n.source = None; n.overrides.clear(); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 2225133b..3af05c62 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -535,6 +535,7 @@ pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<( s.clipboard.nodes = nodes; s.clipboard.images = images; s.clipboard.bounds = bounds; + s.clipboard.layers_generation = s.clipboard.layers_generation.wrapping_add(1); Ok(()) } @@ -610,8 +611,18 @@ fn paste_design_layers(s: &mut Session, p: &Value) -> Result { let (nodes, images, bounds) = copied(&doc, &tops); let props = s.clipboard.props.take(); // No open file has id 0. So paste treats this copy as a copy from a different file. - s.clipboard = - crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None, copied_components: BTreeSet::new() }; + let layers_generation = s.clipboard.layers_generation.wrapping_add(1); + s.clipboard = crate::Clipboard { + nodes, + images, + props, + bounds, + source_doc: 0, + source: Some(doc), + source_path: None, + copied_components: BTreeSet::new(), + layers_generation, + }; let mut out = paste(s, p)?; if !other.issues.is_empty() { out["warnings"] = json!(other.issues); @@ -1375,10 +1386,12 @@ fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result { slot.kind = figcraft_color::PaintKind::Solid { color: c.with_alpha(1.0) }; if c.a < 1.0 { slot.opacity = c.a; + slot.color_alpha = c.a; } } if let Some(o) = num(p, "opacity") { slot.opacity = (if o > 1.0 { o / 100.0 } else { o }).clamp(0.0, 1.0) as f32; + slot.color_alpha = 1.0; } if let Some(v) = boolean(p, "visible") { slot.visible = v; diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index d8ad924d..ec579d51 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -488,11 +488,11 @@ pub(crate) fn write(path: &str, _bytes: &[u8]) -> Result<()> { } fn ext(path: &str) -> String { - std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase() + std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase() } fn file_stem(path: &str) -> String { - std::path::Path::new(path).file_stem().and_then(|e| e.to_str()).unwrap_or("Untitled").to_string() + std::path::Path::new(path).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Untitled").to_string() } /// The counts the import report shows for a freshly loaded document. @@ -1181,8 +1181,11 @@ fn export_path(name: &str) -> String { if parts.is_empty() { "Untitled".into() } else { parts.join("/") } } -/// `name` without path separators and characters file systems reject. -fn safe_file_name(name: &str) -> String { +/// `name` without path separators and characters file systems reject (`/ \ : * ? " < > |` and +/// control characters become `_`); everything else is kept, up to the first 200 characters. +/// `figcraft-cli export` names its files by this rule too (and keeps at most 100 characters before +/// the number and extension it adds). +pub fn safe_file_name(name: &str) -> String { name.chars().take(200).map(|c| if c.is_control() || "/\\:*?\"<>|".contains(c) { '_' } else { c }).collect() } diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 3ed0f9b0..2eedab45 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -13,7 +13,7 @@ pub const STROKE_SIDES: [&str; 4] = ["strokeTopWeight", "strokeRightWeight", "st /// Keys `node.set` understands (documented for agents). pub const KEYS: &str = "name, visible, locked, opacity (0-1), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), \ -fills (paints or colours; gradients as {type: linearGradient|radialGradient|angularGradient|diamondGradient, stops: [\"#hex\", ...], from?, to?} too), fill (colour), strokes, stroke (colour), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \ +fills (paints or colours; gradients as {type: linearGradient|radialGradient|angularGradient|diamondGradient, stops: [\"#hex\", ...], from?, to?} too), fill (colour, or \"none\"/null: no fill), strokes, stroke (colour, or \"none\"/null: no stroke), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \ strokeCap / startCap / endCap, strokeJoin, miterAngle (degrees: sharper joins bevel; or miterLimit as a ratio), dashes [..], widthProfile (preset uniform|wedge|taper|eye|rounded|pinch, [[t, scale], ...] or null), brush (preset id, {preset, spacing, sizeJitter, rotationJitter, scatter, seed} or null), cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \ clip, isMask, layout (null|horizontal|vertical|grid|{...}), gap, crossGap, padding (n or [t,r,b,l]), paddingTop/Right/Bottom/Left, \ mainAlign (min|center|max, or the auto gaps spaceBetween|spaceAround|spaceEvenly), crossAlign (min|center|max|baseline: first text baselines, horizontal layouts), contentAlign (wrapped lines: auto = packed where crossAlign says | spaceBetween), wrap (rows of a horizontal layout, columns of a vertical one), strokesIncluded (the frame's inside stroke takes up space), reverseZ (canvas stacking: true = first on top), \ @@ -372,14 +372,21 @@ fn set_paints(n: &mut Node, fills: bool, paints: Vec) { } } -/// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, variable -/// bindings last so paints set in the same call don't detach them. +/// `"none"` (either case) and null ask for no paint, as an empty `fills`/`strokes` list does. +fn paint_none(v: &Value) -> bool { + v.is_null() || v.as_str().is_some_and(|s| s.eq_ignore_ascii_case("none")) +} + +/// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, style +/// links after the values so a link wins over values its style sets, variable bindings last so +/// paints set in the same call don't detach them. fn apply_order(k: &str) -> u8 { match k { "width" | "height" | "w" | "h" => 0, "rotation" => 2, "x" | "y" => 3, - "boundVariables" => 4, + "fillStyle" | "strokeStyle" | "effectStyle" | "textStyle" | "gridStyle" => 4, + "boundVariables" => 5, _ => 1, } } @@ -462,13 +469,21 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map set_paints(doc.get_mut(id)?, true, paints_of(v).ok_or_else(|| bad(cmd, "`fills` must be paints or colours"))?), "fill" => { - let c = color_of(v).ok_or_else(|| bad(cmd, "`fill` must be a colour"))?; - set_paints(doc.get_mut(id)?, true, vec![Paint::solid(c)]); + if paint_none(v) { + set_paints(doc.get_mut(id)?, true, vec![]); + } else { + let c = color_of(v).ok_or_else(|| bad(cmd, "`fill` must be a colour, \"none\" or null"))?; + set_paints(doc.get_mut(id)?, true, vec![Paint::solid(c)]); + } } "strokes" => set_paints(doc.get_mut(id)?, false, paints_of(v).ok_or_else(|| bad(cmd, "`strokes` must be paints or colours"))?), "stroke" => { - let c = color_of(v).ok_or_else(|| bad(cmd, "`stroke` must be a colour"))?; - set_paints(doc.get_mut(id)?, false, vec![Paint::solid(c)]); + if paint_none(v) { + set_paints(doc.get_mut(id)?, false, vec![]); + } else { + let c = color_of(v).ok_or_else(|| bad(cmd, "`stroke` must be a colour, \"none\" or null"))?; + set_paints(doc.get_mut(id)?, false, vec![Paint::solid(c)]); + } } "strokeWeight" => doc.get_mut(id)?.stroke.weight = f64_of(v).ok_or_else(|| bad(cmd, "`strokeWeight` must be a number"))?.max(0.0), "strokeAlign" => doc.get_mut(id)?.stroke.align = enum_of(v).ok_or_else(|| bad(cmd, "strokeAlign: inside|center|outside"))?, diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs index 90e18611..28a242af 100644 --- a/crates/engine/src/cmd/structure.rs +++ b/crates/engine/src/cmd/structure.rs @@ -107,6 +107,20 @@ pub fn specs() -> Vec { } ok() }), + spec!( + "vector.fillRule", + "Set fill rule", + "{ids? | id?, region? (a filled area of a vector; every area when left out), rule: nonZero|evenOdd}", + true, + fill_rule + ), + spec!( + "vector.reverseLoop", + "Reverse loop", + "{ids? | id?, region, loop} (a loop of a filled area; its direction flips, which changes how the area fills)", + true, + reverse_loop + ), spec!("vector.simplify", "Simplify vector", "{ids?, tolerance?}", true, simplify), spec!("vector.offset", "Offset path", "{ids?, offset}", true, offset_path), spec!("vector.join", "Join selection", "{ids?} (in vector edit mode: joins the selected points)", true, |s, p| { @@ -729,3 +743,146 @@ fn resize_to_fit(s: &mut Session, p: &Value) -> Result { } Ok(json!({"ids": fitted})) } + +/// The vector network of `id`, when it is a vector layer. +fn network_mut(doc: &mut Document, id: NodeId) -> Option<&mut VectorNetwork> { + match &mut doc.get_mut(id).ok()?.kind { + NodeKind::Vector { network } => Some(network), + _ => None, + } +} + +/// The id a fill rule is written as. +fn rule_id(rule: WindingRule) -> &'static str { + match rule { + WindingRule::NonZero => "nonZero", + WindingRule::EvenOdd => "evenOdd", + } +} + +/// `region` as an index: absent (or null) means every area. +fn area_index(cmd: &str, p: &Value) -> Result> { + match get(p, "region") { + None | Some(Value::Null) => Ok(None), + Some(v) => { + let k = f64_of(v).ok_or_else(|| bad(cmd, "`region` must be a number"))?; + if !(0.0..1e9).contains(&k) || k.fract() != 0.0 { + return Err(bad(cmd, "`region` out of range")); + } + Ok(Some(k as usize)) + } + } +} + +/// Segments another one- or two-segment loop lists: a short loop takes its direction from the ends +/// its segments are stored with, so re-storing one of those would turn that loop round too. A loop +/// of three or more segments is left out: its ring is walked in list order, which the stored ends +/// don't decide. +fn short_loop_segments(net: &VectorNetwork, skip: (usize, usize)) -> std::collections::HashSet { + let mut out = std::collections::HashSet::new(); + for (r, region) in net.regions.iter().enumerate() { + for (l, lp) in region.loops.iter().enumerate() { + if (r, l) != skip && lp.len() <= 2 { + out.extend(lp.iter().copied()); + } + } + } + out +} + +fn fill_rule(s: &mut Session, p: &Value) -> Result { + let ids = req_targets("vector.fillRule", s, p)?; + let rule = match string(p, "rule").as_deref().map(str::trim) { + Some("evenOdd" | "evenodd" | "EVENODD") => WindingRule::EvenOdd, + Some("nonZero" | "nonzero" | "NONZERO") => WindingRule::NonZero, + _ => return Err(bad("vector.fillRule", "`rule` is nonZero or evenOdd")), + }; + let only = area_index("vector.fillRule", p)?; + let doc = s.doc_mut()?; + let mut count = 0usize; + for id in ids { + let Some(net) = network_mut(doc, id) else { continue }; + match only { + Some(r) => { + if let Some(region) = net.regions.get_mut(r) { + region.winding = rule; + count += 1; + } + } + None => { + for region in &mut net.regions { + region.winding = rule; + count += 1; + } + } + } + } + if count == 0 { + return Err(EngineError::Disabled("vector.fillRule".into(), "no filled area to set (select a vector)".into())); + } + Ok(json!({"regions": count, "rule": rule_id(rule)})) +} + +/// One loop of a vector, worked out but not yet written back: its segments in the new order and, +/// for a loop of one or two segments, the one to store the other way. +struct LoopFlip { + id: NodeId, + turned: Vec, + store: Option, +} + +/// Turn one loop of a filled area round: its segments are walked the other way, so the area it +/// bounds counts the other way in the winding (which turns a hole into a filled part and back). +/// +/// How a loop is walked depends on how many segments it has: +/// - three or more: the order they are listed in, each segment turned to continue from the point +/// reached. Reversing the list is therefore enough, and the stored ends are left alone (a shared +/// segment's ends belong to its neighbour too); +/// - two: the same ring either way round, so one of the two has to be stored the other way instead; +/// - one: only its stored ends say which way it runs, so they are swapped. +/// +/// A short loop can only turn round by re-storing a segment, which is refused when another short +/// loop lists that segment too: it would turn that area round as well. +fn reverse_loop(s: &mut Session, p: &Value) -> Result { + let cmd = "vector.reverseLoop"; + let ids = req_targets(cmd, s, p)?; + let region = req_index(cmd, p, "region")?; + let index = req_index(cmd, p, "loop")?; + let doc = s.doc_mut()?; + // Work every loop out before touching the document: one that can't turn round refuses the call + // and the others stay as they were. + let mut flips: Vec = Vec::new(); + for id in ids { + let Some(NodeKind::Vector { network }) = doc.node(id).map(|n| &n.kind) else { continue }; + let Some(loop_) = network.regions.get(region).and_then(|r| r.loops.get(index)) else { continue }; + if loop_.is_empty() { + continue; + } + let mut store = None; + if loop_.len() <= 2 { + let busy = short_loop_segments(network, (region, index)); + let Some(pick) = loop_.iter().copied().filter(|s| !busy.contains(s)).min() else { + return Err(EngineError::Disabled( + cmd.into(), + "the loop shares its segments with another one- or two-segment loop, so turning it round would turn that one round too".into(), + )); + }; + store = Some(pick); + } + flips.push(LoopFlip { id, turned: loop_.iter().rev().copied().collect(), store }); + } + if flips.is_empty() { + return Err(EngineError::Disabled(cmd.into(), "no such loop (select a vector with filled areas)".into())); + } + for LoopFlip { id, turned, store } in flips { + let Some(net) = network_mut(doc, id) else { continue }; + if let Some(seg) = store.and_then(|i| net.segments.get_mut(i)) { + std::mem::swap(&mut seg.a, &mut seg.b); + std::mem::swap(&mut seg.ta, &mut seg.tb); + } + if let Some(l) = net.regions.get_mut(region).and_then(|r| r.loops.get_mut(index)) { + *l = turned; + } + } + Ok(json!({"reversed": true})) +} diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index af029e5f..e5ac145e 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -804,10 +804,10 @@ pub fn bind(s: &mut Session, p: &Value) -> Result { ok() } -/// A colour variable bound to a solid paint owns its whole colour, alpha included. Binding restarts -/// the opacity (which holds the alpha a typed colour had, see `Paint::solid`) at 1, so the -/// variable's alpha applies once; unbinding moves the colour's alpha back into the opacity, where a -/// typed colour keeps it. An opacity set while bound multiplies on top and stays. +/// A colour variable bound to a solid paint owns its whole colour, alpha included. Binding takes the +/// alpha a typed colour had (see `Paint::solid`) out of the opacity, so the variable's alpha applies +/// once; an opacity set on its own, before or while bound, multiplies on top and stays. Unbinding +/// moves the colour's alpha back into the opacity, where a typed colour keeps it. pub(crate) fn rebind_paint_alpha(fills: &mut [Paint], strokes: &mut [Paint], field: &str, bind: bool) { use super::lists::{BoundList, list_binding}; let Some((list, i, "color")) = list_binding(field) else { return }; @@ -819,7 +819,14 @@ pub(crate) fn rebind_paint_alpha(fills: &mut [Paint], strokes: &mut [Paint], fie if let Some(p) = paints.get_mut(i) && let PaintKind::Solid { color } = &mut p.kind { - p.opacity = if bind { 1.0 } else { p.opacity * color.a }; + if bind { + // What's left is the opacity set on its own (unknown under a colour alpha of 0: 1). + p.opacity = if p.color_alpha > 0.0 { (p.opacity / p.color_alpha).clamp(0.0, 1.0) } else { 1.0 }; + p.color_alpha = 1.0; + } else { + p.opacity *= color.a; + p.color_alpha *= color.a; + } color.a = 1.0; } } diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 0905284c..17f71b49 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -537,7 +537,14 @@ pub fn specs() -> Vec { spec!("tool.text", "Text", "{}", false, |s, _| tool(s, ToolId::Text)), spec!("tool.comment", "Comment", "{}", false, |s, _| tool(s, ToolId::Comment)), spec!("tool.shapeBuilder", "Shape builder", "{}", false, |s, _| tool(s, ToolId::ShapeBuilder)), - spec!("edit.pickColor", "Pick color", "{}", false, |s, _| tool(s, ToolId::Eyedropper)), + // `apply: false` is for the UI colour picker: the eyedropper only samples (into + // `ToolState::picked`) and the picker writes the colour to the paint it edits. + spec!("edit.pickColor", "Pick color", "{apply?}", false, |s, p| { + let r = tool(s, ToolId::Eyedropper)?; + s.tool.pick_only = !boolean(p, "apply").unwrap_or(true); + s.tool.picked = None; + Ok(r) + }), ] } diff --git a/crates/engine/src/components.rs b/crates/engine/src/components.rs index 67259d03..91402b58 100644 --- a/crates/engine/src/components.rs +++ b/crates/engine/src/components.rs @@ -12,9 +12,10 @@ //! `characters`, `textStyle` (font, size, spacing, resizing, …), `padding`, `gap` and `alignment` //! (of auto layout: its direction and other settings are the main's), `grids`, `exports`, //! `interactions`, `size` and `resizing` (hug, fill or fixed, with min and max sizes) of layers -//! inside an instance, `component` (a swapped nested instance), `props` (property values of a -//! nested instance) and `slot` (a slot holding the instance's own content). An instance's own -//! export settings, interactions and resizing are its own, like its place. `position` +//! inside an instance, `modes` (the variable modes a layer inside an instance chooses), +//! `component` (a swapped nested instance), `props` (property values of a nested instance) and +//! `slot` (a slot holding the instance's own content). An instance's own export settings, +//! interactions, resizing and variable modes are its own, like its place. `position` //! overrides of layers inside an instance are still read and kept, but new ones are refused //! ([`check_override_edit`]): those layers take their place and constraints from the main. //! @@ -60,6 +61,7 @@ pub const OVERRIDE_GROUPS: &[&str] = &[ "size", "resizing", "position", + "modes", "component", "props", SLOT, @@ -114,6 +116,23 @@ pub fn inside_instance(doc: &Document, id: NodeId) -> bool { enclosing_instance(doc, id).is_some() } +/// Mark the `modes` override on layers inside instances whose variable modes differ from their +/// main layer's, for files from before `modes` was an override group. Copies follow their main +/// layer's modes, so a layer on other modes chose them itself. +pub fn mark_layer_modes(doc: &mut Document) { + let chose: Vec = doc + .nodes() + .filter(|n| !n.overrides.contains("modes") && n.source.and_then(|s| doc.node(s)).is_some_and(|m| m.var_modes != n.var_modes)) + .map(|n| n.id) + .filter(|id| inside_instance(doc, *id)) + .collect(); + for id in chose { + if let Some(n) = doc.node_mut(id) { + n.overrides.insert("modes".into()); + } + } +} + /// The slot property frame `id` stands for (its name and preferred components): a frame bound to /// a slot property of its component, or an instance's copy of one (followed through its sources). pub fn slot_def(doc: &Document, id: NodeId) -> Option<(&str, &[NodeId])> { @@ -335,6 +354,27 @@ fn carry_overrides(from: &Node, to: &mut Node, what: Carry) { } } +/// An instance switched from variant `was` to `new`: its resizing (sizing and min / max per axis) +/// that is still `was`'s follows `new`; what the instance set itself stays in `own`. +fn follow_variant_resizing(own: &mut ChildLayout, was: &ChildLayout, new: &ChildLayout) { + if own.sizing_h == was.sizing_h { + own.sizing_h = new.sizing_h; + } + if own.sizing_v == was.sizing_v { + own.sizing_v = new.sizing_v; + } + for (o, w, n) in [ + (&mut own.min_w, was.min_w, new.min_w), + (&mut own.max_w, was.max_w, new.max_w), + (&mut own.min_h, was.min_h, new.min_h), + (&mut own.max_h, was.max_h, new.max_h), + ] { + if *o == w { + *o = n; + } + } +} + /// Copy the values of property group `g` of `from` onto `to`, with the variables bound to them. pub(crate) fn copy_group(from: &Node, to: &mut Node, g: &str) { if from.bound.iter().chain(to.bound.iter()).any(|(k, _)| binding_group(k) == Some(g)) { @@ -415,6 +455,7 @@ pub(crate) fn copy_group(from: &Node, to: &mut Node, g: &str) { (b.sizing_h, b.sizing_v, b.min_w, b.max_w, b.min_h, b.max_h) = (a.sizing_h, a.sizing_v, a.min_w, a.max_w, a.min_h, a.max_h); } "position" => to.transform = from.transform, + "modes" => to.var_modes = from.var_modes.clone(), "component" => { if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&from.kind, &mut to.kind) { *b = *a; @@ -779,6 +820,7 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT if let Some(prev) = root.source.and_then(|p| doc.node(p)).filter(|p| sibling_variants(doc, p.id, component)) { let differs = diff_groups(prev, &comp); root.overrides.retain(|g| g == "name" || !differs.contains(&g.as_str())); + follow_variant_resizing(&mut fresh.child_layout, &prev.child_layout, &comp.child_layout); } carry_overrides(&root, &mut fresh, Carry::Root); if let Some(r) = doc.node_mut(inst) { @@ -830,9 +872,10 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT } // Nested instances showing another main than the main's copy (swapped): rebuild from it, // with the old layers below them this rebuild didn't reuse. - let unused: BTreeMap> = old.into_iter().filter(|(i, _)| !used.contains(i) && !doc.contains(*i)).collect(); + let unused: BTreeMap> = + old.iter().filter(|(i, _)| !used.contains(*i) && !doc.contains(**i)).map(|(i, x)| (*i, x.clone())).collect(); for n in nested { - let below: BTreeMap> = unused + let mut below: BTreeMap> = unused .iter() .filter(|(_, x)| { let mut cur = x.parent; @@ -847,10 +890,77 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT }) .map(|(i, x)| (*i, x.clone())) .collect(); + take_placing_overrides(doc, n, &old, &mut below); sync_instance_depth(doc, n, depth + 1, below); } } +/// Before swapped nested instance `n` is rebuilt from its new main: each of its old layers (the +/// copies just made of the outer main's, and `below`) takes the overrides held along the source +/// chain it had before this rebuild (`before`), through layers inside instances in the mains that +/// place `n`. Matched by name, they carry onto the new main's layers as the instance's own, so a +/// swap takes what the outer main set once: later rebuilds find layers mirroring the new main. +/// Geometry and slot content follow the new main; groups the layer overrides itself keep its value. +fn take_placing_overrides( + doc: &mut Document, + n: NodeId, + before: &BTreeMap>, + below: &mut BTreeMap>, +) { + let placing = placing_mains(doc, n); + for id in doc.subtree(n).into_iter().filter(|i| *i != n) { + let held = before.get(&id).map(|b| held_along(doc, b, &placing)).unwrap_or_default(); + if let Some(layer) = doc.node_mut(id) { + take(layer, &held); + } + } + for layer in below.values_mut() { + let held = held_along(doc, layer, &placing); + if !held.is_empty() { + take(std::sync::Arc::make_mut(layer), &held); + } + } +} + +/// The mains placing nested instance `n`: those of the layers it mirrors, outward. +fn placing_mains(doc: &Document, n: NodeId) -> BTreeSet { + let mut out = BTreeSet::new(); + let mut cur = doc.node(n).and_then(|x| x.source); + for _ in 0..=MAX_DEPTH { + let Some(s) = cur.and_then(|c| doc.node(c)).filter(|s| !matches!(s.kind, NodeKind::Component { .. })) else { break }; + out.extend(owning_component(doc, s.id)); + cur = s.source; + } + out +} + +/// The overrides but geometry and slot content held along `layer`'s source chain while it runs +/// through layers inside instances in `placing` mains, each with the nearest layer holding it. +fn held_along(doc: &Document, layer: &Node, placing: &BTreeSet) -> BTreeMap> { + let mut out = BTreeMap::new(); + let mut cur = layer.source; + for _ in 0..=MAX_DEPTH { + let Some(s) = cur.and_then(|c| doc.node_arc(c)) else { break }; + if !inside_instance(doc, s.id) || !owning_component(doc, s.id).is_some_and(|m| placing.contains(&m)) { + break; + } + for g in s.overrides.iter().filter(|g| !matches!(g.as_str(), "size" | "position" | SLOT)) { + out.entry(g.clone()).or_insert_with(|| s.clone()); + } + cur = s.source; + } + out +} + +/// Give `layer` the `held` groups it has no override of its own for. +fn take(layer: &mut Node, held: &BTreeMap>) { + for (g, from) in held { + if layer.overrides.insert(g.clone()) { + copy_group(from, layer, g); + } + } +} + /// Whether main components `a` and `b` are two variants of one set. fn sibling_variants(doc: &Document, a: NodeId, b: NodeId) -> bool { a != b && variant_set(doc, a).is_some_and(|s| variant_set(doc, b) == Some(s)) @@ -1049,6 +1159,9 @@ pub fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> { if a.interactions != b.interactions { out.push("interactions"); } + if a.var_modes != b.var_modes { + out.push("modes"); + } if let (NodeKind::Instance { component: x, props: px }, NodeKind::Instance { component: y, props: py }) = (&a.kind, &b.kind) { if x != y { out.push("component"); @@ -1146,10 +1259,10 @@ pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId] } let raw = diff_groups(b, n); let mut groups = raw.clone(); - // An instance's own place, resizing, export settings and interactions are its own, not - // overrides. + // An instance's own place, resizing, export settings, interactions and variable modes are + // its own, not overrides. if top_root { - groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "resizing" | "exports" | "interactions")); + groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "resizing" | "exports" | "interactions" | "modes")); } if groups.iter().any(|g| matches!(*g, "size" | "position")) { groups.retain(|g| !matches!(*g, "size" | "position") || !geometry_derived(doc, before, id, g)); @@ -1969,7 +2082,7 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) { let mut groups: Vec = value_groups(m, n) .into_iter() .filter(|g| !matches!(g.as_str(), "name" | "exports" | "interactions") && !from_variables.contains(&g.as_str())) - .filter(|g| placed_source.is_some() || g != "resizing") + .filter(|g| placed_source.is_some() || !matches!(g.as_str(), "resizing" | "modes")) .collect(); let named = if placed_source.is_some() { n.name != m.name } else { n.name != default_instance_name(doc, component) }; if named { @@ -1993,8 +2106,11 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) { } if let Some(n) = doc.node_mut(inst) { n.source = root_base; - // A top-level instance's resizing is its own (a nested one's overrides its main layer's). - n.overrides.retain(|g| !matches!(g.as_str(), "component" | "props" | "position") && (placed_source.is_some() || g != "resizing")); + // A top-level instance's resizing and modes are its own (a nested one's override its main + // layer's). + n.overrides.retain(|g| { + !matches!(g.as_str(), "component" | "props" | "position") && (placed_source.is_some() || !matches!(g.as_str(), "resizing" | "modes")) + }); n.overrides.extend(root_groups); } } diff --git a/crates/engine/src/components_behaviour_tests.rs b/crates/engine/src/components_behaviour_tests.rs index 7a5c1f90..a95e1268 100644 --- a/crates/engine/src/components_behaviour_tests.rs +++ b/crates/engine/src/components_behaviour_tests.rs @@ -657,6 +657,64 @@ fn switching_variants_keeps_overrides_where_the_variants_agreed() { healthy(&s); } +/// A "Layout" set: Row (horizontal, hugging, a 40 wide "A") and Stacked (vertical, fixed 300 wide, +/// "A" filling it). Returns (set, row, stacked). +fn layout_set(s: &mut Session) -> (NodeId, NodeId, NodeId) { + let mut variants = Vec::new(); + for (k, (name, props, child)) in [ + ("Layout=Row", json!({"layout": "horizontal", "gap": 8, "sizingH": "hug", "sizingV": "hug", "fills": []}), json!({})), + ( + "Layout=Stacked", + json!({"layout": "vertical", "gap": 8, "sizingH": "fixed", "width": 300, "sizingV": "hug", "fills": []}), + json!({"sizingH": "fill"}), + ), + ] + .into_iter() + .enumerate() + { + let c = idv(&ex(s, "node.create", json!({"type": "component", "name": name, "x": k as f64 * 400.0, "props": props}))); + ex(s, "node.create", json!({"type": "rectangle", "parent": c, "name": "A", "width": 40, "height": 20, "props": child})); + variants.push(c); + } + let set = idv(&ex(s, "component.combineVariants", json!({"ids": variants.clone()}))); + (set, variants[0], variants[1]) +} + +#[test] +fn switching_variants_takes_the_new_variants_resizing_unless_the_instance_set_its_own() { + let mut s = Session::new(); + let (set, row, stacked) = layout_set(&mut s); + let i = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 200}))); + assert_eq!(component_of(&s, i), row); + ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Layout": "Stacked"}})); + assert_eq!(component_of(&s, i), stacked); + let direct = idv(&ex(&mut s, "instance.create", json!({"component": stacked, "x": 0, "y": 400}))); + let (n, d) = (node(&s, i), node(&s, direct)); + assert_eq!(n.child_layout.sizing_h, Sizing::Fixed, "Row's Hug was never the instance's own: it takes Stacked's Fixed"); + assert_eq!((n.width, n.child_layout.sizing_h), (d.width, d.child_layout.sizing_h), "the same as an instance made from Stacked"); + let a = layer(&s, i, "A").unwrap(); + assert_eq!((node(&s, a).child_layout.sizing_h, node(&s, a).width), (Sizing::Fill, 300.0), "A fills the 300 wide instance"); + // Filling an auto layout parent afterwards leaves A filling, with nothing turned into an override. + let col = idv(&ex( + &mut s, + "node.create", + json!({"type": "frame", "name": "Column", "x": 600, "y": 0, "width": 500, "props": {"layout": "vertical", "sizingH": "fixed", "sizingV": "hug", "fills": []}}), + )); + ex(&mut s, "node.reparent", json!({"ids": [i], "parent": col})); + ex(&mut s, "node.set", json!({"ids": [i], "props": {"sizingH": "fill"}})); + let a = layer(&s, i, "A").unwrap(); + assert_eq!(node(&s, a).child_layout.sizing_h, Sizing::Fill, "A still fills"); + assert!(!node(&s, a).overrides.contains("resizing"), "A's resizing is still the main's"); + assert_eq!(node(&s, a).width, node(&s, i).width); + // Resizing the instance set itself stays across a switch. + let own = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 600}))); + ex(&mut s, "node.set", json!({"ids": [own], "props": {"minWidth": 120}})); + ex(&mut s, "instance.setProperties", json!({"ids": [own], "properties": {"Layout": "Stacked"}})); + assert_eq!(node(&s, own).child_layout.min_w, Some(120.0), "its own min width stays"); + assert_eq!(node(&s, own).child_layout.sizing_h, Sizing::Fixed, "its sizing, still Row's, follows Stacked"); + healthy(&s); +} + #[test] fn variant_combinations_order_default_and_conflicts() { let mut s = Session::new(); diff --git a/crates/engine/src/components_rebase_source_tests.rs b/crates/engine/src/components_rebase_source_tests.rs index b272b82f..94eae71f 100644 --- a/crates/engine/src/components_rebase_source_tests.rs +++ b/crates/engine/src/components_rebase_source_tests.rs @@ -201,6 +201,20 @@ fn rebase_detached_nested_uses_own_main_and_keeps_inherited_geometry() { assert_eq!(s.doc.node(s.base).unwrap().transform.translation().x, 2.0); } +#[test] +fn rebase_marks_a_nested_roots_own_mode_but_not_a_top_level_instances() { + let mut s = scene(); + let modes = BTreeMap::from([("Tone".to_string(), "B".to_string())]); + s.doc.node_mut(s.nested).unwrap().var_modes = modes.clone(); + both(&mut s); + assert!(s.doc.node(s.nested).unwrap().overrides.contains("modes"), "its slot in Outer sets no mode"); + let page = s.doc.first_page().unwrap(); + let detached = mirror(&mut s.doc, s.nested, page); + rebase_instance(&mut s.doc, detached); + let n = s.doc.node(detached).unwrap(); + assert_eq!((&n.var_modes, n.overrides.contains("modes")), (&modes, false), "a top-level instance's modes are its own"); +} + #[test] fn rebase_does_not_preserve_existing_foreign_or_stale_sources() { let mut s = scene(); diff --git a/crates/engine/src/fill_none_tests.rs b/crates/engine/src/fill_none_tests.rs new file mode 100644 index 00000000..74fe6231 --- /dev/null +++ b/crates/engine/src/fill_none_tests.rs @@ -0,0 +1,88 @@ +//! `props.fill` and `props.stroke` accept `"none"` (either case) and `null` as no paint, the +//! same as an empty `fills`/`strokes` list (#1036): an outline-only shape in one call. + +use figcraft_doc::NodeId; +use serde_json::{Value, json}; + +use crate::Session; + +fn id(v: Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) +} + +fn create_rect(s: &mut Session, fill: Value) -> NodeId { + id(ex( + s, + "node.create", + json!({ + "type": "rectangle", "x": 0, "y": 0, "width": 10, "height": 10, + "props": {"fill": fill, "stroke": "#000000", "strokeWeight": 1} + }), + )) +} + +#[test] +fn fill_none_or_null_makes_an_outline_only_shape_in_one_call() { + for none in [json!("none"), json!("None"), json!("NONE"), json!(null)] { + let mut s = Session::new(); + let r = create_rect(&mut s, none.clone()); + let n = s.doc().unwrap().node(r).unwrap().clone(); + assert!(n.fills.is_empty(), "{none:?}: fills not empty"); + assert_eq!(n.strokes.len(), 1, "{none:?}: stroke kept"); + } +} + +#[test] +fn fill_none_clears_an_existing_fill_as_one_undo_step() { + let mut s = Session::new(); + let r = create_rect(&mut s, json!("#FF0000")); + assert_eq!(s.doc().unwrap().node(r).unwrap().fills.len(), 1); + ex(&mut s, "node.set", json!({"id": r, "props": {"fill": "none"}})); + assert!(s.doc().unwrap().node(r).unwrap().fills.is_empty()); + ex(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc().unwrap().node(r).unwrap().fills.len(), 1); +} + +#[test] +fn stroke_none_or_null_clears_the_strokes() { + for none in [json!("none"), json!(null)] { + let mut s = Session::new(); + let r = create_rect(&mut s, json!("#FF0000")); + ex(&mut s, "node.set", json!({"id": r, "props": {"stroke": none}})); + assert!(s.doc().unwrap().node(r).unwrap().strokes.is_empty(), "{none:?}"); + assert_eq!(s.doc().unwrap().node(r).unwrap().fills.len(), 1, "{none:?}"); + } +} + +#[test] +fn text_with_fill_none_gets_no_automatic_colour() { + let mut s = Session::new(); + let t = id(ex( + &mut s, + "node.create", + json!({ + "type": "text", "x": 0, "y": 0, "text": "Hi", "props": {"fill": "none"} + }), + )); + assert!(s.doc().unwrap().node(t).unwrap().fills.is_empty()); +} + +#[test] +fn an_unknown_string_still_names_the_key() { + let mut s = Session::new(); + let e = s + .execute( + "node.create", + &json!({ + "type": "rectangle", "x": 0, "y": 0, "width": 10, "height": 10, + "props": {"fill": "nope"} + }), + ) + .unwrap_err(); + let msg = e.to_string(); + assert!(msg.contains("`fill`") && msg.contains("none"), "{msg}"); +} diff --git a/crates/engine/src/fx_behaviour_tests.rs b/crates/engine/src/fx_behaviour_tests.rs index eb8da9b2..d9d93592 100644 --- a/crates/engine/src/fx_behaviour_tests.rs +++ b/crates/engine/src/fx_behaviour_tests.rs @@ -53,6 +53,29 @@ fn the_eyedropper_picks_the_colour_seen_and_fills_the_selection() { assert_eq!(s.doc().unwrap().node(b).unwrap().fills[0].solid_color().map(|c| c.to_hex()), Some("00FF00".into())); } +#[test] +fn the_eyedropper_of_a_colour_picker_samples_without_filling() { + // Opened from a colour picker (`apply: false`), the eyedropper hands the colour to the picker, + // which writes it to its own paint (a stroke, an effect...): the fill stays as it was. + let mut s = Session::new(); + s.execute("page.setBackground", &json!({"color": "#FFFFFF"})).unwrap(); + rect(&mut s, None, (0.0, 0.0, 100.0, 100.0), "#FF0000"); + let b = rect(&mut s, None, (200.0, 0.0, 50.0, 50.0), "#00FF00"); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + let undo = s.undo_depth().0; + s.execute("edit.pickColor", &json!({"apply": false})).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Down, 50.0, 50.0, 1.0)).unwrap(); + assert_eq!(s.doc().unwrap().node(b).unwrap().fills[0].solid_color().map(|c| c.to_hex()), Some("00FF00".into())); + assert_eq!(s.undo_depth().0, undo, "nothing to undo"); + assert_eq!(s.tool.picked.take().map(|c| c.to_hex()), Some("FF0000".into())); + assert_eq!(s.tool.tool, ToolId::Move, "picking ends the eyedropper"); + // The I key's eyedropper fills the selection again. + s.execute("edit.pickColor", &json!({})).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Down, 50.0, 50.0, 1.0)).unwrap(); + assert_eq!(s.doc().unwrap().node(b).unwrap().fills[0].solid_color().map(|c| c.to_hex()), Some("FF0000".into())); + assert_eq!(s.tool.picked, None); +} + #[test] fn blend_modes_and_effects_through_commands() { let mut s = Session::new(); diff --git a/crates/engine/src/guard.rs b/crates/engine/src/guard.rs index 6161f46b..d77a1bfc 100644 --- a/crates/engine/src/guard.rs +++ b/crates/engine/src/guard.rs @@ -9,7 +9,7 @@ pub fn run(cmd: &str, f: impl FnOnce() -> Result) -> Result { Err(payload) => { let msg = payload .downcast_ref::<&str>() - .map(|s| s.to_string()) + .map(ToString::to_string) .or_else(|| payload.downcast_ref::().cloned()) .unwrap_or_else(|| "panic".to_string()); log::error!("command `{cmd}` panicked: {msg}"); diff --git a/crates/engine/src/instance_modes_tests.rs b/crates/engine/src/instance_modes_tests.rs index 79007622..43de3ca4 100644 --- a/crates/engine/src/instance_modes_tests.rs +++ b/crates/engine/src/instance_modes_tests.rs @@ -126,3 +126,71 @@ fn detaching_keeps_the_mode_it_took_from_its_main() { assert_eq!(fill(&s, i), "0000FF", "the detached frame keeps mode B"); assert_eq!(s.doc().unwrap().node(i).unwrap().var_modes.len(), 1); } + +/// A "Button" holding an instance of a swatch "Icon" on mode A, and a "Modal" holding a Button +/// instance whose icon copy was set to mode B: (the Button main, its icon layer, the copy in Modal). +fn modal(s: &mut Session) -> (NodeId, NodeId, NodeId) { + tone(s); + let icon = swatch(s, "Icon", None); + let frame = id(run(s, "frame.create", json!({"x": 100, "y": 0, "width": 60, "height": 40, "name": "Button"}))); + let button_icon = id(run(s, "instance.create", json!({"component": icon, "x": 10, "y": 10, "parent": frame}))); + set_mode(s, button_icon, json!("A")); + let button = id(run(s, "component.create", json!({"ids": [frame]}))); + let outer = id(run(s, "frame.create", json!({"x": 300, "y": 0, "width": 200, "height": 100, "name": "Modal"}))); + let close = id(run(s, "instance.create", json!({"component": button, "x": 10, "y": 10, "parent": outer}))); + let nested = s.doc().unwrap().node(close).unwrap().children[0]; + set_mode(s, nested, json!("B")); + run(s, "component.create", json!({"ids": [outer]})); + (button, button_icon, nested) +} + +#[test] +fn a_mode_set_on_a_layer_inside_an_instance_is_an_override() { + let mut s = Session::new(); + let (button, button_icon, nested) = modal(&mut s); + assert_eq!(fill(&s, nested), "0000FF"); + // An edit the copies can't take as they are rebuilds the Button instance. + run(&mut s, "node.set", json!({"ids": [button], "layout": "horizontal"})); + assert_eq!(fill(&s, nested), "0000FF", "a rebuild keeps the mode set on the copy"); + // The main's layer choosing another mode doesn't reach it either. + set_mode(&mut s, button_icon, json!("B")); + set_mode(&mut s, button_icon, Value::Null); + assert_eq!(fill(&s, nested), "0000FF"); + run(&mut s, "component.resetOverride", json!({"id": nested, "group": "modes"})); + assert_eq!(fill(&s, nested), "FF0000", "reset: the main's layer is on Auto (mode A)"); + assert!(s.doc().unwrap().node(nested).unwrap().var_modes.is_empty()); +} + +#[test] +fn a_mode_set_on_an_icon_inside_a_placed_instance_survives_main_edits() { + let mut s = Session::new(); + let (button, _, _) = modal(&mut s); + // A Button placed on the page, its icon recoloured through the icon's mode. + let placed = instance(&mut s, button); + let icon = s.doc().unwrap().node(placed).unwrap().children[0]; + set_mode(&mut s, icon, json!("B")); + assert!(s.doc().unwrap().node(icon).unwrap().overrides.contains("modes"), "it shows as an override"); + run(&mut s, "node.set", json!({"ids": [button], "layout": "vertical"})); + assert_eq!(fill(&s, icon), "0000FF", "a rebuild of the Button keeps it"); + run(&mut s, "component.resetInstance", json!({"ids": [placed]})); + assert_eq!(fill(&s, icon), "FF0000", "reset all brings back the main's mode"); +} + +#[test] +fn a_file_from_before_modes_were_overrides_keeps_the_modes_chosen_inside_its_instances() { + let mut s = Session::new(); + let (button, _, nested) = modal(&mut s); + // Saved before `modes` was an override group: the copy has its mode but no marker. + s.doc_mut().unwrap().node_mut(nested).unwrap().overrides.remove("modes"); + let path = std::env::temp_dir().join(format!("figcraft-instance-modes-{}.figcraft", std::process::id())); + let p = path.to_string_lossy().into_owned(); + run(&mut s, "file.saveAs", json!({"path": p})); + let mut s = Session::new(); + run(&mut s, "file.open", json!({"path": p})); + let _ = std::fs::remove_file(&path); + let doc = s.doc().unwrap(); + assert!(doc.node(nested).unwrap().overrides.contains("modes"), "opening it marks the mode the copy chose"); + assert!(doc.nodes().filter(|n| n.id != nested).all(|n| !n.overrides.contains("modes")), "layers on their main's modes stay unmarked"); + run(&mut s, "node.set", json!({"ids": [button], "layout": "horizontal"})); + assert_eq!(fill(&s, nested), "0000FF", "a rebuild keeps it"); +} diff --git a/crates/engine/src/keymap.rs b/crates/engine/src/keymap.rs index 1ffad5e2..6f0641db 100644 --- a/crates/engine/src/keymap.rs +++ b/crates/engine/src/keymap.rs @@ -229,8 +229,10 @@ pub static KEYS: &[Binding] = &[ k(View, "view.prevPage", &["PageUp"]), k(View, "view.nextPage", &["PageDown"]), // Zoom - k(Zoom, "view.zoomIn", &["Cmd+=", "Shift+=", "Shift+Cmd+="]), - k(Zoom, "view.zoomOut", &["Cmd+-", "-", "Shift+-"]), + // Each zoom-in key has its zoom-out twin on the key beside it: `-` alone zooms out, so `=` + // alone (the `+` key without Shift) zooms in (#973). + k(Zoom, "view.zoomIn", &["Cmd+=", "Shift+=", "Shift+Cmd+=", "="]), + k(Zoom, "view.zoomOut", &["Cmd+-", "-", "Shift+-", "Shift+Cmd+-"]), k(Zoom, "view.zoom100", &["Shift+0", "Cmd+0"]), k(Zoom, "view.zoomFit", &["Shift+1"]), k(Zoom, "view.zoomSelection", &["Shift+2"]), diff --git a/crates/engine/src/keymap_tests.rs b/crates/engine/src/keymap_tests.rs index 38775d3b..f624ecfb 100644 --- a/crates/engine/src/keymap_tests.rs +++ b/crates/engine/src/keymap_tests.rs @@ -168,6 +168,25 @@ fn display_per_platform() { assert_eq!(primary("polygon.none", mac), None); } +/// Each way of zooming in has its zoom-out twin on the key beside it, as the keys arrive from a US +/// keyboard (#973: `-` alone zoomed out but `=` alone did nothing, so zooming back in was stuck). +#[test] +fn zoom_keys_come_in_pairs() { + let none = Mods::default(); + let shift = mods(true, false, false, false); + let cmd = mods(false, false, true, false); + let shift_cmd = mods(true, false, true, false); + for p in PLATFORMS { + for (zoom_in, zoom_out, m) in [("Equals", "Minus", none), ("Equals", "Minus", cmd), ("Plus", "Minus", shift_cmd), ("Plus", "Minus", shift)] { + assert_eq!(id_for(p, Context::Canvas, zoom_in, m), Some("view.zoomIn"), "{zoom_in} {m:?} on {p:?}"); + assert_eq!(id_for(p, Context::Canvas, zoom_out, m), Some("view.zoomOut"), "{zoom_out} {m:?} on {p:?}"); + } + // The menus still show Cmd/Ctrl and `+`. + assert_eq!(primary("view.zoomIn", p), Some("Cmd+=")); + assert_eq!(primary("view.zoomOut", p), Some("Cmd+-")); + } +} + #[test] fn tool_keys_come_from_the_keymap() { assert_eq!(ToolId::Move.shortcut(), "V"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c9298022..f9796580 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -325,6 +325,17 @@ pub struct TextEdit { pub anchor: usize, /// The node was created by this edit (deleted if left empty). pub created: bool, + /// Identity of this editing session (`textedit::begin` assigns a fresh one). + /// + /// The IME owner is `(document uid, node)`, which stays the same through ordinary edits + /// and caret moves; this token changes only when editing is started again, so an IME + /// context is never silently inherited by a replaced target that reuses the same node. + /// + /// It is deliberately not serialized: current consumers only inspect a live session. A + /// serialized `TextEdit` restored from data would get zero here, so it must not be restored + /// into a live editor without minting (or otherwise invalidating) a new session. + #[serde(default, skip_serializing)] + pub editing_session: u64, /// An IME composition in progress, shown inline at the caret; the document gets it on commit. #[serde(default, skip_serializing_if = "Option::is_none")] pub preedit: Option, @@ -414,6 +425,7 @@ impl DocState { } doc.link_layer_text_styles(); cmd::styles::apply_paint_bindings_on_load(&mut doc); + components::mark_layer_modes(&mut doc); // Initial import settlement belongs to the saved baseline, not the user's first edit. let page = doc.current_page.filter(|p| doc.pages.contains(p)).or_else(|| doc.first_page()).unwrap_or(NodeId(0)); let doc = Arc::new(doc); @@ -765,6 +777,10 @@ pub struct Clipboard { pub source_doc: u64, /// Copied component/set roots, excluding cuts and originals discarded by undo (ids are reused). pub copied_components: std::collections::BTreeSet, + /// Monotonic generation of the copied layers (contents, images, bounds and source). A UI + /// clipboard mirror records it, so a later programmatic copy that never touched the system + /// clipboard is not mistaken for the user's own copy just because the text still matches. + pub layers_generation: u64, /// The document copied from (pastes into other files follow its mains) and its file path. pub source: Option>, pub source_path: Option, @@ -1690,6 +1706,8 @@ mod fig_export_tests; #[cfg(test)] mod fig_import_tests; #[cfg(test)] +mod fill_none_tests; +#[cfg(test)] mod finite_geometry_tests; #[cfg(test)] mod frame_guides_tests; @@ -1902,6 +1920,8 @@ mod variants_discovery_tests; #[cfg(test)] mod vector_behaviour_tests; #[cfg(test)] +mod vector_fill_rule_tests; +#[cfg(test)] mod vector_multi_behaviour_tests; #[cfg(test)] mod vector_offset_tests; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 26863561..cc6c3856 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -339,7 +339,7 @@ pub fn publish(doc: &Document, prev: Option<&PublishedLibrary>, sel: &Selection, .map(|v| v.id.as_str()) .collect(); let (bound, mode_colls) = used_variables(doc, &units); - let var_ids = alias_closure(&doc.variables, offered_vars.iter().map(|v| v.to_string()).chain(bound)); + let var_ids = alias_closure(&doc.variables, offered_vars.iter().map(ToString::to_string).chain(bound)); snap.variables.variables = doc.variables.variables.iter().filter(|v| var_ids.contains(&v.id)).cloned().collect(); let coll_ids: BTreeSet<&str> = snap .variables @@ -491,6 +491,11 @@ impl Cache { None => Err(EngineError::Other(crate::library_server::NOT_FETCHED.into())), }; } + // The path comes from a document: a network share would be contacted (and on Windows get + // the opener's credentials) by the stat below. + if !craft_docpath::is_local(path) { + return Err(EngineError::Other(format!("{path}: a library is opened only from this computer"))); + } let stamp = stat(path); // Checked before the cache: the limit is the caller's. if let Ok(st) = &stamp @@ -553,11 +558,18 @@ fn stat(_path: &str) -> std::result::Result { Err(NO_FILES.into()) } +/// A library read from a file or a server, with the modes chosen inside its instances marked when +/// it was published before they were overrides ([`crate::components::mark_layer_modes`]). +pub(crate) fn settled(mut lib: PublishedLibrary) -> PublishedLibrary { + crate::components::mark_layer_modes(Arc::make_mut(&mut lib.doc)); + lib +} + #[cfg(not(target_arch = "wasm32"))] fn read_file(path: &str) -> std::result::Result { let f = crate::file_access::open(path).map_err(|e| e.to_string())?; match figcraft_format::read_library(std::io::BufReader::new(f)) { - Ok(Some(lib)) => Ok(lib), + Ok(Some(lib)) => Ok(settled(lib)), Ok(None) => Err(format!("{path} has no published library: open it and publish it first (Libraries ▸ Publish library…)")), Err(e) => Err(format!("{path}: {e}")), } diff --git a/crates/engine/src/library_path_tests.rs b/crates/engine/src/library_path_tests.rs index f15105dc..de40128a 100644 --- a/crates/engine/src/library_path_tests.rs +++ b/crates/engine/src/library_path_tests.rs @@ -28,3 +28,12 @@ fn a_local_library_path_is_still_followed() { let e = s.fetch_library(&missing.to_string_lossy(), false).expect_err("the file does not exist"); assert!(!e.to_string().contains("only from this computer"), "a local path must not be refused as remote: {e}"); } + +#[test] +fn the_library_cache_refuses_a_path_off_this_computer() { + let mut cache = crate::library::Cache::default(); + for path in [r"\\attacker.example\share\lib.figcraft", "//attacker.example/share/lib.figcraft"] { + let e = cache.load(path).expect_err(&format!("{path} must be refused")); + assert!(e.to_string().contains("only from this computer"), "{path}: {e}"); + } +} diff --git a/crates/engine/src/library_server.rs b/crates/engine/src/library_server.rs index 99d5f29a..10852b5f 100644 --- a/crates/engine/src/library_server.rs +++ b/crates/engine/src/library_server.rs @@ -53,7 +53,7 @@ pub fn archive(lib: &PublishedLibrary) -> Result> { /// The library in an archive from a server (untrusted: read with the library file's limits). pub fn read_archive(bytes: &[u8]) -> std::result::Result { match figcraft_format::read_library(Cursor::new(bytes)) { - Ok(Some(lib)) => Ok(lib), + Ok(Some(lib)) => Ok(crate::library::settled(lib)), Ok(None) => Err("the server sent a file without a library".into()), Err(e) => Err(format!("the server sent a library that can't be read ({e})")), } diff --git a/crates/engine/src/library_variables_tests.rs b/crates/engine/src/library_variables_tests.rs index 4fc778a7..b4a4171a 100644 --- a/crates/engine/src/library_variables_tests.rs +++ b/crates/engine/src/library_variables_tests.rs @@ -344,3 +344,67 @@ fn library_style_bindings_ignore_unrelated_local_variables() { healthy(&s); let _ = std::fs::remove_dir_all(&d); } + +/// A library "Modal" holding a Button instance whose icon copy is on mode Dark: (the Button main, +/// the icon copy in Modal). +fn nested_mode_kit(lib: &mut Session) -> (NodeId, NodeId) { + theme(lib); + let f = idv(&ex(lib, "frame.create", json!({"x": 0, "y": 0, "width": 20, "height": 20, "name": "Icon"}))); + ex(lib, "variable.bind", json!({"ids": [f], "field": "fill", "variable": "primary"})); + let icon = idv(&ex(lib, "component.create", json!({"ids": [f]}))); + let b = idv(&ex(lib, "frame.create", json!({"x": 100, "y": 0, "width": 60, "height": 40, "name": "Button"}))); + ex(lib, "instance.create", json!({"component": icon, "x": 10, "y": 10, "parent": b})); + let button = idv(&ex(lib, "component.create", json!({"ids": [b]}))); + let m = idv(&ex(lib, "frame.create", json!({"x": 300, "y": 0, "width": 200, "height": 100, "name": "Modal"}))); + let close = idv(&ex(lib, "instance.create", json!({"component": button, "x": 10, "y": 10, "parent": m}))); + let nested = lib.doc().unwrap().node(close).unwrap().children[0]; + ex(lib, "variable.setMode", json!({"ids": [nested], "collection": "Theme", "mode": "Dark"})); + ex(lib, "component.create", json!({"ids": [m]})); + (button, nested) +} + +/// The icon inside the Button inside library Modal instance `inst`. +fn modal_icon(s: &Session, inst: NodeId) -> NodeId { + let doc = s.doc().unwrap(); + doc.node(doc.node(inst).unwrap().children[0]).unwrap().children[0] +} + +#[test] +fn modes_set_inside_nested_instances_come_along() { + let d = dir("nested-modes"); + let lib_path = path_str(&d.join("kit.figcraft")); + let mut lib = Session::new(); + let (button, _) = nested_mode_kit(&mut lib); + let lid = publish_to(&mut lib, &lib_path); + + let mut s = Session::new(); + ex(&mut s, "library.add", json!({"path": lib_path})); + let inst = idv(&ex(&mut s, "library.place", json!({"library": lid, "component": "Modal", "x": 0, "y": 0}))); + let copy = |s: &Session| modal_icon(s, inst); + assert_eq!(fill(&s, copy(&s)), "FFFFFF", "the Dark mode set on the nested icon comes along"); + // An update that rebuilds the Button copies keeps it too. + ex(&mut lib, "node.set", json!({"ids": [button], "layout": "horizontal"})); + republish(&mut lib); + ex(&mut s, "library.acceptUpdates", json!({})); + assert_eq!(fill(&s, copy(&s)), "FFFFFF"); + healthy(&s); + let _ = std::fs::remove_dir_all(&d); +} + +#[test] +fn a_library_published_before_modes_were_overrides_keeps_the_modes_set_inside_nested_instances() { + let d = dir("nested-modes-old"); + let lib_path = path_str(&d.join("kit.figcraft")); + let mut lib = Session::new(); + let (_, nested) = nested_mode_kit(&mut lib); + // Published before `modes` was an override group: the copy has its mode but no marker. + lib.doc_mut().unwrap().node_mut(nested).unwrap().overrides.remove("modes"); + let lid = publish_to(&mut lib, &lib_path); + + let mut s = Session::new(); + ex(&mut s, "library.add", json!({"path": lib_path})); + let inst = idv(&ex(&mut s, "library.place", json!({"library": lid, "component": "Modal", "x": 0, "y": 0}))); + assert_eq!(fill(&s, modal_icon(&s, inst)), "FFFFFF", "the Dark mode comes along"); + healthy(&s); + let _ = std::fs::remove_dir_all(&d); +} diff --git a/crates/engine/src/nested_override_tests.rs b/crates/engine/src/nested_override_tests.rs index f684a693..0d61edf7 100644 --- a/crates/engine/src/nested_override_tests.rs +++ b/crates/engine/src/nested_override_tests.rs @@ -198,3 +198,179 @@ fn copied_instances_keep_their_overrides() { assert!(node(&s, t).source.is_none() && node(&s, t).overrides.is_empty()); healthy(&s); } + +/// `button()` with the main's copy of the icon's Shape filled red, and an instance-swap property +/// `Icon` on the nested icon: (icon, shape, button, nested). +fn button_with_icon_override(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId) { + let (icon_main, shape, button, nested) = button(s); + let main_shape = copy_of(s, nested, shape); + ex(s, "node.set", json!({"id": main_shape, "props": {"fill": "#ff0000"}})); + ex(s, "component.addProperty", json!({"id": button, "name": "Icon", "type": "instanceSwap", "default": icon_main, "layer": nested})); + (icon_main, shape, button, nested) +} + +/// Swap instance `i`'s icon to `to` by its instance-swap property `Icon`. +fn swap_icon(s: &mut Session, i: NodeId, to: NodeId) { + ex(s, "instance.setProperties", json!({"ids": [i], "properties": {"Icon": to}})); + healthy(s); +} + +#[test] +fn a_main_override_carries_onto_a_swapped_nested_instance() { + // Documented: overrides survive a swap when layer names match, and a swapped icon takes the + // colour the main component set (forum thread with staff replies). Rows: by the instance-swap + // property and by instance.swap, onto a matching and an unmatched name. The main's size + // override is geometry and stays out; the instance's own override wins over the main's. + let mut s = Session::new(); + let (_icon, shape, button, nested) = button_with_icon_override(&mut s); + let main_shape = copy_of(&s, nested, shape); + ex(&mut s, "node.set", json!({"id": main_shape, "props": {"width": 10}})); + let other = icon(&mut s, 200.0, "Other", "Shape"); + let unmatched = icon(&mut s, 300.0, "Unmatched", "Glyph"); + for (by_property, to, want) in [(true, other, "FF0000"), (false, other, "FF0000"), (true, unmatched, "111111"), (false, unmatched, "111111")] { + let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 400}))); + if by_property { + swap_icon(&mut s, i, to); + } else { + let inested = copy_of(&s, i, nested); + ex(&mut s, "instance.swap", json!({"ids": [inested], "component": to})); + healthy(&s); + } + let glyph = kids(&s, copy_of(&s, i, nested))[0]; + assert_eq!(fill(&s, glyph), want, "by property {by_property}, to {to}"); + assert_eq!(node(&s, glyph).width, 20.0, "the main's size override stays out"); + } + let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 600}))); + let glyph = kids(&s, copy_of(&s, i, nested))[0]; + ex(&mut s, "node.set", json!({"id": glyph, "props": {"fill": "#0000ff"}})); + swap_icon(&mut s, i, other); + assert_eq!(fill(&s, kids(&s, copy_of(&s, i, nested))[0]), "0000FF", "the instance's own override wins"); +} + +#[test] +fn a_main_override_carries_onto_a_swap_one_level_deeper() { + // As above for a Button instance inside a Toolbar main, swapped in a Toolbar instance. + let mut s = Session::new(); + let (_icon, _shape, button, nested) = button_with_icon_override(&mut s); + let other = icon(&mut s, 200.0, "Other", "Shape"); + let bar = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 600, "width": 300, "height": 60, "name": "Toolbar"}))); + let inner = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 10, "y": 10, "parent": bar}))); + let toolbar = idv(&ex(&mut s, "component.create", json!({"ids": [bar]}))); + let t = idv(&ex(&mut s, "instance.create", json!({"component": toolbar, "x": 0, "y": 800}))); + let icon_in_t = copy_of(&s, t, copy_of(&s, inner, nested)); + ex(&mut s, "instance.swap", json!({"ids": [icon_in_t], "component": other})); + healthy(&s); + assert_eq!(fill(&s, kids(&s, icon_in_t)[0]), "FF0000"); +} + +#[test] +fn a_swapped_nested_instance_keeps_the_main_override_it_took() { + // Documented (staff reply): later edits of the main's icon colour don't reach instances that + // swapped, and do reach the rest. Undo and redo follow; resetting the override shows the icon + // main's own fill. + let mut s = Session::new(); + let (_icon, shape, button, nested) = button_with_icon_override(&mut s); + let other = icon(&mut s, 200.0, "Other", "Shape"); + let swapped = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 400}))); + let kept = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 500}))); + swap_icon(&mut s, swapped, other); + let glyph = |s: &Session, i: NodeId| fill(s, kids(s, copy_of(s, i, nested))[0]); + let main_shape = copy_of(&s, nested, shape); + ex(&mut s, "node.set", json!({"id": main_shape, "props": {"fill": "#00ff00"}})); + healthy(&s); + assert_eq!((glyph(&s, swapped), glyph(&s, kept)), ("FF0000".into(), "00FF00".into())); + s.undo().unwrap(); + assert_eq!((glyph(&s, swapped), glyph(&s, kept)), ("FF0000".into(), "FF0000".into())); + s.redo().unwrap(); + assert_eq!((glyph(&s, swapped), glyph(&s, kept)), ("FF0000".into(), "00FF00".into())); + let swapped_glyph = kids(&s, copy_of(&s, swapped, nested))[0]; + ex(&mut s, "component.resetOverride", json!({"id": swapped_glyph, "group": "fills"})); + healthy(&s); + assert_eq!(glyph(&s, swapped), "111111"); +} + +#[test] +fn a_swapped_in_icon_keeps_its_own_overrides_to_itself() { + // Docs silent; pinned: what an icon main overrides inside itself stays that main's. After a + // swap to it, its edits still reach the instance; a second swap doesn't carry them on. + let mut s = Session::new(); + let (_icon, _shape, button, nested) = button_with_icon_override(&mut s); + let dot = icon(&mut s, 200.0, "Dot", "Shape"); + let with_dot = |s: &mut Session, x: f64, name: &str, fill: Option<&str>| { + let f = idv(&ex(s, "frame.create", json!({"x": x, "y": 300, "width": 24, "height": 24, "name": name}))); + let d = idv(&ex(s, "instance.create", json!({"component": dot, "x": 0, "y": 0, "parent": f}))); + if let Some(c) = fill { + let shape = kids(s, d)[0]; + ex(s, "node.set", json!({"id": shape, "props": {"fill": c}})); + } + (idv(&ex(s, "component.create", json!({"ids": [f]}))), d) + }; + let (other2, dot2) = with_dot(&mut s, 300.0, "Other2", Some("#0000ff")); + let (other3, _) = with_dot(&mut s, 400.0, "Other3", None); + let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 400}))); + swap_icon(&mut s, i, other2); + let shape_in = |s: &Session| kids(s, kids(s, copy_of(s, i, nested))[0])[0]; + assert_eq!(fill(&s, shape_in(&s)), "0000FF"); + let shape2 = kids(&s, dot2)[0]; + ex(&mut s, "node.set", json!({"id": shape2, "props": {"fill": "#00ffff"}})); + healthy(&s); + assert_eq!(fill(&s, shape_in(&s)), "00FFFF", "Other2's own edit reaches it"); + swap_icon(&mut s, i, other3); + assert_eq!(fill(&s, shape_in(&s)), "111111", "Other2's override doesn't carry onto Other3"); +} + +#[test] +fn a_swapped_out_icons_own_overrides_stay_with_it() { + // Docs silent; pinned: what the Button's default icon main overrides inside itself isn't the + // Button main's to carry. Icon A colours its Base's Shape green; swapping to icon B, also built + // on Base, shows Base's own fill. + let mut s = Session::new(); + let base = icon(&mut s, 0.0, "Base", "Shape"); + let on_base = |s: &mut Session, x: f64, name: &str| { + let f = idv(&ex(s, "frame.create", json!({"x": x, "y": 500, "width": 24, "height": 24, "name": name}))); + let b = idv(&ex(s, "instance.create", json!({"component": base, "x": 0, "y": 0, "parent": f}))); + (idv(&ex(s, "component.create", json!({"ids": [f]}))), b) + }; + let (a, a_base) = on_base(&mut s, 100.0, "A"); + let (b, _) = on_base(&mut s, 200.0, "B"); + let a_shape = kids(&s, a_base)[0]; + ex(&mut s, "node.set", json!({"id": a_shape, "props": {"fill": "#00ff00"}})); + let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 700, "width": 40, "height": 40, "name": "Button"}))); + let nested = idv(&ex(&mut s, "instance.create", json!({"component": a, "x": 4, "y": 4, "parent": f}))); + let button = idv(&ex(&mut s, "component.create", json!({"ids": [f]}))); + ex(&mut s, "component.addProperty", json!({"id": button, "name": "Icon", "type": "instanceSwap", "default": a, "layer": nested})); + let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 0, "y": 800}))); + swap_icon(&mut s, i, b); + let shape = kids(&s, kids(&s, copy_of(&s, i, nested))[0])[0]; + assert_eq!(fill(&s, shape), "111111"); + assert!(node(&s, shape).overrides.is_empty()); +} + +#[test] +fn a_slot_the_main_filled_shows_the_swapped_in_mains_content() { + // Docs silent on slot content across a swap; pinned: a Chip the Panel main put in its card's + // slot doesn't carry onto the swapped-in card, which shows its own default content. + let mut s = Session::new(); + let card = |s: &mut Session, x: f64, name: &str| { + let f = idv(&ex(s, "frame.create", json!({"x": x, "y": 0, "width": 100, "height": 60, "name": name}))); + let content = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 60, "parent": f, "name": "Content"}))); + ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 20, "parent": content, "props": {"name": format!("{name} default")}})); + let c = idv(&ex(s, "component.create", json!({"ids": [f]}))); + ex(s, "component.makeSlot", json!({"id": content})); + c + }; + let (a, b) = (card(&mut s, 0.0, "A"), card(&mut s, 200.0, "B")); + let chip = icon(&mut s, 400.0, "Chip", "Dot"); + let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 400, "width": 200, "height": 100, "name": "Panel"}))); + let nested = idv(&ex(&mut s, "instance.create", json!({"component": a, "x": 0, "y": 0, "parent": f}))); + let panel = idv(&ex(&mut s, "component.create", json!({"ids": [f]}))); + ex(&mut s, "instance.slotInsert", json!({"id": nested, "slot": "Content", "component": chip, "replace": true})); + ex(&mut s, "component.addProperty", json!({"id": panel, "name": "Card", "type": "instanceSwap", "default": a, "layer": nested})); + let i = idv(&ex(&mut s, "instance.create", json!({"component": panel, "x": 0, "y": 700}))); + ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Card": b}})); + healthy(&s); + let content = kids(&s, copy_of(&s, i, nested))[0]; + let names: Vec = kids(&s, content).into_iter().map(|k| node(&s, k).name.clone()).collect(); + assert_eq!(names, ["B default"]); + assert!(!node(&s, content).overrides.contains(SLOT)); +} diff --git a/crates/engine/src/pen_overlay_tests.rs b/crates/engine/src/pen_overlay_tests.rs index 7e0064d2..7f6dba3c 100644 --- a/crates/engine/src/pen_overlay_tests.rs +++ b/crates/engine/src/pen_overlay_tests.rs @@ -63,3 +63,24 @@ fn finishing_the_path_clears_its_handles_and_band() { let ov = &s.tool.overlay; assert!(ov.pen.is_empty() && ov.pen_handles.is_empty() && ov.rubber.is_none() && ov.rubber_handle.is_none()); } + +#[test] +fn shift_keeps_the_band_and_the_next_point_at_45_degree_steps() { + // #970: with Shift the band shows where the point will land, at a 45° step from the last + // anchor (the same length as the pointer is away), not under the pointer. + let mut s = Session::new(); + s.set_tool(ToolId::Pen); + at(&mut s, PointerKind::Down, 100.0, 100.0); + at(&mut s, PointerKind::Up, 100.0, 100.0); + let shifted = |kind, x, y| PointerEvent { shift: true, ..PointerEvent::at(kind, x, y, 1.0) }; + s.pointer(&shifted(PointerKind::Hover, 300.0, 120.0)).unwrap(); + let [x0, y0, x1, y1] = s.tool.overlay.rubber.unwrap(); + assert_eq!([x0, y0, y1], [100.0, 100.0, 100.0], "level with the last anchor"); + assert!((x1 - (100.0 + 200f64.hypot(20.0))).abs() < 1e-9, "{x1}"); + s.pointer(&shifted(PointerKind::Down, 300.0, 120.0)).unwrap(); + s.pointer(&shifted(PointerKind::Up, 300.0, 120.0)).unwrap(); + s.pointer(&shifted(PointerKind::Hover, 400.0, 230.0)).unwrap(); + let [x0, y0, x1, y1] = s.tool.overlay.rubber.unwrap(); + assert_eq!(y0, 100.0, "the point landed on the band"); + assert!((x1 - x0 - (y1 - y0)).abs() < 1e-9, "45° down to the right: {:?}", [x0, y0, x1, y1]); +} diff --git a/crates/engine/src/plugins.rs b/crates/engine/src/plugins.rs index be997079..b7c12227 100644 --- a/crates/engine/src/plugins.rs +++ b/crates/engine/src/plugins.rs @@ -107,6 +107,8 @@ const DENIED: &[&str] = &[ "plugins.", "libraries.", "library.add", + // Uploads the open document to a server. + "library.publishToServer", "edit.undo", "edit.redo", "edit.repeat", @@ -132,6 +134,9 @@ fn refusal(m: &Manifest, command: &str, params: &Value) -> Option { if command == "image.place" && get(params, "path").is_some() { return Some("plugins can't read files: pass the image as `data`".into()); } + if command == "text.loadFont" && get(params, "path").is_some() { + return Some("plugins can't read files: pass the font as `data`".into()); + } // `path` here is a file on disk (elsewhere it is a vector path or an action's place). if matches!(command, "variable.export" | "codeConnect.export" | "component.playground") && get(params, "path").is_some() { return Some("plugins can't write files: leave out `path` to get the result back".into()); @@ -155,7 +160,8 @@ fn refusal(m: &Manifest, command: &str, params: &Value) -> Option { /// Whether `params` set layer data (`pluginData`, anywhere in them) under a widget's own keys. fn writes_widget_data(params: &Value, depth: usize) -> bool { let own = |data: &Value| data.as_object().is_some_and(|m| m.keys().any(|k| k.starts_with(crate::widgets::DATA_PREFIX))); - depth < 64 + // serde_json refuses nesting past 128, so nothing deeper can reach here. + depth < 128 && match params { Value::Object(m) => m.iter().any(|(k, v)| (k == "pluginData" && own(v)) || writes_widget_data(v, depth + 1)), Value::Array(a) => a.iter().any(|v| writes_widget_data(v, depth + 1)), @@ -592,3 +598,28 @@ pub fn specs() -> Vec { spec!("plugins.manage", "Manage plugins…", "{} opens the plugins window; returns the list like `plugin.list`", false, manage), ] } + +#[cfg(test)] +mod refusal_tests { + use super::*; + + #[test] + fn read_only_plugin_cannot_publish_to_server() { + let m = Manifest::parse(r#"{"id":"x.y","name":"X","main":"plugin.wasm"}"#).unwrap(); + let r = refusal(&m, "library.publishToServer", &json!({})); + assert!(r.is_some(), "library.publishToServer uploads the document and must be refused"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn widget_data_is_found_at_any_depth_serde_accepts() { + let depth = 100; + let text = format!("{}{{\"pluginData\":{{\"{}x\":1}}}}{}", "{\"a\":".repeat(depth), crate::widgets::DATA_PREFIX, "}".repeat(depth)); + let v: Value = serde_json::from_str(&text).unwrap(); + assert!(writes_widget_data(&v, 0)); + } +} diff --git a/crates/engine/src/plugins_tests.rs b/crates/engine/src/plugins_tests.rs index 657b6825..7f6c3740 100644 --- a/crates/engine/src/plugins_tests.rs +++ b/crates/engine/src/plugins_tests.rs @@ -164,6 +164,7 @@ fn files_dialogs_history_and_plugins_are_off_limits() { ("file.saveAs", json!({"path": target})), ("file.open", json!({"path": "C:/Windows/win.ini"})), ("image.place", json!({"path": "secret.png"})), + ("text.loadFont", json!({"path": "/etc/hosts"})), ("library.add", json!({"path": "lib.figcraft"})), ("plugin.run", json!({"id": "org.test.deny"})), ("plugin.remove", json!({"id": "org.test.deny"})), diff --git a/crates/engine/src/save_format_tests.rs b/crates/engine/src/save_format_tests.rs index d635808e..fc4ca775 100644 --- a/crates/engine/src/save_format_tests.rs +++ b/crates/engine/src/save_format_tests.rs @@ -15,8 +15,12 @@ struct SaveFiles { impl SaveFiles { fn new() -> Self { + // Tests run in parallel and the clock can read the same for two of them (macOS counts + // microseconds), so a counter keeps their folders apart. + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); - let dir = std::env::temp_dir().join(format!("figcraft-save-formats-{}-{stamp}", std::process::id())); + let dir = std::env::temp_dir().join(format!("figcraft-save-formats-{}-{stamp}-{n}", std::process::id())); std::fs::create_dir(&dir).unwrap(); Self { dir, files: Vec::new() } } diff --git a/crates/engine/src/selection_behaviour_tests.rs b/crates/engine/src/selection_behaviour_tests.rs index bc961a4a..ef5869b2 100644 --- a/crates/engine/src/selection_behaviour_tests.rs +++ b/crates/engine/src/selection_behaviour_tests.rs @@ -324,6 +324,49 @@ fn shift_drag_moves_the_selection() { assert_eq!(s.selection(), vec![k.empty], "Shift let go before the button still takes the layer out"); } +#[test] +fn a_drag_inside_the_selection_box_moves_it_where_nothing_is_hit() { + let mut s = Session::new(); + let k = scene(&mut s); + for i in [k.r, k.c] { + ex(&mut s, "node.set", json!({"ids": [i], "props": {"fills": [], "strokes": ["#000000"], "strokeWeight": 1}})); + } + let at = |s: &Session, i: NodeId| s.doc().unwrap().world_bounds(i).unwrap().origin(); + // (selection before, drag from, selection after, the layers that move by (10, 10), derivation) + let rows: Vec<(Vec, (f64, f64), Vec, Vec, &str)> = vec![ + (vec![k.r], (520.0, 20.0), vec![k.r], vec![k.r], "a selected unfilled shape drags from its inside, not only its stroke"), + (vec![k.c], (40.0, 140.0), vec![k.c], vec![k.c], "the same in a top-level frame, whose background is under the inside"), + (vec![k.r, k.empty], (570.0, 20.0), vec![k.r, k.empty], vec![k.r, k.empty], "a gap inside a multiple selection's box drags them all"), + (vec![], (520.0, 20.0), vec![k.r], vec![], "an unselected unfilled shape is hit on its stroke only: the drag is a marquee, which touches it"), + ]; + for (before, from, after, moved, why) in rows { + let home: Vec = [k.r, k.c, k.empty].iter().map(|i| at(&s, *i)).collect(); + ex(&mut s, "select.set", json!({"ids": before})); + drag(&mut s, from, (from.0 + 10.0, from.1 + 10.0), NONE); + assert_eq!(sorted(s.selection()), sorted(after), "{why}"); + for (i, h) in [k.r, k.c, k.empty].iter().zip(&home) { + let d = if moved.contains(i) { 10.0 } else { 0.0 }; + assert_eq!(at(&s, *i), Point::new(h.x + d, h.y + d), "{why}"); + } + if !moved.is_empty() { + assert!(s.undo().unwrap(), "{why}: one undo step"); + } + } + // A turned layer's box is turned with it: the corners of its page-aligned bounds are outside. + ex(&mut s, "node.set", json!({"ids": [k.r], "props": {"rotation": 45}})); + let b = s.doc().unwrap().world_bounds(k.r).unwrap(); + ex(&mut s, "select.set", json!({"ids": [k.r]})); + drag(&mut s, (b.x0 + 2.0, b.y0 + 2.0), (b.x0 + 12.0, b.y0 + 12.0), NONE); + assert_eq!(at(&s, k.r), b.origin(), "a press outside a turned layer's box is a marquee: it doesn't move the layer"); + ex(&mut s, "select.set", json!({"ids": [k.r]})); + let c = b.center(); + drag(&mut s, (c.x, c.y), (c.x + 10.0, c.y + 10.0), NONE); + assert_eq!(s.selection(), vec![k.r], "inside the turned box it drags"); + // Pixel snapping rounds the turned box's origin, so the move is about the pointer's. + let d = at(&s, k.r) - b.origin(); + assert!((d.x - 10.0).abs() < 2.0 && (d.y - 10.0).abs() < 2.0, "it moves with the pointer: {d:?}"); +} + #[test] fn alt_shift_drag_on_a_selected_layer_duplicates_it() { let mut s = Session::new(); diff --git a/crates/engine/src/shortcut_parity_tests.rs b/crates/engine/src/shortcut_parity_tests.rs index c0d7e65e..cb2faf13 100644 --- a/crates/engine/src/shortcut_parity_tests.rs +++ b/crates/engine/src/shortcut_parity_tests.rs @@ -171,8 +171,8 @@ static ROWS: &[Row] = &[ doc(Zoom, "Zoom out", &["Cmd+-", "-"], &[], Bound("view.zoomOut"), ""), doc(Zoom, "Zoom to fit", &["Shift+1"], &[], Bound("view.zoomFit"), ""), doc(Zoom, "Zoom to the selection", &["Shift+2"], &[], Bound("view.zoomSelection"), ""), - pin(Zoom, "view.zoomIn", &["Shift+Cmd+="], &[]), - pin(Zoom, "view.zoomOut", &["Shift+-"], &[]), + pin(Zoom, "view.zoomIn", &["Shift+Cmd+=", "="], &[]), + pin(Zoom, "view.zoomOut", &["Shift+-", "Shift+Cmd+-"], &[]), pin(Zoom, "view.zoom100", &["Shift+0", "Cmd+0"], &[]), pin(Zoom, "view.prevFrame", &["Shift+N", "Home"], &[]), pin(Zoom, "view.nextFrame", &["N", "End"], &[]), diff --git a/crates/engine/src/styles_behaviour_tests.rs b/crates/engine/src/styles_behaviour_tests.rs index ff7af50b..de9da5a5 100644 --- a/crates/engine/src/styles_behaviour_tests.rs +++ b/crates/engine/src/styles_behaviour_tests.rs @@ -80,6 +80,20 @@ fn link(s: &Session, n: NodeId, kind: &str) -> Option { } } +/// The key linking a style of `kind` (a paint style links fills). +fn style_key(kind: &str) -> String { + if kind == "paint" { "fillStyle".into() } else { format!("{kind}Style") } +} + +/// `node.create` params for an unselected layer that takes `kind` styles, with `props`. +fn new_layer(kind: &str, props: Value) -> Value { + if kind == "text" { + json!({"type": "text", "text": "Hej", "select": false, "props": props}) + } else { + json!({"type": "frame", "select": false, "props": props}) + } +} + #[test] fn a_style_holds_the_whole_property_and_links_its_source() { // Documented: colour styles can hold several fills (gradients, images); effect styles combine @@ -234,6 +248,59 @@ fn editing_a_styled_property_detaches_that_style_only() { let _ = st; } +#[test] +fn a_style_link_wins_over_values_in_the_same_call() { + // Docs silent on one call setting both (a linked layer shows its style's values; editing one + // detaches it); pinned: the link wins whatever the keys are called, on node.create and + // node.set, and a deviation set in a later call unlinks as before. + let mut s = Session::new(); + let rows = styled(&mut s); + let pairs: [(&str, &str, Value); 12] = [ + ("paint", "fill", json!("#123456")), + ("paint", "fills", json!(["#123456"])), + ("stroke", "stroke", json!("#123456")), + ("stroke", "strokes", json!(["#123456"])), + ("effect", "effects", json!([])), + ("grid", "grids", json!([])), + ("grid", "layoutGrids", json!([])), + ("text", "fontStyle", json!("Bold")), + ("text", "lineHeight", json!(40)), + ("text", "textDecoration", json!("underline")), + ("text", "letterSpacing", json!(5)), + ("text", "variationSettings", json!({"wght": 700})), + ]; + for (kind, key, value) in pairs { + let (source, st, _) = rows.iter().find(|r| r.2 == kind).unwrap().clone(); + let link_key = style_key(kind); + let props = json!({key: value.clone(), link_key.as_str(): st.clone()}); + let made = id(run(&mut s, "node.create", new_layer(kind, props.clone()))); + let set = if kind == "text" { text(&mut s, 400.0) } else { rect(&mut s, 400.0) }; + run(&mut s, "node.set", json!({"id": set, "props": props})); + for (how, layer) in [("node.create", made), ("node.set", set)] { + assert_eq!(link(&s, layer, kind).as_deref(), Some(st.as_str()), "{how} `{key}` + `{link_key}`: linked"); + assert_eq!(shown(&s, layer, kind), shown(&s, source, kind), "{how} `{key}` + `{link_key}`: the style's values"); + } + run(&mut s, "node.set", json!({"id": set, "props": {key: value}})); + assert_eq!(link(&s, set, kind), None, "`{key}` in a later call unlinks"); + assert_ne!(shown(&s, set, kind), shown(&s, source, kind), "`{key}` in a later call applies"); + } +} + +#[test] +fn a_style_link_leaves_values_its_style_does_not_set() { + // Docs silent; pinned: alignment given beside a text style applies, on node.create and node.set. + let mut s = Session::new(); + let st = styled(&mut s).into_iter().find(|r| r.2 == "text").unwrap().1; + let props = json!({"textStyle": st, "textAlign": "center"}); + let made = id(run(&mut s, "node.create", new_layer("text", props.clone()))); + let set = text(&mut s, 400.0); + run(&mut s, "node.set", json!({"id": set, "props": props})); + for (how, layer) in [("node.create", made), ("node.set", set)] { + assert_eq!(link(&s, layer, "text").as_deref(), Some(st.as_str()), "{how}: linked"); + assert_eq!(node(&s, layer).kind.text().unwrap().align_h, figcraft_doc::TextAlignH::Center, "{how}: `textAlign` applies"); + } +} + #[test] fn applying_a_style_replaces_variables_bound_to_that_property() { // Documented: applying a style sets the property to the style's values. Docs silent on bound diff --git a/crates/engine/src/synthetic.rs b/crates/engine/src/synthetic.rs index c2a9f5d6..d5ead671 100644 --- a/crates/engine/src/synthetic.rs +++ b/crates/engine/src/synthetic.rs @@ -28,6 +28,53 @@ fn fill(doc: &mut Document, id: NodeId, c: Color) { } } +fn add_screen_header(doc: &mut Document, screen: NodeId, s: usize) -> DocResult<()> { + let header = doc.create(screen, NodeKind::Frame, "Header", 0.0, 0.0, SCREEN_W, 40.0)?; + if let Some(n) = doc.node_mut(header) { + n.layout = Some(AutoLayout { gap: 8.0, padding: [8.0; 4], ..AutoLayout::default() }); + n.fills = vec![Paint::solid(Color::from_rgb8_const(0xF5, 0xF5, 0xF5))]; + } + doc.create(header, text(&format!("Screen {}", s + 1)), "Title", 0.0, 0.0, 120.0, 16.0)?; + for k in 0..2 { + let icon = doc.create(header, ellipse(), &format!("Icon {}", k + 1), 0.0, 0.0, 20.0, 20.0)?; + fill(doc, icon, Color::from_rgb8_const(0x99, 0x99, 0x99)); + } + Ok(()) +} + +fn add_card_group(doc: &mut Document, screen: NodeId) -> DocResult<()> { + let card = doc.create(screen, NodeKind::Group, "Card", 12.0, 52.0, 160.0, 64.0)?; + // Children are in the group's space; normalising fits the group around them. + let cbg = doc.create(card, NodeKind::Rectangle, "Card background", 0.0, 0.0, 160.0, 64.0)?; + fill(doc, cbg, Color::from_rgb8_const(0xEE, 0xF4, 0xFF)); + doc.create(card, ellipse(), "Avatar", 8.0, 8.0, 32.0, 32.0)?; + doc.create(card, text("Ada Lovelace"), "Name", 48.0, 8.0, 100.0, 16.0)?; + doc.create(card, text("Analyst"), "Role", 48.0, 28.0, 100.0, 16.0)?; + Ok(()) +} + +fn add_screen_grid(doc: &mut Document, screen: NodeId, s: usize, rest: usize, made: &mut usize) -> DocResult<()> { + for k in 0..rest { + let (gx, gy) = (184.0 + (k % 8) as f64 * 21.0, 52.0 + (k / 8) as f64 * 34.0); + let hue = ((s * PER_SCREEN + k) % 360) as f32 / 360.0; + // The grid starts with a rectangle so even the smallest workload has one. + let kind = (k + 2) % 5; + let id = match kind { + 0 => doc.create(screen, text(&format!("Item {k}")), "Text", gx, gy, 20.0, 14.0)?, + 1 | 3 => doc.create(screen, ellipse(), "Ellipse", gx, gy, 18.0, 18.0)?, + _ => doc.create(screen, NodeKind::Rectangle, "Rectangle", gx, gy, 18.0, 18.0)?, + }; + if kind != 0 { + fill(doc, id, Color::new(hue, 0.5, 1.0 - hue, 1.0)); + if let Some(n) = doc.node_mut(id) { + n.corners.radii = [3.0; 4]; + } + } + *made += 1; + } + Ok(()) +} + /// A page of about `layers` layers (pages not counted), normalised like an opened file. Returns /// the document and its page. pub fn large_document(layers: usize) -> DocResult<(Document, NodeId)> { @@ -53,28 +100,13 @@ pub fn large_document(layers: usize) -> DocResult<(Document, NodeId)> { let budget = |made: usize| layers.saturating_sub(made); // Auto layout header: title + two icons (4 layers). if budget(made) >= 4 { - let header = doc.create(screen, NodeKind::Frame, "Header", 0.0, 0.0, SCREEN_W, 40.0)?; - if let Some(n) = doc.node_mut(header) { - n.layout = Some(AutoLayout { gap: 8.0, padding: [8.0; 4], ..AutoLayout::default() }); - n.fills = vec![Paint::solid(Color::from_rgb8_const(0xF5, 0xF5, 0xF5))]; - } - doc.create(header, text(&format!("Screen {}", s + 1)), "Title", 0.0, 0.0, 120.0, 16.0)?; - for k in 0..2 { - let icon = doc.create(header, ellipse(), &format!("Icon {}", k + 1), 0.0, 0.0, 20.0, 20.0)?; - fill(&mut doc, icon, Color::from_rgb8_const(0x99, 0x99, 0x99)); - } + add_screen_header(&mut doc, screen, s)?; made += 4; } // A card group (5 layers). // The card and the instances leave one layer for the grid, which starts with a rectangle. if budget(made) > 5 { - let card = doc.create(screen, NodeKind::Group, "Card", 12.0, 52.0, 160.0, 64.0)?; - // Children are in the group's space; normalising fits the group around them. - let cbg = doc.create(card, NodeKind::Rectangle, "Card background", 0.0, 0.0, 160.0, 64.0)?; - fill(&mut doc, cbg, Color::from_rgb8_const(0xEE, 0xF4, 0xFF)); - doc.create(card, ellipse(), "Avatar", 8.0, 8.0, 32.0, 32.0)?; - doc.create(card, text("Ada Lovelace"), "Name", 48.0, 8.0, 100.0, 16.0)?; - doc.create(card, text("Analyst"), "Role", 48.0, 28.0, 100.0, 16.0)?; + add_card_group(&mut doc, screen)?; made += 5; } // Three button instances (3 layers each). @@ -87,24 +119,7 @@ pub fn large_document(layers: usize) -> DocResult<(Document, NodeId)> { } // Fill the rest of the screen with a grid of shapes and text. let rest = PER_SCREEN.saturating_sub(4 + 5 + 9 + 1).min(budget(made)); - for k in 0..rest { - let (gx, gy) = (184.0 + (k % 8) as f64 * 21.0, 52.0 + (k / 8) as f64 * 34.0); - let hue = ((s * PER_SCREEN + k) % 360) as f32 / 360.0; - // The grid starts with a rectangle so even the smallest workload has one. - let kind = (k + 2) % 5; - let id = match kind { - 0 => doc.create(screen, text(&format!("Item {k}")), "Text", gx, gy, 20.0, 14.0)?, - 1 | 3 => doc.create(screen, ellipse(), "Ellipse", gx, gy, 18.0, 18.0)?, - _ => doc.create(screen, NodeKind::Rectangle, "Rectangle", gx, gy, 18.0, 18.0)?, - }; - if kind != 0 { - fill(&mut doc, id, Color::new(hue, 0.5, 1.0 - hue, 1.0)); - if let Some(n) = doc.node_mut(id) { - n.corners.radii = [3.0; 4]; - } - } - made += 1; - } + add_screen_grid(&mut doc, screen, s, rest, &mut made)?; if budget(made) == 0 { break; } diff --git a/crates/engine/src/templates/kit.rs b/crates/engine/src/templates/kit.rs index 4ad5b11a..39bd248b 100644 --- a/crates/engine/src/templates/kit.rs +++ b/crates/engine/src/templates/kit.rs @@ -216,7 +216,7 @@ impl Kit { pub fn variables(&mut self, collection: &str, modes: &[&str], vars: Vec<(&str, &str, Vec)>) -> Result<()> { self.run("variable.createCollection", json!({"name": collection, "modes": modes}))?; for (name, kind, values) in vars { - let values: Map = modes.iter().map(|m| m.to_string()).zip(values).collect(); + let values: Map = modes.iter().map(ToString::to_string).zip(values).collect(); self.run("variable.create", json!({"collection": collection, "name": name, "type": kind, "values": values}))?; } Ok(()) diff --git a/crates/engine/src/text_behaviour_tests.rs b/crates/engine/src/text_behaviour_tests.rs index b9a76fbc..d88066cb 100644 --- a/crates/engine/src/text_behaviour_tests.rs +++ b/crates/engine/src/text_behaviour_tests.rs @@ -134,6 +134,58 @@ fn ime_composition_with_cjk() { assert!(s.ds().unwrap().text_edit.as_ref().unwrap().preedit.is_none()); } +#[test] +fn ime_delete_surrounding_bytes_is_exact_and_one_undo_step() { + // 0.37's `DeleteSurroundingBytes` counts UTF-8 bytes around the selection, before the paired + // commit. Multibyte CJK and a supplementary emoji must be removed exactly, not as tofu or + // extended to grapheme clusters, and the whole edit remains one undo step. + let mut s = Session::new(); + let t = text(&mut s, "ab\u{4F60}\u{597D}c\u{1F600}d", json!({"fontSize": 24})); + begin(&mut s, t, Some(4), false).unwrap(); + assert!(crate::textedit::delete_surrounding_bytes(&mut s, 6, 1).unwrap(), "你好 before and c after"); + assert_eq!(chars(&s, t), "ab\u{1F600}d"); + assert_eq!(caret(&s), (2, 2)); + crate::textedit::set_caret(&mut s, 3, false).unwrap(); + assert!(crate::textedit::delete_surrounding_bytes(&mut s, 4, 0).unwrap(), "the emoji is four bytes"); + assert_eq!(chars(&s, t), "abd"); + assert_eq!(caret(&s), (2, 2)); + // Past the start of the text: nothing more to delete, not an error. + assert!(!crate::textedit::delete_surrounding_bytes(&mut s, 0, 0).unwrap()); + crate::textedit::end(&mut s).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(chars(&s, t), "ab\u{4F60}\u{597D}c\u{1F600}d", "one undo restores the whole session"); +} + +#[test] +fn ime_delete_surrounding_bytes_keeps_partial_characters_and_selection() { + // The fork deletes the byte range exactly and leaves a character the range only partly + // covers. FigCraft's editor counts characters with the same rule (mirroring egui's + // `surrounding_bytes_to_chars`), including around a selection. + let mut s = Session::new(); + let t = text(&mut s, "\u{e9}x\u{1F600}", json!({"fontSize": 24})); + let del = |s: &mut Session, b: usize, a: usize| crate::textedit::delete_surrounding_bytes(s, b, a).unwrap(); + begin(&mut s, t, Some(1), false).unwrap(); + assert!(!del(&mut s, 1, 0), "one byte ends inside 'é' (two bytes): leave it"); + assert_eq!(chars(&s, t), "\u{e9}x\u{1F600}"); + crate::textedit::set_caret(&mut s, 2, false).unwrap(); + assert!(!del(&mut s, 0, 2), "two bytes start inside the four-byte emoji: leave it"); + assert_eq!(chars(&s, t), "\u{e9}x\u{1F600}"); + assert!(del(&mut s, 0, 4), "its four bytes cover it exactly"); + assert_eq!(chars(&s, t), "\u{e9}x"); + assert_eq!(caret(&s), (2, 2)); + // Complete bytes before the selection go; a character the range only partly covers stays, + // and the selection itself is kept for the paired commit. + crate::textedit::set_caret(&mut s, 1, false).unwrap(); + crate::textedit::set_caret(&mut s, 1, true).unwrap(); + crate::textedit::set_caret(&mut s, 2, true).unwrap(); + assert!(del(&mut s, 2, 2), "é before (two bytes); two bytes start inside the four-byte emoji"); + // The selected 'x' is kept for the paired commit, as in stock `TextEdit`; the caret moves left + // with the deleted prefix and the selection direction is preserved. + assert_eq!(chars(&s, t), "x"); + assert_eq!(caret(&s), (0, 1)); + crate::textedit::end(&mut s).unwrap(); +} + #[test] fn vertical_trim_through_node_set() { let mut s = Session::new(); diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/textedit.rs index 85ea535b..bf8dd940 100644 --- a/crates/engine/src/textedit.rs +++ b/crates/engine/src/textedit.rs @@ -1,9 +1,83 @@ //! On-canvas text editing: caret, selection, typing. A whole editing session is one undo step. +use std::sync::{LazyLock, Mutex}; + use figcraft_doc::{Document, ListStyle, NodeId, NodeKind, TextData}; use crate::{EngineError, Preedit, Result, Session, TextEdit}; +/// Most committed UTF-8 bytes an outgoing IME snapshot may describe. +/// +/// This mirrors the fork's `egui::output::MAX_IME_SURROUNDING_TEXT_BYTES`; the engine keeps its +/// own constant so this layer has no egui dependency. The query below returns only this bounded +/// window around the selection, never a copy of the whole document. +pub const MAX_IME_SURROUNDING_BYTES: usize = 4000; + +/// Stable identity of the on-canvas text edit an IME context belongs to. +/// +/// `document` and `node` do not change through ordinary text or caret edits. `editing_session` +/// changes when [`begin`] starts a new session for an equal `(document, node)` pair (leaving and +/// re-entering the same layer), so a replaced target never inherits an old composition. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ImeEditOwner { + /// The document tab's uid. + pub document: u64, + /// The edited text node. + pub node: NodeId, + /// [`TextEdit::editing_session`] of the current edit. + pub editing_session: u64, +} + +/// Why the committed text around the IME caret cannot be described. +/// +/// These are the only reasons this custom editor reports +/// `egui::output::IMESurroundingText::Unavailable`; an empty text layer is available with empty +/// text and a collapsed selection. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ImeSurroundingUnavailable { + /// No text layer is being edited. + NotEditing, + /// The editing target is not a text node any more. + NotText, + /// The selection alone is wider than [`MAX_IME_SURROUNDING_BYTES`], so no bounded snapshot + /// can contain both selection ends. + SelectionTooWide, + /// Defensive: the computed window was not a character-boundary slice. The rounding above + /// makes this unreachable for a well-formed text model. + InvalidWindow, +} + +/// One bounded, committed logical-text observation for the outgoing IME snapshot. +/// +/// `text` is never display/bidi order and never contains the pre-edit (which is UI-only in this +/// editor). `cursor` and `anchor` are UTF-8 byte offsets into `text`; `anchor > cursor` is a +/// valid reversed selection. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ImeSurrounding { + /// Identity of the edit this observation belongs to. + pub owner: ImeEditOwner, + /// At most [`MAX_IME_SURROUNDING_BYTES`] bytes of committed text around the selection. + pub text: String, + /// UTF-8 byte offset of the primary caret in [`Self::text`]. + pub cursor: usize, + /// UTF-8 byte offset of the selection anchor in [`Self::text`]. + pub anchor: usize, +} + +/// Every editing session gets its own token; zero is valid and never reused deliberately. +/// +/// A mutex (rather than an atomic) also works on wasm32 targets without 64-bit atomics. +static EDITING_SESSION: LazyLock> = LazyLock::new(|| Mutex::new(0)); + +fn next_editing_session() -> u64 { + let mut session = match EDITING_SESSION.lock() { + Ok(session) => session, + Err(poisoned) => poisoned.into_inner(), + }; + *session = session.wrapping_add(1); + *session +} + fn text_of(s: &Session, id: NodeId) -> Result { match &s.doc()?.get(id)?.kind { NodeKind::Text(t) => Ok(t.clone()), @@ -21,7 +95,7 @@ pub fn begin(s: &mut Session, id: NodeId, caret: Option, created: bool) - Some(c) => (c.min(n), c.min(n)), None => (n, 0), }; - ds.text_edit = Some(TextEdit { id, caret, anchor, created, preedit: None }); + ds.text_edit = Some(TextEdit { id, caret, anchor, created, editing_session: next_editing_session(), preedit: None }); ds.selection = vec![id]; ds.revision += 1; s.live_text_notice(id); @@ -64,13 +138,29 @@ fn range(te: &TextEdit) -> (usize, usize) { } fn edit(s: &mut Session, f: impl FnOnce(&mut TextData, &mut TextEdit)) -> Result<()> { + edit_if(s, |t, te| { + f(t, te); + true + }) + .map(|_| ()) +} + +/// [`edit`] for a mutation that can still turn out to be a no-op. +/// +/// The closure returns whether it changed the model; return `false` only before mutating (the +/// text is handed over directly). A no-op leaves the composition (`preedit`), the revision and the +/// document untouched, so an IME deletion that finds nothing to delete does not cancel an +/// in-progress composition. +fn edit_if(s: &mut Session, f: impl FnOnce(&mut TextData, &mut TextEdit) -> bool) -> Result { let ds = s.ds_mut()?; let Some(mut te) = ds.text_edit.clone() else { return Err(EngineError::Other("not editing text".into())) }; let before = ds.doc.clone(); let doc = ds.doc_mut(); let n = doc.get_mut(te.id)?; let NodeKind::Text(t) = &mut n.kind else { return Err(EngineError::Other("not a text layer".into())) }; - f(t, &mut te); + if !f(t, &mut te) { + return Ok(false); + } // Any edit ends a composition (a commit replaces it with the final text). te.preedit = None; let len = t.char_count(); @@ -82,16 +172,78 @@ fn edit(s: &mut Session, f: impl FnOnce(&mut TextData, &mut TextEdit)) -> Result crate::normalize(doc, &before); ds.text_edit = Some(te); ds.revision += 1; - Ok(()) + Ok(true) +} + +/// What an accepted IME `Commit` did to the committed model. +/// +/// An IME receipt may label the snapshot `InputMethod` only for [`Self::Plain`]. AutoFormat +/// (typing "- " at a line start turns the paragraph into a list) is an application +/// transformation in the same command, so the frame's change must be reported as `Other`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ImeInsertion { + /// Nothing was inserted (an empty, control-only, or bare-newline commit). + Nothing, + /// The committed text replaced the selection exactly. + Plain, + /// The editor transformed the commit before applying it: paragraph AutoFormat, or line-ending + /// normalisation/control-character removal. The frame's change is application-origin. + Transformed, } /// Type (or paste) `text` replacing the selection. Line endings are normalised and other control /// characters dropped, so pasted plain text from any platform lands cleanly. pub fn insert(s: &mut Session, text: &str) -> Result<()> { - let text: String = crate::cmd::props::normalize_line_endings(text).chars().filter(|c| !c.is_control() || matches!(c, '\n' | '\t')).collect(); - if text.is_empty() { + insert_model(s, text, false).map(|_| ()) +} + +/// End the composition without changing the document, for an empty IME commit. +/// +/// Returns without touching the session when no composition is active (or nothing is being +/// edited): an empty commit is otherwise a no-op, never an error. +fn clear_composition(s: &mut Session) -> Result<()> { + let composing = s.ds().ok().and_then(|d| d.text_edit.as_ref()).is_some_and(|te| te.preedit.is_some()); + if !composing { return Ok(()); } + let ds = s.ds_mut()?; + if let Some(te) = ds.text_edit.as_mut() { + te.preedit = None; + } + ds.revision += 1; + Ok(()) +} + +/// Apply an IME `Commit`, reporting whether it was a plain insertion. +/// +/// The UI calls this instead of [`insert`] so only a commit that the editor applied as a plain +/// insertion can earn an `InputMethod` receipt. AutoFormat, line-ending normalisation and +/// control-character removal are application transformations and are reported as +/// [`ImeInsertion::Transformed`]. Like stock `TextEdit`, a bare `"\n"`/`"\r"` commit is ignored. +pub fn insert_ime_commit(s: &mut Session, text: &str) -> Result { + insert_model(s, text, true) +} + +fn insert_model(s: &mut Session, text: &str, ime_commit: bool) -> Result { + // Stock `TextEdit` deliberately ignores these two commits (they are not a mutation); only a + // real commit may do so, since typing Enter goes through `insert`. + if ime_commit && matches!(text, "\n" | "\r") { + return Ok(ImeInsertion::Nothing); + } + let normalized: String = + crate::cmd::props::normalize_line_endings(text).chars().filter(|c| !c.is_control() || matches!(c, '\n' | '\t')).collect(); + if normalized.is_empty() { + // An empty commit is how some integrations (Safari's egui web integration when the last + // composition character is deleted) end the composition. It inserts nothing, but it must + // still clear the pre-edit; stock `TextEdit` does the same in its non-ignored `Commit` arm. + if ime_commit { + clear_composition(s)?; + } + return Ok(ImeInsertion::Nothing); + } + // A commit the editor had to transform is an application edit, not a plain insertion. + let mut outcome = if normalized == text { ImeInsertion::Plain } else { ImeInsertion::Transformed }; + let text = normalized; edit(s, |t, te| { let (a, b) = range(te); // "- ", "* " or "1. " typed at the start of a plain paragraph starts a list. @@ -108,13 +260,15 @@ pub fn insert(s: &mut Session, text: &str) -> Result<()> { t.replace_range(start, a, ""); t.set_para_lists(start, start, |pl| pl.list = list); (te.caret, te.anchor) = (start, start); + outcome = ImeInsertion::Transformed; return; } } t.replace_range(a, b, &text); te.caret = a + text.chars().count(); te.anchor = te.caret; - }) + })?; + Ok(outcome) } /// List keys: Tab nests the selected list items a level deeper; Shift+Tab, Backspace at an item's @@ -339,6 +493,160 @@ fn select(s: &mut Session, anchor: usize, caret: usize) -> Result<()> { Ok(()) } +/// Delete the text surrounding the caret/selection that an IME reports with +/// [`egui::ImeEvent::DeleteSurroundingBytes`]: `before_bytes` UTF-8 bytes end where the selection +/// starts and `after_bytes` start where it ends. As in stock `TextEdit`, the selected text itself +/// is kept for the paired `Commit`; only whole characters covered by the byte counts beyond the +/// selection are removed (a character the range only partly covers stays). Returns whether +/// anything was deleted. +pub fn delete_surrounding_bytes(s: &mut Session, before_bytes: usize, after_bytes: usize) -> Result { + let Some(te) = s.ds()?.text_edit.clone() else { return Ok(false) }; + let t = text_of(s, te.id)?; + let (a, b) = range(&te); + let (before, after) = surrounding_chars(&t.characters, a, b, before_bytes, after_bytes); + if before == 0 && after == 0 { + return Ok(false); + } + edit_if(s, |t, te| { + let (a, b) = range(te); + let (before, after) = surrounding_chars(&t.characters, a, b, before_bytes, after_bytes); + if before == 0 && after == 0 { + return false; + } + // Delete after the selection first so the before range's indices stay valid. + if after > 0 { + t.replace_range(b, b + after, ""); + } + if before > 0 { + t.replace_range(a - before, a, ""); + } + // Keep the selection: both ends move left by the deleted prefix, reversed or not. + te.caret = te.caret.saturating_sub(before); + te.anchor = te.anchor.saturating_sub(before); + true + }) +} + +/// Delete surrounding text counted in characters (the older `DeleteSurrounding` event). The +/// selection is kept, as in stock `TextEdit`; only text before and after it is removed. +pub fn delete_surrounding_chars(s: &mut Session, before_chars: usize, after_chars: usize) -> Result { + let Some(te) = s.ds()?.text_edit.clone() else { return Ok(false) }; + let t = text_of(s, te.id)?; + let (a, b) = range(&te); + let (_, b_byte) = char_byte_offsets(&t.characters, a, b); + let before = before_chars.min(a); + let after = t.characters.get(b_byte..).map_or(0, |rest| rest.chars().take(after_chars).count()); + if before == 0 && after == 0 { + return Ok(false); + } + edit_if(s, |t, te| { + let (a, b) = range(te); + let (_, b_byte) = char_byte_offsets(&t.characters, a, b); + let before = before_chars.min(a); + let after = t.characters.get(b_byte..).map_or(0, |rest| rest.chars().take(after_chars).count()); + if before == 0 && after == 0 { + return false; + } + if after > 0 { + t.replace_range(b, b + after, ""); + } + if before > 0 { + t.replace_range(a - before, a, ""); + } + te.caret = te.caret.saturating_sub(before); + te.anchor = te.anchor.saturating_sub(before); + true + }) +} + +/// The UTF-8 byte offsets of character indices `a` and `b` in one forward pass. +/// +/// An index at or past the end maps to `text.len()`. Walking once (instead of twice) matters on +/// every IME event over a large text layer; the walk is still O(max(a, b)) characters, and callers +/// that need the end still pay O(n). See [`ime_surrounding`]. +fn char_byte_offsets(text: &str, a: usize, b: usize) -> (usize, usize) { + let (mut a_byte, mut b_byte) = (None, None); + for (i, (byte, _)) in text.char_indices().enumerate() { + if a_byte.is_none() && i == a { + a_byte = Some(byte); + } + if b_byte.is_none() && i == b { + b_byte = Some(byte); + } + if a_byte.is_some() && b_byte.is_some() { + break; + } + } + (a_byte.unwrap_or(text.len()), b_byte.unwrap_or(text.len())) +} + +/// `(before, after)` characters covered by the IME's byte counts around the selection `[a, b)` +/// (character indices): mirror of egui's own `surrounding_bytes_to_chars`. +fn surrounding_chars(text: &str, a: usize, b: usize, before_bytes: usize, after_bytes: usize) -> (usize, usize) { + let (a_byte, b_byte) = char_byte_offsets(text, a, b); + let from = a_byte.saturating_sub(before_bytes); + let before = text[..a_byte].char_indices().rev().take_while(|(i, _)| *i >= from).count(); + let after = text[b_byte..].char_indices().take_while(|(i, c)| i + c.len_utf8() <= after_bytes).count(); + (before, after) +} + +/// The committed text around the IME caret for the outgoing surrounding-text snapshot. +/// +/// This is the edited node's logical model text (never the bidi display order), with the +/// pre-edit excluded because a composition is UI-only in this editor until it is committed. +/// Character caret/anchor indices become independent UTF-8 byte offsets, so a reversed +/// selection stays reversed. At most [`MAX_IME_SURROUNDING_BYTES`] bytes are copied: the window +/// is centred on the selection and rounded inward to character boundaries. An empty text layer +/// is `Ok` with `text == ""` and both offsets zero; the result is [`ImeSurroundingUnavailable`] +/// only when there is no text edit, the target is not a text node, or the selection alone is +/// wider than the budget. +/// +/// Cost: one character walk up to the later of the two cursor indices (to turn char indices into +/// UTF-8 bytes) plus the bounded copy. The caret is a character index, so reaching a caret near +/// the end of a very large layer is still O(n); the UI calls this once per pass and twice around +/// each text-affecting event (`ime_stamp`), so such a layer pays O(caret/anchor) per event. The +/// document is never cloned here. +pub fn ime_surrounding(s: &Session) -> std::result::Result { + let ds = s.ds().map_err(|_| ImeSurroundingUnavailable::NotEditing)?; + let te = ds.text_edit.as_ref().ok_or(ImeSurroundingUnavailable::NotEditing)?; + let node = ds.doc.node(te.id).ok_or(ImeSurroundingUnavailable::NotText)?; + let NodeKind::Text(t) = &node.kind else { return Err(ImeSurroundingUnavailable::NotText) }; + let text = t.characters.as_str(); + // One pass for both offsets (the caret and anchor are already clamped by `edit`/`select`). + let (cursor, anchor) = char_byte_offsets(text, te.caret, te.anchor); + let (selection_start, selection_end) = (cursor.min(anchor), cursor.max(anchor)); + let selection_len = selection_end - selection_start; + if selection_len > MAX_IME_SURROUNDING_BYTES { + return Err(ImeSurroundingUnavailable::SelectionTooWide); + } + // Spend the budget on both sides, giving room unused on one side to the other. + let budget = MAX_IME_SURROUNDING_BYTES - selection_len; + let before_available = selection_start; + let after_available = text.len() - selection_end; + let mut before = (budget / 2).min(before_available); + let mut after = (budget - before).min(after_available); + before = (budget - after).min(before_available); + after = (budget - before).min(after_available); + let mut window_start = selection_start - before; + let mut window_end = selection_end + after; + // Round inward so the window stays within the byte budget and on character boundaries. + while window_start < window_end && !text.is_char_boundary(window_start) { + window_start += 1; + } + while window_end > window_start && !text.is_char_boundary(window_end) { + window_end -= 1; + } + let Some(window) = text.get(window_start..window_end) else { + return Err(ImeSurroundingUnavailable::InvalidWindow); + }; + Ok(ImeSurrounding { + owner: ImeEditOwner { document: ds.uid, node: te.id, editing_session: te.editing_session }, + text: window.to_owned(), + cursor: cursor.saturating_sub(window_start), + anchor: anchor.saturating_sub(window_start), + }) +} + /// Show an IME composition inline at the caret (`cursor` = the IME's cursor inside it, in chars; /// default its end). The document doesn't change until the composition is committed with /// [`insert`]; an empty `text` cancels it. Starting to compose over a selection deletes it, as @@ -596,10 +904,16 @@ mod tests { s.pointer(&ev(PointerKind::Up, mid, y, false)).unwrap(); s.pointer(&ev(PointerKind::DoubleClick, mid, y, false)).unwrap(); assert_eq!(sel(&s), (0, 11)); + let epoch = s.ds().unwrap().text_edit.as_ref().unwrap().editing_session; s.pointer(&ev(PointerKind::Down, mid, y, false)).unwrap(); s.pointer(&ev(PointerKind::DoubleClick, mid, y, false)).unwrap(); s.pointer(&ev(PointerKind::Up, mid, y, false)).unwrap(); assert_eq!(sel(&s), (6, 11)); + assert_eq!( + s.ds().unwrap().text_edit.as_ref().unwrap().editing_session, + epoch, + "a double-click inside the edit is an ordinary edit: same IME replacement epoch" + ); // Drag-select from the start of "Hello" to the middle. s.pointer(&ev(PointerKind::Down, x_of(0) + 0.5, y, false)).unwrap(); assert_eq!(sel(&s), (0, 0)); @@ -811,4 +1125,184 @@ mod tests { } } } + + #[test] + fn ime_surrounding_uses_logical_utf8_bytes_and_keeps_selection_direction() { + let (mut s, id) = setup("café😀e\u{301}x"); + begin(&mut s, id, Some(8), false).unwrap(); + set_caret(&mut s, 8, false).unwrap(); + set_caret(&mut s, 5, true).unwrap(); // anchor after the emoji, caret before it + let ime = ime_surrounding(&s).unwrap(); + assert_eq!(ime.text, "café😀e\u{301}x"); + assert_eq!((ime.cursor, ime.anchor), (9, 13), "char 5 and char 8 as UTF-8 bytes"); + assert!(ime.cursor < ime.anchor, "the reversed selection stays reversed"); + assert_eq!(ime.owner.node, id); + assert_eq!(ime.owner.document, s.ds().unwrap().uid); + } + + #[test] + fn ime_surrounding_excludes_the_composition_and_empty_text_is_available() { + let (mut s, id) = setup("Hi"); + begin(&mut s, id, Some(2), false).unwrap(); + preedit(&mut s, "にほ", Some(1)).unwrap(); + let ime = ime_surrounding(&s).unwrap(); + assert_eq!(ime.text, "Hi", "the composition is not committed text"); + assert_eq!((ime.cursor, ime.anchor), (2, 2)); + end(&mut s).unwrap(); + + let (mut s, id) = setup(""); + begin(&mut s, id, Some(0), false).unwrap(); + let ime = ime_surrounding(&s).unwrap(); + assert_eq!((ime.text.as_str(), ime.cursor, ime.anchor), ("", 0, 0), "an empty layer is available, not unavailable"); + } + + #[test] + fn ime_surrounding_is_bounded_and_a_too_wide_selection_is_explicitly_unavailable() { + let long = "a".repeat(5000); + let (mut s, id) = setup(&long); + begin(&mut s, id, Some(5000), false).unwrap(); + let ime = ime_surrounding(&s).unwrap(); + assert_eq!(ime.text.len(), MAX_IME_SURROUNDING_BYTES); + assert_eq!((ime.cursor, ime.anchor), (MAX_IME_SURROUNDING_BYTES, MAX_IME_SURROUNDING_BYTES)); + + // Exactly the budget is still describable; one more byte is not. + set_caret(&mut s, 1000, false).unwrap(); + set_caret(&mut s, 5000, true).unwrap(); + let ime = ime_surrounding(&s).unwrap(); + assert_eq!(ime.text.len(), 4000); + assert_eq!((ime.cursor, ime.anchor), (4000, 0)); + set_caret(&mut s, 999, false).unwrap(); + set_caret(&mut s, 5000, true).unwrap(); + assert_eq!(ime_surrounding(&s), Err(ImeSurroundingUnavailable::SelectionTooWide)); + } + + #[test] + fn ime_owner_is_stable_through_edits_and_the_session_changes_on_begin() { + let (mut s, id) = setup("Hello"); + begin(&mut s, id, Some(0), false).unwrap(); + let first = ime_surrounding(&s).unwrap().owner; + insert(&mut s, "x").unwrap(); + set_caret(&mut s, 2, false).unwrap(); + assert_eq!(ime_surrounding(&s).unwrap().owner, first, "edits and caret moves keep the owner"); + end(&mut s).unwrap(); + begin(&mut s, id, Some(0), false).unwrap(); + let replaced = ime_surrounding(&s).unwrap().owner; + assert_eq!(replaced.node, id); + assert_ne!(replaced.editing_session, first.editing_session, "a new edit session is a replacement epoch"); + } + + #[test] + fn ime_delete_surrounding_bytes_keeps_the_reversed_selection_and_deletes_around_it() { + let (mut s, id) = setup("ab你好cd"); + begin(&mut s, id, Some(4), false).unwrap(); + set_caret(&mut s, 4, false).unwrap(); + set_caret(&mut s, 2, true).unwrap(); // reversed: caret before 你, anchor after 好 + assert!(delete_surrounding_bytes(&mut s, 1, 1).unwrap()); + // "b" (whole character before the selection) and "c" (whole character after it) go; the + // selected "你好" stays for the paired commit, and the reversed direction is preserved. + assert_eq!(data(&s, id).characters, "a你好d"); + assert_eq!(sel(&s), (3, 1)); // (anchor, caret), still reversed + assert!(delete_surrounding_bytes(&mut s, 1, 1).unwrap()); + assert_eq!(data(&s, id).characters, "你好"); + assert_eq!(sel(&s), (2, 0)); // (anchor, caret), still reversed + assert!(!delete_surrounding_bytes(&mut s, 1, 1).unwrap(), "nothing is left around the selection"); + assert_eq!(data(&s, id).characters, "你好", "a no-op delete does not touch the text"); + } + + #[test] + fn ime_delete_surrounding_chars_keeps_the_selection_and_ignores_a_no_op() { + let (mut s, id) = setup("ab你好cd"); + begin(&mut s, id, Some(2), false).unwrap(); + set_caret(&mut s, 4, true).unwrap(); // "你好" + assert!(delete_surrounding_chars(&mut s, 1, 1).unwrap()); + assert_eq!(data(&s, id).characters, "a你好d"); + assert_eq!(sel(&s), (1, 3), "the selection is kept, shifted by the deleted prefix"); + set_caret(&mut s, 0, false).unwrap(); + // A composition in progress survives a delete that finds nothing to remove. + preedit(&mut s, "x", None).unwrap(); + let revision = s.ds().unwrap().revision; + assert!(!delete_surrounding_chars(&mut s, 1, 0).unwrap()); + assert_eq!(s.ds().unwrap().text_edit.as_ref().and_then(|te| te.preedit.as_ref()).map(|p| p.text.as_str()), Some("x")); + assert_eq!(s.ds().unwrap().revision, revision, "a no-op does not bump the revision"); + assert!(!delete_surrounding_bytes(&mut s, 1, 0).unwrap()); + assert_eq!(s.ds().unwrap().text_edit.as_ref().and_then(|te| te.preedit.as_ref()).map(|p| p.text.as_str()), Some("x")); + assert_eq!(s.ds().unwrap().revision, revision); + } + + #[test] + fn ime_owner_identity_changes_with_the_document() { + let (mut s, id) = setup("Hi"); + begin(&mut s, id, Some(2), false).unwrap(); + let first = ime_surrounding(&s).unwrap().owner; + end(&mut s).unwrap(); + s.execute("file.new", &json!({"name": "Second"})).unwrap(); + let r = s.execute("text.create", &json!({"text": "There", "x": 0, "y": 0})).unwrap(); + let other = crate::params::id_of(&r["id"]).unwrap(); + begin(&mut s, other, Some(5), false).unwrap(); + let second = ime_surrounding(&s).unwrap().owner; + assert_ne!(second.document, first.document, "another document is another IME owner"); + assert_eq!(second.node, other); + } + + #[test] + fn ime_commit_reports_list_autoformat_as_an_application_transformation() { + let (mut s, id) = setup("-"); + begin(&mut s, id, Some(1), false).unwrap(); + assert_eq!(insert_ime_commit(&mut s, " ").unwrap(), ImeInsertion::Transformed); + assert_eq!(data(&s, id).characters, ""); + assert_eq!(data(&s, id).para_list(0).list, ListStyle::Bulleted); + assert_eq!(insert_ime_commit(&mut s, "x").unwrap(), ImeInsertion::Plain); + assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing); + } + + #[test] + fn ime_commit_normalisation_is_a_transformation_and_a_bare_newline_is_ignored() { + let (mut s, id) = setup("Hi"); + begin(&mut s, id, Some(2), false).unwrap(); + // CRLF is inserted as LF, but the editor changed the commit: application-origin. + assert_eq!(insert_ime_commit(&mut s, "a\r\nb").unwrap(), ImeInsertion::Transformed); + assert_eq!(data(&s, id).characters, "Hia\nb"); + // Control characters are dropped; the commit itself is a no-op. + assert_eq!(insert_ime_commit(&mut s, "\u{0}\u{7}").unwrap(), ImeInsertion::Nothing); + assert_eq!(data(&s, id).characters, "Hia\nb"); + // Stock `TextEdit` ignores a bare newline/CR commit; typing Enter still inserts one. + // (It also leaves a live composition alone, unlike the non-ignored commit below.) + preedit(&mut s, "x", None).unwrap(); + assert_eq!(insert_ime_commit(&mut s, "\n").unwrap(), ImeInsertion::Nothing); + assert_eq!(insert_ime_commit(&mut s, "\r").unwrap(), ImeInsertion::Nothing); + assert_eq!(preedit_state(&s).map(|p| p.text), Some("x".into())); + preedit(&mut s, "", None).unwrap(); + assert_eq!(data(&s, id).characters, "Hia\nb"); + insert(&mut s, "\n").unwrap(); + assert_eq!(data(&s, id).characters, "Hia\nb\n"); + } + + #[test] + fn an_empty_ime_commit_ends_the_composition_without_inserting_text() { + let (mut s, id) = setup("Hello"); + begin(&mut s, id, Some(5), false).unwrap(); + preedit(&mut s, "にほ", None).unwrap(); + assert_eq!(preedit_state(&s).map(|p| p.text), Some("にほ".into())); + let revision = s.ds().unwrap().revision; + assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing); + assert_eq!(s.ds().unwrap().revision, revision + 1, "ending a live composition bumps the revision"); + assert!(preedit_state(&s).is_none(), "an empty commit ends the composition"); + assert!(preedit_doc(&s).is_none()); + assert_eq!(data(&s, id).characters, "Hello", "and inserts nothing"); + + // A control-only commit normalises to empty and ends the composition too. + preedit(&mut s, "x", None).unwrap(); + let revision = s.ds().unwrap().revision; + assert_eq!(insert_ime_commit(&mut s, "\u{0}\u{7}").unwrap(), ImeInsertion::Nothing); + assert_eq!(s.ds().unwrap().revision, revision + 1, "clearing a live composition bumps the revision"); + assert!(preedit_state(&s).is_none()); + assert_eq!(data(&s, id).characters, "Hello"); + + // With no composition (and not even while editing) an empty commit stays a harmless no-op. + let revision = s.ds().unwrap().revision; + assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing); + assert_eq!(s.ds().unwrap().revision, revision); + end(&mut s).unwrap(); + assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing); + } } diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 4b7b2abf..6aee4ef0 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -459,6 +459,13 @@ pub(crate) enum Gesture { }, } +/// Where Shift puts the Pen's next point: as far from `last` as `p` is, at the nearest 45° step. +fn at_45_degrees(last: Point, p: Point) -> Point { + let v = p - last; + let ang = (v.atan2() / (std::f64::consts::PI / 4.0)).round() * std::f64::consts::PI / 4.0; + last + Vec2::from_angle(ang) * v.hypot() +} + /// The handles a pen path shows while it is drawn ([`Overlay::pen_handles`]): the last anchor's in /// and out handles and the one before's out handle, those that aren't zero. fn pen_handles(points: &[(Point, Vec2, Vec2)]) -> Vec<[f64; 4]> { @@ -512,6 +519,11 @@ pub struct ToolState { pub pencil_brush: Option, /// The board tools' colours and choices. pub board: crate::board::BoardTool, + /// The eyedropper samples for a colour picker (`edit.pickColor` with `apply: false`): the + /// colour goes to `picked`, for the picker to write to the paint it edits, not to the fill. + pub pick_only: bool, + /// The colour the eyedropper sampled for a colour picker, until the picker takes it. + pub picked: Option, } /// Pen pressure along drawn samples as width profile points `[t, scale]`: `t` by length, at most @@ -973,6 +985,7 @@ impl Session { } self.tool.tool = t; self.tool.gesture = Gesture::None; + self.tool.pick_only = false; // The tool's cursor shows before the pointer moves. self.tool.overlay = Overlay { builder_remove: self.tool.builder_remove && t == ToolId::ShapeBuilder, cursor: t.cursor(), ..Overlay::default() }; @@ -1152,6 +1165,8 @@ impl Session { guides = g; self.tool.overlay.cursor = Cursor::Pen(self.pen_cursor(&doc, &sel, to, ev.zoom)); if let Some((last, _, out)) = self.tool.pen.as_ref().and_then(|pen| pen.points.last()) { + // With Shift the band ends where a click puts the point (#970). + let to = if ev.shift { at_45_degrees(*last, to) } else { to }; self.tool.overlay.rubber = Some([last.x, last.y, to.x, to.y]); self.tool.overlay.rubber_handle = (out.hypot() > 0.0).then_some([last.x + out.x, last.y + out.y]); } @@ -1260,11 +1275,21 @@ impl Session { n.parent == Some(page) && n.kind.is_frame_like() && !n.children.is_empty() && !crate::widgets::is_widget(n) && !n.is_board_atom() }) }; - let target = label_at(&doc, page, &self.tool.labels, ev.p()).or_else(|| { + let label = label_at(&doc, page, &self.tool.labels, ev.p()); + let target = label.or_else(|| { self.hit(&doc, page, ev.p(), ev.tol() * 2.0) .filter(|leaf| ev.cmd || !board_background(*leaf)) .map(|leaf| select_target(&doc, leaf, &sel, ev.cmd)) }); + // A press inside the selection's box that reaches nothing above it (an unfilled shape's + // inside, or the frame or canvas behind it) drags the selection. + let behind_sel = |t: Option| t.is_none_or(|t| sel.iter().any(|s| *s != t && doc.is_ancestor(t, *s))); + let target = match sel.iter().copied().find(|s| !locked_in(&doc, *s)) { + Some(first) if label.is_none() && !ev.shift && behind_sel(target) && selection_box(&doc, &sel).is_some_and(|q| in_quad(&q, ev.p())) => { + Some(first) + } + _ => target, + }; match target { Some(target) => { // Clicking inside the current selection's box keeps it (drag them all). @@ -2000,13 +2025,15 @@ impl Session { && doc.node(target).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))) && (sel.contains(&target) || select_target(&doc, leaf, &sel, false) == target) { - // Entering a text selects all of it; in the text being edited, the word. + // Entering a text selects all of it; in the text being edited, the word. A + // double-click inside the running edit is an ordinary edit: starting a new session + // here would replace the IME owner's epoch and drop a composition on a plain gesture. let editing = self.ds()?.text_edit.as_ref().is_some_and(|te| te.id == target); let idx = self.text_index_at(target, ev.p())?; - textedit::begin(self, target, Some(idx), false)?; if editing { textedit::select_word(self, idx)?; } else { + textedit::begin(self, target, Some(idx), false)?; textedit::key(self, "SelectAll", false, false, false)?; } return Ok(()); @@ -2134,7 +2161,9 @@ impl Session { 255, ); let sel = ds.selection.clone(); - if !sel.is_empty() { + if self.tool.pick_only { + self.tool.picked = Some(c); + } else if !sel.is_empty() { self.execute("node.set", &json!({"ids": sel, "props": {"fill": c.to_css_hex()}}))?; } self.recent_colors.retain(|x| *x != c); @@ -2196,9 +2225,7 @@ impl Session { if ev.shift && let Some((last, _, _)) = pen.points.last() { - let v = p - *last; - let ang = (v.atan2() / (std::f64::consts::PI / 4.0)).round() * std::f64::consts::PI / 4.0; - p = *last + Vec2::from_angle(ang) * v.hypot(); + p = at_45_degrees(*last, p); } pen.points.push((p, Vec2::ZERO, Vec2::ZERO)); self.tool.gesture = Gesture::PenDrag { anchor: p }; diff --git a/crates/engine/src/vector_fill_rule_tests.rs b/crates/engine/src/vector_fill_rule_tests.rs new file mode 100644 index 00000000..8a8fe850 --- /dev/null +++ b/crates/engine/src/vector_fill_rule_tests.rs @@ -0,0 +1,306 @@ +//! Fill rules on vectors: `vector.fillRule` sets the rule a filled area fills by and +//! `vector.reverseLoop` turns one of its loops round — which is how a hole is made and unmade. +//! The numbers come from the two rules' definitions: non-zero fills where the winding count is not +//! zero (so a hole is a loop running against its outer loop), even-odd where it is odd (so the +//! direction of a loop doesn't matter). + +use figcraft_doc::{NodeId, NodeKind, Region, Segment, VectorNetwork, Vertex, WindingRule}; +use figcraft_geom::{BezPath, Point}; +use serde_json::{Value, json}; + +use crate::Session; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn net(s: &Session, id: NodeId) -> VectorNetwork { + match &s.doc().unwrap().node(id).unwrap().kind { + NodeKind::Vector { network } => network.clone(), + other => panic!("not a vector: {other:?}"), + } +} + +/// A 100 × 100 square with a 25…75 hole, both loops in one area: the outer one clockwise, the inner +/// one the same way round (so non-zero fills the hole too until it is turned round). +fn ring() -> VectorNetwork { + let mut path = BezPath::new(); + for (x0, y0, x1, y1) in [(0.0, 0.0, 100.0, 100.0), (25.0, 25.0, 75.0, 75.0)] { + path.move_to((x0, y0)); + path.line_to((x1, y0)); + path.line_to((x1, y1)); + path.line_to((x0, y1)); + path.close_path(); + } + let mut net = VectorNetwork::from_path(&path); + net.regions[0].winding = WindingRule::NonZero; + net +} + +/// A selected vector layer holding `network`. +fn vector_layer(s: &mut Session, network: VectorNetwork) -> NodeId { + let id = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + let id = NodeId(id); + ex(s, "vector.edit.begin", json!({"id": id})); + ex(s, "vector.edit.end", json!({})); + { + let doc = s.doc_mut().unwrap(); + match &mut doc.get_mut(id).unwrap().kind { + NodeKind::Vector { network: net } => *net = network, + other => panic!("not a vector: {other:?}"), + } + } + ex(s, "select.set", json!({"ids": [id]})); + id +} + +/// A selected vector layer holding `ring()`. +fn ringed(s: &mut Session) -> NodeId { + vector_layer(s, ring()) +} + +/// Two 50 × 100 squares side by side share the vertical edge between them: one segment listed by +/// both areas' single loops. A segment shared between areas is the case `vector.reverseLoop` must +/// not flip, or a neighbour's fill or stroke would turn round with it. +fn two_squares() -> VectorNetwork { + // 0:A(0,0) 1:B(50,0) 2:C(100,0) 3:D(100,100) 4:E(50,100) 5:F(0,100) + // s0 A→B (left bottom), s1 B→E (the shared vertical edge), s2 E→F (left top), + // s3 F→A (left left), s4 B→C (right bottom), s5 C→D (right right), s6 D→E (right top); + // the right area's left edge is s1 walked the other way, so the squares share one segment. + // region 0: left square A→B→E→F→A = [s0, s1, s2, s3]. + // region 1: right square B→C→D→E→B = [s4, s5, s6, s1] (s1 walked backwards). + VectorNetwork { + vertices: vec![ + Vertex::new(0.0, 0.0), + Vertex::new(50.0, 0.0), + Vertex::new(100.0, 0.0), + Vertex::new(100.0, 100.0), + Vertex::new(50.0, 100.0), + Vertex::new(0.0, 100.0), + ], + segments: vec![ + Segment::line(0, 1), + Segment::line(1, 4), + Segment::line(4, 5), + Segment::line(5, 0), + Segment::line(1, 2), + Segment::line(2, 3), + Segment::line(3, 4), + ], + regions: vec![ + Region { loops: vec![vec![0, 1, 2, 3]], winding: WindingRule::NonZero }, + Region { loops: vec![vec![4, 5, 6, 1]], winding: WindingRule::NonZero }, + ], + } +} + +/// A selected vector layer holding [`two_squares`]. +fn squared(s: &mut Session) -> NodeId { + vector_layer(s, two_squares()) +} + +/// Two areas of two segments each over the same line: one curve above it and one below. Both areas +/// list the line's segment — the first area first (`[line, above]`), the second area last +/// (`[below, line]`) — and a two-segment ring reads the same whichever way its list is turned, so +/// these only turn round when a segment is stored the other way. +fn two_lenses() -> VectorNetwork { + // 0:A(0,0) 1:B(100,0) + // s0 A→B the straight line both areas use; s1 B→A curving above it; s2 A→B curving below it. + VectorNetwork { + vertices: vec![Vertex::new(0.0, 0.0), Vertex::new(100.0, 0.0)], + segments: vec![ + Segment::line(0, 1), + Segment { a: 1, b: 0, ta: (0.0, -50.0), tb: (0.0, -50.0) }, + Segment { a: 0, b: 1, ta: (0.0, 50.0), tb: (0.0, 50.0) }, + ], + regions: vec![ + Region { loops: vec![vec![0, 1]], winding: WindingRule::NonZero }, + Region { loops: vec![vec![2, 0]], winding: WindingRule::NonZero }, + ], + } +} + +/// A selected vector layer holding [`two_lenses`]. +fn lensed(s: &mut Session) -> NodeId { + vector_layer(s, two_lenses()) +} + +/// Two areas whose only loop is the same closed segment (a curve the path closes with a straight +/// line): a one-segment loop's direction is its segment's stored ends, and both areas read them. +fn one_shared_segment() -> VectorNetwork { + VectorNetwork { + vertices: vec![Vertex::new(0.0, 0.0), Vertex::new(100.0, 0.0)], + segments: vec![Segment { a: 0, b: 1, ta: (0.0, -60.0), tb: (0.0, -60.0) }], + regions: vec![Region { loops: vec![vec![0]], winding: WindingRule::NonZero }, Region { loops: vec![vec![0]], winding: WindingRule::NonZero }], + } +} + +/// The point in the middle of the hole, and one on the square's left band. +const HOLE: Point = Point::new(50.0, 50.0); +const BAND: Point = Point::new(10.0, 50.0); + +#[test] +fn a_loop_running_with_its_outer_loop_fills_the_hole_until_it_is_turned_round() { + let mut s = Session::new(); + let id = ringed(&mut s); + let n = net(&s, id); + assert_eq!(n.regions.len(), 1); + assert_eq!(n.regions[0].loops.len(), 2, "an outer loop and the hole's"); + assert!(n.region_contains(0, BAND), "the band fills"); + assert!(n.region_contains(0, HOLE), "the hole fills while its loop runs the same way"); + // Turning the inner loop round makes it a hole under non-zero. + let r = ex(&mut s, "vector.reverseLoop", json!({"id": id, "region": 0, "loop": 1})); + assert_eq!(r["reversed"], json!(true)); + let n = net(&s, id); + assert!(n.region_contains(0, BAND), "the band still fills"); + assert!(!n.region_contains(0, HOLE), "the hole is empty"); + assert_eq!(n.region_winding(0, HOLE), Some(0), "its loops cancel out there"); + // Undo turns it back. + s.undo().unwrap(); + assert!(net(&s, id).region_contains(0, HOLE), "one undo step restores the loop"); +} + +#[test] +fn the_rule_switches_every_area_or_only_the_one_asked_for() { + let mut s = Session::new(); + let id = ringed(&mut s); + // Even-odd leaves the hole empty whatever way its loop runs. + let r = ex(&mut s, "vector.fillRule", json!({"id": id, "rule": "evenOdd"})); + assert_eq!((r["regions"].clone(), r["rule"].clone()), (json!(1), json!("evenOdd"))); + let n = net(&s, id); + assert_eq!(n.regions[0].winding, WindingRule::EvenOdd); + assert!(!n.region_contains(0, HOLE), "even-odd counts the hole's loop as a second crossing"); + // And back. + ex(&mut s, "vector.fillRule", json!({"id": id, "rule": "nonZero"})); + assert!(net(&s, id).region_contains(0, HOLE), "non-zero fills it again"); + // One area of several, by index. + let n = net(&s, id); + let mut two = n.clone(); + two.regions.push(n.regions[0].clone()); + { + let doc = s.doc_mut().unwrap(); + match &mut doc.get_mut(id).unwrap().kind { + NodeKind::Vector { network } => *network = two, + other => panic!("not a vector: {other:?}"), + } + } + ex(&mut s, "vector.fillRule", json!({"id": id, "region": 1, "rule": "evenOdd"})); + let n = net(&s, id); + assert_eq!(n.regions[0].winding, WindingRule::NonZero, "the other area keeps its rule"); + assert_eq!(n.regions[1].winding, WindingRule::EvenOdd); + // Every area at once. + ex(&mut s, "vector.fillRule", json!({"id": id, "rule": "evenOdd"})); + assert!(net(&s, id).regions.iter().all(|r| r.winding == WindingRule::EvenOdd)); +} + +#[test] +fn junk_parameters_are_refused_and_change_nothing() { + let mut s = Session::new(); + let id = ringed(&mut s); + let before = net(&s, id); + for p in [ + json!({"id": id, "rule": "nope"}), + json!({"id": id}), + json!({"id": id, "rule": 7}), + json!({"id": id, "rule": "evenOdd", "region": -1}), + json!({"id": id, "rule": "evenOdd", "region": 1.5}), + json!({"id": id, "rule": "evenOdd", "region": "x"}), + json!({"id": id, "rule": "evenOdd", "region": 99}), + ] { + assert!(s.execute("vector.fillRule", &p).is_err(), "{p} is refused"); + } + for p in [ + json!({"id": id}), + json!({"id": id, "region": 0}), + json!({"id": id, "region": 0, "loop": 99}), + json!({"id": id, "region": 99, "loop": 0}), + json!({"id": id, "region": -2, "loop": 0}), + ] { + assert!(s.execute("vector.reverseLoop", &p).is_err(), "{p} is refused"); + } + assert_eq!(net(&s, id), before, "nothing changed"); + // A rectangle that was never a vector is refused too. + let rect = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); + assert!(s.execute("vector.fillRule", &json!({"id": rect, "rule": "evenOdd"})).is_err()); + assert!(s.execute("vector.reverseLoop", &json!({"id": rect, "region": 0, "loop": 0})).is_err()); +} + +#[test] +fn reversing_a_loop_shared_with_a_neighbour_leaves_its_segment_and_fill_alone() { + let mut s = Session::new(); + let id = squared(&mut s); + let n = net(&s, id); + assert_eq!(n.regions.len(), 2, "two areas, side by side"); + // Both areas fill under non-zero: a point in each. + assert!(n.region_contains(0, Point::new(25.0, 50.0)), "the left area fills"); + assert!(n.region_contains(1, Point::new(75.0, 50.0)), "the right area fills"); + // The vertical edge between them is one segment listed by both areas' loops. + const SHARED: usize = 1; + assert!(n.regions[0].loops[0].contains(&SHARED) && n.regions[1].loops[0].contains(&SHARED), "one segment is shared"); + let before = n.segments[SHARED]; + let left_winding = n.region_winding(0, Point::new(25.0, 50.0)).unwrap(); + assert_ne!(left_winding, 0, "the left area winds once"); + // Reverse the left area's only loop. + let r = ex(&mut s, "vector.reverseLoop", json!({"id": id, "region": 0, "loop": 0})); + assert_eq!(r["reversed"], json!(true)); + let n = net(&s, id); + // The shared segment's stored ends are untouched: reversing the loop must not flip a neighbour's + // segment (reversing the list already turns this loop's traversal of it the other way). + assert_eq!(n.segments[SHARED], before, "the shared segment is left as it was"); + // The neighbour's fill is unchanged: it still fills its interior. + assert!(n.region_contains(1, Point::new(75.0, 50.0)), "the neighbour still fills"); + // The reversed loop really turned round: its winding at a left-area point flipped sign. + assert_eq!(n.region_winding(0, Point::new(25.0, 50.0)), Some(-left_winding), "the left loop reversed"); + // Undo restores the shared segment and the loop. + s.undo().unwrap(); + assert_eq!(net(&s, id).segments[SHARED], before, "undo restores the shared segment too"); +} + +#[test] +fn a_two_segment_loop_turns_round_with_its_shared_segment_listed_first_or_last() { + let mut s = Session::new(); + let id = lensed(&mut s); + const SHARED: usize = 0; + const ABOVE: Point = Point::new(50.0, -25.0); + const BELOW: Point = Point::new(50.0, 25.0); + let n = net(&s, id); + assert_eq!(n.regions.len(), 2, "two areas over the same line"); + assert_eq!((n.regions[0].loops[0][0], n.regions[0].loops[0].len()), (SHARED, 2), "the shared segment is listed first"); + assert_eq!((n.regions[1].loops[0][1], n.regions[1].loops[0].len()), (SHARED, 2), "and last"); + assert!(n.region_contains(0, ABOVE), "the area above the line fills"); + assert!(n.region_contains(1, BELOW), "the area below it fills"); + let (above, below) = (n.region_winding(0, ABOVE).unwrap(), n.region_winding(1, BELOW).unwrap()); + assert_ne!(above, 0, "the area above winds once"); + assert_ne!(below, 0, "the area below winds once"); + let shared = n.segments[SHARED]; + // Turn the area that lists the shared segment first round: a two-segment ring reads the same + // either way, so this only works by storing one of its segments the other way. + assert_eq!(ex(&mut s, "vector.reverseLoop", json!({"id": id, "region": 0, "loop": 0}))["reversed"], json!(true)); + let n = net(&s, id); + assert_eq!(n.segments[SHARED], shared, "the shared segment keeps its ends"); + assert!(n.region_contains(0, ABOVE), "the area still fills"); + assert_eq!(n.region_winding(0, ABOVE), Some(-above), "it really turned round"); + assert_eq!(n.region_winding(1, BELOW), Some(below), "the neighbour did not"); + // And the area that lists it last. + ex(&mut s, "vector.reverseLoop", json!({"id": id, "region": 1, "loop": 0})); + let n = net(&s, id); + assert_eq!(n.segments[SHARED], shared, "still untouched"); + assert_eq!(n.region_winding(1, BELOW), Some(-below), "the second area really turned round"); + assert_eq!(n.region_winding(0, ABOVE), Some(-above), "the first stays as it was"); + // Undo turns each back. + s.undo().unwrap(); + assert_eq!(net(&s, id).region_winding(1, BELOW), Some(below), "undo turns the second back"); + s.undo().unwrap(); + assert_eq!(net(&s, id).region_winding(0, ABOVE), Some(above), "and the first"); +} + +#[test] +fn a_one_segment_loop_shared_with_another_is_refused_instead_of_claiming_a_turn() { + let mut s = Session::new(); + let id = vector_layer(&mut s, one_shared_segment()); + let before = net(&s, id); + let err = s.execute("vector.reverseLoop", &json!({"id": id, "region": 0, "loop": 0})).unwrap_err(); + let message = err.to_string(); + assert!(message.contains("shares its segments"), "the error says why: {message}"); + assert_eq!(net(&s, id), before, "nothing changed"); +} diff --git a/crates/engine/src/vector_multi_behaviour_tests.rs b/crates/engine/src/vector_multi_behaviour_tests.rs index 1d2075b1..b1e6a948 100644 --- a/crates/engine/src/vector_multi_behaviour_tests.rs +++ b/crates/engine/src/vector_multi_behaviour_tests.rs @@ -169,13 +169,19 @@ fn editing_several_vectors_at_once() { assert!(near(page_pos(&s, a, atl), (1.0, 0.0)) && near(page_pos(&s, b, btl), (201.0, 0.0)), "an arrow nudges the points in every layer"); s.undo().unwrap(); assert!(near(page_pos(&s, a, atl), (0.0, 0.0)) && near(page_pos(&s, b, btl), (200.0, 0.0)), "one undo step for every layer"); - // Dragging one selected point drags them all. - let active = editing(&s).unwrap().id; - let grab = if active == a { (0.0, 0.0) } else { (200.0, 0.0) }; - drag(&mut s, grab, (grab.0 + 10.0, grab.1 + 20.0), NONE); - assert!(near(page_pos(&s, a, atl), (10.0, 20.0)) && near(page_pos(&s, b, btl), (210.0, 20.0)), "dragging one selected point moves them all"); + // Dragging one selected point drags them all (one on the transform box's edge: its corners + // scale, as with points of one layer). + let atr = point_at(&s, a, (100.0, 0.0)); + ex(&mut s, "vector.edit.select", json!({"id": a, "vertices": [atr], "add": true})); + drag(&mut s, (100.0, 0.0), (110.0, 20.0), NONE); + assert!( + near(page_pos(&s, a, atl), (10.0, 20.0)) && near(page_pos(&s, a, atr), (110.0, 20.0)) && near(page_pos(&s, b, btl), (210.0, 20.0)), + "dragging one selected point moves them all" + ); s.undo().unwrap(); assert!(near(page_pos(&s, a, atl), (0.0, 0.0)) && near(page_pos(&s, b, btl), (200.0, 0.0)), "the drag is one undo step"); + ex(&mut s, "vector.edit.select", json!({"vertices": [atl]})); + ex(&mut s, "vector.edit.select", json!({"id": b, "vertices": [btl], "add": true})); // Corner radius, mirroring and deleting reach every layer's selected points. ex(&mut s, "vector.edit.cornerRadius", json!({"radius": 10})); assert!(net(&s, a).vertices[atl].radius == 10.0 && net(&s, b).vertices[btl].radius == 10.0, "the corner radius applies to every layer"); diff --git a/crates/engine/src/vectoredit.rs b/crates/engine/src/vectoredit.rs index a3fbf803..3b79dd4b 100644 --- a/crates/engine/src/vectoredit.rs +++ b/crates/engine/src/vectoredit.rs @@ -4,7 +4,7 @@ //! Entering edit mode on a non-vector shape converts it to a vector first (like the incumbent). //! Vertex coordinates are node-local; after each edit the node is re-fitted so its box wraps the //! network again. The network edits themselves live in [`ops`]; pointer gestures in `pointer.rs`, -//! the transform box around several points in [`transform`], point snapping in `point_snap.rs` and +//! the transform box around several points (in one layer or several) in [`transform`], point snapping in `point_snap.rs` and //! hovering, hatching and selecting filled regions in [`regions`]. pub mod ops; diff --git a/crates/engine/src/vectoredit/pointer.rs b/crates/engine/src/vectoredit/pointer.rs index 6667a209..4721d6bd 100644 --- a/crates/engine/src/vectoredit/pointer.rs +++ b/crates/engine/src/vectoredit/pointer.rs @@ -262,7 +262,7 @@ impl Session { } /// Every edited layer's selected points (segment ends included) other than the active one's. - fn others_moving(doc: &Document, ve: &VectorEdit) -> Vec<(NodeId, Vec)> { + pub(super) fn others_moving(doc: &Document, ve: &VectorEdit) -> Vec<(NodeId, Vec)> { ve.others .iter() .filter_map(|o| { @@ -310,7 +310,7 @@ impl Session { let Some(b) = transform::shown_box(&doc, ve) else { return Ok(()) }; match bh { BoxHit::Inside => { - let g = self.points_gesture(ve.id, ev.p(), b.points, vec![], None, true)?; + let g = self.points_gesture(ve.id, ev.p(), b.points, b.others, None, true)?; self.begin_interaction("Move points")?; self.vgesture(g); } @@ -320,7 +320,7 @@ impl Session { self.vgesture(VGesture::BoxRotate { b, start_angle, angle: 0.0 }); } BoxHit::Handle(handle) => { - let targets = Arc::new(point_snap::targets(&doc, page, Some(ve.id), &b.points, &[])); + let targets = Arc::new(point_snap::targets_moving(&doc, page, Some(ve.id), &b.points, &[], &b.others)); self.begin_interaction("Scale points")?; self.vgesture(VGesture::BoxScale { b, handle, targets, pick }); } @@ -428,11 +428,11 @@ impl Session { } /// The region of the active layer a press at `ev` would take (Move tool, `h` the network's part - /// there). Inside the transform box around points selected in their own right, the box keeps - /// its drag unless the region is already selected. + /// there). Inside the transform box around points selected in their own right (in any edited + /// layer), the box keeps its drag unless the region is already selected. fn region_under(doc: &Document, ve: &VectorEdit, h: Option, ev: &PointerEvent) -> Option { let r = region_hit(doc, ve, ev.p(), ev.zoom)?; - let own = !ve.shown_vertices().is_empty() || !ve.segments.is_empty(); + let own = !ve.shown_vertices().is_empty() || !ve.segments.is_empty() || ve.others_selected(); let in_box = !ev.shift && own && transform::box_hit(doc, ve, h, ev.p(), ev.zoom) == Some(BoxHit::Inside); (!in_box || ve.regions.contains(&r)).then_some(r) } @@ -883,7 +883,7 @@ impl Session { let q = b.frame.inverse() * (ev.p() + c); let (x0, y0, x1, y1) = crate::tools::resized_box(handle, q, b.width, b.height, ev.shift, ev.alt); let m = b.onto(x0, y0, x1, y1); - self.preview(|doc, _| transform::transform_points(doc, id, &b.points, m))?; + self.preview(|doc, _| transform::transform_box(doc, id, &b, m))?; } VGesture::BoxRotate { b, start_angle, .. } => { let c = b.center(); @@ -892,7 +892,7 @@ impl Session { angle = (angle / 15.0).round() * 15.0; } let m = figcraft_geom::rotate_about(Affine::IDENTITY, c, -angle); - self.preview(|doc, _| transform::transform_points(doc, id, &b.points, m))?; + self.preview(|doc, _| transform::transform_box(doc, id, &b, m))?; self.tool.overlay.cursor = Cursor::Rotate((ev.p() - c).atan2().to_degrees()); self.vgesture(VGesture::BoxRotate { b, start_angle, angle }); } @@ -1007,10 +1007,7 @@ impl Session { } else { self.cancel_interaction()?; if click_clears { - set_state(self, |e| { - e.vertices.clear(); - e.segments.clear(); - })?; + self.clear_points()?; } } } @@ -1037,23 +1034,7 @@ impl Session { } })?, VGesture::Erase { .. } | VGesture::Paint { .. } => self.commit_interaction()?, - VGesture::BoxScale { pick, .. } => { - let ds = self.ds()?; - let clicked = ds.interaction.as_ref().is_some_and(|it| Arc::ptr_eq(&it.doc, &ds.doc)); - match pick.filter(|_| clicked) { - Some(v) => { - self.cancel_interaction()?; - set_state(self, |e| { - if let Some(i) = e.vertices.iter().position(|x| *x == v) { - e.vertices.remove(i); - } else { - e.vertices.push(v); - } - })?; - } - None => self.commit_interaction()?, - } - } + VGesture::BoxScale { pick, .. } => self.box_scale_up(pick)?, VGesture::Cut { start, hit } => self.cut_up(ve, start, hit, ev)?, VGesture::Width(g) => self.width_up(g)?, } @@ -1063,6 +1044,34 @@ impl Session { Ok(()) } + /// Clears the point selection of the active layer and of the other layers being edited. + fn clear_points(&mut self) -> Result<()> { + set_state(self, |e| { + e.vertices.clear(); + e.segments.clear(); + for o in &mut e.others { + o.vertices.clear(); + o.segments.clear(); + } + }) + } + + /// The transform box's corner or edge was let go: a Shift-click (no drag) on the corner's + /// point `pick` toggles it in the selection; anything else keeps the scale. + fn box_scale_up(&mut self, pick: Option) -> Result<()> { + let ds = self.ds()?; + let clicked = ds.interaction.as_ref().is_some_and(|it| Arc::ptr_eq(&it.doc, &ds.doc)); + let Some(v) = pick.filter(|_| clicked) else { return self.commit_interaction() }; + self.cancel_interaction()?; + set_state(self, |e| { + if let Some(i) = e.vertices.iter().position(|x| *x == v) { + e.vertices.remove(i); + } else { + e.vertices.push(v); + } + }) + } + /// Point `v` of vector `id`, dragged and dropped within `tol` (page units) of another of its /// points, merges into that point (in the same undo step as the move), which stays selected. fn merge_dropped_point(&mut self, id: NodeId, v: usize, tol: f64) -> Result<()> { diff --git a/crates/engine/src/vectoredit/transform.rs b/crates/engine/src/vectoredit/transform.rs index 1d5f27a6..ca6e59e2 100644 --- a/crates/engine/src/vectoredit/transform.rs +++ b/crates/engine/src/vectoredit/transform.rs @@ -1,11 +1,11 @@ //! The transform box of vector edit mode. With two or more points selected (Move tool) a box in the -//! vector's own axes wraps them: its corners and edges scale them (Shift keeps proportions, Alt -//! scales from the centre), dragging just outside a corner rotates them about its centre (Shift: -//! 15° steps) and dragging inside it moves them; each drag is one undo step (the gestures live in -//! `pointer.rs`). Commands make the same edits for the Design panel, the control channel and MCP, +//! vector's own axes wraps them (in the page's axes when they are in several layers): its corners +//! and edges scale them (Shift keeps proportions, Alt scales from the centre), dragging just +//! outside a corner rotates them about its centre (Shift: 15° steps) and dragging inside it moves +//! them; each drag is one undo step for every layer (the gestures live in `pointer.rs`). Commands make the same edits for the Design panel, the control channel and MCP, //! and while points are selected the flip and 90°/180° rotation commands act on them. -use figcraft_doc::Document; +use figcraft_doc::{Document, NodeId}; use figcraft_geom::{Affine, Point}; use serde_json::{Value, json}; @@ -19,16 +19,23 @@ use crate::{Result, Session, bad, spec}; const EPS: f64 = 1e-9; /// The selected points' box: the page transform of its top-left corner (axes along the vector's -/// own), its size in those axes and the points in it. +/// own), its size in those axes and the points in it: the active layer's, and other edited +/// layers' in `others`. #[derive(Clone, Debug, PartialEq)] pub struct PointBox { pub frame: Affine, pub width: f64, pub height: f64, pub points: Vec, + pub others: Vec<(NodeId, Vec)>, } impl PointBox { + /// How many points the box wraps, in every layer. + pub fn point_count(&self) -> usize { + self.points.len() + self.others.iter().map(|(_, v)| v.len()).sum::() + } + /// Corners in page coordinates (tl, tr, br, bl). pub fn corners(&self) -> [Point; 4] { let (f, w, h) = (self.frame, self.width, self.height); @@ -62,17 +69,37 @@ pub fn point_box(doc: &Document, ve: &VectorEdit) -> Option { if !(x0.is_finite() && y0.is_finite() && width.is_finite() && height.is_finite()) { return None; } - Some(PointBox { frame: doc.world_transform(ve.id) * Affine::translate((x0, y0)), width, height, points }) + Some(PointBox { frame: doc.world_transform(ve.id) * Affine::translate((x0, y0)), width, height, points, others: vec![] }) +} + +/// The box of the selected points of every edited layer, in the page's axes (the layers' own may +/// differ). +fn layers_box(doc: &Document, ve: &VectorEdit) -> Option { + let points = selected_points(network(doc, ve.id)?, ve); + let others = Session::others_moving(doc, ve); + let page = |id: NodeId, verts: &[usize]| -> Vec { + let w = doc.world_transform(id); + network(doc, id).map(|n| verts.iter().filter_map(|v| n.vertices.get(*v)).map(|v| w * v.point()).collect()).unwrap_or_default() + }; + let all = page(ve.id, &points).into_iter().chain(others.iter().flat_map(|(id, v)| page(*id, v))); + let (x0, y0, x1, y1) = all.fold((f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY), |(a, b, c, d), p| { + (a.min(p.x), b.min(p.y), c.max(p.x), d.max(p.y)) + }); + let (width, height) = (x1 - x0, y1 - y0); + if !(x0.is_finite() && y0.is_finite() && width.is_finite() && height.is_finite()) { + return None; + } + Some(PointBox { frame: Affine::translate((x0, y0)), width, height, points, others }) } /// The box drawn and dragged on the canvas: the Move tool, no open pen path, and two or more -/// points that aren't all in one place, all in the active layer. -/// shortcut: with points selected in several layers there is no box (drags, nudges and the Design panel still reach them all); wrap them all if asked for. +/// points that aren't all in one place, in any of the edited layers. pub fn shown_box(doc: &Document, ve: &VectorEdit) -> Option { - if ve.tool != VTool::Move || ve.anchor.is_some() || ve.others_selected() { + if ve.tool != VTool::Move || ve.anchor.is_some() { return None; } - point_box(doc, ve).filter(|b| b.points.len() >= 2 && (b.width > EPS || b.height > EPS)) + let b = if ve.others_selected() { layers_box(doc, ve) } else { point_box(doc, ve) }; + b.filter(|b| b.point_count() >= 2 && (b.width > EPS || b.height > EPS)) } /// What the transform box does under the pointer. @@ -115,12 +142,21 @@ pub fn box_hit(doc: &Document, ve: &VectorEdit, h: Option, p: Point, zoom: /// Map points `verts` of vector `id` (and the handles at them) through page transform `m`, then /// re-fit the vector. -pub fn transform_points(doc: &mut Document, id: figcraft_doc::NodeId, verts: &[usize], m: Affine) -> Result<()> { +pub fn transform_points(doc: &mut Document, id: NodeId, verts: &[usize], m: Affine) -> Result<()> { let w = doc.world_transform(id); let local = w.inverse() * m * w; edit(doc, id, |net| ops::transform_vertices(net, verts, local)) } +/// Map the points of box `b` (`id`: the active layer) in every layer through page transform `m`. +pub fn transform_box(doc: &mut Document, id: NodeId, b: &PointBox, m: Affine) -> Result<()> { + transform_points(doc, id, &b.points, m)?; + for (o, verts) in &b.others { + transform_points(doc, *o, verts, m)?; + } + Ok(()) +} + /// Transform the selected points by `f(box)` (page space) when editing with points selected and /// the command names no layers (`p` has no `ids` or `id`); `None` otherwise (it then acts on /// layers). diff --git a/crates/engine/src/vectoredit/transform_tests.rs b/crates/engine/src/vectoredit/transform_tests.rs index d69baf52..f29ed2a5 100644 --- a/crates/engine/src/vectoredit/transform_tests.rs +++ b/crates/engine/src/vectoredit/transform_tests.rs @@ -307,3 +307,122 @@ fn edit_mode_transform_commands_survive_junk() { } } } + +/// Two edited layers turned differently: `a` a 100×100 square at the origin turned -20°, `b` one +/// at (200, 0) turned 30° (the active layer), with two of `a`'s corners and all of `b`'s points +/// selected. Neither layer's axes are the page's. Snapping (the pixel grid too) is off so drags +/// land exactly. +fn two_layers(s: &mut Session) -> (NodeId, NodeId) { + let mut square = |x: f64| crate::params::id_of(&ex(s, "shape.rectangle", json!({"x": x, "y": 0, "width": 100, "height": 100}))["id"]).unwrap(); + let (a, b) = (square(0.0), square(200.0)); + ex(s, "node.set", json!({"id": a, "props": {"rotation": -20}})); + ex(s, "node.set", json!({"id": b, "props": {"rotation": 30}})); + ex(s, "view.snapGeometry", json!({"on": false})); + ex(s, "view.snapObjects", json!({"on": false})); + ex(s, "view.snapPixel", json!({"on": false})); + ex(s, "vector.edit.begin", json!({"ids": [a, b]})); + let n = net(s, a); + ex(s, "vector.edit.select", json!({"vertices": [vertex_at(&n, 0.0, 0.0), vertex_at(&n, 100.0, 100.0)]})); + ex(s, "vector.edit.select", json!({"id": b, "vertices": [0, 1, 2, 3], "add": true})); + assert_eq!(ve(s).id, b, "the layer named last is the active one"); + (a, b) +} + +/// Every selected point's page position, layer by layer. +fn selected_page_points(s: &Session, a: NodeId, b: NodeId) -> Vec<(NodeId, usize, Point)> { + let e = ve(s); + let parts = e.parts(); + [a, b] + .iter() + .flat_map(|id| { + let part = parts.iter().find(|p| p.id == *id).unwrap(); + part.vertices.iter().map(|v| (*id, *v, page_pt(s, *id, *v))).collect::>() + }) + .collect() +} + +#[test] +fn the_box_wraps_points_selected_in_several_layers() { + let mut s = Session::new(); + let (a, b) = two_layers(&mut s); + let before = selected_page_points(&s, a, b); + assert_eq!(before.len(), 6); + // On the page's axes: `b` is turned, so neither layer's own axes fit both. + let (x0, y0) = before.iter().fold((f64::INFINITY, f64::INFINITY), |(x, y), (_, _, p)| (x.min(p.x), y.min(p.y))); + let (x1, y1) = before.iter().fold((f64::NEG_INFINITY, f64::NEG_INFINITY), |(x, y), (_, _, p)| (x.max(p.x), y.max(p.y))); + let c = s.vector_edit_box().expect("a box around the points of both layers"); + assert!(near(c[0], pt(x0, y0)) && near(c[2], pt(x1, y1)), "{c:?} vs ({x0}, {y0})–({x1}, {y1})"); + + // The bottom-right corner dragged to twice the width scales every point in both layers. + drag(&mut s, (x1, y1), (x0 + 2.0 * (x1 - x0), y1)); + for (id, v, p) in &before { + let q = page_pt(&s, *id, *v); + assert!(near(q, pt(x0 + 2.0 * (p.x - x0), p.y)), "point {v} of {id:?}: {p:?} → {q:?}"); + } + s.undo().unwrap(); + for (id, v, p) in &before { + assert!(near(page_pt(&s, *id, *v), *p), "one undo step brings back point {v} of {id:?}"); + } + + // From just outside the top-right corner, a quarter turn clockwise about the box's centre. + let ctr = pt((x0 + x1) / 2.0, (y0 + y1) / 2.0); + let from = pt(x1 + 10.0, y0 - 10.0); + let d = from - ctr; + drag(&mut s, (from.x, from.y), (ctr.x - d.y, ctr.y + d.x)); + for (id, v, p) in &before { + let r = *p - ctr; + let q = page_pt(&s, *id, *v); + assert!(near(q, pt(ctr.x - r.y, ctr.y + r.x)), "point {v} of {id:?}: {p:?} → {q:?}"); + } + s.undo().unwrap(); + for (id, v, p) in &before { + assert!(near(page_pt(&s, *id, *v), *p), "one undo step brings back point {v} of {id:?}"); + } +} + +#[test] +fn inside_the_box_of_several_layers_drags_and_a_click_clears_them_all() { + let mut s = Session::new(); + let (a, b) = two_layers(&mut s); + let before = selected_page_points(&s, a, b); + // Clear of both networks: between the two squares, inside the box. + let doc = s.doc().unwrap().clone(); + assert!(hit_any(&doc, &ve(&s), pt(150.0, 50.0), 6.0).is_none()); + assert_eq!(box_hit(&doc, &ve(&s), None, pt(150.0, 50.0), 1.0), Some(BoxHit::Inside)); + drag(&mut s, (150.0, 50.0), (170.0, 60.0)); + for (id, v, p) in &before { + assert!(near(page_pt(&s, *id, *v), *p + Vec2::new(20.0, 10.0)), "point {v} of {id:?} moves along"); + } + s.undo().unwrap(); + click(&mut s, 150.0, 50.0); + assert!(ve(&s).parts().iter().all(|p| p.vertices.is_empty()), "a click inside clears every layer's points"); +} + +#[test] +fn inside_the_box_of_another_layer_drags_over_a_region_of_the_active_one() { + // `b`'s points selected, `a` (a filled 300 square around `b`) active with nothing selected: + // the press at the box's centre lands on `a`'s region too. + let mut s = Session::new(); + let mut square = |x: f64, size: f64| { + crate::params::id_of(&ex(&mut s, "shape.rectangle", json!({"x": x, "y": x, "width": size, "height": size}))["id"]).unwrap() + }; + let (a, b) = (square(0.0, 300.0), square(100.0, 50.0)); + ex(&mut s, "view.snapGeometry", json!({"on": false})); + ex(&mut s, "view.snapObjects", json!({"on": false})); + ex(&mut s, "view.snapPixel", json!({"on": false})); + ex(&mut s, "vector.edit.begin", json!({"ids": [a, b]})); + ex(&mut s, "vector.edit.select", json!({"id": b, "vertices": [0, 1, 2, 3]})); + ex(&mut s, "vector.edit.select", json!({"id": a, "add": true})); + let doc = s.doc().unwrap().clone(); + let e = ve(&s); + assert!(e.id == a && e.vertices.is_empty() && e.others_selected()); + assert!(hit_any(&doc, &e, pt(125.0, 125.0), 6.0).is_none()); + assert!(regions::region_hit(&doc, &e, pt(125.0, 125.0), 1.0).is_some(), "a region of the active layer under the press"); + assert_eq!(box_hit(&doc, &e, None, pt(125.0, 125.0), 1.0), Some(BoxHit::Inside)); + let before: Vec = (0..4).map(|v| page_pt(&s, b, v)).collect(); + drag(&mut s, (125.0, 125.0), (145.0, 135.0)); + assert!(ve(&s).regions.is_empty(), "the region isn't selected"); + for (v, p) in before.iter().enumerate() { + assert!(near(page_pt(&s, b, v), *p + Vec2::new(20.0, 10.0)), "point {v} of b moves along"); + } +} diff --git a/crates/format/src/fig/assets_tests.rs b/crates/format/src/fig/assets_tests.rs index 4777be18..a271a908 100644 --- a/crates/format/src/fig/assets_tests.rs +++ b/crates/format/src/fig/assets_tests.rs @@ -104,7 +104,10 @@ fn themed() -> Document { add(&mut doc, frame, NodeKind::Rectangle, "Swatch", (10.0, 10.0), (80.0, 60.0), &|n| { let mut g = Gradient::default_for(GradientKind::Linear, Color::BLACK); g.stops = vec![ColorStop { position: 0.0, color: Color::BLACK }, ColorStop { position: 1.0, color: Color::WHITE }]; - n.fills = vec![solid(0.5, 0.5, 0.5), Paint { kind: PaintKind::Gradient(g), opacity: 1.0, visible: true, blend: BlendMode::Normal }]; + n.fills = vec![ + solid(0.5, 0.5, 0.5), + Paint { kind: PaintKind::Gradient(g), opacity: 1.0, visible: true, blend: BlendMode::Normal, color_alpha: 1.0 }, + ]; n.strokes = vec![solid(0.0, 0.0, 0.0), solid(1.0, 0.0, 0.0)]; n.effects = vec![Effect { kind: EffectKind::DropShadow(Shadow { diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index 0cad2122..234e1459 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -25,10 +25,10 @@ use figcraft_doc::proto::{ Action, Direction, Easing, GAMEPAD_BUTTONS, Interaction, KeyMods, Overflow, OverlayPosition, ScrollPosition, Transition, Trigger, }; use figcraft_doc::{ - AutoLayout, BoolOp, ContentAlign, CrossAlign, Document, EffectKind, ExportSize, GridAlign, GridKind, HConstraint, LayoutMode, LetterSpacing, - LineHeight, ListStyle, MainAlign, MaskType, Node, NodeId, NodeKind, NoiseMode, PropDef, Sizing, StrokeAlign, StrokeCap, StrokeJoin, StyleKind, - TextAlignH, TextAlignV, TextAutoResize, TextCase, TextData, TextDecoration, TextStyle, VConstraint, VariableType, VariableValue, VectorNetwork, - WindingRule, + AutoLayout, BoolOp, ContentAlign, CrossAlign, Document, EffectKind, ExportSize, GridAlign, GridKind, GridTrack, HConstraint, LayoutMode, + LetterSpacing, LineHeight, ListStyle, MainAlign, MaskType, Node, NodeId, NodeKind, NoiseMode, PropDef, Sizing, StrokeAlign, StrokeCap, + StrokeJoin, StyleKind, TextAlignH, TextAlignV, TextAutoResize, TextCase, TextData, TextDecoration, TextStyle, VConstraint, VariableType, + VariableValue, VectorNetwork, WindingRule, }; use serde_json::Value; @@ -168,7 +168,12 @@ message PrototypeStartingPoint string name=1, string position=3 message OverlayBackgroundAppearance OverlayBackgroundType backgroundType=1, Color backgroundColor=2 message GUIDPositionMapEntry GUID id=1, string position=2 message GUIDPositionMap GUIDPositionMapEntry[] entries=1 -message NodeChange GUID guid=1, NodePhase phase=2, ParentIndex parentIndex=3, NodeType type=4, string name=5, bool visible=6, bool locked=7, float opacity=8, BlendMode blendMode=9, Vector size=11, Matrix transform=12, bool mask=16, MaskType maskType=317, float backgroundOpacity=19, bool backgroundEnabled=15, bool internalOnly=142, string description=318, StyleType styleType=163, string sortPosition=320, StyleId styleIdForFill=332, StyleId styleIdForStrokeFill=333, StyleId styleIdForText=334, StyleId styleIdForEffect=335, StyleId styleIdForGrid=336, VariableDataMap variableConsumptionMap=307, VariableModeBySetMap variableModeBySetMap=316, VariableSetMode[] variableSetModes=312, VariableSetID variableSetID=313, VariableResolvedDataType variableResolvedType=314, VariableDataValues variableDataValues=315, VariableScope[] variableScopes=353, DocumentColorProfile documentColorProfile=349, Paint[] fillPaints=38, Paint[] strokePaints=39, float strokeWeight=26, StrokeAlign strokeAlign=29, StrokeJoin strokeJoin=31, StrokeCap strokeCap=30, float[] dashPattern=13, float miterLimit=25, bool borderStrokeWeightsIndependent=299, float borderTopWeight=295, float borderRightWeight=298, float borderBottomWeight=296, float borderLeftWeight=297, Effect[] effects=43, float cornerRadius=20, float cornerSmoothing=160, bool rectangleCornerRadiiIndependent=149, float rectangleTopLeftCornerRadius=145, float rectangleTopRightCornerRadius=146, float rectangleBottomRightCornerRadius=148, float rectangleBottomLeftCornerRadius=147, ConstraintType horizontalConstraint=28, ConstraintType verticalConstraint=37, bool frameMaskDisabled=115, bool resizeToFit=117, bool isStateGroup=225, bool sectionContentsHidden=283, Color backgroundColor=50, StackMode stackMode=105, float stackSpacing=107, float stackCounterSpacing=324, StackWrap stackWrap=323, StackCounterAlignContent stackCounterAlignContent=343, float stackHorizontalPadding=209, float stackVerticalPadding=210, float stackPaddingRight=233, float stackPaddingBottom=234, StackJustify stackPrimaryAlignItems=230, StackAlign stackCounterAlignItems=231, StackSize stackPrimarySizing=229, StackSize stackCounterSizing=221, bool bordersTakeSpace=294, bool stackReverseZIndex=271, float gridColumnGap=438, float gridRowGap=437, GUIDPositionMap gridColumns=436, GUIDPositionMap gridRows=435, float stackChildPrimaryGrow=232, StackCounterAlign stackChildAlignSelf=236, StackPositioning stackPositioning=269, OptionalVector minSize=325, OptionalVector maxSize=326, GUID gridColumnAnchor=440, GUID gridRowAnchor=439, uint gridColumnSpan=442, uint gridRowSpan=441, LayoutGrid[] layoutGrids=47, ExportSettings[] exportSettings=45, TextData textData=42, FontName fontName=41, float fontSize=21, Number lineHeight=40, Number letterSpacing=165, TextCase textCase=34, TextDecoration textDecoration=35, TextAlignHorizontal textAlignHorizontal=32, TextAlignVertical textAlignVertical=33, TextAutoResize textAutoResize=46, TextTruncation textTruncation=280, int maxLines=351, float paragraphSpacing=23, float paragraphIndent=22, Hyperlink hyperlink=223, bool autoRename=14, bool fontVariantCommonLigatures=127, bool fontVariantContextualLigatures=128, string fontVersion=202, uint textUserLayoutVersion=203, uint textExplicitLayoutVersion=396, uint textBidiVersion=279, float textTracking=27, bool textDecorationSkipInk=412, EmojiImageSet emojiImageSet=391, uint styleID=49, Path[] fillGeometry=51, Path[] strokeGeometry=52, VectorData vectorData=48, VectorMirror handleMirroring=44, uint count=10, float starInnerScale=24, ArcData arcData=195, BooleanOperation booleanOperation=36, string symbolDescription=144, VariableWidthPoint[] variableWidthPoints=447, PluginData[] pluginData=204, SymbolData symbolData=113, NodeChange[] derivedSymbolData=125, GUID overriddenSymbolID=143, ComponentPropDef[] componentPropDefs=266, ComponentPropAssignment[] componentPropAssignments=268, VariableDataMap parameterConsumptionMap=445, GUIDPath guidPath=111, PrototypeInteraction[] prototypeInteractions=226, PrototypeStartingPoint prototypeStartingPoint=249, ScrollDirection scrollDirection=159, ScrollBehavior scrollBehavior=186, OverlayPositionType overlayPositionType=198, OverlayBackgroundInteraction overlayBackgroundInteraction=200, OverlayBackgroundAppearance overlayBackgroundAppearance=201 +enum GridTrackSizingType FLEX=0 FIXED=1 HUG=2 UNKNOWN=3 +message GridTrackSizeFunction GridTrackSizingType type=1, float value=2 +message GridTrackSize GridTrackSizeFunction minSizing=1, GridTrackSizeFunction maxSizing=2 +message GridTrackSizeEntry GUID id=1, GridTrackSize trackSize=2 +message GridTrackSizeMap GridTrackSizeEntry[] entries=1 +message NodeChange GUID guid=1, NodePhase phase=2, ParentIndex parentIndex=3, NodeType type=4, string name=5, bool visible=6, bool locked=7, float opacity=8, BlendMode blendMode=9, Vector size=11, Matrix transform=12, bool mask=16, MaskType maskType=317, float backgroundOpacity=19, bool backgroundEnabled=15, bool internalOnly=142, string description=318, StyleType styleType=163, string sortPosition=320, StyleId styleIdForFill=332, StyleId styleIdForStrokeFill=333, StyleId styleIdForText=334, StyleId styleIdForEffect=335, StyleId styleIdForGrid=336, VariableDataMap variableConsumptionMap=307, VariableModeBySetMap variableModeBySetMap=316, VariableSetMode[] variableSetModes=312, VariableSetID variableSetID=313, VariableResolvedDataType variableResolvedType=314, VariableDataValues variableDataValues=315, VariableScope[] variableScopes=353, DocumentColorProfile documentColorProfile=349, Paint[] fillPaints=38, Paint[] strokePaints=39, float strokeWeight=26, StrokeAlign strokeAlign=29, StrokeJoin strokeJoin=31, StrokeCap strokeCap=30, float[] dashPattern=13, float miterLimit=25, bool borderStrokeWeightsIndependent=299, float borderTopWeight=295, float borderRightWeight=298, float borderBottomWeight=296, float borderLeftWeight=297, Effect[] effects=43, float cornerRadius=20, float cornerSmoothing=160, bool rectangleCornerRadiiIndependent=149, float rectangleTopLeftCornerRadius=145, float rectangleTopRightCornerRadius=146, float rectangleBottomRightCornerRadius=148, float rectangleBottomLeftCornerRadius=147, ConstraintType horizontalConstraint=28, ConstraintType verticalConstraint=37, bool frameMaskDisabled=115, bool resizeToFit=117, bool isStateGroup=225, bool sectionContentsHidden=283, Color backgroundColor=50, StackMode stackMode=105, float stackSpacing=107, float stackCounterSpacing=324, StackWrap stackWrap=323, StackCounterAlignContent stackCounterAlignContent=343, float stackHorizontalPadding=209, float stackVerticalPadding=210, float stackPaddingRight=233, float stackPaddingBottom=234, StackJustify stackPrimaryAlignItems=230, StackAlign stackCounterAlignItems=231, StackSize stackPrimarySizing=229, StackSize stackCounterSizing=221, bool bordersTakeSpace=294, bool stackReverseZIndex=271, float gridColumnGap=438, float gridRowGap=437, GUIDPositionMap gridColumns=436, GUIDPositionMap gridRows=435, GridTrackSizeMap gridColumnsSizing=443, GridTrackSizeMap gridRowsSizing=444, float stackChildPrimaryGrow=232, StackCounterAlign stackChildAlignSelf=236, StackPositioning stackPositioning=269, OptionalVector minSize=325, OptionalVector maxSize=326, GUID gridColumnAnchor=440, GUID gridRowAnchor=439, uint gridColumnSpan=442, uint gridRowSpan=441, LayoutGrid[] layoutGrids=47, ExportSettings[] exportSettings=45, TextData textData=42, FontName fontName=41, float fontSize=21, Number lineHeight=40, Number letterSpacing=165, TextCase textCase=34, TextDecoration textDecoration=35, TextAlignHorizontal textAlignHorizontal=32, TextAlignVertical textAlignVertical=33, TextAutoResize textAutoResize=46, TextTruncation textTruncation=280, int maxLines=351, float paragraphSpacing=23, float paragraphIndent=22, Hyperlink hyperlink=223, bool autoRename=14, bool fontVariantCommonLigatures=127, bool fontVariantContextualLigatures=128, string fontVersion=202, uint textUserLayoutVersion=203, uint textExplicitLayoutVersion=396, uint textBidiVersion=279, float textTracking=27, bool textDecorationSkipInk=412, EmojiImageSet emojiImageSet=391, uint styleID=49, Path[] fillGeometry=51, Path[] strokeGeometry=52, VectorData vectorData=48, VectorMirror handleMirroring=44, uint count=10, float starInnerScale=24, ArcData arcData=195, BooleanOperation booleanOperation=36, string symbolDescription=144, VariableWidthPoint[] variableWidthPoints=447, PluginData[] pluginData=204, SymbolData symbolData=113, NodeChange[] derivedSymbolData=125, GUID overriddenSymbolID=143, ComponentPropDef[] componentPropDefs=266, ComponentPropAssignment[] componentPropAssignments=268, VariableDataMap parameterConsumptionMap=445, GUIDPath guidPath=111, PrototypeInteraction[] prototypeInteractions=226, PrototypeStartingPoint prototypeStartingPoint=249, ScrollDirection scrollDirection=159, ScrollBehavior scrollBehavior=186, OverlayPositionType overlayPositionType=198, OverlayBackgroundInteraction overlayBackgroundInteraction=200, OverlayBackgroundAppearance overlayBackgroundAppearance=201 struct Blob byte[] bytes message Message MessageType type=1, uint sessionID=2, NodeChange[] nodeChanges=4, Blob[] blobs=6 "; @@ -277,6 +282,37 @@ fn s(x: &str) -> Kv<'static> { fn b(x: bool) -> Kv<'static> { Kv::Bool(x) } + +/// A track's size as the format's enum name and value, when both sizing functions agree (the +/// importer keeps only those). `None`: the format has no name for it, so an equal track stands in. +fn grid_track_size(track: &GridTrack) -> Option<(&'static str, f64)> { + let value = track.value; + let fits = value.is_finite() && (0.0..=crate::fig::map::MAX_GRID_TRACK_VALUE).contains(&value); + match track.sizing { + Sizing::Fixed if fits => Some(("FIXED", value)), + Sizing::Fill if fits && value > 0.0 => Some(("FLEX", value)), + Sizing::Hug => Some(("HUG", 0.0)), + _ => None, + } +} + +/// A grid axis's track sizes, keyed by track id like the order map. A track the document doesn't +/// set is left out, and one the format can't hold keeps its id (the import equalises it) and sets +/// `dropped`. +fn grid_sizing(tracks: &[GridTrack], ids: &[u32], dropped: &mut bool) -> Option> { + let mut entries = Vec::new(); + for (track, id) in tracks.iter().zip(ids) { + match grid_track_size(track) { + Some((kind, value)) => { + let size = || o(vec![("type", e(kind)), ("value", fl(value))]); + entries.push(o(vec![("id", guid(AUX, *id)), ("trackSize", o(vec![("minSizing", size()), ("maxSizing", size())]))])); + } + None => *dropped = true, + } + } + (!entries.is_empty()).then(|| o(vec![("entries", arr(entries))])) +} + fn guid(session: u32, local: u32) -> Kv<'static> { o(vec![("sessionID", u(session)), ("localID", u(local))]) } @@ -1400,25 +1436,7 @@ impl<'d> Exporter<'d> { ("stackReverseZIndex", b(l.reverse_z)), ]); if grid { - // Instance layers share their main layer's track ids, so unchanged tracks and the cells - // their layers sit in still match. - let key = self.main_layer(n.id); - let (want_c, want_r) = (l.grid_columns.min(1000) as usize, l.grid_rows.min(1000) as usize); - let (cols, rows) = match self.tracks.get(&key) { - Some((c, r)) if (c.len(), r.len()) == (want_c, want_r) => (c.clone(), r.clone()), - _ => ((0..want_c).map(|_| self.next_aux()).collect(), (0..want_r).map(|_| self.next_aux()).collect()), - }; - let map = |ids: &[u32]| { - o(vec![( - "entries", - arr(ids.iter().enumerate().map(|(i, t)| o(vec![("id", guid(AUX, *t)), ("position", s(&position(i)))])).collect()), - )]) - }; - f.extend([("gridColumnGap", fl(l.gap)), ("gridRowGap", fl(l.cross_gap)), ("gridColumns", map(&cols)), ("gridRows", map(&rows))]); - if l.column_tracks.iter().chain(&l.row_tracks).any(|t| t.sizing != Sizing::Fill) { - self.warn("grid tracks of fixed or content size were written as equal tracks"); - } - self.tracks.insert(key, (cols, rows)); + self.grid_layout(n, l, f); } else { let spread = l.content_align == ContentAlign::SpaceBetween; f.extend([ @@ -1429,6 +1447,34 @@ impl<'d> Exporter<'d> { } } + /// A grid layout's track gaps, order and sizes. + fn grid_layout(&mut self, n: &Node, l: &AutoLayout, f: &mut Fields) { + // Instance layers share their main layer's track ids, so unchanged tracks and the cells + // their layers sit in still match. + let key = self.main_layer(n.id); + let (want_c, want_r) = (l.grid_columns.min(1000) as usize, l.grid_rows.min(1000) as usize); + let (cols, rows) = match self.tracks.get(&key) { + Some((c, r)) if (c.len(), r.len()) == (want_c, want_r) => (c.clone(), r.clone()), + _ => ((0..want_c).map(|_| self.next_aux()).collect(), (0..want_r).map(|_| self.next_aux()).collect()), + }; + let map = |ids: &[u32]| { + o(vec![("entries", arr(ids.iter().enumerate().map(|(i, t)| o(vec![("id", guid(AUX, *t)), ("position", s(&position(i)))])).collect()))]) + }; + f.extend([("gridColumnGap", fl(l.gap)), ("gridRowGap", fl(l.cross_gap)), ("gridColumns", map(&cols)), ("gridRows", map(&rows))]); + let mut dropped = false; + let column_sizing = grid_sizing(&l.column_tracks, &cols, &mut dropped); + let row_sizing = grid_sizing(&l.row_tracks, &rows, &mut dropped); + if dropped { + self.warn("grid tracks whose size the format can't hold were written as equal tracks"); + } + for (name, sizing) in [("gridColumnsSizing", column_sizing), ("gridRowsSizing", row_sizing)] { + if let Some(v) = sizing { + f.push((name, v)); + } + } + self.tracks.insert(key, (cols, rows)); + } + /// How a layer sizes and sits in its auto layout parent. Min/max sizes are the layer's own /// and are written whatever its parent is. fn child_layout(&mut self, n: &Node, parent: Option<&Node>, f: &mut Fields) { @@ -2374,7 +2420,7 @@ fn unset(key: &str) -> Option> { "styleIdForFill" | "styleIdForStrokeFill" | "styleIdForEffect" | "styleIdForGrid" | "styleIdForText" => { o(vec![("guid", guid(u32::MAX, u32::MAX))]) } - "variableConsumptionMap" | "variableModeBySetMap" => o(vec![("entries", arr(Vec::new()))]), + "variableConsumptionMap" | "variableModeBySetMap" | "gridColumnsSizing" | "gridRowsSizing" => o(vec![("entries", arr(Vec::new()))]), "mask" | "rectangleCornerRadiiIndependent" | "borderStrokeWeightsIndependent" => b(false), "effects" | "dashPattern" | "layoutGrids" | "exportSettings" | "prototypeInteractions" | "fillGeometry" | "variableWidthPoints" => { arr(Vec::new()) diff --git a/crates/format/src/fig/export_tests.rs b/crates/format/src/fig/export_tests.rs index 0eac9571..1f0fe172 100644 --- a/crates/format/src/fig/export_tests.rs +++ b/crates/format/src/fig/export_tests.rs @@ -74,7 +74,7 @@ fn paint(r: &mut Lcg, image: &str) -> Paint { p.kind } }; - Paint { kind, opacity: r.pick(&[1.0, 0.5, 0.25]), visible: r.chance(85), blend: r.pick(&BLENDS) } + Paint { kind, opacity: r.pick(&[1.0, 0.5, 0.25]), visible: r.chance(85), blend: r.pick(&BLENDS), color_alpha: 1.0 } } fn effect(r: &mut Lcg) -> Effect { @@ -1022,6 +1022,81 @@ fn junk_documents_never_panic() { } } +/// Grid track sizes reach the file and come back: fixed px, fractional fill and hug tracks, and a +/// track the format has no name for leaves an equal track behind instead of failing the write. +#[test] +fn grid_track_sizes_round_trip() { + let mut doc = Document::new("File"); + let page = doc.first_page().unwrap(); + let grid = doc.create(page, NodeKind::Frame, "Grid", 0.0, 0.0, 400.0, 200.0).unwrap(); + { + let node = doc.node_mut(grid).unwrap(); + node.layout = Some(AutoLayout { + mode: LayoutMode::Grid, + grid_columns: 3, + grid_rows: 2, + column_tracks: vec![GridTrack::fixed(120.0), GridTrack::fill(2.0), GridTrack::hug()], + row_tracks: vec![GridTrack::fill(1.0), GridTrack::fixed(50.0)], + ..AutoLayout::default() + }); + } + let (bytes, warnings) = export(&doc, None).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let (back, iw) = import(&bytes, "File").unwrap(); + assert!(iw.is_empty(), "{iw:?}"); + let l = back.node(back.pages[0]).and_then(|p| p.children.first()).and_then(|c| back.node(*c)).and_then(|n| n.layout.clone()).unwrap(); + assert_eq!(l.column_tracks, vec![GridTrack::fixed(120.0), GridTrack::fill(2.0), GridTrack::hug()]); + assert_eq!(l.row_tracks, vec![GridTrack::fill(1.0), GridTrack::fixed(50.0)]); + + // A negative fixed track is no size the format can name: the write still succeeds, the track + // falls back to an equal one, and the warning says so. + let mut odd = doc.clone(); + if let Some(l) = odd.node_mut(grid).and_then(|n| n.layout.as_mut()) { + l.column_tracks[0] = GridTrack::fixed(-5.0); + } + let (bytes, warnings) = export(&odd, None).unwrap(); + assert_eq!(warnings, vec!["grid tracks whose size the format can't hold were written as equal tracks".to_string()]); + let (back, _) = import(&bytes, "File").unwrap(); + let l = back.node(back.pages[0]).and_then(|p| p.children.first()).and_then(|c| back.node(*c)).and_then(|n| n.layout.clone()).unwrap(); + assert_eq!(l.column_tracks[0], GridTrack::fill(1.0), "an unwritable track reads back equal"); +} + +/// An instance layer whose grid tracks are equal doesn't read back its main layer's track sizes. +#[test] +fn instance_grid_tracks_clear_the_mains_sizes() { + let mut doc = Document::new("File"); + let page = doc.first_page().unwrap(); + let main = doc.create(page, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, "Main", 0.0, 0.0, 400.0, 200.0).unwrap(); + let grid = doc.create(main, NodeKind::Frame, "Grid", 0.0, 0.0, 400.0, 200.0).unwrap(); + let layout = AutoLayout { + mode: LayoutMode::Grid, + grid_columns: 2, + grid_rows: 1, + column_tracks: vec![GridTrack::fixed(120.0); 2], + ..AutoLayout::default() + }; + doc.node_mut(grid).unwrap().layout = Some(layout.clone()); + let inst = doc.alloc_id(); + let mut n = doc.node(main).unwrap().clone(); + (n.id, n.kind, n.parent, n.children) = (inst, NodeKind::Instance { component: main, props: BTreeMap::new() }, None, Vec::new()); + doc.insert(n, page, None).unwrap(); + let mut layer = doc.node(grid).unwrap().clone(); + (layer.id, layer.parent, layer.source) = (doc.alloc_id(), None, Some(grid)); + layer.layout = Some(AutoLayout { column_tracks: Vec::new(), ..layout }); + doc.insert(layer, inst, None).unwrap(); + let (bytes, _) = export(&doc, None).unwrap(); + let (back, _) = import(&bytes, "File").unwrap(); + let tracks = |inside: bool| { + let n = back.nodes().find(|n| { + n.name == "Grid" + && back.ancestors(n.id).iter().any(|a| matches!(back.node(*a).map(|p| &p.kind), Some(NodeKind::Instance { .. }))) == inside + }); + n.and_then(|n| n.layout.clone()).unwrap().column_tracks + }; + assert_eq!(tracks(false), vec![GridTrack::fixed(120.0); 2]); + assert!(tracks(true).iter().all(|t| *t == GridTrack::fill(1.0)), "{:?}", tracks(true)); +} + /// A frame's own min/max size survives whatever its parent is, not only under auto layout. #[test] fn frame_size_limits_survive_without_auto_layout_parent() { diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 8e467151..ec0dd8e4 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -24,7 +24,7 @@ const MAX_DEPTH: usize = 256; /// Most path commands read from one geometry blob. const MAX_COMMANDS: usize = 200_000; const MAX_GRID_TRACKS: usize = 1_000; -const MAX_GRID_TRACK_VALUE: f64 = 1e6; +pub(super) const MAX_GRID_TRACK_VALUE: f64 = 1e6; /// Id of "no style". const NO_STYLE: &str = "4294967295:4294967295"; /// Longest asset `key` or `version` kept for resolving references given as assets. diff --git a/crates/format/src/rest/props.rs b/crates/format/src/rest/props.rs index efb7710d..f5be3d27 100644 --- a/crates/format/src/rest/props.rs +++ b/crates/format/src/rest/props.rs @@ -123,10 +123,13 @@ pub(super) fn paints(cx: &mut Ctx, v: &Value, k: &str) -> Option> { fn paint(cx: &mut Ctx, p: &Value) -> Option { let ty = text(p, "type")?; let mut opacity = num(p, "opacity").unwrap_or(1.0).clamp(0.0, 1.0) as f32; + // The colour's alpha goes into the opacity as for a typed colour (`Paint::solid`). + let mut color_alpha = 1.0; let kind = match ty { "SOLID" => { let c = p.get("color").and_then(color).unwrap_or(Color::BLACK); opacity *= c.a; + color_alpha = c.a; PaintKind::Solid { color: c.with_alpha(1.0) } } "GRADIENT_LINEAR" => PaintKind::Gradient(gradient(GradientKind::Linear, p)), @@ -139,7 +142,7 @@ fn paint(cx: &mut Ctx, p: &Value) -> Option { return None; } }; - Some(Paint { kind, opacity, visible: flag(p, "visible").unwrap_or(true), blend: paint_blend(p) }) + Some(Paint { kind, opacity, visible: flag(p, "visible").unwrap_or(true), blend: paint_blend(p), color_alpha }) } /// Gradient handles are already in the node's unit box (start, end, width), like ours. diff --git a/crates/format/src/rest/tests.rs b/crates/format/src/rest/tests.rs index 1b3f85b6..f9acf2ea 100644 --- a/crates/format/src/rest/tests.rs +++ b/crates/format/src/rest/tests.rs @@ -162,6 +162,7 @@ fn paints_gradients_and_image_placeholders() { let s = &named(&doc, "Solid").fills[0]; assert!(matches!(s.kind, PaintKind::Solid { color } if color.r == 1.0 && color.a == 1.0)); assert!(close(f64::from(s.opacity), 0.25), "paint opacity × colour alpha"); + assert!(close(f64::from(s.color_alpha), 0.5), "the colour's part, which a bound variable replaces"); assert!(!s.visible && s.blend == BlendMode::Screen); let PaintKind::Gradient(g) = &named(&doc, "Linear").fills[0].kind else { panic!() }; assert_eq!((g.kind, g.from, g.to, g.width), (GradientKind::Linear, (0.0, 0.5), (1.0, 0.5), (0.0, 1.0))); diff --git a/crates/geom/src/measure.rs b/crates/geom/src/measure.rs index 7acfe4ed..0c24aa9d 100644 --- a/crates/geom/src/measure.rs +++ b/crates/geom/src/measure.rs @@ -91,6 +91,27 @@ pub fn polylines(path: &BezPath) -> Option> { polylines_with_limit(path, MAX_SAMPLES) } +fn flatten_curve_segment(el: PathEl, seg_start: Point, limit: usize, count: &mut usize, cur: &mut Polyline) -> bool { + let mut over = false; + kurbo::flatten([PathEl::MoveTo(seg_start), el], FLATTEN_TOL, |fel| { + if over { + return; + } + if let PathEl::LineTo(p) = fel { + *count += 1; + if *count > limit { + over = true; + return; + } + if cur.points.is_empty() { + cur.push(seg_start); + } + cur.push(p); + } + }); + !over +} + /// [`polylines`] with an explicit sample budget, so tests can use a small one. /// /// The budget is enforced *while* flattening, not after it (issue #204): the input element @@ -141,25 +162,7 @@ fn polylines_with_limit(path: &BezPath, limit: usize) -> Option> { PathEl::QuadTo(_, end) | PathEl::CurveTo(_, _, end) => { // Flatten this one segment on its own, so an over-budget curve aborts // here instead of after the whole path has been flattened. - let seg_start = pen; - let mut over = false; - kurbo::flatten([PathEl::MoveTo(seg_start), *el], FLATTEN_TOL, |fel| { - if over { - return; - } - if let PathEl::LineTo(p) = fel { - count += 1; - if count > limit { - over = true; - return; - } - if cur.points.is_empty() { - cur.push(seg_start); - } - cur.push(p); - } - }); - if over { + if !flatten_curve_segment(*el, pen, limit, &mut count, &mut cur) { return None; } pen = end; diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index aad3530e..9b8bf7be 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -59,7 +59,7 @@ fn error(id: Value, code: i64, message: impl Into) -> Value { } fn panic_message(payload: &(dyn std::any::Any + Send)) -> String { - payload.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| payload.downcast_ref::().cloned()).unwrap_or_else(|| "panic".into()) + payload.downcast_ref::<&str>().map(ToString::to_string).or_else(|| payload.downcast_ref::().cloned()).unwrap_or_else(|| "panic".into()) } impl Server { diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index fb9cfcfe..324af4d4 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -13,6 +13,8 @@ mod brush_tests; mod ops_tests; mod points; #[cfg(test)] +mod rounded_side_strokes_tests; +#[cfg(test)] mod sides_tests; pub mod width; #[cfg(test)] @@ -451,6 +453,104 @@ pub fn rect_side_strokes(w: f64, h: f64, sides: [f64; 4], align: StrokeAlign) -> p } +/// Per-side stroke of a rectangle with corner radii `[tl, tr, br, bl]`, like CSS borders: the outer +/// edge is the rounded rectangle (center and outside grow its box by half or all of each side's +/// weight, and each non-zero radius with it); the inner edge is inset by each side's weight, with +/// elliptical corners `max(0, R − weight)` across and down (square where either is 0). Corners +/// that stay circular are smoothed like the shape's. An even-odd ring whose inner edge runs against +/// the outer one, so it is a ring under non-zero too. +pub fn rounded_side_strokes(w: f64, h: f64, radii: [f64; 4], smoothing: f64, sides: [f64; 4], align: StrokeAlign) -> BezPath { + let [t, r, b, l] = sides.map(non_negative); + if t + r + b + l <= 0.0 { + return BezPath::new(); + } + let f = match align { + StrokeAlign::Inside => 0.0, + StrokeAlign::Center => 0.5, + StrokeAlign::Outside => 1.0, + }; + // Clamped like the fill: at most half the shorter side. + let max_r = non_negative(w.min(h)) / 2.0; + let radii = radii.map(|v| non_negative(v).min(max_r)); + // Each corner's radius with the weights across and down it, clockwise from the top-left. + let corners = [(radii[0], l, t), (radii[1], r, t), (radii[2], r, b), (radii[3], l, b)]; + let outer = if align == StrokeAlign::Inside { + figcraft_geom::rounded_rect(w, h, radii, smoothing) + } else { + let grown = corners.map(|(rr, x, y)| if rr > 0.0 { (rr + x * f, rr + y * f) } else { (0.0, 0.0) }); + corner_rect(figcraft_geom::Rect::new(-l * f, -t * f, w + r * f, h + b * f), grown, smoothing) + }; + let k = 1.0 - f; + let inner = figcraft_geom::Rect::new(l * k, t * k, w - r * k, h - b * k); + if inner.width() <= 0.0 || inner.height() <= 0.0 { + return outer; + } + let (inset, fit) = fit_corners(inner, corners.map(|(rr, x, y)| ((rr - x * k).max(0.0), (rr - y * k).max(0.0)))); + // The hole's corners sit on the outer ones, inside them, unless they were shrunk to fit or + // smoothed: then it may leave the outer edge. + ring(outer, &corner_rect(inner, inset, smoothing), fit < 1.0 || smoothing > 0.0) +} + +/// Rectangle `r` with `(rx, ry)` corners, smoothed like `figcraft_geom::rounded_rect` where every +/// corner is circular (smoothing is defined for those only). +fn corner_rect(r: figcraft_geom::Rect, corners: [(f64, f64); 4], smoothing: f64) -> BezPath { + if smoothing > 0.0 && corners.iter().all(|(rx, ry)| rx == ry) { + return kurbo::Affine::translate((r.x0, r.y0)) * figcraft_geom::rounded_rect(r.width(), r.height(), corners.map(|c| c.0), smoothing); + } + elliptical_rect(r, corners) +} + +fn non_negative(v: f64) -> f64 { + if v.is_finite() { v.max(0.0) } else { 0.0 } +} + +/// `outer` with `hole` taken out: cut out of it when the hole may leave it, else as an even-odd +/// ring whose hole runs against it. +fn ring(outer: BezPath, hole: &BezPath, cut: bool) -> BezPath { + if cut && let Ok(p) = boolean_step(&outer, hole, BoolOp::Subtract) { + return p; + } + let mut p = outer; + p.extend(hole.reverse_subpaths().elements().iter().copied()); + p +} + +/// Corner radii `(rx, ry)` for the inner box `inner`, square where either is 0, all shrunk by one +/// factor (returned too) where two on an edge add up to more than it, as CSS shrinks radii. +fn fit_corners(inner: figcraft_geom::Rect, corners: [(f64, f64); 4]) -> ([(f64, f64); 4], f64) { + let c = corners.map(|(rx, ry)| if rx > 0.0 && ry > 0.0 { (rx, ry) } else { (0.0, 0.0) }); + let [tl, tr, br, bl] = c; + let edges = [(inner.width(), tl.0 + tr.0), (inner.width(), bl.0 + br.0), (inner.height(), tl.1 + bl.1), (inner.height(), tr.1 + br.1)]; + let fit = edges.iter().filter(|(_, sum)| *sum > 0.0).fold(1.0_f64, |m, (len, sum)| m.min(len / sum)); + (c.map(|(rx, ry)| (rx * fit, ry * fit)), fit) +} + +/// Rectangle `r` with an `(rx, ry)` quarter ellipse at each corner `[tl, tr, br, bl]`, drawn like +/// `figcraft_geom::rounded_rect` (circular corners come out the same). +fn elliptical_rect(r: figcraft_geom::Rect, [tl, tr, br, bl]: [(f64, f64); 4]) -> BezPath { + use std::f64::consts::{FRAC_PI_2, PI}; + // Each corner, the direction of its ellipse centre from it, and the angle its quarter starts at. + let quarters = [ + (Point::new(r.x1, r.y0), (-1.0, 1.0), tr, -FRAC_PI_2), + (Point::new(r.x1, r.y1), (-1.0, -1.0), br, 0.0), + (Point::new(r.x0, r.y1), (1.0, -1.0), bl, FRAC_PI_2), + (Point::new(r.x0, r.y0), (1.0, 1.0), tl, PI), + ]; + let mut p = BezPath::new(); + p.move_to((r.x0 + tl.0, r.y0)); + for (corner, (sx, sy), (rx, ry), start) in quarters { + if rx > 0.0 && ry > 0.0 { + let c = Point::new(corner.x + sx * rx, corner.y + sy * ry); + p.line_to((c.x + rx * start.cos(), c.y + ry * start.sin())); + kurbo::Arc::new(c, (rx, ry), start, FRAC_PI_2, 0.0).to_cubic_beziers(0.01, |a, b, e| p.curve_to(a, b, e)); + } else { + p.line_to(corner); + } + } + p.close_path(); + p +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/pathops/src/rounded_side_strokes_tests.rs b/crates/pathops/src/rounded_side_strokes_tests.rs new file mode 100644 index 00000000..9c2f387e --- /dev/null +++ b/crates/pathops/src/rounded_side_strokes_tests.rs @@ -0,0 +1,124 @@ +//! Per-side stroke weights on rounded rectangles, drawn like CSS borders: square corners, a bar +//! along a rounded edge, tapers through corners, alignments, inner corners that no longer fit, +//! smoothing and degenerate weights. + +use figcraft_doc::StrokeAlign; +use figcraft_geom::kurbo::{BezPath, Point, Shape}; +use figcraft_geom::rounded_rect; + +use crate::{rect_side_strokes, rounded_side_strokes}; + +const W: f64 = 120.0; +const H: f64 = 60.0; + +/// The area of a ring whose hole runs against its outer edge. +fn area(p: &BezPath) -> f64 { + p.area().abs() +} + +fn painted(p: &BezPath, x: f64, y: f64) -> bool { + p.winding(Point::new(x, y)) % 2 != 0 +} + +#[test] +fn square_corners_paint_what_rect_side_strokes_paints() { + for align in [StrokeAlign::Inside, StrokeAlign::Center, StrokeAlign::Outside] { + let sides = [1.0, 2.0, 3.0, 4.0]; + let a = area(&rounded_side_strokes(W, H, [0.0; 4], 0.0, sides, align)); + assert!((a - area(&rect_side_strokes(W, H, sides, align))).abs() < 1e-6, "{align:?}: {a}"); + } +} + +/// `border-l-4 rounded-r-lg`. +#[test] +fn a_left_border_on_a_right_rounded_box_is_a_straight_bar() { + let bar = rounded_side_strokes(W, H, [0.0, 8.0, 8.0, 0.0], 0.0, [0.0, 0.0, 0.0, 4.0], StrokeAlign::Inside); + assert!((area(&bar) - 4.0 * H).abs() < 1e-6, "{}", area(&bar)); + assert!(painted(&bar, 2.0, 30.0)); + for (x, y) in [(119.9, 30.0), (117.0, 1.0), (60.0, 0.1), (60.0, 59.9), (4.1, 30.0)] { + assert!(!painted(&bar, x, y), "({x}, {y})"); + } +} + +#[test] +fn one_weight_on_every_side_is_the_uniform_ring() { + let ring = rounded_side_strokes(W, H, [8.0; 4], 0.0, [2.0; 4], StrokeAlign::Inside); + let want = area(&rounded_rect(W, H, [8.0; 4], 0.0)) - area(&rounded_rect(W - 4.0, H - 4.0, [6.0; 4], 0.0)); + assert!((area(&ring) - want).abs() < 1e-6, "{} vs {want}", area(&ring)); + assert_eq!(ring.winding(Point::new(60.0, 30.0)), 0, "a hole under non-zero too"); +} + +#[test] +fn center_and_outside_grow_the_box_and_its_corners() { + for (align, out, inset) in [(StrokeAlign::Center, 1.0, 1.0), (StrokeAlign::Outside, 2.0, 0.0)] { + let a = area(&rounded_side_strokes(W, H, [8.0; 4], 0.0, [2.0; 4], align)); + let outer = area(&rounded_rect(W + 2.0 * out, H + 2.0 * out, [8.0 + out; 4], 0.0)); + let hole = area(&rounded_rect(W - 2.0 * inset, H - 2.0 * inset, [8.0 - inset; 4], 0.0)); + assert!((a - (outer - hole)).abs() < 1e-6, "{align:?}: {a} vs {}", outer - hole); + } +} + +/// 8 × 7 inner ellipses at the bottom corners. +#[test] +fn a_bottom_border_tapers_into_the_bottom_corners() { + let bottom = rounded_side_strokes(W, H, [8.0; 4], 0.0, [0.0, 0.0, 1.0, 0.0], StrokeAlign::Inside); + assert!(painted(&bottom, 60.0, 59.5) && painted(&bottom, 8.0, 59.5) && painted(&bottom, 112.0, 59.5)); + for (x, y) in [(60.0, 0.5), (0.5, 30.0), (0.5, 52.0), (119.5, 52.0), (4.0, 4.0)] { + assert!(!painted(&bottom, x, y), "({x}, {y})"); + } +} + +#[test] +fn a_weight_past_the_radius_leaves_square_inner_corners() { + let top = rounded_side_strokes(W, H, [8.0; 4], 0.0, [10.0, 0.0, 0.0, 2.0], StrokeAlign::Inside); + assert!(!painted(&top, 2.2, 10.2) && !painted(&top, 119.8, 10.2)); + assert!(painted(&top, 60.0, 9.8) && painted(&top, 1.8, 30.0)); +} + +/// A weight past `w − R` leaves inner corners wider than the inner box: they shrink to fit, so the +/// hole stays inside the outer edge, across or down. +#[test] +fn inner_corners_shrink_to_fit_a_narrow_inner_box() { + let wide = rounded_side_strokes(20.0, 20.0, [10.0; 4], 0.0, [0.0, 0.0, 0.0, 12.0], StrokeAlign::Inside); + assert!(painted(&wide, 6.0, 10.0) && !painted(&wide, 16.0, 10.0)); + assert!(!painted(&wide, 17.5, 3.0) && !painted(&wide, 17.5, 17.0), "nothing past the outer edge"); + let tall = rounded_side_strokes(20.0, 20.0, [10.0; 4], 0.0, [12.0, 0.0, 0.0, 0.0], StrokeAlign::Inside); + assert!(painted(&tall, 10.0, 6.0) && !painted(&tall, 10.0, 16.0)); + assert!(!painted(&tall, 3.0, 17.5) && !painted(&tall, 17.0, 17.5), "nothing past the outer edge"); + let pill = rounded_side_strokes(30.0, 20.0, [10.0; 4], 0.0, [0.0, 0.0, 0.0, 25.0], StrokeAlign::Inside); + assert!(painted(&pill, 12.0, 10.0) && !painted(&pill, 27.0, 10.0)); + assert!(!painted(&pill, 24.0, 0.5) && !painted(&pill, 24.0, 19.5), "nothing past the outer edge"); +} + +#[test] +fn smoothed_corners_keep_the_stroke_inside_the_fill() { + let smooth = rounded_side_strokes(W, H, [16.0; 4], 0.6, [0.0, 0.0, 2.0, 0.0], StrokeAlign::Inside); + let fill = rounded_rect(W, H, [16.0; 4], 0.6); + let grid = (0..1200).flat_map(|i| (0..600).map(move |j| Point::new(f64::from(i) * 0.1 + 0.05, f64::from(j) * 0.1 + 0.05))); + let stray = grid.filter(|pt| painted(&smooth, pt.x, pt.y) && (pt.y < H / 2.0 || fill.winding(*pt) == 0)); + assert_eq!(stray.count(), 0, "painted outside the fill or along the top"); + assert!(painted(&smooth, 60.0, 59.5)); +} + +#[test] +fn center_and_outside_strokes_follow_smoothed_corners() { + for (align, out, inset) in [(StrokeAlign::Center, 1.0, 1.0), (StrokeAlign::Outside, 2.0, 0.0)] { + let a = area(&rounded_side_strokes(W, H, [16.0; 4], 0.6, [2.0; 4], align)); + let outer = area(&rounded_rect(W + 2.0 * out, H + 2.0 * out, [16.0 + out; 4], 0.6)); + let hole = area(&rounded_rect(W - 2.0 * inset, H - 2.0 * inset, [16.0 - inset; 4], 0.6)); + assert!((a - (outer - hole)).abs() < 1e-3, "{align:?}: {a} vs {}", outer - hole); + } +} + +#[test] +fn weights_wider_than_the_box_leave_the_outer_edge() { + let full = rounded_side_strokes(20.0, 10.0, [4.0; 4], 0.0, [6.0; 4], StrokeAlign::Inside); + assert!((area(&full) - area(&rounded_rect(20.0, 10.0, [4.0; 4], 0.0))).abs() < 1e-6); +} + +#[test] +fn no_weight_paints_nothing() { + assert!(rounded_side_strokes(W, H, [8.0; 4], 0.0, [0.0; 4], StrokeAlign::Inside).elements().is_empty()); + let junk = [f64::NAN, -4.0, f64::INFINITY, 0.0]; + assert!(rounded_side_strokes(W, H, [8.0; 4], 0.0, junk, StrokeAlign::Inside).elements().is_empty()); +} diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 3656cf1e..a696e279 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -271,3 +271,30 @@ fn sample_files() { std::fs::write(format!("{stem}-{}.pdf", i + 1), export_pdf(&d, &[*f], &PdfOptions::default()).unwrap()).unwrap(); } } + +/// A tiny PNG whose header claims 30,000 × 30,000 RGBA (3.6 GB decoded) is refused when it is read, +/// before anything is decoded: the PDF writer is craft-libs' patched krilla. +#[test] +fn a_png_that_decodes_too_large_is_refused() { + fn chunk(out: &mut Vec, kind: &[u8; 4], data: &[u8]) { + out.extend_from_slice(&u32::try_from(data.len()).unwrap().to_be_bytes()); + let mut crc = flate2::Crc::new(); + crc.update(kind); + crc.update(data); + out.extend_from_slice(kind); + out.extend_from_slice(data); + out.extend_from_slice(&crc.sum().to_be_bytes()); + } + let mut ihdr = Vec::new(); + ihdr.extend_from_slice(&30_000u32.to_be_bytes()); + ihdr.extend_from_slice(&30_000u32.to_be_bytes()); + ihdr.extend_from_slice(&[8, 6, 0, 0, 0]); + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + std::io::Write::write_all(&mut z, &[0; 64]).unwrap(); + let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); + chunk(&mut png, b"IHDR", &ihdr); + chunk(&mut png, b"IDAT", &z.finish().unwrap()); + chunk(&mut png, b"IEND", &[]); + const { assert!(krilla::CRAFT_UNWIND_SAFE_DROP) }; + assert!(krilla::image::Image::from_png(krilla::Data::from(png), true).is_err()); +} diff --git a/crates/plugin/src/manifest.rs b/crates/plugin/src/manifest.rs index 48e44e88..4bd3590f 100644 --- a/crates/plugin/src/manifest.rs +++ b/crates/plugin/src/manifest.rs @@ -44,7 +44,7 @@ pub struct Manifest { pub main: String, /// The plugin API version the plugin was written for (1 when not given; panels and settings /// need 2, widgets 3). - #[serde(default = "api_version")] + #[serde(default = "api_version", deserialize_with = "api_from_number_or_string")] pub api: u32, #[serde(default)] pub permissions: Permissions, @@ -61,6 +61,25 @@ fn api_version() -> u32 { 1 } +/// `api` as a whole number or a version string (`"1.0.0"`, whose major part is the API version). +fn api_from_number_or_string<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + #[derive(Deserialize)] + #[serde(untagged)] + enum Api { + Number(u32), + Text(String), + } + match Api::deserialize(d)? { + Api::Number(n) => Ok(n), + Api::Text(s) => s + .trim() + .split('.') + .next() + .and_then(|major| major.parse().ok()) + .ok_or_else(|| serde::de::Error::custom(format!("invalid api version `{s}`"))), + } +} + /// What a plugin may do. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct Permissions { diff --git a/crates/plugin/src/tests.rs b/crates/plugin/src/tests.rs index 5d094ab4..a6e2a587 100644 --- a/crates/plugin/src/tests.rs +++ b/crates/plugin/src/tests.rs @@ -462,3 +462,12 @@ fn bad_manifests_are_refused() { assert!(Manifest::parse(junk).is_err(), "accepted {}", junk.chars().take(40).collect::()); } } + +#[test] +fn api_may_be_a_version_string() { + assert_eq!(manifest(json!({"api": "1.0.0"})).unwrap().api, 1); + assert_eq!(manifest(json!({"api": "2"})).unwrap().api, 2); + assert_eq!(manifest(json!({"api": 2})).unwrap().api, 2); + assert!(manifest(json!({"api": "4.0.0"})).is_err()); + assert!(manifest(json!({"api": "x"})).is_err()); +} diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 63b38220..3718df26 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -162,9 +162,13 @@ pub fn stroke_area(n: &Node, path: &BezPath, closed: bool) -> Arc { // Per-side strokes on rectangles/frames. if let Some(sides) = props.sides && matches!(n.kind, NodeKind::Rectangle | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. }) - && n.corners.uniform() == Some(0.0) { - return Arc::new(figcraft_pathops::rect_side_strokes(n.width, n.height, sides, props.align)); + let c = &n.corners; + return Arc::new(if c.uniform() == Some(0.0) { + figcraft_pathops::rect_side_strokes(n.width, n.height, sides, props.align) + } else { + figcraft_pathops::rounded_side_strokes(n.width, n.height, c.radii, c.smoothing, sides, props.align) + }); } // Rect/ellipse inside/outside strokes without booleans: an even-odd ring of two outlines. if props.dashes.is_empty() && props.profile().is_none() && props.brush.is_none() && closed && props.align != StrokeAlign::Center { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 55488153..a5a59831 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -129,6 +129,27 @@ fn strokes_align() { assert_eq!(px(&pix, 30, 8)[0], 0xF5); } +#[test] +fn side_strokes_follow_rounded_corners() { + // border-l-4 rounded-r-lg: a straight 4 px bar on the left, nothing on the other edges. + let mut d = figcraft_doc::Document::new("t"); + let p = d.first_page().unwrap(); + let f = d.create(p, NodeKind::Frame, "F", 10.0, 10.0, 120.0, 60.0).unwrap(); + let n = d.get_mut(f).unwrap(); + n.fills = vec![Paint::solid(Color::WHITE)]; + n.corners.radii = [0.0, 8.0, 8.0, 0.0]; + n.strokes = vec![Paint::solid(Color::BLACK)]; + n.stroke.align = StrokeAlign::Inside; + n.stroke.sides = Some([0.0, 0.0, 0.0, 4.0]); + let pix = render_view(&d, p, &view(150, 80), &Options::default()).unwrap(); + for x in 10..14 { + assert_eq!(px(&pix, x, 40), [0, 0, 0, 255], "x {x}"); + } + for (x, y) in [(14, 40), (129, 40), (126, 12), (126, 67), (70, 10), (70, 69)] { + assert_eq!(px(&pix, x, y), [255, 255, 255, 255], "({x}, {y})"); + } +} + #[test] fn mask_clips_siblings() { let mut d = figcraft_doc::Document::new("t"); diff --git a/crates/svg/src/import/mod.rs b/crates/svg/src/import/mod.rs index fdae7156..3aed363a 100644 --- a/crates/svg/src/import/mod.rs +++ b/crates/svg/src/import/mod.rs @@ -377,15 +377,38 @@ fn path(cx: &mut Ctx, p: &usvg::Path, parent: NodeId, base: Affine) { return; } path.apply_affine(Affine::translate((-bb.x0, -bb.y0))); - let mut network = VectorNetwork::from_path(&path); let rule = if fill.is_some_and(|f| f.rule() == usvg::FillRule::EvenOdd) { WindingRule::EvenOdd } else { WindingRule::NonZero }; - for r in &mut network.regions { - r.winding = rule; + let vector = |path: &BezPath, filled: bool| { + let mut network = VectorNetwork::from_path(path); + for r in &mut network.regions { + r.winding = rule; + } + if !filled { + network.regions.clear(); + } + NodeKind::Vector { network } + }; + let at = Affine::translate((bb.x0, bb.y0)); + // Fill closes every subpath; a stroke only those that close themselves. A filled + // and stroked path with a really open subpath becomes a fill layer and a stroke layer. + let tol = CLOSE_TOLERANCE * bb.width().max(bb.height()); + let (snapped, closed) = (closed_for_fill(&path, tol, false), closed_for_fill(&path, tol, true)); + if let (Some(_), Some(_)) = (fill, stroke) + && snapped != closed + { + let fill_only = + Node::new(cx.doc.alloc_id(), vector(&closed, true), format!("{} fill", layer_name(p.id(), "Vector")), bb.width(), bb.height()); + let stroke_only = Node::new(cx.doc.alloc_id(), vector(&snapped, false), layer_name(p.id(), "Vector"), bb.width(), bb.height()); + let mut layers = [paint_path(cx, p, fill_only, at, world, true, false), paint_path(cx, p, stroke_only, at, world, false, true)]; + if p.paint_order() == usvg::PaintOrder::StrokeAndFill { + layers.reverse(); + } + for n in layers { + cx.add(n, parent); + } + return; } - if fill.is_none() { - network.regions.clear(); - } - (NodeKind::Vector { network }, Affine::translate((bb.x0, bb.y0)), bb.width(), bb.height(), 0.0) + (vector(if fill.is_some() { &closed } else { &path }, fill.is_some()), at, bb.width(), bb.height(), 0.0) } }; let fallback = match kind { @@ -395,12 +418,18 @@ fn path(cx: &mut Ctx, p: &usvg::Path, parent: NodeId, base: Affine) { _ => "Vector", }; let mut n = Node::new(cx.doc.alloc_id(), kind, layer_name(p.id(), fallback), w, h); - n.transform = transform; n.corners.radii = [radius; 4]; - let target = Target { to_node: transform.inverse() * world, size: (w, h) }; - n.fills = fill.and_then(|f| paint::paint(cx, f.paint(), f.opacity().get(), target)).into_iter().collect(); + let n = paint_path(cx, p, n, transform, world, true, true); + cx.add(n, parent); +} + +/// Layer `n` of path `p` placed at `transform`, with the path's fill and / or stroke. +fn paint_path(cx: &mut Ctx, p: &usvg::Path, mut n: Node, transform: Affine, world: Affine, with_fill: bool, with_stroke: bool) -> Node { + n.transform = transform; + let target = Target { to_node: transform.inverse() * world, size: (n.width, n.height) }; + n.fills = p.fill().filter(|_| with_fill).and_then(|f| paint::paint(cx, f.paint(), f.opacity().get(), target)).into_iter().collect(); n.strokes.clear(); - if let Some(s) = stroke { + if let Some(s) = p.stroke().filter(|_| with_stroke) { n.strokes = paint::paint(cx, s.paint(), s.opacity().get(), target).into_iter().collect(); n.stroke = stroke_props(s, length_scale(world)); // A stroke painted under the fill shows only its outer half. @@ -409,7 +438,55 @@ fn path(cx: &mut Ctx, p: &usvg::Path, parent: NodeId, base: Affine) { n.stroke.weight /= 2.0; } } - cx.add(n, parent); + n +} + +/// How far (as a share of the path's larger side) a subpath may end from its start and still be +/// closed onto it: exporters that round coordinates leave closed outlines this far apart. +const CLOSE_TOLERANCE: f64 = 1e-3; + +/// `path` with the subpaths that end within `tol` of their start closed onto it and, with `all` +/// (SVG fills every subpath as if closed), every other subpath that can enclose an area (two or +/// more segments, or one curve) closed with a straight segment. +fn closed_for_fill(path: &BezPath, tol: f64, all: bool) -> BezPath { + use figcraft_geom::kurbo::PathEl; + let mut out = BezPath::new(); + let mut sub: Vec = Vec::new(); + let flush = |sub: &mut Vec, out: &mut BezPath| { + let start = match sub.first() { + Some(PathEl::MoveTo(p)) => *p, + _ => { + sub.clear(); + return; + } + }; + let open = !matches!(sub.last(), Some(PathEl::ClosePath)); + let segments = sub.len() - 1; + let curve = matches!(sub.last(), Some(PathEl::QuadTo(..) | PathEl::CurveTo(..))); + if open && (segments >= 2 || curve) { + let near = sub.last().and_then(|e| e.end_point()).is_some_and(|end| (end - start).hypot() <= tol); + if near && let Some(last) = sub.last_mut() { + match last { + PathEl::LineTo(p) | PathEl::QuadTo(_, p) | PathEl::CurveTo(_, _, p) => *p = start, + PathEl::MoveTo(_) | PathEl::ClosePath => {} + } + } + if near || all { + sub.push(PathEl::ClosePath); + } + } + for e in sub.drain(..) { + out.push(e); + } + }; + for e in path.elements() { + if matches!(e, PathEl::MoveTo(_)) { + flush(&mut sub, &mut out); + } + sub.push(*e); + } + flush(&mut sub, &mut out); + out } /// A recognised primitive placed by `world`: its kind, transform, size and corner radius. `None` diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 55176aaf..656307d9 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -79,6 +79,8 @@ struct W<'a> { clip: Option, /// `id` attributes written so far. used_ids: std::collections::BTreeSet, + /// The next `_k` suffix to try per base id, so repeated names don't rescan from `_2`. + next_suffix: std::collections::HashMap, } /// ` clip-path="url(#…)"` for a clip path id (else nothing). @@ -150,8 +152,15 @@ fn blend_style(b: figcraft_color::BlendMode) -> String { } } +/// `s` escaped for XML text and attribute values; characters XML 1.0 forbids (control characters +/// other than tab, newline and carriage return, U+FFFE and U+FFFF) are left out. fn esc(s: &str) -> String { - s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) + s.chars().filter(|&c| xml_char(c)).collect::().replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) +} + +/// Whether XML 1.0 allows `c` (see [`esc`]). +fn xml_char(c: char) -> bool { + !matches!(c, '\u{0}'..='\u{8}' | '\u{b}' | '\u{c}' | '\u{e}'..='\u{1f}' | '\u{fffe}' | '\u{ffff}') } fn hex(c: Color) -> String { @@ -160,8 +169,13 @@ fn hex(c: Color) -> String { impl W<'_> { fn id(&mut self, prefix: &str) -> String { - self.next += 1; - format!("{prefix}{}", self.next) + loop { + self.next += 1; + let id = format!("{prefix}{}", self.next); + if self.used_ids.insert(id.clone()) { + return id; + } + } } /// `fill="..." [fill-opacity]` for a paint on a w×h box; `None` when it paints nothing. @@ -547,9 +561,19 @@ impl W<'_> { /// `name` as an `id` not used yet in this file (spaces as underscores; repeats get `_2`…). fn unique_id(&mut self, name: &str) -> String { - let base: String = name.chars().take(200).map(|c| if c.is_whitespace() || c.is_control() { '_' } else { c }).collect(); + // As written: what `esc` leaves out isn't part of it. + let base: String = + name.chars().take(200).map(|c| if c.is_whitespace() || c.is_control() { '_' } else { c }).filter(|&c| xml_char(c)).collect(); let base = if base.is_empty() { "layer".to_string() } else { base }; - let id = (1..).map(|k| if k == 1 { base.clone() } else { format!("{base}_{k}") }).find(|i| !self.used_ids.contains(i)).unwrap_or(base); + let next = self.next_suffix.entry(base.clone()).or_insert(1); + let id = loop { + let k = *next; + *next += 1; + let candidate = if k == 1 { base.clone() } else { format!("{base}_{k}") }; + if !self.used_ids.contains(&candidate) { + break candidate; + } + }; self.used_ids.insert(id.clone()); id } @@ -1004,6 +1028,7 @@ pub fn try_export_svg(doc: &Document, ids: &[NodeId], opts: &SvgOptions) -> Resu mask: None, clip: None, used_ids: Default::default(), + next_suffix: Default::default(), }; for id in ids { let parent_xf = doc.node(*id).and_then(|n| n.parent).map(|p| doc.world_transform(p)).unwrap_or(Affine::IDENTITY); diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs index 8dece77f..dedeca3b 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -135,6 +135,54 @@ fn title_case_does_not_restart_words_across_fill_runs_in_live_text() { assert!(svg.contains(">He") && svg.contains(">l") && svg.contains(">lo"), "{svg}"); } +/// Control characters in text (a pasted vertical-tab soft break, U+0001) are not allowed in XML: +/// live-text export must leave them out so the SVG still parses, for plain and per-run spans. +#[test] +fn live_text_export_drops_xml_forbidden_control_characters() { + use figcraft_doc::{StyleRun, TextData}; + figcraft_text::scan_system_fonts(); + for styled in [false, true] { + let mut d = Document::new("t"); + let page = d.first_page().unwrap(); + let mut text = TextData::new("line one\u{b}line two\u{1}"); + text.style.font_size = 24.0; + if styled { + let mut style = text.style.clone(); + style.fills = Some(vec![Paint::solid(Color::from_hex("00FF00").unwrap())]); + text.runs.push(StyleRun { start: 5, end: 12, style }); + text.normalize(); + } + let id = d.create(page, NodeKind::Text(text), "Text", 0.0, 0.0, 600.0, 40.0).unwrap(); + d.get_mut(id).unwrap().fills = vec![Paint::solid(Color::BLACK)]; + let svg = export_svg(&d, &[id], &SvgOptions { outline_text: false, ..SvgOptions::default() }).unwrap(); + assert!(!svg.contains('\u{b}') && !svg.contains('\u{1}'), "{svg:?}"); + assert!(svg.contains("line") && svg.contains("two"), "{svg}"); + let mut d2 = Document::new("t2"); + let p2 = d2.first_page().unwrap(); + assert!(import_svg(&mut d2, svg.as_bytes(), p2, 0.0, 0.0, "x").is_ok(), "{svg:?}"); + } +} + +#[test] +fn side_strokes_on_rounded_corners_export_as_their_area() { + // border-l-4 rounded-r-lg: the stroke path is the 4 px bar (60 px tall), drawn even-odd. + use figcraft_geom::Shape; + let mut d = Document::new("t"); + let p = d.first_page().unwrap(); + let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 120.0, 60.0).unwrap(); + let n = d.get_mut(f).unwrap(); + n.corners.radii = [0.0, 8.0, 8.0, 0.0]; + n.strokes = vec![Paint::solid(Color::from_hex("FF0000").unwrap())]; + n.stroke.align = figcraft_doc::StrokeAlign::Inside; + n.stroke.sides = Some([0.0, 0.0, 0.0, 4.0]); + let svg = export_svg(&d, &[f], &SvgOptions::default()).unwrap(); + let stroke = svg.split(""##; + import_svg(&mut d, svg.as_bytes(), p, 0.0, 0.0, "x").unwrap(); + let loops = |n: &figcraft_doc::Node| match &n.kind { + NodeKind::Vector { network } => network.regions.iter().map(|r| r.loops.len()).sum::(), + _ => 0, + }; + assert_eq!(d.nodes().map(loops).sum::(), 1); +} diff --git a/crates/svg/tests/corpus.rs b/crates/svg/tests/corpus.rs index 35f6f9f4..24803c40 100644 --- a/crates/svg/tests/corpus.rs +++ b/crates/svg/tests/corpus.rs @@ -243,6 +243,27 @@ fn groups_keep_their_ids_and_transforms_bake_into_shapes() { assert_eq!(layer(&d, f, "d").kind.type_name(), "VECTOR"); } +#[test] +fn fills_close_open_subpaths() { + let (d, f) = imported("open_subpaths"); + let loops = |n: &figcraft_doc::Node| match &n.kind { + NodeKind::Vector { network } => network.regions.iter().map(|r| r.loops.len()).sum::(), + _ => 0, + }; + // One filled layer: all four subpaths are fill loops, the 0.001 gap and the triangle included. + let filled = layer(&d, f, "filled"); + assert_eq!((filled.kind.type_name(), loops(filled), filled.strokes.len()), ("VECTOR", 4, 0)); + // Filled and stroked with an open triangle: a fill layer under the stroke, which stays open. + let (fill, stroke) = (layer(&d, f, "stroked fill"), layer(&d, f, "stroked")); + assert_eq!((loops(fill), fill.fills.len(), fill.strokes.len()), (2, 1, 0)); + assert_eq!((loops(stroke), stroke.fills.len(), stroke.strokes.len()), (0, 0, 1)); + // Only near-closed ends: one layer, closed, stroke and all. + let near = layer(&d, f, "near"); + assert_eq!((loops(near), near.fills.len(), near.strokes.len()), (1, 1, 1)); + let order: Vec<&str> = d.node(f).unwrap().children.iter().filter_map(|c| d.node(*c)).map(|n| n.name.as_str()).collect(); + assert_eq!(order, ["filled", "stroked fill", "stroked", "near", "under", "under fill"], "paint order: fill then stroke, or the stroke first"); +} + #[test] fn basic_shapes_stay_rectangles_ellipses_and_lines() { let (d, f) = imported("primitives"); diff --git a/crates/svg/tests/corpus/open_subpaths.svg b/crates/svg/tests/corpus/open_subpaths.svg new file mode 100644 index 00000000..daed50b7 --- /dev/null +++ b/crates/svg/tests/corpus/open_subpaths.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/text/build.rs b/crates/text/build.rs index 05dfc653..f46547cd 100644 --- a/crates/text/build.rs +++ b/crates/text/build.rs @@ -13,6 +13,13 @@ fn main() { if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from) { let mut ok = false; for manifest in ["fonts/latin-manifest.txt", "fonts/manifest.txt"] { + // Presentation faces are an optional extension of craft-fonts. Required mode + // validates the core manifest and every extension that is actually supplied. + let path = dir.join(manifest); + if manifest == "fonts/latin-manifest.txt" && !path.exists() { + watch(&path); + continue; + } match craft_fonts(&dir, manifest) { Ok(entries) => { ok = true; diff --git a/crates/text/src/breaks.rs b/crates/text/src/breaks.rs index 3f5458ea..52284d0b 100644 --- a/crates/text/src/breaks.rs +++ b/crates/text/src/breaks.rs @@ -19,6 +19,8 @@ enum Class { Hyphen, /// Everything else (letters, digits, other symbols): no break inside a run of them. Other, + /// The figure space: glues what's on either side, ideographs included. + Glue, } /// Opening brackets and quotes (line-end prohibited). @@ -39,7 +41,8 @@ fn class(c: char) -> Class { return Class::Close; } match c { - ' ' | '\t' | '\u{3000}' | '\u{2002}'..='\u{200B}' => Class::Space, + ' ' | '\t' | '\u{3000}' | '\u{2002}'..='\u{2006}' | '\u{2008}'..='\u{200B}' => Class::Space, + '\u{2007}' => Class::Glue, '-' | '/' | '\u{2010}' | '\u{2013}' | '\u{2014}' => Class::Hyphen, '\u{2E80}'..='\u{2FFF}' | '\u{3003}'..='\u{33FF}' @@ -60,8 +63,8 @@ pub(crate) fn break_between(a: char, b: char) -> bool { use Class::*; match (class(a), class(b)) { // Spaces hang at the end of the line before them; kinsoku: no closing mark starts a line, - // no opening mark ends one. - (_, Space | Close) | (Open, _) => false, + // no opening mark ends one; glue holds on both sides. + (_, Space | Close | Glue) | (Open | Glue, _) => false, (Space | Hyphen | Ideo, _) | (_, Ideo) => true, _ => false, } @@ -98,3 +101,16 @@ pub fn grapheme_step(text: &str, index: usize, forward: bool) -> usize { } at.min(index) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn figure_space_is_non_breaking() { + assert!(!break_between('\u{2007}', '0')); + assert!(!break_between('1', '\u{2007}')); + assert!(!break_between('日', '\u{2007}') && !break_between('\u{2007}', '日'), "nor beside ideographs"); + assert!(!is_space('\u{2007}')); + } +} diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 604f3eee..079a7efe 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -812,7 +812,7 @@ fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap bool { matches!(c, '\u{1F1E6}'..='\u{1F1FF}' | '\u{1F300}'..='\u{1FAFF}') } +/// The lower case of the capital sigma at `i`: the final form ς or σ, chosen from the letters +/// around it in the whole text (as lower-casing the whole string would), not from the lone char. +fn lower_sigma(chars: &[char], i: usize) -> char { + // Enough context to step over any realistic run of combining marks next to the sigma. + const CONTEXT: usize = 32; + let before: String = chars.get(i.saturating_sub(CONTEXT)..i).unwrap_or(&[]).iter().collect(); + let after: String = chars.iter().skip(i + 1).take(CONTEXT).collect(); + let lower = format!("{before}\u{3A3}{after}").to_lowercase(); + // Only the sigma's lower case depends on context, so the text before it keeps its length. + lower.get(before.to_lowercase().len()..).and_then(|s| s.chars().next()).unwrap_or('σ') +} + /// One run of chars shaped with a single face and style. struct Segment<'a> { start: usize, @@ -1065,6 +1077,8 @@ fn shape_segment(chars: &[char], seg: &Segment, face: &Arc, features: &[Fe for u in c.to_uppercase() { mapped.push((u, src, true)); } + } else if style.case == TextCase::Lower && *c == 'Σ' { + mapped.push((lower_sigma(chars, src), src, false)); } else { let mut buf = [0u8; 4]; for u in style.case.apply_after(c.encode_utf8(&mut buf), prev).chars() { diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index ebf5889c..4475fbbc 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -497,3 +497,25 @@ fn the_caret_after_a_line_break_sits_on_the_next_line() { let l = layout(&TextData::new("ab\n"), None, None); assert_eq!(l.caret(3), (0.0, 1)); } + +#[test] +fn lower_case_writes_final_sigma() { + if !have_fonts() { + return; + } + let gids = |t: &TextData| layout(t, None, None).glyphs.iter().map(|g| (g.gid, g.char_start)).collect::>(); + let want = gids(&TextData::new("οδος ας")); + let Some(face) = layout(&TextData::new("ς"), None, None).glyphs.first().map(|g| g.face.clone()) else { return }; + if face.glyph_for('σ') == face.glyph_for('ς') { + eprintln!("face has no distinct final sigma; skipping"); + return; + } + let mut t = TextData::new("ΟΔΟΣ ΑΣ"); + t.style.case = figcraft_doc::TextCase::Lower; + assert_eq!(gids(&t), want, "one run"); + // The word split across two styled runs: the final form is still chosen from the whole text. + let mut other = t.style.clone(); + other.font_size += 4.0; + t.runs.push(figcraft_doc::StyleRun { start: 3, end: 4, style: other }); + assert_eq!(gids(&t).iter().map(|g| g.0).collect::>(), want.iter().map(|g| g.0).collect::>(), "split runs"); +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 09cd2226..d72b1416 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -18,7 +18,7 @@ figcraft-render = { workspace = true } figcraft-pathops = { workspace = true } figcraft-engine = { workspace = true } figcraft-format = { workspace = true } -egui = { workspace = true } +egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } @@ -34,3 +34,6 @@ toml = "1" [dev-dependencies] wat = { workspace = true } +# Keep the test font features explicit; production uses the same bundled fallback faces. +# Dependency dev-features do not propagate into app release builds. +egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] } diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml index 3e2ce652..bc7c948c 100644 --- a/crates/ui-egui/locales/bn.toml +++ b/crates/ui-egui/locales/bn.toml @@ -253,7 +253,6 @@ status = "draft — needs native review" "Close tab" = "ট্যাব বন্ধ করুন" "Close version history" = "সংস্করণ ইতিহাস বন্ধ করুন" "Close when clicking outside" = "বাইরে ক্লিক করলে বন্ধ করুন" -"Close without saving" = "সেভ না করেই বন্ধ করুন" "Code" = "কোড" "Code syntax" = "কোড সিনট্যাক্স" "Collapse" = "সংকুচিত করুন" @@ -410,6 +409,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "অনুভূমিক ব্যবধান বিতরণ করুন" "Distribute vertical spacing" = "উল্লম্ব ব্যবধান বিতরণ করুন" "Divider" = "ডিভাইডার" +"Don't Save" = "সংরক্ষণ করবেন না" "Don't truncate" = "ট্রাঙ্কেট করবেন না" "Done" = "সম্পন্ন" "Done editing" = "সম্পাদনা শেষ" @@ -1455,7 +1455,7 @@ status = "draft — needs native review" "Unread" = "অপঠিত" "Unresolve" = "অমীমাংসিত করুন" "Unsaved changes" = "সংরক্ষিত হয়নি এমন পরিবর্তন" -"Unsaved changes in {names}. Close the window and lose them?" = "{names}-এ সংরক্ষিত হয়নি এমন পরিবর্তন রয়েছে। উইন্ডোটি বন্ধ করলে কি সেগুলো হারিয়ে যাবে?" +"Unsaved changes in {names}. Save them before closing?" = "{names}-এ সংরক্ষিত হয়নি এমন পরিবর্তন রয়েছে। বন্ধ করার আগে সেগুলো সংরক্ষণ করবেন?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "সংরক্ষিত হয়নি এমন পরিবর্তন: প্রথমে সেভ করুন ({save}), অথবা বাতিল করতে {alt}-ক্লিক করে ট্যাব বন্ধ করুন" "Unsaved changes: save first, or close again with {key} to discard" = "সংরক্ষিত হয়নি এমন পরিবর্তন: প্রথমে সেভ করুন, অথবা বাতিল করতে {key} দিয়ে আবার বন্ধ করুন" "Up" = "উপরে" diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 6544b7b8..e7a2dec1 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -243,7 +243,6 @@ status = "draft — needs native review" "Close tab" = "Zavřít kartu" "Close version history" = "Zavřít historii verzí" "Close when clicking outside" = "Zavřít při kliknutí mimo" -"Close without saving" = "Zavřít bez uložení" "Code" = "Kód" "Code syntax" = "Syntaxe kódu" "Collapse" = "Sbalit" @@ -400,6 +399,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "Rozmístit vodorovné rozestupy" "Distribute vertical spacing" = "Rozmístit svislé rozestupy" "Divider" = "Oddělovač" +"Don't Save" = "Neukládat" "Don't truncate" = "Nezkracovat" "Done" = "Hotovo" "Done editing" = "Dokončit úpravy" @@ -908,6 +908,7 @@ status = "draft — needs native review" "Open branch" = "Otevřít větev" "Open file" = "Otevřít soubor" "Open files" = "Otevřít soubory" +"Open presentations" = "Otevřené prezentace" "Open in focus view" = "Otevřít v zaměřeném pohledu" "Open in new window" = "Otevřít v novém okně" "Open invite links in FigCraft" = "Otevírat odkazy s pozvánkou v aplikaci FigCraft" @@ -1424,7 +1425,7 @@ status = "draft — needs native review" "Unread" = "Nepřečtené" "Unresolve" = "Znovu otevřít" "Unsaved changes" = "Neuložené změny" -"Unsaved changes in {names}. Close the window and lose them?" = "Neuložené změny v {names}. Zavřít okno a zahodit je?" +"Unsaved changes in {names}. Save them before closing?" = "Neuložené změny v {names}. Uložit je před zavřením?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Neuložené změny: nejprve uložte ({save}) nebo zavřete kartu kliknutím s {alt} a zahoďte je" "Unsaved changes: save first, or close again with {key} to discard" = "Neuložené změny: nejprve uložte nebo zavřete znovu s {key} a zahoďte je" "Up" = "Nahoru" @@ -2241,11 +2242,13 @@ status = "draft — needs native review" "vector.edit.tool" = "Nástroj úprav vektoru" "vector.edit.transform" = "Transformovat body" "vector.edit.widthPoint" = "Bod šířky" +"vector.fillRule" = "Nastavit pravidlo výplně" "vector.flatten" = "Sloučit do vektoru" "vector.join" = "Spojit výběr" "vector.offset" = "Odsadit cestu" "vector.outlineStroke" = "Převést tah na obrysy" "vector.reverse" = "Obrátit směr cesty" +"vector.reverseLoop" = "Obrátit smyčku" "vector.simplify" = "Zjednodušit vektor" "vector.smoothJoin" = "Hladce spojit výběr" "version.autosave" = "Automaticky uložit verzi" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index b8cf80ef..816fecf1 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -238,7 +238,6 @@ status = "draft — needs native review" "Close tab" = "Tab schließen" "Close version history" = "Versionsverlauf schließen" "Close when clicking outside" = "Bei Klick außerhalb schließen" -"Close without saving" = "Ohne Speichern schließen" "Code" = "Code" "Code syntax" = "Code-Syntax" "Collapse" = "Einklappen" @@ -394,6 +393,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "Horizontale Abstände verteilen" "Distribute vertical spacing" = "Vertikale Abstände verteilen" "Divider" = "Trennlinie" +"Don't Save" = "Nicht speichern" "Don't truncate" = "Nicht kürzen" "Done" = "Fertig" "Done editing" = "Bearbeitung beenden" @@ -879,6 +879,7 @@ status = "draft — needs native review" "Open branch" = "Branch öffnen" "Open file" = "Datei öffnen" "Open files" = "Geöffnete Dateien" +"Open presentations" = "Geöffnete Präsentationen" "Open in focus view" = "In Fokusansicht öffnen" "Open in new window" = "In neuem Fenster öffnen" "Open invite links in FigCraft" = "Einladungslinks in FigCraft öffnen" @@ -1383,7 +1384,7 @@ status = "draft — needs native review" "Unread" = "Ungelesen" "Unresolve" = "Wieder öffnen" "Unsaved changes" = "Nicht gespeicherte Änderungen" -"Unsaved changes in {names}. Close the window and lose them?" = "Nicht gespeicherte Änderungen in {names}. Fenster schließen und Änderungen verwerfen?" +"Unsaved changes in {names}. Save them before closing?" = "Nicht gespeicherte Änderungen in {names}. Vor dem Schließen speichern?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Nicht gespeicherte Änderungen: Speichern Sie zuerst ({save}) oder schließen Sie den Tab mit {alt}+Klick, um sie zu verwerfen" "Unsaved changes: save first, or close again with {key} to discard" = "Nicht gespeicherte Änderungen: Speichern Sie zuerst oder schließen Sie erneut mit {key}, um sie zu verwerfen" "Up" = "Nach oben" @@ -2193,11 +2194,13 @@ status = "draft — needs native review" "vector.edit.tool" = "Vektorbearbeitungswerkzeug" "vector.edit.transform" = "Punkte transformieren" "vector.edit.widthPoint" = "Breitenpunkt" +"vector.fillRule" = "Füllregel festlegen" "vector.flatten" = "Zusammenfügen" "vector.join" = "Auswahl verbinden" "vector.offset" = "Pfad versetzen" "vector.outlineStroke" = "Kontur in Pfad umwandeln" "vector.reverse" = "Pfadrichtung umkehren" +"vector.reverseLoop" = "Schleife umdrehen" "vector.simplify" = "Vektor vereinfachen" "vector.smoothJoin" = "Auswahl glatt verbinden" "version.autosave" = "Version automatisch speichern" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 2f302eff..83cfcbe1 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -253,7 +253,6 @@ name = "English" "Close tab" = "Close tab" "Close version history" = "Close version history" "Close when clicking outside" = "Close when clicking outside" -"Close without saving" = "Close without saving" "Code" = "Code" "Code syntax" = "Code syntax" "Collapse" = "Collapse" @@ -413,6 +412,7 @@ name = "English" "Distribute horizontal spacing" = "Distribute horizontal spacing" "Distribute vertical spacing" = "Distribute vertical spacing" "Divider" = "Divider" +"Don't Save" = "Don't Save" "Don't truncate" = "Don't truncate" "Done" = "Done" "Done editing" = "Done editing" @@ -933,6 +933,7 @@ name = "English" "Open branch" = "Open branch" "Open file" = "Open file" "Open files" = "Open files" +"Open presentations" = "Open presentations" "Open in focus view" = "Open in focus view" "Open in new window" = "Open in new window" "Open invite links in FigCraft" = "Open invite links in FigCraft" @@ -1474,7 +1475,7 @@ name = "English" "Unread" = "Unread" "Unresolve" = "Unresolve" "Unsaved changes" = "Unsaved changes" -"Unsaved changes in {names}. Close the window and lose them?" = "Unsaved changes in {names}. Close the window and lose them?" +"Unsaved changes in {names}. Save them before closing?" = "Unsaved changes in {names}. Save them before closing?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" "Unsaved changes: save first, or close again with {key} to discard" = "Unsaved changes: save first, or close again with {key} to discard" "Up" = "Up" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 3f4263da..e172584c 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -254,7 +254,6 @@ status = "draft — needs native review" "Close tab" = "Cerrar pestaña" "Close version history" = "Cerrar historial de versiones" "Close when clicking outside" = "Cerrar al hacer clic fuera" -"Close without saving" = "Cerrar sin guardar" "Code" = "Código" "Code syntax" = "Sintaxis de código" "Collapse" = "Contraer" @@ -414,6 +413,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "Distribuir espacio horizontal" "Distribute vertical spacing" = "Distribuir espacio vertical" "Divider" = "Separador" +"Don't Save" = "No guardar" "Don't truncate" = "No truncar" "Done" = "Listo" "Done editing" = "Terminar edición" @@ -934,6 +934,7 @@ status = "draft — needs native review" "Open branch" = "Abrir rama" "Open file" = "Abrir archivo" "Open files" = "Archivos abiertos" +"Open presentations" = "Presentaciones abiertas" "Open in focus view" = "Abrir en vista de enfoque" "Open in new window" = "Abrir en una ventana nueva" "Open invite links in FigCraft" = "Abrir enlaces de invitación en FigCraft" @@ -1475,7 +1476,7 @@ status = "draft — needs native review" "Unread" = "No leídos" "Unresolve" = "Reabrir" "Unsaved changes" = "Cambios sin guardar" -"Unsaved changes in {names}. Close the window and lose them?" = "Hay cambios sin guardar en {names}. ¿Cerrar la ventana y perderlos?" +"Unsaved changes in {names}. Save them before closing?" = "Hay cambios sin guardar en {names}. ¿Guardarlos antes de cerrar?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Cambios sin guardar: guarda primero ({save}) o cierra la pestaña con {alt}+clic para descartarlos" "Unsaved changes: save first, or close again with {key} to discard" = "Cambios sin guardar: guarda primero o vuelve a cerrar con {key} para descartarlos" "Up" = "Arriba" @@ -2313,11 +2314,13 @@ status = "draft — needs native review" "vector.edit.tool" = "Herramienta de edición vectorial" "vector.edit.transform" = "Transformar puntos" "vector.edit.widthPoint" = "Punto de ancho" +"vector.fillRule" = "Establecer regla de relleno" "vector.flatten" = "Acoplar" "vector.join" = "Unir selección" "vector.offset" = "Desplazar trazado" "vector.outlineStroke" = "Contornear trazo" "vector.reverse" = "Invertir dirección del trazado" +"vector.reverseLoop" = "Invertir bucle" "vector.simplify" = "Simplificar vector" "vector.smoothJoin" = "Unir selección con suavizado" "version.autosave" = "Guardar versión automáticamente" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index 8b1b8743..f18ffeaf 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -248,7 +248,6 @@ status = "draft — needs native review" "Close tab" = "Sulje välilehti" "Close version history" = "Sulje versiohistoria" "Close when clicking outside" = "Sulje napsautettaessa ulkopuolelle" -"Close without saving" = "Sulje tallentamatta" "Code" = "Koodi" "Code or invite link" = "Koodi tai kutsulinkki" "Code syntax" = "Koodin syntaksi" @@ -412,6 +411,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "Jaa vaakavälit tasan" "Distribute vertical spacing" = "Jaa pystyvälit tasan" "Divider" = "Erotin" +"Don't Save" = "Älä tallenna" "Don't truncate" = "Älä lyhennä" "Done" = "Valmis" "Done editing" = "Lopeta muokkaus" @@ -928,6 +928,7 @@ status = "draft — needs native review" "Open branch" = "Avaa haara" "Open file" = "Avaa tiedosto" "Open files" = "Avaa tiedostoja" +"Open presentations" = "Avoimet esitykset" "Open in focus view" = "Avaa kohdistusnäkymässä" "Open in new window" = "Avaa uudessa ikkunassa" "Open invite links in FigCraft" = "Avaa kutsulinkit FigCraftissa" @@ -1459,7 +1460,7 @@ status = "draft — needs native review" "Unread" = "Lukematon" "Unresolve" = "Merkitse ratkaisemattomaksi" "Unsaved changes" = "Tallentamattomat muutokset" -"Unsaved changes in {names}. Close the window and lose them?" = "Tallentamattomia muutoksia kohteissa {names}. Suljetaanko ikkuna ja hylätään ne?" +"Unsaved changes in {names}. Save them before closing?" = "Tallentamattomia muutoksia kohteissa {names}. Tallennetaanko ne ennen sulkemista?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Tallentamattomia muutoksia: tallenna ensin ({save}) tai hylkää sulkemalla välilehti {alt}-napsautuksella" "Unsaved changes: save first, or close again with {key} to discard" = "Tallentamattomia muutoksia: tallenna ensin tai hylkää sulkemalla uudelleen näppäimellä {key}" "Up" = "Ylös" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 5616dca1..e5823983 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -247,7 +247,6 @@ status = "draft — needs native review" "Close tab" = "Fermer l’onglet" "Close version history" = "Fermer l’historique des versions" "Close when clicking outside" = "Fermer en cliquant à l’extérieur" -"Close without saving" = "Fermer sans enregistrer" "Code" = "Code" "Code syntax" = "Syntaxe du code" "Collapse" = "Réduire" @@ -411,6 +410,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "Répartir l’espacement horizontal" "Distribute vertical spacing" = "Répartir l’espacement vertical" "Divider" = "Séparateur" +"Don't Save" = "Ne pas enregistrer" "Don't truncate" = "Ne pas tronquer" "Done" = "Terminé" "Done editing" = "Terminer la modification" @@ -927,6 +927,7 @@ status = "draft — needs native review" "Open branch" = "Ouvrir la branche" "Open file" = "Ouvrir un fichier" "Open files" = "Fichiers ouverts" +"Open presentations" = "Présentations ouvertes" "Open in focus view" = "Ouvrir en vue ciblée" "Open in new window" = "Ouvrir dans une nouvelle fenêtre" "Open invite links in FigCraft" = "Ouvrir les liens d’invitation dans FigCraft" @@ -1459,7 +1460,7 @@ status = "draft — needs native review" "Unread" = "Non lus" "Unresolve" = "Rouvrir" "Unsaved changes" = "Modifications non enregistrées" -"Unsaved changes in {names}. Close the window and lose them?" = "Modifications non enregistrées dans {names}. Fermer la fenêtre et les perdre ?" +"Unsaved changes in {names}. Save them before closing?" = "Modifications non enregistrées dans {names}. Les enregistrer avant de fermer ?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Modifications non enregistrées : enregistrez d’abord ({save}) ou fermez l’onglet avec {alt}+clic pour les abandonner" "Unsaved changes: save first, or close again with {key} to discard" = "Modifications non enregistrées : enregistrez d’abord ou fermez à nouveau avec {key} pour les abandonner" "Up" = "Haut" @@ -2290,11 +2291,13 @@ status = "draft — needs native review" "vector.edit.tool" = "Outil de modification vectorielle" "vector.edit.transform" = "Transformer les points" "vector.edit.widthPoint" = "Point de largeur" +"vector.fillRule" = "Définir la règle de remplissage" "vector.flatten" = "Aplatir" "vector.join" = "Joindre la sélection" "vector.offset" = "Décaler le tracé" "vector.outlineStroke" = "Convertir le contour en tracé" "vector.reverse" = "Inverser le sens du tracé" +"vector.reverseLoop" = "Inverser la boucle" "vector.simplify" = "Simplifier le vecteur" "vector.smoothJoin" = "Joindre la sélection en lissant" "version.autosave" = "Enregistrer automatiquement une version" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index bf5fe82e..c06f2dff 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -247,7 +247,6 @@ status = "draft — needs native review" "Close tab" = "Tutup tab" "Close version history" = "Tutup riwayat versi" "Close when clicking outside" = "Tutup saat klik di luar" -"Close without saving" = "Tutup tanpa menyimpan" "Code" = "Kode" "Code syntax" = "Sintaks kode" "Collapse" = "Ciutkan" @@ -404,6 +403,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "Sebarkan jarak horizontal" "Distribute vertical spacing" = "Sebarkan jarak vertikal" "Divider" = "Pembatas" +"Don't Save" = "Jangan simpan" "Don't truncate" = "Jangan potong" "Done" = "Selesai" "Done editing" = "Selesai mengedit" @@ -912,6 +912,7 @@ status = "draft — needs native review" "Open branch" = "Buka branch" "Open file" = "Buka file" "Open files" = "Buka file" +"Open presentations" = "Presentasi yang terbuka" "Open in focus view" = "Buka di tampilan fokus" "Open in new window" = "Buka di jendela baru" "Open invite links in FigCraft" = "Buka link undangan di FigCraft" @@ -1428,7 +1429,7 @@ status = "draft — needs native review" "Unread" = "Belum dibaca" "Unresolve" = "Buka kembali" "Unsaved changes" = "Perubahan belum disimpan" -"Unsaved changes in {names}. Close the window and lose them?" = "Perubahan belum disimpan di {names}. Tutup jendela dan buang perubahan?" +"Unsaved changes in {names}. Save them before closing?" = "Perubahan belum disimpan di {names}. Simpan sebelum menutup?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Perubahan belum disimpan: simpan dulu ({save}), atau tutup tab dengan {alt}-klik untuk membuangnya" "Unsaved changes: save first, or close again with {key} to discard" = "Perubahan belum disimpan: simpan dulu, atau tutup lagi dengan {key} untuk membuangnya" "Up" = "Atas" @@ -2245,11 +2246,13 @@ status = "draft — needs native review" "vector.edit.tool" = "Alat edit vector" "vector.edit.transform" = "Transformasi titik" "vector.edit.widthPoint" = "Titik lebar" +"vector.fillRule" = "Atur aturan isian" "vector.flatten" = "Satukan" "vector.join" = "Gabungkan pilihan" "vector.offset" = "Offset path" "vector.outlineStroke" = "Outline stroke" "vector.reverse" = "Balik arah path" +"vector.reverseLoop" = "Balik loop" "vector.simplify" = "Sederhanakan vector" "vector.smoothJoin" = "Gabungkan pilihan dengan halus" "version.autosave" = "Simpan versi otomatis" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index a6ffa01f..d7ee7287 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -248,7 +248,6 @@ status = "draft — needs native review" "Close tab" = "タブを閉じる" "Close version history" = "バージョン履歴を閉じる" "Close when clicking outside" = "外側をクリックしたら閉じる" -"Close without saving" = "保存せずに閉じる" "Code" = "コード" "Code syntax" = "コード構文" "Collapse" = "折りたたむ" @@ -409,6 +408,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "水平方向に等間隔に分布" "Distribute vertical spacing" = "垂直方向に等間隔に分布" "Divider" = "区切り線" +"Don't Save" = "保存しない" "Don't truncate" = "切り詰めない" "Done" = "完了" "Done editing" = "編集を完了" @@ -920,6 +920,7 @@ status = "draft — needs native review" "Open branch" = "ブランチを開く" "Open file" = "ファイルを開く" "Open files" = "開いているファイル" +"Open presentations" = "開いているプレゼンテーション" "Open in focus view" = "フォーカスビューで開く" "Open in new window" = "新しいウィンドウで開く" "Open invite links in FigCraft" = "招待リンクを FigCraft で開く" @@ -1442,7 +1443,7 @@ status = "draft — needs native review" "Unread" = "未読" "Unresolve" = "未解決に戻す" "Unsaved changes" = "未保存の変更" -"Unsaved changes in {names}. Close the window and lose them?" = "{names} に未保存の変更がある。ウィンドウを閉じて変更を破棄する?" +"Unsaved changes in {names}. Save them before closing?" = "{names} に未保存の変更がある。閉じる前に保存する?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "未保存の変更:先に保存する({save})か、{alt}-クリックでタブを閉じて破棄" "Unsaved changes: save first, or close again with {key} to discard" = "未保存の変更:先に保存するか、もう一度 {key} で閉じて破棄" "Up" = "上" @@ -2268,11 +2269,13 @@ status = "draft — needs native review" "vector.edit.tool" = "ベクター編集ツール" "vector.edit.transform" = "ポイントを変形" "vector.edit.widthPoint" = "幅のポイント" +"vector.fillRule" = "塗りつぶし規則を設定" "vector.flatten" = "平坦化" "vector.join" = "選択範囲を連結" "vector.offset" = "パスのオフセット" "vector.outlineStroke" = "線のアウトライン化" "vector.reverse" = "パスの方向を反転" +"vector.reverseLoop" = "ループを反転" "vector.simplify" = "ベクターを単純化" "vector.smoothJoin" = "選択範囲を滑らかに連結" "version.autosave" = "自動保存バージョン" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index ffbc8444..42f78be6 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -255,7 +255,6 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Close tab" = "ჩანართის დახურვა" "Close version history" = "ვერსიების ისტორიის დახურვა" "Close when clicking outside" = "დახურვა გარეთ დაწკაპუნებისას" -"Close without saving" = "დახურვა შენახვის გარეშე" "Code" = "კოდი" "Code syntax" = "კოდის სინტაქსი" "Collapse" = "ჩაკეცვა" @@ -412,6 +411,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Distribute horizontal spacing" = "ჰორიზონტალური შუალედების განაწილება" "Distribute vertical spacing" = "ვერტიკალური შუალედების განაწილება" "Divider" = "გამყოფი" +"Don't Save" = "არ შეინახო" "Don't truncate" = "შეკვეცის გარეშე" "Done" = "მზადაა" "Done editing" = "რედაქტირების დასრულება" @@ -1457,7 +1457,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Unread" = "წაუკითხავი" "Unresolve" = "ხელახლა გახსნა" "Unsaved changes" = "შეუნახავი ცვლილებები" -"Unsaved changes in {names}. Close the window and lose them?" = "შეუნახავი ცვლილებები: {names}. დაიხუროს ფანჯარა და დაიკარგოს ისინი?" +"Unsaved changes in {names}. Save them before closing?" = "შეუნახავი ცვლილებები: {names}. შევინახოთ დახურვამდე?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "შეუნახავი ცვლილებები: ჯერ შეინახეთ ({save}) ან გასაუქმებლად დახურეთ ჩანართი {alt}+დაწკაპუნებით" "Unsaved changes: save first, or close again with {key} to discard" = "შეუნახავი ცვლილებები: ჯერ შეინახეთ ან გასაუქმებლად ხელახლა დახურეთ {key}-ით" "Up" = "ზემოთ" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 2192e29b..66eb54c2 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -238,7 +238,6 @@ status = "draft — needs native review" "Close tab" = "탭 닫기" "Close version history" = "버전 기록 닫기" "Close when clicking outside" = "바깥쪽을 클릭하면 닫기" -"Close without saving" = "저장하지 않고 닫기" "Code" = "코드" "Code syntax" = "코드 구문" "Collapse" = "접기" @@ -393,6 +392,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "수평 간격 균등 분포" "Distribute vertical spacing" = "수직 간격 균등 분포" "Divider" = "구분선" +"Don't Save" = "저장 안 함" "Don't truncate" = "자르지 않음" "Done" = "완료" "Done editing" = "편집 완료" @@ -875,6 +875,7 @@ status = "draft — needs native review" "Open branch" = "브랜치 열기" "Open file" = "파일 열기" "Open files" = "열린 파일" +"Open presentations" = "열린 프레젠테이션" "Open in focus view" = "포커스 보기로 열기" "Open in new window" = "새 창에서 열기" "Open invite links in FigCraft" = "초대 링크를 FigCraft에서 열기" @@ -1374,7 +1375,7 @@ status = "draft — needs native review" "Unread" = "읽지 않음" "Unresolve" = "미해결로 되돌리기" "Unsaved changes" = "저장되지 않은 변경 사항" -"Unsaved changes in {names}. Close the window and lose them?" = "{names}에 저장되지 않은 변경 사항이 있음. 창을 닫고 변경 사항을 버릴까요?" +"Unsaved changes in {names}. Save them before closing?" = "{names}에 저장되지 않은 변경 사항이 있음. 닫기 전에 저장할까요?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "저장되지 않은 변경 사항: 먼저 저장({save})하거나 {alt}-클릭으로 탭을 닫아 버리세요" "Unsaved changes: save first, or close again with {key} to discard" = "저장되지 않은 변경 사항: 먼저 저장하거나 {key}로 다시 닫아 버리세요" "Up" = "위" @@ -2184,11 +2185,13 @@ status = "draft — needs native review" "vector.edit.tool" = "벡터 편집 도구" "vector.edit.transform" = "점 변형" "vector.edit.widthPoint" = "너비 점" +"vector.fillRule" = "칠 규칙 설정" "vector.flatten" = "평면화" "vector.join" = "선택 항목 연결" "vector.offset" = "패스 오프셋" "vector.outlineStroke" = "선 윤곽선 만들기" "vector.reverse" = "패스 방향 반전" +"vector.reverseLoop" = "루프 반전" "vector.simplify" = "벡터 단순화" "vector.smoothJoin" = "선택 항목 부드럽게 연결" "version.autosave" = "자동 저장 버전" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index 2b301b39..84f13545 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -248,7 +248,6 @@ status = "draft — needs native review" "Close tab" = "Tabblad sluiten" "Close version history" = "Versiegeschiedenis sluiten" "Close when clicking outside" = "Sluiten bij klikken erbuiten" -"Close without saving" = "Sluiten zonder opslaan" "Code" = "Code" "Code or invite link" = "Code of uitnodigingslink" "Code syntax" = "Code-syntaxis" @@ -412,6 +411,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "Horizontale afstand verdelen" "Distribute vertical spacing" = "Verticale afstand verdelen" "Divider" = "Scheidslijn" +"Don't Save" = "Niet opslaan" "Don't truncate" = "Niet afkappen" "Done" = "Klaar" "Done editing" = "Klaar met bewerken" @@ -928,6 +928,7 @@ status = "draft — needs native review" "Open branch" = "Branch openen" "Open file" = "Bestand openen" "Open files" = "Geopende bestanden" +"Open presentations" = "Geopende presentaties" "Open in focus view" = "Openen in focusweergave" "Open in new window" = "Openen in een nieuw venster" "Open invite links in FigCraft" = "Uitnodigingslinks openen in FigCraft" @@ -1459,7 +1460,7 @@ status = "draft — needs native review" "Unread" = "Ongelezen" "Unresolve" = "Oplossing ongedaan maken" "Unsaved changes" = "Niet-opgeslagen wijzigingen" -"Unsaved changes in {names}. Close the window and lose them?" = "Niet-opgeslagen wijzigingen in {names}. Het venster sluiten en ze verliezen?" +"Unsaved changes in {names}. Save them before closing?" = "Niet-opgeslagen wijzigingen in {names}. Opslaan voor het sluiten?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Niet-opgeslagen wijzigingen: sla eerst op ({save}), of sluit het tabblad met {alt}-klik om ze te verwerpen" "Unsaved changes: save first, or close again with {key} to discard" = "Niet-opgeslagen wijzigingen: sla eerst op, of sluit nogmaals met {key} om te verwerpen" "Up" = "Omhoog" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index 51c59f9e..7f301a6e 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -251,7 +251,6 @@ status = "draft — needs native review" "Close tab" = "Zamknij kartę" "Close version history" = "Zamknij historię wersji" "Close when clicking outside" = "Zamknij przy kliknięciu poza obszarem" -"Close without saving" = "Zamknij bez zapisywania" "Code" = "Kod" "Code syntax" = "Składnia kodu" "Collapse" = "Zwiń" @@ -409,6 +408,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "Rozmieść poziomo" "Distribute vertical spacing" = "Rozmieść pionowo" "Divider" = "Rozdzielacz" +"Don't Save" = "Nie zapisuj" "Don't truncate" = "Nie obcinaj tekstu" "Done" = "Gotowe" "Done editing" = "Zakończ edycję" @@ -925,6 +925,7 @@ status = "draft — needs native review" "Open branch" = "Otwórz gałąź" "Open file" = "Otwórz plik" "Open files" = "Otwórz pliki" +"Open presentations" = "Otwarte prezentacje" "Open in focus view" = "Otwórz w widoku skupienia" "Open in new window" = "Otwórz w nowym oknie" "Open invite links in FigCraft" = "Otwieraj linki z zaproszeniem w FigCraft" @@ -1454,7 +1455,7 @@ status = "draft — needs native review" "Unread" = "Nieprzeczytane" "Unresolve" = "Cofnij rozwiązanie" "Unsaved changes" = "Niezapisane zmiany" -"Unsaved changes in {names}. Close the window and lose them?" = "Niezapisane zmiany w: {names}. Zamknąć okno i utracić je?" +"Unsaved changes in {names}. Save them before closing?" = "Niezapisane zmiany w: {names}. Zapisać je przed zamknięciem?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Niezapisane zmiany: najpierw zapisz ({save}) lub zamknij kartę z {alt}-kliknięciem, aby odrzucić" "Unsaved changes: save first, or close again with {key} to discard" = "Niezapisane zmiany: najpierw zapisz lub naciśnij ponownie {key}, aby odrzucić" "Up" = "W górę" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index ed841775..22fffb17 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -236,7 +236,6 @@ status = "draft — needs native review" "Close tab" = "Fechar aba" "Close version history" = "Fechar histórico de versões" "Close when clicking outside" = "Fechar ao clicar fora" -"Close without saving" = "Fechar sem salvar" "Code" = "Código" "Code syntax" = "Sintaxe de código" "Collapse" = "Recolher" @@ -392,6 +391,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "Distribuir espaçamento horizontal" "Distribute vertical spacing" = "Distribuir espaçamento vertical" "Divider" = "Divisor" +"Don't Save" = "Não salvar" "Don't truncate" = "Não truncar" "Done" = "Concluído" "Done editing" = "Concluir edição" @@ -874,6 +874,7 @@ status = "draft — needs native review" "Open branch" = "Abrir branch" "Open file" = "Abrir arquivo" "Open files" = "Arquivos abertos" +"Open presentations" = "Apresentações abertas" "Open in focus view" = "Abrir na visualização em foco" "Open in new window" = "Abrir em nova janela" "Open invite links in FigCraft" = "Abrir links de convite no FigCraft" @@ -1373,7 +1374,7 @@ status = "draft — needs native review" "Unread" = "Não lidos" "Unresolve" = "Reabrir" "Unsaved changes" = "Alterações não salvas" -"Unsaved changes in {names}. Close the window and lose them?" = "Há alterações não salvas em {names}. Fechar a janela e perdê-las?" +"Unsaved changes in {names}. Save them before closing?" = "Há alterações não salvas em {names}. Salvá-las antes de fechar?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Alterações não salvas: salve primeiro ({save}) ou feche a aba com {alt}+clique para descartá-las" "Unsaved changes: save first, or close again with {key} to discard" = "Alterações não salvas: salve primeiro ou feche de novo com {key} para descartá-las" "Up" = "Para cima" @@ -2297,11 +2298,13 @@ status = "draft — needs native review" "vector.edit.tool" = "Ferramenta de edição vetorial" "vector.edit.transform" = "Transformar pontos" "vector.edit.widthPoint" = "Ponto de largura" +"vector.fillRule" = "Definir regra de preenchimento" "vector.flatten" = "Nivelar" "vector.join" = "Unir seleção" "vector.offset" = "Deslocar caminho" "vector.outlineStroke" = "Contornar traço" "vector.reverse" = "Inverter direção do caminho" +"vector.reverseLoop" = "Inverter laço" "vector.simplify" = "Simplificar vetor" "vector.smoothJoin" = "Unir seleção com suavização" "version.autosave" = "Salvar versão automaticamente" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 38cfaa4e..baba56fd 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -250,7 +250,6 @@ name = "Русский" "Close tab" = "Закрыть вкладку" "Close version history" = "Закрыть историю версий" "Close when clicking outside" = "Закрывать при нажатии снаружи" -"Close without saving" = "Закрыть без сохранения" "Code" = "Код" "Code syntax" = "Синтаксис кода" "Collapse" = "Свернуть" @@ -411,6 +410,7 @@ name = "Русский" "Distribute horizontal spacing" = "Распределить по горизонтали" "Distribute vertical spacing" = "Распределить по вертикали" "Divider" = "Разделитель" +"Don't Save" = "Не сохранять" "Don't truncate" = "Не обрезать" "Done" = "Готово" "Done editing" = "Завершить редактирование" @@ -930,6 +930,7 @@ name = "Русский" "Open branch" = "Открыть ветку" "Open file" = "Открыть файл" "Open files" = "Открыть файлы" +"Open presentations" = "Открытые презентации" "Open in focus view" = "Открыть в режиме фокуса" "Open in new window" = "Открыть в новом окне" "Open invite links in FigCraft" = "Открывать ссылки приглашения в FigCraft" @@ -1468,7 +1469,7 @@ name = "Русский" "Unread" = "Непрочитанные" "Unresolve" = "Снова открыть" "Unsaved changes" = "Несохранённые изменения" -"Unsaved changes in {names}. Close the window and lose them?" = "Несохранённые изменения в {names}. Закрыть окно и потерять их?" +"Unsaved changes in {names}. Save them before closing?" = "Несохранённые изменения в {names}. Сохранить их перед закрытием?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Есть несохранённые изменения: сохраните ({save}) или закройте вкладку нажатием с {alt}, чтобы отбросить их" "Unsaved changes: save first, or close again with {key} to discard" = "Есть несохранённые изменения: сохраните или закройте ещё раз с {key}, чтобы отбросить их" "Up" = "Вверх" @@ -2304,11 +2305,13 @@ name = "Русский" "vector.edit.tool" = "Инструмент редактирования вектора" "vector.edit.transform" = "Трансформировать точки" "vector.edit.widthPoint" = "Точка ширины" +"vector.fillRule" = "Задать правило заливки" "vector.flatten" = "Свести" "vector.join" = "Соединить выделение" "vector.offset" = "Сместить путь" "vector.outlineStroke" = "Преобразовать обводку в контур" "vector.reverse" = "Обратить направление пути" +"vector.reverseLoop" = "Развернуть контур" "vector.simplify" = "Упростить вектор" "vector.smoothJoin" = "Плавно соединить выделение" "version.autosave" = "Автосохранить версию" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index a5f4d4ba..be25d38e 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -247,7 +247,6 @@ name = "Svenska" "Close tab" = "Stäng flik" "Close version history" = "Stäng versionshistorik" "Close when clicking outside" = "Stäng vid klick utanför" -"Close without saving" = "Stäng utan att spara" "Code" = "Kod" "Code syntax" = "Kodsyntax" "Collapse" = "Komprimera" @@ -406,6 +405,7 @@ name = "Svenska" "Distribute horizontal spacing" = "Fördela horisontellt avstånd" "Distribute vertical spacing" = "Fördela vertikalt avstånd" "Divider" = "Avdelare" +"Don't Save" = "Spara inte" "Don't truncate" = "Korta inte av" "Done" = "Klart" "Done editing" = "Klar med redigeringen" @@ -919,6 +919,7 @@ name = "Svenska" "Open branch" = "Öppna gren" "Open file" = "Öppna fil" "Open files" = "Öppna filer" +"Open presentations" = "Öppna presentationer" "Open in focus view" = "Öppna i fokusvy" "Open in new window" = "Öppna i nytt fönster" "Open invite links in FigCraft" = "Öppna inbjudningslänkar i FigCraft" @@ -1443,7 +1444,7 @@ name = "Svenska" "Unread" = "Olästa" "Unresolve" = "Markera som olöst" "Unsaved changes" = "Osparade ändringar" -"Unsaved changes in {names}. Close the window and lose them?" = "Osparade ändringar i {names}. Vill du stänga fönstret och förlora dem?" +"Unsaved changes in {names}. Save them before closing?" = "Osparade ändringar i {names}. Vill du spara dem innan du stänger?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Osparade ändringar: spara först ({save}), eller stäng fliken med {alt}-klick för att slänga dem" "Unsaved changes: save first, or close again with {key} to discard" = "Osparade ändringar: spara först, eller stäng igen med {key} för att slänga dem" "Up" = "Upp" @@ -2268,11 +2269,13 @@ name = "Svenska" "vector.edit.tool" = "Vektorredigeringsverktyg" "vector.edit.transform" = "Transformera punkter" "vector.edit.widthPoint" = "Breddpunkt" +"vector.fillRule" = "Ange fyllnadsregel" "vector.flatten" = "Platta till" "vector.join" = "Sammanfoga markering" "vector.offset" = "Förskjut bana" "vector.outlineStroke" = "Konvertera kontur till form" "vector.reverse" = "Vänd banans riktning" +"vector.reverseLoop" = "Vänd slinga" "vector.simplify" = "Förenkla vektor" "vector.smoothJoin" = "Sammanfoga markering mjukt" "version.autosave" = "Autospara version" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index aa3207a5..5d15c9c9 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -247,7 +247,6 @@ name = "Türkçe" "Close tab" = "Sekmeyi kapat" "Close version history" = "Sürüm geçmişini kapat" "Close when clicking outside" = "Dışarı tıklanınca kapat" -"Close without saving" = "Kaydetmeden kapat" "Code" = "Kod" "Code syntax" = "Kod söz dizimi" "Collapse" = "Daralt" @@ -404,6 +403,7 @@ name = "Türkçe" "Distribute horizontal spacing" = "Yatay aralığı dağıt" "Distribute vertical spacing" = "Dikey aralığı dağıt" "Divider" = "Ayırıcı" +"Don't Save" = "Kaydetme" "Don't truncate" = "Kısaltma yapma" "Done" = "Bitti" "Done editing" = "Düzenlemeyi bitir" @@ -912,6 +912,7 @@ name = "Türkçe" "Open branch" = "Dalı aç" "Open file" = "Dosya aç" "Open files" = "Dosyaları aç" +"Open presentations" = "Açık sunumlar" "Open in focus view" = "Odak görünümünde aç" "Open in new window" = "Yeni pencerede aç" "Open invite links in FigCraft" = "Davet bağlantılarını FigCraft'ta aç" @@ -1428,7 +1429,7 @@ name = "Türkçe" "Unread" = "Okunmamış" "Unresolve" = "Çözülmedi yap" "Unsaved changes" = "Kaydedilmemiş değişiklikler" -"Unsaved changes in {names}. Close the window and lose them?" = "{names} içinde kaydedilmemiş değişiklikler var. Pencere kapatılıp bunlar kaybedilsin mi?" +"Unsaved changes in {names}. Save them before closing?" = "{names} içinde kaydedilmemiş değişiklikler var. Kapatmadan önce kaydedilsin mi?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Kaydedilmemiş değişiklikler: önce kaydedin ({save}) veya atmak için sekmeyi {alt}-tıklamayla kapatın" "Unsaved changes: save first, or close again with {key} to discard" = "Kaydedilmemiş değişiklikler: önce kaydedin veya atmak için {key} ile yeniden kapatın" "Up" = "Yukarı" @@ -2245,11 +2246,13 @@ name = "Türkçe" "vector.edit.tool" = "Vektör düzenleme aracı" "vector.edit.transform" = "Noktaları dönüştür" "vector.edit.widthPoint" = "Genişlik noktası" +"vector.fillRule" = "Dolgu kuralını ayarla" "vector.flatten" = "Düzleştir" "vector.join" = "Seçimi birleştir" "vector.offset" = "Yolu ötele" "vector.outlineStroke" = "Konturu ana hatlara dönüştür" "vector.reverse" = "Yol yönünü ters çevir" +"vector.reverseLoop" = "Döngüyü çevir" "vector.simplify" = "Vektörü sadeleştir" "vector.smoothJoin" = "Seçimi yumuşak birleştir" "version.autosave" = "Otomatik kayıt sürümü" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 3d56e7a3..7add99c5 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -239,7 +239,6 @@ status = "draft — needs native review" "Close tab" = "关闭标签页" "Close version history" = "关闭版本历史" "Close when clicking outside" = "点击外部时关闭" -"Close without saving" = "关闭但不保存" "Code" = "代码" "Code syntax" = "代码语法" "Collapse" = "折叠" @@ -394,6 +393,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "水平间距分布" "Distribute vertical spacing" = "垂直间距分布" "Divider" = "分隔线" +"Don't Save" = "不保存" "Don't truncate" = "不截断" "Done" = "完成" "Done editing" = "完成编辑" @@ -876,6 +876,7 @@ status = "draft — needs native review" "Open branch" = "打开分支" "Open file" = "打开文件" "Open files" = "打开的文件" +"Open presentations" = "打开的演示" "Open in focus view" = "在聚焦视图中打开" "Open in new window" = "在新窗口中打开" "Open invite links in FigCraft" = "在 FigCraft 中打开邀请链接" @@ -1375,7 +1376,7 @@ status = "draft — needs native review" "Unread" = "未读" "Unresolve" = "取消解决" "Unsaved changes" = "未保存的更改" -"Unsaved changes in {names}. Close the window and lose them?" = "{names} 中有未保存的更改。要关闭窗口并丢弃这些更改吗?" +"Unsaved changes in {names}. Save them before closing?" = "{names} 中有未保存的更改。关闭前要保存吗?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "有未保存的更改:请先保存({save}),或按住 {alt} 点击关闭标签页以丢弃" "Unsaved changes: save first, or close again with {key} to discard" = "有未保存的更改:请先保存,或再次按 {key} 关闭以丢弃" "Up" = "向上" @@ -2185,11 +2186,13 @@ status = "draft — needs native review" "vector.edit.tool" = "矢量编辑工具" "vector.edit.transform" = "变换点" "vector.edit.widthPoint" = "宽度点" +"vector.fillRule" = "设置填充规则" "vector.flatten" = "拼合" "vector.join" = "连接所选项" "vector.offset" = "偏移路径" "vector.outlineStroke" = "轮廓化描边" "vector.reverse" = "反转路径方向" +"vector.reverseLoop" = "反转环" "vector.simplify" = "简化矢量" "vector.smoothJoin" = "平滑连接所选项" "version.autosave" = "自动保存版本" diff --git a/crates/ui-egui/src/a11y.rs b/crates/ui-egui/src/a11y.rs index 5fc098a4..79a28118 100644 --- a/crates/ui-egui/src/a11y.rs +++ b/crates/ui-egui/src/a11y.rs @@ -239,6 +239,10 @@ pub fn scroll_focus(nav: &mut Nav, ui: &Ui) { #[derive(Clone, Default)] struct Shared { canvas: Option, + /// The root `Ui::scope_id` of the frame. A resizable panel's real `__resize` widget is + /// `root_scope.with().with("__resize")`; FigCraft's panels are fixed, so its own + /// `panel_edge` and `pages_edge` IDs are the ones to exclude today. + root_scope: Option, keyboard: bool, /// The focused widget showed its focus itself this frame (no ring needed). ring: Option, @@ -250,7 +254,7 @@ struct Shared { } fn shared(ctx: &Context, f: impl FnOnce(&mut Shared) -> R) -> R { - ctx.data_mut(|d| f(d.get_temp_mut_or_default::(Id::new("figcraft_a11y")))) + ctx.data_mut(|d| f(d.get_temp_mut_or_default::(Id::unique("figcraft_a11y")))) } /// Whether a widget other than the canvas has the keyboard (a field, a focused button): the @@ -284,8 +288,10 @@ pub fn keyboard(ctx: &Context) -> bool { /// Before the panels draw: pointer presses, the focused control's region, and the navigation /// keys (F6, Tab, arrows, Escape), which are taken out of the input when they move the keyboard. -pub fn begin(app: &mut FigApp, ctx: &Context) { +pub fn begin(app: &mut FigApp, ctx: &Context, root_scope: Id) { app.ui.nav.last = std::mem::take(&mut app.ui.nav.next); + // Before the keys: a panel edge derived from the root scope must already be excluded. + shared(ctx, |s| s.root_scope = Some(root_scope)); keys(app, ctx); let keyboard = app.ui.nav.keyboard; shared(ctx, |s| { @@ -646,7 +652,13 @@ fn step_within(app: &mut FigApp, ctx: &Context, region: Region, step: i32) { /// them, are left out). fn focusables(ctx: &Context, nav: &Nav, region: Region) -> Vec<(Id, Rect)> { let canvas = nav.last.canvas; - let edges = [Id::new("left_panel").with("__resize"), Id::new("right_panel").with("__resize")]; + // Both the real resize widgets (should a panel ever become resizable) and FigCraft's own + // custom panel and pages-list edges, which must never enter the F6/Tab order. + let mut edges = vec![Id::unique(("panel_edge", "left")), Id::unique(("panel_edge", "right")), Id::unique("pages_edge")]; + if let Some(root) = shared(ctx, |s| s.root_scope) { + edges.push(root.with("left_panel").with("__resize")); + edges.push(root.with("right_panel").with("__resize")); + } ctx.viewport(|v| { v.prev_pass .widgets diff --git a/crates/ui-egui/src/a11y_tests.rs b/crates/ui-egui/src/a11y_tests.rs index d041a5b3..a2c54cbe 100644 --- a/crates/ui-egui/src/a11y_tests.rs +++ b/crates/ui-egui/src/a11y_tests.rs @@ -403,3 +403,29 @@ fn the_align_buttons_are_off_for_a_layer_alone_on_the_page() { u.app.run("select.set", json!({"ids": [inner]})); assert!(!align_left(&tree(&mut u)), "one in a frame aligns to it"); } + +#[test] +fn the_custom_panel_edges_never_enter_the_keyboard_or_focus_order() { + // FigCraft's panels are fixed, so egui makes no `panel.__resize` widgets; the draggable edges + // are `Id::unique(("panel_edge", ...))`. They sense the pointer but must not be focusable, or + // Tab/F6 would stop on an unnamed control. + let (mut u, _, _) = setup(); + let (left, right) = (egui::Id::unique(("panel_edge", "left")), egui::Id::unique(("panel_edge", "right"))); + // The Pages list's edge has the same contract as the side panels' edges. + let pages = egui::Id::unique("pages_edge"); + for f in [left, right, pages] { + let focusable = u.ctx.viewport(|v| v.prev_pass.widgets.get(f).map(|w| w.sense.is_focusable())); + assert_eq!(focusable, Some(false), "the panel/pages edge still senses clicks/drags but is not focusable"); + } + let mut seen = Vec::new(); + for back in [false, true] { + f6(&mut u, back); + for _ in 0..30 { + press(&mut u, Key::Tab, Modifiers::NONE); + if let Some(id) = u.ctx.memory(|m| m.focused()) { + seen.push(id); + } + } + } + assert!(!seen.contains(&left) && !seen.contains(&right), "Tab never stops on a panel edge: {seen:?}"); +} diff --git a/crates/ui-egui/src/assets_panel.rs b/crates/ui-egui/src/assets_panel.rs index 36636c3d..9de8833b 100644 --- a/crates/ui-egui/src/assets_panel.rs +++ b/crates/ui-egui/src/assets_panel.rs @@ -165,7 +165,7 @@ enum Act { } fn drag_id() -> Id { - Id::new("asset_drag") + Id::unique("asset_drag") } /// Whether component `id` has the same layers in two versions of a document (layers an edit @@ -275,7 +275,7 @@ fn refresh_libraries(app: &mut FigApp, ui: &Ui) { return; } let paths: Vec = doc.libraries.enabled.iter().map(|l| l.path.clone()).collect(); - let id = Id::new("asset_libraries_checked"); + let id = Id::unique("asset_libraries_checked"); let now = ui.input(|i| i.time); let last: Option = ui.data(|d| d.get_temp(id)); if last.is_none_or(|l| now - l >= LIBRARY_REFRESH) { @@ -284,7 +284,7 @@ fn refresh_libraries(app: &mut FigApp, ui: &Ui) { } ui.data_mut(|d| d.insert_temp(id, now)); // Team libraries: in the background, at once for one not fetched yet. - let sid = Id::new("asset_server_libraries_checked"); + let sid = Id::unique("asset_server_libraries_checked"); let fetched: Option = ui.data(|d| d.get_temp(sid)); let unread = paths.iter().any(|p| is_server_link(p) && app.session.libraries.cached(p).is_none()); if paths.iter().any(|p| is_server_link(p)) && (unread || fetched.is_none_or(|l| now - l >= SERVER_REFRESH)) { @@ -298,7 +298,7 @@ fn refresh_libraries(app: &mut FigApp, ui: &Ui) { /// The search field with the libraries button; returns the lowercase query. fn search_row(app: &mut FigApp, ui: &mut Ui, w: f32) -> String { let t = Tokens::get(ui.ctx()); - let filter_id = Id::new("asset_filter"); + let filter_id = Id::unique("asset_filter"); let mut filter: String = ui.data(|d| d.get_temp(filter_id)).unwrap_or_default(); let (fr, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover()); let field = Rect::from_min_max(pos2(fr.min.x + 12.0, fr.min.y + 8.0), pos2(fr.max.x - 44.0, fr.max.y - 4.0)); @@ -308,7 +308,7 @@ fn search_row(app: &mut FigApp, ui: &mut Ui, w: f32) -> String { let mut child = ui.new_child(egui::UiBuilder::new().max_rect(te_rect)); let r = child.add( egui::TextEdit::singleline(&mut filter) - .id(Id::new("asset_filter_field")) + .id(Id::unique("asset_filter_field")) .frame(egui::Frame::NONE) .font(regular(11.0)) .hint_text(tr("Search assets")) @@ -323,7 +323,7 @@ fn search_row(app: &mut FigApp, ui: &mut Ui, w: f32) -> String { } ui.data_mut(|d| d.insert_temp(filter_id, filter.clone())); let br = Rect::from_center_size(pos2(fr.max.x - 26.0, field.center().y), vec2(24.0, 24.0)); - let bresp = ui.interact(br, Id::new("asset_libraries_btn"), Sense::click()).tip(tr("Libraries")); + let bresp = ui.interact(br, Id::unique("asset_libraries_btn"), Sense::click()).tip(tr("Libraries")); if bresp.hovered() { ui.painter().rect_filled(br, CornerRadius::same(5), t.hover); } @@ -341,7 +341,7 @@ fn updates_banner(app: &mut FigApp, ui: &mut Ui, w: f32, count: usize) { let r = Rect::from_min_max(pos2(row.min.x + 12.0, row.min.y + 4.0), pos2(row.max.x - 12.0, row.max.y - 4.0)); ui.painter().rect_filled(r, CornerRadius::same(6), t.selected_row); let what = trn("{n} components and styles have newer versions", count, &[]); - ui.interact(r, Id::new("asset_updates"), Sense::hover()).on_hover_text(what); + ui.interact(r, Id::unique("asset_updates"), Sense::hover()).on_hover_text(what); ui.painter().text(pos2(r.min.x + 10.0, r.center().y), Align2::LEFT_CENTER, tr("Library updates available"), semibold(11.0), t.text); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_max(pos2(r.max.x - 70.0, r.min.y + 6.0), pos2(r.max.x - 6.0, r.max.y)))); @@ -436,7 +436,7 @@ fn tiles(ui: &mut Ui, w: f32, src: &Source, list: &[Tile], thumbs: &Thumbs, want let r = Rect::from_min_size(pos2(x, y), vec2(tile, tile)); let resp = ui.interact( r.union(Rect::from_min_size(r.left_bottom(), vec2(tile, TILE_LABEL))), - Id::new(("asset", src, item.id.0)), + Id::unique(("asset", src, item.id.0)), Sense::click_and_drag(), ); ui.painter().rect_filled(r, CornerRadius::same(6), t.field); @@ -591,7 +591,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { let revision = ds.revision; let q = search_row(app, ui, w); let key = (revision, q.clone(), libraries_key(app, &doc)); - let listing_id = Id::new("asset_listing"); + let listing_id = Id::unique("asset_listing"); let listing = match ui.data(|d| d.get_temp::>(listing_id)) { Some(l) if l.key == key => l, _ => { @@ -603,12 +603,12 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { if listing.updates > 0 { updates_banner(app, ui, w, listing.updates); } - let mut thumbs: Thumbs = ui.data(|d| d.get_temp(Id::new("asset_thumbs"))).unwrap_or_default(); + let mut thumbs: Thumbs = ui.data(|d| d.get_temp(Id::unique("asset_thumbs"))).unwrap_or_default(); let mut sources: Vec<(Source, Arc)> = vec![(Source::Local, doc.clone())]; sources.extend(listing.libraries.iter().filter_map(|l| l.doc.clone().map(|d| (Source::Library(l.id.clone()), d)))); invalidate_thumbs(ui, &mut thumbs, &sources); let mut wanted = Vec::new(); - let collapsed_id = Id::new("asset_collapsed_libraries"); + let collapsed_id = Id::unique("asset_collapsed_libraries"); let collapsed: HashSet = ui.data(|d| d.get_temp(collapsed_id)).unwrap_or_default(); let mut act = None; egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { @@ -655,7 +655,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { render_thumbs(ui, w, &mut thumbs, &wanted); let drag = ui.data(|d| d.get_temp::(drag_id())); let tex = drag.as_ref().and_then(|d| thumbs.map.get(&(d.src.clone(), d.component)).cloned().flatten()); - ui.data_mut(|d| d.insert_temp(Id::new("asset_thumbs"), thumbs)); + ui.data_mut(|d| d.insert_temp(Id::unique("asset_thumbs"), thumbs)); match act { Some(Act::Drag(d)) => ui.data_mut(|m| { m.insert_temp(drag_id(), d); @@ -761,7 +761,7 @@ fn drag_feedback(app: &mut FigApp, ui: &mut Ui, doc: &Document, libs: &HashMap) { let t = Tokens::get(ui.ctx()); - let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, Id::new("asset_ghost"))); + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, Id::unique("asset_ghost"))); let r = Rect::from_center_size(p, vec2(64.0, 64.0)); painter.rect_filled(r, CornerRadius::same(6), t.panel.gamma_multiply(0.9)); painter.rect_stroke(r, CornerRadius::same(6), Stroke::new(1.0, COMPONENT), egui::StrokeKind::Inside); @@ -798,7 +798,7 @@ fn libraries_view(app: &mut FigApp, ui: &mut Ui) { let w = ui.available_width(); let (hr, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover()); let back = Rect::from_center_size(pos2(hr.min.x + 24.0, hr.center().y), vec2(24.0, 24.0)); - let bresp = ui.interact(back, Id::new("asset_libraries_back"), Sense::click()).tip(tr("Back to assets")); + let bresp = ui.interact(back, Id::unique("asset_libraries_back"), Sense::click()).tip(tr("Back to assets")); if bresp.hovered() { ui.painter().rect_filled(back, CornerRadius::same(5), t.hover); } @@ -818,7 +818,7 @@ fn libraries_view(app: &mut FigApp, ui: &mut Ui) { } fn publish_form(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { - let form_id = Id::new("asset_publish_form"); + let form_id = Id::unique("asset_publish_form"); let mut form: PublishForm = ui.data(|d| d.get_temp(form_id)).unwrap_or_default(); title(ui, w, tr("This file")); let m = doc.published.as_ref().map(|p| &p.manifest); @@ -887,7 +887,7 @@ fn publish_form(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { ui.horizontal(|ui| { ui.add_space(PAD); if let Some(d) = - crate::widgets::text_field(ui, Id::new("asset_publish_description"), &description, w - PAD * 2.0, tr("Description (optional)")) + crate::widgets::text_field(ui, Id::unique("asset_publish_description"), &description, w - PAD * 2.0, tr("Description (optional)")) { form.description = Some(d); } @@ -948,7 +948,7 @@ fn publish_form(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { /// How other files use this file's library on its server (asked for with a button: it is a /// request to the server). fn usage(app: &mut FigApp, ui: &mut Ui, w: f32, link: &str) { - let usage_id = Id::new(("asset_library_usage", link)); + let usage_id = Id::unique(("asset_library_usage", link)); let loading = app.session.waiting_for(USAGE); let (mut ask, mut stop) = (false, false); ui.horizontal(|ui| { @@ -1033,9 +1033,9 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { let clip = Rect::from_min_size(pos2(r.min.x + PAD, r.min.y), vec2(text_w, r.height())); ui.painter().with_clip_rect(clip).galley(pos2(r.min.x + PAD, r.min.y + 6.0), name, t.text); ui.painter().with_clip_rect(clip).galley(pos2(r.min.x + PAD, r.min.y + 23.0), line, t.text_secondary); - ui.interact(clip, Id::new(("asset_lib_row", &l.id)), Sense::hover()).on_hover_text(shown_path(&l.path)); + ui.interact(clip, Id::unique(("asset_lib_row", &l.id)), Sense::hover()).on_hover_text(shown_path(&l.path)); let x = Rect::from_center_size(pos2(r.max.x - 24.0, r.center().y), vec2(24.0, 24.0)); - let xr = ui.interact(x, Id::new(("asset_lib_remove", &l.id)), Sense::click()).tip(tr("Remove library from this file")); + let xr = ui.interact(x, Id::unique(("asset_lib_remove", &l.id)), Sense::click()).tip(tr("Remove library from this file")); if xr.hovered() { ui.painter().rect_filled(x, CornerRadius::same(5), t.hover); } @@ -1046,7 +1046,7 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { if swappable { let sw = Rect::from_center_size(pos2(r.max.x - 52.0, r.center().y), vec2(24.0, 24.0)); let sr = ui - .interact(sw, Id::new(("asset_lib_swap", &l.id)), Sense::click()) + .interact(sw, Id::unique(("asset_lib_swap", &l.id)), Sense::click()) .tip(tr("Swap library: use another library's assets of the same names")); if sr.hovered() { ui.painter().rect_filled(sw, CornerRadius::same(5), t.hover); @@ -1072,7 +1072,7 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { let pasted = ui .horizontal(|ui| { ui.add_space(PAD); - crate::widgets::text_field(ui, Id::new("asset_add_link"), "", w - PAD * 2.0, tr("Paste a team library link (a view link)")) + crate::widgets::text_field(ui, Id::unique("asset_add_link"), "", w - PAD * 2.0, tr("Paste a team library link (a view link)")) }) .inner; if let Some(link) = pasted.map(|l| l.trim().to_string()).filter(|l| !l.is_empty()) { @@ -1118,7 +1118,7 @@ fn added(app: &mut FigApp, r: &Value) { /// Tests: the components whose thumbnails are cached (for this file's assets). #[cfg(test)] pub(crate) fn cached_thumbnails(ctx: &egui::Context) -> Vec { - let thumbs: Thumbs = ctx.data(|d| d.get_temp(Id::new("asset_thumbs"))).unwrap_or_default(); + let thumbs: Thumbs = ctx.data(|d| d.get_temp(Id::unique("asset_thumbs"))).unwrap_or_default(); let mut out: Vec = thumbs.map.keys().filter(|(s, _)| *s == Source::Local).map(|(_, id)| *id).collect(); out.sort(); out diff --git a/crates/ui-egui/src/bidi.rs b/crates/ui-egui/src/bidi.rs index 58c5df93..0c7bf99e 100644 --- a/crates/ui-egui/src/bidi.rs +++ b/crates/ui-egui/src/bidi.rs @@ -1,69 +1,23 @@ //! Names in right-to-left and mixed-direction scripts (Hebrew, Arabic…) in the panels: layer, page, -//! style, component and file names. egui has no bidi: it shapes each run of one font on its own, -//! reversing right-to-left runs but leaving the words and runs of a name in logical order. So -//! names go through [`figcraft_text::display`] (visual order, Arabic letters joined, mirrored -//! brackets) and every shown cluster is laid out as its own section, which egui's shaper leaves as -//! it is. Names are elided at their end (the left end of a right-to-left name) and edited by -//! [`single_line`], whose caret, clicks and selection follow the shown text (the arrow keys move -//! visually). Left-to-right names take egui's own path unchanged. +//! style, component and file names. egui 0.37's epaint shapes the logical (typing-order) text with +//! the Unicode bidirectional algorithm (UAX #9), lays every run out in visual order, joins Arabic +//! letters through the font's OpenType features, mirrors brackets, and keeps one glyph per char so +//! that caret, selection and click offsets stay logical. Names are therefore passed to egui in +//! logical order and elided at their visual end. The rename editor is egui's own single-line +//! `TextEdit`, whose arrow keys move the caret on screen while its state remains a logical range. -use std::collections::HashMap; use std::sync::Arc; -use egui::text::{ByteIndex, CCursor, CCursorRange, LayoutJob, LayoutSection, TextFormat, TextWrapping}; -use egui::{Color32, FontId, Galley, Id, Painter, Pos2, Rect, Response, Stroke, Ui, pos2, vec2}; -use figcraft_text::display::{self, VisualLine}; - -/// Shown (visual, elided) lines by name, font and width: elision measures many candidates, so the -/// result is kept between frames (cleared when it grows large). -#[derive(Clone, Default)] -struct Elided(Arc>>); - -/// A name, its font's size and family (proportional or not) and the width it was cut to (bits). -type ElidedKey = (String, u32, bool, u32); - -const ELIDED_MAX: usize = 4096; +use egui::text::{LayoutJob, TextWrapping}; +use egui::{Color32, FontId, Galley, Id, Painter, Pos2, Rect, Response, Ui, pos2, vec2}; /// `name` on one line in `font`, at most `max_w` wide (ending in an ellipsis when cut), ready to -/// paint: in visual order with Arabic letters joined when it has right-to-left text. +/// paint. The text stays in logical order; egui shapes, joins and reorders it, and puts the +/// ellipsis at the row's visual end (the left end of a right-to-left name). pub fn name_galley(painter: &Painter, name: &str, font: FontId, color: Color32, max_w: f32) -> Arc { let max_w = max_w.max(1.0); - if !display::has_rtl(name) { - let mut job = LayoutJob::simple_singleline(name.to_string(), font, color); - job.wrap = TextWrapping::truncate_at_width(max_w); - return painter.layout_job(job); - } - let key = (name.to_string(), font.size.to_bits(), matches!(font.family, egui::FontFamily::Proportional), max_w.to_bits()); - let cache = painter.ctx().data_mut(|d| d.get_temp_mut_or_default::(Id::new("bidi_elided")).clone()); - let hit = cache.0.lock().ok().and_then(|m| m.get(&key).cloned()); - let shown = hit.unwrap_or_else(|| { - let fits = |v: &VisualLine| visual_galley(painter, v, &font, color).size().x <= max_w; - let v = display::elide(name, fits); - if let Ok(mut m) = cache.0.lock() { - if m.len() >= ELIDED_MAX { - m.clear(); - } - m.insert(key, v.clone()); - } - v - }); - visual_galley(painter, &shown, &font, color) -} - -/// A line in display order laid out with each cluster as its own section, so egui's shaper (which -/// would reverse a right-to-left run once more) gets one ordered, joined cluster at a time. -fn visual_galley(painter: &Painter, line: &VisualLine, font: &FontId, color: Color32) -> Arc { - let chars: Vec = line.text.chars().collect(); - let format = TextFormat::simple(font.clone(), color); - let mut job = LayoutJob::default(); - for c in &line.clusters { - let start = job.text.len(); - job.text.extend(chars.get(c.shown.clone()).unwrap_or_default()); - // Pushed as is: `LayoutJob::append` would merge it with the section before. - if job.text.len() > start { - job.sections.push(LayoutSection { leading_space: 0.0, byte_range: ByteIndex(start)..ByteIndex(job.text.len()), format: format.clone() }); - } - } + let mut job = LayoutJob::simple_singleline(name.to_string(), font, color); + job.wrap = TextWrapping::truncate_at_width(max_w); painter.layout_job(job) } @@ -76,209 +30,29 @@ pub fn paint_name(painter: &Painter, left_center: Pos2, name: &str, font: FontId rect } -/// Each shown cluster's left and right x in a galley of `v.text` laid out from x = 0. -fn edges(v: &VisualLine, galley: &Galley) -> Vec<(f64, f64)> { - let chars: Vec<(f64, f64)> = galley - .rows - .first() - .map(|r| r.row.glyphs.iter().map(|g| (f64::from(g.pos.x), f64::from(g.pos.x + g.advance_width))).collect()) - .unwrap_or_default(); - v.cluster_edges(&chars) -} - -/// The shown text of a field being edited, laid out once per frame. -struct Shown { - line: VisualLine, - galley: Arc, - edges: Vec<(f64, f64)>, -} - -impl Shown { - fn new(ui: &Ui, text: &str, font: &FontId, color: Color32) -> Shown { - let line = display::visual_line(text); - let galley = visual_galley(ui.painter(), &line, font, color); - let edges = edges(&line, &galley); - Shown { line, galley, edges } - } - - fn caret_x(&self, i: usize) -> f32 { - self.line.caret_x(i, &self.edges) as f32 - } - - fn hit(&self, x: f32) -> usize { - self.line.hit(f64::from(x), &self.edges) - } -} - -fn scroll_id(id: Id) -> Id { - id.with("bidi_scroll") -} - -fn anchor_id(id: Id) -> Id { - id.with("bidi_anchor") -} - -/// A frameless one-line text editor for a name (a [`egui::TextEdit`] with id `id`). Text with -/// right-to-left characters shows in visual order, and the caret, the selection, clicks, drags and -/// the left and right arrow keys follow what's on screen. +/// A frameless one-line text editor for a name (a [`egui::TextEdit`] with id `id`). The editor's +/// text is logical; egui's galley paints it in visual order, and its caret, selection, clicks, +/// drags and left and right arrows follow what is on screen while reporting logical char offsets. pub fn single_line(ui: &mut Ui, id: Id, text: &mut String, font: FontId, width: f32) -> Response { - let rtl = display::has_rtl(text); - let focused = ui.memory(|m| m.has_focus(id)); - let color = ui.visuals().text_color(); - if rtl && focused { - visual_arrows(ui, id, text, &font, color); - } // Centred in the height it's given (a rename row), like the name it replaces. let height = ui.available_height().max(0.0); - let edit = egui::TextEdit::singleline(text) + egui::TextEdit::singleline(text) .id(id) .frame(egui::Frame::NONE) - .font(font.clone()) + .font(font) .desired_width(width) - .vertical_align(egui::Align::Center) - .min_size(vec2(0.0, height)); - if !rtl { - ui.data_mut(|d| d.remove::(scroll_id(id))); - return edit.show(ui).response.response; - } - // egui would draw the chars in logical order: hide its text, caret and selection and draw the - // shown text over it. - let out = ui - .scope(|ui| { - let v = ui.visuals_mut(); - v.selection.bg_fill = Color32::TRANSPARENT; - v.selection.stroke = Stroke::new(0.0, Color32::TRANSPARENT); - v.text_cursor.stroke = Stroke::NONE; - edit.text_color(Color32::TRANSPARENT).show(ui) - }) - .inner; - let response = out.response.response; - let shown = Shown::new(ui, text, &font, color); - let clip = out.text_clip_rect; - let mut scroll: f32 = ui.data(|d| d.get_temp(scroll_id(id))).unwrap_or(0.0); - let origin = |scroll: f32| pos2(clip.min.x - scroll, out.galley_pos.y); - // Clicks and drags place the caret by what's on screen. - let mut range = out.state.cursor.char_range(); - if let Some(p) = response.interact_pointer_pos() { - let at = shown.hit(p.x - origin(scroll).x); - if response.double_clicked() { - let (a, b) = word_at(text, at); - range = Some(CCursorRange::two(CCursor::new(a), CCursor::new(b))); - } else if response.clicked() || (response.is_pointer_button_down_on() && !response.dragged()) { - // Pressed (not yet dragging): the caret and the drag's anchor go here. - ui.data_mut(|d| d.insert_temp(anchor_id(id), at)); - range = Some(CCursorRange::one(CCursor::new(at))); - } else if response.dragged() { - let anchor = ui.data(|d| d.get_temp::(anchor_id(id))).unwrap_or(at); - range = Some(CCursorRange { primary: CCursor::new(at), secondary: CCursor::new(anchor), h_pos: None }); - } - if range != out.state.cursor.char_range() { - let mut state = out.state.clone(); - state.cursor.set_char_range(range); - state.store(ui.ctx(), id); - } - } - // Keep the caret in view. - let caret = range.map_or(0, |r| r.primary.index.0); - let cx = shown.caret_x(caret); - let w = clip.width().max(1.0); - if cx - scroll > w - 1.0 { - scroll = cx - w + 1.0; - } - if cx - scroll < 0.0 { - scroll = cx; - } - scroll = scroll.clamp(0.0, (shown.galley.size().x - w + 1.0).max(0.0)); - ui.data_mut(|d| d.insert_temp(scroll_id(id), scroll)); - let o = origin(scroll); - let painter = ui.painter().with_clip_rect(clip.expand2(vec2(1.0, 0.0))); - let h = shown.galley.size().y; - if focused && let Some(r) = range { - let (a, b) = (r.primary.index.0, r.secondary.index.0); - for (x0, x1) in shown.line.selection(a, b, &shown.edges) { - let sel = Rect::from_min_max(pos2(o.x + x0 as f32, o.y), pos2(o.x + x1 as f32, o.y + h)); - painter.rect_filled(sel, 0.0, ui.visuals().selection.bg_fill); - } - } - painter.galley(o, shown.galley.clone(), color); - if focused { - let x = (o.x + cx).round() + 0.5; - painter.line_segment([pos2(x, o.y), pos2(x, o.y + h)], ui.visuals().text_cursor.stroke); - ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); - } - response -} - -/// Plain and Shift-held left and right arrows move the caret across the shown text (consumed so -/// the editor doesn't move it logically). Word and line jumps stay logical. -fn visual_arrows(ui: &mut Ui, id: Id, text: &str, font: &FontId, color: Color32) { - let moves: Vec<(bool, bool)> = ui.input_mut(|i| { - let mut out = Vec::new(); - i.events.retain(|e| match e { - egui::Event::Key { key: key @ (egui::Key::ArrowLeft | egui::Key::ArrowRight), pressed: true, modifiers, .. } - if !modifiers.alt && !modifiers.command && !modifiers.ctrl => - { - out.push((*key == egui::Key::ArrowRight, modifiers.shift)); - false - } - _ => true, - }); - out - }); - if moves.is_empty() { - return; - } - let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), id) else { return }; - let shown = Shown::new(ui, text, font, color); - let mut r = state.cursor.char_range().unwrap_or_default(); - for (right, shift) in moves { - let (lo, hi) = (r.primary.index.0.min(r.secondary.index.0), r.primary.index.0.max(r.secondary.index.0)); - let next = if !shift && lo != hi { - // A selection collapses to its end on the side of the arrow. - let (xa, xb) = (shown.caret_x(lo), shown.caret_x(hi)); - if (xa < xb) == right { hi } else { lo } - } else { - shown.line.step(r.primary.index.0, right, &shown.edges) - }; - r = if shift { - CCursorRange { primary: CCursor::new(next), secondary: r.secondary, h_pos: None } - } else { - CCursorRange::one(CCursor::new(next)) - }; - } - state.cursor.set_char_range(Some(r)); - state.store(ui.ctx(), id); -} - -/// The word around char `i` (letters, digits and marks; else the single char). -fn word_at(text: &str, i: usize) -> (usize, usize) { - let chars: Vec = text.chars().collect(); - let is_word = - |k: usize| chars.get(k).is_some_and(|c| c.is_alphanumeric() || matches!(*c as u32, 0x0300..=0x036F | 0x0591..=0x05C7 | 0x064B..=0x065F)); - let i = i.min(chars.len()); - let at = if is_word(i) { - i - } else if i > 0 && is_word(i - 1) { - i - 1 - } else { - return (i, (i + 1).min(chars.len())); - }; - let mut a = at; - while a > 0 && is_word(a - 1) { - a -= 1; - } - let mut b = at; - while is_word(b) { - b += 1; - } - (a, b) + .align(egui::Align2::LEFT_CENTER) + .min_size(vec2(0.0, height)) + .show(ui) + .response + .response } /// Faces the UI adds for scripts its own fonts lack (desktop: from the system), as egui font data; /// `taken` as for [`crate::theme::system_fallbacks`]. pub fn script_fallbacks(have: &dyn Fn(char) -> bool, taken: &mut Vec>) -> Vec<(String, egui::FontData)> { - // Hebrew, Arabic letters, Arabic presentation forms (what joined names are drawn with). - const SAMPLES: [char; 3] = ['\u{05D0}', '\u{0628}', '\u{FE91}']; + // Hebrew and Arabic letters; egui's shaper selects the joined contextual forms itself. + const SAMPLES: [char; 2] = ['\u{05D0}', '\u{0628}']; const FAMILIES: [&str; 11] = [ "Segoe UI", "Arial", diff --git a/crates/ui-egui/src/board_bar.rs b/crates/ui-egui/src/board_bar.rs index c312ddb9..6ddd1cb8 100644 --- a/crates/ui-egui/src/board_bar.rs +++ b/crates/ui-egui/src/board_bar.rs @@ -170,7 +170,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { let size = vec2(h.width(), 40.0); let at = place(sel, size, app.canvas.rect); let t = Tokens::get(ctx); - egui::Area::new(egui::Id::new("board_bar")).fixed_pos(at).order(egui::Order::Middle).show(ctx, |ui| { + egui::Area::new(egui::Id::unique("board_bar")).fixed_pos(at).order(egui::Order::Middle).show(ctx, |ui| { let (rect, _) = ui.allocate_exact_size(size, Sense::click()); a11y::container_bounds(ui, rect); let p = ui.painter(); @@ -198,7 +198,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { fn color_part(app: &mut FigApp, ui: &mut Ui, mut x: f32, cy: f32, t: &Tokens) -> f32 { for (i, (name, c)) in BOARD_COLORS.iter().enumerate() { let r = Rect::from_center_size(pos2(x + SWATCH / 2.0, cy), vec2(SWATCH, SWATCH)); - let resp = ui.interact(r, ui.id().with(("bb_color", i)), Sense::click()).on_hover_text(tr(color_label(name))); + let resp = ui.interact(r, ui.scope_id().with(("bb_color", i)), Sense::click()).on_hover_text(tr(color_label(name))); a11y::button(&resp, tr(color_label(name))); if resp.hovered() { ui.painter().rect_filled(r.expand(2.0), CornerRadius::same(6), t.hover); @@ -215,7 +215,7 @@ fn color_part(app: &mut FigApp, ui: &mut Ui, mut x: f32, cy: f32, t: &Tokens) -> /// The shape menu at `at` (its left edge and middle), showing `shape`; where the next part starts. fn shape_part(app: &mut FigApp, ui: &mut Ui, shape: BoardShape, at: Pos2, t: &Tokens) -> f32 { let r = Rect::from_min_size(pos2(at.x, at.y - CELL / 2.0), vec2(CELL + 12.0, CELL)); - let resp = ui.interact(r, ui.id().with("bb_shape"), Sense::click()).on_hover_text(tr("Shape")); + let resp = ui.interact(r, ui.scope_id().with("bb_shape"), Sense::click()).on_hover_text(tr("Shape")); a11y::menu_button(&resp, tr("Shape"), a11y::menu_open(&resp)); if resp.hovered() { ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); @@ -240,7 +240,7 @@ fn shape_part(app: &mut FigApp, ui: &mut Ui, shape: BoardShape, at: Pos2, t: &To fn size_part(app: &mut FigApp, ui: &mut Ui, mut x: f32, cy: f32, t: &Tokens) -> f32 { for (label, size) in TEXT_SIZES { let r = Rect::from_center_size(pos2(x + CELL / 2.0, cy), vec2(CELL, CELL)); - let resp = ui.interact(r, ui.id().with(("bb_size", label)), Sense::click()).on_hover_text(tr("Text size")); + let resp = ui.interact(r, ui.scope_id().with(("bb_size", label)), Sense::click()).on_hover_text(tr("Text size")); a11y::button(&resp, &format!("{} {label}", tr("Text size"))); if resp.hovered() { ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); diff --git a/crates/ui-egui/src/board_ui_tests.rs b/crates/ui-egui/src/board_ui_tests.rs index 4ffb3cf4..d184c934 100644 --- a/crates/ui-egui/src/board_ui_tests.rs +++ b/crates/ui-egui/src/board_ui_tests.rs @@ -56,12 +56,12 @@ fn pick_option(u: &mut Ui, i: usize, n: usize) { /// Whether the bar over the selection showed in the last frame. fn bar_shown(ctx: &egui::Context) -> bool { - ctx.memory(|m| m.areas().visible_last_frame(&egui::LayerId::new(egui::Order::Middle, egui::Id::new("board_bar")))) + ctx.memory(|m| m.areas().visible_last_frame(&egui::LayerId::new(egui::Order::Middle, egui::Id::unique("board_bar")))) } /// The bar over the selection, when it shows. fn bar(u: &Ui) -> Option { - bar_shown(&u.ctx).then(|| u.ctx.memory(|m| m.area_rect(egui::Id::new("board_bar")))).flatten() + bar_shown(&u.ctx).then(|| u.ctx.memory(|m| m.area_rect(egui::Id::unique("board_bar")))).flatten() } #[test] diff --git a/crates/ui-egui/src/branch_ui.rs b/crates/ui-egui/src/branch_ui.rs index 1e3c558c..c52fc216 100644 --- a/crates/ui-egui/src/branch_ui.rs +++ b/crates/ui-egui/src/branch_ui.rs @@ -75,7 +75,7 @@ pub fn banner(app: &mut FigApp, ctx: &egui::Context, canvas: Rect) { let Some(b) = m.branches.iter().find(|b| b.id == id) else { return }; let (file, name, active) = (m.doc.name.clone(), b.name.clone(), b.state == BranchState::Active); let t = Tokens::get(ctx); - let review = widgets::canvas_banner(ctx, Id::new("branch_banner"), canvas, |ui| { + let review = widgets::canvas_banner(ctx, Id::unique("branch_banner"), canvas, |ui| { ui.label(egui::RichText::new(format!("“{name}”")).font(semibold(11.0)).color(t.text)); let state = if active { trf("Branch of {file}", &[("file", &file)]) } else { trf("Branch of {file} · merged or archived", &[("file", &file)]) }; @@ -146,7 +146,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { let mut open = true; let mut action = None; egui::Window::new("Review and merge") - .id(Id::new("branch_review_window")) + .id(Id::unique("branch_review_window")) .title_bar(false) .collapsible(false) .resizable(false) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 0b13f5a8..b536f293 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -168,7 +168,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { app.canvas.rect = rect; let ctx = ui.ctx().clone(); let ppp = ctx.pixels_per_point(); - let resp = ui.interact(rect, ui.id().with("canvas"), Sense::click_and_drag()); + let resp = ui.interact(rect, ui.scope_id().with("canvas"), Sense::click_and_drag()); let key_box = app.ui.key_box; crate::a11y::canvas(&mut app.ui.nav, &resp, || key_box.map_or_else(|| describe(&app.session), describe_box)); if let Ok(ds) = app.session.ds_mut() diff --git a/crates/ui-egui/src/clipboard_fence_tests.rs b/crates/ui-egui/src/clipboard_fence_tests.rs new file mode 100644 index 00000000..9d4aeefc --- /dev/null +++ b/crates/ui-egui/src/clipboard_fence_tests.rs @@ -0,0 +1,184 @@ +//! Programmatic gestures never replace the user's system clipboard: keys and clicks injected +//! through the control channel (`ui.input`, `ui.menu.invoke`, …) may copy inside FigCraft but the +//! OS clipboard is the user's. Every copy command goes through `FigApp::clipboard_set_text` / +//! `FigApp::clipboard_set_png`, which drop the write while the frame's input is synthetic. + +use std::sync::{Arc, Mutex}; + +use egui::{Event, Pos2, RawInput, Rect, vec2}; +use figcraft_doc::{NodeId, NodeKind}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::control::ControlRequest; +use crate::{FigApp, Services}; + +#[derive(Default)] +struct Recorded { + text: Vec, + pngs: usize, + /// What the system clipboard holds now (the last text written). + last_text: Option, +} + +/// The system clipboard of the test machine. +struct Clipboard(Arc>); + +impl Services for Clipboard { + fn clipboard_set_text(&mut self, s: &str) { + let mut r = self.0.lock().unwrap(); + r.text.push(s.to_string()); + r.last_text = Some(s.to_string()); + } + fn clipboard_get_text(&mut self) -> Option { + self.0.lock().unwrap().last_text.clone() + } + fn clipboard_set_png(&mut self, _w: u32, _h: u32, _rgba: &[u8]) { + self.0.lock().unwrap().pngs += 1; + } +} + +struct Win { + ctx: egui::Context, + app: FigApp, + time: f64, + recorded: Arc>, +} + +impl Win { + fn new() -> Self { + let mut s = Session::new(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap(); + let mut win = Self::with_session(s); + let _ = win.app.run("select.all", json!({})); + win + } + + fn with_session(s: Session) -> Self { + let recorded = Arc::new(Mutex::new(Recorded::default())); + let app = FigApp::new(s, Box::new(Clipboard(recorded.clone()))); + let mut win = Self { ctx: egui::Context::default(), app, time: 0.0, recorded }; + for _ in 0..3 { + win.frame(vec![]); + } + win + } + + fn frame(&mut self, events: Vec) { + self.time += 1.0 / 60.0; + self.app.clock.set_test_now(Some(self.time)); + let input = + RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), events, ..Default::default() }; + let app = &mut self.app; + let mut out = self.ctx.run_ui(input, |ui| app.ui(ui)); + out.textures_delta.clear(); + } + + fn writes(&self) -> (usize, usize) { + let r = self.recorded.lock().unwrap(); + (r.text.len(), r.pngs) + } +} + +#[test] +fn a_synthetic_copy_shortcut_leaves_the_system_clipboard_alone() { + let mut w = Win::new(); + assert!(!w.app.session.selection().is_empty(), "something to copy"); + let (tx, rx) = std::sync::mpsc::channel(); + w.app.control_queue.push(ControlRequest { + id: json!(1), + method: "ui.input".into(), + // Shift+Cmd+C: `edit.copyAsPng`, which wrote the OS clipboard directly before the fence. + params: json!({"events": [{"key": "C", "shift": true, "cmd": true}]}), + reply: tx, + cancelled: Default::default(), + }); + w.frame(vec![]); + let events = crate::windows::take_input(&mut w.app, egui::ViewportId::ROOT); + assert!(!events.is_empty(), "the injected shortcut"); + w.frame(events); + assert_eq!(w.writes(), (0, 0), "a control-channel gesture never replaces the user's clipboard"); + assert!( + w.app.ui.toast.as_ref().is_some_and(|(m, _)| m.contains("Copied")), + "the command itself ran (only its OS clipboard write is fenced): {:?}", + w.app.ui.toast + ); + assert!(rx.try_recv().is_ok(), "the batch is acknowledged"); +} + +#[test] +fn a_copy_command_invoked_for_a_synthetic_click_still_keeps_the_clipboard() { + let mut w = Win::new(); + // What a synthetic pointer click on Copy runs, fenced as `control::drain`/`background` fence it. + w.app.control_synthetic = true; + crate::menus::invoke(&mut w.app, "edit.copyAsCss"); + crate::menus::invoke(&mut w.app, "edit.copyAsText"); + assert_eq!(w.writes(), (0, 0)); + w.app.control_synthetic = false; + // A user's own copy still writes. + crate::menus::invoke(&mut w.app, "edit.copyAsCss"); + assert_eq!(w.writes(), (1, 0)); +} + +fn edit_text(w: &Win, id: NodeId) -> String { + match &w.app.session.doc().unwrap().node(id).unwrap().kind { + NodeKind::Text(t) => t.characters.clone(), + _ => panic!("not text"), + } +} + +#[test] +fn a_synthetic_cut_deletes_the_selection_but_leaves_the_users_clipboard_alone() { + let mut s = Session::new(); + let id = NodeId(s.execute("text.create", &json!({"text": "abc", "x": 40, "y": 40})).unwrap()["id"].as_u64().unwrap()); + let mut w = Win::with_session(s); + figcraft_engine::textedit::begin(&mut w.app.session, id, Some(3), false).unwrap(); + figcraft_engine::textedit::set_caret(&mut w.app.session, 0, true).unwrap(); + + // A control-channel gesture is fenced from the OS clipboard, but its Cut still edits the + // document: the selection is deleted exactly as a user's Cut would delete it. + w.app.push_control_input(vec![Event::Cut]); + let events = crate::windows::take_input(&mut w.app, egui::ViewportId::ROOT); + assert_eq!(events, [Event::Cut], "the production input path owns the synthetic frame"); + w.frame(events); + assert_eq!(w.writes(), (0, 0), "a synthetic cut never writes the user's clipboard"); + assert_eq!(edit_text(&w, id), "", "the selected text is deleted"); + assert!(w.app.session.ds().unwrap().text_edit.is_some(), "the edit session stays active"); + assert!(!w.app.control_synthetic, "the fence ends with the frame"); +} + +#[test] +fn a_programmatic_copy_does_not_let_a_stale_mirror_hide_the_users_paste() { + let mut s = Session::new(); + let hello = figcraft_doc::NodeId(s.execute("text.create", &json!({"text": "hello", "x": 10, "y": 10})).unwrap()["id"].as_u64().unwrap()); + let other = figcraft_doc::NodeId(s.execute("text.create", &json!({"text": "other", "x": 10, "y": 80})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [hello]})).unwrap(); + let mut w = Win::with_session(s); + + // The user copies the "hello" text layer: the OS clipboard and `last_copy` become "hello". + crate::menus::invoke(&mut w.app, "edit.copy"); + assert_eq!(w.app.ui.last_copy.as_deref(), Some("hello")); + assert_eq!(w.writes().0, 1, "the user's copy reaches the system clipboard"); + + // A control-channel copy changes the engine clipboard to "other" but never writes the system + // clipboard. The stale "hello" mirror must not claim the programmatic selection is ours. + let _ = w.app.run("select.set", json!({"ids": [other]})); + w.app.control_synthetic = true; + crate::menus::invoke(&mut w.app, "edit.copy"); + w.app.control_synthetic = false; + assert_eq!(w.writes().0, 1, "the programmatic copy leaves the system clipboard alone"); + assert_eq!(w.app.ui.last_copy, None, "the old system-clipboard mirror is invalidated"); + + // The user pastes: the system clipboard's own text wins over the programmatic layers. + crate::menus::paste(&mut w.app, Some("hello"), None); + let doc = w.app.session.doc().unwrap(); + let texts: Vec = doc + .pages + .iter() + .filter_map(|p| doc.node(*p)) + .flat_map(|p| p.children.iter().copied()) + .filter_map(|id| doc.node(id).and_then(|n| n.kind.text().map(|t| t.characters.clone()))) + .collect(); + assert_eq!(texts.iter().filter(|t| t.as_str() == "hello").count(), 2, "the user's text was pasted"); + assert_eq!(texts.iter().filter(|t| t.as_str() == "other").count(), 1, "the programmatic selection was not pasted"); +} diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs index d6aad31d..90d00c3f 100644 --- a/crates/ui-egui/src/colorpicker.rs +++ b/crates/ui-egui/src/colorpicker.rs @@ -199,7 +199,7 @@ fn finish(app: &mut FigApp) { /// The selected gradient stop in the picker (shared with the on-canvas stops). pub fn stop_id() -> Id { - Id::new("cp_stop") + Id::unique("cp_stop") } /// Apply a swatch colour: a solid paint's colour, or the selected gradient stop's. @@ -224,7 +224,7 @@ fn set_color(app: &mut FigApp, target: &PickerTarget, paint: &mut Paint, c: Colo fn page_colors(app: &FigApp, ui: &Ui) -> Vec { let Ok(ds) = app.session.ds() else { return vec![] }; let key = (std::sync::Arc::as_ptr(&ds.doc) as usize, ds.page); - let cache_id = Id::new("cp_doc_colors"); + let cache_id = Id::unique("cp_doc_colors"); if let Some((k, colors)) = ui.data(|d| d.get_temp::<((usize, figcraft_doc::NodeId), Vec)>(cache_id)) && k == key { @@ -241,7 +241,7 @@ const MODELS: [&str; 5] = ["Hex", "RGB", "HSL", "HSB", "CSS"]; /// The model dropdown with its fields and the alpha field. Returns a new colour and whether the /// edit is done (false while a field is being scrubbed). fn model_fields(ui: &mut Ui, c: Color) -> Option<(Color, bool)> { - let model_id = Id::new("cp_model"); + let model_id = Id::unique("cp_model"); let model: usize = ui.data(|d| d.get_temp(model_id)).unwrap_or(0).min(MODELS.len() - 1); let mut out: Option = None; let mut done = true; @@ -259,7 +259,7 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option<(Color, bool)> { }); let num = |ui: &mut Ui, id: &str, v: f64, w: f32| match widgets::number_field( ui, - Id::new(("cp_f", id)), + Id::unique(("cp_f", id)), widgets::Lead::None, Some(v.round()), "", @@ -309,19 +309,19 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option<(Color, bool)> { } } "CSS" => { - if let Some(text) = widgets::text_field(ui, Id::new("cp_css"), &c.to_css(), 152.0, "") { + if let Some(text) = widgets::text_field(ui, Id::unique("cp_css"), &c.to_css(), 152.0, "") { out = Color::parse_css(&text); } } _ => { - if let Some(h) = widgets::text_field(ui, Id::new("cp_hex"), &c.to_hex(), 101.0, "") { + if let Some(h) = widgets::text_field(ui, Id::unique("cp_hex"), &c.to_hex(), 101.0, "") { out = Color::from_hex_or_name(&h).map(|h| h.with_alpha(if h.a < 1.0 { h.a } else { c.a })); } } } if MODELS.get(model) != Some(&"CSS") { let alpha = Some((f64::from(out.unwrap_or(c).a) * 100.0).round()); - let edit = match widgets::number_field(ui, Id::new("cp_a"), widgets::Lead::None, alpha, "%", 44.0, 1.0) { + let edit = match widgets::number_field(ui, Id::unique("cp_a"), widgets::Lead::None, alpha, "%", 44.0, 1.0) { Some(widgets::FieldEdit::Set(v)) => Some((v, true)), Some(widgets::FieldEdit::Scrub(v, end)) => Some((v, end)), None => None, @@ -362,7 +362,7 @@ fn sv_square(ui: &mut Ui, rect: Rect, hsb: &mut Hsb) -> (bool, bool) { m2.add_triangle(0, 1, 2); m2.add_triangle(0, 2, 3); ui.painter().add(m2); - let resp = ui.interact(rect, Id::new("cp_sv"), Sense::click_and_drag()); + let resp = ui.interact(rect, Id::unique("cp_sv"), Sense::click_and_drag()); let mut changed = false; if (resp.dragged() || resp.clicked() || resp.drag_started()) && let Some(p) = resp.interact_pointer_pos() @@ -393,7 +393,7 @@ fn hue_slider(ui: &mut Ui, rect: Rect, hsb: &mut Hsb) -> (bool, bool) { } } ui.painter().add(m); - slider_knob(ui, rect, hsb.h / 360.0, Id::new("cp_hue")) + slider_knob(ui, rect, hsb.h / 360.0, Id::unique("cp_hue")) .map(|(t, done)| { hsb.h = (t * 360.0).clamp(0.0, 359.9); (true, done) @@ -432,11 +432,24 @@ fn alpha_slider(ui: &mut Ui, rect: Rect, color: Color) -> Option<(f32, bool)> { m.add_triangle(0, 1, 2); m.add_triangle(0, 2, 3); ui.painter().add(m); - slider_knob(ui, rect, color.a, Id::new("cp_alpha")) + slider_knob(ui, rect, color.a, Id::unique("cp_alpha")) +} + +/// The colour picker window's area id. A fixed id (not `Window::new`'s, which egui keys by +/// `Id::unique(Some(title))`) so the style picker can tell a click inside the picker from one outside. +pub fn window_id() -> Id { + Id::unique("color_picker") } pub fn show(app: &mut FigApp, ctx: &egui::Context) { - let Some(target) = app.ui.picker.clone() else { return }; + let Some(target) = app.ui.picker.clone() else { + // A colour sampled for a picker that has closed has no paint to go to. + app.session.tool.picked = None; + if app.session.tool.pick_only { + app.run("tool.move", json!({})); + } + return; + }; // A layer's picker belongs to the selection it was opened for: once that changes, close it // rather than keep editing layers that are no longer selected. let stale = match &target { @@ -463,6 +476,9 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { let mut open = true; let mut close_now = false; egui::Window::new("color_picker") + // A fixed area id so the style picker can tell a click in here from a click outside + // (`Window::new` alone would key the area by `Id::unique(Some(title))`). + .id(window_id()) .title_bar(false) .fixed_pos(pos) .fixed_size(vec2(240.0, 0.0)) @@ -470,11 +486,15 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { .open(&mut open) .show(ctx, |ui| { ui.spacing_mut().item_spacing = vec2(8.0, 8.0); + // The eyedropper's colour goes to this picker's paint (a stroke, an effect, a gradient stop...). + if let Some(c) = app.session.tool.picked.take() { + set_color(app, &target, &mut paint, c, stop_id(), ui); + } // Header. let (hr, _) = ui.allocate_exact_size(vec2(240.0, 40.0), Sense::hover()); ui.painter().text(pos2(hr.min.x + 16.0, hr.center().y), Align2::LEFT_CENTER, tr("Custom"), semibold(11.0), t.text); let cr = Rect::from_center_size(pos2(hr.max.x - 20.0, hr.center().y), vec2(24.0, 24.0)); - let cresp = ui.interact(cr, Id::new("cp_close"), Sense::click()); + let cresp = ui.interact(cr, Id::unique("cp_close"), Sense::click()); icons::paint(ui.painter(), cr.shrink(6.0), Icon::Close, t.icon); if cresp.clicked() { close_now = true; @@ -585,11 +605,11 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { let mut changed = false; let mut done = false; // The bar first, so the stops on it take their own clicks. - let bresp = ui.interact(bar, Id::new("cp_bar"), Sense::click()); + let bresp = ui.interact(bar, Id::unique("cp_bar"), Sense::click()); for (i, s) in g.stops.clone().iter().enumerate() { let x = bar.min.x + s.position * bar.width(); let r = Rect::from_center_size(pos2(x, bar.center().y), vec2(12.0, 18.0)); - let resp = ui.interact(r, Id::new(("cp_stopk", i)), Sense::click_and_drag()); + let resp = ui.interact(r, Id::unique(("cp_stopk", i)), Sense::click_and_drag()); ui.painter().rect_filled(r, CornerRadius::same(3), doc_c32(ui.ctx(), s.color.with_alpha(1.0))); ui.painter().rect_stroke( r, @@ -753,7 +773,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { } if let Some(color) = edit_color { let mut hsb = ui - .data(|d| d.get_temp::<(f32, f32, f32, [u8; 4])>(Id::new("cp_hsb"))) + .data(|d| d.get_temp::<(f32, f32, f32, [u8; 4])>(Id::unique("cp_hsb"))) .filter(|(_, _, _, rgba)| *rgba == color.to_rgba8()) .map(|(h, s, b, _)| Hsb { h, s, b }) .unwrap_or_else(|| Hsb::from_color(color)); @@ -772,8 +792,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { ) .clicked() { - app.ui.picker = None; - app.run("edit.pickColor", json!({})); + app.run("edit.pickColor", json!({"apply": false})); return; } let (c2, d2) = hue_slider(ui, hr, &mut hsb); @@ -798,7 +817,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { changed = true; done |= d; } - ui.data_mut(|d| d.insert_temp(Id::new("cp_hsb"), (hsb.h, hsb.s, hsb.b, nc.to_rgba8()))); + ui.data_mut(|d| d.insert_temp(Id::unique("cp_hsb"), (hsb.h, hsb.s, hsb.b, nc.to_rgba8()))); // A Display P3 colour sRGB can't show: the canvas (sRGB) shows the nearest one. if app.session.doc().is_ok_and(|d| d.color_profile.conversion_to(figcraft_color::ColorProfile::Srgb).clips(nc)) { gamut_warning(ui, &t); @@ -964,7 +983,7 @@ fn pattern_controls(app: &mut FigApp, ui: &mut Ui, target: &PickerTarget, paint: ui.add_space(16.0); let field = |ui: &mut Ui, key: &str, lead: Lead, value: f64, suffix: &str| match widgets::number_field( ui, - Id::new(key), + Id::unique(key), lead, Some(value), suffix, @@ -1010,7 +1029,7 @@ fn saved_preset_row(ui: &mut Ui, name: &str) -> Option { let w = ui.available_width().max(180.0); let (r, resp) = ui.allocate_exact_size(vec2(w, 26.0), Sense::click()); let del = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(20.0, 20.0)); - let del_resp = ui.interact(del, ui.id().with(("cp_preset_del", name)), Sense::click()).tip(tr("Delete preset")); + let del_resp = ui.interact(del, ui.scope_id().with(("cp_preset_del", name)), Sense::click()).tip(tr("Delete preset")); let hot = resp.hovered() || del_resp.hovered(); if hot { ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover); @@ -1042,7 +1061,7 @@ fn image_presets(app: &mut FigApp, ui: &mut Ui, target: &PickerTarget, paint: &m .find(|(_, f)| *f == filters) .map(|(name, _)| name) .unwrap_or_else(|| if filters.is_identity() { tr("No preset").into() } else { tr("Custom").into() }); - let name_id = Id::new("cp_preset_name"); + let name_id = Id::unique("cp_preset_name"); let mut naming: Option = ui.data(|d| d.get_temp(name_id)); let mut action = None; ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/colorpicker_eyedropper_tests.rs b/crates/ui-egui/src/colorpicker_eyedropper_tests.rs new file mode 100644 index 00000000..51856b48 --- /dev/null +++ b/crates/ui-egui/src/colorpicker_eyedropper_tests.rs @@ -0,0 +1,90 @@ +//! The eyedropper of a colour picker, driven through egui headless: the colour it samples goes to +//! the paint the picker edits (a stroke, an effect), not to the layer's fill (vectorcraft#3342). + +use egui::accesskit::Role; +use egui::{Event, Pos2, pos2}; +use figcraft_doc::NodeId; +use figcraft_engine::{Session, ToolId}; +use serde_json::json; + +use crate::colorpicker::PickerTarget; +use crate::layers_drag_tests::Ui; + +fn rect(s: &mut Session, x: f64, props: serde_json::Value) -> NodeId { + let v = s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 60, "height": 60, "props": props})).unwrap(); + NodeId(v["id"].as_u64().unwrap()) +} + +/// The middle of the picker's eyedropper button. +fn eyedropper_button(u: &mut Ui) -> Pos2 { + u.ctx.enable_accesskit(); + u.frame(vec![]); + let out = u.frame_output(vec![]); + let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes; + let n = nodes.iter().map(|(_, n)| n).find(|n| n.role() == Role::Button && n.label() == Some("Pick color (I)")).expect("the eyedropper button"); + let b = n.bounds().expect("bounds"); + pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32) +} + +fn click(u: &mut Ui, p: Pos2) { + u.button(p, true); + u.button(p, false); + u.frame(vec![Event::PointerMoved(p)]); + u.frame(vec![]); +} + +#[test] +fn the_pickers_eyedropper_applies_the_colour_to_the_paint_it_edits() { + for effect in [false, true] { + // A red layer to sample, and a green one with a blue stroke (and a shadow) to edit. + let mut s = Session::new(); + s.execute("page.setBackground", &json!({"color": "#FFFFFF"})).unwrap(); + let red = rect(&mut s, 0.0, json!({"fill": "#FF0000"})); + let b = rect(&mut s, 200.0, json!({"fill": "#00FF00", "stroke": "#0000FF", "strokeWeight": 2})); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + let target = if effect { + s.execute("effect.add", &json!({"ids": [b], "type": "dropShadow"})).unwrap(); + PickerTarget::Effect { ids: vec![b], index: 0, secondary: false } + } else { + PickerTarget::Paint { fills: false, index: 0, ids: vec![b] } + }; + let mut u = Ui::new(s); + u.app.ui.picker = Some(target.clone()); + let at = eyedropper_button(&mut u); + click(&mut u, at); + assert_eq!(u.app.session.tool.tool, ToolId::Eyedropper, "the button picks up the eyedropper"); + let undo = u.app.session.undo_depth().0; + let on_red = u.app.to_screen(30.0, 30.0); + click(&mut u, on_red); + let doc = u.app.session.doc().unwrap(); + let n = doc.node(b).unwrap(); + let hex = |p: Option| p.map(|c| c.to_hex()).unwrap_or_default(); + let picked = if effect { hex(n.effects[0].color(false)) } else { hex(n.strokes[0].solid_color()) }; + assert_eq!(picked, "FF0000", "the picker's paint takes the colour (effect: {effect})"); + assert_eq!(hex(n.fills[0].solid_color()), "00FF00", "the fill is left alone (effect: {effect})"); + assert!(doc.node(red).is_some()); + assert_eq!(u.app.session.tool.tool, ToolId::Move, "picking ends the eyedropper"); + assert_eq!(u.app.ui.picker, Some(target), "the picker stays open on its paint"); + assert_eq!(u.app.session.undo_depth().0, undo + 1, "one undo step"); + } +} + +#[test] +fn closing_the_picker_drops_its_eyedropper() { + // The colour sampled after the picker closed has no paint to go to: it must not wait for the + // next picker, and the eyedropper gives way to the Move tool. + let mut s = Session::new(); + let b = rect(&mut s, 200.0, json!({"fill": "#00FF00", "stroke": "#0000FF"})); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + let mut u = Ui::new(s); + u.app.ui.picker = Some(PickerTarget::Paint { fills: false, index: 0, ids: vec![b] }); + let at = eyedropper_button(&mut u); + click(&mut u, at); + assert_eq!(u.app.session.tool.tool, ToolId::Eyedropper); + u.app.ui.picker = None; + u.app.session.tool.picked = figcraft_color::Color::from_hex("#FF0000"); + u.frame(vec![]); + u.frame(vec![]); + assert_eq!(u.app.session.tool.tool, ToolId::Move); + assert_eq!(u.app.session.tool.picked, None, "a colour sampled for a closed picker is dropped"); +} diff --git a/crates/ui-egui/src/colorpicker_selection_tests.rs b/crates/ui-egui/src/colorpicker_selection_tests.rs index 57aae197..fff5536a 100644 --- a/crates/ui-egui/src/colorpicker_selection_tests.rs +++ b/crates/ui-egui/src/colorpicker_selection_tests.rs @@ -52,3 +52,54 @@ fn picker_closes_when_another_layer_is_selected() { u.frame(vec![]); assert!(u.app.ui.picker.is_none(), "selecting another layer closes the picker: {:?}", u.app.ui.picker); } + +// Regression test for #237: the "On this page" swatches wrapped onto rows that started at the picker's +// left edge (only the first row was indented) and ran past its right edge. +#[test] +fn wrapped_page_colour_swatches_stay_inset_inside_the_picker() { + use egui::Shape; + let mut s = Session::new(); + let mut first = None; + for i in 0..40 { + let v = s + .execute( + "shape.rectangle", + &json!({"x": i * 30, "y": 0, "width": 20, "height": 20, "props": {"fill": format!("#{:02X}{:02X}80", i * 6, 255 - i * 6)}}), + ) + .unwrap(); + first.get_or_insert(NodeId(v["id"].as_u64().unwrap())); + } + let a = first.unwrap(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let mut u = Ui::new(s); + u.app.ui.picker = Some(PickerTarget::Paint { fills: true, index: 0, ids: vec![a] }); + for _ in 0..3 { + u.frame(vec![]); + } + let out = u.frame_output(vec![]); + // The picker is the only floating window open. + let picker = u + .ctx + .memory(|m| m.areas().visible_layer_ids().into_iter().filter(|l| l.order == egui::Order::Middle).find_map(|l| m.area_rect(l.id))) + .expect("the picker is open"); + // A swatch is a 14 pt square with a 1 pt outline. + let swatches: Vec = out + .shapes + .iter() + .filter_map(|c| match &c.shape { + Shape::Rect(r) if r.stroke.width == 1.0 && (r.rect.width() - 14.0).abs() < 0.01 && (r.rect.height() - 14.0).abs() < 0.01 => Some(r.rect), + _ => None, + }) + .filter(|r| picker.contains_rect(*r)) + .collect(); + let mut rows: Vec = swatches.iter().map(|r| r.min.y).collect(); + rows.sort_by(f32::total_cmp); + rows.dedup_by(|a, b| (*a - *b).abs() < 1.0); + assert!(rows.len() >= 2, "expected the page colours to wrap onto several rows, got {} swatches in {} row(s)", swatches.len(), rows.len()); + for r in &swatches { + assert!( + r.min.x >= picker.min.x + 16.0 - 0.5 && r.max.x <= picker.max.x - 16.0 + 0.5, + "swatch {r:?} is not inset 16 pt from both sides of the picker {picker:?}" + ); + } +} diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index d33c81ee..468836de 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -476,7 +476,7 @@ pub fn start_draft(app: &mut FigApp, x: f64, y: f64) { /// The reply field's id for a thread. fn reply_id(thread: u64) -> Id { - Id::new(("comment_reply", thread)) + Id::unique(("comment_reply", thread)) } fn set_cursor(ctx: &egui::Context, id: Id, at: usize) { @@ -650,7 +650,7 @@ fn message( let t = Tokens::get(ui.ctx()); let ctx = ui.ctx().clone(); let id = cx.c.id; - let hover_key = Id::new(("comment_msg_hover", id, i)); + let hover_key = Id::unique(("comment_msg_hover", id, i)); let hovered = ctx.data(|d| d.get_temp::(hover_key)).unwrap_or(false); let indent = 36.0; let block = ui.scope(|ui| { @@ -663,10 +663,10 @@ fn message( ui.label(egui::RichText::new(when).font(regular(11.0)).color(t.text_tertiary)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if m.author == cx.me { - let menu = Id::new(("comment_msg_more_menu", id, i)); + let menu = Id::unique(("comment_msg_more_menu", id, i)); let more = hover_button( ui, - Id::new(("comment_msg_more", id, i)), + Id::unique(("comment_msg_more", id, i)), Icon::Dots, hovered || egui::Popup::is_id_open(&ctx, menu), tr("More"), @@ -682,10 +682,10 @@ fn message( } }); } - let menu = Id::new(("comment_msg_react_menu", id, i)); + let menu = Id::unique(("comment_msg_react_menu", id, i)); let react = hover_button( ui, - Id::new(("comment_msg_react", id, i)), + Id::unique(("comment_msg_react", id, i)), Icon::Reaction, hovered || egui::Popup::is_id_open(&ctx, menu), tr("Add reaction"), @@ -715,7 +715,7 @@ fn message( ui.horizontal(|ui| { ui.add_space(indent); ui.vertical(|ui| { - if composer(ui, Id::new(("comment_edit", id, i)), text, tr("Edit comment"), cx.people, cx.me, pick) { + if composer(ui, Id::unique(("comment_edit", id, i)), text, tr("Edit comment"), cx.people, cx.me, pick) { let mut p = message_params(id, i); p["text"] = json!(text.trim()); *act = Some(Act::Run("comment.edit", p)); @@ -828,12 +828,12 @@ pub fn popovers(app: &mut FigApp, ctx: &egui::Context) { let me = me(app); let mut pick = std::mem::take(&mut app.ui.comments.mention); let mut post = false; - egui::Area::new(Id::new("comment_draft")).order(egui::Order::Foreground).fixed_pos(popover_pos(app.to_screen(x, y), canvas)).show( + egui::Area::new(Id::unique("comment_draft")).order(egui::Order::Foreground).fixed_pos(popover_pos(app.to_screen(x, y), canvas)).show( ctx, |ui| { popover_frame(&t).show(ui, |ui| { ui.set_width(POPOVER_W - 24.0); - post = composer(ui, Id::new("comment_draft_text"), &mut text, tr("Add a comment"), &people, &me, &mut pick); + post = composer(ui, Id::unique("comment_draft_text"), &mut text, tr("Add a comment"), &people, &me, &mut pick); }); }, ); @@ -877,7 +877,7 @@ pub fn thread(app: &mut FigApp, ctx: &egui::Context, id: u64, anchor: Pos2, boun let mut pick = std::mem::take(&mut app.ui.comments.mention); let mut act: Option = None; let cx = ThreadCtx { c: &c, me: &me, people: &people }; - egui::Area::new(Id::new("comment_thread")).order(egui::Order::Foreground).fixed_pos(popover_pos(anchor, bounds)).show(ctx, |ui| { + egui::Area::new(Id::unique("comment_thread")).order(egui::Order::Foreground).fixed_pos(popover_pos(anchor, bounds)).show(ctx, |ui| { popover_frame(&t).show(ui, |ui| { ui.set_width(POPOVER_W - 24.0); // Header: resolve, more, close. @@ -934,7 +934,7 @@ pub fn thread(app: &mut FigApp, ctx: &egui::Context, id: u64, anchor: Pos2, boun } Some(Act::CopyLink) => { if let Some(link) = app.run("comment.link", json!({"id": id})).and_then(|v| v.get("link").and_then(Value::as_str).map(str::to_string)) { - app.services.clipboard_set_text(&link); + app.clipboard_set_text(&link); app.toast(tr("Link copied")); } } @@ -1051,7 +1051,7 @@ pub fn panel(app: &mut FigApp, ui: &mut Ui) { ui.horizontal(|ui| { ui.add_space(16.0); let w = ui.available_width() - 16.0; - if let Some(s) = crate::widgets::text_field(ui, Id::new("comment_search"), &app.ui.comments.search, w, tr("Search comments")) { + if let Some(s) = crate::widgets::text_field(ui, Id::unique("comment_search"), &app.ui.comments.search, w, tr("Search comments")) { app.ui.comments.search = s; } }); @@ -1178,7 +1178,7 @@ pub fn presenter(app: &mut FigApp, ctx: &egui::Context, frame: NodeId, place: cr let mut clicked = None; for p in &pins { let r = pin_rect(p.at); - egui::Area::new(Id::new(("present_comment_pin", p.id))).order(egui::Order::Foreground).fixed_pos(r.min).show(ctx, |ui| { + egui::Area::new(Id::unique(("present_comment_pin", p.id))).order(egui::Order::Foreground).fixed_pos(r.min).show(ctx, |ui| { let (rr, resp) = ui.allocate_exact_size(r.size(), Sense::click()); paint_pin(ui.painter(), rr, p, open == Some(p.id) || resp.hovered()); if resp.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() { diff --git a/crates/ui-egui/src/component_panel.rs b/crates/ui-egui/src/component_panel.rs index 2b2573c7..06a0385c 100644 --- a/crates/ui-egui/src/component_panel.rs +++ b/crates/ui-egui/src/component_panel.rs @@ -79,7 +79,8 @@ fn combine_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { fn description_ui(app: &mut FigApp, ui: &mut Ui, id: NodeId, description: &str) { widgets::separator(ui); widgets::sublabels(ui, tr("Description"), None); - if let Some(new) = widgets::multiline_field(ui, Id::new(("comp_desc", id)), description, tr("Describe how and when to use this component"), 3) { + if let Some(new) = widgets::multiline_field(ui, Id::unique(("comp_desc", id)), description, tr("Describe how and when to use this component"), 3) + { app.run("node.set", json!({"id": id, "props": {"description": new.chars().take(10_000).collect::()}})); } ui.add_space(8.0); @@ -88,10 +89,10 @@ fn description_ui(app: &mut FigApp, ui: &mut Ui, id: NodeId, description: &str) /// Local components (and component sets, not their variants), for swap menus: (id, label), page /// by page. Listed once per document revision (a file with thousands of layers isn't walked every /// frame). -fn local_components(app: &FigApp, ui: &Ui, doc: &Document) -> std::sync::Arc> { +pub(crate) fn local_components(app: &FigApp, ui: &Ui, doc: &Document) -> std::sync::Arc> { type Cached = ((u64, u64), std::sync::Arc>); let key = app.session.ds().map(|d| (d.uid, d.revision)).unwrap_or_default(); - let id = Id::new("component_panel_local_components"); + let id = Id::unique("component_panel_local_components"); if let Some((k, list)) = ui.data(|d| d.get_temp::(id)) && k == key { @@ -267,7 +268,7 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { let header = row_rect(ui, 32.0, |ui| { let (ir, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover()); icons::paint(ui.painter(), ir, Icon::ComponentSet, COMPONENT); - if let Some(nn) = widgets::text_field(ui, Id::new(("vprop_name", owner, k.as_str())), k, 110.0, tr("Name")) { + if let Some(nn) = widgets::text_field(ui, Id::unique(("vprop_name", owner, k.as_str())), k, 110.0, tr("Name")) { app.run("component.editProperty", json!({"id": owner, "name": k, "newName": nn})); } let def = defaults.get(k).or(vals.first()).cloned().unwrap_or_default(); @@ -292,7 +293,7 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { row(ui, 30.0, |ui| { ui.add_space(22.0); for v in &chunk { - if let Some(nv) = widgets::text_field(ui, Id::new(("vprop_val", owner, k.as_str(), v.as_str())), v, 62.0, "") + if let Some(nv) = widgets::text_field(ui, Id::unique(("vprop_val", owner, k.as_str(), v.as_str())), v, 62.0, "") && nv != *v { app.run("component.editProperty", json!({"id": owner, "name": k, "value": v, "newValue": nv})); @@ -320,7 +321,7 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { row(ui, 32.0, |ui| { let (ir, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover()); icons::paint(ui.painter(), ir, type_icon(d), COMPONENT); - if let Some(nn) = widgets::text_field(ui, Id::new(("prop_name", owner, k.as_str())), k, 96.0, tr("Name")) { + if let Some(nn) = widgets::text_field(ui, Id::unique(("prop_name", owner, k.as_str())), k, 96.0, tr("Name")) { app.run("component.editProperty", json!({"id": owner, "name": k, "newName": nn})); } match d { @@ -330,7 +331,7 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { } } PropDef::Text { default } => { - if let Some(v) = widgets::text_field(ui, Id::new(("prop_def", owner, k.as_str())), default, 84.0, tr("Default")) { + if let Some(v) = widgets::text_field(ui, Id::unique(("prop_def", owner, k.as_str())), default, 84.0, tr("Default")) { app.run("component.editProperty", json!({"id": owner, "name": k, "default": v})); } } @@ -521,6 +522,7 @@ fn group_label(g: &str) -> String { "grids" => tr("Layout grids"), "exports" => tr("Export settings"), "interactions" => tr("Interactions"), + "modes" => tr("Variable modes"), "size" => tr("Size"), "resizing" => tr("Resizing"), "position" => tr("Position"), @@ -700,7 +702,7 @@ fn instance_props(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node, comps PropDef::Text { .. } => { crate::variable_ui::bindable(app, ui, &[id], &bind_field, VariableType::String, w, |app, ui| { let cur = v.as_str().unwrap_or_default().to_string(); - if let Some(ns) = widgets::text_field(ui, Id::new(("iprop", id, k.as_str())), &cur, w, "") + if let Some(ns) = widgets::text_field(ui, Id::unique(("iprop", id, k.as_str())), &cur, w, "") && ns != cur { app.run("instance.setProperties", json!({"ids": [id], "properties": {k.clone(): ns}})); @@ -708,35 +710,9 @@ fn instance_props(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node, comps }); } PropDef::InstanceSwap { preferred, .. } => { - let cur = figcraft_engine::params::id_of(&v); - let label = cur.and_then(|c| doc.node(c)).map(|c| c.name.clone()).unwrap_or_else(|| tr("None").into()); - let resp = widgets::dropdown(ui, &label, w, Some(Icon::Instance)); - menu_popup(&resp).width(200.0).show(|ui| { - let t = Tokens::get(ui.ctx()); - let heading = |ui: &mut Ui, text: &str| ui.label(egui::RichText::new(text).font(semibold(11.0)).color(t.text_secondary)); - let mut pick = |ui: &mut Ui, c: NodeId, name: &str| { - let current = cur == Some(c); - if menu_item(ui, &format!("{}{name}", if current { "✓ " } else { "" }), "") { - if !current { - app.run("instance.setProperties", json!({"ids": [id], "properties": {k.clone(): c.0}})); - } - ui.close(); - } - }; - let preferred: Vec<(NodeId, String)> = preferred.iter().filter_map(|c| doc.node(*c).map(|n| (*c, n.name.clone()))).collect(); - if !preferred.is_empty() { - heading(ui, tr("Preferred")); - for (c, name) in &preferred { - pick(ui, *c, name); - } - heading(ui, tr("All components")); - } - egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { - for (c, name) in comps { - pick(ui, *c, name); - } - }); - }); + if let Some(c) = swap_dropdown(ui, doc, figcraft_engine::params::id_of(&v), &preferred, comps, w, "").1 { + app.run("instance.setProperties", json!({"ids": [id], "properties": {k.clone(): c.0}})); + } } PropDef::Variant { .. } => {} PropDef::Slot { preferred } => { @@ -749,6 +725,54 @@ fn instance_props(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node, comps } } +/// The dropdown of an instance-swap property showing component `cur`: its menu lists the +/// property's preferred components first (when it has any), then every component in the file. The +/// dropdown, and the component picked (picking the current one picks nothing). `name` labels it for +/// assistive technology. +pub(crate) fn swap_dropdown( + ui: &mut Ui, + doc: &Document, + cur: Option, + preferred: &[NodeId], + comps: &[(NodeId, String)], + w: f32, + name: &str, +) -> (egui::Response, Option) { + let label = cur.and_then(|c| doc.node(c)).map(|c| c.name.clone()).unwrap_or_else(|| tr("None").into()); + let resp = widgets::dropdown(ui, &label, w, Some(Icon::Instance)); + if !name.is_empty() { + crate::a11y::combo(&resp, name, &label, crate::a11y::menu_open(&resp)); + } + let mut picked = None; + menu_popup(&resp).width(200.0).show(|ui| { + let t = Tokens::get(ui.ctx()); + let heading = |ui: &mut Ui, text: &str| ui.label(egui::RichText::new(text).font(semibold(11.0)).color(t.text_secondary)); + let mut pick = |ui: &mut Ui, c: NodeId, name: &str| { + let current = cur == Some(c); + if menu_item(ui, &format!("{}{name}", if current { "✓ " } else { "" }), "") { + if !current { + picked = Some(c); + } + ui.close(); + } + }; + let preferred: Vec<(NodeId, String)> = preferred.iter().filter_map(|c| doc.node(*c).map(|n| (*c, n.name.clone()))).collect(); + if !preferred.is_empty() { + heading(ui, tr("Preferred")); + for (c, name) in &preferred { + pick(ui, *c, name); + } + heading(ui, tr("All components")); + } + egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { + for (c, name) in comps { + pick(ui, *c, name); + } + }); + }); + (resp, picked) +} + fn prop_label(ui: &mut Ui, name: &str, icon: Icon) { let t = Tokens::get(ui.ctx()); let (r, _) = ui.allocate_exact_size(vec2(84.0, 20.0), Sense::hover()); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 4daea00b..c21bddc0 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -1,6 +1,7 @@ //! The control channel's UI-thread side. The app crate accepts JSON lines on a localhost socket -//! and queues [`ControlRequest`]s; [`drain`] runs them at the start of a frame so replies reflect -//! the real UI. Methods: +//! and queues [`ControlRequest`]s. [`background`] runs the phase-safe part from every logic +//! callback; [`drain`] admits and answers the UI-only work inside a real frame so replies reflect +//! the drawn UI. Methods: //! //! - `engine.execute {command, params}` — run any command (no dialogs open) //! - `engine.commands {}` — every command with its params doc @@ -16,8 +17,9 @@ //! prototype playing (presenter or preview pane) //! - `ui.menu.invoke {id}` — a menu item exactly like clicking it (may open a dialog) //! - `ui.set {rightTab?, leftTab?, theme?, home?, homeSection?, homeSearch?, homeRecentsView?, homeRecentsSort?, homeRecentsOrder?, palette?, focusRegion?, preview?, presentScroll?, …}` -//! - `ui.screenshot {path?}` — PNG of the window (needs a visible window), written to `path` or -//! returned as base64 `data` +//! - `ui.screenshot {path?}` — PNG of the window, written to `path` or returned as base64 `data`; +//! a hidden window is asked to paint (`RequestPaintWhileHidden`) and the request expires once at +//! its deadline (native; the shared web runner does not implement hidden paint yet) //! - `ui.render {path?, scale?, ids?}` — PNG of the document (no UI chrome; works without a //! window), written to `path` or returned as base64 `data` //! - `window.new {path?, index?}` → `{window}` / `window.list {}` / `window.focus {id}` — several @@ -27,6 +29,9 @@ //! Any method takes `window` (an id from `window.list`) to run in that window, with its tabs and //! panels; without it, requests go to the window last in front. +use std::sync::atomic::AtomicBool; +use std::sync::{Arc, Mutex}; + use egui::Context; use figcraft_engine::cmd::comment::{Filter, Sort}; use figcraft_engine::{PointerEvent, PointerKind}; @@ -54,54 +59,497 @@ impl ControlRequest { } } -/// Pending screenshot replies. -pub struct PendingShot { +/// One screenshot's completion slot. +/// +/// The renderer's one-shot callback only stores the raw image here and wakes the captured +/// viewport; it never encodes or writes a file, and it holds only a [`std::sync::Weak`] reference, so a late +/// capture after the request (or the app) is gone is a no-op. The live request owns the only +/// strong reference, together with the viewport `Context` clone used for the wakeup. +pub struct CaptureSlot { + inner: Mutex, + ctx: Context, + viewport: egui::ViewportId, +} + +#[derive(Default)] +struct SlotInner { + image: Option>, + closed: bool, + /// The one image was already handed to a frame (a second callback cannot store another). + taken: bool, +} + +impl CaptureSlot { + fn new(ctx: &Context, viewport: egui::ViewportId) -> Arc { + Arc::new(Self { inner: Mutex::new(SlotInner::default()), ctx: ctx.clone(), viewport }) + } + + /// The one-shot callback for `ViewportCommand::Screenshot`. + fn callback(slot: &Arc) -> egui::ScreenshotCallback { + let weak = Arc::downgrade(slot); + egui::ScreenshotCallback::new(move |image| { + if let Some(slot) = weak.upgrade() { + slot.complete(image); + } + }) + } + + pub(crate) fn complete(&self, image: Arc) { + { + let mut inner = self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if inner.closed || inner.image.is_some() || inner.taken { + return; + } + inner.image = Some(image); + } + // The lock is released before waking: the wakeup may draw the frame that drains this slot. + self.ctx.request_repaint_of(self.viewport); + } + + /// The captured image, once: a second callback or drain finds the slot taken. + fn take(&self) -> Option> { + let mut inner = self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if inner.taken { + return None; + } + let image = inner.image.take(); + inner.taken = image.is_some(); + image + } + + /// Whether the renderer delivered the image. A hidden window's frame must be asked to paint + /// again to drain it: the capture command's own paint was the one that produced it. + fn has_result(&self) -> bool { + self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner).image.is_some() + } + + /// Forget a cancelled, timed-out or superseded request: a late callback finds it closed. + fn close(&self) { + let mut inner = self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + inner.closed = true; + inner.image = None; + } +} + +/// A screenshot's capture-time geometry (the viewport surface and the scale it was admitted at). +/// Pixels are derived from these dimensions, never from a stale `ViewportInfo::inner_rect` or a +/// previous pass's viewport rectangle. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CaptureSpec { + pub viewport: egui::ViewportId, + /// Expected readback pixels. + pub size_px: [usize; 2], + pub pixels_per_point: f32, + pub zoom: f32, +} + +/// Slack allowed for PNG headers and chunk framing past the raw pixel bytes. +const SHOT_PNG_OVERHEAD: usize = 64 * 1024; + +impl CaptureSpec { + fn bytes(&self) -> Option { + self.size_px[0].checked_mul(self.size_px[1])?.checked_mul(4) + } + + /// The bytes this capture may hold at its peak. + /// + /// `shot_reply` first holds the readback image, the straight-RGBA copy handed to the encoder + /// and the PNG being built (two raw buffers plus the PNG), then drops the copy and builds the + /// base64 reply from the PNG (readback plus PNG plus four thirds of the PNG). The budget is the + /// larger of those phases, never a sum of all four buffers at once. The reservation is a bound, + /// not an estimate of the encoded size; `shot_reply` enforces the reply side of it and must + /// drop the straight-RGBA copy before encoding base64. + fn cost(&self) -> Option { + let raw = self.bytes()?; + let png = raw.checked_add(SHOT_PNG_OVERHEAD)?; + let encode_phase = raw.checked_mul(2)?.checked_add(png)?; + let reply = png.checked_div(3)?.checked_add(1)?.checked_mul(4)?; + let reply_phase = raw.checked_add(png)?.checked_add(reply)?; + Some(encode_phase.max(reply_phase)) + } +} + +/// One admitted screenshot: its deadline, capture identity and completion slot. +pub struct ShotRequest { pub id: Value, /// Where to write the PNG; `None`: return it as data. pub path: Option, pub reply: std::sync::mpsc::Sender, - /// When it was asked for (UI clock, seconds). - pub since: f64, - /// The window it captures. + pub cancelled: Arc, + /// Monotonic seconds ([`crate::FrameClock`]); covers admission, settling, retries and arrival. + pub deadline: f64, pub viewport: egui::ViewportId, + /// The window whose document generation the capture belongs to. + pub window: u64, + /// The document UID shown when the request was admitted. + pub document: u64, + /// That presentation's monotonic generation (the UID alone cannot tell A→B→A apart). + pub presentation: u64, + pub spec: Option, + /// The geometry epoch at issuance; a resize/DPI/zoom change rejects the pixels. + pub epoch: u64, + /// Completed, presented UI frames since admission (hidden logic ticks never count; a + /// discarded sizing pass inside a frame does not count separately from that frame's final + /// presented pass). + pub settle: u8, + /// The last completed-frame number counted into `settle`; `None` until the first observed + /// pass anchors the count. A frame is counted only once it has completed, so its first pass + /// is never treated as settled before the frame has presented a layout. + pub settle_frame: Option, + pub issued: bool, + pub slot: Option>, + /// Image bytes reserved for this request (in flight or completed but undrained). + pub reserved: usize, + pub retries: u8, +} + +impl ShotRequest { + fn release(&mut self) { + if let Some(slot) = self.slot.take() { + slot.close(); + } + self.reserved = 0; + } +} + +/// Screenshot state owned by the app: pending requests and the geometry epoch they were admitted +/// against. Pending and completed-but-undrained images both count toward the budget. +#[derive(Default)] +pub struct CaptureState { + pub(crate) shots: Vec, + /// The last UI geometry of each viewport and the epoch it defines (a resize, DPI change or + /// zoom change — even back — bumps it). Per viewport: one window's frames must not invalidate + /// another's captures. + pub(crate) geometry: std::collections::HashMap, + pub(crate) epoch: u64, } /// How long a screenshot waits for the window to deliver it (the control server gives up then too). -const SHOT_TIMEOUT: f64 = 60.0; +pub(crate) const SHOT_TIMEOUT: f64 = 60.0; -fn pending(ctx: &Context) -> std::sync::Arc>> { - ctx.data_mut(|d| d.get_temp_mut_or_insert_with(egui::Id::new("pending_shots"), || std::sync::Arc::new(std::sync::Mutex::new(Vec::new()))).clone()) +/// Completed real UI frames a capture waits for before it is issued, so fonts, layout and the +/// renderer have settled (hidden logic ticks do not count; a frame counts once, after presenting +/// its final pass). +const SHOT_SETTLE_PASSES: u8 = 3; + +/// The suite's capture budget, counted over in-flight and completed-but-undrained images. +pub(crate) const MAX_SHOTS: usize = 4; +const MAX_SHOT_IMAGE_BYTES: usize = 128 * 1024 * 1024; +const MAX_SHOT_TOTAL_BYTES: usize = 256 * 1024 * 1024; +/// A PNG larger than this is refused before its base64 reply is built (a PNG cannot normally grow +/// past its raw pixels, so only pathological content reaches it). +const MAX_SHOT_REPLY_BYTES: usize = 192 * 1024 * 1024; + +/// The capture geometry of the UI pass drawing now, if the surface size is known. The screenshot +/// is the whole viewport surface (a notch's area is still read back), and pixel bounds are rounded +/// up: a fractional DPI must not turn a valid surface into a one-pixel mismatch. +fn current_spec(ctx: &Context, viewport: egui::ViewportId) -> Option { + let surface = ctx.viewport_rect(); + let rect = if surface.width() >= 1.0 && surface.height() >= 1.0 { surface } else { ctx.content_rect() }; + let ppp = ctx.pixels_per_point(); + let zoom = ctx.zoom_factor(); + if !(rect.width().is_finite() && rect.height().is_finite() && ppp.is_finite() && zoom.is_finite()) { + return None; + } + let w = (rect.width() * ppp).ceil(); + let h = (rect.height() * ppp).ceil(); + if w < 1.0 || h < 1.0 || w > 32768.0 || h > 32768.0 { + return None; + } + Some(CaptureSpec { viewport, size_px: [w as usize, h as usize], pixels_per_point: ppp, zoom }) } -pub fn drain(app: &mut FigApp, ctx: &Context) { - let now = ctx.input(|i| i.time); - let list = pending(ctx); - // Finish screenshots that arrived (a window's capture can arrive in any window's input). - let shots: Vec<(egui::ViewportId, std::sync::Arc)> = ctx.input(|i| { - i.events - .iter() - .filter_map(|e| match e { - egui::Event::Screenshot { viewport_id, image, .. } => Some((*viewport_id, image.clone())), - _ => None, - }) - .collect() - }); - for (vp, img) in &shots { - list.lock().unwrap_or_else(std::sync::PoisonError::into_inner).retain(|p| { - if p.viewport != *vp { - return true; - } - let res = shot_reply(img, p.path.as_deref()); - let _ = p.reply.send(ControlReply { id: p.id.clone(), result: res }); - false - }); +fn reply(r: &mut ShotRequest, result: Result) { + let _ = r.reply.send(ControlReply { id: r.id.clone(), result }); +} + +/// Admit a screenshot request. Geometry is filled in on the next UI pass; until then the request +/// is only counted, so a hidden window's capture can wait for its forced paint without inventing +/// geometry from a stale pass. +fn admit(app: &mut FigApp, r: ControlRequest, window: u64, now: f64) { + let canceled = r.is_cancelled(); + let ControlRequest { id, method: _, params, reply: tx, cancelled } = r; + if app.capture.shots.len() >= MAX_SHOTS { + if !canceled { + let _ = tx.send(ControlReply { id, result: Err(format!("too many screenshots are pending (at most {MAX_SHOTS})")) }); + } + return; } - let reqs = std::mem::take(&mut app.control_queue); - for r in reqs { + let path = params.get("path").and_then(Value::as_str).map(str::to_string); + let (document, presentation) = app.windows.presentation(&app.session, window); + app.capture.shots.push(ShotRequest { + id, + path, + reply: tx, + cancelled, + deadline: now + SHOT_TIMEOUT, + viewport: crate::windows::viewport(window), + window, + document, + presentation, + spec: None, + epoch: app.capture.epoch, + settle: 0, + settle_frame: None, + issued: false, + slot: None, + reserved: 0, + retries: 0, + }); +} + +/// Expire requests whose whole lifetime (admission, settling, retries, arrival) is over, exactly +/// once, and release their image reservation. +fn expire(app: &mut FigApp, ctx: &Context, now: f64) { + let mut i = 0; + while i < app.capture.shots.len() { + let (deadline, cancelled, window) = { + let r = &app.capture.shots[i]; + (r.deadline, r.cancelled.load(std::sync::atomic::Ordering::Relaxed), r.window) + }; + let closed = !app.windows.exists(window); + if now >= deadline || cancelled || closed { + let mut r = app.capture.shots.remove(i); + r.release(); + if !cancelled { + let why = if closed { + "the window closed before the screenshot was captured" + } else { + "the window didn't deliver a screenshot (is it visible?)" + }; + reply(&mut r, Err(why.into())); + } + ctx.request_repaint(); + } else { + i += 1; + } + } +} + +/// Whether the platform reports window `id` as visible; `None` means it has not said (a +/// minimised window can report no `visible` flag at all). +fn visibility(ctx: &Context, id: u64) -> Option { + ctx.input(|i| { + let info = i.raw.viewports.get(&crate::windows::viewport(id))?; + // Some platforms report `visible: true` for a minimised window; `minimized` is the more + // specific signal there. No information at all stays `None` (unknown), never visible. + if info.minimized == Some(true) { + return Some(false); + } + info.visible() + }) +} + +/// Whether window `id` draws no frames now: minimised, or covered by other windows (on macOS also +/// on another Space). +fn hidden(ctx: &Context, id: u64) -> bool { + visibility(ctx, id) == Some(false) +} + +/// Send `RequestPaintWhileHidden` for work that needs a real UI pass (a queued capture or +/// synthetic input), and schedule a wakeup for the next capture deadline. Native eframe honours +/// this command; the shared web runner does not implement it yet, so a hidden browser tab remains +/// an explicit, recorded qualification gap (see `docs/egui-037-local-followups.md`). +fn request_paint(app: &FigApp, ctx: &Context) { + for r in &app.capture.shots { + if !app.windows.exists(r.window) { + continue; + } + // A capture that is still settling needs the real UI pass its geometry comes from; one + // whose pixels arrived while the window was hidden needs the pass that drains the slot. + // The capture command's own paint was drained when it produced the image. + if !r.issued || r.slot.as_ref().is_some_and(|s| s.has_result()) { + ctx.send_viewport_cmd_to(r.viewport, egui::ViewportCommand::RequestPaintWhileHidden); + } + } + for r in &app.control_queue { + if r.method == "ui.input" { + let target = r.params.get("window").and_then(Value::as_u64).unwrap_or(app.windows.focused); + if hidden(ctx, target) { + ctx.send_viewport_cmd_to(crate::windows::viewport(target), egui::ViewportCommand::RequestPaintWhileHidden); + } + } + } + // A batch queued for a frame that has not drawn it yet also keeps asking. During a child + // window's pass `app.control_input` is that window's live pane; from the main window's logic + // it is the root's, so the viewport drawing now is the one to wake. + if !app.control_input.is_empty() { + ctx.send_viewport_cmd_to(ctx.viewport_id(), egui::ViewportCommand::RequestPaintWhileHidden); + } + for viewport in app.windows.pending_input_viewports() { + ctx.send_viewport_cmd_to(viewport, egui::ViewportCommand::RequestPaintWhileHidden); + } + // A batch already handed to a hidden window's raw input (a logic-only pass takes it, egui + // saves it for the next pass) still needs the real pass that applies and acknowledges it. + if let Some(viewport) = app.applied_frame { + ctx.send_viewport_cmd_to(viewport, egui::ViewportCommand::RequestPaintWhileHidden); + } + if let Some(d) = app.capture.shots.iter().map(|r| r.deadline).reduce(f64::min) { + crate::repaint_in(ctx, (d - app.clock.now()).clamp(0.0, SHOT_TIMEOUT)); + } +} + +/// Work a logic-only callback can do safely: expire deadlines, admit or park screenshot requests, +/// and answer state-only control requests for hidden windows. Input/geometry are never read here. +pub fn background(app: &mut FigApp, ctx: &Context) { + let now = app.clock.now(); + expire(app, ctx, now); + for r in std::mem::take(&mut app.control_queue) { + if r.is_cancelled() { + continue; + } + let target = r.params.get("window").and_then(Value::as_u64).unwrap_or(app.windows.focused); + if !app.windows.exists(target) { + let _ = r.reply.send(ControlReply { id: r.id, result: Err(format!("no window {target}")) }); + continue; + } + let is_hidden = hidden(ctx, target); + if r.method == "ui.screenshot" && visibility(ctx, target) != Some(true) { + // Hidden, minimised, or with visibility the platform has not reported: admit it now + // so the request gets its deadline and a forced paint. Waiting for a frame that may + // never draw would leave it queued past the transport's own timeout with no reply. + admit(app, r, target, now); + continue; + } + if !is_hidden && target == app.windows.current { + // The window's frame admits and issues it against real geometry. + app.control_queue.push(r); + continue; + } + if !is_hidden { + // Another visible window draws only when asked. + ctx.request_repaint_of(crate::windows::viewport(target)); + app.control_queue.push(r); + continue; + } + if r.method == "ui.input" { + // Keep one synthetic batch until a real UI pass consumes it. + app.control_queue.push(r); + continue; + } + if r.method == "window.new" { + let error = format!("window {target} is minimised or hidden: `window.new` needs it on screen"); + let _ = r.reply.send(ControlReply { id: r.id, result: Err(error) }); + continue; + } + // Outside a frame, so outside the frame's guard: within the window's swap, a panic still + // ends in recovery copies and leaves every window its own state. + let res = crate::windows::as_window(app, target, |app| { + let mut res = Err("internal error".to_string()); + // Every control-channel gesture is fenced from the user's system clipboard. + let before = app.control_synthetic; + app.control_synthetic = true; + crate::windows::guard(app, ctx, |app| res = handle(app, ctx, &r.method, &r.params)); + app.control_synthetic = before; + res + }); + let _ = r.reply.send(ControlReply { id: r.id, result: res.unwrap_or_else(|| Err(format!("no window {target}"))) }); + } + request_paint(app, ctx); +} + +/// The UI phase's control work: drain completed captures, admit and settle queued screenshots +/// against this pass's geometry, and run state-only requests for the window drawing now. +pub fn drain(app: &mut FigApp, ctx: &Context) { + let now = app.clock.now(); + let viewport = ctx.viewport_id(); + // This pass's geometry defines the epoch: a resize, DPI change or zoom change (back or not) + // invalidates captures issued under the old one. + let spec = current_spec(ctx, viewport); + if let Some(spec) = spec { + let changed = app.capture.geometry.get(&viewport).is_none_or(|(old, _)| *old != spec); + if changed { + app.capture.epoch = app.capture.epoch.wrapping_add(1); + app.capture.geometry.insert(viewport, (spec, app.capture.epoch)); + } + } + let epoch = app.capture.geometry.get(&viewport).map_or(0, |(_, e)| *e); + expire(app, ctx, now); + + // Drain completed captures first, so their budget is free before new requests are admitted. + let mut i = 0; + while i < app.capture.shots.len() { + // Each viewport drains its own captures: their geometry and document differ. + if app.capture.shots[i].viewport != viewport { + i += 1; + continue; + } + let Some(slot) = app.capture.shots[i].slot.clone() else { + i += 1; + continue; + }; + let Some(image) = slot.take() else { + i += 1; + continue; + }; + let mut r = app.capture.shots.remove(i); + r.release(); + let cancelled = r.cancelled.load(std::sync::atomic::Ordering::Relaxed); + let current = current_spec(ctx, viewport); + let (document, presentation) = app.windows.presentation(&app.session, r.window); + if cancelled { + // The transport stopped waiting: no reply, no file. + } else if now >= r.deadline { + reply(&mut r, Err("the window didn't deliver a screenshot in time (is it visible?)".into())); + } else if r.window != app.windows.current || r.document != document || r.presentation != presentation { + reply(&mut r, Err("the document changed while the screenshot was being captured".into())); + } else if current.is_none() { + // Fail closed: with no representable current geometry the pixels cannot be proven to + // belong to the surface that was admitted, so they are never the capture's reply. + reply(&mut r, Err("the window's current geometry is unavailable (is it minimised?)".into())); + } else if let Some(current) = current + && (r.epoch != epoch || r.spec.is_none_or(|s| s.size_px != current.size_px || s.pixels_per_point != current.pixels_per_point)) + { + // Geometry moved between issuance and arrival (even back): retry against the new pass, + // never accept the old pixels. Document changes are errors above. + if r.retries >= 2 { + reply(&mut r, Err("the window kept changing size while the screenshot was captured".into())); + } else { + r.retries += 1; + r.spec = Some(current); + r.epoch = epoch; + r.reserved = r.spec.and_then(|s| s.cost()).unwrap_or(0); + r.settle = 0; + r.settle_frame = None; + r.issued = false; + app.capture.shots.push(r); + } + } else if let (Some(current), Some(spec)) = (current, r.spec) + // The admitted surface is in whole UI points; the readback is in physical pixels. Up + // to one pixel of rounding either way is the same surface, not a scale change. The + // difference is absolute: an undersized readback is a scale mismatch too. + && (image.size[0].abs_diff(spec.size_px[0]) > 1 || image.size[1].abs_diff(spec.size_px[1]) > 1) + { + if r.retries >= 2 { + reply( + &mut r, + Err(format!( + "the screenshot is {}x{} but the window is {}x{} (a scale mismatch)", + image.size[0], image.size[1], spec.size_px[0], spec.size_px[1] + )), + ); + } else { + r.retries += 1; + r.spec = Some(current); + r.epoch = epoch; + r.reserved = r.spec.and_then(|s| s.cost()).unwrap_or(0); + r.settle = 0; + r.settle_frame = None; + r.issued = false; + app.capture.shots.push(r); + } + } else { + let result = shot_reply(&image, r.path.as_deref()); + reply(&mut r, result); + } + ctx.request_repaint(); + } + + // Admit queued captures for the window drawing now; other windows keep waiting for their frame. + for r in std::mem::take(&mut app.control_queue) { if r.is_cancelled() { continue; } - // Requests for another window wait for its frame. let target = r.params.get("window").and_then(Value::as_u64).unwrap_or(app.windows.focused); if target != app.windows.current { if app.windows.exists(target) { @@ -113,97 +561,132 @@ pub fn drain(app: &mut FigApp, ctx: &Context) { continue; } if r.method == "ui.screenshot" { - let path = r.params.get("path").and_then(Value::as_str).map(str::to_string); - let viewport = ctx.viewport_id(); - list.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(PendingShot { id: r.id, path, reply: r.reply, since: now, viewport }); - ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::default())); + admit(app, r, target, now); continue; } + // Every control-channel gesture is fenced from the user's system clipboard (the flag is + // restored so a batch already in a frame's raw input keeps its own provenance). + let before = app.control_synthetic; + app.control_synthetic = true; let res = handle(app, ctx, &r.method, &r.params); - let _ = r.reply.send(ControlReply { id: r.id, result: res }); + app.control_synthetic = before; + if r.method == "ui.input" && std::mem::take(&mut app.control_input_queued) { + // Acknowledge once the pass that applies the batch is presented, never from a sizing + // pass whose input is thrown away. + app.input_acks.push(crate::PendingAck { viewport, tx: r.reply, reply: ControlReply { id: r.id, result: res } }); + } else { + let _ = r.reply.send(ControlReply { id: r.id, result: res }); + } ctx.request_repaint(); } - // The window delivers a screenshot some frames after it was asked for (once the GPU has copied - // it back), and only while frames are drawn: keep drawing until it arrives (a hidden window - // never delivers; its requests expire). - let mut list = list.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - list.retain(|p| { - let live = now - p.since < SHOT_TIMEOUT; - if !live { - let _ = p.reply.send(ControlReply { id: p.id.clone(), result: Err("the window didn't deliver a screenshot (is it visible?)".into()) }); - } - live - }); - if !list.is_empty() { - ctx.request_repaint(); - } -} -/// Methods only a drawn frame can answer: a capture of the window, raw window input and a new -/// window (the main window's frame opens it). Everything else acts on the app's state, which a -/// hidden window answers without a frame (#269, #720). -const NEEDS_FRAME: &[&str] = &["ui.screenshot", "ui.input", "window.new"]; - -/// Whether window `id` draws no frames now: minimised, or covered by other windows (on macOS also -/// on another Space). -fn hidden(ctx: &Context, id: u64) -> bool { - ctx.input(|i| i.raw.viewports.get(&crate::windows::viewport(id)).and_then(egui::ViewportInfo::visible)) == Some(false) -} - -/// A hidden window (minimised, or covered by other windows) draws no frames, but the desktop app's -/// `logic` still runs, for the main window even while every window is hidden: it answers the -/// requests for any hidden window, in that window's file, so an attached agent can edit, inspect, -/// type and save while the app is out of sight. What needs a frame ([`NEEDS_FRAME`]) is an error at -/// once instead of waiting for the window to show. A request for a window on screen waits for its -/// frame; another window on screen is asked to draw, which the main window's frame would do but -/// doesn't while it is hidden. A request for a window that doesn't exist is an error at once. -pub fn drain_hidden(app: &mut FigApp, ctx: &Context) { - for r in std::mem::take(&mut app.control_queue) { - if r.is_cancelled() { - continue; - } - let target = r.params.get("window").and_then(Value::as_u64).unwrap_or(app.windows.focused); - if !app.windows.exists(target) { - let _ = r.reply.send(ControlReply { id: r.id, result: Err(format!("no window {target}")) }); - continue; - } - if !hidden(ctx, target) { - // Another window on screen draws only when asked: while the main window is hidden no - // frame of it does that (`drain`), so ask here. - if target != app.windows.current { - ctx.request_repaint_of(crate::windows::viewport(target)); + // Fill in this pass's geometry, enforce the byte budget (a post-callback check alone would + // not bound peak memory), and release anything over it before issuing. + let mut i = 0; + while i < app.capture.shots.len() { + let total: usize = app.capture.shots.iter().map(|s| s.reserved).sum(); + let mut remove = false; + { + let r = &mut app.capture.shots[i]; + if r.viewport != viewport { + i += 1; + continue; + } + if !r.issued && r.spec != spec { + // A request can resize during its settling frames. Reserve and validate this + // actual surface before issuance, never the old (possibly much smaller) one. + r.spec = spec; + r.settle = 0; + r.settle_frame = None; + if spec.is_none() { + r.reserved = 0; + } + } + if let Some(spec) = r.spec { + let raw = spec.bytes().unwrap_or(usize::MAX); + let cost = spec.cost().unwrap_or(usize::MAX); + if raw > MAX_SHOT_IMAGE_BYTES || total.saturating_sub(r.reserved).saturating_add(cost) > MAX_SHOT_TOTAL_BYTES { + reply(r, Err(format!("the screenshot needs {cost} bytes, over the capture budget"))); + remove = true; + } else if r.reserved != cost { + r.reserved = cost; + } } - app.control_queue.push(r); - continue; } - if NEEDS_FRAME.contains(&r.method.as_str()) { - let error = - format!("window {target} is minimised or hidden: `{}` needs it on screen (`ui.render` draws the document without it)", r.method); - let _ = r.reply.send(ControlReply { id: r.id, result: Err(error) }); - continue; + if remove { + let mut r = app.capture.shots.remove(i); + r.release(); + } else { + i += 1; } - // Outside a frame, so outside the frame's guard: within the window's swap, a panic still - // ends in recovery copies and leaves every window its own state. - let res = crate::windows::as_window(app, target, |app| { - let mut res = Err("internal error".to_string()); - crate::windows::guard(app, ctx, |app| res = handle(app, ctx, &r.method, &r.params)); - res - }); - let _ = r.reply.send(ControlReply { id: r.id, result: res.unwrap_or_else(|| Err(format!("no window {target}"))) }); } + + // Settle on completed UI frames only, then issue once. (The screenshot command itself forces + // the paint that delivers it.) The request's document and window were fixed at admission and + // are never rewritten here: a tab switched during settling is an error, not a capture of the + // new one. + // + // `cumulative_frame_nr` counts frames whose final (presented) pass has finished. A frame whose + // first pass calls `request_discard` is therefore counted once, after it presents its layout, + // not when its discarded sizing pass runs. Passes after the first (`current_pass_index > 0`) + // belong to such a frame and never add another count. + let first_pass = ctx.current_pass_index() == 0; + let frame_nr = ctx.cumulative_frame_nr_for(viewport); + for r in &mut app.capture.shots { + if !first_pass || r.viewport != viewport || r.issued || r.spec.is_none() { + continue; + } + if r.cancelled.load(std::sync::atomic::Ordering::Relaxed) || now >= r.deadline { + continue; + } + match r.settle_frame { + None => { + // Anchor the count at the first pass that sees this request's geometry; this + // frame has not completed yet. + r.settle_frame = Some(frame_nr); + continue; + } + Some(seen) if frame_nr > seen => { + r.settle_frame = Some(frame_nr); + r.settle = r.settle.saturating_add(1); + } + _ => continue, + } + if r.settle < SHOT_SETTLE_PASSES { + continue; + } + let slot = CaptureSlot::new(ctx, viewport); + r.slot = Some(slot.clone()); + r.epoch = epoch; + // Issued against this pass's surface: the size the readback is expected at. + if let Some(spec) = spec { + r.spec = Some(spec); + } + r.issued = true; + ctx.send_viewport_cmd_to(viewport, egui::ViewportCommand::Screenshot(CaptureSlot::callback(&slot))); + } + request_paint(app, ctx); } /// A screenshot's reply: the PNG written to `path` (through the automation roots, when the app /// has them), or returned as base64 `data`. fn shot_reply(img: &egui::ColorImage, path: Option<&str>) -> Result { let [w, h] = img.size; - let mut rgba = Vec::with_capacity(w * h * 4); + let raw = w.checked_mul(h).and_then(|n| n.checked_mul(4)).ok_or("bad screenshot size")?; + if raw > MAX_SHOT_IMAGE_BYTES { + return Err(format!("the screenshot is {raw} bytes, over the capture budget")); + } + let mut rgba = Vec::with_capacity(raw); for p in &img.pixels { rgba.extend_from_slice(&p.to_srgba_unmultiplied()); } let buf = image::RgbaImage::from_raw(w as u32, h as u32, rgba).ok_or("bad screenshot buffer")?; let mut png = std::io::Cursor::new(Vec::new()); buf.write_to(&mut png, image::ImageFormat::Png).map_err(|e| e.to_string())?; + // The encoder copy is not needed once the PNG exists. Drop it before the (possibly bigger) + // base64 reply is built so the peak is the larger phase accounted by `CaptureSpec::cost`, + // not the sum of all four buffers. + drop(buf); let png = png.into_inner(); match path { Some(path) => { @@ -211,6 +694,10 @@ fn shot_reply(img: &egui::ColorImage, path: Option<&str>) -> Result { + if png.len() > MAX_SHOT_REPLY_BYTES { + // Refuse before the base64 string (4/3 of the PNG) is allocated. + return Err(format!("the encoded screenshot is {} bytes, over the reply budget", png.len())); + } use base64::Engine as _; Ok(json!({"format": "png", "width": w, "height": h, "data": base64::engine::general_purpose::STANDARD.encode(&png)})) } @@ -238,11 +725,16 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul "engine.execute" => { let cmd = p.get("command").and_then(Value::as_str).ok_or("`command` required")?; let params = p.get("params").cloned().unwrap_or(json!({})); + let layers_generation = app.session.clipboard.layers_generation; let r = app.session.execute(cmd, ¶ms).map_err(|e| e.to_string()); app.clear_comment_draft_after_tool_set(cmd, r.is_ok()); + app.invalidate_synthetic_copy(layers_generation); // Programmatic calls never open dialogs. app.session.ui_requests.retain(|u| !u.kind.starts_with("dialog.")); app.handle_ui_requests(); + // A programmatic `doc.switch` changes the presented file just like a tab click: the + // capture generation must see it even though this path bypasses `FigApp::run`. + app.observe_presentation(); r } "engine.commands" => Ok(Value::Array( @@ -320,8 +812,9 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul app.session.execute(cmd, ¶ms).map_err(|e| e.to_string()) } _ => { - // Number keys: opacity (two in quick succession make an exact percentage). - let now = ctx.input(|i| i.time); + // Number keys: opacity (two in quick succession make an exact percentage). A + // hidden window's logic tick has a stale `ctx.input(i.time)`. + let now = app.clock.now(); match egui::Key::from_name(name) { Some(k) if m.is_none() && crate::shortcuts::opacity_digit(app, k, now) => Ok(json!({"opacity": true})), _ => Err(format!("no shortcut `{key}`")), @@ -336,7 +829,8 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul .ok_or("`events` required: [{move|down|up: [x, y]} | {key, shift?, alt?, cmd?} | {text} | {leave: true}]")?; let pos = |v: &Value| -> Option { let a = v.as_array()?; - Some(egui::pos2(a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32)) + let p = egui::pos2(a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32); + p.is_finite().then_some(p) }; let mut events = Vec::new(); let mut pads = Vec::new(); @@ -366,6 +860,7 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul // Content moves by `d` points (negative y scrolls down, like a wheel turned towards you). events.push(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Wheel, delta: d.to_vec2(), phase: egui::TouchPhase::Move, modifiers: mods, @@ -396,7 +891,7 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul (None, None) => return Err("controller buttons go to a prototype that is playing: run proto.present or proto.preview".into()), } } - app.control_input.extend(events); + app.control_input_queued = app.push_control_input(events); ctx.request_repaint(); Ok(json!({"queued": list.len().min(256)})) } @@ -763,3 +1258,7 @@ fn pointer(app: &mut FigApp, p: &Value) -> Result { #[cfg(test)] #[path = "control_roots_tests.rs"] mod roots_tests; + +#[cfg(test)] +#[path = "control_capture_tests.rs"] +mod capture_tests; diff --git a/crates/ui-egui/src/control_capture_tests.rs b/crates/ui-egui/src/control_capture_tests.rs new file mode 100644 index 00000000..137b3bf9 --- /dev/null +++ b/crates/ui-egui/src/control_capture_tests.rs @@ -0,0 +1,521 @@ +//! Screenshot capture (the local completion-slot adapter): per-request identity and reverse +//! completion, cancellation/late delivery, budget admission, straight-alpha round-trips and zero +//! retained bytes after draining. Driven headless through the real UI pass, so geometry comes from +//! the frame that issues the capture, not from a stale logic tick. + +use std::sync::Arc; + +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::control::{CaptureSpec, ControlReply, ControlRequest}; +use crate::layers_drag_tests::Ui; + +fn push(u: &mut Ui, id: u64, params: serde_json::Value) -> std::sync::mpsc::Receiver { + let (tx, rx) = std::sync::mpsc::channel(); + u.app.control_queue.push(ControlRequest { id: json!(id), method: "ui.screenshot".into(), params, reply: tx, cancelled: Default::default() }); + rx +} + +/// Run UI passes until every admitted capture has been issued (settled on real UI passes). +fn settle_to_issue(u: &mut Ui, n: usize) { + for _ in 0..12 { + u.frame(vec![]); + if u.app.capture.shots.len() == n && u.app.capture.shots.iter().all(|r| r.issued) { + return; + } + } + panic!("{n} captures were not issued: {} admitted", u.app.capture.shots.len()); +} + +/// A real changed viewport size, without allocating a readback image of that size. +fn resized_frame(u: &mut Ui, size: egui::Vec2) -> egui::FullOutput { + let input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), ..Default::default() }; + let app = &mut u.app; + let mut out = u.ctx.run_ui(input, |ui| app.ui(ui)); + out.textures_delta.clear(); + out +} + +fn has_screenshot(out: &egui::FullOutput) -> bool { + out.viewport_output.values().any(|v| v.commands.iter().any(|c| matches!(c, egui::ViewportCommand::Screenshot(_)))) +} + +#[test] +fn two_captures_correlate_by_request_across_reverse_completion() { + let mut u = Ui::new(Session::empty()); + let first = push(&mut u, 1, json!({})); + let second = push(&mut u, 2, json!({})); + settle_to_issue(&mut u, 2); + let [a, b] = [0, 1].map(|i| u.app.capture.shots[i].spec.expect("geometry")); + assert_eq!(a.viewport, b.viewport); + assert!(a.size_px[0] > 0 && a.size_px[1] > 0); + // The second capture arrives first; only its own request is completed. + let slot_b = u.app.capture.shots[1].slot.clone().expect("issued"); + slot_b.complete(Arc::new(egui::ColorImage::filled(b.size_px, egui::Color32::GREEN))); + u.frame(vec![]); + let got = second.try_recv().expect("the second capture's reply"); + assert_eq!(got.id, json!(2)); + assert!(first.try_recv().is_err(), "the first is still in flight"); + assert_eq!(u.app.capture.shots.len(), 1); + let slot_a = u.app.capture.shots[0].slot.clone().expect("issued"); + slot_a.complete(Arc::new(egui::ColorImage::filled(a.size_px, egui::Color32::RED))); + u.frame(vec![]); + let got = first.try_recv().expect("the first capture's reply"); + assert_eq!(got.id, json!(1)); + assert!(u.app.capture.shots.is_empty(), "no retained requests"); +} + +#[test] +fn a_cancelled_capture_never_replies_or_writes() { + let mut u = Ui::new(Session::empty()); + let rx = push(&mut u, 3, json!({})); + settle_to_issue(&mut u, 1); + let spec = u.app.capture.shots[0].spec.expect("geometry"); + let slot = u.app.capture.shots[0].slot.clone().expect("issued"); + u.app.capture.shots[0].cancelled.store(true, std::sync::atomic::Ordering::Relaxed); + slot.complete(Arc::new(egui::ColorImage::filled(spec.size_px, egui::Color32::BLUE))); + u.frame(vec![]); + assert!(rx.try_recv().is_err(), "a cancelled transport gets no reply"); + assert!(u.app.capture.shots.is_empty(), "the slot is released"); +} + +#[test] +fn a_capture_after_its_deadline_replies_once_and_late_pixels_are_inert() { + let mut u = Ui::new(Session::empty()); + let rx = push(&mut u, 4, json!({})); + settle_to_issue(&mut u, 1); + let spec = u.app.capture.shots[0].spec.expect("geometry"); + let slot = u.app.capture.shots[0].slot.clone().expect("issued"); + u.advance_clock(crate::control::SHOT_TIMEOUT + 1.0); + u.frame(vec![]); + let reply = rx.try_recv().expect("timed out"); + assert_eq!(reply.id, json!(4)); + assert!(reply.result.unwrap_err().contains("didn't deliver"), "one timeout reply"); + assert!(u.app.capture.shots.is_empty(), "released"); + // The renderer finishing afterwards finds a closed slot: no reply, no write, no panic. + slot.complete(Arc::new(egui::ColorImage::filled(spec.size_px, egui::Color32::RED))); + u.frame(vec![]); + assert!(rx.try_recv().is_err(), "a late capture is inert"); +} + +#[test] +fn a_snapshot_batch_is_capped_before_issuing_over_the_count_budget() { + let mut u = Ui::new(Session::empty()); + let mut rxs: Vec<_> = (0..crate::control::MAX_SHOTS as u64).map(|i| push(&mut u, 10 + i, json!({}))).collect(); + settle_to_issue(&mut u, crate::control::MAX_SHOTS); + let over = push(&mut u, 99, json!({})); + u.frame(vec![]); + let reply = over.try_recv().expect("rejected at admission"); + assert!(reply.result.unwrap_err().contains("too many screenshots"), "budget error"); + assert_eq!(u.app.capture.shots.len(), crate::control::MAX_SHOTS); + // Every admitted capture still completes with its own identity. Each drain removes its + // request, so always complete the first remaining one: the vector shifts under the loop. + for (i, rx) in rxs.iter_mut().enumerate() { + let spec = u.app.capture.shots.first().expect("one left").spec.expect("geometry"); + let slot = u.app.capture.shots.first().and_then(|r| r.slot.clone()).expect("issued"); + slot.complete(Arc::new(egui::ColorImage::filled(spec.size_px, egui::Color32::RED))); + u.frame(vec![]); + assert_eq!(rx.try_recv().expect("answered").id, json!(10 + i as u64), "request {i} keeps its identity"); + } + rxs.clear(); +} + +#[test] +fn an_undersized_readback_is_reissued_instead_of_accepted() { + let mut u = Ui::new(Session::empty()); + let rx = push(&mut u, 30, json!({})); + settle_to_issue(&mut u, 1); + let spec = u.app.capture.shots[0].spec.expect("geometry"); + let slot = u.app.capture.shots[0].slot.clone().expect("issued"); + assert!(spec.size_px[0] > 2, "room for a two-pixel undersized readback"); + // Smaller by two pixels on one axis: a scale mismatch, not the admitted surface. + slot.complete(Arc::new(egui::ColorImage::filled([spec.size_px[0] - 2, spec.size_px[1]], egui::Color32::RED))); + u.frame(vec![]); + assert!(rx.try_recv().is_err(), "the undersized pixels are not accepted"); + let r = u.app.capture.shots.first().expect("reissued"); + assert_eq!(r.retries, 1, "one retry, not an error"); + assert!(!r.issued, "waits for the settled geometry again"); +} + +#[test] +fn a_one_pixel_rounding_difference_is_still_the_same_surface() { + let mut u = Ui::new(Session::empty()); + let rx = push(&mut u, 31, json!({})); + settle_to_issue(&mut u, 1); + let spec = u.app.capture.shots[0].spec.expect("geometry"); + let slot = u.app.capture.shots[0].slot.clone().expect("issued"); + slot.complete(Arc::new(egui::ColorImage::filled([spec.size_px[0] - 1, spec.size_px[1] + 1], egui::Color32::RED))); + u.frame(vec![]); + let reply = rx.try_recv().expect("a pixel of rounding is accepted"); + assert_eq!(reply.id, json!(31)); + assert!(reply.result.is_ok()); + assert!(u.app.capture.shots.is_empty(), "released"); +} + +#[test] +fn a_document_a_to_b_to_a_switch_is_not_the_admitted_capture() { + let mut s = Session::empty(); + s.execute("file.new", &json!({"name": "A"})).unwrap(); + s.execute("file.new", &json!({"name": "B"})).unwrap(); + let (a, b) = (s.docs.iter().position(|d| d.doc.name == "A").expect("file A"), s.docs.iter().position(|d| d.doc.name == "B").expect("file B")); + let mut u = Ui::new(s); + u.app.session.active = a; + let rx = push(&mut u, 32, json!({})); + settle_to_issue(&mut u, 1); + let spec = u.app.capture.shots[0].spec.expect("geometry"); + let slot = u.app.capture.shots[0].slot.clone().expect("issued"); + // A→B→A before the callback is drained: the final UID is A again, but its pixels were never + // what this capture asked for. + assert!(u.app.run("doc.switch", json!({"index": b})).is_some()); + assert!(u.app.run("doc.switch", json!({"index": a})).is_some()); + slot.complete(Arc::new(egui::ColorImage::filled(spec.size_px, egui::Color32::RED))); + u.frame(vec![]); + let reply = rx.try_recv().expect("answered"); + assert!(reply.result.unwrap_err().contains("document changed"), "the A→B→A switch invalidates the capture"); + assert!(u.app.capture.shots.is_empty(), "released"); +} + +#[test] +fn a_geometry_change_reissues_instead_of_accepting_old_pixels() { + let mut u = Ui::new(Session::empty()); + let rx = push(&mut u, 5, json!({})); + settle_to_issue(&mut u, 1); + let old = u.app.capture.shots[0].spec.expect("geometry"); + let slot = u.app.capture.shots[0].slot.clone().expect("issued"); + // A resize between issuance and arrival bumps the epoch. + u.app + .capture + .geometry + .insert(egui::ViewportId::ROOT, (CaptureSpec { size_px: [old.size_px[0] + 1, old.size_px[1]], ..old }, u.app.capture.epoch.wrapping_add(1))); + slot.complete(Arc::new(egui::ColorImage::filled(old.size_px, egui::Color32::RED))); + u.frame(vec![]); + assert!(rx.try_recv().is_err(), "the old pixels are not accepted"); + let r = u.app.capture.shots.first().expect("reissued"); + assert_eq!(r.retries, 1, "one retry, not an error"); + assert!(!r.issued, "waits for the settled geometry again"); +} + +#[test] +fn a_translucent_pixel_round_trips_straight_rgba() { + let img = egui::ColorImage::from_rgba_unmultiplied([1, 1], &[128, 64, 32, 128]); + let reply = super::shot_reply(&img, None).expect("encoded"); + use base64::Engine as _; + let png = base64::engine::general_purpose::STANDARD.decode(reply["data"].as_str().expect("data")).expect("base64"); + let decoded = image::load_from_memory(&png).expect("png").to_rgba8(); + assert_eq!(decoded.get_pixel(0, 0).0, [128, 64, 32, 128], "straight alpha is not darkened"); +} + +#[test] +fn a_capture_is_not_issued_before_three_completed_real_ui_frames() { + let mut u = Ui::new(Session::empty()); + let _rx = push(&mut u, 6, json!({})); + u.frame(vec![]); + assert_eq!(u.app.capture.shots.len(), 1, "admitted"); + assert!(!u.app.capture.shots[0].issued, "not on the frame that anchors the count"); + u.frame(vec![]); + assert!(!u.app.capture.shots[0].issued, "not on the second frame"); + u.frame(vec![]); + assert!(!u.app.capture.shots[0].issued, "the third frame has not completed when its pass 0 runs"); + u.frame(vec![]); + assert!(u.app.capture.shots[0].issued, "issued once three frames have presented their layout"); +} + +#[test] +fn a_discarded_sizing_pass_does_not_count_as_a_settled_capture_frame() { + let mut u = Ui::new(Session::empty()); + let _rx = push(&mut u, 33, json!({})); + // Each `frame_raw_with_discard` presents a layout after rerunning a discarded first pass. + // Only that completed frame counts; the discarded pass must not make the capture look settled. + u.frame_raw_with_discard(egui::RawInput::default()); + u.frame_raw_with_discard(egui::RawInput::default()); + u.frame_raw_with_discard(egui::RawInput::default()); + assert!(!u.app.capture.shots[0].issued, "three multipass frames present only two prior layouts here; the third pass 0 is not settled"); + u.frame(vec![]); + assert!(u.app.capture.shots[0].issued, "the next completed frame issues the capture"); +} + +#[test] +fn a_hidden_window_that_delivers_pixels_is_asked_to_paint_the_draining_pass() { + let mut u = Ui::new(Session::empty()); + let rx = push(&mut u, 7, json!({})); + settle_to_issue(&mut u, 1); + let spec = u.app.capture.shots[0].spec.expect("geometry"); + let slot = u.app.capture.shots[0].slot.clone().expect("issued"); + // The renderer completes the readback while the window is hidden; the screenshot command that + // produced it is drained, so without a new forced paint the request would only time out. + slot.complete(Arc::new(egui::ColorImage::filled(spec.size_px, egui::Color32::RED))); + let mut input = egui::RawInput::default(); + input.viewports.insert(egui::ViewportId::ROOT, egui::ViewportInfo { minimized: Some(true), ..Default::default() }); + let ctx = u.ctx.clone(); + let out = ctx.run_logic(&input, |ctx| crate::control::background(&mut u.app, ctx)); + let asked = out + .viewport_commands + .get(&egui::ViewportId::ROOT) + .is_some_and(|cmds| cmds.iter().any(|c| matches!(c, egui::ViewportCommand::RequestPaintWhileHidden))); + assert!(asked, "the completed pixels keep the hidden window painting"); + // The forced paint drains the slot; the deadline is untouched. + u.frame(vec![]); + let reply = rx.try_recv().expect("the completed capture is answered, not timed out"); + assert_eq!(reply.id, json!(7)); + assert!(reply.result.unwrap()["width"].as_u64().is_some(), "written or returned"); + assert!(u.app.capture.shots.is_empty(), "released"); +} + +#[test] +fn a_capture_for_a_window_with_unknown_visibility_is_admitted_with_a_deadline() { + let mut u = Ui::new(Session::empty()); + let rx = push(&mut u, 40, json!({})); + // A minimised window may report no `visible` flag at all. The request must still be parked + // with its deadline rather than left in `control_queue` waiting for a frame that never draws. + let mut input = egui::RawInput::default(); + // Neither `visible` nor `minimized` is reported: the platform has not said whether the + // window draws, so waiting for its frame would leave the request queued indefinitely. + input.viewports.insert(egui::ViewportId::ROOT, egui::ViewportInfo::default()); + let ctx = u.ctx.clone(); + let out = ctx.run_logic(&input, |ctx| crate::control::background(&mut u.app, ctx)); + assert_eq!(u.app.capture.shots.len(), 1, "admitted, not left queued"); + assert!(u.app.control_queue.is_empty(), "the parked request no longer waits in the input queue"); + let r = &u.app.capture.shots[0]; + assert!(!r.issued); + let now = u.app.clock.now(); + assert!((r.deadline - (now + crate::control::SHOT_TIMEOUT)).abs() < 1e-6, "the hidden request has its deadline"); + let asked = out + .viewport_commands + .get(&egui::ViewportId::ROOT) + .is_some_and(|cmds| cmds.iter().any(|c| matches!(c, egui::ViewportCommand::RequestPaintWhileHidden))); + assert!(asked, "the parked capture asks the window to paint"); + // The deadline still answers the transport when no frame draws. + u.advance_clock(crate::control::SHOT_TIMEOUT + 2.0); + u.frame(vec![]); + let reply = rx.try_recv().expect("the parked capture times out"); + assert_eq!(reply.id, json!(40)); + assert!(reply.result.unwrap_err().contains("didn't deliver")); + assert!(u.app.capture.shots.is_empty(), "released"); +} + +#[test] +fn a_completed_capture_is_rejected_when_current_geometry_is_unavailable() { + let mut u = Ui::new(Session::empty()); + let rx = push(&mut u, 41, json!({})); + settle_to_issue(&mut u, 1); + let spec = u.app.capture.shots[0].spec.expect("geometry"); + let slot = u.app.capture.shots[0].slot.clone().expect("issued"); + slot.complete(Arc::new(egui::ColorImage::filled(spec.size_px, egui::Color32::RED))); + // A surface that cannot represent geometry at all (smaller than one point here). The pixels + // may still match the old admitted spec, but they cannot be proven current: fail closed. + u.advance_clock(1.0); + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(0.0, 0.0))), + time: Some(1.0), + ..Default::default() + }; + let app = &mut u.app; + let ctx = u.ctx.clone(); + let mut out = ctx.run_ui(input, |ui| crate::control::drain(app, ui.ctx())); + out.textures_delta.clear(); + let reply = rx.try_recv().expect("answered instead of accepted"); + assert_eq!(reply.id, json!(41)); + assert!(reply.result.unwrap_err().contains("geometry is unavailable"), "fail closed"); + assert!(u.app.capture.shots.is_empty(), "released"); +} + +#[test] +fn a_capture_belongs_to_the_document_admitted_not_the_one_showing_when_it_arrives() { + let mut s = Session::empty(); + s.execute("file.new", &json!({"name": "A"})).unwrap(); + s.execute("file.new", &json!({"name": "B"})).unwrap(); + let (a, b) = (s.docs.iter().position(|d| d.doc.name == "A").expect("file A"), s.docs.iter().position(|d| d.doc.name == "B").expect("file B")); + let mut u = Ui::new(s); + u.app.session.active = a; + let rx = push(&mut u, 8, json!({})); + u.frame(vec![]); + assert_eq!(u.app.capture.shots.len(), 1, "admitted against A"); + // The tab switches while the capture settles: the pixels that arrive are B's, not A's. + u.app.session.active = b; + settle_to_issue(&mut u, 1); + let spec = u.app.capture.shots[0].spec.expect("geometry"); + let slot = u.app.capture.shots[0].slot.clone().expect("issued"); + slot.complete(Arc::new(egui::ColorImage::filled(spec.size_px, egui::Color32::RED))); + u.frame(vec![]); + let reply = rx.try_recv().expect("answered"); + assert!(reply.result.unwrap_err().contains("document changed"), "a tab switch invalidates the capture"); + assert!(u.app.capture.shots.is_empty(), "released"); +} + +#[test] +fn the_capture_budget_counts_the_encoding_peak_not_only_the_pixels() { + let fits = CaptureSpec { viewport: egui::ViewportId::ROOT, size_px: [4096, 4096], pixels_per_point: 1.0, zoom: 1.0 }; + let over = CaptureSpec { viewport: egui::ViewportId::ROOT, size_px: [5000, 5000], pixels_per_point: 1.0, zoom: 1.0 }; + // The encode phase holds the readback and the straight-RGBA copy while the PNG is built; the + // reply phase holds the readback and PNG plus the base64 string after that copy is dropped. + let raw = fits.bytes().expect("pixels"); + let png = raw + super::SHOT_PNG_OVERHEAD; + let encode_phase = raw * 2 + png; + let reply_phase = raw + png + (png / 3 + 1) * 4; + assert_eq!(fits.cost(), Some(encode_phase.max(reply_phase)), "cost covers both live encoding phases"); + // 5000² × 4 is under the per-image pixel budget but its PNG copy and base64 reply are not. + assert!(over.bytes().expect("pixels") <= super::MAX_SHOT_IMAGE_BYTES); + assert!(over.cost().expect("cost") > super::MAX_SHOT_TOTAL_BYTES, "the reply is part of the bound"); + assert!(fits.cost().expect("cost") <= super::MAX_SHOT_TOTAL_BYTES); + // The real resize path enforces that bound, even after admission at a smaller size. + let mut u = Ui::new(Session::empty()); + let rx = push(&mut u, 9, json!({})); + u.frame(vec![]); + assert_eq!(u.app.capture.shots.len(), 1, "admitted"); + let out = resized_frame(&mut u, egui::vec2(5000.0, 5000.0)); + let reply = rx.try_recv().expect("rejected"); + assert!(reply.result.unwrap_err().contains("capture budget"), "the encoding peak is refused"); + assert!(u.app.capture.shots.is_empty(), "nothing retained"); + assert!(!has_screenshot(&out), "over-budget growth is rejected before any readback command"); +} + +#[test] +fn resizing_settling_captures_rechecks_the_aggregate_reserved_bytes() { + let mut u = Ui::new(Session::empty()); + let a = push(&mut u, 81, json!({})); + let b = push(&mut u, 82, json!({})); + u.frame(vec![]); + assert_eq!(u.app.capture.shots.len(), 2, "both fit at the original size"); + let out = resized_frame(&mut u, egui::vec2(3200.0, 3200.0)); + let replies: Vec<_> = [a.try_recv(), b.try_recv()].into_iter().filter_map(Result::ok).collect(); + assert_eq!(replies.len(), 1, "each resized request fits alone, but not both together"); + assert!(replies[0].result.as_ref().unwrap_err().contains("capture budget")); + assert_eq!(u.app.capture.shots.len(), 1); + let remaining = &u.app.capture.shots[0]; + assert_eq!(remaining.spec.unwrap().size_px, [3200, 3200]); + assert_eq!(remaining.reserved, remaining.spec.unwrap().cost().unwrap(), "the survivor reserves its real current cost"); + assert!(!remaining.issued); + assert!(!has_screenshot(&out), "neither request was read back using the old small reservation"); +} + +#[test] +fn an_allowed_resize_restarts_settling_and_issues_with_the_current_reservation() { + let mut u = Ui::new(Session::empty()); + let _rx = push(&mut u, 83, json!({})); + u.frame(vec![]); + u.frame(vec![]); + u.frame(vec![]); + assert_eq!(u.app.capture.shots[0].settle, 2); + let size = egui::vec2(2000.0, 1200.0); + let out = resized_frame(&mut u, size); + assert!(!has_screenshot(&out), "the third old-size frame cannot issue a newly resized surface"); + assert_eq!(u.app.capture.shots[0].settle, 0); + for _ in 0..2 { + assert!(!has_screenshot(&resized_frame(&mut u, size)), "wait for three completed new-size frames"); + } + let out = resized_frame(&mut u, size); + assert!(has_screenshot(&out), "a valid settled capture still issues"); + let request = &u.app.capture.shots[0]; + assert!(request.issued); + assert_eq!(request.spec.unwrap().size_px, [2000, 1200]); + assert_eq!(request.reserved, request.spec.unwrap().cost().unwrap()); +} + +/// Queue an injected-input request (the desktop app's `raw_input_hook` is simulated by +/// `windows::take_input` in these tests). +fn push_input(u: &mut Ui, id: u64) -> std::sync::mpsc::Receiver { + let (tx, rx) = std::sync::mpsc::channel(); + u.app.control_queue.push(ControlRequest { + id: json!(id), + method: "ui.input".into(), + params: json!({"events": [{"move": [10.0, 10.0]}]}), + reply: tx, + cancelled: Default::default(), + }); + rx +} + +#[test] +fn injected_input_is_acknowledged_only_after_the_pass_that_applies_it() { + let mut u = Ui::new(Session::empty()); + let rx = push_input(&mut u, 20); + u.frame(vec![]); + assert!(rx.try_recv().is_err(), "queued, not applied yet"); + let events = crate::windows::take_input(&mut u.app, egui::ViewportId::ROOT); + assert!(!events.is_empty(), "the batch the window draws with"); + assert!(u.app.control_synthetic, "the applying frame is fenced from the system clipboard"); + u.frame(vec![]); + let reply = rx.try_recv().expect("acknowledged by the presented pass"); + assert_eq!(reply.id, json!(20)); + assert!(reply.result.is_ok()); + assert!(!u.app.control_synthetic, "the fence ends with the frame"); +} + +#[test] +fn a_discarded_pass_does_not_acknowledge_or_lose_an_injected_batch() { + let mut u = Ui::new(Session::empty()); + let rx = push_input(&mut u, 21); + u.frame(vec![]); + let events = crate::windows::take_input(&mut u.app, egui::ViewportId::ROOT); + assert!(!events.is_empty()); + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0))), + time: Some(1.0), + ..Default::default() + }; + let app = &mut u.app; + let ctx = u.ctx.clone(); + let mut passes = 0; + let mut out = ctx.run_ui(input, |ui| { + passes += 1; + app.ui(ui); + if passes == 1 { + ui.ctx().request_discard("test sizing pass"); + } + }); + out.textures_delta.clear(); + assert_eq!(passes, 2); + // The batch's side effects already happened; it is never replayed. Its acknowledgement waits + // for the pass whose output is actually presented. + assert_eq!(rx.try_recv().expect("acknowledged by the presented pass").id, json!(21)); + assert!(app.control_input.is_empty(), "the batch is not replayed"); + assert!(app.input_acks.is_empty() && app.applied_acks.is_empty()); +} + +#[test] +fn injected_text_lands_once_across_a_discarded_sizing_pass() { + // egui reruns a discarded pass with the same `RawInput`. The custom editor applied the text in + // the first pass; the rerun must not insert it a second time. + let mut s = Session::new(); + let id = NodeId(s.execute("text.create", &json!({"text": "X", "x": 40, "y": 40})).unwrap()["id"].as_u64().unwrap()); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, id, Some(1), false).unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + u.app.control_queue.push(ControlRequest { + id: json!(22), + method: "ui.input".into(), + params: json!({"events": [{"text": "abc"}]}), + reply: tx, + cancelled: Default::default(), + }); + u.frame(vec![]); + let events = crate::windows::take_input(&mut u.app, egui::ViewportId::ROOT); + assert!(!events.is_empty(), "the applying frame's batch"); + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0))), + time: Some(1.0), + events, + ..Default::default() + }; + let app = &mut u.app; + let ctx = u.ctx.clone(); + let mut passes = 0; + let mut out = ctx.run_ui(input, |ui| { + passes += 1; + app.ui(ui); + if passes == 1 { + ui.ctx().request_discard("a grid-like sizing pass"); + } + }); + out.textures_delta.clear(); + assert_eq!(passes, 2, "egui reran the frame"); + let typed = match &app.session.doc().expect("a file").node(id).expect("the text layer").kind { + figcraft_doc::NodeKind::Text(t) => t.characters.clone(), + _ => panic!("not text"), + }; + assert_eq!(typed, "Xabc", "the discarded pass's text was not applied twice"); + assert_eq!(rx.try_recv().expect("acknowledged once").id, json!(22)); + assert!(app.input_acks.is_empty() && app.applied_acks.is_empty()); +} diff --git a/crates/ui-egui/src/control_roots_tests.rs b/crates/ui-egui/src/control_roots_tests.rs index 35454bb5..763f00e6 100644 --- a/crates/ui-egui/src/control_roots_tests.rs +++ b/crates/ui-egui/src/control_roots_tests.rs @@ -90,3 +90,13 @@ fn window_captures_are_written_through_the_roots_or_returned() { }); assert!(d.0.join("out/shot.png").is_file() && !d.0.join("outside/shot.png").exists()); } + +#[test] +fn ui_input_rejects_non_finite_coordinates() { + let (mut app, _d) = confined_app("input"); + for ev in [json!({"move": [1e300, 5]}), json!({"down": [5, 1e300]}), json!({"scroll": [1e300, 0]})] { + let r = handle(&mut app, "ui.input", json!({"events": [ev]})); + assert!(r.is_err(), "{ev}: {r:?}"); + } + assert!(app.control_input.iter().all(|e| !matches!(e, egui::Event::PointerMoved(p) if !p.is_finite()))); +} diff --git a/crates/ui-egui/src/corner_individual_tests.rs b/crates/ui-egui/src/corner_individual_tests.rs new file mode 100644 index 00000000..7885e265 --- /dev/null +++ b/crates/ui-egui/src/corner_individual_tests.rs @@ -0,0 +1,75 @@ +//! Individual corners in the Design panel, driven headless: the four corner fields sit where +//! their corners are (top left, top right / bottom left, bottom right) and each sets its own. + +use egui::accesskit::Role; +use egui::{Event, Id, Key, Modifiers, Pos2, Rect, pos2}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; + +/// A rectangle rounded only on its left corners (top left and bottom left 30), selected. +fn scene() -> (Ui, NodeId) { + let mut s = Session::new(); + let r = NodeId(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 200, "height": 120})).unwrap()["id"].as_u64().unwrap()); + s.execute("node.set", &json!({"ids": [r], "props": {"topLeftRadius": 30, "bottomLeftRadius": 30}})).unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + (Ui::new(s), r) +} + +/// The rectangle's radii as `[top left, top right, bottom right, bottom left]`. +fn radii(u: &Ui, r: NodeId) -> [f64; 4] { + u.app.session.doc().unwrap().node(r).unwrap().corners.radii +} + +/// A full press-and-release at `p`, then settling one frame. +fn click(u: &mut Ui, p: Pos2) { + u.button(p, true); + u.button(p, false); + u.frame(vec![]); +} + +/// A press-and-release of Enter, committing the focused number field. +fn enter(u: &mut Ui) { + u.frame(vec![ + Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }, + Event::Key { key: Key::Enter, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }, + ]); +} + +/// The middle of the toggle button named `label`, found in the AccessKit tree. +fn named_button(u: &mut Ui, label: &str) -> Pos2 { + u.ctx.enable_accesskit(); + u.frame(vec![]); + let out = u.frame_output(vec![]); + let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes; + let n = nodes.iter().map(|(_, n)| n).find(|n| n.role() == Role::Button && n.label() == Some(label)).expect(label); + let b = n.bounds().expect("bounds"); + pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32) +} + +/// Corner field `k` (an index into the radii). +fn field(u: &Ui, k: usize) -> Rect { + u.ctx.read_response(Id::unique(("corner", k)).with("field")).expect("corner field shows").rect +} + +/// The fields sit at their corners, and typing in the bottom right one sets only that corner in a +/// single undo step (#1002: the bottom row showed bottom right on the left). +#[test] +fn corner_fields_sit_at_their_corners_and_set_their_own() { + let (mut u, r) = scene(); + let t = named_button(&mut u, "Individual corners"); + click(&mut u, t); + let (tl, tr, br, bl) = (field(&u, 0).center(), field(&u, 1).center(), field(&u, 2).center(), field(&u, 3).center()); + assert!(tl.x < tr.x && (tl.y - tr.y).abs() < 1.0, "top row: top left, then top right"); + assert!(bl.x < br.x && (bl.y - br.y).abs() < 1.0, "bottom row: bottom left, then bottom right"); + assert!(bl.y > tl.y && (bl.x - tl.x).abs() < 1.0, "bottom left sits under top left"); + let history = u.app.session.undo_depth().0; + click(&mut u, br); + u.frame(vec![Event::Text("12".into())]); + enter(&mut u); + u.frame(vec![]); + assert_eq!(radii(&u, r), [30.0, 0.0, 12.0, 30.0]); + assert_eq!(u.app.session.undo_depth().0, history + 1); +} diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index 3fc8b7a2..bce5822c 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -256,7 +256,7 @@ pub fn contributors_ui(ui: &mut egui::Ui) { /// [`contributors_ui`] over a given list (tests pass their own). fn contributors_in(ui: &mut egui::Ui, contributors: &[Contributor], total_commits: u64) { let t = Tokens::get(ui.ctx()); - let id = egui::Id::new("credits_view"); + let id = egui::Id::unique("credits_view"); let mut v = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_default(); ui.horizontal_wrapped(|ui| { ui.label(RichText::new(tr("Show")).color(t.text_secondary)); @@ -446,7 +446,7 @@ fn cell(ui: &mut egui::Ui, numeric: bool, text: &str) { fn header_cell(ui: &mut egui::Ui, t: &Tokens, label: &str, numeric: bool, sort: Option>) -> egui::Response { let rect = ui.max_rect(); let resp = ui.allocate_rect(rect, if sort.is_some() { egui::Sense::click() } else { egui::Sense::hover() }); - let kind = if sort.is_some() { egui::WidgetType::Button } else { egui::WidgetType::Label }; + let kind = if sort.is_some() { egui::Role::Button } else { egui::Role::Label }; resp.widget_info(|| egui::WidgetInfo::labeled(kind, true, label)); let resp = if sort.is_some() { resp.on_hover_cursor(egui::CursorIcon::PointingHand) } else { resp }; let current = sort.flatten(); diff --git a/crates/ui-egui/src/credits_tests.rs b/crates/ui-egui/src/credits_tests.rs index eebce841..8b10a622 100644 --- a/crates/ui-egui/src/credits_tests.rs +++ b/crates/ui-egui/src/credits_tests.rs @@ -52,11 +52,11 @@ impl Ui { } fn set_view(&self, v: View) { - self.ctx.data_mut(|d| d.insert_temp(egui::Id::new("credits_view"), v)); + self.ctx.data_mut(|d| d.insert_temp(egui::Id::unique("credits_view"), v)); } fn view(&self) -> View { - self.ctx.data(|d| d.get_temp::(egui::Id::new("credits_view"))).unwrap_or_default() + self.ctx.data(|d| d.get_temp::(egui::Id::unique("credits_view"))).unwrap_or_default() } /// One frame; returns the rect `draw` filled. diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/cursors.rs index 1c4e4ad0..dec2a1b3 100644 --- a/crates/ui-egui/src/cursors.rs +++ b/crates/ui-egui/src/cursors.rs @@ -121,7 +121,7 @@ impl Shown { pub fn show(self, ctx: &egui::Context, at: Pos2) { ctx.set_cursor_icon(self.icon()); if let Shown::Drawn(g) = self { - paint(&ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("canvas_cursor"))), at, g); + paint(&ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("canvas_cursor"))), at, g); } } } diff --git a/crates/ui-egui/src/dev_compare.rs b/crates/ui-egui/src/dev_compare.rs index 0fd57aa7..a8c11354 100644 --- a/crates/ui-egui/src/dev_compare.rs +++ b/crates/ui-egui/src/dev_compare.rs @@ -179,7 +179,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { let mut open = true; let mut action = None; egui::Window::new("Compare changes") - .id(Id::new("dev_compare_window")) + .id(Id::unique("dev_compare_window")) .title_bar(false) .collapsible(false) .resizable(false) @@ -277,7 +277,7 @@ pub(crate) fn header(ui: &mut Ui, t: &Tokens, title: &str, name: &str, mode: &mu for (m, label) in modes.iter().rev() { let g = ui.painter().layout_no_wrap((*label).into(), regular(11.0), t.text); let cell = Rect::from_min_max(pos2(x - g.size().x - 20.0, r.center().y - 12.0), pos2(x, r.center().y + 12.0)); - let resp = ui.interact(cell, Id::new(("compare_mode", label)), Sense::click()); + let resp = ui.interact(cell, Id::unique(("compare_mode", label)), Sense::click()); let on = *mode == *m; crate::a11y::tab(&resp, label, on); if on { @@ -294,7 +294,7 @@ pub(crate) fn header(ui: &mut Ui, t: &Tokens, title: &str, name: &str, mode: &mu } if widgets::icon_button_at( ui, - Id::new("compare_close"), + Id::unique("compare_close"), Rect::from_center_size(pos2(r.max.x - 24.0, r.center().y), vec2(24.0, 24.0)), Icon::Close, tr("Close"), @@ -344,7 +344,7 @@ pub(crate) fn images(ui: &mut Ui, t: &Tokens, r: Rect, shots: [(&Shot, &str); 2] p.text(pos2(img.min.x, label_y), Align2::LEFT_CENTER, before_label, semibold(11.0), t.text_secondary); p.text(pos2(img.max.x, label_y), Align2::RIGHT_CENTER, after_label, semibold(11.0), t.text_secondary); // The slider: a line with a knob, dragged (or clicked) anywhere over the images. - let resp = ui.interact(img, Id::new("compare_split"), Sense::click_and_drag()); + let resp = ui.interact(img, Id::unique("compare_split"), Sense::click_and_drag()); if let Some(pos) = resp.interact_pointer_pos().filter(|_| resp.dragged() || resp.clicked()) { *split = ((pos.x - img.min.x) / img.width().max(1.0)).clamp(0.0, 1.0); } diff --git a/crates/ui-egui/src/dev_mode.rs b/crates/ui-egui/src/dev_mode.rs index 1b21ded2..eef8e6dd 100644 --- a/crates/ui-egui/src/dev_mode.rs +++ b/crates/ui-egui/src/dev_mode.rs @@ -256,7 +256,7 @@ impl Callout { /// Where the template being edited in Connect to code keeps its draft. fn template_id(owner: NodeId, lang: CodeLang) -> Id { - Id::new(("code_connect_template", owner, lang.id())) + Id::unique(("code_connect_template", owner, lang.id())) } /// Layer `id`'s code in `lang`, generated again only when the document or the choice change (a @@ -285,13 +285,14 @@ pub fn code(app: &mut FigApp, ui: &Ui, id: NodeId, lang: CodeLang) -> String { } /// The component playground, for a selected instance, component or component set: a preview of the -/// component and a control for each variant, boolean and text property. The values tried here -/// change the preview and the code only (returned when any is set: the Code section shows it), -/// never the document. +/// component and a control for each variant, boolean, text and instance-swap property. The values +/// tried here change the preview and the code only (returned when any is set: the Code section +/// shows it), never the document. pub fn playground_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId, lang: CodeLang) -> Option { figcraft_engine::playground::component_of(doc, id)?; let t = Tokens::get(ui.ctx()); let Ok((uid, revision)) = app.session.ds().map(|d| (d.uid, d.revision)) else { return None }; + let comps = crate::component_panel::local_components(app, ui, doc); let play = &mut app.ui.dev.playground; if play.target != Some(id) { *play = Playground { target: Some(id), ..Playground::default() }; @@ -354,14 +355,23 @@ pub fn playground_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: Nod "text" => row(ui, 32.0, |ui| { widgets::label(ui, name, t.text_secondary); let cur = value.as_str().unwrap_or(""); - let field = Id::new(("playground_text", id, name)); + let field = Id::unique(("playground_text", id, name)); if let Some(new) = widgets::text_field_named(ui, field, cur, ui.available_width().max(60.0), name) && new != cur { set = Some((name.to_string(), json!(new.chars().take(10_000).collect::()))); } }), - // Instance swaps and slots show as the component has them. + "instanceSwap" => row(ui, 32.0, |ui| { + widgets::label(ui, name, t.text_secondary); + let preferred: Vec = p["preferred"].as_array().into_iter().flatten().filter_map(figcraft_engine::params::id_of).collect(); + let w = ui.available_width().max(60.0); + if let Some(c) = crate::component_panel::swap_dropdown(ui, doc, figcraft_engine::params::id_of(value), &preferred, &comps, w, name).1 + { + set = Some((name.to_string(), json!(c.0))); + } + }), + // Slots show as the component has them. _ => {} } } @@ -426,10 +436,10 @@ pub fn connect_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId let col = widgets::col_width(ui.available_width()); row(ui, 32.0, |ui| { ui.spacing_mut().item_spacing.x = 8.0; - if let Some(v) = widgets::text_field(ui, Id::new(("code_connect_import", owner, lang.id())), &link.import_path, col, tr("Import path")) { + if let Some(v) = widgets::text_field(ui, Id::unique(("code_connect_import", owner, lang.id())), &link.import_path, col, tr("Import path")) { edit = Some(("importPath", v)); } - if let Some(v) = widgets::text_field(ui, Id::new(("code_connect_name", owner, lang.id())), &link.name, col, tr("Component name")) { + if let Some(v) = widgets::text_field(ui, Id::unique(("code_connect_name", owner, lang.id())), &link.name, col, tr("Component name")) { edit = Some(("name", v)); } }); @@ -540,13 +550,13 @@ pub fn annotations_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: No t.text_secondary, ); let del = Rect::from_center_size(pos2(r.max.x - PAD - 4.0, r.center().y), vec2(22.0, 22.0)); - if widgets::icon_button_at(ui, Id::new(("annot_del", id, k)), del, Icon::Minus, tr("Remove annotation")) { + if widgets::icon_button_at(ui, Id::unique(("annot_del", id, k)), del, Icon::Minus, tr("Remove annotation")) { app.run("annotation.remove", json!({"id": id, "index": k})); } - if let Some(cat) = category_menu(ui, doc, Id::new(("annot_cat", id, k)), a) { + if let Some(cat) = category_menu(ui, doc, Id::unique(("annot_cat", id, k)), a) { app.run("annotation.edit", json!({"id": id, "index": k, "category": cat})); } - if let Some(label) = widgets::multiline_field(ui, Id::new(("annot_note", id, k)), &a.label, tr("Add a note for developers"), 2) { + if let Some(label) = widgets::multiline_field(ui, Id::unique(("annot_note", id, k)), &a.label, tr("Add a note for developers"), 2) { if label.trim().is_empty() && a.properties.is_empty() { app.run("annotation.remove", json!({"id": id, "index": k})); } else { @@ -557,7 +567,7 @@ pub fn annotations_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: No for (p, v) in annotations::pinned(doc, id, a) { prop_row(app, ui, &v.unwrap_or_else(|| Prop { label: p.label.to_string(), value: "—".into(), color: None, token: None })); } - if let Some(p) = pin_menu(ui, Id::new(("annot_pin", id, k)), §ions, &a.properties) { + if let Some(p) = pin_menu(ui, Id::unique(("annot_pin", id, k)), §ions, &a.properties) { let mut props = a.properties.clone(); match props.iter().position(|q| q == p.id) { Some(i) => { @@ -575,7 +585,7 @@ pub fn annotations_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: No } // A new annotation starts as a note; properties are pinned to it afterwards. if n.annotations.len() < annotations::MAX_PER_LAYER - && let Some(label) = widgets::multiline_field(ui, Id::new(("annot_new", id)), "", tr("Add an annotation for developers"), 1) + && let Some(label) = widgets::multiline_field(ui, Id::unique(("annot_new", id)), "", tr("Add an annotation for developers"), 1) && !label.trim().is_empty() { app.run("annotation.add", json!({"id": id, "label": label})); @@ -591,7 +601,7 @@ pub fn dev_resources_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: let Some(n) = doc.node(id).filter(|n| !n.is_page()) else { return }; let list = dev_resources::list(doc, id); let can_add = !components::inside_instance(doc, id) && n.dev_resources.len() < dev_resources::MAX_PER_LAYER; - let (adding_id, editing_id) = (Id::new(("devres_adding", id)), Id::new(("devres_editing", id))); + let (adding_id, editing_id) = (Id::unique(("devres_adding", id)), Id::unique(("devres_editing", id))); let mut adding = can_add && ui.data(|d| d.get_temp::(adding_id)).unwrap_or(false); let mut editing = ui.data(|d| d.get_temp::(editing_id)).filter(|i| *i < n.dev_resources.len()); widgets::separator(ui); @@ -607,10 +617,10 @@ pub fn dev_resources_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: let mut edit = None; row(ui, 32.0, |ui| { ui.spacing_mut().item_spacing.x = 8.0; - if let Some(v) = widgets::text_field(ui, Id::new(("devres_name", id, i)), name, col, tr("Name")) { + if let Some(v) = widgets::text_field(ui, Id::unique(("devres_name", id, i)), name, col, tr("Name")) { edit = Some(json!({"id": id, "index": i, "name": v})); } - if let Some(v) = widgets::text_field(ui, Id::new(("devres_url", id, i)), url, col, "https://…") { + if let Some(v) = widgets::text_field(ui, Id::unique(("devres_url", id, i)), url, col, "https://…") { edit = Some(json!({"id": id, "index": i, "url": v})); } if widgets::icon_button(ui, Icon::Check, tr("Done")) { @@ -647,10 +657,10 @@ pub fn dev_resources_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: if let Some(i) = item.index { let del = Rect::from_center_size(pos2(r.max.x - PAD - 4.0, r.center().y), vec2(22.0, 22.0)); let pen = del.translate(vec2(-26.0, 0.0)); - if widgets::icon_button_at(ui, Id::new(("devres_edit", id, i)), pen, Icon::Pencil, tr("Edit dev resource")) { + if widgets::icon_button_at(ui, Id::unique(("devres_edit", id, i)), pen, Icon::Pencil, tr("Edit dev resource")) { editing = Some(i); } - if widgets::icon_button_at(ui, Id::new(("devres_del", id, i)), del, Icon::Minus, tr("Remove dev resource")) { + if widgets::icon_button_at(ui, Id::unique(("devres_del", id, i)), del, Icon::Minus, tr("Remove dev resource")) { app.run("devResource.remove", json!({"id": id, "index": i})); editing = None; } @@ -659,7 +669,7 @@ pub fn dev_resources_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: if adding { let w = ui.available_width() - PAD * 2.0; let mut added = None; - row(ui, 32.0, |ui| added = widgets::text_field(ui, Id::new(("devres_new", id)), "", w, tr("Paste a link (https://…)"))); + row(ui, 32.0, |ui| added = widgets::text_field(ui, Id::unique(("devres_new", id)), "", w, tr("Paste a link (https://…)"))); if let Some(url) = added.filter(|u| !u.trim().is_empty()) && app.run("devResource.add", json!({"id": id, "url": url})).is_some() { @@ -750,7 +760,7 @@ pub fn categories_section(app: &mut FigApp, ui: &mut Ui) { let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::hover()); // The colour: click for the palette. let sw = Rect::from_center_size(pos2(r.min.x + PAD + 8.0, r.center().y), vec2(16.0, 16.0)); - let sw_resp = ui.interact(sw, Id::new(("cat_color", &c.id)), Sense::click()).tip(tr("Colour")); + let sw_resp = ui.interact(sw, Id::unique(("cat_color", &c.id)), Sense::click()).tip(tr("Colour")); crate::a11y::color(&sw_resp, &c.name, &c.color.to_css_hex()); ui.painter().rect_filled(sw, CornerRadius::same(4), doc_c32(ui.ctx(), c.color)); if sw_resp.hovered() { @@ -773,14 +783,14 @@ pub fn categories_section(app: &mut FigApp, ui: &mut Ui) { }); }); let name_r = Rect::from_min_max(pos2(sw.max.x + 8.0, r.min.y + 3.0), pos2(r.max.x - PAD - 28.0, r.max.y - 3.0)); - if let Some(name) = widgets::text_field_inline(ui, Id::new(("cat_name", &c.id)), &c.name, name_r, regular(11.0)) + if let Some(name) = widgets::text_field_inline(ui, Id::unique(("cat_name", &c.id)), &c.name, name_r, regular(11.0)) && name.trim() != c.name && !name.trim().is_empty() { action = Some(("annotation.editCategory", json!({"id": c.id, "name": name.trim()}))); } let del = Rect::from_center_size(pos2(r.max.x - PAD - 8.0, r.center().y), vec2(22.0, 22.0)); - if widgets::icon_button_at(ui, Id::new(("cat_del", &c.id)), del, Icon::Minus, tr("Delete category (its annotations stay)")) { + if widgets::icon_button_at(ui, Id::unique(("cat_del", &c.id)), del, Icon::Minus, tr("Delete category (its annotations stay)")) { action = Some(("annotation.removeCategory", json!({"id": c.id}))); } } @@ -878,7 +888,7 @@ pub fn ready_list(app: &mut FigApp, ui: &mut Ui) { CornerRadius::same(5), t.hover, ); - if widgets::icon_button_at(ui, Id::new(("ready_focus", item.id)), focus_r, Icon::Frame, tr("Open in focus view")) { + if widgets::icon_button_at(ui, Id::unique(("ready_focus", item.id)), focus_r, Icon::Frame, tr("Open in focus view")) { focus = Some(item.id); } } @@ -922,7 +932,7 @@ pub fn focus_bar(app: &mut FigApp, ctx: &egui::Context, canvas: Rect) { // Somewhere to go: another ready frame, or into the list from outside it. let can_step = list.len() > usize::from(at.is_some()); let t = Tokens::get(ctx); - let action = widgets::canvas_banner(ctx, Id::new("dev_focus_bar"), canvas, |ui| { + let action = widgets::canvas_banner(ctx, Id::unique("dev_focus_bar"), canvas, |ui| { let mut out = None; let icon = crate::layers::node_icon(&doc, id); icons::paint(ui.painter(), Rect::from_center_size(ui.cursor().min + vec2(7.0, 12.0), vec2(14.0, 14.0)), icon, t.icon_secondary); diff --git a/crates/ui-egui/src/dev_mode_tests.rs b/crates/ui-egui/src/dev_mode_tests.rs index 941f1b2c..1a8de0e2 100644 --- a/crates/ui-egui/src/dev_mode_tests.rs +++ b/crates/ui-egui/src/dev_mode_tests.rs @@ -1,8 +1,10 @@ //! Dev Mode in the UI, driven headless (the harness is `present_tests`'): the compare view opens //! from `dev.compare {show}`, switches to the overlay and closes with Escape before the focus view -//! does; the focus view's Escape; the annotation filter. +//! does; the focus view's Escape; the annotation filter; the playground's instance-swap menu. -use egui::{Key, pos2}; +use egui::accesskit::{Node, Role}; +use egui::{Event, Key, MouseWheelUnit, Pos2, TouchPhase, pos2, vec2}; +use figcraft_doc::NodeId; use figcraft_engine::Session; use serde_json::json; @@ -80,3 +82,81 @@ fn annotation_filter_and_categories_panel() { assert_eq!(CategoryFilter::parse(&doc, "none"), Some(CategoryFilter::Uncategorized)); assert_eq!(CategoryFilter::parse(&doc, "nope"), None); } + +/// A "Button" component whose nested "Heart" instance is its instance-swap property "Icon", a +/// second icon "Star", and an instance of the button selected in the Inspect tab. Returns (ui, +/// instance, heart, star). +fn swap_setup() -> (crate::layers_drag_tests::Ui, NodeId, NodeId, NodeId) { + let mut s = Session::new(); + let icon = |s: &mut Session, name: &str, x: i32| { + let f = id(&s.execute("frame.create", &json!({"x": x, "y": 0, "width": 24, "height": 24, "name": name})).unwrap()); + id(&s.execute("component.create", &json!({"ids": [f]})).unwrap()) + }; + let (heart, star) = (icon(&mut s, "Heart", 0), icon(&mut s, "Star", 40)); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 100, "width": 120, "height": 40, "name": "Button"})).unwrap()); + let nested = id(&s.execute("instance.create", &json!({"component": heart, "x": 8, "y": 108, "parent": f})).unwrap()); + let button = id(&s.execute("component.create", &json!({"ids": [f]})).unwrap()); + s.execute("component.addProperty", &json!({"id": button, "name": "Icon", "type": "instanceSwap", "default": heart, "layer": nested})).unwrap(); + let inst = id(&s.execute("instance.create", &json!({"component": button, "x": 0, "y": 300})).unwrap()); + s.execute("select.set", &json!({"ids": [inst]})).unwrap(); + let mut u = crate::layers_drag_tests::Ui::new(s); + u.app.ui.right_tab = crate::RightTab::Inspect; + u.ctx.enable_accesskit(); + (u, inst, heart, star) +} + +fn tree(u: &mut crate::layers_drag_tests::Ui) -> Vec { + u.frame_output(vec![]).platform_output.accesskit_update.unwrap().nodes.into_iter().map(|(_, n)| n).collect() +} + +fn centre(n: &Node) -> Pos2 { + let b = n.bounds().unwrap(); + pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32) +} + +fn click(u: &mut crate::layers_drag_tests::Ui, at: Pos2) { + u.button(at, true); + u.button(at, false); +} + +/// The playground's dropdown for property `name`, scrolled into view. +fn playground_combo(u: &mut crate::layers_drag_tests::Ui, name: &str) -> Option { + for _ in 0..20 { + let combo = tree(u).into_iter().find(|n| n.role() == Role::ComboBox && n.label() == Some(name))?; + if centre(&combo).y < 860.0 { + return Some(combo); + } + let wheel = Event::MouseWheel { + unit: MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, + delta: vec2(0.0, -100.0), + phase: TouchPhase::Move, + modifiers: Default::default(), + }; + u.frame(vec![Event::PointerMoved(pos2(1300.0, 500.0)), wheel]); + for _ in 0..30 { + u.frame(vec![]); + } + } + None +} + +#[test] +fn the_playground_swaps_an_instance_in_the_preview_only() { + let (mut u, inst, heart, star) = swap_setup(); + let (doc, undo) = (u.app.session.ds().unwrap().doc.clone(), u.app.session.undo_depth()); + let combo = playground_combo(&mut u, "Icon").expect("the playground has a dropdown for the instance-swap property"); + assert_eq!(combo.value(), Some("Heart")); + // Open it and pick Star: the item that wasn't there before the menu opened. + let before: Vec = tree(&mut u).iter().filter(|n| n.label() == Some("Star")).map(centre).collect(); + click(&mut u, centre(&combo)); + let item = tree(&mut u).into_iter().find(|n| n.label() == Some("Star") && !before.contains(¢re(n))).expect("Star in the menu"); + click(&mut u, centre(&item)); + let combo = playground_combo(&mut u, "Icon").unwrap(); + assert_eq!(combo.value(), Some("Star"), "the playground shows the swapped icon"); + // The file isn't touched: the instance still has the heart, nothing to undo. + assert!(std::sync::Arc::ptr_eq(&doc, &u.app.session.ds().unwrap().doc)); + assert_eq!(u.app.session.undo_depth(), undo); + let props = u.app.session.execute("component.properties", &json!({"id": inst})).unwrap(); + assert_eq!(props["properties"][0]["value"], json!(heart.0), "{star:?}"); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index bf9f1b29..3ec562a2 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -105,7 +105,7 @@ pub(crate) fn tab_strip(ui: &mut egui::Ui, t: &Tokens, tabs let active = *current == tab; let galley = ui.painter().layout_no_wrap(label.into(), semibold(11.0), if active { t.text } else { t.text_secondary }); let (r, resp) = ui.allocate_exact_size(vec2(galley.size().x + 16.0, 24.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, active, label)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, active, label)); if active || resp.hovered() { ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); } @@ -246,7 +246,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) { ui.add_space(8.0); ui.horizontal(|ui| { ui.label(tr("Your name in comments")); - if let Some(name) = crate::widgets::text_field(ui, egui::Id::new("pref_user_name"), &prefs.user_name, 160.0, tr("You")) { + if let Some(name) = crate::widgets::text_field(ui, egui::Id::unique("pref_user_name"), &prefs.user_name, 160.0, tr("You")) { app.run("pref.userName", json!({"name": name})); } }); @@ -256,7 +256,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) { // Where Share ▸ Online keeps files and join codes are asked; no key here // (preferences are saved as text). let name = tr("Server for online sharing"); - if let Some(url) = crate::widgets::text_field_named(ui, egui::Id::new("pref_publish_server"), &prefs.publish_server, 220.0, name) { + if let Some(url) = crate::widgets::text_field_named(ui, egui::Id::unique("pref_publish_server"), &prefs.publish_server, 220.0, name) { app.run("pref.publishServer", json!({"url": url})); } let usual = prefs.publish_server == figcraft_engine::live::DEFAULT_SERVER; @@ -329,7 +329,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) { let mut open = true; let mut action = None; egui::Window::new(tr("Find and replace")) - .id(egui::Id::new("Find and replace")) + .id(egui::Id::unique("Find and replace")) .open(&mut open) .anchor(Align2::LEFT_TOP, vec2(260.0, 120.0)) .resizable(false) @@ -396,7 +396,7 @@ fn menu_input_dialog(app: &mut FigApp, ctx: &Context) { match &mut input { MenuInput::Link(url) => { ui.horizontal(|ui| { - let field = crate::widgets::text_input(ui, egui::Id::new("menu_input_link"), url, 250.0, "https://"); + let field = crate::widgets::text_input(ui, egui::Id::unique("menu_input_link"), url, 250.0, "https://"); let enter = field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); let ok = !url.trim().is_empty(); if (crate::widgets::button(ui, tr("Done"), true, ok).clicked() || enter) && ok { @@ -406,7 +406,7 @@ fn menu_input_dialog(app: &mut FigApp, ctx: &Context) { } MenuInput::Offset(offset) => { ui.horizontal(|ui| { - let id = egui::Id::new("menu_input_offset"); + let id = egui::Id::unique("menu_input_offset"); if let Some(crate::widgets::FieldEdit::Set(v) | crate::widgets::FieldEdit::Scrub(v, _)) = crate::widgets::number_field(ui, id, crate::widgets::Lead::None, Some(*offset), "", 120.0, 1.0) { @@ -498,12 +498,42 @@ fn interface_size(app: &mut FigApp, ui: &mut egui::Ui, chosen: f64) { let resp = crate::widgets::dropdown(ui, &shown, 120.0, None); crate::a11y::combo(&resp, tr("Interface size"), &shown, crate::a11y::menu_open(&resp)); crate::right::menu_popup(&resp).show(|ui| { + let focused = ui.memory(|memory| memory.focused()); + let step = ui.input(|input| { + if input.key_pressed(egui::Key::ArrowDown) || (input.key_pressed(egui::Key::Tab) && !input.modifiers.shift) { + 1 + } else if input.key_pressed(egui::Key::ArrowUp) || (input.key_pressed(egui::Key::Tab) && input.modifiers.shift) { + -1 + } else { + 0 + } + }); + let mut rows = Vec::with_capacity(crate::ui_scale::SIZES.len()); for size in crate::ui_scale::SIZES { - if crate::right::menu_check(ui, (chosen - size).abs() < 1e-6, &percent(size), "") { + let label = percent(size); + let selected = (chosen - size).abs() < 1e-6; + let row = crate::right::menu_row(ui, &format!("{}{label}", if selected { "✓ " } else { " " }), "", 0.0, |_, _, _| {}); + crate::a11y::menu_item(&row, &label, "", Some(selected)); + if row.has_focus() { + ui.memory_mut(|memory| memory.set_focus_lock_filter(row.id, egui::EventFilter { tab: true, ..Default::default() })); + } + rows.push(row.id); + if row.clicked() { app.run("pref.uiScale", json!({"scale": size})); ui.close(); } } + // Spatial focus can jump to a control behind the popup when the menu overlaps the + // Preferences window. Once an item is focused, walk the menu's own rows instead. + if step != 0 + && let Some(index) = rows.iter().position(|id| Some(*id) == focused) + { + let next = index.saturating_add_signed(step).min(rows.len() - 1); + ui.memory_mut(|memory| { + memory.request_focus(rows[next]); + memory.move_focus(egui::FocusDirection::None); + }); + } }); }); crate::widgets::label( @@ -543,7 +573,7 @@ fn batch_rename_dialog(app: &mut FigApp, ctx: &Context) { let mut open = true; let mut cancel = false; let mut apply = false; - window(tr("Rename layers"), ctx).id(egui::Id::new("Rename layers")).open(&mut open).show(ctx, |ui| { + window(tr("Rename layers"), ctx).id(egui::Id::unique("Rename layers")).open(&mut open).show(ctx, |ui| { ui.set_width(320.0); ui.label(RichText::new(trn("{n} layers", n, &[])).color(t.text_secondary)); ui.add_space(6.0); @@ -772,7 +802,7 @@ fn export_dialog(app: &mut FigApp, ctx: &Context) { // The PDF preset's print options. ui.add_space(6.0); caption(ui, tr("PDF print options")); - if let Some(next) = crate::export_panel::print_options(ui, egui::Id::new("export_dialog_print"), &print) { + if let Some(next) = crate::export_panel::print_options(ui, egui::Id::unique("export_dialog_print"), &print) { app.ui.pdf_print = next; } divider(ui); diff --git a/crates/ui-egui/src/export_panel.rs b/crates/ui-egui/src/export_panel.rs index 8617c35a..4562a385 100644 --- a/crates/ui-egui/src/export_panel.rs +++ b/crates/ui-egui/src/export_panel.rs @@ -72,13 +72,13 @@ fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Expo if let Some(size_w) = size_w { let presets: Vec = ExportSize::SCALES.iter().map(|value| ExportSize::Scale { value: *value }.label()).chain(["512w".into(), "512h".into()]).collect(); - if let Some(s) = widgets::combo_field(ui, Id::new(("export_size", i)), &e.size.label(), size_w, &presets) + if let Some(s) = widgets::combo_field(ui, Id::unique(("export_size", i)), &e.size.label(), size_w, &presets) && ExportSize::parse(&s) != Some(e.size) { app.run("export.update", json!({"ids": ids, "index": i, "size": s})); } } - if let Some(sfx) = widgets::text_field(ui, Id::new(("export_suffix", i)), &e.suffix, suffix_w, tr("Suffix")) + if let Some(sfx) = widgets::text_field(ui, Id::unique(("export_suffix", i)), &e.suffix, suffix_w, tr("Suffix")) && sfx != e.suffix { app.run("export.update", json!({"ids": ids, "index": i, "suffix": sfx})); @@ -94,7 +94,7 @@ fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Expo }); let more = icons::button(ui, Icon::Dots, button, false, tr("Export settings")); egui::Popup::from_toggle_button_response(&more) - .id(Id::new(("export_more", i))) + .id(Id::unique(("export_more", i))) .width(216.0) .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) .show(|ui| { @@ -136,7 +136,7 @@ fn advanced_options(ui: &mut Ui, e: &ExportSetting) -> Option { fn print_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &PrintSettings) { let t = Tokens::get(ui.ctx()); row(ui, 26.0, |ui| { - let open_id = Id::new(("export_print", i)); + let open_id = Id::unique(("export_print", i)); let open = egui::Popup::is_id_open(ui.ctx(), open_id); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), FIELD_H), Sense::click()); if resp.hovered() || open { @@ -150,7 +150,7 @@ fn print_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &PrintS .width(216.0) .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) .show(|ui| { - if let Some(next) = print_options(ui, Id::new(("export_print_fields", i)), p) { + if let Some(next) = print_options(ui, Id::unique(("export_print_fields", i)), p) { let mut params = print_params(&next); params["ids"] = json!(ids); params["index"] = json!(i); @@ -258,7 +258,7 @@ fn preview(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId) { } let revision = app.session.ds().map(|d| d.revision).unwrap_or(0); let now = ui.input(|i| i.time); - let cache_id = Id::new("export_preview_tex"); + let cache_id = Id::unique("export_preview_tex"); let cached: Option = ui.data(|d| d.get_temp(cache_id)); let stale = cached.as_ref().is_none_or(|c| c.id != id || c.revision != revision); let due = cached.as_ref().is_none_or(|c| c.id != id || now - c.at >= PREVIEW_REFRESH); diff --git a/crates/ui-egui/src/export_preview_tests.rs b/crates/ui-egui/src/export_preview_tests.rs index d57e294d..8939ebbf 100644 --- a/crates/ui-egui/src/export_preview_tests.rs +++ b/crates/ui-egui/src/export_preview_tests.rs @@ -60,7 +60,7 @@ fn export_rows_offer_a_size_for_images_only() { s.execute("select.set", &json!({"ids": [r]})).unwrap(); s.execute("export.add", &json!({"ids": [r]})).unwrap(); let mut u = crate::layers_drag_tests::Ui::new(s); - let size_menu = |u: &crate::layers_drag_tests::Ui| u.ctx.read_response(egui::Id::new(("export_size", 0)).with("menu")).is_some(); + let size_menu = |u: &crate::layers_drag_tests::Ui| u.ctx.read_response(egui::Id::unique(("export_size", 0)).with("menu")).is_some(); u.frame(vec![]); assert!(size_menu(&u), "PNG has sizes"); for format in ["svg", "pdf"] { diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index fea0be8e..68a6564b 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -370,6 +370,58 @@ fn arithmetic_commits_tab_moves_on_and_escape_restores() { assert_eq!(geom(&u, a).0, 78.0); } +/// Answers a Save as dialog with `path`, once, and counts the dialogs asked for. +struct SaveAsPicker { + path: Option, + asked: std::rc::Rc>, +} + +impl crate::Services for SaveAsPicker { + fn pick_save(&mut self, _title: &str, _name: &str, _exts: &[&str]) -> crate::DialogFuture { + self.asked.set(self.asked.get() + 1); + Box::pin(std::future::ready(self.path.take())) + } +} + +/// The x of layer `a` in the file at `path`, opened again. +fn saved_x(path: &std::path::Path, a: NodeId) -> f64 { + let mut back = Session::new(); + back.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); + back.doc().unwrap().world_bounds(a).unwrap().x0 +} + +#[test] +fn the_save_shortcut_saves_from_a_field_with_what_was_typed() { + // Cmd/Ctrl+S with the keyboard in a panel field saved nothing (#1157). It commits the field + // first (as clicking away does), then saves; the field lets go of the keyboard. + // Shift+Cmd/Ctrl+S does the same through Save as. + let cmd = if cfg!(target_os = "macos") { Modifiers::COMMAND } else { Modifiers::COMMAND.plus(Modifiers::CTRL) }; + let (mut u, a, _) = setup(); + let dir = std::env::temp_dir().join(format!("figcraft-save-from-field-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (path, copy) = (dir.join("doc.figcraft"), dir.join("copy.figcraft")); + u.app.session.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); + let asked = std::rc::Rc::new(std::cell::Cell::new(0)); + u.app.services = Box::new(SaveAsPicker { path: Some(copy.clone()), asked: asked.clone() }); + for (shortcut, mods, x, file) in [("Cmd/Ctrl+S", cmd, 77.0, &path), ("Shift+Cmd/Ctrl+S", cmd.plus(Modifiers::SHIFT), 66.0, ©)] { + let (p, _) = field(&mut u, "X"); + click(&mut u, p); + assert!(crate::a11y::field_focused(&u.ctx), "{shortcut}: the field has the keyboard"); + u.frame(vec![Event::Text(x.to_string())]); + key(&mut u, Key::S, mods); + for _ in 0..3 { + u.frame(vec![]); + } + assert_eq!(geom(&u, a).0, x, "{shortcut}: what was typed is committed"); + assert!(!crate::a11y::field_focused(&u.ctx), "{shortcut}: the field let go"); + assert!(!u.app.session.ds().unwrap().is_dirty(), "{shortcut}: saved"); + assert_eq!(saved_x(file, a), x, "{shortcut}: the file on disk has the typed value"); + } + assert_eq!(asked.get(), 1, "only Save as asked where"); + assert_eq!(saved_x(&path, a), 77.0, "Save as left the first file alone"); + std::fs::remove_dir_all(&dir).ok(); +} + #[test] fn mixed_values_take_edits_relative_to_each_layer() { let (mut u, a, b) = setup(); @@ -420,18 +472,18 @@ fn scrubbing_colour_picker_fields_is_live_and_one_undo_step() { let (mut u, a, _) = setup(); let rgba = |u: &Ui| u.app.session.doc().unwrap().node(a).unwrap().fills[0].solid_color().unwrap().to_rgba8(); let [r0, .., a0] = rgba(&u); - u.ctx.data_mut(|d| d.insert_temp(Id::new("cp_model"), 1usize)); + u.ctx.data_mut(|d| d.insert_temp(Id::unique("cp_model"), 1usize)); u.app.ui.picker = Some(crate::colorpicker::PickerTarget::Paint { fills: true, index: 0, ids: vec![a] }); // The picker window takes a frame to size itself. for _ in 0..3 { u.frame(vec![]); } let steps = undo_steps(&u); - let from = lead(&u, Id::new(("cp_f", "r"))); + let from = lead(&u, Id::unique(("cp_f", "r"))); drag(&mut u, from, -20.0, Modifiers::NONE); assert_eq!((rgba(&u)[0], undo_steps(&u)), (r0 - 20, steps + 1), "R"); // The alpha field: a scrub moves it too. - let from = lead(&u, Id::new("cp_a")); + let from = lead(&u, Id::unique("cp_a")); drag(&mut u, from, -30.0, Modifiers::NONE); let [.., alpha] = rgba(&u); assert!(alpha < a0, "alpha {a0} → {alpha}"); @@ -453,7 +505,7 @@ fn scrubbing_a_prototype_delay_sets_it_once_when_let_go() { u.frame(vec![]); } let steps = undo_steps(&u); - let from = lead(&u, Id::new(("proto_delay", f1, 0usize))); + let from = lead(&u, Id::unique(("proto_delay", f1, 0usize))); drag(&mut u, from, 30.0, Modifiers::NONE); let ms = match u.app.session.doc().unwrap().node(f1).unwrap().interactions[0].trigger { figcraft_doc::proto::Trigger::AfterDelay { ms } => ms, @@ -468,7 +520,7 @@ fn scrubbing_the_scale_panel_width_scales_live_in_one_undo_step() { u.app.run("tool.set", json!({"tool": "scale"})); u.frame(vec![]); let steps = undo_steps(&u); - let from = lead(&u, Id::new("scale_w")); + let from = lead(&u, Id::unique("scale_w")); drag(&mut u, from, 20.0, Modifiers::NONE); let (_, _, w, h) = geom(&u, a); assert_eq!((w, h, undo_steps(&u)), (60.0, 45.0, steps + 1), "40×30 scaled to a width of 60"); @@ -498,14 +550,14 @@ fn the_glass_light_intensity_field_has_a_label() { s.execute("select.set", &json!({"ids": [r]})).unwrap(); s.execute("effect.add", &json!({"type": "glass"})).unwrap(); let mut u = Ui::new(s); - u.ctx.data_mut(|d| d.insert_temp(Id::new(("fx_open", 0usize)), true)); + u.ctx.data_mut(|d| d.insert_temp(Id::unique(("fx_open", 0usize)), true)); u.frame(vec![]); let shapes = u.frame_output(vec![]).shapes; - let lead = u.ctx.read_response(Id::new(("fx_gi", 0usize)).with("lead")).expect("the intensity field is shown").rect; + let lead = u.ctx.read_response(Id::unique(("fx_gi", 0usize)).with("lead")).expect("the intensity field is shown").rect; let labels: Vec<(String, bool)> = painted_texts(&shapes).into_iter().filter(|(_, _, at)| lead.contains(at.center())).map(|(text, cut, _)| (text, cut)).collect(); assert_eq!(labels, [("Intensity".to_string(), false)], "labelled, and not cut short"); - let field = u.ctx.read_response(Id::new(("fx_gi", 0usize)).with("field")).expect("its value").rect; + let field = u.ctx.read_response(Id::unique(("fx_gi", 0usize)).with("field")).expect("its value").rect; assert_eq!(field.max.x - lead.min.x, GLASS_FIELD_W, "the width the next test measures against"); } @@ -538,7 +590,7 @@ fn a_label_too_long_for_its_field_ends_in_an_ellipsis_before_the_value() { let mut field = egui::Rect::NOTHING; let mut out = ctx.run_ui(Default::default(), |ui| { let lead = crate::widgets::Lead::Text("A label much wider than its field"); - let _ = crate::widgets::number_field(ui, Id::new("long"), lead, Some(80.0), "%", 60.0, 1.0); + let _ = crate::widgets::number_field(ui, Id::unique("long"), lead, Some(80.0), "%", 60.0, 1.0); field = ui.min_rect(); }); out.textures_delta.clear(); @@ -571,7 +623,7 @@ fn grid_ui(grid: serde_json::Value) -> (Ui, NodeId) { s.execute("node.set", &json!({"ids": [f], "props": {"grids": [grid]}})).unwrap(); s.execute("select.set", &json!({"ids": [f]})).unwrap(); let mut u = Ui::new(s); - u.ctx.data_mut(|d| d.insert_temp(Id::new(("grid_open", 0usize)), true)); + u.ctx.data_mut(|d| d.insert_temp(Id::unique(("grid_open", 0usize)), true)); u.frame(vec![]); (u, f) } @@ -581,6 +633,7 @@ fn grid_ui(grid: serde_json::Value) -> (Ui, NodeId) { fn scroll_to(u: &mut Ui, id: Id) { let wheel = Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, delta: egui::vec2(0.0, -300.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE, @@ -603,7 +656,7 @@ fn scroll_to(u: &mut Ui, id: Id) { /// placeholder said 0 everywhere, which turned the gap's mode off. #[test] fn typing_auto_is_zero_only_where_zero_is_the_automatic_value() { - let count_id = Id::new(("grid_field", "count", 0usize)); + let count_id = Id::unique(("grid_field", "count", 0usize)); let (mut u, f) = grid_ui(json!({"kind": "columns", "align": "min", "count": 3, "size": 8, "gutter": 10})); let count = |u: &Ui| u.app.session.doc().unwrap().node(f).unwrap().grids[0].count; scroll_to(&mut u, count_id); @@ -613,7 +666,7 @@ fn typing_auto_is_zero_only_where_zero_is_the_automatic_value() { assert_eq!(count(&u), 4, "and a number is that count"); // A stretched grid works its width out from the frame: the field shows Auto and takes no edit. let (mut u, f) = grid_ui(json!({"kind": "columns", "align": "stretch", "count": 3, "size": 0})); - let size_id = Id::new(("grid_field", "size", 0usize)); + let size_id = Id::unique(("grid_field", "size", 0usize)); scroll_to(&mut u, size_id); type_at(&mut u, size_id, "40"); assert_eq!(u.app.session.doc().unwrap().node(f).unwrap().grids[0].size, 0.0, "a stretched grid's width isn't set"); @@ -631,6 +684,336 @@ fn typing_auto_is_zero_only_where_zero_is_the_automatic_value() { u.frame(vec![]); let gap = |u: &Ui| u.app.session.doc().unwrap().node(f).unwrap().layout.as_ref().unwrap().gap; let before = gap(&u); - type_at(&mut u, Id::new("al_gap"), "Auto"); + type_at(&mut u, Id::unique("al_gap"), "Auto"); assert_eq!(gap(&u), before, "the word isn't a value where Auto is a mode"); } + +/// Save starts after the field commits, even if the next raw input releases a tab click first. +#[test] +fn a_deferred_field_save_keeps_its_document_when_the_next_input_switches_tabs() { + for (save_as, same_batch, discard) in + [(false, false, false), (true, false, false), (false, true, false), (true, true, false), (false, true, true), (true, true, true)] + { + let (mut u, a, _) = setup(); + let dir = std::env::temp_dir().join(format!("figcraft-deferred-field-tab-{}-{save_as}-{same_batch}-{discard}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (path, copy, other) = (dir.join("a.figcraft"), dir.join("copy.figcraft"), dir.join("b.figcraft")); + u.app.session.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); + let owner = u.app.session.ds().unwrap().uid; + u.app.session.execute("file.new", &json!({"name": "Deferred B"})).unwrap(); + u.app.session.execute("shape.rectangle", &json!({"x": 200, "y": 0, "width": 30, "height": 30})).unwrap(); + u.app.session.execute("file.saveAs", &json!({"path": other.to_string_lossy()})).unwrap(); + u.app.session.execute("shape.rectangle", &json!({"x": 300, "y": 0, "width": 10, "height": 10})).unwrap(); + let other_uid = u.app.session.ds().unwrap().uid; + let other_name = u.app.session.doc().unwrap().name.clone(); + let other_bytes = std::fs::read(&other).unwrap(); + let index = u.app.session.docs.iter().position(|d| d.uid == owner).unwrap(); + u.app.session.execute("doc.switch", &json!({"index": index})).unwrap(); + let asked = std::rc::Rc::new(std::cell::Cell::new(0)); + u.app.services = Box::new(SaveAsPicker { path: Some(copy.clone()), asked: asked.clone() }); + u.frame(vec![]); + let output = u.frame_output(vec![]); + let nodes = output.platform_output.accesskit_update.unwrap().nodes; + let tab = nodes + .iter() + .map(|(_, node)| node) + .find(|node| node.role() == Role::Tab && node.label() == Some(other_name.as_str())) + .expect("the actual B tab"); + let bounds = tab.bounds().unwrap(); + let at = pos2(((bounds.x0 + bounds.x1) / 2.0) as f32, ((bounds.y0 + bounds.y1) / 2.0) as f32); + let (field_at, _) = field(&mut u, "X"); + click(&mut u, field_at); + assert!(crate::a11y::field_focused(&u.ctx)); + assert!(u.ctx.text_edit_focused(), "the actual field editor owns raw text"); + assert_eq!(u.ctx.memory(|m| m.focused()), Some(Id::unique("pos_x").with("te")), "X owns the keyboard"); + if !same_batch { + u.frame(vec![Event::Text("77".into())]); + assert_eq!(u.ctx.data(|d| d.get_temp::(Id::unique("pos_x").with("editing"))), Some("77".into()), "raw text reached X's draft"); + } + assert_eq!(geom(&u, a).0, 10.0, "the field has not committed before the shortcut"); + let mut modifiers = if cfg!(target_os = "macos") { Modifiers::COMMAND } else { Modifiers::COMMAND.plus(Modifiers::CTRL) }; + if save_as { + modifiers = modifiers.plus(Modifiers::SHIFT); + } + let button = |pressed| Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + // Pressing the tab relinquishes the real field; its release switches documents in the + // next frame, before shortcuts::handle consumes the deferred request. + let mut events = vec![ + Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: true, repeat: false, modifiers }, + Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: false, repeat: false, modifiers }, + Event::PointerMoved(at), + button(true), + ]; + if same_batch { + events.insert(0, Event::Text("77".into())); + } + if discard { + events.insert(0, Event::ModifiersChanged(modifiers)); + u.frame_raw_with_discard(egui::RawInput { events, ..Default::default() }); + } else { + u.frame_with(events, modifiers); + } + assert!(u.app.ui.after_field.is_some(), "the real shortcut deferred a save"); + if !discard { + assert!( + !u.ctx.input(|i| i.events.iter().any(|e| matches!(e, Event::Key { key: Key::S, pressed: true, .. }))), + "the save press was consumed once" + ); + } + // A discarded presented pass sees the original RawInput but does not dispatch it again; + // pending/picker/write assertions below also prove that pass did not duplicate the save. + assert_eq!(u.app.session.ds().unwrap().uid, owner, "press alone did not switch tabs"); + assert_eq!(geom(&u, a).0, 77.0, "the original field committed before delivery"); + u.frame(vec![button(false)]); + assert_eq!(u.app.session.ds().unwrap().uid, other_uid, "the real tab release switched to B"); + for _ in 0..3 { + u.frame(vec![]); + } + assert_eq!(u.app.session.ds().unwrap().uid, other_uid, "saving A restores the front tab B"); + assert!(u.app.session.ds().unwrap().is_dirty(), "B was not saved"); + assert_eq!(std::fs::read(&other).unwrap(), other_bytes, "B's file was not overwritten"); + assert_eq!(asked.get(), usize::from(save_as), "only the original Save as asks for a path"); + assert_eq!(saved_x(if save_as { © } else { &path }, a), 77.0, "A's typed field is saved"); + assert_eq!(saved_x(&path, a), if save_as { 10.0 } else { 77.0 }); + assert!(u.app.ui.after_field.is_none()); + std::fs::remove_dir_all(&dir).ok(); + } +} + +#[test] +fn a_deferred_field_save_does_not_retarget_a_closed_moved_or_foreign_owner() { + for change in ["closed", "moved", "foreign"] { + for save_as in [false, true] { + let (mut u, a, _) = setup(); + u.ctx.set_embed_viewports(false); + let dir = std::env::temp_dir().join(format!("figcraft-deferred-field-invalid-{}-{change}-{save_as}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (path, other, copy) = (dir.join("a.figcraft"), dir.join("b.figcraft"), dir.join("copy.figcraft")); + u.app.session.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); + let owner = u.app.session.ds().unwrap().uid; + u.app.session.execute("file.new", &json!({"name": "Deferred B"})).unwrap(); + u.app.session.execute("file.saveAs", &json!({"path": other.to_string_lossy()})).unwrap(); + u.app.session.execute("shape.rectangle", &json!({"x": 200, "y": 0, "width": 10, "height": 10})).unwrap(); + let other_uid = u.app.session.ds().unwrap().uid; + let other_bytes = std::fs::read(&other).unwrap(); + let index = u.app.session.docs.iter().position(|d| d.uid == owner).unwrap(); + u.app.session.execute("doc.switch", &json!({"index": index})).unwrap(); + let asked = std::rc::Rc::new(std::cell::Cell::new(0)); + u.app.services = Box::new(SaveAsPicker { path: Some(copy.clone()), asked: asked.clone() }); + let (at, _) = field(&mut u, "X"); + click(&mut u, at); + assert!(crate::a11y::field_focused(&u.ctx)); + assert!(u.ctx.text_edit_focused(), "the actual field editor owns raw text"); + assert_eq!(u.ctx.memory(|m| m.focused()), Some(Id::unique("pos_x").with("te")), "X owns the keyboard"); + u.frame(vec![Event::Text("77".into())]); + assert_eq!(u.ctx.data(|d| d.get_temp::(Id::unique("pos_x").with("editing"))), Some("77".into()), "raw text reached X's draft"); + assert_eq!(geom(&u, a).0, 10.0, "typed field text is not committed before Save"); + let mut modifiers = if cfg!(target_os = "macos") { Modifiers::COMMAND } else { Modifiers::COMMAND.plus(Modifiers::CTRL) }; + if save_as { + modifiers = modifiers.plus(Modifiers::SHIFT); + } + u.frame_with( + vec![ + Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: true, repeat: false, modifiers }, + Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: false, repeat: false, modifiers }, + ], + modifiers, + ); + assert!(u.app.ui.after_field.is_some()); + assert_eq!(geom(&u, a).0, 77.0, "field commit is still before the save"); + let owner_index = u.app.session.docs.iter().position(|d| d.uid == owner).unwrap(); + if change == "closed" { + u.app.session.execute("file.close", &json!({"index": owner_index, "force": true})).unwrap(); + } else if change == "moved" { + let window = crate::windows::open(&mut u.app, &u.ctx, Some(owner_index), None, None).unwrap(); + assert_ne!(window, 0); + assert_eq!(u.app.windows.of(owner), window); + } else { + // An impossible/misrouted pane must fail closed too; no real window has this id. + u.app.windows.current = u64::MAX; + } + let other_index = u.app.session.docs.iter().position(|d| d.uid == other_uid).unwrap(); + u.app.session.execute("doc.switch", &json!({"index": other_index})).unwrap(); + if change == "foreign" { + // Exercise the actual consumer without begin() resetting this deliberate invalid + // scope; the valid input/field producer above was the real app path. + // Match this direct pass and the harness clock; egui's default predicted_dt is f32. + let time = u.ctx.input(|i| i.time) + 1.0 / 60.0; + u.advance_clock(time); + u.ctx + .run_ui(egui::RawInput { time: Some(time), ..Default::default() }, |ui| { + crate::shortcuts::handle(&mut u.app, ui.ctx(), false, Default::default()) + }) + .textures_delta + .clear(); + u.app.windows.current = 0; + } else { + u.frame(vec![]); + } + for _ in 0..2 { + u.frame(vec![]); + } + assert_eq!(u.app.session.ds().unwrap().uid, other_uid); + assert!(u.app.session.ds().unwrap().is_dirty(), "stale delivery must not save B"); + assert_eq!(std::fs::read(&other).unwrap(), other_bytes); + assert_eq!(saved_x(&path, a), 10.0, "a cancelled owner is not saved"); + assert_eq!(asked.get(), 0, "a stale Save as must not open for B"); + assert!(!copy.exists()); + assert!(u.app.ui.after_field.is_none(), "consume stale requests once"); + std::fs::remove_dir_all(&dir).ok(); + } + } +} + +/// A save prepared for A must not force its cached X editor into B when routing changes the +/// presented document before the fields draw, even though both panels use the same widget ID. +#[test] +fn a_prepared_field_save_cannot_commit_into_a_same_pass_replacement_document() { + for route in ["control", "tab"] { + for save_as in [false, true] { + for discard in [false, true] { + let (mut u, a, _) = setup(); + let dir = std::env::temp_dir().join(format!("figcraft-field-save-owner-{}-{route}-{save_as}-{discard}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (path, other, copy) = (dir.join("a.figcraft"), dir.join("b.figcraft"), dir.join("copy.figcraft")); + u.app.session.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); + let owner = u.app.session.ds().unwrap().uid; + let owner_bytes = std::fs::read(&path).unwrap(); + u.app.session.execute("file.new", &json!({"name": "Same-pass B"})).unwrap(); + let b = id(u.app.session.execute("shape.rectangle", &json!({"x": 200, "y": 40, "width": 30, "height": 30})).unwrap()); + u.app.session.execute("file.saveAs", &json!({"path": other.to_string_lossy()})).unwrap(); + u.app.session.execute("shape.rectangle", &json!({"x": 300, "y": 0, "width": 10, "height": 10})).unwrap(); + u.app.session.execute("select.set", &json!({"ids": [b]})).unwrap(); + let other_index = u.app.session.active; + let other_uid = u.app.session.ds().unwrap().uid; + let other_name = u.app.session.doc().unwrap().name.clone(); + let other_model = u.app.session.ds().unwrap().doc.clone(); + let before_switch_revision = u.app.session.ds().unwrap().revision; + let other_history = u.app.session.undo_depth(); + let other_selection = u.app.session.ds().unwrap().selection.clone(); + let other_dirty = u.app.session.ds().unwrap().is_dirty(); + let other_bytes = std::fs::read(&other).unwrap(); + assert!(other_dirty, "B has independent unsaved content"); + let owner_index = u.app.session.docs.iter().position(|d| d.uid == owner).unwrap(); + u.app.session.execute("doc.switch", &json!({"index": owner_index})).unwrap(); + // Every command bumps the outgoing document's revision, even a nonediting switch. + let other_revision = u.app.session.docs.iter().find(|d| d.uid == other_uid).unwrap().revision; + assert_eq!(other_revision, before_switch_revision + 1, "the setup switch advances only B's command revision"); + let asked = std::rc::Rc::new(std::cell::Cell::new(0)); + u.app.services = Box::new(SaveAsPicker { path: Some(copy.clone()), asked: asked.clone() }); + let output = u.frame_output(vec![]); + let nodes = output.platform_output.accesskit_update.unwrap().nodes; + let tab = nodes + .iter() + .map(|(_, node)| node) + .find(|node| node.role() == Role::Tab && node.label() == Some(other_name.as_str())) + .expect("the actual B tab"); + let bounds = tab.bounds().unwrap(); + let tab_at = pos2(((bounds.x0 + bounds.x1) / 2.0) as f32, ((bounds.y0 + bounds.y1) / 2.0) as f32); + let (at, _) = field(&mut u, "X"); + click(&mut u, at); + assert_eq!(u.ctx.memory(|m| m.focused()), Some(Id::unique("pos_x").with("te"))); + assert!(u.ctx.text_edit_focused()); + u.frame(vec![Event::Text("77".into())]); + assert_eq!(u.ctx.data(|d| d.get_temp::(Id::unique("pos_x").with("editing"))), Some("77".into())); + assert_eq!(geom(&u, a).0, 10.0, "A's raw draft is not committed yet"); + assert_eq!(u.app.session.ds().unwrap().uid, owner, "the save begins with A presented"); + let reply = if route == "control" { + let (reply, rx) = std::sync::mpsc::channel(); + u.app.control_queue.push(crate::ControlRequest { + id: json!("same-pass-switch"), + method: "engine.execute".into(), + params: json!({"command": "doc.switch", "params": {"index": other_index}, "window": 0}), + reply, + cancelled: Default::default(), + }); + Some(rx) + } else { + None + }; + let mut modifiers = if cfg!(target_os = "macos") { Modifiers::COMMAND } else { Modifiers::COMMAND.plus(Modifiers::CTRL) }; + if save_as { + modifiers = modifiers.plus(Modifiers::SHIFT); + } + let mut events = vec![ + Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: true, repeat: false, modifiers }, + Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: false, repeat: false, modifiers }, + ]; + if route == "tab" { + events.push(Event::PointerMoved(tab_at)); + for pressed in [true, false] { + events.push(Event::PointerButton { + pos: tab_at, + button: egui::PointerButton::Primary, + pressed, + modifiers: Default::default(), + }); + } + } + if discard { + events.insert(0, Event::ModifiersChanged(modifiers)); + u.frame_raw_with_discard(egui::RawInput { events, ..Default::default() }); + } else { + u.frame_with(events, modifiers); + } + if let Some(reply) = reply { + let reply = reply.try_recv().expect("real control::drain acknowledged doc.switch in this pass"); + assert!(reply.result.is_ok(), "{:?}", reply.result); + } + let unchanged = |u: &Ui| { + assert_eq!(u.app.session.ds().unwrap().uid, other_uid, "the real same-pass route presents B"); + assert_eq!( + geom(u, b), + (200.0, 40.0, 30.0, 30.0), + "A's draft cannot enter B's field: route={route}, save_as={save_as}, discard={discard}" + ); + assert!(std::sync::Arc::ptr_eq(&u.app.session.ds().unwrap().doc, &other_model), "B's model is untouched"); + assert_eq!(u.app.session.ds().unwrap().revision, other_revision); + assert_eq!(u.app.session.undo_depth(), other_history, "B's undo/redo history is unchanged"); + assert_eq!(u.app.session.ds().unwrap().selection, other_selection); + assert_eq!(u.app.session.ds().unwrap().is_dirty(), other_dirty); + assert_eq!(std::fs::read(&other).unwrap(), other_bytes); + assert_eq!(std::fs::read(&path).unwrap(), owner_bytes); + assert_eq!(u.app.session.docs.iter().find(|d| d.uid == owner).unwrap().doc.world_bounds(a).unwrap().x0, 10.0); + assert_eq!(asked.get(), 0, "the cancelled request never asks for a path"); + assert!(!copy.exists()); + assert!(u.app.ui.after_field.is_none(), "cancel before late fallback or queueing"); + }; + unchanged(&u); + for _ in 0..3 { + u.frame(vec![]); + unchanged(&u); + } + // A cancelled draft is not a permanent veto: B accepts a later real field edit + // and saves its own value through the same focused-field shortcut path. + let (at, _) = field(&mut u, "X"); + click(&mut u, at); + assert_eq!(u.ctx.memory(|m| m.focused()), Some(Id::unique("pos_x").with("te"))); + u.frame(vec![Event::Text("88".into())]); + assert_eq!(geom(&u, b).0, 200.0, "fresh B text still waits for a normal commit"); + assert_eq!(u.ctx.data(|d| d.get_temp::(Id::unique("pos_x").with("editing"))), Some("88".into())); + u.frame_with( + vec![ + Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: true, repeat: false, modifiers }, + Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: false, repeat: false, modifiers }, + ], + modifiers, + ); + assert_eq!(geom(&u, b).0, 88.0, "a fresh B field commits normally"); + assert!(u.app.ui.after_field.is_some()); + for _ in 0..3 { + u.frame(vec![]); + } + assert_eq!(u.app.session.ds().unwrap().uid, other_uid); + assert_eq!(saved_x(if save_as { © } else { &other }, b), 88.0, "a later valid save belongs to B"); + if save_as { + assert_eq!(std::fs::read(&other).unwrap(), other_bytes); + } + assert_eq!(std::fs::read(&path).unwrap(), owner_bytes, "A stays unsaved"); + assert_eq!(asked.get(), usize::from(save_as)); + assert!(u.app.ui.after_field.is_none()); + std::fs::remove_dir_all(&dir).ok(); + } + } + } +} diff --git a/crates/ui-egui/src/font_preview.rs b/crates/ui-egui/src/font_preview.rs index a16177dc..ad05645d 100644 --- a/crates/ui-egui/src/font_preview.rs +++ b/crates/ui-egui/src/font_preview.rs @@ -32,7 +32,7 @@ fn key(style: &TextStyle, text: &str, ppp: f32) -> u64 { pub fn texture(ctx: &egui::Context, style: &TextStyle, text: &str, budget: &mut usize) -> Option { let ppp = ctx.pixels_per_point(); let k = key(style, text, ppp); - let cache = ctx.data_mut(|d| d.get_temp_mut_or_default::(Id::new("font_previews")).clone()); + let cache = ctx.data_mut(|d| d.get_temp_mut_or_default::(Id::unique("font_previews")).clone()); if let Some(t) = cache.0.lock().ok()?.get(&k) { return t.clone(); } diff --git a/crates/ui-egui/src/frame_labels.rs b/crates/ui-egui/src/frame_labels.rs index 9e8b2e7a..4c4b0de1 100644 --- a/crates/ui-egui/src/frame_labels.rs +++ b/crates/ui-egui/src/frame_labels.rs @@ -62,6 +62,9 @@ fn turn(shape: &mut Shape, rot: Rot2, angle: f32, pivot: Pos2) { let at = |p: &mut Pos2| *p = pivot + rot * (*p - pivot); match shape { Shape::Noop | Shape::Callback(_) => {} + // Bands are not drawn by this UI; if one ever arrives, rotate it via its own `angle` + // rather than approximating the samples as points. + Shape::Band(_) => {} Shape::Vec(v) => v.iter_mut().for_each(|s| turn(s, rot, angle, pivot)), Shape::Circle(c) => at(&mut c.center), Shape::Ellipse(e) => { @@ -166,7 +169,7 @@ pub fn rename_field(app: &mut FigApp, ui: &mut egui::Ui) { let font = regular(11.0); let text_w = ui.painter().layout_no_wrap(name.clone(), font.clone(), Color32::WHITE).size().x; let rect = Rect::from_min_size(pos2(area.min.x, area.max.y - 16.0), vec2((text_w + 24.0).max(area.width()).clamp(80.0, 480.0), 16.0)); - let (done, open) = crate::widgets::rename_field(ui, egui::Id::new(("frame_label_rename", r.id)), &name, rect, font, r.start); + let (done, open) = crate::widgets::rename_field(ui, egui::Id::unique(("frame_label_rename", r.id)), &name, rect, font, r.start); app.ui.rename_label = open.then_some(crate::layers::Rename { id: r.id, start: false }); if let Some(new) = done { app.run("object.rename", serde_json::json!({"ids": [r.id], "name": new})); diff --git a/crates/ui-egui/src/frame_presets.rs b/crates/ui-egui/src/frame_presets.rs index 51662d3d..0d1c4b91 100644 --- a/crates/ui-egui/src/frame_presets.rs +++ b/crates/ui-egui/src/frame_presets.rs @@ -22,7 +22,7 @@ pub fn panel(app: &mut FigApp, ui: &mut Ui) { } let mut chosen = None; for cat in cats { - let open_id = Id::new(("preset_cat", cat)); + let open_id = Id::unique(("preset_cat", cat)); // The first category starts open. let open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(cat == "Phone"); let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), Sense::click()); diff --git a/crates/ui-egui/src/guides.rs b/crates/ui-egui/src/guides.rs index 69ab8ee8..827597ac 100644 --- a/crates/ui-egui/src/guides.rs +++ b/crates/ui-egui/src/guides.rs @@ -50,12 +50,12 @@ struct Placed { } fn drag_id() -> Id { - Id::new("guide_drag") + Id::unique("guide_drag") } /// The dragged guide as it would land. fn preview_id() -> Id { - Id::new("guide_preview") + Id::unique("guide_preview") } /// The page's guides and those of its top-level frames (frames last, so they're found first). diff --git a/crates/ui-egui/src/handles.rs b/crates/ui-egui/src/handles.rs index ec489bf8..b7a575fa 100644 --- a/crates/ui-egui/src/handles.rs +++ b/crates/ui-egui/src/handles.rs @@ -68,7 +68,7 @@ fn crop_corners(app: &FigApp, id: NodeId, r: (f64, f64, f64, f64)) -> Option<[Po } fn drag_id() -> Id { - Id::new("figcraft_handle_drag") + Id::unique("figcraft_handle_drag") } /// The gradient being edited (single node, picker open on a gradient paint). diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 326a3b14..68bec7aa 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -195,8 +195,13 @@ fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) { y += 52.0; let search = Rect::from_min_size(pos2(left.min.x + 8.0, y), vec2(SIDEBAR - 16.0, widgets::FIELD_H)); ui.scope_builder(egui::UiBuilder::new().max_rect(search), |ui| { - let response = - widgets::text_input(ui, Id::new("home_file_search"), &mut app.ui.home_view.file_query, search.width(), tr("Search files and templates")); + let response = widgets::text_input( + ui, + Id::unique("home_file_search"), + &mut app.ui.home_view.file_query, + search.width(), + tr("Search files and templates"), + ); a11y::text(&response, tr("Search files and templates"), &app.ui.home_view.file_query); if (response.has_focus() || response.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape)) { app.ui.home_view.file_query.clear(); @@ -205,7 +210,7 @@ fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) { y += widgets::FIELD_H + 12.0; for (section, icon, label) in Section::ALL { let r = Rect::from_min_size(pos2(left.min.x + 8.0, y), vec2(SIDEBAR - 16.0, 32.0)); - let resp = ui.interact(r, ui.id().with(("home_section", label)), Sense::click()); + let resp = ui.interact(r, ui.scope_id().with(("home_section", label)), Sense::click()); let current = app.ui.home_view.file_query.trim().is_empty() && app.ui.home_view.section == section; a11y::tab(&resp, tr(label), current); if current || resp.hovered() { @@ -250,7 +255,7 @@ fn search_results(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { detail_size: 11.0, detail_ink: t.text_secondary, }; - if listed(ui, t, ui.id().with(("search_online", &f.server, &f.file)), 36.0, &row) { + if listed(ui, t, ui.scope_id().with(("search_online", &f.server, &f.file)), 36.0, &row) { open_online = Some(f.clone()); } } @@ -385,7 +390,7 @@ fn open_files(app: &mut FigApp, ui: &mut Ui, t: &Tokens, query: &str) -> usize { detail_size: 11.0, detail_ink: t.text_secondary, }; - if listed(ui, t, ui.id().with(("open", d.uid)), 36.0, &row) { + if listed(ui, t, ui.scope_id().with(("open", d.uid)), 36.0, &row) { switch = Some(i); } } @@ -423,7 +428,7 @@ fn getting_started(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { let close = Rect::from_min_size(pos2(rect.max.x - 36.0, rect.min.y + 12.0), vec2(24.0, 24.0)); if widgets::icon_button_at( ui, - ui.id().with("tips_close"), + ui.scope_id().with("tips_close"), close, Icon::Close, tr("Hide these tips (Getting started in the Help menu shows them again)"), @@ -473,7 +478,7 @@ fn matches(template: &Template, query: &str, kind: Option) -> bool { fn templates(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { heading(ui, t, tr("Templates")); ui.horizontal(|ui| { - let search = widgets::text_input(ui, Id::new("home_template_search"), &mut app.ui.home_view.query, 260.0, tr("Search templates")); + let search = widgets::text_input(ui, Id::unique("home_template_search"), &mut app.ui.home_view.query, 260.0, tr("Search templates")); a11y::text(&search, tr("Search templates"), &app.ui.home_view.query); let labels: Vec<&str> = std::iter::once(tr("All")).chain(Kind::ALL.iter().map(|k| tr(k.label()))).collect(); let active = app.ui.home_view.kind.and_then(|k| Kind::ALL.iter().position(|x| *x == k)).map_or(0, |i| i + 1); @@ -506,7 +511,7 @@ fn templates(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { fn template_grid(app: &mut FigApp, ui: &mut Ui, t: &Tokens, list: &[&Template]) { let columns = (((ui.available_width() + GAP) / (CARD_W + GAP)).floor() as usize).max(1); let dark = ui.visuals().dark_mode; - let mut thumbs: Thumbs = ui.data(|d| d.get_temp(Id::new("template_thumbs"))).unwrap_or_default(); + let mut thumbs: Thumbs = ui.data(|d| d.get_temp(Id::unique("template_thumbs"))).unwrap_or_default(); let mut chosen = None; let mut rendered = false; for row in list.chunks(columns) { @@ -529,7 +534,7 @@ fn template_grid(app: &mut FigApp, ui: &mut Ui, t: &Tokens, list: &[&Template]) }); ui.add_space(GAP); } - ui.data_mut(|d| d.insert_temp(Id::new("template_thumbs"), thumbs)); + ui.data_mut(|d| d.insert_temp(Id::unique("template_thumbs"), thumbs)); if let Some(template) = chosen && app.run("file.newFromTemplate", json!({"template": template.id, "name": tr(template.name)})).is_some() { @@ -601,6 +606,6 @@ fn thumbnail(ctx: &egui::Context, template: &Template, background: figcraft_colo /// Tests: the templates whose pictures are cached for the light or dark theme. #[cfg(test)] pub(crate) fn cached_thumbnails(ctx: &egui::Context, dark: bool) -> Vec<&'static str> { - let thumbs: Thumbs = ctx.data(|d| d.get_temp(Id::new("template_thumbs"))).unwrap_or_default(); + let thumbs: Thumbs = ctx.data(|d| d.get_temp(Id::unique("template_thumbs"))).unwrap_or_default(); thumbs.0.iter().filter(|((_, d), thumb)| *d == dark && thumb.is_some()).map(|((id, _), _)| *id).collect() } diff --git a/crates/ui-egui/src/home_recents.rs b/crates/ui-egui/src/home_recents.rs index 2d39c6fe..0e151da2 100644 --- a/crates/ui-egui/src/home_recents.rs +++ b/crates/ui-egui/src/home_recents.rs @@ -685,7 +685,7 @@ fn heading(ui: &mut Ui, t: &Tokens, text: &str) { /// The id of `path`'s card or row (stable, so the keyboard stays on it as the order changes). fn card_id(path: &str) -> Id { - Id::new(("home_recent_file", path)) + Id::unique(("home_recent_file", path)) } /// Clicks, keys and the context menu on a card or row: a click selects, a double-click or Enter @@ -909,7 +909,7 @@ fn list(ui: &mut Ui, t: &Tokens, recents: &mut Recents, files: &[Shown], now: u6 let x0 = if i == 0 { header.min.x } else { header.min.x + date_x(i) }; let x1 = if i + 1 < columns.len() { header.min.x + date_x(i + 1) } else { header.max.x }; let cell = Rect::from_min_max(pos2(x0, header.min.y), pos2(x1, header.max.y)); - let resp = ui.interact(cell, Id::new(("home_recents_header", key.id())), Sense::click()); + let resp = ui.interact(cell, Id::unique(("home_recents_header", key.id())), Sense::click()); a11y::button(&resp, &trf("Sort by {column}", &[("column", label)])); let on = sort.key == *key; if resp.hovered() { diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index c7b10388..6b96c86e 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -83,6 +83,11 @@ icons! { FlipH, FlipV, CornerRadius, + /// Individual corners: the rounded corner each field sets. + CornerTopLeft, + CornerTopRight, + CornerBottomRight, + CornerBottomLeft, Angle, IndependentCorners, /// Individual padding: a frame with one inward tick per side, each set alone. @@ -719,7 +724,10 @@ fn draw(p: &P, icon: Icon) { p.poly(&[(3.5, 5.5), (12.5, 5.5), (12.5, 1.5)], true); p.fill(&[(3.0, 9.0), (13.0, 9.0), (13.0, 14.0)]); } - CornerRadius => p.corner(2.5, 2.5, 1.0, 1.0, 11.5, 6.0), + CornerRadius | CornerTopLeft => p.corner(2.5, 2.5, 1.0, 1.0, 11.5, 6.0), + CornerTopRight => p.corner(13.5, 2.5, -1.0, 1.0, 11.5, 6.0), + CornerBottomRight => p.corner(13.5, 13.5, -1.0, -1.0, 11.5, 6.0), + CornerBottomLeft => p.corner(2.5, 13.5, 1.0, -1.0, 11.5, 6.0), Angle => { // Two rays from a vertex, the arc between them marking the angle (all unhinted, so // the arc meets the rays at every size). @@ -1259,7 +1267,7 @@ pub fn paint_outlines(painter: &Painter, rect: Rect, size: f64, parts: &[(&figcr /// Every icon at the sizes the UI uses, labelled: for reviewing the set (`ui.set {iconGallery}`). pub fn gallery(ctx: &egui::Context) { let t = crate::theme::Tokens::get(ctx); - egui::Area::new(egui::Id::new("icon_gallery")).order(egui::Order::Foreground).fixed_pos(Pos2::ZERO).show(ctx, |ui| { + egui::Area::new(egui::Id::unique("icon_gallery")).order(egui::Order::Foreground).fixed_pos(Pos2::ZERO).show(ctx, |ui| { let screen = ctx.content_rect(); let (r, _) = ui.allocate_exact_size(screen.size(), egui::Sense::click()); ui.painter().rect_filled(r, 0.0, t.panel); @@ -1357,7 +1365,7 @@ mod tests { let mut u = crate::layers_drag_tests::Ui::new(s); u.frame(vec![]); let shapes = u.frame_output(vec![]).shapes; - let lead = |name: &str| u.ctx.read_response(egui::Id::new(name).with("lead")).map(|r| r.rect).unwrap_or_else(|| panic!("{name} is shown")); + let lead = |name: &str| u.ctx.read_response(egui::Id::unique(name).with("lead")).map(|r| r.rect).unwrap_or_else(|| panic!("{name} is shown")); let (rotation, radius) = (marks(&shapes, lead("pos_rot")), marks(&shapes, lead("ap_rad"))); assert!(!rotation.is_empty() && !radius.is_empty(), "both draw an icon"); assert_ne!(rotation, radius); @@ -1417,4 +1425,18 @@ mod tests { let inside = (0..h.len()).all(|i| side(i) >= 0.0) || (0..h.len()).all(|i| side(i) <= 0.0); assert!(inside, "the handle covers the blade's base at {base:?}"); } + + /// Each corner's icon shows its own corner: no two of the four draw alike. + #[test] + fn corner_icons_differ() { + let drawn: Vec = [Icon::CornerTopLeft, Icon::CornerTopRight, Icon::CornerBottomRight, Icon::CornerBottomLeft] + .iter() + .map(|&icon| format!("{:?}", shapes_at_32(icon))) + .collect(); + for (i, a) in drawn.iter().enumerate() { + for b in &drawn[i + 1..] { + assert_ne!(a, b); + } + } + } } diff --git a/crates/ui-egui/src/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs index 192073b3..4c35ae40 100644 --- a/crates/ui-egui/src/idle_tests.rs +++ b/crates/ui-egui/src/idle_tests.rs @@ -36,6 +36,7 @@ impl Harness { /// One frame with `events`, 1/60 s after the last; returns the repaint delay it asked for. fn frame(&mut self, events: Vec) -> Duration { self.time += 1.0 / 60.0; + self.app.clock.set_test_now(Some(self.time)); let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), events, ..Default::default() }; let app = &mut self.app; @@ -268,13 +269,18 @@ fn control_requests_and_screenshots_wake_an_idle_ui() { let params = json!({"path": path.to_string_lossy()}); h.app.control_queue.push(ControlRequest { id: json!(2), method: "ui.screenshot".into(), params, reply: tx, cancelled: Default::default() }); for _ in 0..5 { - assert_eq!(h.frame(vec![]), Duration::ZERO, "waiting for the screenshot"); + let delay = h.frame(vec![]); + assert!(delay <= Duration::from_secs_f64(crate::control::SHOT_TIMEOUT), "waiting for the screenshot"); } assert!(rx.try_recv().is_err(), "not delivered yet"); - let image = Arc::new(egui::ColorImage::filled([4, 4], egui::Color32::RED)); - h.frame(vec![Event::Screenshot { viewport_id: ViewportId::ROOT, user_data: egui::UserData::default(), image }]); + // The settled UI issued the viewport capture command; the renderer completes its one-shot + // slot, and the next UI pass writes the reply. + let spec = h.app.capture.shots.first().and_then(|r| r.spec).expect("admitted against real geometry"); + let slot = h.app.capture.shots.first().and_then(|r| r.slot.clone()).expect("issued after settling"); + slot.complete(Arc::new(egui::ColorImage::filled(spec.size_px, egui::Color32::RED))); + h.frame(vec![]); let reply = rx.try_recv().unwrap(); - assert_eq!(reply.result.unwrap()["width"], json!(4)); + assert_eq!(reply.result.unwrap()["width"], json!(spec.size_px[0])); let _ = std::fs::remove_file(&path); h.assert_idle("after a screenshot"); } @@ -358,18 +364,24 @@ fn a_hidden_window_answers_at_once() { h.app.control_queue.push(request(3, "ui.screenshot", json!({"path": dir.join("shot.png").to_string_lossy()}))); let mut hidden = RawInput::default(); hidden.viewports.insert(ViewportId::ROOT, egui::ViewportInfo { minimized: Some(true), ..Default::default() }); - let app = &mut h.app; - let _ = h.ctx.run_logic(&hidden, |ctx| crate::control::drain_hidden(app, ctx)); + let ctx = h.ctx.clone(); + let _ = ctx.run_logic(&hidden, |ctx| crate::control::background(&mut h.app, ctx)); let saved = rx.try_recv().unwrap(); assert_eq!(saved.id, json!(1)); assert!(saved.result.is_ok() && path.exists(), "saved while hidden: {:?}", saved.result); let inspected = rx.try_recv().unwrap(); assert_eq!(inspected.id, json!(2)); assert!(inspected.result.is_ok(), "the interface's state: {:?}", inspected.result); - let shot = rx.try_recv().unwrap(); - assert_eq!(shot.id, json!(3)); - assert!(shot.result.unwrap_err().contains("minimised or hidden"), "a capture needs the window on screen"); + assert!(rx.try_recv().is_err(), "a capture queues for a forced hidden paint"); + assert_eq!(h.app.capture.shots.len(), 1, "one admitted capture"); assert!(h.app.control_queue.is_empty(), "nothing waits for the window"); + // Its deadline expires it once, with the same error the transport used to get. + h.app.clock.set_test_now(Some(crate::control::SHOT_TIMEOUT + 1.0)); + let _ = ctx.run_logic(&hidden, |ctx| crate::control::background(&mut h.app, ctx)); + let shot = rx.try_recv().expect("expired"); + assert_eq!(shot.id, json!(3)); + assert!(shot.result.unwrap_err().contains("didn't deliver a screenshot"), "a capture needs paint"); + assert!(h.app.capture.shots.is_empty(), "released after its deadline"); let _ = std::fs::remove_dir_all(&dir); } @@ -408,3 +420,44 @@ fn auto_save_writes_a_changed_file_then_rests() { h.assert_idle("an auto-saved file"); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn a_hidden_logic_tick_runs_background_work_but_counts_no_ui_frame() { + let (mut h, _) = document(); + h.settle(); + let frames = h.app.frame_stats.frames; + let deadline_before = h.app.ui.next_autosave; + h.time += crate::AUTOSAVE_SECS + 1.0; + h.app.clock.set_test_now(Some(h.time)); + let mut input = RawInput::default(); + input.viewports.insert(ViewportId::ROOT, egui::ViewportInfo { minimized: Some(true), ..Default::default() }); + let ctx = h.ctx.clone(); + let _ = ctx.run_logic(&input, |ctx| h.app.background_tick(ctx)); + assert_eq!(h.app.frame_stats.frames, frames, "a logic-only tick is not a UI frame"); + let deadline = h.app.ui.next_autosave; + assert!(deadline > deadline_before + crate::AUTOSAVE_SECS - 1.0, "the autosave deadline advanced"); + // Repeating the same due instant does not advance it again (consumed/deadline-driven work). + let _ = ctx.run_logic(&input, |ctx| h.app.background_tick(ctx)); + assert_eq!(h.app.ui.next_autosave, deadline, "idempotent at the same monotonic time"); +} + +#[test] +fn a_discarded_sizing_pass_sets_up_and_counts_one_frame_only() { + let (mut h, _) = document(); + h.settle(); + let frames = h.app.frame_stats.frames; + let input = + RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(h.time + 1.0 / 60.0), ..Default::default() }; + let mut passes = 0; + let app = &mut h.app; + let mut out = h.ctx.run_ui(input, |ui| { + passes += 1; + app.ui(ui); + if passes == 1 { + ui.ctx().request_discard("test sizing pass"); + } + }); + out.textures_delta.clear(); + assert_eq!(passes, 2, "egui reran the frame"); + assert_eq!(app.frame_stats.frames, frames + 1, "one frame was drawn, not one per pass"); +} diff --git a/crates/ui-egui/src/ime_tests.rs b/crates/ui-egui/src/ime_tests.rs index a6db0cbd..0b82abe6 100644 --- a/crates/ui-egui/src/ime_tests.rs +++ b/crates/ui-egui/src/ime_tests.rs @@ -3,6 +3,7 @@ //! edit (an idle frame too) with the candidate window at the caret, the composition shows without //! entering the document, keys pressed while composing are the IME's, and the commit lands once. +use egui::output::{IMEOutput, IMESurroundingText, IMESurroundingTextPayload, ImeChangeCause}; use egui::{Event, ImeEvent, Key, Modifiers, RawInput, Rect}; use figcraft_doc::{NodeId, NodeKind}; use figcraft_engine::Session; @@ -42,6 +43,22 @@ fn composing(u: &Ui) -> Option { u.app.session.ds().unwrap().text_edit.as_ref().expect("still editing").preedit.as_ref().map(|p| p.text.clone()) } +/// The whole IME descriptor one frame leaves for the platform. +fn ime_descriptor(u: &mut Ui, events: Vec) -> IMEOutput { + u.frame_raw(RawInput { events, ..Default::default() }).platform_output.ime.expect("the IME stays on while editing") +} + +fn available(ime: &IMEOutput) -> &IMESurroundingTextPayload { + match &ime.surrounding_text { + IMESurroundingText::Available(payload) => payload, + IMESurroundingText::Unavailable => panic!("expected available surrounding text"), + } +} + +fn text_layer(s: &mut Session, text: &str) -> NodeId { + NodeId(s.execute("text.create", &json!({"text": text, "x": 40, "y": 40})).unwrap()["id"].as_u64().unwrap()) +} + #[test] fn pinyin_composes_inline_and_commits_once_with_the_ime_on_every_frame() { // The text is measured with a font: without craft-fonts a system one, scanned here rather than @@ -85,3 +102,503 @@ fn pinyin_composes_inline_and_commits_once_with_the_ime_on_every_frame() { ime(&mut u, vec![key(Key::Enter, true), key(Key::Enter, false), Event::Text("!".into())]); assert_eq!(text(&u, t), "Hi你好\n!"); } + +#[test] +fn an_empty_ime_commit_ends_the_composition_without_inserting_text() { + // Safari's egui web integration sends `Commit("")` (not `Preedit("")`) when the last + // composition character is deleted. It ends the composition; it must not leave the pre-edit + // rendering or insert anything. + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = text_layer(&mut s, "Hello"); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(5), false).unwrap(); + ime(&mut u, vec![preedit("にほ")]); + assert_eq!(composing(&u).as_deref(), Some("にほ")); + let after = ime_descriptor(&mut u, vec![Event::Ime(ImeEvent::Commit(String::new()))]); + assert_eq!(composing(&u), None, "the empty commit ends the composition"); + assert_eq!(text(&u, t), "Hello", "and inserts nothing"); + assert!(matches!(after.surrounding_text, IMESurroundingText::Available(_))); + // The editor is still focused, and the next pass observes committed text only. + let next = ime_descriptor(&mut u, vec![]); + assert_eq!(composing(&u), None); + assert_eq!(available(&next).text, "Hello"); + assert_eq!(text(&u, t), "Hello"); +} + +#[test] +fn an_ime_replacing_text_deletes_in_bytes_before_the_paired_commit() { + // 0.37 sends `DeleteSurroundingBytes` immediately before the commit that replaces it. The + // custom engine editor deletes the exact UTF-8 range (never display-bidi text) and keeps the + // whole edit as one undo step. + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = NodeId(s.execute("text.create", &json!({"text": "ab\u{4F60}\u{597D}cd", "x": 40, "y": 40})).unwrap()["id"].as_u64().unwrap()); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(4), false).unwrap(); + ime(&mut u, vec![]); + // Compose, then replace "你好c" with "X". + ime(&mut u, vec![preedit("x")]); + ime(&mut u, vec![Event::Ime(ImeEvent::DeleteSurroundingBytes { before_bytes: 6, after_bytes: 1 }), Event::Ime(ImeEvent::Commit("X".into()))]); + assert_eq!(text(&u, t), "abXd"); + figcraft_engine::textedit::end(&mut u.app.session).unwrap(); + u.app.session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&u, t), "ab\u{4F60}\u{597D}cd", "one undo restores the pre-IME text"); +} + +#[test] +fn a_caret_click_rejects_the_stale_delete_and_commit_in_the_same_input_batch() { + figcraft_text::scan_system_fonts(); + for delete in + [ImeEvent::DeleteSurrounding { before_chars: 1, after_chars: 1 }, ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 1 }] + { + let mut s = Session::new(); + let t = text_layer(&mut s, "abcdef"); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(6), false).unwrap(); + ime(&mut u, vec![preedit("z")]); + let p = u.app.to_screen(42.0, 45.0); + // Real canvas pointer handling moves the caret and cancels preedit before text_keys. + ime( + &mut u, + vec![ + Event::PointerMoved(p), + Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + Event::Ime(delete), + Event::Ime(ImeEvent::Commit("Q".into())), + ], + ); + let edit = u.app.session.ds().unwrap().text_edit.as_ref().unwrap(); + assert!(edit.caret < 6, "the production canvas click actually moved the caret"); + assert!(edit.preedit.is_none(), "the click ended the composition"); + assert_eq!(text(&u, t), "abcdef", "neither stale deletion nor its Commit may mutate the clicked caret"); + u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }]); + // A later direct commit without an active composition is legitimate. + let caret = u.app.session.ds().unwrap().text_edit.as_ref().unwrap().caret; + ime(&mut u, vec![Event::Ime(ImeEvent::Commit("X".into()))]); + let mut expected = "abcdef".to_string(); + expected.insert(caret, 'X'); + assert_eq!(text(&u, t), expected, "the interrupted batch does not poison a later direct commit"); + figcraft_engine::textedit::end(&mut u.app.session).unwrap(); + u.app.session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&u, t), "abcdef", "the valid edit retains one-session undo"); + } +} + +#[test] +fn a_document_switch_rejects_the_old_compositions_delete_and_commit_batch() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let a = text_layer(&mut s, "Alpha"); + s.execute("file.new", &json!({"name": "B"})).unwrap(); + let b = text_layer(&mut s, "Bravo"); + figcraft_engine::textedit::begin(&mut s, b, Some(5), false).unwrap(); + s.execute("doc.switch", &json!({"index": 0})).unwrap(); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, a, Some(5), false).unwrap(); + ime(&mut u, vec![preedit("z")]); + let (tx, rx) = std::sync::mpsc::channel(); + u.app.control_queue.push(crate::control::ControlRequest { + id: json!(91), + method: "engine.execute".into(), + params: json!({"window": 0, "command": "doc.switch", "params": {"index": 1}}), + reply: tx, + cancelled: Default::default(), + }); + ime(&mut u, vec![Event::Ime(ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 0 }), Event::Ime(ImeEvent::Commit("Q".into()))]); + assert!(rx.try_recv().unwrap().result.is_ok(), "production control drain switched the document"); + assert_eq!(u.app.session.active, 1); + assert_eq!(text(&u, b), "Bravo", "the new target cannot inherit the old composition's mutations"); + assert_eq!(u.app.session.docs[0].doc.node(a).unwrap().kind.text().unwrap().characters, "Alpha"); + ime(&mut u, vec![Event::Ime(ImeEvent::Commit("X".into()))]); + assert_eq!(text(&u, b), "BravoX", "a direct commit to the new noncomposing target remains valid"); +} + +#[test] +fn ime_cause_is_input_method_only_for_an_accepted_commit() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = text_layer(&mut s, "Hi"); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(2), false).unwrap(); + + let first = ime_descriptor(&mut u, vec![]); + assert_eq!(first.change_cause, ImeChangeCause::Other, "the first focused observation starts a context"); + assert_eq!(available(&first).text, "Hi"); + assert_eq!((available(&first).cursor, available(&first).anchor), (2, 2)); + + let commit = ime_descriptor(&mut u, vec![Event::Ime(ImeEvent::Commit("你好".into()))]); + assert_eq!(commit.change_cause, ImeChangeCause::InputMethod); + assert_eq!(available(&commit).text, "Hi你好"); + assert_eq!(available(&commit).cursor, 8, "UTF-8 bytes, not characters"); + assert_eq!(commit.context_generation, first.context_generation, "a commit is not a context change"); + assert_eq!(commit.owner, first.owner); + + let typed = ime_descriptor(&mut u, vec![Event::Text("x".into())]); + assert_eq!(typed.change_cause, ImeChangeCause::Other, "ordinary typing is an application edit"); + assert_eq!(available(&typed).text, "Hi你好x"); + assert_eq!(text(&u, t), "Hi你好x"); +} + +#[test] +fn ime_cause_reports_mixed_application_and_ime_mutations_as_other() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = text_layer(&mut s, "Hi"); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(2), false).unwrap(); + ime(&mut u, vec![]); + + let mixed = ime_descriptor(&mut u, vec![Event::Ime(ImeEvent::Commit("あ".into())), Event::Text("b".into())]); + assert_eq!(mixed.change_cause, ImeChangeCause::Other, "an application edit in the same frame dominates"); + assert_eq!(available(&mixed).text, "Hiあb"); + + // The next frame is only an accepted commit: the frame-scoped `Other` does not linger. + let pure = ime_descriptor(&mut u, vec![Event::Ime(ImeEvent::Commit("う".into()))]); + assert_eq!(pure.change_cause, ImeChangeCause::InputMethod); + assert_eq!(available(&pure).text, "Hiあbう"); + + // An application edit before the IME receipt, and IME → application → IME, are both Other. + let before = ime_descriptor(&mut u, vec![Event::Text("c".into()), Event::Ime(ImeEvent::Commit("え".into()))]); + assert_eq!(before.change_cause, ImeChangeCause::Other); + let sandwiched = + ime_descriptor(&mut u, vec![Event::Ime(ImeEvent::Commit("お".into())), Event::Text("d".into()), Event::Ime(ImeEvent::Commit("か".into()))]); + assert_eq!(sandwiched.change_cause, ImeChangeCause::Other); + assert_eq!(text(&u, t), "Hiあbうcえおdか"); +} + +#[test] +fn a_control_channel_text_edit_is_an_application_change() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = text_layer(&mut s, "Hi"); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(2), false).unwrap(); + ime(&mut u, vec![]); + + // A command from the control channel (not an ImeEvent) changes committed text. + u.app.session.execute("text.edit.insert", &json!({"text": "z"})).unwrap(); + let out = ime_descriptor(&mut u, vec![]); + assert_eq!(out.change_cause, ImeChangeCause::Other); + assert_eq!(available(&out).text, "Hiz"); +} + +#[test] +fn ime_delete_then_commit_is_input_method_and_describes_the_post_mutation_text() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = text_layer(&mut s, "ab\u{4F60}\u{597D}cd"); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(4), false).unwrap(); + ime(&mut u, vec![]); + + let out = ime_descriptor( + &mut u, + vec![Event::Ime(ImeEvent::DeleteSurroundingBytes { before_bytes: 6, after_bytes: 1 }), Event::Ime(ImeEvent::Commit("X".into()))], + ); + assert_eq!(out.change_cause, ImeChangeCause::InputMethod); + assert_eq!(available(&out).text, "abXd", "the snapshot is observed after both mutations"); + assert_eq!(available(&out).cursor, 3); + assert_eq!(text(&u, t), "abXd"); +} + +#[test] +fn preedit_is_not_surrounding_text_but_the_commit_that_follows_is_input_method() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = text_layer(&mut s, "Hi"); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(2), false).unwrap(); + ime(&mut u, vec![]); + + let composing = ime_descriptor(&mut u, vec![preedit("にほ")]); + assert_eq!(available(&composing).text, "Hi", "the composition is excluded"); + assert_eq!((available(&composing).cursor, available(&composing).anchor), (2, 2)); + assert_eq!(composing.change_cause, ImeChangeCause::Other, "preedit alone earns no receipt"); + + let committed = ime_descriptor(&mut u, vec![preedit(""), Event::Ime(ImeEvent::Commit("にほ".into()))]); + assert_eq!(committed.change_cause, ImeChangeCause::InputMethod); + assert_eq!(available(&committed).text, "Hiにほ"); + assert_eq!(available(&committed).cursor, 8); +} + +#[test] +fn ime_surrounding_text_is_logical_utf8_and_keeps_a_reversed_selection() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = text_layer(&mut s, "café😀e\u{301}x"); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(8), false).unwrap(); + figcraft_engine::textedit::set_caret(&mut u.app.session, 8, false).unwrap(); + figcraft_engine::textedit::set_caret(&mut u.app.session, 5, true).unwrap(); + + let out = ime_descriptor(&mut u, vec![]); + let payload = available(&out); + assert_eq!(payload.text, "café😀e\u{301}x"); + assert_eq!((payload.cursor, payload.anchor), (9, 13)); + assert!(payload.cursor < payload.anchor, "the reversed selection is not sorted"); +} + +#[test] +fn empty_text_is_available_while_a_too_wide_selection_is_explicitly_unavailable() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = text_layer(&mut s, ""); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(0), false).unwrap(); + let empty = ime_descriptor(&mut u, vec![]); + let payload = available(&empty); + assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("", 0, 0)); + + // The same editor over a selection wider than the 4000-byte budget cannot describe it. + let mut s = Session::new(); + let long = "a".repeat(5000); + let t = text_layer(&mut s, &long); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, None, false).unwrap(); + let wide = ime_descriptor(&mut u, vec![]); + assert_eq!(wide.surrounding_text, IMESurroundingText::Unavailable); + assert!(wide.context_generation > 0, "the owner stays known when only the text is unavailable"); +} + +#[test] +fn a_commit_replacing_a_too_wide_selection_is_still_an_input_method_receipt() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let long = "a".repeat(5000); + let t = text_layer(&mut s, &long); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, None, false).unwrap(); + let wide = ime_descriptor(&mut u, vec![]); + assert_eq!(wide.surrounding_text, IMESurroundingText::Unavailable); + + // The unavailable baseline and the pre-mutation stamp carry the engine's document revision, + // so replacing the over-wide selection with a normal commit is a provable IME receipt rather + // than an unproven `Other` change. + let out = ime_descriptor(&mut u, vec![Event::Ime(ImeEvent::Commit("X".into()))]); + assert_eq!(out.change_cause, ImeChangeCause::InputMethod); + assert_eq!(text(&u, t), "X"); + assert_eq!(available(&out).text, "X"); + assert_eq!((available(&out).cursor, available(&out).anchor), (1, 1)); +} + +#[test] +fn a_new_edit_session_and_another_text_node_are_new_ime_owners() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let a = text_layer(&mut s, "A"); + let b = text_layer(&mut s, "B"); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, a, Some(1), false).unwrap(); + let first = ime_descriptor(&mut u, vec![]); + + figcraft_engine::textedit::end(&mut u.app.session).unwrap(); + figcraft_engine::textedit::begin(&mut u.app.session, b, Some(1), false).unwrap(); + let other = ime_descriptor(&mut u, vec![]); + assert_ne!(other.owner, first.owner, "another text node is another owner"); + assert_ne!(other.context_generation, first.context_generation); + + figcraft_engine::textedit::end(&mut u.app.session).unwrap(); + figcraft_engine::textedit::begin(&mut u.app.session, a, Some(1), false).unwrap(); + let again = ime_descriptor(&mut u, vec![]); + assert_eq!(again.owner, first.owner, "the owner key is the document and node"); + assert_ne!(again.context_generation, first.context_generation, "re-entering is a replaced target"); +} + +#[test] +fn an_accepted_commit_survives_a_discarded_pass_without_reapplying_or_losing_its_cause() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = text_layer(&mut s, "Hi"); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(2), false).unwrap(); + let first = ime_descriptor(&mut u, vec![]); + + let out = u.frame_raw_with_discard(RawInput { events: vec![Event::Ime(ImeEvent::Commit("あ".into()))], ..Default::default() }); + let ime = out.platform_output.ime.expect("the presented pass keeps the IME on"); + assert_eq!(text(&u, t), "Hiあ", "the discarded rerun must not insert twice"); + assert_eq!(available(&ime).text, "Hiあ"); + assert_eq!(ime.change_cause, ImeChangeCause::InputMethod, "the unchanged second pass keeps the first pass's cause"); + assert_eq!(ime.context_generation, first.context_generation); + + let mixed = u + .frame_raw_with_discard(RawInput { events: vec![Event::Ime(ImeEvent::Commit("い".into())), Event::Text("x".into())], ..Default::default() }); + let mixed = mixed.platform_output.ime.expect("still editing"); + assert_eq!(text(&u, t), "Hiあいx"); + assert_eq!(mixed.change_cause, ImeChangeCause::Other); +} + +#[test] +fn ime_context_state_is_scoped_per_viewport() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = text_layer(&mut s, "Hi"); + figcraft_engine::textedit::begin(&mut s, t, Some(2), false).unwrap(); + let mut app = crate::FigApp::new(s, Box::new(crate::NoServices)); + // No window drew here, so give the canvas a finite rect for the caret geometry. + app.canvas.rect = Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)); + let ctx = egui::Context::default(); + ctx.set_embed_viewports(false); + let aux = egui::ViewportId::from_hash_of("figcraft-ime-aux"); + + let mut raw = RawInput { viewport_id: egui::ViewportId::ROOT, ..Default::default() }; + raw.viewports.entry(egui::ViewportId::ROOT).or_default(); + let root_out = ctx.run_ui(raw, |ui| { + ui.ctx().show_viewport_deferred(aux, egui::ViewportBuilder::default(), |_, _| {}); + crate::shortcuts::ime_output(&mut app, ui.ctx()); + }); + let root = root_out.platform_output.ime.clone().expect("root descriptor"); + root_out.drop_without_applying_deltas(); + + let mut raw = RawInput { viewport_id: aux, ..Default::default() }; + raw.viewports.entry(aux).or_default(); + let aux_out = ctx.run_ui(raw, |ui| crate::shortcuts::ime_output(&mut app, ui.ctx())); + let aux_ime = aux_out.platform_output.ime.clone().expect("aux descriptor"); + aux_out.drop_without_applying_deltas(); + assert_eq!(aux_ime.owner, root.owner, "the same edit is the same owner"); + assert_ne!(aux_ime.context_generation, root.context_generation, "each viewport gets its own generation"); + assert_eq!(available(&aux_ime).text, "Hi"); + + // The root's next consecutive pass keeps its context even though the aux pass ran between. + let mut raw = RawInput { viewport_id: egui::ViewportId::ROOT, ..Default::default() }; + raw.viewports.entry(egui::ViewportId::ROOT).or_default(); + let root_out = ctx.run_ui(raw, |ui| { + ui.ctx().show_viewport_deferred(aux, egui::ViewportBuilder::default(), |_, _| {}); + crate::shortcuts::ime_output(&mut app, ui.ctx()); + }); + let root_again = root_out.platform_output.ime.clone().expect("root descriptor"); + root_out.drop_without_applying_deltas(); + assert_eq!(root_again.context_generation, root.context_generation); + + // Closing the aux viewport releases its state; it never inherits the old context. + app.ime.end_viewport(aux); + let mut raw = RawInput { viewport_id: aux, ..Default::default() }; + raw.viewports.entry(aux).or_default(); + let aux_out = ctx.run_ui(raw, |ui| crate::shortcuts::ime_output(&mut app, ui.ctx())); + let aux_again = aux_out.platform_output.ime.clone().expect("aux descriptor"); + aux_out.drop_without_applying_deltas(); + assert_ne!(aux_again.context_generation, aux_ime.context_generation); +} + +#[test] +fn the_engine_surrounding_limit_matches_the_wire_limit() { + // A divergence would truncate or overrun the bounded payload silently. + let engine = figcraft_engine::textedit::MAX_IME_SURROUNDING_BYTES; + let wire = egui::output::MAX_IME_SURROUNDING_TEXT_BYTES; + assert_eq!(engine, wire); +} + +#[test] +fn a_frame_observes_the_ime_once_even_when_it_ends_early() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = text_layer(&mut s, "Hi"); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(2), false).unwrap(); + let before = u.app.ime_observations; + u.frame(vec![]); + assert_eq!(u.app.ime_observations - before, 1, "one final observation for the editor pass"); + + // The home/present early returns make the same single idle observation. + u.app.ui.home = true; + let before = u.app.ime_observations; + u.frame(vec![]); + assert_eq!(u.app.ime_observations - before, 1, "one idle observation on the home screen"); +} + +#[test] +fn a_double_click_inside_the_edit_keeps_the_ime_generation() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = text_layer(&mut s, "Hello world"); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(2), false).unwrap(); + let (p, zoom) = { + let doc = u.app.session.doc().unwrap(); + let node = doc.node(t).unwrap(); + let Some((layout, dy)) = figcraft_text::layout_node(node) else { return }; + if layout.glyphs.is_empty() || layout.lines.is_empty() { + return; // no fonts + } + let (x0, x1) = (layout.caret(7).0, layout.caret(8).0); + let line = &layout.lines[0]; + let world = doc.world_transform(t); + let p = world * figcraft_geom::Point::new((x0 + x1) / 2.0, line.top + line.height / 2.0 + dy); + (p, u.app.session.ds().unwrap().view.zoom) + }; + let first = ime_descriptor(&mut u, vec![]); + let ev = figcraft_engine::PointerEvent::at(figcraft_engine::PointerKind::DoubleClick, p.x, p.y, zoom); + u.app.session.pointer(&ev).unwrap(); + + let after = ime_descriptor(&mut u, vec![]); + assert_eq!(after.owner, first.owner, "the same text node is the same owner"); + assert_eq!(after.context_generation, first.context_generation, "a word-select inside the edit is not a replaced target"); + assert_eq!(available(&after).text, "Hello world"); +} + +#[test] +fn a_window_new_that_moves_the_edited_file_leaves_the_root_ime_idle() { + figcraft_text::scan_system_fonts(); + let mut s = Session::empty(); + s.execute("file.new", &json!({"name": "A"})).unwrap(); + let uid = s.ds().unwrap().uid; + let t = text_layer(&mut s, "Hi"); + figcraft_engine::textedit::begin(&mut s, t, Some(2), false).unwrap(); + s.execute("file.new", &json!({"name": "B"})).unwrap(); + let a = s.docs.iter().position(|d| d.uid == uid).unwrap(); + s.execute("doc.switch", &json!({"index": a})).unwrap(); + let mut u = Ui::new(s); + u.ctx.set_embed_viewports(false); + let first = ime_descriptor(&mut u, vec![]); + assert_eq!(available(&first).text, "Hi"); + + // A `window.new` queued before the pass is applied after `windows::begin` picked the file + // for the root: the root no longer shows the edited file when the pass ends. + u.app.window_requests.push(figcraft_engine::UiRequest { kind: "window.new".into(), data: json!({"index": a}) }); + let out = u.frame_raw(RawInput::default()); + assert_ne!(u.app.windows.of(uid), 0, "the file moved to its own window"); + assert!(out.platform_output.ime.is_none(), "the root must not emit a descriptor for a file it no longer shows"); +} + +#[test] +fn a_transformed_commit_is_application_origin_and_a_bare_newline_does_nothing() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = text_layer(&mut s, "Hi"); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(2), false).unwrap(); + ime(&mut u, vec![]); + + let normalized = ime_descriptor(&mut u, vec![Event::Ime(ImeEvent::Commit("a\r\nb".into()))]); + assert_eq!(normalized.change_cause, ImeChangeCause::Other, "CRLF normalisation is an application transformation"); + assert_eq!(text(&u, t), "Hia\nb"); + assert_eq!(available(&normalized).text, "Hia\nb"); + + let newline = ime_descriptor(&mut u, vec![Event::Ime(ImeEvent::Commit("\n".into()))]); + assert_eq!(text(&u, t), "Hia\nb", "stock TextEdit ignores a bare newline commit"); + assert!(matches!(newline.surrounding_text, IMESurroundingText::Available(_))); + let control = ime_descriptor(&mut u, vec![Event::Ime(ImeEvent::Commit("\u{0}\u{7}".into()))]); + assert_eq!(text(&u, t), "Hia\nb", "a control-only commit inserts nothing"); + assert!(matches!(control.surrounding_text, IMESurroundingText::Available(_))); +} + +#[test] +fn ime_delete_surrounding_chars_keeps_the_selection_for_the_paired_commit() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = text_layer(&mut s, "ab\u{4F60}\u{597D}cd"); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(2), false).unwrap(); + figcraft_engine::textedit::set_caret(&mut u.app.session, 4, true).unwrap(); // "你好" + ime(&mut u, vec![]); + + let out = ime_descriptor( + &mut u, + vec![Event::Ime(ImeEvent::DeleteSurrounding { before_chars: 1, after_chars: 1 }), Event::Ime(ImeEvent::Commit("X".into()))], + ); + // The delete removes "b" and "c"; the selected "你好" stays for the commit, which replaces it. + assert_eq!(text(&u, t), "aXd"); + assert_eq!(available(&out).text, "aXd"); + assert_eq!(available(&out).cursor, 2); + assert_eq!(out.change_cause, ImeChangeCause::InputMethod); +} diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index f172b309..d6c5300d 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -81,7 +81,7 @@ pub fn rail(app: &mut FigApp, ui: &mut Ui) { let mut y = r.min.y + 8.0; // Main menu (logo). let lr = Rect::from_center_size(pos2(r.center().x, y + 16.0), vec2(36.0, 32.0)); - let lresp = ui.interact(lr, ui.id().with("rail_menu"), Sense::click()).tip(tr("Main menu")); + let lresp = ui.interact(lr, ui.scope_id().with("rail_menu"), Sense::click()).tip(tr("Main menu")); if lresp.hovered() || app.ui.main_menu { ui.painter().rect_filled(lr, CornerRadius::same(6), t.hover); } @@ -100,7 +100,7 @@ pub fn rail(app: &mut FigApp, ui: &mut Ui) { for (icon, label, tab) in items { let ir = Rect::from_center_size(pos2(r.center().x, y + 14.0), vec2(36.0, 28.0)); let hit = Rect::from_min_max(pos2(r.min.x, y), pos2(r.max.x, y + 48.0)); - let resp = ui.interact(hit, ui.id().with(("rail", label)), Sense::click()).tip(label); + let resp = ui.interact(hit, ui.scope_id().with(("rail", label)), Sense::click()).tip(label); let active = tab.is_some_and(|tb| tb == app.ui.left_tab && !app.ui.left_collapsed && !app.ui.versions.open); if tab.is_some() { a11y::tab(&resp, label, active); @@ -149,13 +149,13 @@ pub fn left_panel(app: &mut FigApp, ui: &mut Ui) { let name = app.session.doc().map(|d| d.name.clone()).unwrap_or_default(); let nr = Rect::from_min_max(pos2(head.min.x + 14.0, head.min.y + 14.0), pos2(head.max.x - 48.0, head.min.y + 34.0)); let start = std::mem::take(&mut app.ui.rename_file); - if let (Some(new), _) = crate::widgets::rename_field(ui, Id::new("filename"), &name, nr, semibold(13.0), start) { + if let (Some(new), _) = crate::widgets::rename_field(ui, Id::unique("filename"), &name, nr, semibold(13.0), start) { app.run("file.rename", json!({"name": new})); } ui.painter().text(pos2(head.min.x + 16.0, head.min.y + 46.0), Align2::LEFT_CENTER, tr("Drafts"), regular(11.0), t.text_secondary); } let hr = Rect::from_center_size(pos2(head.max.x - 24.0, head.min.y + 24.0), vec2(24.0, 24.0)); - let hresp = ui.interact(hr, ui.id().with("hideui"), Sense::click()).tip(tr("Collapse panel")); + let hresp = ui.interact(hr, ui.scope_id().with("hideui"), Sense::click()).tip(tr("Collapse panel")); if hresp.hovered() { ui.painter().rect_filled(hr, CornerRadius::same(5), t.hover); } @@ -177,19 +177,19 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let w = ui.available_width(); let (hr, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover()); - let tresp = ui.interact(Rect::from_min_size(hr.min, vec2(w - 40.0, 40.0)), ui.id().with("pages_toggle"), Sense::click()); + let tresp = ui.interact(Rect::from_min_size(hr.min, vec2(w - 40.0, 40.0)), ui.scope_id().with("pages_toggle"), Sense::click()); a11y::toggle(&tresp, tr("Pages"), app.ui.pages_open); ui.painter().text(pos2(hr.min.x + 16.0, hr.center().y), Align2::LEFT_CENTER, tr("Pages"), semibold(11.0), t.text); if tresp.clicked() { app.ui.pages_open = !app.ui.pages_open; } let sr = Rect::from_center_size(pos2(hr.max.x - 50.0, hr.center().y), vec2(24.0, 24.0)); - let sresp = ui.interact(sr, ui.id().with("lsearch"), Sense::click()).tip(tr("Search pages")); + let sresp = ui.interact(sr, ui.scope_id().with("lsearch"), Sense::click()).tip(tr("Search pages")); if sresp.hovered() || app.ui.page_search { ui.painter().rect_filled(sr, CornerRadius::same(5), t.hover); } icons::paint(ui.painter(), sr.shrink(5.0), Icon::Search, t.icon); - let search_id = Id::new("page_search_field"); + let search_id = Id::unique("page_search_field"); if sresp.clicked() { app.ui.page_search = !app.ui.page_search; app.ui.page_filter.clear(); @@ -198,7 +198,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { } } let ar = Rect::from_center_size(pos2(hr.max.x - 22.0, hr.center().y), vec2(24.0, 24.0)); - let aresp = ui.interact(ar, ui.id().with("addpage"), Sense::click()).tip(tr("Add new page")); + let aresp = ui.interact(ar, ui.scope_id().with("addpage"), Sense::click()).tip(tr("Add new page")); if aresp.hovered() { ui.painter().rect_filled(ar, CornerRadius::same(5), t.hover); } @@ -275,7 +275,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { let tr = Rect::from_min_max(pos2(r.min.x + 8.0, r.min.y + 4.0), pos2(r.max.x - 4.0, r.max.y - 4.0)); let start = ui.data_mut(|d| d.remove_temp::(page_rename_start_id())).unwrap_or(false); let font = if id == cur { semibold(11.0) } else { regular(11.0) }; - if let (Some(new), _) = crate::widgets::rename_field(ui, Id::new(("pagename", id)), &name, tr, font, start) { + if let (Some(new), _) = crate::widgets::rename_field(ui, Id::unique(("pagename", id)), &name, tr, font, start) { rename_done = Some((id, new)); } } else if !divider { @@ -372,8 +372,9 @@ fn pages_edge(app: &mut FigApp, ui: &mut Ui, shown: f32) { let w = ui.available_width(); let (line, _) = ui.allocate_exact_size(vec2(w, 1.0), Sense::hover()); let grab = ui.style().interaction.resize_grab_radius_side; - let id = Id::new("pages_edge"); - let resp = ui.interact(line.expand2(vec2(0.0, grab)), id, Sense::click_and_drag()); + let id = Id::unique("pages_edge"); + // Not focusable, like the side panels' edges: Tab/F6 must not stop on an unnamed drag handle. + let resp = ui.interact(line.expand2(vec2(0.0, grab)), id, Sense::CLICK | Sense::DRAG); let active = resp.hovered() || resp.dragged(); if active { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); @@ -406,12 +407,12 @@ fn pages_edge(app: &mut FigApp, ui: &mut Ui, shown: f32) { /// A page rename just asked for by a double click: its field takes the focus when drawn (temp data). fn page_rename_start_id() -> Id { - Id::new("page_rename_start") + Id::unique("page_rename_start") } /// The layer row being dragged in the panel (temp data). fn layer_drag_id() -> Id { - Id::new("layer_drag") + Id::unique("layer_drag") } /// A press on a row's eye or padlock dragged across other rows: every row passed gets the state the @@ -425,7 +426,7 @@ struct Sweep { } fn sweep_id() -> Id { - Id::new("layer_toggle_sweep") + Id::unique("layer_toggle_sweep") } /// What a drag of row `dragging` moves: the selection when the row is in it, else the row. @@ -470,7 +471,7 @@ fn hash_expanded(expanded: &BTreeSet) -> u64 { pub(crate) fn rows_for(ui: &Ui, doc: &Arc, page: NodeId, expanded: &BTreeSet, filter: &str) -> Arc> { let ex = if filter.is_empty() { hash_expanded(expanded) } else { 0 }; ui.data_mut(|d| { - let c = d.get_temp_mut_or_default::(Id::new("layer_rows")); + let c = d.get_temp_mut_or_default::(Id::unique("layer_rows")); let fresh = c.doc.as_ref().is_some_and(|d| Arc::ptr_eq(d, doc)) && c.page == Some(page) && c.expanded == ex && c.filter == filter; if !fresh { let rows = listed_rows(doc, page, expanded, filter); @@ -635,7 +636,7 @@ fn rename_layer(app: &mut FigApp, id: NodeId) { } fn rename_id(id: NodeId) -> Id { - Id::new(("rename", id)) + Id::unique(("rename", id)) } /// Left edge of a row's contents (disclosure triangle) in the row's inner rect `rr`. @@ -685,7 +686,7 @@ fn search_field(ui: &mut Ui, t: &Tokens, w: f32, id: Id, hint: &str, text: &mut .frame(egui::Frame::NONE) .font(regular(11.0)) .hint_text(hint) - .vertical_align(egui::Align::Center) + .align(egui::Align2::LEFT_CENTER) .desired_width(te_rect.width()), ); // After the field: the click that opened it ends elsewhere, which would take the focus back. @@ -708,12 +709,12 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { let (hr, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover()); ui.painter().text(pos2(hr.min.x + 16.0, hr.center().y), Align2::LEFT_CENTER, tr("Layers"), semibold(11.0), t.text); let sr = Rect::from_center_size(pos2(hr.max.x - 22.0, hr.center().y), vec2(24.0, 24.0)); - let sresp = ui.interact(sr, ui.id().with("layer_search_btn"), Sense::click()).tip(tr("Search layers")); + let sresp = ui.interact(sr, ui.scope_id().with("layer_search_btn"), Sense::click()).tip(tr("Search layers")); if sresp.hovered() || app.ui.layer_search { ui.painter().rect_filled(sr, CornerRadius::same(5), t.hover); } icons::paint(ui.painter(), sr.shrink(5.0), Icon::Search, t.icon); - let search_id = Id::new("layer_search_field"); + let search_id = Id::unique("layer_search_field"); if sresp.clicked() { app.ui.layer_search = !app.ui.layer_search; app.ui.layer_filter.clear(); @@ -865,7 +866,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { // Expand triangle. if row.has_kids { let tri = Rect::from_center_size(pos2(x0 + 6.0, r.center().y), vec2(14.0, 14.0)); - let tresp = ui.interact(tri, ui.id().with(("tri", row.id)), Sense::click()); + let tresp = ui.interact(tri, ui.scope_id().with(("tri", row.id)), Sense::click()); let open = app.ui.expanded.contains(&row.id); a11y::button(&tresp, if open { tr("Collapse") } else { tr("Expand") }); icons::paint(ui.painter(), tri.shrink(1.0), if open { Icon::Chevron } else { Icon::ChevronRight }, t.icon_secondary); @@ -928,7 +929,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { let er = Rect::from_center_size(pos2(rr.max.x - 14.0, r.center().y), vec2(20.0, 20.0)); let lr = Rect::from_center_size(pos2(rr.max.x - 36.0, r.center().y), vec2(20.0, 20.0)); if show_eye { - let eresp = ui.interact(er, ui.id().with(("eye", row.id)), Sense::click_and_drag()); + let eresp = ui.interact(er, ui.scope_id().with(("eye", row.id)), Sense::click_and_drag()); a11y::toggle(&eresp, tr("Hidden"), hidden); icons::paint(ui.painter(), er.shrink(3.0), if hidden { Icon::EyeOff } else { Icon::Eye }, t.icon_secondary); if eresp.clicked() { @@ -939,7 +940,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { } } if show_lock { - let lresp = ui.interact(lr, ui.id().with(("lock", row.id)), Sense::click_and_drag()); + let lresp = ui.interact(lr, ui.scope_id().with(("lock", row.id)), Sense::click_and_drag()); a11y::toggle(&lresp, tr("Locked"), locked || row.locked_above); let col = if locked || !row.locked_above { t.icon_secondary } else { t.text_tertiary }; icons::paint(ui.painter(), lr.shrink(3.0), if locked || row.locked_above { Icon::Lock } else { Icon::Unlock }, col); @@ -995,7 +996,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { ui.painter().rect_stroke(rr, CornerRadius::same(5), Stroke::new(2.0, ACCENT), egui::StrokeKind::Inside); // Holding over a collapsed group or frame opens it, to drop deeper inside. if row.has_kids && !app.ui.expanded.contains(&row.id) { - let hold_id = Id::new("layer_drag_hold"); + let hold_id = Id::unique("layer_drag_hold"); let now = ui.input(|i| i.time); match ui.data(|d| d.get_temp::<(NodeId, f64)>(hold_id)) { Some((id, since)) if id == row.id => { @@ -1030,7 +1031,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { // Empty space below clears selection. let rest = Rect::from_x_y_ranges(band.x_range(), (top + total)..=(top + total.max(viewport.max.y))); // A click target for the pointer only (no stop for the keyboard). - if rest.height() > 0.0 && ui.interact(rest, Id::new("layers_empty_space"), Sense::CLICK).clicked() { + if rest.height() > 0.0 && ui.interact(rest, Id::unique("layers_empty_space"), Sense::CLICK).clicked() { app.run("select.none", json!({})); } }); @@ -1063,7 +1064,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { if ui.input(|i| i.pointer.any_released()) { ui.data_mut(|d| { d.remove::(drag_id); - d.remove::<(NodeId, f64)>(Id::new("layer_drag_hold")); + d.remove::<(NodeId, f64)>(Id::unique("layer_drag_hold")); }); if let (Some(_), Some((target, zone))) = (dragging, drop) { let ids = moving.clone(); diff --git a/crates/ui-egui/src/layers_behaviour_tests.rs b/crates/ui-egui/src/layers_behaviour_tests.rs index 4d987e66..e801ad00 100644 --- a/crates/ui-egui/src/layers_behaviour_tests.rs +++ b/crates/ui-egui/src/layers_behaviour_tests.rs @@ -330,6 +330,7 @@ fn a_long_pages_list_scrolls_and_leaves_room_for_the_layers() { // The wheel over the pages list scrolls it to its last page. let wheel = Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, delta: egui::vec2(0.0, -400.0), phase: egui::TouchPhase::Move, modifiers: Default::default(), @@ -394,10 +395,10 @@ fn double_clicking_a_page_renames_it_with_the_focus_in_the_screen_reader_tree() double_click_checking_focus(&mut u, r2); assert_eq!(u.app.session.page().unwrap(), p2); assert_eq!(u.app.ui.rename_page.as_ref().map(|r| r.0), Some(p2), "a double click renames the page"); - assert!(u.ctx.memory(|m| m.has_focus(Id::new(("pagename", p2)).with("te"))), "with the keyboard in its name"); + assert!(u.ctx.memory(|m| m.has_focus(Id::unique(("pagename", p2)).with("te"))), "with the keyboard in its name"); // The page that's open. u.app.ui.rename_page = None; - u.ctx.memory_mut(|m| m.surrender_focus(Id::new(("pagename", p2)).with("te"))); + u.ctx.memory_mut(|m| m.surrender_focus(Id::unique(("pagename", p2)).with("te"))); u.app.session.execute("page.select", &json!({"id": p1})).unwrap(); for _ in 0..30 { u.frame(vec![]); diff --git a/crates/ui-egui/src/layers_drag_tests.rs b/crates/ui-egui/src/layers_drag_tests.rs index 37002f9f..37949c6d 100644 --- a/crates/ui-egui/src/layers_drag_tests.rs +++ b/crates/ui-egui/src/layers_drag_tests.rs @@ -43,6 +43,7 @@ impl Ui { /// screen and clock. pub(crate) fn frame_raw(&mut self, raw: RawInput) -> egui::FullOutput { self.time += 1.0 / 60.0; + self.app.clock.set_test_now(Some(self.time)); let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), ..raw }; let app = &mut self.app; let mut out = self.ctx.run_ui(input, |ui| app.ui(ui)); @@ -50,6 +51,32 @@ impl Ui { out } + /// One frame that egui reruns as a discarded pass (the returned output is the presented pass). + /// + /// The rerun sees the same `RawInput`; the custom text editor must not apply its events twice, + /// and the IME descriptor of the presented pass must still describe the first pass's result. + pub(crate) fn frame_raw_with_discard(&mut self, raw: RawInput) -> egui::FullOutput { + self.time += 1.0 / 60.0; + self.app.clock.set_test_now(Some(self.time)); + let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), ..raw }; + let Ui { ctx, app, .. } = self; + let mut out = ctx.run_ui(input, |ui| { + app.ui(ui); + if ui.ctx().current_pass_index() == 0 { + ui.ctx().request_discard("ime test: exercise a discarded pass"); + } + }); + out.textures_delta.clear(); + out + } + + /// Move the harness time (and the app's monotonic clock) forward, e.g. past a deadline; the + /// next frame continues from here. + pub(crate) fn advance_clock(&mut self, secs: f64) { + self.time = secs; + self.app.clock.set_test_now(Some(secs)); + } + /// The middle of `id`'s row in the layers panel (found by hovering down the panel). pub(crate) fn row_at(&mut self, id: NodeId) -> Pos2 { let mut hits = Vec::new(); diff --git a/crates/ui-egui/src/layers_rename_tests.rs b/crates/ui-egui/src/layers_rename_tests.rs index c4b9211c..6ffa9696 100644 --- a/crates/ui-egui/src/layers_rename_tests.rs +++ b/crates/ui-egui/src/layers_rename_tests.rs @@ -158,6 +158,7 @@ fn a_rename_scrolled_out_of_view_goes_on() { let p = egui::pos2(150.0, 400.0); let wheel = Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, delta: egui::vec2(0.0, -400.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE, diff --git a/crates/ui-egui/src/layout_panel.rs b/crates/ui-egui/src/layout_panel.rs index b5ff1d45..eef37755 100644 --- a/crates/ui-egui/src/layout_panel.rs +++ b/crates/ui-egui/src/layout_panel.rs @@ -156,9 +156,9 @@ fn dimensions(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], has let col = widgets::col_width(ui.available_width()); widgets::sublabels(ui, tr("Dimensions"), None); row(ui, 32.0, |ui| { - let e = crate::variable_ui::number_field_mixed(app, ui, &ids, "width", Id::new("lay_w"), Lead::Text("W"), w, "", col); + let e = crate::variable_ui::number_field_mixed(app, ui, &ids, "width", Id::unique("lay_w"), Lead::Text("W"), w, "", col); app.field_of(&ids, "width", e, w_of, |v| json!(v.max(0.0))); - let e = crate::variable_ui::number_field_mixed(app, ui, &ids, "height", Id::new("lay_h"), Lead::Text("H"), h, "", col); + let e = crate::variable_ui::number_field_mixed(app, ui, &ids, "height", Id::unique("lay_h"), Lead::Text("H"), h, "", col); app.field_of(&ids, "height", e, h_of, |v| json!(v.max(0.0))); let locked = common(doc, sel, |n| n.constrain_proportions).unwrap_or(false); let link = ui @@ -259,7 +259,7 @@ fn limit_row(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], hori let (lead, add) = if is_min { (tr(axis.min_lead), tr(axis.add_min)) } else { (tr(axis.max_lead), tr(axis.add_max)) }; if has(is_min) { let v = common(doc, sel, |n| limit(n, horizontal, is_min).map(|v| (v * 100.0).round() / 100.0)).flatten(); - let e = crate::variable_ui::number_field(app, ui, &ids, key, Id::new(("lay_limit", key)), Lead::Text(lead), v, "", col); + let e = crate::variable_ui::number_field(app, ui, &ids, key, Id::unique(("lay_limit", key)), Lead::Text(lead), v, "", col); app.field(&ids, key, e, |v| json!(v.max(0.0))); } else if add_slot(ui, lead, add, col).clicked() { app.set_props(&ids, json!({key: current})); @@ -309,7 +309,7 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { for xi in 0..3 { let c = pos2(grid.min.x + 12.0 + xi as f32 * 24.0, grid.min.y + 10.0 + yi as f32 * 22.0); let r = Rect::from_center_size(c, vec2(22.0, 20.0)); - let resp = ui.interact(r, Id::new(("al_grid", xi, yi)), Sense::click()); + let resp = ui.interact(r, Id::unique(("al_grid", xi, yi)), Sense::click()); let (main_i, cross_i) = if horizontal { (xi, yi) } else { (yi, xi) }; let on = (cur_main == main_i || cur_main == 9) && (cur_cross == cross_i || cur_cross == 9); if on { @@ -338,7 +338,7 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { ui, ids, "columnGap", - Id::new("al_gap"), + Id::unique("al_gap"), Lead::Icon(Icon::Gap, tr("Column gap")), Some(l.gap), "", @@ -351,7 +351,7 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { ui, ids, "rowGap", - Id::new("al_rowgap"), + Id::unique("al_rowgap"), Lead::Icon(Icon::DistributeV, tr("Row gap")), Some(l.cross_gap), "", @@ -363,7 +363,7 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { let auto = l.main_align.is_auto_gap(); let gap_icon = if l.mode == LayoutMode::Vertical { Icon::DistributeV } else { Icon::Gap }; let (shown, lead) = ((!auto).then_some(l.gap), Lead::Icon(gap_icon, tr("Gap between items"))); - let e = crate::variable_ui::number_field_or(app, ui, ids, "gap", Id::new("al_gap"), shown, 96.0, tr("Auto"), lead, ""); + let e = crate::variable_ui::number_field_or(app, ui, ids, "gap", Id::unique("al_gap"), shown, 96.0, tr("Auto"), lead, ""); app.field(ids, "gap", e, |v| json!(v)); ui.add_space(8.0); let label = match l.main_align { @@ -406,10 +406,10 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { let col = widgets::col_width(ui.available_width()); let [pt, pr, pb, pl] = l.padding; - let open = ui.data(|d| d.get_temp::(Id::new("indep_padding"))).unwrap_or(false); + let open = ui.data(|d| d.get_temp::(Id::unique("indep_padding"))).unwrap_or(false); let toggle = |ui: &mut Ui| { if icons::button(ui, Icon::IndependentPadding, 24.0, open, tr("Individual padding")).clicked() { - ui.data_mut(|d| d.insert_temp(Id::new("indep_padding"), !open)); + ui.data_mut(|d| d.insert_temp(Id::unique("indep_padding"), !open)); } }; if open { @@ -424,7 +424,8 @@ fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { for (r, pair) in sides.chunks(2).enumerate() { row(ui, 32.0, |ui| { for (key, name, icon, v, k) in pair { - let e = crate::variable_ui::number_field(app, ui, ids, key, Id::new(("padding", *k)), Lead::Icon(*icon, name), Some(*v), "", col); + let e = + crate::variable_ui::number_field(app, ui, ids, key, Id::unique(("padding", *k)), Lead::Icon(*icon, name), Some(*v), "", col); app.field(ids, key, e, |v| json!(v.max(0.0))); } if r == 0 { @@ -439,7 +440,7 @@ fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { ui, ids, "paddingHorizontal", - Id::new("al_padh"), + Id::unique("al_padh"), Lead::Icon(Icon::PaddingH, tr("Horizontal padding")), (pl == pr).then_some(pl), "", @@ -451,7 +452,7 @@ fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { ui, ids, "paddingVertical", - Id::new("al_padv"), + Id::unique("al_padv"), Lead::Icon(Icon::PaddingV, tr("Vertical padding")), (pt == pb).then_some(pt), "", @@ -475,7 +476,7 @@ fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { ui, ids, "crossGap", - Id::new("al_crossgap"), + Id::unique("al_crossgap"), shown, col, tr("Auto"), @@ -500,9 +501,9 @@ fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { if l.mode == LayoutMode::Grid { widgets::sublabels(ui, tr("Columns"), Some(tr("Rows"))); row(ui, 32.0, |ui| { - let e = widgets::number_field(ui, Id::new("al_cols"), Lead::None, Some(f64::from(l.grid_columns)), "", col, 1.0); + let e = widgets::number_field(ui, Id::unique("al_cols"), Lead::None, Some(f64::from(l.grid_columns)), "", col, 1.0); app.field(ids, "gridColumns", e, |v| json!(v.max(1.0))); - let e = widgets::number_field(ui, Id::new("al_rows"), Lead::None, Some(f64::from(l.grid_rows)), "", col, 1.0); + let e = widgets::number_field(ui, Id::unique("al_rows"), Lead::None, Some(f64::from(l.grid_rows)), "", col, 1.0); app.field(ids, "gridRows", e, |v| json!(v.max(1.0))); }); } @@ -571,7 +572,7 @@ fn tracks(app: &mut FigApp, ui: &mut Ui, doc: &Document, ids: &[NodeId]) { let (value, suffix) = if track.sizing == Sizing::Fill { (track.value, "fr") } else { (size, "") }; let e = widgets::number_field( ui, - Id::new(("al_track", axis, i)), + Id::unique(("al_track", axis, i)), Lead::Text(&label), Some(value), suffix, @@ -614,9 +615,9 @@ fn grid_cell(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { // Moving on one axis pins the other where it is, so the child doesn't jump. let at = |c: f64, r: f64| json!({"ids": [id], "props": {"gridColumn": (c - 1.0).max(0.0), "gridRow": (r - 1.0).max(0.0)}}); let (c0, r0) = ((cell.column + 1) as f64, (cell.row + 1) as f64); - let e = widgets::number_field(ui, Id::new("al_cell_col"), Lead::None, Some(c0), "", col, 1.0); + let e = widgets::number_field(ui, Id::unique("al_cell_col"), Lead::None, Some(c0), "", col, 1.0); app.field_run(e, "node.set", |v| at(v, r0)); - let e = widgets::number_field(ui, Id::new("al_cell_row"), Lead::None, Some(r0), "", col, 1.0); + let e = widgets::number_field(ui, Id::unique("al_cell_row"), Lead::None, Some(r0), "", col, 1.0); app.field_run(e, "node.set", |v| at(c0, v)); if anchored && icons::button(ui, Icon::Reset, 24.0, false, tr("Place automatically")).clicked() { app.set_props(&ids, json!({"gridColumn": null, "gridRow": null})); @@ -627,9 +628,9 @@ fn grid_cell(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let rs = common(doc, sel, |n| f64::from(n.child_layout.row_span())); widgets::sublabels(ui, tr("Column span"), Some(tr("Row span"))); row(ui, 32.0, |ui| { - let e = widgets::number_field(ui, Id::new("al_span_c"), Lead::None, cs, "", col, 1.0); + let e = widgets::number_field(ui, Id::unique("al_span_c"), Lead::None, cs, "", col, 1.0); app.field(&ids, "gridColumnSpan", e, |v| json!(v.max(1.0))); - let e = widgets::number_field(ui, Id::new("al_span_r"), Lead::None, rs, "", col, 1.0); + let e = widgets::number_field(ui, Id::unique("al_span_r"), Lead::None, rs, "", col, 1.0); app.field(&ids, "gridRowSpan", e, |v| json!(v.max(1.0))); }); // Alignment in the cell; Auto follows the frame's alignment. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 11fc307a..2108ace6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -90,12 +90,18 @@ mod canvas_fixes_tests; #[cfg(test)] mod canvas_image_rect_tests; #[cfg(test)] +mod clipboard_fence_tests; +#[cfg(test)] +mod colorpicker_eyedropper_tests; +#[cfg(test)] mod colorpicker_selection_tests; #[cfg(test)] mod comments_tests; #[cfg(test)] mod component_props_reorder_tests; #[cfg(test)] +mod corner_individual_tests; +#[cfg(test)] mod cursors_tests; #[cfg(test)] mod design_paste_tests; @@ -156,6 +162,8 @@ mod measure_nudge_tests; #[cfg(test)] mod menu_aim_tests; #[cfg(test)] +mod menu_contrast_tests; +#[cfg(test)] mod menu_input_tests; #[cfg(test)] mod missing_fonts_tests; @@ -188,6 +196,8 @@ mod popup_width_tests; #[cfg(test)] mod present_links_tests; #[cfg(test)] +mod present_tab_tests; +#[cfg(test)] mod present_tests; #[cfg(test)] mod present_view_tests; @@ -233,6 +243,7 @@ mod windows_qa_tests; mod windows_tests; use std::collections::BTreeSet; +use std::ffi::OsStr; use std::future::Future; use std::path::{Path, PathBuf}; use std::pin::Pin; @@ -466,6 +477,34 @@ pub(crate) fn repaint_in(ctx: &egui::Context, secs: f64) { } } +/// A monotonic clock for background work (autosave, recovery, capture deadlines). +/// +/// `Context::input(|i| i.time)` is the UI pass's time and is stale in `run_logic`; wall-clock time +/// can jump. `web_time::Instant` is monotonic on native and in the browser. Tests drive deadlines +/// directly with [`FrameClock::set_test_now`]. +pub struct FrameClock { + origin: web_time::Instant, + test_now: Option, +} + +impl Default for FrameClock { + fn default() -> Self { + Self { origin: web_time::Instant::now(), test_now: None } + } +} + +impl FrameClock { + /// Seconds since the app started (or the injected test time). + pub fn now(&self) -> f64 { + self.test_now.unwrap_or_else(|| self.origin.elapsed().as_secs_f64()) + } + + /// Set a fake monotonic time (tests); `None` returns to the real clock. + pub fn set_test_now(&mut self, secs: Option) { + self.test_now = secs; + } +} + /// The side panels' widths: the least and most a drag of their edge gives, what a double-click on /// the edge puts back, and how much of the window they leave the canvas. pub const LEFT_PANEL: std::ops::RangeInclusive = 200.0..=480.0; @@ -550,10 +589,10 @@ pub struct UiState { /// A message at the bottom of the window and how long it has been showing. pub toast: Option<(String, ToastClock)>, pub present: Option, - /// A presentation kept alive while its design tab or Home is in front. - pub paused_present: Option, - /// The file that owns the presentation tab. - pub present_uid: Option, + pub presents: Vec, + /// The tab shown last. It is on screen while `present` has a value. + pub present_active: Option, + pub present_next: u64, /// The full-window presenter's options (how it sizes the prototype), kept between /// presentations. pub present_view: present::PresentView, @@ -632,19 +671,28 @@ pub struct UiState { /// What FigCraft last put on the system clipboard: when the clipboard still holds it, a paste /// uses our copied layers; anything else there (an image, SVG, text) came from another app. + /// `None` also means a programmatic copy changed the engine clipboard without writing the OS + /// clipboard, so the system text is no longer known to be ours. pub last_copy: Option, + /// The engine clipboard's layers generation when `last_copy` was mirrored. A programmatic + /// copy changes the engine clipboard without writing the OS clipboard; the generation then + /// no longer matches, so the stale mirror is not mistaken for the user's own copy. + pub last_copy_generation: u64, /// Since the last V key release: whether its press arrived, and whether a paste event did /// (`shortcuts::swallowed_paste`). pub v_pressed: bool, pub paste_event: bool, /// The window got the focus back since the last V release. pub refocused: bool, + /// A save shortcut pressed while a panel field had the keyboard: the field lets go (which + /// commits what was typed) and the command runs next frame for that document/window. + pub after_field: Option, /// The modifiers held, as of the last modifier change in the raw events: a paste event doesn't /// carry them. pub held: egui::Modifiers, - /// When (UI clock, seconds) open documents next get an autosave checkpoint. + /// When (monotonic [`FrameClock`], seconds) open documents next get an autosave checkpoint. pub next_autosave: f64, - /// With auto-save on: when (UI clock, seconds) files with unsaved changes are next saved, and + /// With auto-save on: when (monotonic [`FrameClock`], seconds) files with unsaved changes are next saved, and /// the revisions of those files it waits on (a change restarts the wait). pub auto_save_at: Option<(f64, u64)>, /// Files being encoded for auto-save on threads of their own (tab `uid`). @@ -665,6 +713,12 @@ pub struct UiState { pub online: online::OnlineUi, /// The window was asked to close with unsaved changes: the question shows. pub confirm_close: bool, + /// The file a Save as started from the unsaved-changes question is saving: the window closes + /// once it is saved, and stays open if the dialog is cancelled or the save fails. + pub close_after_save_as: Option, + /// Save was answered: the files save next frame, once a panel field the click took the + /// keyboard from has committed what was typed (as [`UiState::after_field`]). + pub save_on_close: bool, } impl Default for UiState { @@ -707,8 +761,9 @@ impl Default for UiState { icon_gallery: false, toast: None, present: None, - paused_present: None, - present_uid: None, + presents: Vec::new(), + present_active: None, + present_next: 1, present_view: present::PresentView::default(), preview: None, picker: None, @@ -754,9 +809,11 @@ impl Default for UiState { dev: dev_mode::DevUi::default(), last_copy: None, + last_copy_generation: 0, v_pressed: false, paste_event: false, refocused: false, + after_field: None, held: egui::Modifiers::NONE, next_autosave: 0.0, auto_save_at: None, @@ -769,10 +826,20 @@ impl Default for UiState { live: live::LiveUi::default(), online: online::OnlineUi::default(), confirm_close: false, + close_after_save_as: None, + save_on_close: false, } } } +/// A control reply waiting for the pass that presents an injected input batch. The viewport keeps +/// one window's batch from acknowledging (or erroring) another window's request. +pub(crate) struct PendingAck { + pub(crate) viewport: egui::ViewportId, + pub(crate) tx: std::sync::mpsc::Sender, + pub(crate) reply: ControlReply, +} + /// The whole FigCraft UI. pub struct FigApp { pub session: Session, @@ -811,6 +878,37 @@ pub struct FigApp { /// The automation roots the app was started with: every frame and control request runs /// confined to them ([`windows::guard`]). pub automation_roots: Option>, + /// The active-document id when synthetic input was queued: a batch is dropped when the file + /// changed before a UI pass applied it. + pub(crate) control_input_doc: Option, + /// This frame applies events injected through the control channel: no system clipboard + /// read or write may happen for them. + pub(crate) control_synthetic: bool, + /// `ui.input` replies waiting for the pass that actually presents the injected batch (a + /// discarded sizing pass must not acknowledge it). + pub(crate) input_acks: Vec, + /// Replies whose batch this frame took into its raw input: sent only if the pass is presented. + pub(crate) applied_acks: Vec, + /// The batch taken into this frame's raw input, kept so a discarded pass gives it back. + pub(crate) applied_input: Option>, + /// `handle("ui.input")` queued raw events whose acknowledgement waits for the applied pass + /// (controller-only input is applied at once and needs no fence). + pub(crate) control_input_queued: bool, + /// The viewport whose batch this frame took into its raw input (`applied_input`/`applied_acks`). + pub(crate) applied_frame: Option, + /// Monotonic time for background work and capture deadlines. + pub(crate) clock: FrameClock, + /// Screenshot requests admitted, settling and awaiting readback. + pub(crate) capture: control::CaptureState, + /// [`Self::background_tick`] ran for this pass (so [`Self::ui`] does not repeat it). + logic_ticked: bool, + /// The last frame whose UI setup ran (multi-pass frames set up once). + ui_frame_stamp: Option<(egui::ViewportId, u64)>, + /// IME owner/generation/change-cause state for the on-canvas text editor, per viewport. + pub(crate) ime: egui::ime_context::ImeContextTracker, + /// Test-only count of the one IME observation each viewport pass makes. + #[cfg(test)] + pub(crate) ime_observations: u32, } /// What to do with the result of a command run in the background. @@ -842,6 +940,20 @@ impl FigApp { pending_dialog: None, ui_context: None, automation_roots: None, + control_input_doc: None, + control_synthetic: false, + input_acks: Vec::new(), + applied_acks: Vec::new(), + applied_input: None, + control_input_queued: false, + applied_frame: None, + clock: FrameClock::default(), + capture: control::CaptureState::default(), + logic_ticked: false, + ui_frame_stamp: None, + ime: egui::ime_context::ImeContextTracker::new(), + #[cfg(test)] + ime_observations: 0, } } @@ -854,12 +966,34 @@ impl FigApp { /// Run a command, surfacing errors as a toast. Returns the result. pub fn run(&mut self, id: &str, params: Value) -> Option { + let layers_generation = self.session.clipboard.layers_generation; let r = self.session.execute(id, ¶ms); self.clear_comment_draft_after_tool_set(id, r.is_ok()); self.handle_ui_requests(); + self.invalidate_synthetic_copy(layers_generation); + // A command may change the file the window shows (`doc.switch`, `doc.nextTab`, + // `file.open`/`file.close`). Record that presentation before returning, so an in-flight + // screenshot admitted on the old file cannot accept pixels from the new one. + self.observe_presentation(); r.map_err(|e| self.failed(id, &e)).ok() } + /// A programmatic command that changed the engine clipboard never wrote the system clipboard: + /// forget the old system mirror so a later user paste does not trust it as our own copy. + /// `before` is the layers generation captured before the command ran. + pub(crate) fn invalidate_synthetic_copy(&mut self, before: u64) { + if self.control_synthetic && self.session.clipboard.layers_generation != before { + self.ui.last_copy = None; + } + } + + /// Record the file the window drawing now shows, with a monotonic generation that changes on + /// every switch (an A→B→A switch is two generations, never the admitted one again). + pub(crate) fn observe_presentation(&mut self) { + let window = self.windows.current; + self.windows.presentation(&self.session, window); + } + /// [`run`](Self::run) for something the user did (a dialog, a drop, a paste, a recent file): /// a file it imports shows its import report. Programmatic calls use `run` and never open one. pub fn run_user(&mut self, id: &str, params: Value) -> Option { @@ -982,6 +1116,83 @@ impl FigApp { } } + /// Finish the frame that applies an injected batch: acknowledge it once the pass is presented, + /// and stop fencing the system clipboard. Only the viewport whose batch this frame took acts. + pub(crate) fn end_input_frame(&mut self, ctx: &egui::Context) { + if self.applied_frame != Some(ctx.viewport_id()) { + return; + } + self.applied_input = None; + if ctx.will_discard() { + // Keep `applied_frame`/`applied_acks`: the rerun pass must present (and acknowledge) + // the same frame without replaying the input. + return; + } + self.applied_frame = None; + for ack in self.applied_acks.drain(..) { + let _ = ack.tx.send(ack.reply); + } + self.control_synthetic = false; + } + + /// The app's monotonic clock in seconds, also usable from logic-only callbacks (recovery, + /// control deadlines). Never `ctx.input(i.time)`, which is stale there. + pub fn now_secs(&self) -> f64 { + self.clock.now() + } + + /// Queue raw input injected through the control channel for the window drawing next, tagged + /// with the document it belongs to (a batch is dropped if another file is active by then). + pub(crate) fn push_control_input(&mut self, events: Vec) -> bool { + if events.is_empty() { + return false; + } + let doc = self.session.docs.get(self.session.active).map(|d| d.uid); + if !self.control_input.is_empty() && self.control_input_doc != doc { + // A queued batch belongs to the file that was open when it was injected; it cannot be + // merged with one for the file showing now. Its acknowledgement says so. + self.control_input.clear(); + let viewport = crate::windows::viewport(self.windows.current); + crate::windows::fail_input_acks(self, viewport); + } + // Always the live pane vector: while a child window draws, `windows::enter` has swapped + // its pane into the app, and `leave` swaps these events back into that pane. + self.control_input.extend(events); + self.control_input_doc = doc; + self.control_synthetic = true; + true + } + + /// The acknowledgements waiting for `viewport`'s input batch (other windows keep theirs). + pub(crate) fn take_input_acks(&mut self, viewport: egui::ViewportId) -> Vec { + let mut mine = Vec::new(); + for ack in std::mem::take(&mut self.input_acks) { + if ack.viewport == viewport { + mine.push(ack); + } else { + self.input_acks.push(ack); + } + } + mine + } + + /// Put `text` on the system clipboard. Input injected through the control channel never + /// replaces what the user copied: this is the one write path every copy command goes through. + pub(crate) fn clipboard_set_text(&mut self, text: &str) { + if self.control_synthetic { + return; + } + self.services.clipboard_set_text(text); + } + + /// [`Self::clipboard_set_text`] for an image (straight RGBA). + pub(crate) fn clipboard_set_png(&mut self, width: u32, height: u32, rgba: &[u8]) { + if self.control_synthetic { + return; + } + self.services.clipboard_set_png(width, height, rgba); + } + pub fn toast(&mut self, msg: impl Into) { self.ui.toast = Some((msg.into(), ToastClock::Raised)); } @@ -1063,12 +1274,7 @@ impl FigApp { "dialog.colorProfile" => self.ui.color_profile = self.session.doc().ok().map(|d| d.color_profile), "present" => { if let Some(start) = r.data.get("start").and_then(figcraft_engine::params::id_of) { - let mut p = present::Presenter::new(start); - p.view = self.ui.present_view; - self.ui.present = Some(p); - self.ui.paused_present = None; - self.ui.present_uid = self.session.docs.get(self.session.active).map(|d| d.uid); - self.ui.home = false; + tabs::open_present(self, start); } } "present.options" => { @@ -1152,8 +1358,10 @@ impl FigApp { fn panel_edge(ui: &mut egui::Ui, name: &str, x: f32, rows: egui::Rangef, width: f32, grows: f32) -> Option { let grab = ui.style().interaction.resize_grab_radius_side; let rect = egui::Rect::from_x_y_ranges(x - grab..=x + grab, rows); - let id = egui::Id::new(("panel_edge", name)); - let resp = ui.interact(rect, id, egui::Sense::click_and_drag()); + let id = egui::Id::unique(("panel_edge", name)); + // Not focusable: an edge is a pointer target, so it must not take Tab/F6 focus from the + // panels' controls (see `a11y::focusables`). + let resp = ui.interact(rect, id, egui::Sense::CLICK | egui::Sense::DRAG); if resp.hovered() || resp.dragged() { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); ui.painter().vline(x, rows, egui::Stroke::new(2.0, theme::ACCENT)); @@ -1175,7 +1383,7 @@ impl FigApp { pub fn save_as_dialog(&mut self) { let Ok(name) = self.session.doc().map(|d| d.name.clone()) else { return }; self.pick_save(i18n::tr("Save as"), &format!("{name}.figcraft"), &["figcraft", "fig"], |app, mut p| { - if p.extension().is_none_or(|e| e.is_empty()) { + if p.extension().is_none_or(OsStr::is_empty) { if !p.set_extension("figcraft") { app.toast(i18n::tr("Use the .figcraft or .fig extension.")); return; @@ -1192,7 +1400,7 @@ impl FigApp { return; } } - } else if !p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("figcraft") || e.eq_ignore_ascii_case("fig")) { + } else if !p.extension().and_then(OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("figcraft") || e.eq_ignore_ascii_case("fig")) { app.toast(i18n::tr("Use the .figcraft or .fig extension.")); return; } @@ -1211,7 +1419,7 @@ impl FigApp { .docs .get(tab) .and_then(|d| d.path.as_deref()) - .is_some_and(|path| !Path::new(path).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("fig"))); + .is_some_and(|path| !Path::new(path).extension().and_then(OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("fig"))); if native_path { if self.run("file.save", json!({})).is_some() { self.toast(i18n::tr("Saved")); @@ -1395,18 +1603,55 @@ impl FigApp { need } - /// Per-frame logic that doesn't draw (control requests, theme, language). - pub fn logic(&mut self, ctx: &egui::Context) { + /// Work safe from a logic-only callback (`App::logic`, which also runs while hidden) and from + /// a normal UI pass: consumed control work, live polling, background completions, autosave and + /// recovery deadlines. It never reads UI input, so it must not scan events, run shortcuts or + /// lay out anything. + pub fn background_tick(&mut self, ctx: &egui::Context) { + control::background(self, ctx); + // What happened in a live session (the network woke us for it). + if self.session.live_poll() { + if let Some(m) = self.session.message.take() { + self.toast(live::failure_text(&m)); + } + self.background_done(ctx); + } + // Files that arrived without a command (joined, recovered) take the kept View toggles too. + self.session.apply_view_prefs(); + let now = self.clock.now(); + self.autosave_tick(ctx, now); + self.auto_save_tick(ctx, now); + self.logic_ticked = true; + } + + /// UI-phase setup that needs a real current `Ui`: fonts/language/theme, interface zoom and the + /// render worker. Runs once per frame (pass 0) for the main window, and once per frame for + /// every child window (`windows::draw`), never on a logic-only tick. + fn ui_setup(&mut self, ctx: &egui::Context) { + self.viewport_setup(ctx); + } + + /// The app-global part of [`Self::ui_setup`] that every window's frame needs: language, fonts, + /// theme and `ui.dark`, the render worker, and the interface zoom plan. eframe only calls + /// `App::ui` for the root window, so a child must run this itself before it draws; a child that + /// only runs while the root is minimised still gets theme switches, CJK faces for its own + /// document, and the right `ui.dark` for its tabs and dialogs. + pub(crate) fn viewport_setup(&mut self, ctx: &egui::Context) { if self.language.as_ref() != Some(&self.session.prefs.language) { i18n::set(&self.session.prefs.language, self.services.system_language().as_deref()); self.language = Some(self.session.prefs.language.clone()); } + // Screen-reader and bidi language of the UI (fork E17). + ctx.set_language(i18n::current().map(|l| l.code.to_string())); if !self.fonts_installed { // Cmd/Ctrl+= / - / 0 zoom the canvas (view.zoomIn, view.zoomOut, view.zoom100): egui's // own keyboard zoom would scale the whole interface on the same keys. ctx.options_mut(|o| o.zoom_with_keyboard = false); theme::install_fonts(ctx, false, 0); self.fonts_installed = true; + // Installed fonts apply from the next frame; a child window draws on here, so ask for + // that frame (the root draws nothing on the frame it installs them). + ctx.request_repaint(); } else if figcraft_text::system_fonts_scanned() { // Once the system fonts are indexed: faces for names in scripts the UI fonts lack, and // for each CJK script once something shows it. @@ -1421,12 +1666,15 @@ impl FigApp { } // egui's zoom is for the whole app and multiplies each window's own system scale, so the // size is the same relative to every screen; the main window, whose size can hold a size - // back, sets it. The browser keeps its own zoom. - if ctx.viewport_id() == egui::ViewportId::ROOT { + // back, sets it. The browser keeps its own zoom. Every window records its own plan (the + // Preferences warning and `ui.inspect` read it), from its own size. + { let window_px = ctx.content_rect().size() * ctx.pixels_per_point(); let size = if cfg!(target_arch = "wasm32") { 1.0 } else { self.session.prefs.ui_scale }; let plan = ui_scale::plan(size, ctx.native_pixels_per_point(), Some((window_px.x, window_px.y))); - ctx.set_zoom_factor(plan.zoom); + if ctx.viewport_id() == egui::ViewportId::ROOT { + ctx.set_zoom_factor(plan.zoom); + } self.ui.ui_scale = plan; } let dark = match self.session.prefs.theme.as_str() { @@ -1447,8 +1695,7 @@ impl FigApp { /// Every [`AUTOSAVE_SECS`], each open document with unsaved changes since its last version /// gets an autosave checkpoint in its version history. - fn autosave_tick(&mut self, ctx: &egui::Context) { - let now = ctx.input(|i| i.time); + fn autosave_tick(&mut self, ctx: &egui::Context, now: f64) { if now < self.ui.next_autosave { // Wake up for it even if nothing else draws a frame by then. repaint_in(ctx, self.ui.next_autosave - now); @@ -1469,8 +1716,7 @@ impl FigApp { /// With auto-save on, files that have a path are saved [`AUTO_SAVE_SECS`] after they last /// changed (not during a gesture, a live preview or while a version is shown). The file is /// encoded on a thread of its own, so a big one doesn't hold up the window, and written here. - fn auto_save_tick(&mut self, ctx: &egui::Context) { - let now = ctx.input(|i| i.time); + fn auto_save_tick(&mut self, ctx: &egui::Context, now: f64) { let mut failed = self.auto_save_done(); // The browser has no files to save to. let on = !cfg!(target_arch = "wasm32") && self.session.prefs.auto_save; @@ -1545,25 +1791,36 @@ impl FigApp { let t0 = std::time::Instant::now(); let ctx = ui.ctx().clone(); self.ui_context = Some(ctx.clone()); - self.frame_stats.record(&ctx); + // `App::logic` already ticked this pass; direct harnesses do not call it. The marker only + // covers the tick that shares this pass: clearing it here keeps a direct `App::ui` harness + // ticking on every frame (it used to tick every other frame). + if !std::mem::take(&mut self.logic_ticked) { + self.background_tick(&ctx); + self.logic_ticked = false; + } + // Setup once per frame (pass 0), not once per pass and never on a logic-only tick. + let stamp = (ctx.viewport_id(), ctx.cumulative_frame_nr()); let first = !self.fonts_installed; - self.logic(&ctx); - self.autosave_tick(&ctx); - self.auto_save_tick(&ctx); - if first { - // New fonts apply from the next frame; draw nothing until then. + if ctx.current_pass_index() == 0 && self.ui_frame_stamp != Some(stamp) { + self.ui_frame_stamp = Some(stamp); + self.frame_stats.record(&ctx); + self.ui_setup(&ctx); + } + if first && self.fonts_installed { + // New fonts apply from the next frame; draw nothing until then. Anything taken into + // this frame's (undrawn) input waits for the first drawn frame. ctx.request_repaint(); + if let Some(events) = self.applied_input.take() { + self.push_control_input(events); + } + self.input_acks.append(&mut self.applied_acks); + self.applied_frame = None; + self.control_synthetic = false; + // The next (drawn) frame ticks for itself in a direct harness. + self.logic_ticked = false; + self.observe_ime(&ctx, false); return; } - // What happened in a live session (the network woke us for it). - if self.session.live_poll() { - if let Some(m) = self.session.message.take() { - self.toast(live::failure_text(&m)); - } - self.background_done(&ctx); - } - // Files that arrived without a command (joined, recovered) take the kept View toggles too. - self.session.apply_view_prefs(); windows::begin(self, &ctx); let (files, prefs) = (self.session.docs.len(), self.session.prefs.clone()); self.frame(ui); @@ -1572,6 +1829,10 @@ impl FigApp { windows::repaint_others(self, &ctx); } windows::show(self, &ctx); + // The injected batch was applied by the frame that took it. A discarded sizing pass keeps + // its acknowledgement for the presented pass but never replays the input (its side effects + // already happened and would otherwise duplicate). + self.end_input_frame(&ctx); // Plugins installed, removed or set up are kept at once: a crash doesn't lose them. // So are the owner tokens of files put online. if std::mem::take(&mut self.session.plugins.unsaved) | std::mem::take(&mut self.session.live.unsaved) { @@ -1596,11 +1857,17 @@ impl FigApp { /// One frame of the window drawing now. pub(crate) fn frame(&mut self, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); + // Bind this input batch before controls or pointer input can replace its composing + // target/caret. Outgoing IME receipts run after mutation and cannot authorize it. + let ime_input = shortcuts::ImeInputBaseline::capture(self); let t = theme::Tokens::get(&ctx); // A field that had the keyboard when the frame began keeps its keys from the shortcuts // even if it lets go during the frame (Enter commits it), or as it began (egui drops the // focus on Escape before the frame, which cancels the field's edit and nothing else). let typing = std::mem::take(&mut self.field_focus) || a11y::field_focused(&ctx); + // Bind the originating field/file before tabs can switch. Its widget commits only after + // processing this batch; delivery remains deferred until the following frame. + let field_save = shortcuts::prepare_field_save(self, &ctx); let swallowed = shortcuts::swallowed_paste(self, &ctx); // A dialog that closed acts first, in the window it was opened from. self.poll_dialog(&ctx); @@ -1609,7 +1876,7 @@ impl FigApp { windows::requests(self, &ctx); selection_spacing::sync(self, &ctx); // Before any widget: the navigation keys (F6, Tab, arrows, Escape) move the keyboard. - a11y::begin(self, &ctx); + a11y::begin(self, &ctx, ui.scope_id()); if self.ui.title_bar == TitleBar::Custom { tabs::resize_edges(&ctx); } @@ -1620,7 +1887,11 @@ impl FigApp { widgets::set_nudge(&ctx, self.session.prefs.nudge_small, self.session.prefs.nudge_big); self.sync_dev_mode(); self.end_pen_outside_design(); + tabs::relink_presents(self); tabs::tab_bar(self, ui); + // Control/window work and this pass's physical tab click may have replaced the owner. + // A shared field ID in that replacement must never consume the old commit marker. + let field_save = shortcuts::revalidate_field_save(self, &ctx, field_save); if self.ui.present.is_some() { // A close request can arrive while the presenter fills the window. Let its unsaved // changes dialog take Escape and draw above the player just as it does in the editor. @@ -1630,6 +1901,8 @@ impl FigApp { // What went wrong while presenting (a widget's plugin failing) shows over it. self.toast_ui(&ctx); windows::requests(self, &ctx); + shortcuts::clear_field_save_marker(&ctx); + self.observe_ime(&ctx, false); return; } if !self.has_files() || self.ui.home { @@ -1641,6 +1914,8 @@ impl FigApp { dialogs::show(self, &ctx); self.toast_ui(&ctx); windows::requests(self, &ctx); + shortcuts::clear_field_save_marker(&ctx); + self.observe_ime(&ctx, false); return; } let hidden = self.session.ds().map(|d| d.view.ui_hidden).unwrap_or(false); @@ -1725,7 +2000,7 @@ impl FigApp { branch_ui::keys(self, &ctx); live::dialog_keys(self, &ctx); dialogs::keys(self, &ctx); - shortcuts::handle(self, &ctx, typing, swallowed); + shortcuts::handle_input(self, &ctx, typing, swallowed, ime_input, field_save); menus::popups(self, &ctx); variable_ui::picker(self, &ctx); style_picker::show(self, &ctx); @@ -1745,6 +2020,28 @@ impl FigApp { // changed), from the window with its file. let windows = &self.windows; self.session.live_sync_in(|uid| uid.map_or(0, |u| windows.of(u)) == windows.current); + // The canvas editor's IME context is observed exactly once per pass, after every command + // this pass ran (shortcuts, menus, dialogs, window requests and live sync). A focused panel + // field or an open palette owns the platform IME instead; the early `home`/present paths + // above make the same single idle observation. + self.observe_ime(&ctx, !typing && !self.field_focus && self.ui.palette.is_none()); + } + + /// The pass's one IME observation: the focused on-canvas editor, or an explicit idle. + /// + /// Calling `ImeContextTracker::observe` more than once in a pass would treat an idle followed + /// by a focused observation as a context change (dropping the frame's receipts) and bump the + /// generation. Keeping this in one place makes the fork contract structural. + fn observe_ime(&mut self, ctx: &egui::Context, focused: bool) { + #[cfg(test)] + { + self.ime_observations = self.ime_observations.saturating_add(1); + } + if focused { + shortcuts::ime_output(self, ctx); + } else { + let _ = self.ime.observe(ctx.viewport_id(), None); + } } fn toast_ui(&mut self, ctx: &egui::Context) { @@ -1775,7 +2072,7 @@ impl FigApp { repaint_in(ctx, left); } let screen = ctx.content_rect(); - egui::Area::new(egui::Id::new("toast")) + egui::Area::new(egui::Id::unique("toast")) .fixed_pos(egui::pos2(screen.center().x, screen.max.y - 96.0)) .pivot(egui::Align2::CENTER_BOTTOM) .order(egui::Order::Tooltip) @@ -1807,7 +2104,7 @@ impl FigApp { "focus": {"region": self.ui.nav.region.id(), "keyboard": self.ui.nav.keyboard}, "reduceMotion": self.reduce_motion(), "presenting": self.ui.present.is_some(), - "presenter": self.ui.present.as_ref().or(self.ui.paused_present.as_ref()).map(present::Presenter::inspect), + "presenter": tabs::shown_or_paused(self).map(present::Presenter::inspect), "presentOptions": {"scale": self.ui.present_view.scale.id(), "deviceFrame": self.ui.present_view.device_frame, "comments": self.ui.present_view.comments, "hotspotHints": self.ui.present_view.hotspot_hints, "inspect": self.ui.present_view.inspect}, "preview": self.ui.preview.as_ref().map(|p| p.player.inspect()), "mainMenu": self.ui.main_menu, @@ -1861,6 +2158,7 @@ impl FigApp { state["uiScale"] = json!({"choice": self.session.prefs.ui_scale, "zoom": self.ui.ui_scale.zoom, "drawn": self.ui.ui_scale.drawn, "limited": self.ui.ui_scale.limited}); state["pendingOpens"] = json!(self.ui.pending_opens.len()); state["missingFonts"] = json!(self.ui.missing_fonts.as_ref().map(missing_fonts::Shown::inspect)); + state["presentTabs"] = tabs::present_tabs_inspect(self); state } } @@ -1870,13 +2168,13 @@ pub(crate) const OPEN_EXTENSIONS: &[&str] = &["figcraft", "fig", "json", "svg", /// Whether `path` names a file type that opens as a document (the extension, in any case). pub(crate) fn opens(path: &Path) -> bool { - path.extension().and_then(|e| e.to_str()).is_some_and(|e| OPEN_EXTENSIONS.iter().any(|x| x.eq_ignore_ascii_case(e))) + path.extension().and_then(OsStr::to_str).is_some_and(|e| OPEN_EXTENSIONS.iter().any(|x| x.eq_ignore_ascii_case(e))) } /// Whether `path` is a whole document (`.figcraft`, `.fig`, file JSON) rather than an image or SVG: /// dropped on the canvas it opens as a file of its own instead of adding its pages to the open one. pub(crate) fn is_document(path: &Path) -> bool { - path.extension().and_then(|e| e.to_str()).is_some_and(|e| ["figcraft", "fig", "json"].iter().any(|x| x.eq_ignore_ascii_case(e))) + path.extension().and_then(OsStr::to_str).is_some_and(|e| ["figcraft", "fig", "json"].iter().any(|x| x.eq_ignore_ascii_case(e))) } /// Reads the contents of dropped file `name` that has no file on disk to read by path. @@ -1904,7 +2202,7 @@ pub(crate) fn drop_overlay(ctx: &egui::Context, rect: egui::Rect, label: &str) { return; } let t = theme::Tokens::get(ctx); - let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("drop_overlay"))); + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("drop_overlay"))); let area = rect.shrink(4.0); painter.rect_filled(area, egui::CornerRadius::same(8), t.accent.gamma_multiply(0.12)); painter.rect_stroke(area, egui::CornerRadius::same(8), egui::Stroke::new(2.0, theme::ACCENT), egui::StrokeKind::Inside); @@ -1920,5 +2218,8 @@ pub(crate) fn drop_overlay(ctx: &egui::Context, rect: egui::Rect, label: &str) { #[cfg(test)] mod selection_spacing_tests; +#[cfg(test)] +mod type_settings_tests; + #[cfg(test)] mod typography_scrub_tests; diff --git a/crates/ui-egui/src/list_drag.rs b/crates/ui-egui/src/list_drag.rs index b701a15a..08a391ae 100644 --- a/crates/ui-egui/src/list_drag.rs +++ b/crates/ui-egui/src/list_drag.rs @@ -6,15 +6,15 @@ //! lists bottom first; other lists (an interaction's actions, pages) show their items in model //! order. -use egui::{AsId, Id, Rect, Sense, Ui, pos2, vec2}; +use egui::{AsIdSalt, Id, Rect, Sense, Ui, pos2, vec2}; use crate::icons::{self, Icon}; use crate::theme::{Tokens, regular}; use crate::widgets::{self, PAD}; /// The id of list `list` (any hashable key, e.g. `"fills"`). -fn list_id(list: impl AsId) -> Id { - Id::new(("list_drag", list)) +fn list_id(list: impl AsIdSalt) -> Id { + Id::unique(("list_drag", list)) } /// The row of list `list` being dragged (its model index; temp data). @@ -24,7 +24,7 @@ fn drag_id(list: Id) -> Id { /// The grip of item `index` of list `list` (tests find rows by it). #[cfg(test)] -pub(crate) fn grip_id(list: impl AsId, index: usize) -> Id { +pub(crate) fn grip_id(list: impl AsIdSalt, index: usize) -> Id { grip_of(list_id(list), index) } @@ -49,12 +49,12 @@ pub struct ListDrag { impl ListDrag { /// A list shown topmost item (the model's last) first, as the Design panel shows paints. - pub fn new(ui: &Ui, list: impl AsId) -> Self { + pub fn new(ui: &Ui, list: impl AsIdSalt) -> Self { Self::with_order(ui, list_id(list), true) } /// A list shown in model order, first item on top (an interaction's actions). - pub fn in_order(ui: &Ui, list: impl AsId) -> Self { + pub fn in_order(ui: &Ui, list: impl AsIdSalt) -> Self { Self::with_order(ui, list_id(list), false) } @@ -164,34 +164,34 @@ fn drop_slot(rows: &[(usize, Rect)], from: usize, p: egui::Pos2, reversed: bool) Some((if reversed { n - 1 - at } else { at }, y)) } -/// Keep per-row flags (`Id::new((salt, index))`, e.g. an open settings section) with their rows +/// Keep per-row flags (`Id::unique((salt, index))`, e.g. an open settings section) with their rows /// when item `from` of a `len`-long list moves to `to`. pub fn move_flags(ui: &Ui, salt: &'static str, len: usize, from: usize, to: usize) { ui.data_mut(|d| { - let flags: Vec> = (0..len).map(|i| d.get_temp::(Id::new((salt, i)))).collect(); + let flags: Vec> = (0..len).map(|i| d.get_temp::(Id::unique((salt, i)))).collect(); for (i, f) in flags.into_iter().enumerate() { let j = figcraft_engine::cmd::lists::moved_index(i, from, to); match f { Some(v) => { - d.insert_temp(Id::new((salt, j)), v); + d.insert_temp(Id::unique((salt, j)), v); } - None => d.remove::(Id::new((salt, j))), + None => d.remove::(Id::unique((salt, j))), } } }); } -/// Keep per-row flags (`Id::new((salt, index))`) with their rows when item `removed` of a +/// Keep per-row flags (`Id::unique((salt, index))`) with their rows when item `removed` of a /// `len`-long list goes; its own flag goes with it. pub fn remove_flags(ui: &Ui, salt: &'static str, len: usize, removed: usize) { ui.data_mut(|d| { - let flags: Vec> = (0..len).map(|i| d.get_temp::(Id::new((salt, i)))).collect(); + let flags: Vec> = (0..len).map(|i| d.get_temp::(Id::unique((salt, i)))).collect(); for i in 0..len { - d.remove::(Id::new((salt, i))); + d.remove::(Id::unique((salt, i))); } for (i, f) in flags.into_iter().enumerate() { if let (Some(j), Some(v)) = (figcraft_engine::cmd::lists::removed_index(i, removed), f) { - d.insert_temp(Id::new((salt, j)), v); + d.insert_temp(Id::unique((salt, j)), v); } } }); diff --git a/crates/ui-egui/src/live.rs b/crates/ui-egui/src/live.rs index a4baa303..7a1af420 100644 --- a/crates/ui-egui/src/live.rs +++ b/crates/ui-egui/src/live.rs @@ -354,7 +354,7 @@ pub fn chat_box(app: &mut FigApp, ctx: &egui::Context) { let _ = app.session.live_chat(""); return; } - let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("live_chat"))); + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("live_chat"))); chat_bubble(&painter, tip, None, &chat.text, fill, alpha); app.ui.live.chat = Some(chat); return; @@ -366,7 +366,7 @@ pub fn chat_box(app: &mut FigApp, ctx: &egui::Context) { } ctx.request_repaint_after(std::time::Duration::from_secs_f64(CHAT_HOLD + CHAT_FADE - idle)); let fg = text_on(fill); - let resp = egui::Area::new(egui::Id::new("live_chat_box")) + let resp = egui::Area::new(egui::Id::unique("live_chat_box")) .order(egui::Order::Foreground) .fixed_pos(tip + vec2(12.0, 16.0)) .show(ctx, |ui| { @@ -378,7 +378,7 @@ pub fn chat_box(app: &mut FigApp, ctx: &egui::Context) { let w = ui.painter().layout_no_wrap(chat.text.clone(), regular(12.0), fg).size().x + 8.0; ui.add( egui::TextEdit::singleline(&mut chat.text) - .id(egui::Id::new("live_chat_text")) + .id(egui::Id::unique("live_chat_text")) .frame(egui::Frame::NONE) .font(regular(12.0)) .text_color(fg) @@ -425,7 +425,7 @@ pub fn spotlight_banner(app: &mut FigApp, ctx: &egui::Context, canvas: Rect) { return; }; let t = Tokens::get(ctx); - let clicked = widgets::canvas_banner(ctx, egui::Id::new("live_spotlight"), canvas, |ui| { + let clicked = widgets::canvas_banner(ctx, egui::Id::unique("live_spotlight"), canvas, |ui| { ui.label(egui::RichText::new(text).font(regular(11.0)).color(t.text)); widgets::button(ui, button, true, true).clicked() }); @@ -473,7 +473,7 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { let mut spotlight = false; if in_session { let c = pos2(x, cy); - let resp = ui.interact(Rect::from_center_size(c, vec2(2.0 * R, 2.0 * R)), ui.id().with("live_me"), Sense::click()); + let resp = ui.interact(Rect::from_center_size(c, vec2(2.0 * R, 2.0 * R)), ui.scope_id().with("live_me"), Sense::click()); a11y::menu_button(&resp, tr("You"), a11y::menu_open(&resp)); if spotlighting || resp.hovered() { ui.painter().circle_stroke(c, R + 2.5, Stroke::new(2.0, me_fill)); @@ -491,7 +491,7 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { let c = pos2(x, cy); let following = follow == Some(*id); let tip = if following { trf("Stop following {name}", &[("name", name)]) } else { trf("Follow {name}", &[("name", name)]) }; - let resp = ui.interact(Rect::from_center_size(c, vec2(2.0 * R, 2.0 * R)), ui.id().with(("live_avatar", id)), Sense::click()).tip(tip); + let resp = ui.interact(Rect::from_center_size(c, vec2(2.0 * R, 2.0 * R)), ui.scope_id().with(("live_avatar", id)), Sense::click()).tip(tip); ring(ui, c); avatar(ui.painter(), c, R, name, *fill); if following || resp.hovered() { @@ -517,7 +517,7 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { ui.painter().text(c, Align2::CENTER_CENTER, format!("+{more}"), semibold(10.0), t.text); } let chev = Rect::from_center_size(pos2(x + R + 10.0, cy), vec2(16.0, 24.0)); - let cresp = ui.interact(chev, ui.id().with("live_people"), Sense::click()); + let cresp = ui.interact(chev, ui.scope_id().with("live_people"), Sense::click()); a11y::menu_button(&cresp, tr("People in this file"), a11y::menu_open(&cresp)); if cresp.hovered() { ui.painter().rect_filled(chev, CornerRadius::same(4), t.hover); @@ -665,7 +665,7 @@ fn online_note(app: &mut FigApp, ui: &mut Ui, t: &Tokens, server: &str) { /// home screen is closed. The session of the join that started, if one did. pub fn join_row(app: &mut FigApp, ui: &mut Ui, width: f32) -> Option { let mut typed = std::mem::take(&mut app.ui.live.code); - let joined = ui.horizontal(|ui| join_field(app, ui, &mut typed, egui::Id::new("live_join_code"), width, false)).inner; + let joined = ui.horizontal(|ui| join_field(app, ui, &mut typed, egui::Id::unique("live_join_code"), width, false)).inner; app.ui.live.code = typed; joined } @@ -714,7 +714,7 @@ fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { ui.horizontal(|ui| { widgets::label(ui, tr("Your name"), t.text); let name = app.session.prefs.user_name.clone(); - if let Some(n) = widgets::text_field(ui, egui::Id::new("live_name"), &name, 160.0, tr("Your name")) { + if let Some(n) = widgets::text_field(ui, egui::Id::unique("live_name"), &name, 160.0, tr("Your name")) { app.run("pref.userName", json!({"name": n})); } }); @@ -755,7 +755,7 @@ fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { } ui.horizontal(|ui| { widgets::label(ui, tr("Port"), t.text); - if let Some(port) = widgets::text_field_named(ui, egui::Id::new("live_port"), &app.ui.live.port, 70.0, tr("Port")) { + if let Some(port) = widgets::text_field_named(ui, egui::Id::unique("live_port"), &app.ui.live.port, 70.0, tr("Port")) { // Digits only; empty: the usual port, or any free one. app.ui.live.port = port.trim().parse::().map(|p| p.to_string()).unwrap_or_default(); } @@ -885,11 +885,11 @@ fn share_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens, share: &Share) { }); ui.horizontal(|ui| { if widgets::button(ui, tr("Copy code"), true, true).clicked() { - app.services.clipboard_set_text(&code); + app.clipboard_set_text(&code); app.toast(tr("Join code copied")); } if widgets::button(ui, tr("Copy link"), false, true).on_hover_text(tr("An invite link that opens FigCraft and joins")).clicked() { - app.services.clipboard_set_text(&link); + app.clipboard_set_text(&link); app.toast(tr("Invite link copied")); } }); @@ -1007,7 +1007,7 @@ fn report_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { .hint_text(tr("Spam, stolen work, abuse…")) .char_limit(figcraft_engine::live::MAX_REPORT) .desired_width(ui.available_width()) - .id(egui::Id::new("live_report_reason")), + .id(egui::Id::unique("live_report_reason")), ); a11y::text(&resp, tr("Report"), &reason); crate::widgets::select_all_on_focus(ui, &resp, &reason); @@ -1042,7 +1042,7 @@ fn link_rows(app: &mut FigApp, ui: &mut Ui, rows: &[(&str, &String, &str)]) { ui.label(egui::RichText::new(widgets::elide(ui, value, w)).font(regular(10.5)).monospace()); }); if widgets::button(ui, tr("Copy"), false, true).on_hover_text(trf("Copy the {label}", &[("label", &label.to_lowercase())])).clicked() { - app.services.clipboard_set_text(value); + app.clipboard_set_text(value); app.toast(*what); } }); @@ -1085,11 +1085,11 @@ pub fn join_dialog(app: &mut FigApp, ctx: &egui::Context) { let mut cancel = false; let mut go = false; let focus = std::mem::take(&mut app.ui.live.focus_join); - crate::dialogs::window(tr("Join live session"), ctx).id(egui::Id::new("Join live session")).open(&mut open).show(ctx, |ui| { + crate::dialogs::window(tr("Join live session"), ctx).id(egui::Id::unique("Join live session")).open(&mut open).show(ctx, |ui| { ui.set_width(380.0); widgets::label(ui, tr("Type the join code you were given, or paste an invite link."), t.text); ui.horizontal(|ui| { - go = join_field(app, ui, &mut link, egui::Id::new("live_join_link"), 300.0, focus).is_some(); + go = join_field(app, ui, &mut link, egui::Id::unique("live_join_link"), 300.0, focus).is_some(); cancel = widgets::button(ui, tr("Cancel"), false, true).clicked(); }); widgets::label(ui, tr("You'll get the shared file as a new tab and edit it together."), t.text_secondary); @@ -1158,7 +1158,7 @@ pub fn publish_dialog(app: &mut FigApp, ctx: &egui::Context) { let open_server = info.as_ref().is_some_and(|v| v["open"] == true); // A first look at an address already filled in. ask |= focus && !address.is_empty() && !keyed && info.is_none(); - crate::dialogs::window(tr("Publish to server"), ctx).id(egui::Id::new("Publish to server")).open(&mut open).show(ctx, |ui| { + crate::dialogs::window(tr("Publish to server"), ctx).id(egui::Id::unique("Publish to server")).open(&mut open).show(ctx, |ui| { ui.set_width(380.0); widgets::label( ui, @@ -1170,7 +1170,7 @@ pub fn publish_dialog(app: &mut FigApp, ctx: &egui::Context) { egui::TextEdit::singleline(&mut form.server) .hint_text("wss://server/ or ws://server:47900/?key=…") .desired_width(380.0) - .id(egui::Id::new("live_publish_server")), + .id(egui::Id::unique("live_publish_server")), ); // The address may carry the server key: it isn't read out. a11y::text(&resp, tr("Server address"), ""); @@ -1202,7 +1202,7 @@ pub fn publish_dialog(app: &mut FigApp, ctx: &egui::Context) { ), } widgets::label(ui, tr("File name"), t.text_secondary); - let nresp = ui.add(egui::TextEdit::singleline(&mut form.name).desired_width(380.0).id(egui::Id::new("live_publish_name"))); + let nresp = ui.add(egui::TextEdit::singleline(&mut form.name).desired_width(380.0).id(egui::Id::unique("live_publish_name"))); a11y::text(&nresp, tr("File name"), &form.name); crate::widgets::select_all_on_focus(ui, &nresp, &form.name); go |= nresp.lost_focus() && enter(ui) && (keyed || open_server); @@ -1249,7 +1249,7 @@ fn published_dialog(app: &mut FigApp, ctx: &egui::Context) { let t = Tokens::get(ctx); let mut open = true; let mut done = false; - crate::dialogs::window(tr("File published"), ctx).id(egui::Id::new("File published")).open(&mut open).show(ctx, |ui| { + crate::dialogs::window(tr("File published"), ctx).id(egui::Id::unique("File published")).open(&mut open).show(ctx, |ui| { ui.set_width(400.0); ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap); widgets::label(ui, &trf("{name} is on the server.", &[("name", &p.name)]), t.text); diff --git a/crates/ui-egui/src/live_join_tests.rs b/crates/ui-egui/src/live_join_tests.rs index 61567ce0..81eddc76 100644 --- a/crates/ui-egui/src/live_join_tests.rs +++ b/crates/ui-egui/src/live_join_tests.rs @@ -15,7 +15,7 @@ fn escape(h: &mut Harness) { } fn link_focused(h: &Harness) -> bool { - h.ctx.memory(|m| m.has_focus(egui::Id::new("live_join_link"))) + h.ctx.memory(|m| m.has_focus(egui::Id::unique("live_join_link"))) } #[test] diff --git a/crates/ui-egui/src/live_server_tests.rs b/crates/ui-egui/src/live_server_tests.rs index 4a92edf0..0bf1e631 100644 --- a/crates/ui-egui/src/live_server_tests.rs +++ b/crates/ui-egui/src/live_server_tests.rs @@ -21,7 +21,7 @@ fn key(h: &mut Harness, key: Key) { } fn focused(h: &Harness, id: &str) -> bool { - h.ctx.memory(|m| m.has_focus(egui::Id::new(id))) + h.ctx.memory(|m| m.has_focus(egui::Id::unique(id))) } /// Joined through a server's link with `access`: the welcome and the document (a new tab). diff --git a/crates/ui-egui/src/live_share_tests.rs b/crates/ui-egui/src/live_share_tests.rs index 66750cac..06d902bd 100644 --- a/crates/ui-egui/src/live_share_tests.rs +++ b/crates/ui-egui/src/live_share_tests.rs @@ -391,7 +391,7 @@ fn the_join_field_takes_a_code_or_a_link_and_enter_joins() { let shapes = open(&mut h); // Click into the field (its hint), type a code the way people type it, press Enter. click(&mut h, &shapes, "Code or invite link"); - assert!(h.ctx.memory(|m| m.has_focus(egui::Id::new("live_join_code")))); + assert!(h.ctx.memory(|m| m.has_focus(egui::Id::unique("live_join_code")))); type_text(&mut h, " k7m 2qx-9td "); assert_eq!(crate::live::inspect(&h.app)["joinField"], " k7m 2qx-9td "); key(&mut h, Key::Enter); @@ -456,7 +456,7 @@ fn the_join_dialog_is_the_same_field() { crate::menus::invoke(&mut h.app, "collab.join"); let shapes = h.frames(2); assert!(has_text(&shapes, "Type the join code you were given, or paste an invite link.") && has_text(&shapes, "Code or invite link")); - assert!(h.ctx.memory(|m| m.has_focus(egui::Id::new("live_join_link"))), "the field has the keyboard"); + assert!(h.ctx.memory(|m| m.has_focus(egui::Id::unique("live_join_link"))), "the field has the keyboard"); type_text(&mut h, "4fh j8r 2wn"); key(&mut h, Key::Enter); h.frames(1); diff --git a/crates/ui-egui/src/live_tests.rs b/crates/ui-egui/src/live_tests.rs index 331316b8..eaff7354 100644 --- a/crates/ui-egui/src/live_tests.rs +++ b/crates/ui-egui/src/live_tests.rs @@ -317,6 +317,7 @@ fn clicking_an_avatar_follows_and_escape_stops() { h.frame(vec![Event::PointerMoved(pos2(700.0, 500.0))]); h.frame(vec![Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, delta: vec2(0.0, -40.0), phase: egui::TouchPhase::Move, modifiers: Default::default(), diff --git a/crates/ui-egui/src/menu_aim_tests.rs b/crates/ui-egui/src/menu_aim_tests.rs index 5e2fe076..ed167e12 100644 --- a/crates/ui-egui/src/menu_aim_tests.rs +++ b/crates/ui-egui/src/menu_aim_tests.rs @@ -88,7 +88,7 @@ impl Ui { const TOPS: [&str; 6] = ["File", "Edit", "View", "Object", "Text", "Arrange"]; fn top(name: &str) -> Id { - Id::new(("menu", name)) + Id::unique(("menu", name)) } /// The submenus of top-level menu `menu` (their keys), from the menu catalog. @@ -152,7 +152,7 @@ fn a_submenu_one_level_down_reopens_closed() { u.settle(u.top_row(0).center()); let keys = submenu_keys("File"); assert!(!keys.is_empty(), "File has submenus"); - let sub_shown = |u: &Ui| keys.iter().find(|k| u.shown(Id::new(("submenu", k.as_str())))).cloned(); + let sub_shown = |u: &Ui| keys.iter().find(|k| u.shown(Id::unique(("submenu", k.as_str())))).cloned(); // Down the middle of File's panel until one of its submenus opens. let panel = u.rect(top("File")); let mut y = panel.min.y + 10.0; diff --git a/crates/ui-egui/src/menu_contrast_tests.rs b/crates/ui-egui/src/menu_contrast_tests.rs new file mode 100644 index 00000000..f3a9b1bd --- /dev/null +++ b/crates/ui-egui/src/menu_contrast_tests.rs @@ -0,0 +1,84 @@ +//! Panel dropdowns (the prototype device picker, Preferences, blend modes…) draw on the theme's +//! popup fill, so their rows' text must read on it in both themes (#972): drawn headless. + +use egui::{Color32, Event, Order, PointerButton, Pos2, RawInput, Rect, Shape, pos2, vec2}; + +use crate::right::{menu_check, menu_item, menu_popup}; +use crate::theme::{self, Tokens}; + +fn input(t: &mut f64, events: Vec) -> RawInput { + *t += 1.0 / 60.0; + RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), time: Some(*t), events, ..Default::default() } +} + +fn press(p: Pos2, pressed: bool) -> Vec { + vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Default::default() }] +} + +fn color(x: Color32) -> figcraft_color::Color { + figcraft_color::Color::new(f32::from(x.r()) / 255.0, f32::from(x.g()) / 255.0, f32::from(x.b()) / 255.0, 1.0) +} + +/// The text painted on the foreground (menu) layers: (text, colour). +fn menu_texts(out: &egui::FullOutput) -> Vec<(String, Color32)> { + fn walk(s: &Shape, out: &mut Vec<(String, Color32)>) { + match s { + Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), + Shape::Text(t) => out.push((t.galley.text().to_string(), t.fallback_color)), + _ => {} + } + } + let mut texts = Vec::new(); + for c in &out.shapes { + walk(&c.shape, &mut texts); + } + texts +} + +/// Open a dropdown with `tokens` and return its rows' text colours, with the pointer away from them. +fn rows(tokens: Tokens) -> Vec<(String, Color32)> { + let ctx = egui::Context::default(); + theme::apply(&ctx, tokens); + let mut t = 0.0; + let mut frame = |events: Vec| { + let mut out = ctx.run_ui(input(&mut t, events), |ui| { + let (_, resp) = ui.allocate_exact_size(vec2(200.0, 24.0), egui::Sense::click()); + menu_popup(&resp).show(|ui| { + menu_check(ui, true, "iPhone 17 Pro Max", ""); + menu_check(ui, false, "Pixel 9", ""); + menu_item(ui, "Copy", "Cmd+C"); + }); + }); + out.textures_delta.clear(); + out + }; + let at = pos2(12.0, 12.0); + frame(vec![]); + frame(press(at, true)); + frame(press(at, false)); + // Past the popup's fade-in, so the colours are the ones it settles on. + for _ in 0..30 { + frame(vec![Event::PointerMoved(pos2(790.0, 590.0))]); + } + let out = frame(vec![]); + assert!(ctx.memory(|m| m.areas().visible_layer_ids().iter().any(|l| l.order == Order::Foreground)), "the menu opened"); + menu_texts(&out) +} + +#[test] +fn dropdown_rows_read_on_the_popup_in_both_themes() { + for tokens in [Tokens::light(), Tokens::dark()] { + // The popup's own fill (`window_fill`), what the rows are drawn on. + let fill = color(tokens.panel); + let texts = rows(tokens); + let keys = crate::toolbar::pretty_shortcut("Cmd+C"); + // Labels at WCAG AA for text; the dimmer shortcut at least 3:1. + for (label, min) in [("iPhone 17 Pro Max", 4.5), ("Pixel 9", 4.5), ("Copy", 4.5), (keys.as_str(), 3.0)] { + let Some((_, col)) = texts.iter().find(|(s, _)| s.trim() == label || s.ends_with(label)) else { + panic!("{label} painted (dark {}): {texts:?}", tokens.dark) + }; + let ratio = color(*col).contrast(fill); + assert!(ratio >= min, "{label} at {ratio:.2}:1 on the popup (dark {}): {texts:?}", tokens.dark); + } + } +} diff --git a/crates/ui-egui/src/menu_input_tests.rs b/crates/ui-egui/src/menu_input_tests.rs index f317b65b..a50173bc 100644 --- a/crates/ui-egui/src/menu_input_tests.rs +++ b/crates/ui-egui/src/menu_input_tests.rs @@ -79,7 +79,7 @@ fn rename_file_edits_the_file_name_in_place() { ui.frame(vec![]); ui.frame(vec![]); assert!(!ui.app.ui.rename_file, "the rename started"); - assert!(ui.ctx.memory(|m| m.has_focus(egui::Id::new("filename").with("te"))), "the file name's editor has the keyboard"); + assert!(ui.ctx.memory(|m| m.has_focus(egui::Id::unique("filename").with("te"))), "the file name's editor has the keyboard"); ui.frame(vec![egui::Event::Text("Brand kit".into())]); ui.frame(vec![egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }]); assert_eq!(ui.app.session.doc().unwrap().name, "Brand kit"); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c48fa401..e2067657 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -37,6 +37,16 @@ pub const UI_COMMANDS: &[&str] = &[ /// middle of the view. /// In Dev Mode only a comment's link pastes (it opens the thread); everything else would edit. pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>) { + // A gesture injected through the control channel pastes only our own copied layers: it must not + // read (and so expose) the user's system clipboard. + if app.control_synthetic { + // Synthetic input is user-like: Dev Mode refuses the paste here too, and only the engine's + // own copied layers are pasted (the system clipboard is never read). + if !dev_mode_refuses(app, "edit.paste") { + app.run("edit.paste", at.map_or(json!({}), |(x, y)| json!({"x": x, "y": y}))); + } + return; + } // Only HTML that holds readable design data takes this path; other HTML pastes as before. if let Some(html) = app.services.clipboard_get_html().filter(|h| figcraft_engine::cmd::edit::design_clipboard_buffer(h).is_some()) { if dev_mode_refuses(app, "edit.paste") { @@ -50,6 +60,7 @@ pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>) if let Some(r) = app.run("edit.pasteDesignLayers", params) { // The pasted layers are now our copy: text another app puts on the clipboard later is newer. app.ui.last_copy = Some(app.services.clipboard_get_text().unwrap_or_default()); + app.ui.last_copy_generation = app.session.clipboard.layers_generation; let left_out = r["warnings"].as_array().map_or(0, Vec::len); if left_out > 0 { app.toast(trn("Pasted design layers; {n} kinds of content were left out", left_out, &[])); @@ -58,7 +69,11 @@ pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>) return; } let sys_text = app.services.clipboard_get_text().or_else(|| event_text.map(str::to_string)).filter(|t| !t.is_empty()); - let ours = sys_text.is_some() && sys_text == app.ui.last_copy; + // The text matching `last_copy` means the system clipboard still holds what we mirrored only + // while the engine clipboard is the one we mirrored. A control-channel copy changes the engine + // clipboard without writing the system clipboard, so its generation no longer matches and the + // user's own text is pasted instead of the programmatic selection. + let ours = sys_text.is_some() && sys_text == app.ui.last_copy && app.ui.last_copy_generation == app.session.clipboard.layers_generation; let (cx, cy) = at.or_else(|| app.session.ds().ok().map(|d| (d.view.cx, d.view.cy))).unwrap_or((0.0, 0.0)); if !ours { if let Some((png, w, h)) = clipboard_png(app) { @@ -72,8 +87,10 @@ pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>) app.run("file.import", json!({"data": b64, "name": "Pasted image.png", "x": x, "y": y})); return; } - // Text from elsewhere when it's newer than our copy, or when we have nothing to paste. - if let Some(text) = sys_text.filter(|_| app.ui.last_copy.is_some() || app.session.clipboard.nodes.is_empty()) { + // Not ours: any other text is newer (another app's text, SVG, a comment link) and wins. + // Our own marker for copied layers without a text/SVG mirror is never a text paste; when + // the engine copy moved on it falls through to `edit.paste` (the only layers we still have). + if let Some(text) = sys_text.filter(|t| !is_clipboard_marker(t)) { return paste_text(app, &text, (cx, cy), at.is_none()); } } @@ -82,6 +99,11 @@ pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>) } } +/// The marker FigCraft puts on the system clipboard for copied layers with no text/SVG mirror. +fn is_clipboard_marker(text: &str) -> bool { + text.starts_with("figcraft://clipboard/") +} + /// Paste text from another app at `(cx, cy)` (`center`: centred there): a comment's link opens its /// thread, SVG imports, anything else becomes a text layer. fn paste_text(app: &mut FigApp, text: &str, (cx, cy): (f64, f64), center: bool) { @@ -184,14 +206,14 @@ pub fn invoke(app: &mut FigApp, id: &str) { let ids = app.session.selection(); if let (Some(first), Ok(doc)) = (ids.first(), app.session.doc()) { let css = figcraft_engine::codegen::code_for(doc, *first, figcraft_engine::codegen::CodeLang::Css); - app.services.clipboard_set_text(&css); + app.clipboard_set_text(&css); app.toast(tr("Copied as CSS")); } } "edit.copyAsSvg" => { let ids = app.session.selection(); if let Some(svg) = app.session.doc().ok().and_then(|d| figcraft_svg_export(d, &ids)) { - app.services.clipboard_set_text(&svg); + app.clipboard_set_text(&svg); app.toast(tr("Copied as SVG")); } } @@ -203,7 +225,7 @@ pub fn invoke(app: &mut FigApp, id: &str) { // Clipboard images are read as sRGB. figcraft_render::to_srgb(&mut pix, profile); let rgba = figcraft_render::to_rgba8(&pix); - app.services.clipboard_set_png(pix.width(), pix.height(), &rgba); + app.clipboard_set_png(pix.width(), pix.height(), &rgba); app.toast(tr("Copied as PNG")); } } @@ -215,12 +237,12 @@ pub fn invoke(app: &mut FigApp, id: &str) { .iter() .filter_map(|i| doc.and_then(|d| d.node(*i)).and_then(|n| n.kind.text().map(|t| t.characters.clone()))) .collect(); - app.services.clipboard_set_text(&text.join("\n")); + app.clipboard_set_text(&text.join("\n")); app.toast(tr("Copied as text")); } "edit.copyLink" => { let ids = app.session.selection(); - app.services.clipboard_set_text(&format!("figcraft://node/{}", ids.first().map(|i| i.0).unwrap_or(0))); + app.clipboard_set_text(&format!("figcraft://node/{}", ids.first().map(|i| i.0).unwrap_or(0))); } "edit.findReplace" => app.ui.find = Some((String::new(), String::new())), // The Actions palette invokes commands without parameters. These values are edited in @@ -260,10 +282,15 @@ pub fn invoke(app: &mut FigApp, id: &str) { let mirror = doc.and_then(|d| text_layers_text(d, &ids).or_else(|| figcraft_svg_export(d, &ids))); app.run(id, json!({})); // Mirror to the system clipboard (text or SVG, else a marker) and remember it, so a - // later paste knows whether the clipboard still holds our layers. - let text = mirror.unwrap_or_else(|| format!("figcraft://clipboard/{}", ids.len())); - app.services.clipboard_set_text(&text); - app.ui.last_copy = Some(text); + // later paste knows whether the clipboard still holds our layers. Control-channel + // gestures keep the engine's copy but never replace the user's clipboard; `run` then + // invalidates the old mirror because it no longer describes the engine clipboard. + if !app.control_synthetic { + let text = mirror.unwrap_or_else(|| format!("figcraft://clipboard/{}", ids.len())); + app.clipboard_set_text(&text); + app.ui.last_copy = Some(text); + app.ui.last_copy_generation = app.session.clipboard.layers_generation; + } } "edit.paste" => paste(app, None, None), "file.close" if app.session.ds().is_ok_and(|d| d.is_dirty() && d.branch_of.is_none()) => { @@ -392,7 +419,7 @@ fn recent_menu(app: &mut FigApp, ui: &mut Ui, prefix: &str, key: &str, label: &s /// it. `key` names the submenu (its menu path, unique in its menu tree). Returns what `add` /// returned. pub(crate) fn submenu(ui: &mut Ui, key: &str, label: &str, t: &Tokens, add: impl FnOnce(&mut Ui) -> Option) -> Option { - submenu_at(ui, Id::new(("submenu", key)), key, label, 4.0, false, t, add) + submenu_at(ui, Id::unique(("submenu", key)), key, label, 4.0, false, t, add) } // --------------------------------------------------------------------------------------------- @@ -453,11 +480,11 @@ struct At { } fn at_key(ui: &Ui) -> Id { - ui.id().with("menu_level") + ui.scope_id().with("menu_level") } fn level_at(ui: &Ui) -> At { - ui.data(|d| d.get_temp::(at_key(ui))).unwrap_or(At { root: Id::new("menus"), path: String::new() }) + ui.data(|d| d.get_temp::(at_key(ui))).unwrap_or(At { root: Id::unique("menus"), path: String::new() }) } fn with_level(ctx: &egui::Context, at: &At, f: impl FnOnce(&mut Level) -> R) -> R { @@ -701,7 +728,7 @@ const MENU_W: f32 = 240.0; /// Where a menu remembers the width its rows need (its frame's ui), per language. fn width_key(ui: &Ui) -> Id { - ui.id().with(("menu_w", i18n::current().map(|l| l.code))) + ui.scope_id().with(("menu_w", i18n::current().map(|l| l.code))) } /// A row needs `w`: the menu widens to it from the next frame on. @@ -764,12 +791,12 @@ pub fn open_main_menu(ctx: &egui::Context, top: &str) { /// The context menu's area (and menu tree). fn ctx_menu_id() -> Id { - Id::new("ctx_menu") + Id::unique("ctx_menu") } /// The main menu's area. pub fn main_menu_id() -> Id { - Id::new("main_menu") + Id::unique("main_menu") } /// Whether the pointer is over a menu (any foreground area). @@ -799,7 +826,7 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { forget_tree(ctx, main_menu_id()); } let ctx_at = app.ui.context_menu.as_ref().map(|(p, _)| *p); - let ctx_seen = Id::new("ctx_menu_at"); + let ctx_seen = Id::unique("ctx_menu_at"); if ctx.data(|d| d.get_temp::>(ctx_seen)) != Some(ctx_at) { ctx.data_mut(|d| d.insert_temp(ctx_seen, ctx_at)); forget_tree(ctx, ctx_menu_id()); @@ -833,7 +860,9 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { } ui.add_space(6.0); for top in TOP_MENUS { - if submenu_at(ui, Id::new(("menu", *top)), top, tr(top), 12.0, false, &t, |ui| menu_level(app, ui, top).then_some(())).is_some() { + if submenu_at(ui, Id::unique(("menu", *top)), top, tr(top), 12.0, false, &t, |ui| menu_level(app, ui, top).then_some(())) + .is_some() + { close = true; } } @@ -852,7 +881,7 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { } if let Some((pos, ids)) = app.ui.context_menu.clone() { if let Some(f) = ctx.memory(|m| m.focused()) { - a11y::popup(&mut app.ui.nav, f, Id::new("ctx_menu")); + a11y::popup(&mut app.ui.nav, f, Id::unique("ctx_menu")); } let mut close = false; let area = egui::Area::new(ctx_menu_id()).fixed_pos(pos).order(egui::Order::Foreground).constrain(true).show(ctx, |ui| { @@ -993,7 +1022,7 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { const SELECT_LAYER: &str = "Select layer"; fn select_layer_ask_id() -> Id { - Id::new("ctx_select_layer_ask") + Id::unique("ctx_select_layer_ask") } /// Open (or close) the context menu's Select layer submenu (applied when the menu next draws). @@ -1022,7 +1051,7 @@ fn select_layer_menu(app: &mut FigApp, ui: &mut Ui, pos: egui::Pos2, t: &Tokens) return false; } let sel = ds.selection.clone(); - let picked = submenu_at(ui, Id::new("ctx_select_layer"), SELECT_LAYER, tr("Select layer"), 4.0, true, t, |ui| { + let picked = submenu_at(ui, Id::unique("ctx_select_layer"), SELECT_LAYER, tr("Select layer"), 4.0, true, t, |ui| { let mut picked = None; for (id, depth, name) in &layers { if indented_item(ui, name, "", true, sel.contains(id), 12.0 * (*depth).min(8) as f32, t) { @@ -1088,7 +1117,7 @@ fn palette(app: &mut FigApp, ctx: &egui::Context) { i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown) as i64 - i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp) as i64 }); - egui::Area::new(Id::new("palette")).fixed_pos(area.min).order(egui::Order::Foreground).show(ctx, |ui| { + egui::Area::new(Id::unique("palette")).fixed_pos(area.min).order(egui::Order::Foreground).show(ctx, |ui| { egui::Frame::NONE .fill(t.panel) .corner_radius(CornerRadius::same(13)) diff --git a/crates/ui-egui/src/online.rs b/crates/ui-egui/src/online.rs index 2f054986..61901fae 100644 --- a/crates/ui-egui/src/online.rs +++ b/crates/ui-egui/src/online.rs @@ -163,8 +163,9 @@ fn row(app: &mut FigApp, ui: &mut Ui, t: &Tokens, f: &OnlineFile, busy: bool) { let shown = FileRow { mark: Some((glyph, tint)), name: &f.name, ink, detail: &details, detail_size: 11.0, detail_ink: t.text_secondary }; file_row(ui, t, r.with_max_x(more.min.x - 4.0), &resp, &shown); // The menu button takes its own clicks. - let mresp = - ui.interact(more, ui.id().with(("online_more", &f.server, &f.file)), Sense::click()).tip(trf("Actions for {name}", &[("name", &f.name)])); + let mresp = ui + .interact(more, ui.scope_id().with(("online_more", &f.server, &f.file)), Sense::click()) + .tip(trf("Actions for {name}", &[("name", &f.name)])); a11y::menu_button(&mresp, &trf("Actions for {name}", &[("name", &f.name)]), a11y::menu_open(&mresp)); if mresp.hovered() { ui.painter().rect_filled(more, CornerRadius::same(5), t.pressed); @@ -199,14 +200,14 @@ fn row(app: &mut FigApp, ui: &mut Ui, t: &Tokens, f: &OnlineFile, busy: bool) { if let Some(code) = shared.and_then(|s| s.edit.code.as_deref()) && item(ui, tr("Copy code"), true) { - app.services.clipboard_set_text(&figcraft_engine::figcraft_collab::code::grouped(code)); + app.clipboard_set_text(&figcraft_engine::figcraft_collab::code::grouped(code)); app.toast(tr("Join code copied")); ui.close(); } if let Some(s) = shared && item(ui, tr("Copy link"), true) { - app.services.clipboard_set_text(&s.edit.link); + app.clipboard_set_text(&s.edit.link); app.toast(tr("Invite link copied")); ui.close(); } @@ -257,7 +258,7 @@ pub fn dialogs(app: &mut FigApp, ctx: &egui::Context) { let t = Tokens::get(ctx); if let Some((server, file, name)) = app.ui.online.remove.take() { let (mut open, mut cancel, mut go) = (true, false, false); - crate::dialogs::window(tr("Remove from server"), ctx).id(egui::Id::new("Remove online file")).open(&mut open).show(ctx, |ui| { + crate::dialogs::window(tr("Remove from server"), ctx).id(egui::Id::unique("Remove online file")).open(&mut open).show(ctx, |ui| { ui.set_width(380.0); ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap); widgets::label(ui, &trf("Remove {name} from {server}?", &[("name", &name), ("server", &server_host(&server))]), t.text); @@ -282,11 +283,11 @@ pub fn dialogs(app: &mut FigApp, ctx: &egui::Context) { if let Some((server, file, mut name)) = app.ui.online.rename.take() { let (mut open, mut cancel, mut go) = (true, false, false); let focus = std::mem::take(&mut app.ui.online.focus_rename); - crate::dialogs::window(tr("Rename on the server"), ctx).id(egui::Id::new("Rename online file")).open(&mut open).show(ctx, |ui| { + crate::dialogs::window(tr("Rename on the server"), ctx).id(egui::Id::unique("Rename online file")).open(&mut open).show(ctx, |ui| { ui.set_width(380.0); ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap); widgets::label(ui, tr("The name in your online files and on the server. The document inside keeps its own name."), t.text_secondary); - let resp = widgets::text_input(ui, egui::Id::new("online_rename"), &mut name, 380.0, tr("File name")); + let resp = widgets::text_input(ui, egui::Id::unique("online_rename"), &mut name, 380.0, tr("File name")); a11y::text(&resp, tr("File name"), &name); if focus { resp.request_focus(); diff --git a/crates/ui-egui/src/padding_individual_tests.rs b/crates/ui-egui/src/padding_individual_tests.rs index bc95886e..bf076d48 100644 --- a/crates/ui-egui/src/padding_individual_tests.rs +++ b/crates/ui-egui/src/padding_individual_tests.rs @@ -61,12 +61,12 @@ fn side_fields_show_only_once_the_toggle_opens_and_set_one_side() { let (mut u, f) = scene(); assert_eq!(padding(&u, f), [10.0; 4]); // Shut: no per-side fields. - assert!(u.ctx.read_response(Id::new(("padding", 0)).with("field")).is_none(), "top field hidden until toggled"); + assert!(u.ctx.read_response(Id::unique(("padding", 0)).with("field")).is_none(), "top field hidden until toggled"); // Click the toggle: the four sides appear. let t = named_button(&mut u, "Individual padding"); click(&mut u, t); - let top = u.ctx.read_response(Id::new(("padding", 0)).with("field")).expect("top field shows").rect.center(); - assert!(u.ctx.read_response(Id::new(("padding", 3)).with("field")).is_some(), "left field shows"); + let top = u.ctx.read_response(Id::unique(("padding", 0)).with("field")).expect("top field shows").rect.center(); + assert!(u.ctx.read_response(Id::unique(("padding", 3)).with("field")).is_some(), "left field shows"); // Typing in the top field sets only the top, in one undo step. let history = u.app.session.undo_depth().0; click(&mut u, top); diff --git a/crates/ui-egui/src/panel_remove_tests.rs b/crates/ui-egui/src/panel_remove_tests.rs index 3805dca3..a2e8d3ee 100644 --- a/crates/ui-egui/src/panel_remove_tests.rs +++ b/crates/ui-egui/src/panel_remove_tests.rs @@ -35,6 +35,7 @@ fn remove_button(u: &mut Ui, list: &str, index: usize) -> Pos2 { } let wheel = Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, delta: egui::vec2(0.0, -100.0), phase: egui::TouchPhase::Move, modifiers: Default::default(), @@ -64,7 +65,7 @@ fn remove_button(u: &mut Ui, list: &str, index: usize) -> Pos2 { } fn flag(u: &Ui, salt: &'static str, i: usize) -> bool { - u.ctx.data(|d| d.get_temp::(Id::new((salt, i)))).unwrap_or(false) + u.ctx.data(|d| d.get_temp::(Id::unique((salt, i)))).unwrap_or(false) } #[test] @@ -78,7 +79,7 @@ fn removing_a_fill_keeps_the_picker_on_its_paint() { let mut u = Ui::new(s); u.ctx.enable_accesskit(); // Green's swatch, then red's Remove: the picker follows green to index 0. - let sw = u.ctx.read_response(Id::new(("swatch", true, 1usize))).unwrap().rect.center(); + let sw = u.ctx.read_response(Id::unique(("swatch", true, 1usize))).unwrap().rect.center(); click(&mut u, sw); assert_eq!(u.app.ui.picker, Some(PickerTarget::Paint { fills: true, index: 1, ids: vec![r] })); let rm = remove_button(&mut u, "fills", 0); @@ -105,7 +106,7 @@ fn removing_effects_and_grids_keeps_open_settings_with_their_rows() { let mut u = Ui::new(s); u.ctx.enable_accesskit(); // Effect 2's settings open and its colour in the picker; effect 0 goes. - u.ctx.data_mut(|d| d.insert_temp(Id::new(("fx_open", 2usize)), true)); + u.ctx.data_mut(|d| d.insert_temp(Id::unique(("fx_open", 2usize)), true)); u.app.ui.picker = Some(PickerTarget::Effect { ids: vec![f], index: 2, secondary: false }); let rm = remove_button(&mut u, "effects", 0); click(&mut u, rm); @@ -118,7 +119,7 @@ fn removing_effects_and_grids_keeps_open_settings_with_their_rows() { assert!(!flag(&u, "fx_open", 0) && !flag(&u, "fx_open", 1)); assert_eq!(u.app.ui.picker, None); // Grid 1's settings open; grid 0 goes. - u.ctx.data_mut(|d| d.insert_temp(Id::new(("grid_open", 1usize)), true)); + u.ctx.data_mut(|d| d.insert_temp(Id::unique(("grid_open", 1usize)), true)); let rm = remove_button(&mut u, "grids", 0); click(&mut u, rm); assert_eq!(u.app.session.doc().unwrap().node(f).unwrap().grids.len(), 1); diff --git a/crates/ui-egui/src/panel_reorder_tests.rs b/crates/ui-egui/src/panel_reorder_tests.rs index 00f969b5..54caa687 100644 --- a/crates/ui-egui/src/panel_reorder_tests.rs +++ b/crates/ui-egui/src/panel_reorder_tests.rs @@ -94,6 +94,7 @@ fn effects_and_grids_drag_too_and_mixed_lists_dont() { // The layout grid section is below the fold: scroll the panel. let wheel = Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, delta: egui::vec2(0.0, -400.0), phase: egui::TouchPhase::Move, modifiers: Default::default(), diff --git a/crates/ui-egui/src/pending_open.rs b/crates/ui-egui/src/pending_open.rs index f5e978c6..c6ade598 100644 --- a/crates/ui-egui/src/pending_open.rs +++ b/crates/ui-egui/src/pending_open.rs @@ -175,7 +175,7 @@ pub fn pending_opens(app: &mut FigApp, ctx: &egui::Context) { let t = Tokens::get(ctx); let pending = &app.ui.pending_opens; // Shown at once: the frame after this one may block the window while the file opens. - crate::dialogs::window("", ctx).id(egui::Id::new("pending_open")).title_bar(false).movable(false).interactable(false).fade_in(false).show( + crate::dialogs::window("", ctx).id(egui::Id::unique("pending_open")).title_bar(false).movable(false).interactable(false).fade_in(false).show( ctx, |ui| { ui.set_width(480.0); diff --git a/crates/ui-egui/src/perf_tests.rs b/crates/ui-egui/src/perf_tests.rs index f8f39e93..7b5d1212 100644 --- a/crates/ui-egui/src/perf_tests.rs +++ b/crates/ui-egui/src/perf_tests.rs @@ -77,6 +77,7 @@ fn report(layers: usize) { rows.push(("hover over the canvas", b.mean(60, |i| pts[i].clone()))); let wheel = |d: f32| Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, delta: vec2(0.0, d), phase: egui::TouchPhase::Move, modifiers: Default::default(), @@ -180,7 +181,13 @@ fn design_system(icons: usize, instances: usize) -> (Document, figcraft_engine:: } fn wheel(d: f32) -> Event { - Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, d), phase: egui::TouchPhase::Move, modifiers: Default::default() } + Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, + delta: vec2(0.0, d), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + } } /// UI frames with a large design system: the Assets panel listing its components and the Design diff --git a/crates/ui-egui/src/plugin_panel_tests.rs b/crates/ui-egui/src/plugin_panel_tests.rs index e4bc6d4f..3b0f2746 100644 --- a/crates/ui-egui/src/plugin_panel_tests.rs +++ b/crates/ui-egui/src/plugin_panel_tests.rs @@ -95,8 +95,9 @@ fn a_colour_field_opens_the_colour_picker_and_two_panels_show_side_by_side() { u.ctx.enable_accesskit(); u.frame(vec![]); let nodes = |u: &mut Ui| u.frame_output(vec![]).platform_output.accesskit_update.unwrap().nodes; - // The swatch beside a colour field opens the colour picker on it. - let swatch = nodes(&mut u).iter().map(|(_, n)| n).find(|n| n.label() == Some("Color")).and_then(|n| n.bounds()).unwrap(); + // The swatch beside the accent field (named for that field, not just "Color") opens the + // colour picker on the plugin field, not on another colour control in the window. + let swatch = nodes(&mut u).iter().map(|(_, n)| n).find(|n| n.label() == Some("Accent Color")).and_then(|n| n.bounds()).unwrap(); let at = Pos2::new(((swatch.x0 + swatch.x1) / 2.0) as f32, ((swatch.y0 + swatch.y1) / 2.0) as f32); u.button(at, true); u.button(at, false); diff --git a/crates/ui-egui/src/plugins_ui.rs b/crates/ui-egui/src/plugins_ui.rs index 745cde88..83295359 100644 --- a/crates/ui-egui/src/plugins_ui.rs +++ b/crates/ui-egui/src/plugins_ui.rs @@ -81,8 +81,8 @@ pub(crate) fn field_value(app: &FigApp, plugin: &str, control: &str) -> Option Option { let id = match &app.ui.plugin_form { - Some(f) if f.id == plugin => Id::new("plugin_form"), - _ => Id::new(("plugin_panel", plugin)), + Some(f) if f.id == plugin => Id::unique("plugin_form"), + _ => Id::unique(("plugin_panel", plugin)), }; ctx.memory(|m| m.area_rect(id)) } @@ -206,7 +206,7 @@ fn panel_windows(app: &mut FigApp, ctx: &Context) { let mut used = None; // Side by side leftwards from the properties panel, three to a row. let at = egui::pos2(right - PANEL_STEP * (i % 3) as f32, 52.0 + 28.0 * (i / 3) as f32); - floating(open.title(), ctx).id(Id::new(("plugin_panel", id))).pivot(Align2::RIGHT_TOP).default_pos(at).open(&mut keep).show(ctx, |ui| { + floating(open.title(), ctx).id(Id::unique(("plugin_panel", id))).pivot(Align2::RIGHT_TOP).default_pos(at).open(&mut keep).show(ctx, |ui| { ui.set_width(320.0); used = controls(ui, id, &open.panel.controls, &open.values, &t); }); @@ -250,7 +250,7 @@ fn manage_window(app: &mut FigApp, ctx: &Context) { let mut open = true; let mut asked = None; let mut tab = app.ui.plugins_manage.tab; - window(tr("Plugins"), ctx).id(Id::new("Plugins")).open(&mut open).show(ctx, |ui| { + window(tr("Plugins"), ctx).id(Id::unique("Plugins")).open(&mut open).show(ctx, |ui| { ui.set_width(MANAGE_W); tab_strip(ui, &t, &PluginsTab::ALL, PluginsTab::label, &mut tab); ui.add_space(12.0); @@ -326,7 +326,7 @@ fn browse_tab(app: &mut FigApp, ui: &mut Ui, t: &Tokens) -> Option { // The field commits on Enter (which also browses) or when Browse takes the focus. let mut enter = false; if let Some(s) = - widgets::text_field(ui, Id::new("plugin_directory_server"), &server, MANAGE_W - 90.0, tr("Server address (ws://host:47900/)")) + widgets::text_field(ui, Id::unique("plugin_directory_server"), &server, MANAGE_W - 90.0, tr("Server address (ws://host:47900/)")) { enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); server = s.clone(); @@ -351,7 +351,7 @@ fn browse_tab(app: &mut FigApp, ui: &mut Ui, t: &Tokens) -> Option { return asked; }; ui.add_space(8.0); - if let Some(q) = widgets::text_field(ui, Id::new("plugin_directory_query"), &app.ui.plugins_manage.query, MANAGE_W, tr("Search plugins")) { + if let Some(q) = widgets::text_field(ui, Id::unique("plugin_directory_query"), &app.ui.plugins_manage.query, MANAGE_W, tr("Search plugins")) { app.ui.plugins_manage.query = q; } ui.add_space(8.0); @@ -432,7 +432,7 @@ fn confirm_window(app: &mut FigApp, ctx: &Context, t: &Tokens) { let installing = app.session.waiting_for(INSTALL); let title = if update { tr("Update plugin") } else { tr("Install plugin") }; let (mut open, mut install, mut cancel) = (true, false, false); - window(title, ctx).id(Id::new("plugin_install_confirm")).open(&mut open).show(ctx, |ui| { + window(title, ctx).id(Id::unique("plugin_install_confirm")).open(&mut open).show(ctx, |ui| { ui.set_width(340.0); ui.label(RichText::new(format!("{} {}", e.name, e.version)).font(semibold(13.0)).color(t.text)); widgets::label(ui, &trf("From {server}", &[("server", &server)]), t.text_secondary); @@ -529,7 +529,7 @@ fn form_window(app: &mut FigApp, ctx: &Context) { let mut pick = None; let label = command.label.trim_end_matches(['…', '.']).trim(); let title = if label.contains(name.as_str()) { label.to_string() } else { format!("{name}: {label}") }; - window(&title, ctx).id(Id::new("plugin_form")).open(&mut open).show(ctx, |ui| { + window(&title, ctx).id(Id::unique("plugin_form")).open(&mut open).show(ctx, |ui| { ui.set_width(320.0); match controls(ui, &format!("{}/{}", form.id, form.command), &command.params, &form.values, &t) { Some(Used::Set(name, v)) => { @@ -586,7 +586,7 @@ fn controls(ui: &mut Ui, salt: &str, params: &[Param], values: &Map { let current = values.get(&p.name).cloned().unwrap_or_else(|| p.default_value()); - field(ui, Id::new(("plugin_control", salt, &p.name)), p, ¤t) + field(ui, Id::unique(("plugin_control", salt, &p.name)), p, ¤t) } }; if hit.is_some() { @@ -643,7 +643,8 @@ fn field(ui: &mut Ui, id: Id, p: &Param, current: &Value) -> Option { let (picked, typed) = ui .horizontal(|ui| { let color = figcraft_color::Color::from_hex(hex).unwrap_or(figcraft_color::Color::BLACK); - let picked = widgets::swatch(ui, &figcraft_color::Paint::solid(color), widgets::FIELD_H).clicked(); + let label = format!("{name} {}", tr("Color")); + let picked = widgets::swatch_named(ui, &figcraft_color::Paint::solid(color), widgets::FIELD_H, &label).clicked(); (picked, widgets::text_field_named(ui, id, hex, FIELD_W - widgets::FIELD_H - 8.0, name).and_then(|s| p.coerce(&json!(s)).ok())) }) .inner; diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs index 9c0f1608..70907c7a 100644 --- a/crates/ui-egui/src/present.rs +++ b/crates/ui-egui/src/present.rs @@ -4,8 +4,9 @@ //! The player works on its own copy of the document (interactive components, variables and scroll //! offsets change it without touching the user's file) and copies the editor's document again //! whenever it changes, so the preview pane follows edits live. On a device, the presented frame -//! is sized to the device's screen ([`fit_viewport`]): what doesn't fit is clipped, or scrolls when -//! the frame's overflow allows it. A responsive prototype's frame is instead resized to the screen +//! is sized to the device's screen ([`fit_viewport`]): a frame bigger than the screen scrolls +//! through its whole size (as in Figma, with no overflow setting), while content past the frame's +//! own edges scrolls only as its overflow allows and is otherwise clipped. A responsive prototype's frame is instead resized to the screen //! (the device's, else the player's whole area) and laid out again, its constraints and auto layout //! responding (`figcraft_engine::cmd::proto::respond`, on the player's copy, from the design each //! time). Device bodies are drawn in code in the device's colour. Every frame is rendered through @@ -330,6 +331,11 @@ impl Presenter { true } + /// The frame the presentation started from (Restart goes back there). + pub fn start(&self) -> NodeId { + self.start + } + pub fn new(start: NodeId) -> Self { Self { current: start, @@ -511,6 +517,16 @@ impl Presenter { self.rev += 1; } + /// After an action changed layers of `design` (since `before`): text resizes to what it shows, + /// instances follow and auto layout reflows, as after an edit on the canvas; then + /// [`relayout`](Self::relayout). + fn settle(&mut self, before: &Document) { + if let Some(d) = self.design.as_mut() { + figcraft_engine::normalize(d, before); + } + self.relayout(); + } + /// Responsive prototypes: resize the presented frame to a screen of `size` (None: frames /// keep their size). Lays out again only when it changes. fn respond_to(&mut self, size: Option<(f64, f64)>) { @@ -879,28 +895,31 @@ impl Presenter { } self.begin(tr, dur, easing, now); let Some(d) = self.design.as_mut() else { return false }; + let before = d.clone(); if let Some(n) = d.node_mut(inst) && let NodeKind::Instance { component, .. } = &mut n.kind { *component = *dest; } figcraft_engine::components::sync_instance(d, inst); - self.relayout(); + self.settle(&before); true } Action::SetVariable { variable, value, expr } => { let Some(d) = self.design.as_mut() else { return false }; + let before = d.clone(); let set = figcraft_engine::proto_run::set_variable(d, node, variable, value, expr.as_ref()); if set { - self.relayout(); + self.settle(&before); } set } Action::SetVariableMode { collection, mode } => { let Some(d) = self.design.as_mut() else { return false }; + let before = d.clone(); let set = figcraft_engine::proto_run::set_mode(d, self.current, collection, mode); if set { - self.relayout(); + self.settle(&before); } set } @@ -1170,11 +1189,13 @@ pub fn scroll_range(doc: &Document, frame: NodeId) -> (f64, f64) { /// How far `frame`'s content can scroll when `viewport` (page units, from its top-left) of it /// shows, as a device's screen shows a top-level frame: the frame itself is content then, so a -/// frame taller than the screen scrolls through its whole height. Without a viewport the frame -/// shows at its own size and what overflows it scrolls. Only the axes its overflow allows scroll. +/// frame bigger than the screen scrolls through its whole size whatever its overflow (as in +/// Figma, a top-level frame needs no overflow setting for that). Without a viewport the frame +/// shows at its own size and what overflows it scrolls. Content past the frame's own edges +/// scrolls only on the axes its overflow allows. pub fn scroll_range_in(doc: &Document, frame: NodeId, viewport: Option<(f64, f64)>) -> (f64, f64) { let Some(n) = doc.node(frame) else { return (0.0, 0.0) }; - if n.overflow == Overflow::None { + if n.overflow == Overflow::None && viewport.is_none() { return (0.0, 0.0); } let (mut x1, mut y1) = (0.0f64, 0.0f64); @@ -1191,6 +1212,13 @@ pub fn scroll_range_in(doc: &Document, frame: NodeId, viewport: Option<(f64, f64 x1 += l.padding[1]; y1 += l.padding[2]; } + // Axes the overflow doesn't scroll clip what lies past the frame's edges. + if !matches!(n.overflow, Overflow::Horizontal | Overflow::Both) { + x1 = 0.0; + } + if !matches!(n.overflow, Overflow::Vertical | Overflow::Both) { + y1 = 0.0; + } let (vw, vh) = match viewport { Some(v) => { x1 = x1.max(n.width); @@ -1199,8 +1227,7 @@ pub fn scroll_range_in(doc: &Document, frame: NodeId, viewport: Option<(f64, f64 } None => (n.width, n.height), }; - let rx = if matches!(n.overflow, Overflow::Horizontal | Overflow::Both) { (x1 - vw).max(0.0) } else { 0.0 }; - let ry = if matches!(n.overflow, Overflow::Vertical | Overflow::Both) { (y1 - vh).max(0.0) } else { 0.0 }; + let (rx, ry) = ((x1 - vw).max(0.0), (y1 - vh).max(0.0)); (if rx.is_finite() { rx } else { 0.0 }, if ry.is_finite() { ry } else { 0.0 }) } @@ -1642,12 +1669,26 @@ enum BarAction { Inspect, } +pub struct PresentTab { + pub id: u64, + /// Empty while the tab is on screen: then the player is in `app.ui.present`. + pub pres: Option, + pub doc_uid: u64, + pub path: Option, + pub page: NodeId, + pub file: String, + pub page_name: String, +} + /// The full-window presenter (while `app.ui.present` is set). Escape or Close ends it. pub fn show(app: &mut FigApp, ui: &mut Ui) { let ctx = ui.ctx().clone(); let now = ctx.input(|i| i.time); let Some(mut pres) = app.ui.present.take() else { return }; - if let Ok(ds) = app.session.ds() { + // A tab can play a file that is closed or not in front. Then it must not read or change + // the file in front. + let live = crate::tabs::present_owner_active(app); + if live && let Ok(ds) = app.session.ds() { pres.sync(&ds.doc, now); } poll_pads(app, &mut pres, &ctx); @@ -1684,7 +1725,8 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { Some(BarAction::Inspect) => pres.view.inspect = !pres.view.inspect, _ => {} } - if pres.view.comments + if live + && pres.view.comments && let Some((frame, place, clip)) = pres.shown { crate::comments::presenter(app, &ctx, frame, place, clip); @@ -1694,17 +1736,20 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { finish(app, &mut pres); // The options carry over to the next presentation. app.ui.present_view = pres.view; - if let Some(BarAction::Responsive(on)) = action { + if live && let Some(BarAction::Responsive(on)) = action { app.run("proto.settings", json!({"responsive": on})); } - if let Some(device) = picked_device { + if live && let Some(device) = picked_device { choose_device(app, &mut pres, &device); } - if action == Some(BarAction::Close) || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { - app.ui.present_uid = None; - return; - } app.ui.present = Some(pres); + if action == Some(BarAction::Close) || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + if let Some(id) = app.ui.present_active { + crate::tabs::close_present(app, id); + } else { + app.ui.present = None; + } + } } /// Device choices in the presenter use the screen's real dimensions and reflow a copy of the @@ -1727,78 +1772,82 @@ fn navigation_bar(p: &mut Presenter, ctx: &egui::Context, now: f64) -> Option Id { - Id::new("proto_preview_pane") + Id::unique("proto_preview_pane") } /// Show the preview pane while `app.ui.preview` is set (call once per frame, before shortcuts: @@ -2119,7 +2168,7 @@ fn player(p: &mut Presenter, ui: &mut Ui, rect: Rect, now: f64, chrome: Chrome) if p.step_fling(now) { ctx.request_repaint(); } - let resp = ui.interact(area, ui.id().with("present_area"), Sense::click_and_drag()); + let resp = ui.interact(area, ui.scope_id().with("present_area"), Sense::click_and_drag()); let over = resp.contains_pointer(); if let Some(l) = layout(p, area, chrome) { // The pane only takes the keyboard while the pointer is over it. @@ -2217,7 +2266,7 @@ fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome) let w = if chrome.pane { 24.0 } else { ui.painter().layout_no_wrap(shown.to_string(), regular(11.5), text).size().x + 16.0 }; let br = Rect::from_min_size(pos2(x - w, bar.center().y - 12.0), vec2(w, 24.0)); x = br.min.x - if chrome.pane { 2.0 } else { 4.0 }; - let mut resp = ui.interact(br, ui.id().with(("pbar", *label)), Sense::click()); + let mut resp = ui.interact(br, ui.scope_id().with(("pbar", *label)), Sense::click()); if chrome.pane { resp = resp.tip(shown); } @@ -2269,7 +2318,7 @@ fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome) let tip = playing.map(|f| f.description.trim()).filter(|d| !d.is_empty()).unwrap_or(tr("Flows")).to_string(); let galley = left.layout_no_wrap(label, semibold(11.5), text); let fr = Rect::from_min_size(pos2(lx, bar.center().y - 12.0), vec2(galley.size().x + 30.0, 24.0)); - let resp = ui.interact(fr.intersect(left.clip_rect()), ui.id().with("pflows"), Sense::click()).tip(tip); + let resp = ui.interact(fr.intersect(left.clip_rect()), ui.scope_id().with("pflows"), Sense::click()).tip(tip); if resp.hovered() { left.rect_filled(fr, CornerRadius::same(5), hover); } @@ -2356,7 +2405,7 @@ fn scrollers_at(p: &Presenter, base: &Document, disp: &Document, root: NodeId, p let mut out: Vec = disp .subtree(root) .into_iter() - .filter(|f| disp.node(*f).is_some_and(|n| n.visible && n.overflow != Overflow::None)) + .filter(|f| disp.node(*f).is_some_and(|n| n.visible && (n.overflow != Overflow::None || *f == root))) .filter(|f| disp.world_bounds(*f).is_some_and(|b| place.rect(b).contains(q))) .filter(|f| p.range(base, *f) != (0.0, 0.0)) .collect(); @@ -3144,9 +3193,11 @@ mod tests { assert_eq!(disp.world_bounds(list).unwrap().y0, 800.0); p.scroll_to(0.0, 99_999.0); assert_eq!(p.scroll[&a], (0.0, 1148.0), "clamped to the range"); - // Without scrolling the screen only clips; without a device the frame shows whole. + // A top-level frame taller than the screen scrolls without an overflow setting (its own + // range still clips nested content); without a device the frame shows whole. d.get_mut(a).unwrap().overflow = Overflow::None; - assert_eq!(scroll_range_in(&d, a, viewport(&d, a)), (0.0, 0.0)); + assert_eq!(scroll_range_in(&d, a, viewport(&d, a)), (0.0, 1148.0)); + assert_eq!(scroll_range(&d, a), (0.0, 0.0)); if let Some(pg) = d.page_of(a) && let NodeKind::Page { prototype, .. } = &mut d.get_mut(pg).unwrap().kind { diff --git a/crates/ui-egui/src/present_behaviour_tests.rs b/crates/ui-egui/src/present_behaviour_tests.rs index 791e0d3f..e9a036f2 100644 --- a/crates/ui-egui/src/present_behaviour_tests.rs +++ b/crates/ui-egui/src/present_behaviour_tests.rs @@ -454,6 +454,99 @@ fn interactive_components_change_in_place() { } } +/// A hugging auto layout frame (in `parent`, else on the page) holding text `chars`: (frame, text). +fn hugging_label(s: &mut Session, parent: Option, name: &str, x: f64, chars: &str) -> (NodeId, NodeId) { + let f = id(&ex(s, "frame.create", json!({"x": x, "y": 600, "width": 60, "height": 30, "name": name, "parent": parent}))); + let t = id(&ex(s, "text.create", json!({"text": chars, "x": 0, "y": 0, "parent": f}))); + ex(s, "node.set", json!({"ids": [f], "props": {"layout": "horizontal", "padding": 8, "sizingH": "hug", "sizingV": "hug"}})); + (f, t) +} + +fn size(d: &Document, n: NodeId) -> (f64, f64) { + d.node(n).map(|n| (n.width, n.height)).unwrap() +} + +#[test] +fn change_to_lays_out_the_instance_around_its_own_content() { + // Change to shows the other variant in place: the instance keeps its overrides (a longer + // label), so it is laid out around them, as switching the variant on the canvas does. + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let variants: Vec = ["Default", "Hover"] + .iter() + .enumerate() + .map(|(k, state)| { + let (f, _) = hugging_label(&mut s, None, &format!("State={state}"), k as f64 * 150.0, "Button"); + id(&ex(&mut s, "component.create", json!({"ids": [f]}))) + }) + .collect(); + let set = id(&ex(&mut s, "component.combineVariants", json!({"ids": variants.clone()}))); + on(&mut s, variants[0], json!({"type": "onClick"}), json!({"type": "changeTo", "dest": variants[1]}), json!({"type": "instant"}), 0); + let screen = frame(&mut s, "Screen", 0.0, 400.0, 300.0); + let button = id(&ex(&mut s, "instance.create", json!({"component": set, "x": 20, "y": 20, "parent": screen}))); + let label = s.doc().unwrap().node(button).unwrap().children[0]; + ex(&mut s, "text.set", json!({"id": label, "characters": "A much longer label"})); + let (main, canvas, canvas_label) = { + let d = s.doc().unwrap(); + (size(d, variants[1]), size(d, button), size(d, label)) + }; + // Without any font nothing is measured and both sizes are the main's. + if figcraft_text::layout_node(s.doc().unwrap().node(label).unwrap()).is_some_and(|(l, _)| !l.glyphs.is_empty()) { + assert!(canvas.0 > main.0 + 20.0, "on the canvas the instance hugs its longer label"); + } + let mut p = player(s.doc().unwrap().clone(), screen); + assert!(p.fire(button, 0, None, 1.0)); + let d = p.doc.as_ref().unwrap(); + assert!(matches!(d.node(button).unwrap().kind, NodeKind::Instance { component, .. } if component == variants[1])); + assert_eq!((size(d, button), size(d, label)), (canvas, canvas_label), "the Hover variant, laid out around the instance's label"); +} + +/// A screen holding a hugging chip whose text is bound to string variable `label` of collection +/// "Copy" (modes A "Short" and B "A much longer label"), the chip's click running `action`: +/// (session, screen, chip, text). +fn bound_chip(action: Value) -> (Session, NodeId, NodeId, NodeId) { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + ex(&mut s, "variable.createCollection", json!({"name": "Copy", "modes": ["A", "B"]})); + ex( + &mut s, + "variable.create", + json!({"collection": "Copy", "name": "label", "type": "string", "values": {"A": "Short", "B": "A much longer label"}}), + ); + let screen = frame(&mut s, "Screen", 0.0, 400.0, 300.0); + let (chip, text) = hugging_label(&mut s, Some(screen), "Chip", 20.0, "Short"); + ex(&mut s, "variable.bind", json!({"ids": [text], "field": "characters", "variable": "label"})); + on(&mut s, chip, json!({"type": "onClick"}), action, json!({"type": "instant"}), 0); + (s, screen, chip, text) +} + +#[test] +fn set_variable_and_set_mode_lay_out_the_text_they_change() { + // A text bound to a string variable shows what an interaction sets, measured: the hugging + // chip around it grows with it, to the size the canvas gives the same text. + let cases = [ + ( + json!({"type": "setVariable", "variable": "label", "value": "A much longer label"}), + "variable.set", + json!({"variable": "label", "value": "A much longer label"}), + ), + (json!({"type": "setVariableMode", "collection": "Copy", "mode": "B"}), "variable.setMode", json!({"collection": "Copy", "mode": "B"})), + ]; + for (action, canvas_cmd, canvas_params) in cases { + let (mut s, screen, chip, text) = bound_chip(action.clone()); + let mut p = player(s.doc().unwrap().clone(), screen); + assert!(p.fire(chip, 0, None, 1.0), "{action}"); + let page = s.doc().unwrap().page_of(screen).unwrap(); + let mut params = canvas_params; + if canvas_cmd == "variable.setMode" { + params["ids"] = json!([page]); + } + ex(&mut s, canvas_cmd, params); + let (d, canvas) = (p.doc.as_ref().unwrap(), s.doc().unwrap()); + assert_eq!((size(d, chip), size(d, text)), (size(canvas, chip), size(canvas, text)), "{action}: the chip grows with its text"); + } +} + #[test] fn a_drag_started_transition_follows_the_pointer() { let (mut u, hot) = one_interaction(json!({"type": "onDrag"}), json!({"type": "push", "direction": "left"}), 400); diff --git a/crates/ui-egui/src/present_tab_tests.rs b/crates/ui-egui/src/present_tab_tests.rs new file mode 100644 index 00000000..1868babb --- /dev/null +++ b/crates/ui-egui/src/present_tab_tests.rs @@ -0,0 +1,193 @@ +use egui::accesskit::{Node, Role}; +use egui::{Key, Pos2, pos2}; +use figcraft_doc::proto::PrototypeSettings; +use serde_json::{Value, json}; + +use crate::present_tests::{Ui, id, setup}; + +const FILE_TAB: Pos2 = pos2(100.0, 20.0); + +fn click(u: &mut Ui, at: Pos2) { + u.button(at, true); + u.button(at, false); + u.frame(vec![]); +} + +fn tabs(u: &Ui) -> Value { + u.app.inspect()["presentTabs"].clone() +} + +fn presenting(u: &Ui) -> bool { + u.app.inspect()["presenting"] == json!(true) +} + +fn nodes(u: &mut Ui) -> Vec { + u.ctx.enable_accesskit(); + u.frame(vec![]); + u.frame_output(vec![]).platform_output.accesskit_update.expect("an AccessKit update").nodes.into_iter().map(|(_, n)| n).collect() +} + +fn centre(n: &Node) -> Pos2 { + let b = n.bounds().unwrap(); + pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32) +} + +fn click_label(u: &mut Ui, label: &str) { + let all = nodes(u); + let n = all.iter().find(|n| n.label().is_some_and(|l| l.trim() == label)).unwrap_or_else(|| panic!("no {label:?} on screen")); + click(u, centre(n)); +} + +/// The presenter bar's Options button: it has no label, and sits right of Show comments. +fn open_options(u: &mut Ui) { + let all = nodes(u); + let comments = all.iter().find(|n| n.label() == Some("Show comments (Shift+C)")).unwrap().bounds().unwrap(); + let options = all + .iter() + .filter(|n| n.role() == Role::Unknown && n.label().is_none()) + .filter(|n| n.bounds().is_some_and(|b| b.y0 == comments.y0 && b.x0 > comments.x1)) + .min_by(|a, b| a.bounds().unwrap().x0.total_cmp(&b.bounds().unwrap().x0)) + .expect("the Options button"); + click(u, centre(options)); +} + +fn front_settings(u: &Ui) -> PrototypeSettings { + let page = u.app.session.page().unwrap(); + crate::present::page_settings(u.app.session.doc().unwrap(), page) +} + +/// The middle of presentation tab `k` and of its close button, after `files` file tabs named +/// "Untitled". +fn present_tab(u: &Ui, files: usize, k: usize) -> (Pos2, Pos2) { + let width = |chars: usize, lo: f32, hi: f32| (12.0 * 0.6 * chars as f32 + 64.0).clamp(lo, hi); + let mut x = 52.0 + files as f32 * width("Untitled".len(), 120.0, 220.0); + let names: Vec = tabs(u).as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap().to_string()).collect(); + for n in names.iter().take(k) { + x += width(n.chars().count(), 140.0, 260.0); + } + let w = width(names[k].chars().count(), 140.0, 260.0); + (pos2(x + w / 2.0, 20.0), pos2(x + w - 16.0, 20.0)) +} + +#[test] +fn the_tab_is_named_after_the_file_and_page_and_its_x_closes_it() { + let (mut u, a, ..) = setup(); + u.run("proto.present", json!({"start": a})); + assert!(presenting(&u)); + assert_eq!(tabs(&u), json!([{"name": "Untitled - Page 1", "active": true}])); + click(&mut u, FILE_TAB); + assert!(!presenting(&u)); + assert_eq!(tabs(&u)[0]["active"], false); + let (tab, close) = present_tab(&u, 1, 0); + click(&mut u, tab); + assert!(presenting(&u)); + click(&mut u, close); + assert_eq!((presenting(&u), tabs(&u)), (false, json!([]))); + assert!(!u.app.ui.home, "back to its file"); +} + +#[test] +fn play_again_shows_the_tab_as_it_was_and_another_page_opens_a_new_one() { + let (mut u, a, b, _) = setup(); + u.run("proto.present", json!({"start": a})); + u.key(pos2(720.0, 470.0), Key::ArrowRight); + click(&mut u, FILE_TAB); + u.run("proto.present", json!({"start": a})); + assert_eq!(tabs(&u).as_array().unwrap().len(), 1, "same page: the same tab"); + assert_eq!(u.app.inspect()["presenter"]["current"], json!(b), "it shows the prototype as it was"); + u.run("proto.present", json!({"start": b})); + assert_eq!(tabs(&u).as_array().unwrap().len(), 1); + assert_eq!(u.app.inspect()["presenter"]["history"], json!([]), "another start frame starts again"); + + click(&mut u, FILE_TAB); + let page = id(&u.run("page.add", json!({"name": "Second"}))); + u.run("page.select", json!({"id": page})); + let c = id(&u.run("node.create", json!({"type": "frame", "name": "C", "x": 0, "y": 0, "width": 200, "height": 200}))); + u.run("proto.present", json!({"start": c})); + assert_eq!(tabs(&u), json!([{"name": "Untitled - Page 1", "active": false}, {"name": "Untitled - Second", "active": true}])); + let (first, _) = present_tab(&u, 1, 0); + click(&mut u, first); + assert_eq!(tabs(&u), json!([{"name": "Untitled - Page 1", "active": true}, {"name": "Untitled - Second", "active": false}])); + assert_eq!(u.app.inspect()["presenter"]["current"], json!(b)); +} + +#[test] +fn the_presentation_plays_on_when_its_file_closes() { + let (mut u, a, b, _) = setup(); + let hot = id(&u.run("node.create", json!({"type": "rectangle", "parent": a, "x": 0, "y": 0, "width": 393, "height": 80}))); + u.run("proto.interaction.add", json!({"id": hot, "dest": b})); + u.run("proto.present", json!({"start": a})); + click(&mut u, FILE_TAB); + u.run("file.close", json!({"index": 0, "force": true})); + assert_eq!(tabs(&u), json!([{"name": "Untitled - Page 1", "active": false}])); + let (tab, _) = present_tab(&u, 0, 0); + click(&mut u, tab); + assert!(presenting(&u)); + let spot = u.app.inspect()["presenter"]["hotspots"][0]["rect"].clone(); + let r: Vec = spot.as_array().unwrap().iter().map(|v| v.as_f64().unwrap() as f32).collect(); + click(&mut u, pos2((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0)); + u.frames(60, Vec::new); + assert_eq!(u.app.inspect()["presenter"]["current"], json!(b), "the prototype still plays"); + u.key(pos2(700.0, 500.0), Key::Escape); + assert_eq!(tabs(&u), json!([])); + assert!(u.app.ui.home, "no file to go back to: Home"); +} + +#[test] +fn a_tab_whose_file_closed_leaves_the_file_in_front_alone() { + let (mut u, a, ..) = setup(); + u.run("proto.present", json!({"start": a})); + click(&mut u, FILE_TAB); + u.run("file.new", json!({})); + u.run("file.close", json!({"index": 0, "force": true})); + let (tab, _) = present_tab(&u, 1, 0); + click(&mut u, tab); + u.frames(5, Vec::new); + assert!(presenting(&u)); + assert!(!crate::tabs::present_owner_active(&u.app)); + assert_eq!(u.app.inspect()["presenter"]["current"], json!(a), "it keeps playing its own copy"); + let before = front_settings(&u); + click_label(&mut u, "iPhone 16"); + click_label(&mut u, "iPhone 17 402×874"); + assert!(presenting(&u)); + for size in ["Fixed size", "Responsive"] { + open_options(&mut u); + click_label(&mut u, size); + } + assert_eq!(front_settings(&u), before, "picking a device or Responsive leaves the file in front alone"); + assert!(!u.app.session.ds().unwrap().is_dirty()); +} + +#[test] +fn a_saved_file_opened_again_gets_its_tab_back() { + let (mut u, a, ..) = setup(); + let dir = std::env::temp_dir().join(format!("figcraft-present-tab-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Proto.figcraft").to_string_lossy().to_string(); + u.run("file.saveAs", json!({"path": path})); + u.run("proto.present", json!({"start": a})); + click(&mut u, FILE_TAB); + u.run("file.close", json!({"index": 0})); + u.run("file.open", json!({"path": path})); + u.run("proto.present", json!({"start": a})); + // Saved as Proto.figcraft: the file's title follows its name. + assert_eq!(tabs(&u), json!([{"name": "Proto - Page 1", "active": true}])); + u.key(pos2(700.0, 500.0), Key::Escape); + assert!(!u.app.ui.home, "back to its file"); + assert_eq!(tabs(&u), json!([])); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn renaming_the_file_or_the_page_renames_the_tab() { + let (mut u, a, ..) = setup(); + u.run("proto.present", json!({"start": a})); + click(&mut u, FILE_TAB); + u.run("file.rename", json!({"name": "Playground"})); + u.run("page.rename", json!({"name": "Calendar"})); + u.frame(vec![]); + assert_eq!(tabs(&u), json!([{"name": "Playground - Calendar", "active": false}])); + u.run("file.close", json!({"index": 0, "force": true})); + u.frame(vec![]); + assert_eq!(tabs(&u)[0]["name"], "Playground - Calendar", "the names stay when the file closes"); +} diff --git a/crates/ui-egui/src/present_tests.rs b/crates/ui-egui/src/present_tests.rs index a1e4ebe2..4679c15d 100644 --- a/crates/ui-egui/src/present_tests.rs +++ b/crates/ui-egui/src/present_tests.rs @@ -23,11 +23,18 @@ impl Ui { } pub(crate) fn frame(&mut self, events: Vec) { + self.frame_output(events); + } + + /// [`Self::frame`], returning what egui output (the AccessKit tree when it's enabled). + pub(crate) fn frame_output(&mut self, events: Vec) -> egui::FullOutput { self.time += 1.0 / 60.0; let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), events, ..Default::default() }; let app = &mut self.app; - self.ctx.run_ui(input, |ui| app.ui(ui)).textures_delta.clear(); + let mut out = self.ctx.run_ui(input, |ui| app.ui(ui)); + out.textures_delta.clear(); + out } pub(crate) fn frames(&mut self, n: usize, events: impl Fn() -> Vec) { @@ -143,6 +150,7 @@ fn the_frame_scrolls_on_the_device_screen_by_wheel_and_drag() { Event::PointerMoved(c), Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, delta: vec2(0.0, -60.0), phase: egui::TouchPhase::Move, modifiers: Default::default(), @@ -175,6 +183,39 @@ fn the_frame_scrolls_on_the_device_screen_by_wheel_and_drag() { assert_eq!(u.scroll_y(), 0.0); } +/// A long top-level frame scrolls on a phone with no overflow setting, as in Figma: the landing +/// page template's Phone frame ("No scrolling", hugging its height) scrolls by the wheel. +#[test] +fn a_top_level_frame_taller_than_the_phone_scrolls_without_an_overflow_setting() { + let mut s = Session::new(); + s.execute("file.newFromTemplate", &json!({"template": "landing-page"})).unwrap(); + s.execute("proto.settings", &json!({"device": "iPhone 16", "responsive": true})).unwrap(); + let doc = s.doc().unwrap(); + let page = doc.first_page().unwrap(); + let phone = doc.top_frames(page).into_iter().find(|f| doc.node(*f).unwrap().name == "Phone").unwrap(); + assert_eq!(doc.node(phone).unwrap().overflow, figcraft_doc::proto::Overflow::None); + let mut u = Ui::new(s); + u.run("proto.preview", json!({"start": phone})); + let range = u.preview()["scrollRange"][1].as_f64().unwrap(); + assert!(range > 1000.0, "the page is far taller than the screen: {range}"); + let c = u.pane().center(); + let wheel = move || { + vec![ + Event::PointerMoved(c), + Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, + delta: vec2(0.0, -60.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + }, + ] + }; + u.frames(6, wheel); + u.frames(20, Vec::new); + assert!(u.scroll_y() > 0.0, "{}", u.scroll_y()); +} + #[test] fn full_window_presenter_shares_the_player() { let (mut u, a, ..) = setup(); diff --git a/crates/ui-egui/src/present_view_tests.rs b/crates/ui-egui/src/present_view_tests.rs index 8fdd5eb0..97fbba03 100644 --- a/crates/ui-egui/src/present_view_tests.rs +++ b/crates/ui-egui/src/present_view_tests.rs @@ -106,14 +106,14 @@ fn presentation_tab_keeps_the_current_screen_when_returning_to_the_editor() { u.button(design_tab, true); u.button(design_tab, false); assert!(u.app.ui.present.is_none()); - assert_eq!(u.app.ui.paused_present.as_ref().map(|p| p.current), Some(b)); + assert_eq!(presenter(&u)["current"], json!(b)); let name_len = u.app.session.doc().unwrap().name.chars().count() as f32; let design_w = (12.0 * 0.6 * name_len + 64.0).clamp(120.0, 220.0); let preview_tab = pos2(8.0 + 44.0 + design_w + 40.0, 20.0); u.button(preview_tab, true); u.button(preview_tab, false); - assert!(u.app.ui.paused_present.is_none()); + assert!(u.app.ui.present.is_some()); assert_eq!(presenter(&u)["current"], json!(b)); } @@ -165,6 +165,7 @@ fn presenter_scales_by_its_options_and_pans_what_overflows() { Event::PointerMoved(at), Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, delta: vec2(0.0, -80.0), phase: egui::TouchPhase::Move, modifiers: Default::default(), @@ -281,6 +282,7 @@ fn the_wheel_coasts_after_it_stops() { Event::PointerMoved(c), Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, delta: vec2(0.0, -40.0), phase: egui::TouchPhase::Move, modifiers: Default::default(), diff --git a/crates/ui-egui/src/proto_canvas.rs b/crates/ui-egui/src/proto_canvas.rs index 956523ee..1faba513 100644 --- a/crates/ui-egui/src/proto_canvas.rs +++ b/crates/ui-egui/src/proto_canvas.rs @@ -21,7 +21,7 @@ struct Drag { } fn drag_id() -> Id { - Id::new("proto_canvas_drag") + Id::unique("proto_canvas_drag") } /// One drawn connection. @@ -61,7 +61,7 @@ struct Sources { pub(crate) fn sources(ctx: &egui::Context, doc: &Arc, page: NodeId) -> Arc> { ctx.data_mut(|d| { - let c = d.get_temp_mut_or_default::(Id::new("proto_canvas_sources")); + let c = d.get_temp_mut_or_default::(Id::unique("proto_canvas_sources")); if !(c.doc.as_ref().is_some_and(|d| Arc::ptr_eq(d, doc)) && c.page == Some(page)) { let ids = doc.subtree(page).into_iter().filter(|i| doc.node(*i).is_some_and(|n| !n.interactions.is_empty())).collect(); *c = Sources { doc: Some(doc.clone()), page: Some(page), ids: Arc::new(ids) }; diff --git a/crates/ui-egui/src/proto_editor.rs b/crates/ui-egui/src/proto_editor.rs index 76ee3044..cc3c352b 100644 --- a/crates/ui-egui/src/proto_editor.rs +++ b/crates/ui-egui/src/proto_editor.rs @@ -32,7 +32,7 @@ pub struct Slot { impl Slot { /// A widget id for `what` of this action. pub fn id(&self, what: &'static str) -> Id { - Id::new((what, self.node, self.k, &self.path)) + Id::unique((what, self.node, self.k, &self.path)) } } @@ -130,7 +130,7 @@ fn unavailable(label: &str) -> &'static str { } fn open_id(cx: &Cx) -> Id { - Id::new(("proto_open_action", cx.node, cx.k)) + Id::unique(("proto_open_action", cx.node, cx.k)) } /// The open action (the conditionals around it are open too); the first one by default. diff --git a/crates/ui-egui/src/proto_editor_tests.rs b/crates/ui-egui/src/proto_editor_tests.rs index 80d49616..e6838a0a 100644 --- a/crates/ui-egui/src/proto_editor_tests.rs +++ b/crates/ui-egui/src/proto_editor_tests.rs @@ -95,7 +95,7 @@ fn the_interaction_editor_shows_conditionals_and_picks_their_variables() { u.app.ui.right_tab = crate::RightTab::Prototype; crate::proto_panel::open_editor(&u.ctx, Some((btn, 0))); // The conditional open, then its nested set variable, then the else-if as an expression. - let open = egui::Id::new(("proto_open_action", btn, 0usize)); + let open = egui::Id::unique(("proto_open_action", btn, 0usize)); for path in [vec![1usize], vec![1, 0, 1], vec![1, 2, 0]] { u.ctx.data_mut(|d| d.insert_temp(open, path)); u.frames(3, Vec::new); @@ -169,7 +169,7 @@ fn typed_conditions_are_checked_and_errors_show_until_fixed() { let mut u = Ui::new(s); u.app.ui.right_tab = crate::RightTab::Prototype; crate::proto_panel::open_editor(&u.ctx, Some((btn, 0))); - u.ctx.data_mut(|d| d.insert_temp(egui::Id::new(("proto_open_action", btn, 0usize)), vec![1usize])); + u.ctx.data_mut(|d| d.insert_temp(egui::Id::unique(("proto_open_action", btn, 0usize)), vec![1usize])); u.frames(3, Vec::new); // The else if is complex: it shows as an expression field. let field = Slot { node: btn, k: 0, path: vec![1] }.id("proto_cond_expr").with(1usize); diff --git a/crates/ui-egui/src/proto_panel.rs b/crates/ui-egui/src/proto_panel.rs index 747dbaf6..972f068b 100644 --- a/crates/ui-egui/src/proto_panel.rs +++ b/crates/ui-egui/src/proto_panel.rs @@ -22,7 +22,7 @@ use crate::widgets::{self, FIELD_H, FieldEdit, Lead, PAD}; /// The interaction whose editor popover is open: (layer, index). fn editor_id() -> Id { - Id::new("proto_editor") + Id::unique("proto_editor") } pub fn open_editor(ctx: &egui::Context, target: Option<(NodeId, usize)>) { @@ -378,17 +378,17 @@ fn flow_row(app: &mut FigApp, ui: &mut Ui, f: &proto::Flow) { let (r, _) = ui.allocate_exact_size(vec2(w, 32.0), Sense::hover()); let button = |dx: f32| Rect::from_center_size(pos2(dx, r.center().y), vec2(24.0, 24.0)); let play = button(r.min.x + PAD + 8.0); - if icon_button(ui, Id::new(("flow_play", f.start)), play, Icon::Play, tr("Present this flow")) { + if icon_button(ui, Id::unique(("flow_play", f.start)), play, Icon::Play, tr("Present this flow")) { app.run("proto.present", json!({"start": f.start})); } let name_rect = Rect::from_min_max(pos2(play.max.x + 6.0, r.center().y - 9.0), pos2(r.max.x - 64.0, r.center().y + 9.0)); - if let Some(n) = widgets::text_field_inline(ui, Id::new(("flow_name", f.start)), &f.name, name_rect, regular(11.0)) { + if let Some(n) = widgets::text_field_inline(ui, Id::unique(("flow_name", f.start)), &f.name, name_rect, regular(11.0)) { app.run("proto.flow.rename", json!({"start": f.start, "name": n})); } - if icon_button(ui, Id::new(("flow_preview", f.start)), button(r.max.x - 44.0), Icon::Preview, tr("Preview this flow")) { + if icon_button(ui, Id::unique(("flow_preview", f.start)), button(r.max.x - 44.0), Icon::Preview, tr("Preview this flow")) { app.run("proto.preview", json!({"start": f.start})); } - if icon_button(ui, Id::new(("flow_rm", f.start)), button(r.max.x - 20.0), Icon::Minus, tr("Remove starting point")) { + if icon_button(ui, Id::unique(("flow_rm", f.start)), button(r.max.x - 20.0), Icon::Minus, tr("Remove starting point")) { app.run("proto.flow.remove", json!({"start": f.start})); } } @@ -479,7 +479,7 @@ fn settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) { } row(ui, 32.0, |ui| { let clicked = widgets::checkbox(ui, settings.responsive, tr("Responsive")); - ui.interact(ui.min_rect(), Id::new("proto_responsive_tip"), Sense::hover()).on_hover_text(tr(RESPONSIVE_TIP)); + ui.interact(ui.min_rect(), Id::unique("proto_responsive_tip"), Sense::hover()).on_hover_text(tr(RESPONSIVE_TIP)); if clicked { app.run("proto.settings", json!({"responsive": !settings.responsive})); } @@ -487,7 +487,7 @@ fn settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) { widgets::sublabels(ui, tr("Background"), None); row(ui, 32.0, |ui| { widgets::swatch(ui, &Paint::solid(bg), 16.0); - if let Some(h) = widgets::text_field(ui, Id::new("proto_bg"), &bg.to_hex(), 96.0, "") + if let Some(h) = widgets::text_field(ui, Id::unique("proto_bg"), &bg.to_hex(), 96.0, "") && let Some(c) = figcraft_color::Color::from_hex_or_name(&h) { app.run("proto.settings", json!({"background": c.to_css_hex()})); @@ -539,7 +539,7 @@ fn custom_size(app: &mut FigApp, ui: &mut Ui, model: Option) { row(ui, 32.0, |ui| { for (key, lead, v) in [("width", "W", d.width), ("height", "H", d.height)] { if let Some(FieldEdit::Set(x) | FieldEdit::Scrub(x, true)) = - widgets::number_field(ui, Id::new(("proto_custom", key)), Lead::Text(lead), Some(v), "", col, 1.0) + widgets::number_field(ui, Id::unique(("proto_custom", key)), Lead::Text(lead), Some(v), "", col, 1.0) && let Some(x) = proto::PrototypeSettings::custom_side(x) && x != v { @@ -570,7 +570,7 @@ fn flow_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, fra if let Some(f) = mine { flow_row(app, ui, &f); row(ui, 32.0, |ui| { - if let Some(d) = widgets::text_field(ui, Id::new(("flow_desc", frame)), &f.description, ui.available_width(), tr("Add a description")) + if let Some(d) = widgets::text_field(ui, Id::unique(("flow_desc", frame)), &f.description, ui.available_width(), tr("Add a description")) && d != f.description { app.run("proto.flow.rename", json!({"start": frame, "description": d})); @@ -597,7 +597,7 @@ fn interactions(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, nod let w = ui.available_width(); let (r, _) = ui.allocate_exact_size(vec2(w, 44.0), Sense::hover()); let body = Rect::from_min_max(pos2(r.min.x + PAD - 6.0, r.min.y + 2.0), pos2(r.max.x - 40.0, r.max.y - 2.0)); - let resp = ui.interact(body, ui.id().with(("proto_it", id, i)), Sense::click()); + let resp = ui.interact(body, ui.scope_id().with(("proto_it", id, i)), Sense::click()); let active = open == Some((id, i)); if active { ui.painter().rect_filled(body, CornerRadius::same(5), t.selected_row); @@ -618,7 +618,7 @@ fn interactions(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, nod open_editor(ui.ctx(), if active { None } else { Some((id, i)) }); } let rm = Rect::from_center_size(pos2(r.max.x - 20.0, r.center().y), vec2(24.0, 24.0)); - if icon_button(ui, ui.id().with(("proto_rm", id, i)), rm, Icon::Minus, tr("Remove interaction")) { + if icon_button(ui, ui.scope_id().with(("proto_rm", id, i)), rm, Icon::Minus, tr("Remove interaction")) { open_editor(ui.ctx(), None); app.run("proto.interaction.remove", json!({"ids": targets_for(app, id), "index": i})); return; @@ -644,7 +644,7 @@ fn editor_popover(app: &mut FigApp, ctx: &egui::Context, doc: &Document, page: N let screen = ctx.content_rect(); let pos = pos2(at.x - width, at.y.min(screen.max.y - 420.0).max(screen.min.y + 48.0)); let mut close = false; - egui::Area::new(Id::new("proto_editor_area")).fixed_pos(pos).order(egui::Order::Foreground).show(ctx, |ui| { + egui::Area::new(Id::unique("proto_editor_area")).fixed_pos(pos).order(egui::Order::Foreground).show(ctx, |ui| { egui::Frame::new() .fill(t.panel) .stroke(Stroke::new(1.0, t.border)) @@ -657,7 +657,7 @@ fn editor_popover(app: &mut FigApp, ctx: &egui::Context, doc: &Document, page: N let (hr, _) = ui.allocate_exact_size(vec2(width, 40.0), Sense::hover()); ui.painter().text(pos2(hr.min.x + PAD, hr.center().y), Align2::LEFT_CENTER, tr("Interaction"), semibold(11.0), t.text); let x = Rect::from_center_size(pos2(hr.max.x - 20.0, hr.center().y), vec2(24.0, 24.0)); - let xr = ui.interact(x, ui.id().with("proto_editor_close"), Sense::click()).tip(tr("Close")); + let xr = ui.interact(x, ui.scope_id().with("proto_editor_close"), Sense::click()).tip(tr("Close")); if xr.hovered() { ui.painter().rect_filled(x, CornerRadius::same(5), t.hover); } @@ -697,7 +697,7 @@ fn animates(a: &Action) -> bool { } fn capture_id() -> Id { - Id::new("proto_key_capture") + Id::unique("proto_key_capture") } fn capturing(ctx: &egui::Context) -> bool { @@ -729,7 +729,7 @@ fn trigger_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Inte Trigger::MouseEnter | Trigger::MouseLeave | Trigger::MouseDown | Trigger::MouseUp => { row(ui, 32.0, |ui| { widgets::label(ui, tr("Delay"), t.text_secondary); - let id = Id::new(("proto_mouse_delay", node, k)); + let id = Id::unique(("proto_mouse_delay", node, k)); if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) = widgets::number_field(ui, id, Lead::None, Some(f64::from(it.delay_ms)), "ms", 96.0, 10.0) && v.is_finite() @@ -742,7 +742,7 @@ fn trigger_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Inte row(ui, 32.0, |ui| { widgets::label(ui, tr("Delay"), t.text_secondary); if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) = - widgets::number_field(ui, Id::new(("proto_delay", node, k)), Lead::None, Some(f64::from(*ms)), "ms", 96.0, 10.0) + widgets::number_field(ui, Id::unique(("proto_delay", node, k)), Lead::None, Some(f64::from(*ms)), "ms", 96.0, 10.0) && v.is_finite() { patch(app, node, k, json!({"trigger": {"type": "afterDelay", "ms": v.clamp(0.0, 600_000.0).round() as u32}})); @@ -1082,7 +1082,7 @@ fn animation_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &In ui.label(egui::RichText::new(ms).font(regular(11.0)).color(t.text_secondary)) .tip(tr("A spring plays until it settles: its mass, stiffness and damping set how long")); } else if let Some(e) = - widgets::number_field(ui, Id::new(("proto_dur", node, k)), Lead::None, Some(f64::from(it.duration_ms)), "ms", 72.0, 10.0) + widgets::number_field(ui, Id::unique(("proto_dur", node, k)), Lead::None, Some(f64::from(it.duration_ms)), "ms", 72.0, 10.0) { match e { FieldEdit::Set(v) => { @@ -1112,7 +1112,7 @@ fn animation_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &In let box_r = Rect::from_min_max(pos2(r.min.x + PAD, r.min.y + 4.0), pos2(r.max.x - PAD, r.max.y - 4.0)); ui.painter().rect_filled(box_r, CornerRadius::same(5), t.field); let period = it.easing.seconds(it.duration_ms).max(0.05) + CURVE_HOLD; - let elapsed = curve_play(ui, Id::new(("proto_curve", node, k)), Id::new((format!("{:?}", it.easing), it.duration_ms)), resp.hovered()); + let elapsed = curve_play(ui, Id::unique(("proto_curve", node, k)), Id::unique((format!("{:?}", it.easing), it.duration_ms)), resp.hovered()); let phase = if resp.hovered() || elapsed < period * CURVE_PLAYS { ui.ctx().request_repaint_after(std::time::Duration::from_millis(33)); ((elapsed % period) / (period - CURVE_HOLD)).min(1.0) @@ -1147,12 +1147,13 @@ fn custom_easing_fields(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, e for i in 0..count { let (Some(name), Some(v), Some(step)) = (names.get(i), vals.get(i), steps.get(i)) else { continue }; let lead = if count == 4 { Lead::Text(name) } else { Lead::None }; - let edit = widgets::number_field(ui, Id::new(("proto_ease", node, k, i)), lead, Some((v * 100.0).round() / 100.0), "", w, *step).map( - |e| match e { - FieldEdit::Set(x) => (x, true, false), - FieldEdit::Scrub(x, done) => (x, done, true), - }, - ); + let edit = + widgets::number_field(ui, Id::unique(("proto_ease", node, k, i)), lead, Some((v * 100.0).round() / 100.0), "", w, *step).map(|e| { + match e { + FieldEdit::Set(x) => (x, true, false), + FieldEdit::Scrub(x, done) => (x, done, true), + } + }); if let Some((x, done, scrub)) = edit && x.is_finite() { diff --git a/crates/ui-egui/src/proto_reorder_tests.rs b/crates/ui-egui/src/proto_reorder_tests.rs index 1a061399..1f70f48d 100644 --- a/crates/ui-egui/src/proto_reorder_tests.rs +++ b/crates/ui-egui/src/proto_reorder_tests.rs @@ -45,7 +45,7 @@ fn setup() -> (Ui, NodeId) { } fn open_id(btn: NodeId) -> egui::Id { - egui::Id::new(("proto_open_action", btn, 0usize)) + egui::Id::unique(("proto_open_action", btn, 0usize)) } /// The kinds of the actions at `prefix` (the interaction's, or a block's). diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index afa884a8..ff3b5f85 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -87,7 +87,7 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { let label = ui.painter().layout_no_wrap(tr("Share").to_string(), semibold(11.5), t.accent_on); let sw = (label.size().x + 24.0).max(56.0); let share = Rect::from_min_size(pos2(top.max.x - 14.0 - sw, top.center().y - 16.0), vec2(sw, 32.0)); - let sresp = ui.interact(share, ui.id().with("share"), Sense::click()).tip(tr("Share: live session, export")); + let sresp = ui.interact(share, ui.scope_id().with("share"), Sense::click()).tip(tr("Share: live session, export")); ui.painter().rect_filled(share, CornerRadius::same(6), if sresp.hovered() { t.accent_hover } else { t.accent }); ui.painter().galley(share.center() - label.size() / 2.0, label, t.accent_on); crate::live::share_popover(app, &sresp); @@ -96,8 +96,8 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { let (tri_r, chev_r) = (Rect::from_min_max(play.min, pos2(play.max.x - 16.0, play.max.y)), Rect::from_min_max(pos2(play.max.x - 16.0, play.min.y), play.max)); let present_keys = crate::toolbar::pretty_shortcut(crate::shortcuts::shortcut("proto.present").unwrap_or("")); - let presp = ui.interact(tri_r, ui.id().with("play"), Sense::click()).tip(format!("{} {present_keys}", tr("Present"))); - let mresp = ui.interact(chev_r, ui.id().with("play_menu"), Sense::click()).tip(tr("Present or preview")); + let presp = ui.interact(tri_r, ui.scope_id().with("play"), Sense::click()).tip(format!("{} {present_keys}", tr("Present"))); + let mresp = ui.interact(chev_r, ui.scope_id().with("play_menu"), Sense::click()).tip(tr("Present or preview")); if presp.hovered() || mresp.hovered() { ui.painter().rect_filled(play, CornerRadius::same(5), t.hover); } @@ -127,7 +127,7 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { for (tab, label) in [(RightTab::Design, tr("Design")), (RightTab::Prototype, tr("Prototype"))] { let galley = ui.painter().layout_no_wrap(label.into(), semibold(11.0), t.text); let r = Rect::from_min_size(pos2(x, tabs.center().y - 12.0), vec2(galley.size().x + 16.0, 24.0)); - let resp = ui.interact(r, ui.id().with(("rtab", label)), Sense::click()); + let resp = ui.interact(r, ui.scope_id().with(("rtab", label)), Sense::click()); let active = app.ui.right_tab == tab; a11y::tab(&resp, label, active); if active { @@ -145,7 +145,7 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { // Zoom menu. let zoom = app.session.ds().map(|d| d.view.zoom).unwrap_or(1.0); let zr = Rect::from_min_size(pos2(tabs.max.x - 64.0, tabs.center().y - 12.0), vec2(56.0, 24.0)); - let zresp = ui.interact(zr, ui.id().with("zoommenu"), Sense::click()); + let zresp = ui.interact(zr, ui.scope_id().with("zoommenu"), Sense::click()); // The value is only spelled out while a screen reader reads the tree. let value = if a11y::active(&zresp.ctx) { format!("{}%", (zoom * 100.0).round()) } else { String::new() }; a11y::combo(&zresp, tr("Zoom"), &value, a11y::menu_open(&zresp)); @@ -286,11 +286,13 @@ pub fn menu_row(ui: &mut Ui, label: &str, shortcut: &str, art_w: f32, art: impl if hovered { ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover); } - let col = t.menu_text; + // These menus sit on the theme's popup fill (white in the light theme), not on the dark + // `t.menu` of the menu bar: the theme's text, white only on the green highlight (#972). + let col = if hovered { t.menu_text } else { t.text }; ui.painter().galley(pos2(r.min.x + MENU_PAD, r.center().y - text.size().y / 2.0), text, col); if let Some(k) = keys { let at = pos2(r.max.x - MENU_PAD - k.size().x, r.center().y - k.size().y / 2.0); - ui.painter().galley(at, k, if hovered { t.menu_text } else { t.menu_dim }); + ui.painter().galley(at, k, if hovered { t.menu_text } else { t.text_secondary }); } art(ui.painter(), r, col); resp @@ -406,7 +408,7 @@ fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) { if widgets::swatch(ui, &p, 16.0).clicked() { open_picker = true; } - if let Some(h) = widgets::text_field(ui, Id::new("pagebg"), &bg.to_hex(), 96.0, "") { + if let Some(h) = widgets::text_field(ui, Id::unique("pagebg"), &bg.to_hex(), 96.0, "") { new_hex = Some(h); } widgets::label(ui, "100 %", t.text); @@ -477,7 +479,7 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let w = ui.available_width(); let (r, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover()); let lr = Rect::from_min_size(pos2(r.min.x + 8.0, r.center().y - 12.0), vec2(120.0, 24.0)); - let resp = ui.interact(lr, ui.id().with("typedd"), Sense::click()); + let resp = ui.interact(lr, ui.scope_id().with("typedd"), Sense::click()); if resp.hovered() { ui.painter().rect_filled(lr, CornerRadius::same(5), t.hover); } @@ -495,7 +497,7 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { } if is_frame { let sr = Rect::from_min_size(pos2(lr.max.x + 4.0, r.center().y - 12.0), vec2(24.0, 24.0)); - let sresp = ui.interact(sr, ui.id().with("swap_orient"), Sense::click()).tip(tr("Swap orientation")); + let sresp = ui.interact(sr, ui.scope_id().with("swap_orient"), Sense::click()).tip(tr("Swap orientation")); if sresp.hovered() { ui.painter().rect_filled(sr, CornerRadius::same(5), t.hover); } @@ -534,7 +536,7 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { for (icon, label, cmd) in actions.into_iter().rev() { let br = Rect::from_min_size(pos2(x - 24.0, r.center().y - 12.0), vec2(24.0, 24.0)); let tip = if cmd == "bool.union" { label } else { crate::shortcuts::tip(label, cmd) }; - let bresp = ui.interact(br, ui.id().with(("qa", cmd)), Sense::click()).tip(tip); + let bresp = ui.interact(br, ui.scope_id().with(("qa", cmd)), Sense::click()).tip(tip); if bresp.hovered() { ui.painter().rect_filled(br, CornerRadius::same(5), t.hover); } @@ -638,9 +640,9 @@ fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId] let absolute = common(doc, sel, |n| n.child_layout.absolute); widgets::sublabels(ui, tr("Position"), None); row(ui, 32.0, |ui| { - let e = widgets::number_field_mixed(ui, Id::new("pos_x"), Lead::Text("X"), x, "", col, 1.0); + let e = widgets::number_field_mixed(ui, Id::unique("pos_x"), Lead::Text("X"), x, "", col, 1.0); app.field_of(&ids, "x", e, x_of, |v| json!(v)); - let e = widgets::number_field_mixed(ui, Id::new("pos_y"), Lead::Text("Y"), y, "", col, 1.0); + let e = widgets::number_field_mixed(ui, Id::unique("pos_y"), Lead::Text("Y"), y, "", col, 1.0); app.field_of(&ids, "y", e, y_of, |v| json!(v)); // Children of auto layout frames can ignore the layout (absolute position). if parent_al { @@ -660,7 +662,7 @@ fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId] }); widgets::sublabels(ui, tr("Rotation"), None); row(ui, 32.0, |ui| { - let e = widgets::number_field_mixed(ui, Id::new("pos_rot"), Lead::Icon(Icon::Angle, tr("Rotation")), rot, "°", col, 1.0); + let e = widgets::number_field_mixed(ui, Id::unique("pos_rot"), Lead::Icon(Icon::Angle, tr("Rotation")), rot, "°", col, 1.0); app.field_of(&ids, "rotation", e, rot_of, |v| json!(v)); let items = [ (Icon::Rotate, tr("Rotate 90° right")), @@ -743,7 +745,7 @@ fn arc_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) -> widgets::section_header(ui, tr("Arc"), &[], false); let col = widgets::col_width(ui.available_width()); let field = |app: &mut FigApp, ui: &mut Ui, key: &str, icon: Icon, name: &str, value: Option, suffix: &str| { - crate::variable_ui::number_field(app, ui, &ids, key, Id::new(("arc", key)), Lead::Icon(icon, name), value, suffix, col) + crate::variable_ui::number_field(app, ui, &ids, key, Id::unique(("arc", key)), Lead::Icon(icon, name), value, suffix, col) }; widgets::sublabels(ui, tr("Start"), Some(tr("Sweep"))); row(ui, 32.0, |ui| { @@ -785,10 +787,10 @@ fn points_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) let col = widgets::col_width(ui.available_width()); widgets::sublabels(ui, tr("Count"), all_stars.then_some(tr("Ratio"))); row(ui, 32.0, |ui| { - let e = widgets::number_field(ui, Id::new("pts_count"), Lead::Icon(Icon::Polygon, tr("Count")), count, "", col, 1.0); + let e = widgets::number_field(ui, Id::unique("pts_count"), Lead::Icon(Icon::Polygon, tr("Count")), count, "", col, 1.0); app.field(&ids, "pointCount", e, |v| json!(v.round().clamp(3.0, 120.0))); if all_stars { - let e = widgets::number_field(ui, Id::new("pts_ratio"), Lead::Icon(Icon::Star, tr("Ratio")), ratio, "%", col, 1.0); + let e = widgets::number_field(ui, Id::unique("pts_ratio"), Lead::Icon(Icon::Star, tr("Ratio")), ratio, "%", col, 1.0); app.field(&ids, "innerRadius", e, |v| json!((v / 100.0).clamp(0.001, 1.0))); } }); @@ -825,7 +827,7 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { crate::variable_ui::open_at_pointer(ui, &ids, "visible", VariableType::Boolean); } if hdr == Some(2) { - ui.data_mut(|d| d.insert_temp(Id::new("blend_open"), !d.get_temp::(Id::new("blend_open")).unwrap_or(false))); + ui.data_mut(|d| d.insert_temp(Id::unique("blend_open"), !d.get_temp::(Id::unique("blend_open")).unwrap_or(false))); } let col = widgets::col_width(ui.available_width()); crate::variable_ui::bound_row(app, ui, &ids, "visible", VariableType::Boolean, Lead::Icon(Icon::Eye, tr("Visible")), col * 2.0 + 8.0); @@ -854,7 +856,7 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { widgets::sublabels(ui, tr("Opacity"), radius_ok.then_some(tr("Corner radius"))); row(ui, 32.0, |ui| { let lead = Lead::Icon(Icon::Opacity, tr("Opacity")); - let e = crate::variable_ui::number_field_mixed(app, ui, &ids, "opacity", Id::new("ap_op"), lead, op, "%", col); + let e = crate::variable_ui::number_field_mixed(app, ui, &ids, "opacity", Id::unique("ap_op"), lead, op, "%", col); app.field_of(&ids, "opacity", e, op_of, |v| json!((v / 100.0).clamp(0.0, 1.0))); if radius_ok { let e = crate::variable_ui::number_field_mixed( @@ -862,7 +864,7 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { ui, &ids, "cornerRadius", - Id::new("ap_rad"), + Id::unique("ap_rad"), Lead::Icon(Icon::CornerRadius, tr("Corner radius")), r.flatten(), "", @@ -871,62 +873,50 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { // A layer with corners of its own starts from its largest. let radius_of = |n: &Node| n.corners.uniform().unwrap_or_else(|| n.corners.radii.iter().copied().fold(0.0, f64::max)); app.field_of(&ids, "cornerRadius", e, radius_of, |v| json!(v.max(0.0))); - let open = ui.data(|d| d.get_temp::(Id::new("indep_corners"))).unwrap_or(false); + let open = ui.data(|d| d.get_temp::(Id::unique("indep_corners"))).unwrap_or(false); if indep_ok && icons::button(ui, Icon::IndependentCorners, 24.0, open, tr("Individual corners")).clicked() { - ui.data_mut(|d| d.insert_temp(Id::new("indep_corners"), !open)); + ui.data_mut(|d| d.insert_temp(Id::unique("indep_corners"), !open)); } } }); - if indep_ok && ui.data(|d| d.get_temp::(Id::new("indep_corners"))).unwrap_or(false) { + if indep_ok && ui.data(|d| d.get_temp::(Id::unique("indep_corners"))).unwrap_or(false) { let radii = common(doc, sel, |n| n.corners.radii); - let keys = [ - ("topLeftRadius", tr("Top left radius")), - ("topRightRadius", tr("Top right radius")), - ("bottomRightRadius", tr("Bottom right radius")), - ("bottomLeftRadius", tr("Bottom left radius")), + // `k` indexes `radii` ([top left, top right, bottom right, bottom left]); the fields sit + // where their corners are: top left, top right / bottom left, bottom right. + let corners = [ + ("topLeftRadius", tr("Top left radius"), Icon::CornerTopLeft, 0), + ("topRightRadius", tr("Top right radius"), Icon::CornerTopRight, 1), + ("bottomLeftRadius", tr("Bottom left radius"), Icon::CornerBottomLeft, 3), + ("bottomRightRadius", tr("Bottom right radius"), Icon::CornerBottomRight, 2), ]; - row(ui, 32.0, |ui| { - for (k, (key, name)) in keys.iter().enumerate().take(2) { - let e = crate::variable_ui::number_field( - app, - ui, - &ids, - key, - Id::new(("corner", k)), - Lead::Icon(Icon::CornerRadius, name), - radii.map(|r| r[k]), - "", - col, - ); - app.field(&ids, key, e, |v| json!(v.max(0.0))); - } - }); - row(ui, 32.0, |ui| { - for (k, (key, name)) in keys.iter().enumerate().skip(2) { - let e = crate::variable_ui::number_field( - app, - ui, - &ids, - key, - Id::new(("corner", k)), - Lead::Icon(Icon::CornerRadius, name), - radii.map(|r| r[k]), - "", - col, - ); - app.field(&ids, key, e, |v| json!(v.max(0.0))); - } - }); + for pair in corners.chunks(2) { + row(ui, 32.0, |ui| { + for (key, name, icon, k) in pair { + let e = crate::variable_ui::number_field( + app, + ui, + &ids, + key, + Id::unique(("corner", *k)), + Lead::Icon(*icon, name), + radii.map(|r| r[*k]), + "", + col, + ); + app.field(&ids, key, e, |v| json!(v.max(0.0))); + } + }); + } let sm = common(doc, sel, |n| (n.corners.smoothing * 100.0).round()); widgets::sublabels(ui, tr("Corner smoothing"), None); row(ui, 32.0, |ui| { - let e = widgets::number_field(ui, Id::new("smoothing"), Lead::None, sm, "%", col, 1.0); + let e = widgets::number_field(ui, Id::unique("smoothing"), Lead::None, sm, "%", col, 1.0); app.field(&ids, "cornerSmoothing", e, |v| json!((v / 100.0).clamp(0.0, 1.0))); widgets::label(ui, "iOS: 60%", Tokens::get(ui.ctx()).text_tertiary); }); } let blend = common(doc, sel, |n| n.blend); - let show_blend = ui.data(|d| d.get_temp::(Id::new("blend_open"))).unwrap_or(false) + let show_blend = ui.data(|d| d.get_temp::(Id::unique("blend_open"))).unwrap_or(false) || blend.is_some_and(|b| !matches!(b, BlendMode::Normal | BlendMode::PassThrough)); if show_blend { widgets::sublabels(ui, tr("Blend mode"), None); @@ -995,7 +985,7 @@ fn variable_modes(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) let name = widgets::elide(ui, &c.name, name_w); ui.painter().text(pos2(rect.min.x, rect.center().y), Align2::LEFT_CENTER, &name, regular(11.0), t.text); if name != c.name || r.library.is_some() { - ui.interact(rect, Id::new(("var_mode_name", &c.id)), Sense::hover()).on_hover_text(&hover); + ui.interact(rect, Id::unique(("var_mode_name", &c.id)), Sense::hover()).on_hover_text(&hover); } let resp = widgets::dropdown(ui, &label, pick_w, None); menu_popup(&resp).width(180.0).show(|ui| { @@ -1054,13 +1044,13 @@ fn range_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId, start: match fills.as_ref().and_then(|f| f.first()) { Some(p) => { widgets::paint_swatch(ui.painter(), sw, p); - if ui.interact(sw.expand(2.0), Id::new("range_swatch"), Sense::click()).clicked() { + if ui.interact(sw.expand(2.0), Id::unique("range_swatch"), Sense::click()).clicked() { app.ui.picker = Some(crate::colorpicker::PickerTarget::TextRange { id, start, end }); } if let Some(c) = p.solid_color() { let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_r)); if let Some(h) = - widgets::text_field_inline(&mut child, Id::new("range_hex"), &c.to_hex(), text_r.shrink2(vec2(0.0, 4.0)), regular(11.0)) + widgets::text_field_inline(&mut child, Id::unique("range_hex"), &c.to_hex(), text_r.shrink2(vec2(0.0, 4.0)), regular(11.0)) && let Some(nc) = figcraft_color::Color::from_hex_or_name(&h) { app.run("text.style", json!({"id": id, "start": start, "end": end, "textFills": [nc.to_css_hex()]})); @@ -1071,7 +1061,7 @@ fn range_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId, start: } None => { ui.painter().text(pos2(text_r.min.x, r.center().y), Align2::LEFT_CENTER, tr("Mixed"), regular(11.0), t.text_secondary); - if ui.interact(r, Id::new("range_mixed"), Sense::click()).clicked() { + if ui.interact(r, Id::unique("range_mixed"), Sense::click()).clicked() { app.ui.picker = Some(crate::colorpicker::PickerTarget::TextRange { id, start, end }); } } @@ -1167,7 +1157,7 @@ fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills: ui, &ids, "strokeWeight", - Id::new("stroke_w"), + Id::unique("stroke_w"), Lead::Icon(Icon::Menu, tr("Stroke weight")), weight, "", @@ -1193,7 +1183,7 @@ fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills: } }); } - let adv_id = Id::new("stroke_adv"); + let adv_id = Id::unique("stroke_adv"); let open = ui.data(|d| d.get_temp::(adv_id)).unwrap_or(false); if icons::button(ui, Icon::Dots, 24.0, open, tr("Advanced stroke")).clicked() { ui.data_mut(|d| d.insert_temp(adv_id, !open)); @@ -1208,13 +1198,13 @@ fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills: row(ui, 32.0, |ui| { for (k, (label, field)) in fields.clone().enumerate().skip(first).take(2) { let v = sides.get(k).copied(); - let e = crate::variable_ui::number_field(app, ui, &ids, field, Id::new(("stroke_side", k)), Lead::Text(label), v, "", col); + let e = crate::variable_ui::number_field(app, ui, &ids, field, Id::unique(("stroke_side", k)), Lead::Text(label), v, "", col); app.field(&ids, field, e, |x| json!(x.max(0.0))); } }); } } - if ui.data(|d| d.get_temp::(Id::new("stroke_adv"))).unwrap_or(false) { + if ui.data(|d| d.get_temp::(Id::unique("stroke_adv"))).unwrap_or(false) { stroke_settings(app, ui, doc, sel); } } @@ -1372,16 +1362,16 @@ fn brush_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], brush: Option