diff --git a/.cargo/config.toml b/.cargo/config.toml index acebfe9f..8abc94d7 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -3,5 +3,12 @@ [env] CRAFT_FONTS_DIR = { value = "../craft-fonts", relative = true } +# The Windows UI Automation adapter can be asked about a node that disappeared in the +# same frame. On MinGW, converting that HRESULT through windows-result's thread COM +# error-info path has crashed in GetErrorInfo. Keep the HRESULT without that extra +# COM lookup; this leaves the AccessKit tree and screen-reader support enabled. +[target.x86_64-pc-windows-gnu] +rustflags = ["--cfg=windows_slim_errors"] + [alias] xtask = "run -q -p xtask --" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1968076f..b79af502 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,11 +21,10 @@ jobs: # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository. uses: storytold/craft-common-actions/.github/workflows/gate.yml@main with: - # The craft-fonts commit release.yml builds with. Needs fonts/latin-manifest.txt (Inter): - # 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 + # Combined Latin presentation and core UI catalog. Required web builds enforce + # Inter Regular/SemiBold and Noto Sans Georgian Regular after asset-policy filtering. + craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69 + require-craft-fonts: true 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..80adf16a 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: 37827adc41fd165f9a9234ac40f94dbad353aa69 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dd2aefdd..f93219fa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,13 +24,12 @@ jobs: with: app-name: figcraft app-title: FigCraft - # 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 + # Combined Latin presentation and core UI catalog. Required web builds enforce + # Inter Regular/SemiBold and Noto Sans Georgian Regular after asset-policy filtering. + craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69 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. + # crates/text/build.rs keeps the web allowlist small (Inter and Georgian). web-craft-fonts: true version: ${{ inputs.version }} secrets: diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 96dff6a9..46b789a8 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -26,8 +26,17 @@ Release builds embed fonts from the optional craft-fonts build input |---|---|---| | Inter | SIL Open Font License 1.1 | https://github.com/rsms/inter | | Archivo | SIL Open Font License 1.1 | [Google Fonts / Archivo](https://github.com/google/fonts/tree/6c70c829f09ea345d3590406693220ea35c6553f/ofl/archivo) | -| Roboto, Montserrat, Lato, Open Sans, Poppins, Nunito Sans, Merriweather, Playfair Display, Source Sans 3, Source Serif 4, Carlito, Caladea | SIL Open Font License 1.1 | Google Fonts / upstream repositories (see craft-fonts) | +| Roboto, Montserrat, Lato, Open Sans, Poppins, Nunito Sans, Merriweather, Playfair Display, Carlito, Caladea | SIL Open Font License 1.1 | Google Fonts / upstream repositories (see craft-fonts) | | Liberation Sans, Serif, Mono | SIL Open Font License 1.1 | https://github.com/liberationfonts | -| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic | SIL Open Font License 1.1 | Google Fonts (see craft-fonts) | +| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic, Noto Sans Georgian | SIL Open Font License 1.1 | Google Fonts (see craft-fonts) | + +The central catalog also contains Adobe-authored Source Sans 3 and Source Serif 4. +FigCraft filters both families from every build in accordance with its asset policy. egui's default fonts (compiled into the `egui`/`epaint` crates) keep their own licences (OFL / Apache-2.0 / Ubuntu Font Licence); see those crates. + +## egui dependency font icons + +| Asset | Author | Source | Licence | Use | +|---|---|---|---|---| +| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 6d518fc7](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset, included by default_fonts independently of monochrome_emoji_fonts; emoji-icon-font.ttf is the full monochrome fallback. Dependency assets unmodified here; no font file copied into this repository. | diff --git a/Cargo.lock b/Cargo.lock index fabd85f8..8d6b8c87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,21 +4,21 @@ version = 4 [[package]] name = "accesskit" -version = "0.24.1" +version = "0.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +checksum = "4e883d1d28c5fd5568c79ed2d2bd48d9f2fd3945c8974ed0f53690ceddb051be" dependencies = [ "uuid", ] [[package]] name = "accesskit_atspi_common" -version = "0.18.1" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +checksum = "24f5baf62e34a4a6c30e3d697e4782f821a7337049c44c3b40576a27dfbb580d" 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.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +checksum = "f3da8f27636015191b2264ee1df681503170a632cafd832fdc1edfdd4e3a2fd3" dependencies = [ "accesskit", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] -name = "accesskit_consumer" -version = "0.36.0" +name = "accesskit_ios" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +checksum = "a5280724673bbfa8b30a838c013500b719392989be3b97c5a9964062c252c232" 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.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +checksum = "123828ceeec755b05255e3976cb0852993202de98a0626c97dd8d34246991f84" 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.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +checksum = "30d5192871228c54f1b0fb4a85cef000e11a3804f25d2165f14afa0daa67f3f6" dependencies = [ "accesskit", "accesskit_atspi_common", @@ -89,13 +83,13 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.32.1" +version = "0.35.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +checksum = "498668b90da9995302a0055aaecad4756932333da2ddef6baee1c00844c430fc" 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.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +checksum = "cd4ab4437b520ff03088fbe48676a11e7d44a929535e64b31432c1073e48dbcc" dependencies = [ "accesskit", + "accesskit_ios", "accesskit_macos", "accesskit_unix", "accesskit_windows", @@ -700,9 +695,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" dependencies = [ "find-msvc-tools", "jobserver", @@ -753,9 +748,9 @@ dependencies = [ [[package]] name = "clipboard-win" -version = "5.4.1" +version = "5.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a" dependencies = [ "error-code", ] @@ -836,6 +831,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -849,7 +854,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "core-graphics-types", "foreign-types", "libc", @@ -862,7 +867,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "libc", ] @@ -904,12 +909,36 @@ name = "craft-docpath" version = "0.1.0" source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" +[[package]] +name = "craft-update" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +dependencies = [ + "chrono", + "dunce", + "egui", + "flate2", + "getrandom 0.3.4", + "log", + "plist", + "semver", + "serde", + "serde_json", + "sha2", + "tar", + "tempfile", + "ureq", + "verifysign", + "winreg", + "winsafe", + "zip 9.1.1", +] + [[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=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f" dependencies = [ - "egui", "wayland-client", ] @@ -1032,6 +1061,12 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + [[package]] name = "derive_arbitrary" version = "1.5.0" @@ -1124,10 +1159,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] -name = "ecolor" -version = "0.36.2" +name = "dunce" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ecolor" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "emath", @@ -1135,9 +1175,8 @@ dependencies = [ [[package]] name = "eframe" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1159,6 +1198,7 @@ dependencies = [ "pollster 1.0.1", "profiling", "raw-window-handle", + "send_wrapper", "static_assertions", "wasm-bindgen", "web-sys", @@ -1170,9 +1210,8 @@ dependencies = [ [[package]] name = "egui" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit", "ahash", @@ -1190,9 +1229,8 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1210,30 +1248,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=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit_winit", "arboard", + "block2 0.6.2", "bytemuck", "egui", + "image", "log", "objc2 0.6.5", + "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", + "parking_lot", "profiling", "raw-window-handle", "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=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "egui", @@ -1245,9 +1286,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=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "egui", @@ -1265,9 +1305,8 @@ checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" [[package]] name = "emath" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", ] @@ -1292,22 +1331,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 +1395,8 @@ dependencies = [ [[package]] name = "epaint" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1374,16 +1412,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=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" [[package]] name = "equivalent" @@ -1465,9 +1504,10 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" [[package]] name = "figcraft" -version = "0.10.0" +version = "0.11.2" dependencies = [ "arboard", + "craft-update", "craft-window", "eframe", "egui", @@ -1491,7 +1531,7 @@ dependencies = [ [[package]] name = "figcraft-cli" -version = "0.10.0" +version = "0.11.2" dependencies = [ "figcraft-doc", "figcraft-engine", @@ -1505,7 +1545,7 @@ dependencies = [ [[package]] name = "figcraft-collab" -version = "0.10.0" +version = "0.11.2" dependencies = [ "base64 0.22.1", "chrono", @@ -1521,7 +1561,7 @@ dependencies = [ [[package]] name = "figcraft-collab-net" -version = "0.10.0" +version = "0.11.2" dependencies = [ "base64 0.22.1", "figcraft-collab", @@ -1544,7 +1584,7 @@ dependencies = [ [[package]] name = "figcraft-color" -version = "0.10.0" +version = "0.11.2" dependencies = [ "kurbo", "serde", @@ -1553,7 +1593,7 @@ dependencies = [ [[package]] name = "figcraft-doc" -version = "0.10.0" +version = "0.11.2" dependencies = [ "figcraft-color", "figcraft-geom", @@ -1565,7 +1605,7 @@ dependencies = [ [[package]] name = "figcraft-engine" -version = "0.10.0" +version = "0.11.2" dependencies = [ "base64 0.22.1", "craft-docpath", @@ -1593,7 +1633,7 @@ dependencies = [ [[package]] name = "figcraft-format" -version = "0.10.0" +version = "0.11.2" dependencies = [ "figcraft-doc", "figcraft-pathops", @@ -1603,12 +1643,12 @@ dependencies = [ "serde", "serde_json", "sha2", - "zip", + "zip 2.6.1", ] [[package]] name = "figcraft-geom" -version = "0.10.0" +version = "0.11.2" dependencies = [ "kurbo", "serde", @@ -1616,7 +1656,7 @@ dependencies = [ [[package]] name = "figcraft-layout" -version = "0.10.0" +version = "0.11.2" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1624,7 +1664,7 @@ dependencies = [ [[package]] name = "figcraft-mcp" -version = "0.10.0" +version = "0.11.2" dependencies = [ "base64 0.22.1", "figcraft-collab-net", @@ -1640,7 +1680,7 @@ dependencies = [ [[package]] name = "figcraft-pathops" -version = "0.10.0" +version = "0.11.2" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1649,7 +1689,7 @@ dependencies = [ [[package]] name = "figcraft-pdf" -version = "0.10.0" +version = "0.11.2" dependencies = [ "figcraft-color", "figcraft-doc", @@ -1666,7 +1706,7 @@ dependencies = [ [[package]] name = "figcraft-plugin" -version = "0.10.0" +version = "0.11.2" dependencies = [ "figcraft-color", "serde", @@ -1679,7 +1719,7 @@ dependencies = [ [[package]] name = "figcraft-render" -version = "0.10.0" +version = "0.11.2" dependencies = [ "crc32fast", "figcraft-color", @@ -1697,7 +1737,7 @@ dependencies = [ [[package]] name = "figcraft-server" -version = "0.10.0" +version = "0.11.2" dependencies = [ "crc32fast", "ctrlc", @@ -1716,12 +1756,12 @@ dependencies = [ "toml", "tungstenite", "wat", - "zip", + "zip 2.6.1", ] [[package]] name = "figcraft-svg" -version = "0.10.0" +version = "0.11.2" dependencies = [ "base64 0.22.1", "figcraft-color", @@ -1737,7 +1777,7 @@ dependencies = [ [[package]] name = "figcraft-text" -version = "0.10.0" +version = "0.11.2" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1751,10 +1791,11 @@ dependencies = [ [[package]] name = "figcraft-ui-egui" -version = "0.10.0" +version = "0.11.2" dependencies = [ "base64 0.22.1", "chrono", + "craft-update", "egui", "egui_extras", "figcraft-color", @@ -1777,7 +1818,7 @@ dependencies = [ [[package]] name = "figcraft-web" -version = "0.10.0" +version = "0.11.2" dependencies = [ "eframe", "egui", @@ -1791,6 +1832,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.15" @@ -2033,9 +2084,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -2061,9 +2114,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", @@ -2281,9 +2334,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" dependencies = [ "displaydoc", "potential_utf", @@ -2295,9 +2348,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" dependencies = [ "displaydoc", "litemap", @@ -2308,9 +2361,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -2322,15 +2375,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" [[package]] name = "icu_properties" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" dependencies = [ "displaydoc", "icu_collections", @@ -2343,15 +2396,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" [[package]] name = "icu_provider" -version = "2.3.1" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" dependencies = [ "displaydoc", "icu_locale_core", @@ -2756,9 +2809,9 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmap2" -version = "0.9.11" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +checksum = "6b0ca1155a9ecc25aa12e8313a119f32edf0255fea2f44839c4ac0a12b2ac157" dependencies = [ "libc", ] @@ -2897,6 +2950,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-traits" version = "0.2.19" @@ -3252,6 +3311,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "orbclient" version = "0.3.55" @@ -3264,9 +3329,9 @@ dependencies = [ [[package]] name = "ordered-float" -version = "5.5.0" +version = "5.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" +checksum = "7f59006ce1e204c30956638535b74d317bca99477b9356dfaec54c37f6f600ab" dependencies = [ "num-traits", ] @@ -3364,9 +3429,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", @@ -3375,9 +3440,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", @@ -3385,9 +3450,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", @@ -3398,9 +3463,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", ] @@ -3460,6 +3525,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "plist" +version = "1.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285" +dependencies = [ + "base64 0.23.1", + "indexmap", + "quick-xml 0.42.0", + "serde", + "time", +] + [[package]] name = "png" version = "0.18.1" @@ -3544,6 +3622,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + [[package]] name = "presser" version = "0.3.1" @@ -3621,6 +3705,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.47" @@ -3881,9 +3974,9 @@ checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" [[package]] name = "roxmltree" -version = "0.21.1" +version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907" dependencies = [ "memchr", ] @@ -3950,6 +4043,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -3959,6 +4064,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + [[package]] name = "rustls-webpki" version = "0.103.16" @@ -4036,6 +4168,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -4059,6 +4200,29 @@ dependencies = [ "tiny-skia 0.11.4", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "self_cell" version = "1.3.0" @@ -4070,6 +4234,16 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" [[package]] name = "serde" @@ -4256,9 +4430,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.16.3" +version = "1.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" dependencies = [ "serde", ] @@ -4442,6 +4616,16 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -4518,6 +4702,36 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-skia" version = "0.11.4" @@ -4571,9 +4785,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" dependencies = [ "displaydoc", "zerovec", @@ -4587,9 +4801,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.54.0" +version = "1.54.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" dependencies = [ "pin-project-lite", ] @@ -4722,6 +4936,12 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "typed-path" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e" + [[package]] name = "typenum" version = "1.20.1" @@ -4811,6 +5031,35 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b" +dependencies = [ + "base64 0.23.1", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "ureq-proto", + "utf8-zero", +] + +[[package]] +name = "ureq-proto" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -4838,7 +5087,7 @@ dependencies = [ "kurbo", "log", "pico-args", - "roxmltree 0.21.1", + "roxmltree 0.21.2", "simplecss", "siphasher", "skrifa 0.44.0", @@ -4851,6 +5100,12 @@ dependencies = [ "xmlwriter", ] +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -4865,9 +5120,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.28.0" +version = "1.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" dependencies = [ "js-sys", "serde_core", @@ -4876,9 +5131,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", @@ -4891,9 +5146,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", @@ -4901,6 +5156,16 @@ dependencies = [ "vello_common", ] +[[package]] +name = "verifysign" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6acfcc42a8d44f340d001193d97ef56bf33210b51ad760ae8308c97d5241ca51" +dependencies = [ + "core-foundation 0.10.1", + "windows-sys 0.61.2", +] + [[package]] name = "version_check" version = "0.9.5" @@ -5209,7 +5474,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.41.0", "quote", ] @@ -5247,9 +5512,9 @@ dependencies = [ [[package]] name = "webbrowser" -version = "1.2.4" +version = "1.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" +checksum = "d2c8ddfbbf66e501434657400268cefe51b95140a0266236415cda7627732df7" dependencies = [ "jni", "log", @@ -5262,10 +5527,19 @@ dependencies = [ ] [[package]] -name = "webpki-roots" -version = "1.0.10" +name = "webpki-root-certs" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" dependencies = [ "rustls-pki-types", ] @@ -5693,8 +5967,7 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winit" version = "0.30.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +source = "git+https://github.com/storytold/winit.git?rev=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc" dependencies = [ "ahash", "android-activity", @@ -5705,7 +5978,7 @@ dependencies = [ "calloop 0.13.0", "cfg_aliases", "concurrent-queue", - "core-foundation", + "core-foundation 0.9.4", "core-graphics", "cursor-icon", "dpi", @@ -5751,6 +6024,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + [[package]] name = "winresource" version = "0.1.34" @@ -5761,6 +6044,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "winsafe" +version = "0.0.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef0ffc427f045c0cc9ebffd6f4f91153dcd2a1547b5c3c29ee3f541e16e95c6" + [[package]] name = "wit-bindgen" version = "0.57.1" @@ -5881,7 +6170,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.10.0" +version = "0.11.2" dependencies = [ "figcraft-engine", "serde_json", @@ -5889,9 +6178,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -6076,9 +6365,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.8" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" dependencies = [ "yoke", "zerofrom", @@ -6087,9 +6376,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" dependencies = [ "proc-macro2", "quote", @@ -6111,6 +6400,23 @@ dependencies = [ "zopfli", ] +[[package]] +name = "zip" +version = "9.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "518b847fd864440c379748ad684c449f293af68978a485a4ff16f7c901c1f7c4" +dependencies = [ + "crc32fast", + "flate2", + "getrandom 0.4.3", + "indexmap", + "memchr", + "nix", + "typed-path", + "windows-sys 0.61.2", + "zopfli", +] + [[package]] name = "zlib-rs" version = "0.6.8" @@ -6190,3 +6496,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=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_inspection" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_kittest" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_system_font_provider" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" diff --git a/Cargo.toml b/Cargo.toml index 65809315..64a8c8a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ exclude = ["plugins"] # default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"] [workspace.package] -version = "0.10.0" +version = "0.11.2" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -35,8 +35,12 @@ 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 = "25da1314d443171b4adeaad2cd9454b273f35c6f" } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] } # foundation kurbo = { version = "0.13", features = ["serde"] } @@ -79,9 +83,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" @@ -122,14 +126,32 @@ codegen-units = 4 # wasm32-unknown-unknown always aborts on panic, so panic = "abort" changes nothing there. [profile.web-release] inherits = "release" -opt-level = "s" +# "z", not "s": 1.9 MB smaller (24.0 MiB -> 22.2 MiB after wasm-opt, 0.11.1), back under the gate. +opt-level = "z" lto = "fat" 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 = "6d518fc799681571963842f95b55a6b68431b15a" } +emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" } + # 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. -[patch.crates-io] krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" } diff --git a/NOTICE b/NOTICE index a0baaa27..9970631d 100644 --- a/NOTICE +++ b/NOTICE @@ -51,3 +51,20 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +egui icon font subset (dependency asset) +---------------------------------------- +epaint_default_fonts 0.37 embeds egui-icons.ttf, a five-glyph subset of +emoji-icon-font by John Slegers. The full monochrome emoji-icon-font fallback +uses the same MIT license. Upstream license text follows unchanged. + +MIT License + +Copyright (c) 2014 John Slegers + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/ROADMAP.md b/ROADMAP.md index 05b7ed65..6f4d2884 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,8 +1,13 @@ # FigCraft Roadmap +M9 / M7.4 server memory follow-up: read-only disconnects retain their durable client-id journal +without rebuilding the image-bearing snapshot. Snapshot encoding appends into its header buffer, +avoiding a second full payload copy while preserving the existing disk format. See +[`docs/server-memory-retention.md`](docs/server-memory-retention.md). + **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; `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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; 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) 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 @@ -86,7 +91,7 @@ native speakers' reviews of the nine language drafts need people. | Styles & variables | 95% | 76% | 2–4 | [components-parity](docs/components-parity.md) | | Prototyping | 92% | 74% | 2–5 | [prototyping-parity](docs/prototyping-parity.md) | | Import/export | 86% | 58% | 5–10 | [file-format-parity](docs/file-format-parity.md) | -| Dev Mode / inspect | 97% | 76% | 2–4 | [dev-mode-parity](docs/dev-mode-parity.md) | +| Dev Mode / inspect | 98% | 78% | 1–2 | [dev-mode-parity](docs/dev-mode-parity.md) | | Comments, versions, misc | 87% | 63% | 1–3 | [collaboration-parity](docs/collaboration-parity.md) | | Multiplayer & sharing | 17% | 5% | 42–84 | [collaboration-parity](docs/collaboration-parity.md) | | Plugins & widgets | 60% | 35% | 8–26 | [plugins](docs/plugins.md) | @@ -132,6 +137,9 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. +- **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change. +- **2026-10-24** · Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 98% / 78%. - **2026-10-24** · Boards (M12): File ▸ New board makes a board, a file kind of its own saved in `.figcraft` and carried to people joining a live session. Sticky notes (8 colours, text that shrinks as the note grows, the author's name, Shift+Cmd/Ctrl+Enter for the next one), shapes with text (7 outlines), connectors that re-route when what they join moves (computed in the engine's normalize pass, so local edits, undo and others' edits agree), marker, highlighter and eraser, stamps with the placer's name on hover; a board toolbar, a contextual bar over the selection and the Design panel hidden until asked for; prototyping and Dev Mode refuse on boards. Design files and boards have their own marks (a pen nib, a note) on tabs, the home screen and the Open files list. Checked in the app, with two app instances in one live session, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)). - **2026-10-15** · Auto-save (M1, #305): Preferences ▸ Save files automatically (`pref.autoSave`, off by default, desktop only) saves each open file that has a path 3 s after its last change, never during a gesture, a live text preview or while a version is shown; the file is encoded off the UI thread, a failed save says why and is tried again a minute later. Numbers unchanged. - **2026-10-15** · A text box by dragging (M1, #363): a drag with the Text tool makes a fixed-size text box the size of the drag; a click still makes auto-width text. Numbers unchanged. @@ -142,6 +150,10 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). - **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-20** · CLI option terminator (M0.9): `--` ends option parsing, so dash-prefixed + filenames open normally; the global `--no-system-fonts` flag also respects the terminator. + Binary regression coverage opens five such native files and checks their JSON info. + - **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. @@ -328,6 +340,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. | +| 2026-10-24 | minor | Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 97% → 98% breadth, 76% → 78% ready, 2–4 h → 1–2 h; headline numbers unchanged at this rounding | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Boards row added outside the weighted numbers; headline numbers unchanged at this rounding | | 2026-10-15 | minor | Auto-save (#305, #296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only, the browser has no files to save to) saves each open file that has a path 3 s after its last change, so steady editing doesn't save every few seconds, and never during a gesture, a live text preview or while a version is shown. The file is encoded on a thread of its own and written as of that moment (edits made meanwhile stay unsaved); a failed save says why in a toast and is tried again a minute later, and version history checkpoints keep coming with it on. Numbers unchanged | | 2026-10-15 | minor | A text box by dragging (#363, #360): a drag with the Text tool makes a fixed-size text box the size of the drag (at least one line tall); a click, or a drag shorter than the drag threshold, still makes auto-width text. Numbers unchanged | diff --git a/apps/figcraft-cli/src/args.rs b/apps/figcraft-cli/src/args.rs index 3fe17e80..0c04b732 100644 --- a/apps/figcraft-cli/src/args.rs +++ b/apps/figcraft-cli/src/args.rs @@ -20,6 +20,10 @@ impl Args { let mut bad: Option = None; let mut it = argv.iter(); while let Some(arg) = it.next() { + if arg == "--" { + a.positional.extend(it.cloned()); + break; + } let name = if let Some(n) = arg.strip_prefix("--") { n } else if let Some(n) = arg.strip_prefix('-').filter(|n| n.len() == 1 && !n.starts_with(|c: char| c.is_ascii_digit())) { diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index b87aa431..a03d5b26 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -5,7 +5,7 @@ use std::path::Path; use std::process::ExitCode; use figcraft_doc::{Document, ExportFormat, ExportSetting, NodeId, NodeKind, PrintSettings}; -use figcraft_engine::Session; +use figcraft_engine::{BatchScope, BatchStep, Session}; use serde_json::{Value, json}; use crate::{CliError, CliResult}; @@ -272,14 +272,19 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult { return Err(CliError::Failed("the page is empty: nothing to export".into())); } let mut used = std::collections::BTreeSet::new(); + let mut next_suffix: std::collections::HashMap = std::collections::HashMap::new(); for id in ids { let base = sanitize(doc.node(id).map(|n| n.name.as_str()).unwrap_or("layer")); - let mut name = base.clone(); - let mut k = 2; - while !used.insert(name.to_lowercase()) { - name = format!("{base} {k}"); - k += 1; - } + let base_lower = base.to_lowercase(); + let next = next_suffix.entry(base_lower).or_insert(1); + let name = loop { + let k = *next; + *next += 1; + let candidate = if k == 1 { base.clone() } else { format!("{base} {k}") }; + if used.insert(candidate.to_lowercase()) { + break candidate; + } + }; let path = Path::new(out).join(format!("{name}.{}", fmt.ext())).to_string_lossy().into_owned(); write(&path, &bytes_of(&[id])?)?; println!("{path}"); @@ -313,7 +318,7 @@ pub fn render(file: &str, out: &str, scale: f64, page: Option<&str>) -> CliResul } /// Parse a script: a JSON array of steps, `{"commands": [...]}`, or JSON lines. -pub fn parse_script(text: &str) -> Result, String> { +pub fn parse_script(text: &str) -> Result, String> { let steps: Vec = match serde_json::from_str::(text) { Ok(Value::Array(a)) => a, Ok(Value::Object(o)) if o.contains_key("commands") => { @@ -338,7 +343,8 @@ pub fn parse_script(text: &str) -> Result, String> { Some(p @ Value::Object(_)) => p.clone(), Some(_) => return Err(format!("step {i} ({cmd}): `params` must be an object")), }; - Ok((cmd.to_string(), params)) + let name = figcraft_engine::batch::step_name(i, &s).map_err(|e| e.to_string())?; + Ok(BatchStep { name, ..BatchStep::new(cmd, params) }) }) .collect() } @@ -356,31 +362,30 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe None => Session::new(), }; // One undo step and all-or-nothing, the way the same commands behave through `command.batch` - // (MCP, the control channel, plugins): `{"$step": n}` resolves against the steps before it, and - // a step that fails takes the whole script back instead of leaving the earlier steps applied. - // The result lines are held back until the script is through, so none of them reports work that - // was rolled back. - let mut results: Vec = Vec::with_capacity(steps.len()); + // (MCP, the control channel, plugins): `{"$step": n}` and `{"$ref": "name"}` resolve against the + // steps before it, and a step that fails takes the whole script back instead of leaving the + // earlier steps applied. The result lines are held back until the script is through, so none of + // them reports work that was rolled back. + let mut scope = BatchScope::new(&steps).map_err(|e| CliError::Failed(format!("{script}: {e}")))?; let mut failed_at = 0; let outcome = s.grouped(&format!("run {script}"), |s| { - for (i, (cmd, params)) in steps.iter().enumerate() { + for (i, step) in steps.iter().enumerate() { failed_at = i; - let v = s.batch_step(&results, i, cmd, params)?; + s.batch_step(&mut scope, &step.command, &step.params)?; s.ui_requests.clear(); - results.push(v); } Ok(()) }); if let Err(e) = outcome { - let cmd = steps.get(failed_at).map_or("", |(cmd, _)| cmd.as_str()); + let cmd = steps.get(failed_at).map_or("", |step| step.command.as_str()); let i = failed_at; println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string(), "rolledBack": true})); eprintln!("error: step {i} ({cmd}) failed: {e}"); eprintln!("the script's document changes were rolled back"); return Ok(ExitCode::from(1)); } - for (i, ((cmd, _), v)) in steps.iter().zip(&results).enumerate() { - println!("{}", json!({"step": i, "command": cmd, "ok": true, "result": v})); + for (i, (step, v)) in steps.iter().zip(scope.results()).enumerate() { + println!("{}", json!({"step": i, "command": step.command, "ok": true, "result": v})); } if let Some(out) = out { let ds = s.ds().map_err(|e| CliError::Failed(e.to_string()))?; @@ -506,6 +511,43 @@ mod tests { std::fs::remove_dir_all(dir).unwrap(); } + /// A script names its steps with `as` and reads paths into their results, like `command.batch`. + #[test] + fn a_script_refers_to_named_steps_and_paths() { + let dir = temp_dir("run-ref"); + let script = dir.join("script.json"); + std::fs::write( + &script, + r#"[ + {"command": "node.create", "as": "card", "params": {"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 200, "height": 100}}, + {"command": "edit.duplicate", "as": "copy", "params": {"ids": [{"$ref": "card"}]}}, + {"command": "node.set", "params": {"ids": [{"$ref": "copy", "field": "ids/0"}], "props": {"name": "Copy"}}} + ]"#, + ) + .unwrap(); + let out = dir.join("out.figcraft"); + run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap(); + + let mut s = Session::empty(); + s.execute("file.open", &json!({"path": out.to_str().unwrap()})).unwrap(); + let doc = s.doc().unwrap(); + let top = doc.node(s.page().unwrap()).unwrap().children.clone(); + let names: Vec<_> = top.iter().map(|id| doc.node(*id).unwrap().name.clone()).collect(); + assert_eq!(names, ["Card", "Copy"]); + std::fs::remove_dir_all(dir).unwrap(); + } + + /// Two steps with one name fail before anything runs. + #[test] + fn a_script_with_a_name_twice_runs_nothing() { + let dir = temp_dir("run-dup-name"); + let script = dir.join("script.json"); + std::fs::write(&script, r#"[{"command": "select.none", "as": "a"}, {"command": "select.none", "as": "a"}]"#).unwrap(); + let Err(CliError::Failed(e)) = run_script(script.to_str().unwrap(), None, None) else { panic!("a name twice must fail") }; + assert!(e.contains("step 1: `as` \"a\" is already step 0's name"), "{e}"); + std::fs::remove_dir_all(dir).unwrap(); + } + /// A step that fails takes the whole script back: nothing is written, and the failure says so. #[test] fn a_failed_step_leaves_no_output() { diff --git a/apps/figcraft-cli/src/main.rs b/apps/figcraft-cli/src/main.rs index 66627673..ba516a83 100644 --- a/apps/figcraft-cli/src/main.rs +++ b/apps/figcraft-cli/src/main.rs @@ -108,15 +108,24 @@ impl From for CliError { pub type CliResult = Result; -fn run(argv: &[String]) -> CliResult { - let system_fonts = !argv.iter().any(|a| a == "--no-system-fonts"); - let argv: Vec = argv.iter().filter(|a| *a != "--no-system-fonts").cloned().collect(); - if system_fonts { +fn parse_args(argv: &[String], valued: &[&str], flags: &[&str]) -> CliResult { + let flags = [flags, &["no-system-fonts"]].concat(); + let args = Args::parse(argv, valued, &flags)?; + if !args.flag("no-system-fonts") { // Text lays out with the installed fonts, like the desktop app, so headless renders and // auto layout sizes match what users see. figcraft_text::scan_system_fonts_on_demand(); } - let Some((cmd, rest)) = argv.split_first() else { return Err(CliError::Usage("missing subcommand".into())) }; + Ok(args) +} + +fn run(argv: &[String]) -> CliResult { + let Some(command_index) = argv.iter().position(|a| a != "--no-system-fonts") else { + return Err(CliError::Usage("missing subcommand".into())); + }; + let cmd = argv.get(command_index).ok_or_else(|| CliError::Usage("missing subcommand".into()))?; + // Keep leading global flags with the subcommand's options, where values and `--` are parsed. + let rest: Vec = argv.iter().enumerate().filter(|(i, _)| *i != command_index).map(|(_, a)| a.clone()).collect(); match cmd.as_str() { "help" | "--help" | "-h" => { println!("{USAGE}"); @@ -127,13 +136,13 @@ fn run(argv: &[String]) -> CliResult { Ok(ExitCode::SUCCESS) } "info" => { - let a = Args::parse(rest, &ROOT_FLAGS, &["json"])?; + let a = parse_args(&rest, &ROOT_FLAGS, &["json"])?; let file = a.one_positional("info ")?; confined(&a, || files::info(&file, a.flag("json"))) } "export" => { let valued = [&["format", "scale", "page", "node", "o", "out", "bleed"][..], &ROOT_FLAGS[..]].concat(); - let a = Args::parse(rest, &valued, &["crop-marks", "bleed-marks", "cmyk"])?; + let a = parse_args(&rest, &valued, &["crop-marks", "bleed-marks", "cmyk"])?; let file = a.one_positional("export -o ")?; let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("export needs -o ".into()))?; let target = @@ -141,7 +150,7 @@ fn run(argv: &[String]) -> CliResult { confined(&a, || files::export(&file, &out, &target)) } "convert" => { - let a = Args::parse(rest, &ROOT_FLAGS, &[])?; + let a = parse_args(&rest, &ROOT_FLAGS, &[])?; match a.positional.as_slice() { [i, o] => confined(&a, || files::convert(i, o)), _ => Err(CliError::Usage("convert ".into())), @@ -149,19 +158,19 @@ fn run(argv: &[String]) -> CliResult { } "render" => { let valued = [&["o", "out", "scale", "page"][..], &ROOT_FLAGS[..]].concat(); - let a = Args::parse(rest, &valued, &[])?; + let a = parse_args(&rest, &valued, &[])?; let file = a.one_positional("render -o ")?; let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("render needs -o ".into()))?; confined(&a, || files::render(&file, &out, a.scale()?, a.value("page").as_deref())) } "run" => { let valued = [&["in", "out"][..], &ROOT_FLAGS[..]].concat(); - let a = Args::parse(rest, &valued, &[])?; + let a = parse_args(&rest, &valued, &[])?; let script = a.one_positional("run ")?; confined(&a, || files::run_script(&script, a.value("in").as_deref(), a.value("out").as_deref())) } "commands" => { - let a = Args::parse(rest, &["filter"], &["json"])?; + let a = parse_args(&rest, &["filter"], &["json"])?; if !a.positional.is_empty() { return Err(CliError::Usage("commands takes no positional arguments".into())); } @@ -170,7 +179,7 @@ fn run(argv: &[String]) -> CliResult { } "mcp" => { let valued = [&["attach"][..], &ROOT_FLAGS[..]].concat(); - let a = Args::parse(rest, &valued, &[])?; + let a = parse_args(&rest, &valued, &[])?; if !a.positional.is_empty() { return Err(CliError::Usage("mcp takes no positional arguments".into())); } @@ -185,7 +194,7 @@ fn run(argv: &[String]) -> CliResult { mcp(attach.as_deref(), roots_of(&a)?) } "plugin" => { - let a = Args::parse(rest, &["server"], &[])?; + let a = parse_args(&rest, &["server"], &[])?; match a.positional.as_slice() { [verb, folder] if verb == "publish" => { let server = a.value("server").ok_or_else(|| CliError::Usage("plugin publish needs --server ws://host:port/".into()))?; @@ -195,10 +204,10 @@ fn run(argv: &[String]) -> CliResult { } } "perf" => { - let a = Args::parse(rest, &["layers", "instances"], &[])?; + let a = parse_args(&rest, &["layers", "instances"], &[])?; let sizes: Vec = a.usizes("layers", &[1000, 10_000])?.into_iter().map(|n| n.clamp(10, 2_000_000)).collect(); let instances: Vec = a.usizes("instances", &[2000])?.into_iter().filter(|n| *n > 0).map(|n| n.min(100_000)).collect(); - perf::run(&sizes, &instances, system_fonts) + perf::run(&sizes, &instances, !a.flag("no-system-fonts")) } other => Err(CliError::Usage(format!("unknown subcommand `{other}`"))), } diff --git a/apps/figcraft-cli/tests/it/cli.rs b/apps/figcraft-cli/tests/it/cli.rs index b1aeb54a..1db451ba 100644 --- a/apps/figcraft-cli/tests/it/cli.rs +++ b/apps/figcraft-cli/tests/it/cli.rs @@ -13,6 +13,38 @@ fn tmp(name: &str) -> std::path::PathBuf { d } +#[test] +fn option_terminator_allows_dash_prefixed_filenames() { + let dir = tmp("option-terminator"); + let doc = figcraft_doc::Document::new("Dash-prefixed file"); + let bytes = figcraft_format::save_bytes(&doc, None).unwrap(); + for name in ["--drawing.figcraft", "-h", "--json", "--no-system-fonts", "--"] { + std::fs::write(dir.join(name), &bytes).unwrap(); + let output = bin().current_dir(&dir).args(["info", "--json", "--no-system-fonts", "--", name]).output().unwrap(); + assert!(output.status.success(), "{name}: {}", String::from_utf8_lossy(&output.stderr)); + let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(info["file"], name); + assert_eq!(info["name"], "Dash-prefixed file"); + } + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn global_font_flag_respects_option_values() { + for filter in ["--", "--no-system-fonts"] { + let output = bin().args(["commands", "--filter", filter, "--no-system-fonts", "--json"]).output().unwrap(); + assert!(output.status.success(), "{filter}: {}", String::from_utf8_lossy(&output.stderr)); + assert!(serde_json::from_slice::(&output.stdout).unwrap().is_array()); + } +} + +#[test] +fn global_font_flag_can_precede_the_subcommand() { + let output = bin().args(["--no-system-fonts", "commands", "--json"]).output().unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + assert!(!serde_json::from_slice::>(&output.stdout).unwrap().is_empty()); +} + #[test] fn bad_arguments_exit_2_without_panicking() { for args in [ diff --git a/apps/figcraft-server/src/abuse.rs b/apps/figcraft-server/src/abuse.rs index baf1882b..2ea939c3 100644 --- a/apps/figcraft-server/src/abuse.rs +++ b/apps/figcraft-server/src/abuse.rs @@ -120,15 +120,15 @@ impl Abuse { open } - /// `ip` tried a join code that was right (`ok`) or wrong: a right one forgives the wrong ones - /// before it; enough wrong ones in a row lock the address out. + /// `ip` tried a join code that was right (`ok`) or wrong: a right one doesn't forgive the wrong + /// ones before it (they only decay with time); enough wrong ones lock the address out. pub fn code_tried(&mut self, ip: IpAddr, ok: bool, now: Instant) { if !ok { self.wrong_codes.take(now); } for (k, times) in ip_blocks(ip) { let a = self.address(k, f64::from(times), now); - if ok || now.saturating_duration_since(a.wrong_at) > CODE_LOCKOUT { + if now.saturating_duration_since(a.wrong_at) > CODE_LOCKOUT { a.wrong = 0; } if !ok { diff --git a/apps/figcraft-server/src/codes_tests.rs b/apps/figcraft-server/src/codes_tests.rs index 6392e552..1e440a32 100644 --- a/apps/figcraft-server/src/codes_tests.rs +++ b/apps/figcraft-server/src/codes_tests.rs @@ -262,17 +262,21 @@ fn the_limits_on_codes_over_time() { let t0 = Instant::now(); let me = ip("198.51.100.9"); let mut abuse = Abuse::new(AbuseConfig { open: false, connects_per_minute: 30, publishes_per_hour: 10, messages_per_second: 200 }); - // A right code forgives the wrong ones before it. + // A right code, even from a neighbour in the same /64, doesn't forgive the wrong ones. + let a = ip("2001:db8:1:2::1"); + let b = ip("2001:db8:1:2::2"); for _ in 0..WRONG_CODES - 1 { + abuse.code_tried(a, false, t0); + } + abuse.code_tried(b, true, t0); + abuse.code_tried(a, true, t0); + assert_eq!(abuse.code_locked(a, t0), None); + abuse.code_tried(a, false, t0); + assert_eq!(abuse.code_locked(a, t0), Some(CODE_LOCKOUT)); + // Wrong codes: locked for the lockout, then free again. + for _ in 0..WRONG_CODES { abuse.code_tried(me, false, t0); } - abuse.code_tried(me, true, t0); - for _ in 0..WRONG_CODES - 1 { - abuse.code_tried(me, false, t0); - } - assert_eq!(abuse.code_locked(me, t0), None); - // One more: locked for the lockout, then free again. - abuse.code_tried(me, false, t0); assert_eq!(abuse.code_locked(me, t0), Some(CODE_LOCKOUT)); assert!(abuse.code_locked(me, t0 + CODE_LOCKOUT - Duration::from_secs(1)).is_some()); assert_eq!(abuse.code_locked(me, t0 + CODE_LOCKOUT), None); diff --git a/apps/figcraft-server/src/disk.rs b/apps/figcraft-server/src/disk.rs index d32a1747..cb6e0453 100644 --- a/apps/figcraft-server/src/disk.rs +++ b/apps/figcraft-server/src/disk.rs @@ -128,13 +128,14 @@ fn u64_at(b: &[u8], at: usize) -> Option { /// A snapshot file's bytes. pub fn encode_snapshot(generation: u64, state: &SessionState) -> Result, String> { - let body = message::encode(state).map_err(|e| e.to_string())?; - let mut out = Vec::with_capacity(SNAP_HEADER + body.len()); + let mut out = Vec::with_capacity(SNAP_HEADER); out.extend_from_slice(SNAP_MAGIC); out.push(FORMAT); out.extend_from_slice(&generation.to_le_bytes()); - out.extend_from_slice(&crc(&body).to_le_bytes()); - out.extend_from_slice(&body); + out.extend_from_slice(&0u32.to_le_bytes()); + let mut out = message::encode_into(state, out).map_err(|e| e.to_string())?; + let sum = crc(&out[SNAP_HEADER..]); + out[13..SNAP_HEADER].copy_from_slice(&sum.to_le_bytes()); Ok(out) } diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index ab65c2d8..31b877cb 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -1480,14 +1480,18 @@ impl Backend for Store { } fn idle(&mut self, file: &str, server: &mut Server) -> bool { - let dirty = self.files.get_mut(file).and_then(|e| e.log.as_mut()).is_some_and(|log| { - let _ = flush_log(file, log); - log.dirty + let compact = self.files.get_mut(file).is_some_and(|entry| { + let snapshot_seq = entry.meta.seq; + entry.log.as_mut().is_some_and(|log| { + // Issued client ids and refusals are durable in the log; only edits need a + // new image-bearing snapshot. Still try a snapshot if syncing the log failed. + flush_log(file, log).is_err() || (log.dirty && server.seq() > snapshot_seq) + }) }); - if dirty { + if compact { self.compact(file, server); } - // It closes anyway, everything in its snapshot: a compaction cut short needs no report. + // It closes anyway, with its state in the snapshot and durable log. self.cut_short.retain(|f| f != file); if let Some(e) = self.files.get_mut(file) { e.log = None; diff --git a/apps/figcraft-server/src/store_tests.rs b/apps/figcraft-server/src/store_tests.rs index d8c5b915..93a872ee 100644 --- a/apps/figcraft-server/src/store_tests.rs +++ b/apps/figcraft-server/src/store_tests.rs @@ -127,6 +127,34 @@ fn a_file_survives_a_crash_and_compacts() { assert_eq!(store.list(&|_| None)[0].seq, seq, "the description follows"); } +#[test] +fn read_only_sessions_keep_client_ids_without_rewriting_the_snapshot() { + let dir = TempDir::new("read-only-reconnects"); + let c = StoreConfig { compact_after: 1000, ..cfg(dir.path()) }; + let (id, original) = { + let mut store = Store::open(dir.path(), c.clone()).unwrap(); + let (id, _, _) = publish(&mut store, "Read only"); + let bytes = fs::read(dir.path().join(FILES).join(&id).join(SNAPSHOT)).unwrap(); + (id, bytes) + }; + let mut last_client = 0; + for _ in 0..6 { + let mut store = Store::open(dir.path(), c.clone()).unwrap(); + let mut server = store.open(&id).unwrap(); + let client = join(&mut server, Access::View); + assert!(client.id() > last_client, "issued ids survive closing and reopening the store"); + last_client = client.id(); + store.changed(&id, &mut server); + // Idle must sync even if no periodic flush ran before the connection closed. + assert!(store.idle(&id, &mut server)); + assert_eq!(store.compactions(), 0, "a client id does not require rebuilding the image-bearing snapshot"); + assert_eq!(fs::read(dir.path().join(FILES).join(&id).join(SNAPSHOT)).unwrap(), original); + let log = disk::read_log(&fs::read(dir.path().join(FILES).join(&id).join(LOG)).unwrap()).unwrap(); + assert!(!log.entries.is_empty()); + assert_eq!(server.seq(), 0); + } +} + #[test] fn a_long_log_is_compacted_while_open() { let dir = TempDir::new("compact"); @@ -252,6 +280,19 @@ fn temporary_directories_are_removed() { assert!(!path.exists()); } +#[test] +fn snapshot_encoding_keeps_the_existing_framing_and_checksum() { + let state = Server::new(doc("Snapshot framing"), "", figcraft_collab::Limits::default()).state().unwrap(); + let body = figcraft_collab::message::encode(&state).unwrap(); + let mut legacy = b"FCSN\x01".to_vec(); + legacy.extend_from_slice(&37u64.to_le_bytes()); + legacy.extend_from_slice(&crc32fast::hash(&body).to_le_bytes()); + legacy.extend_from_slice(&body); + let encoded = disk::encode_snapshot(37, &state).unwrap(); + assert_eq!(encoded, legacy); + assert_eq!(disk::decode_snapshot(&encoded).unwrap(), (37, state)); +} + #[test] fn random_bytes_never_panic_the_readers() { let mut rng = Rng::new(7); diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index f08ca90f..40451d43 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -69,7 +69,15 @@ 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. + /// Run the browser shell's eframe logic callback: guard `background_tick` so hidden tabs + /// still advance autosave, live and control deadlines without painting. `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); } @@ -117,7 +125,7 @@ impl Services for WebServices { } fn open_url(&mut self, url: &str) { let Some(window) = web_sys::window() else { return }; - if let Err(e) = window.open_with_url_and_target(url, "_blank") { + if let Err(e) = window.open_with_url_and_target_and_features(url, "_blank", "noopener,noreferrer") { log::error!("couldn't open {url}: {e:?}"); } } diff --git a/apps/figcraft/Cargo.toml b/apps/figcraft/Cargo.toml index 6196d08d..c13c8832 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 } @@ -33,6 +33,7 @@ env_logger = { version = "0.11", default-features = false, features = ["auto-col # The system's reduce-motion setting (UISettings.AnimationsEnabled), interface language # (GlobalizationPreferences.Languages) and controllers for prototypes (Gaming.Input.Gamepad). [target.'cfg(windows)'.dependencies] +craft-update = { workspace = true } windows = { version = "0.62", default-features = false, features = ["UI_ViewManagement", "System_UserProfile", "Foundation_Collections", "Gaming_Input"] } # Finder double-clicks, Open With, Dock drops and `open -a FigCraft file.figcraft` arrive as a diff --git a/apps/figcraft/build.rs b/apps/figcraft/build.rs index 64f97835..6ed8a047 100644 --- a/apps/figcraft/build.rs +++ b/apps/figcraft/build.rs @@ -1,6 +1,7 @@ //! Windows: embed the icon and version info in figcraft.exe and size its main thread's stack //! (no-op elsewhere). fn main() { + println!("cargo::rustc-check-cfg=cfg(windows_slim_errors)"); println!("cargo::rerun-if-changed=../../assets/app-icon/figcraft.ico"); let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); if target_os == "windows" { diff --git a/apps/figcraft/src/control_server.rs b/apps/figcraft/src/control_server.rs index ff9c2fbe..ada44c0f 100644 --- a/apps/figcraft/src/control_server.rs +++ b/apps/figcraft/src/control_server.rs @@ -6,7 +6,7 @@ use std::net::{TcpListener, TcpStream}; use std::sync::{Arc, Mutex}; use std::time::Duration; -use figcraft_ui_egui::ControlRequest; +use figcraft_ui_egui::{ControlReply, ControlRequest, RequestGate, RequestState}; use serde_json::{Value, json}; type Queue = Arc>>; @@ -21,6 +21,34 @@ const TIMEOUT: Duration = Duration::from_secs(60); /// core busy until it recovers. const ACCEPT_RETRY: Duration = Duration::from_millis(100); +/// Why a request's transport wait ended without a control reply. +#[derive(Debug, PartialEq, Eq)] +enum WaitFailure { + Timeout, + /// The UI side dropped its sender before replying (the app is closing). + Disconnected, +} + +/// Wait for the UI's reply, and settle a timeout under the same gate the UI's final commit uses. +/// A commit that got the gate first has already queued its reply: consume that reply instead of +/// reporting a timeout. If the gate is still open, cancel it so the UI writes nothing. +fn wait_for_reply(rx: &std::sync::mpsc::Receiver, gate: &RequestGate, timeout: Duration) -> Result { + match rx.recv_timeout(timeout) { + Ok(reply) => Ok(reply), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => after_timeout(rx, gate), + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(WaitFailure::Disconnected), + } +} + +/// The timeout branch of [`wait_for_reply`]. Kept separate so it can be tested deterministically +/// against a gate whose commit already happened. +fn after_timeout(rx: &std::sync::mpsc::Receiver, gate: &RequestGate) -> Result { + match gate.cancel_if_open() { + RequestState::Replied => rx.try_recv().map_err(|_| WaitFailure::Disconnected), + RequestState::Open | RequestState::Cancelled => Err(WaitFailure::Timeout), + } +} + /// 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) { target.retain(|request| !request.is_cancelled()); @@ -69,6 +97,9 @@ fn spawn_connection(stream: TcpStream, queue: Queue, ctx: CtxSlot, workers: &mut } } +/// Serve one control connection: read bounded JSON lines, reject oversized frames and a +/// full queue, queue one request, wait under its gate, and write one reply. Non-JSON input +/// answers with an error and closes the connection so HTTP body lines cannot become commands. fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> std::io::Result<()> { out.set_read_timeout(Some(timeout))?; out.set_write_timeout(Some(timeout))?; @@ -96,7 +127,7 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s let method = v.get("method").and_then(Value::as_str).unwrap_or("").to_string(); let params = v.get("params").cloned().unwrap_or(json!({})); let (tx, rx) = std::sync::mpsc::channel(); - let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let gate = RequestGate::new(); let mut pending = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner); pending.retain(|request| !request.is_cancelled()); if pending.len() >= MAX_QUEUED_REQUESTS { @@ -104,20 +135,22 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s writeln!(out, "{}", json!({"id": id, "ok": false, "error": "control request queue is full"}))?; continue; } - pending.push(ControlRequest { id: id.clone(), method, params, reply: tx, cancelled: cancelled.clone() }); + pending.push(ControlRequest { id: id.clone(), method, params, reply: tx, state: gate.clone() }); drop(pending); if let Some(c) = ctx.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() { c.request_repaint(); } - match rx.recv_timeout(timeout) { + match wait_for_reply(&rx, &gate, timeout) { Ok(r) => match r.result { Ok(res) => json!({"id": r.id, "ok": true, "result": res}), Err(e) => json!({"id": r.id, "ok": false, "error": e}), }, - Err(_) => { - cancelled.store(true, std::sync::atomic::Ordering::Relaxed); + Err(WaitFailure::Timeout) => { json!({"id": id, "ok": false, "error": "timed out waiting for the UI: screenshots and input need the FigCraft window on screen (is it minimised or covered?)"}) } + Err(WaitFailure::Disconnected) => { + json!({"id": id, "ok": false, "error": "the FigCraft window closed before answering"}) + } } } Err(e) => { diff --git a/apps/figcraft/src/control_server_tests.rs b/apps/figcraft/src/control_server_tests.rs index 011b0613..f29a7773 100644 --- a/apps/figcraft/src/control_server_tests.rs +++ b/apps/figcraft/src/control_server_tests.rs @@ -28,8 +28,9 @@ fn reply(reader: &mut BufReader) -> Value { serde_json::from_str(&line).unwrap() } +/// Build a `ui.inspect` control request with a throwaway reply channel. fn request() -> ControlRequest { - ControlRequest { id: json!(0), method: "ui.inspect".into(), params: json!({}), reply: mpsc::channel().0, cancelled: Default::default() } + ControlRequest { id: json!(0), method: "ui.inspect".into(), params: json!({}), reply: mpsc::channel().0, state: Default::default() } } #[test] @@ -115,6 +116,52 @@ fn full_queue_rejects_before_dispatch_and_ui_queue_stays_bounded() { worker.join().unwrap().unwrap(); } +/// Asserts a commit that writes the file and queues its reply wins the gate: the transport's +/// timeout branch consumes that reply and leaves the state `Replied`. +#[test] +fn a_committed_reply_wins_over_the_transport_timeout() { + let dir = std::env::temp_dir().join(format!("figcraft-control-commit-{}", std::process::id())); + let path = dir.join("shot.png"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let gate = RequestGate::new(); + let (tx, rx) = mpsc::channel(); + // The UI's final commit gets the gate first: the file and reply are both committed before the + // transport's timeout branch can look at the gate. + let committed = gate.commit(|| { + std::fs::write(&path, b"png").unwrap(); + tx.send(ControlReply { id: json!(7), result: Ok(json!({"path": path.to_string_lossy()})) }).unwrap(); + }); + assert!(committed.is_some()); + assert!(path.is_file()); + let reply = after_timeout(&rx, &gate).expect("the queued commit is consumed"); + assert_eq!(reply.id, json!(7)); + assert!(reply.result.is_ok()); + assert_eq!(gate.state(), RequestState::Replied, "the timeout must not overwrite a commit"); + assert!(path.is_file(), "a winning commit keeps its file"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Asserts a zero-duration wait reports `Timeout` and cancels the open gate. +#[test] +fn a_timeout_cancels_an_open_request() { + let gate = RequestGate::new(); + let (_tx, rx) = mpsc::channel::(); + assert!(matches!(wait_for_reply(&rx, &gate, Duration::ZERO), Err(WaitFailure::Timeout))); + assert_eq!(gate.state(), RequestState::Cancelled, "nothing may write after the timeout"); +} + +/// Asserts a closed reply channel reports `Disconnected` and leaves the gate `Open`, not +/// cancelled. +#[test] +fn a_disconnected_reply_channel_is_not_reported_as_a_timeout() { + let gate = RequestGate::new(); + let (tx, rx) = mpsc::channel::(); + drop(tx); + assert!(matches!(wait_for_reply(&rx, &gate, Duration::from_secs(1)), Err(WaitFailure::Disconnected))); + assert_eq!(gate.state(), RequestState::Open, "a closed app is not a cancel decision"); +} + #[test] fn idle_and_partial_frame_connections_expire() { for bytes in [b"".as_slice(), b"{\"id\":"] { @@ -227,6 +274,9 @@ fn a_limit_sized_frame_is_accepted_at_eof() { worker.join().unwrap().unwrap(); } +/// Asserts expired requests stop occupying the UI queue cap, a later request is admitted +/// and answered, and a window that finally draws discards its expired command before +/// dispatch. #[test] fn timed_out_requests_release_a_full_deferred_ui_queue() { use figcraft_ui_egui::{FigApp, NoServices, control, windows}; @@ -262,7 +312,7 @@ fn timed_out_requests_release_a_full_deferred_ui_queue() { // Include expired requests in the socket queue, too: admission must reclaim their slots. pending.lock().unwrap().extend((0..MAX_QUEUED_REQUESTS).map(|_| { let r = request(); - r.cancelled.store(true, std::sync::atomic::Ordering::Relaxed); + r.state.cancel_if_open(); r })); let (mut client, server) = pair(); diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index 0b71149a..6c9bdf93 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -15,6 +15,11 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] +// A missing Windows GNU flag re-enables the COM error-info lookup that crashes while AccessKit +// reports a removed UI Automation node. Catch build configurations that drop the workaround. +#[cfg(all(target_os = "windows", target_env = "gnu", not(windows_slim_errors)))] +compile_error!("Windows GNU builds must set windows_slim_errors to keep UI Automation responsive"); + #[cfg(target_os = "macos")] mod apple_events; mod args; @@ -62,6 +67,9 @@ struct App { } impl eframe::App for App { + /// Native eframe logic callback: store the first context, drain control requests, run + /// guarded background work, poll macOS open events, handle close requests and title + /// changes, and schedule recovery repaints on the app clock. fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { { let mut slot = self.ctx_slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner); @@ -71,7 +79,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 +96,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)); } } @@ -117,6 +133,11 @@ impl eframe::App for App { } fn main() -> eframe::Result { + // Windows: the update helper is a copy of this program started with `--finish-update`. + #[cfg(windows)] + if let Some(result) = craft_update::self_update::finish(&craft_update::suite::FIGCRAFT, &std::env::args_os().skip(1).collect::>()) { + std::process::exit(i32::from(result.is_err())); + } env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init(); let args: Vec = std::env::args().skip(1).collect(); if args.iter().any(|a| a == "--version" || a == "-V") { @@ -199,6 +220,7 @@ fn main() -> eframe::Result { // open (no frame then). figcraft_ui_egui::i18n::set(&session.prefs.language, figcraft_ui_egui::Services::system_language(&svc).as_deref()); let mut fig = FigApp::new(session, Box::new(svc)); + fig.set_startup_update_check(true); fig.automation_roots = roots; fig.ui.home = false; // Files named on the command line are the user's too: their missing fonts are listed. diff --git a/apps/figcraft/src/recovery.rs b/apps/figcraft/src/recovery.rs index 06b0cbeb..605a04ca 100644 --- a/apps/figcraft/src/recovery.rs +++ b/apps/figcraft/src/recovery.rs @@ -206,10 +206,12 @@ impl Recovery { let doc = d.doc.clone(); let (versions, branches) = (d.versions.clone(), figcraft_engine::branches::current(s, d.uid)); // Which online file it is and what of the server's this very copy has (a line each: - // the server, the file's id, then the session, client id and last change), while it + // the server, the file's id, then the session, client id, last change and the secret the + // client id resumes with), while it // is one. Written after the copy, never before: a note must not promise more of the // server's changes than its copy holds. - let online = s.online_of(d.uid).map(|(server, file, at)| format!("{server}\n{file}\n{} {} {}", at.session, at.client_id, at.seq)); + let online = + s.online_of(d.uid).map(|(server, file, at)| format!("{server}\n{file}\n{} {} {} {}", at.session, at.client_id, at.seq, at.secret)); let job = move || { if !write_atomic(&path, &doc, &versions, &branches) { return false; @@ -312,7 +314,10 @@ fn rejoin_online(file: &Path, s: &mut Session) -> Option { let (server, id, at) = (lines.next()?, lines.next()?, lines.next()?); let mut at = at.split(' ').map(|n| n.parse::().ok()); let (session, client, seq) = (at.next()??, at.next()??, at.next()??); - let params = json!({"path": file.to_string_lossy(), "server": server, "file": id, "session": session, "client": client, "seq": seq}); + // Absent in notes written before ids had secrets. + let secret = at.next().flatten().unwrap_or(0); + let params = + json!({"path": file.to_string_lossy(), "server": server, "file": id, "session": session, "client": client, "seq": seq, "secret": secret}); s.execute("collab.recoverFile", ¶ms).ok()?.get("session")?.as_u64() } @@ -338,7 +343,24 @@ fn stamp(s: &Session, d: &DocState) -> Vec { fn write_atomic(path: &Path, doc: &figcraft_engine::doc::Document, versions: &[Version], branches: &[Branch]) -> bool { let Ok(bytes) = figcraft_format::save_bytes_with_history(doc, None, versions, branches) else { return false }; let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, bytes).is_ok() && std::fs::rename(&tmp, path).is_ok() + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create(true).truncate(true).mode(0o600); + let Ok(mut f) = opts.open(&tmp) else { return false }; + if std::io::Write::write_all(&mut f, &bytes).is_err() { + let _ = std::fs::remove_file(&tmp); + return false; + } + } + #[cfg(not(unix))] + { + if std::fs::write(&tmp, bytes).is_err() { + return false; + } + } + std::fs::rename(&tmp, path).is_ok() } /// [`recover_all`], for a start that keeps nothing waiting: how many documents came back as diff --git a/apps/figcraft/src/recovery_online_tests.rs b/apps/figcraft/src/recovery_online_tests.rs index c9036773..f877a0d4 100644 --- a/apps/figcraft/src/recovery_online_tests.rs +++ b/apps/figcraft/src/recovery_online_tests.rs @@ -193,7 +193,7 @@ fn a_copy_ahead_of_the_server_has_its_edits_applied_and_sent() { let left = crash(&s, &root); let note = std::fs::read_to_string(left.join(format!("{}.online", s.docs[0].uid))).unwrap(); let at = s.online_of(s.docs[0].uid).unwrap().2; - assert_eq!(note, format!("{SERVER}\nf1\n{} {} {}", at.session, at.client_id, at.seq), "which file, who the app was, what it had"); + assert_eq!(note, format!("{SERVER}\nf1\n{} {} {} {}", at.session, at.client_id, at.seq, at.secret), "which file, who the app was, what it had"); assert!(!note.contains(OWNER) && !note.contains("token="), "never a secret"); let (mut next, script, mut r, opened) = restart(&s, &root); assert_eq!(opened, 0, "nothing comes back as a plain copy"); diff --git a/apps/figcraft/src/services.rs b/apps/figcraft/src/services.rs index 7ee41ada..d9cd53ea 100644 --- a/apps/figcraft/src/services.rs +++ b/apps/figcraft/src/services.rs @@ -2,8 +2,8 @@ use std::cell::RefCell; use std::path::{Path, PathBuf}; -use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; use figcraft_engine::{Prefs, Session}; use figcraft_ui_egui::{DialogFuture, FigApp, Services}; @@ -20,6 +20,8 @@ pub struct Desktop { language: Option, /// Recovery copies of unsaved files (written at once after a panic). recovery: crate::recovery::Shared, + /// The folder of the last file or folder a dialog picked, where the next one starts. + last_folder: Arc>>, } pub fn config_dir() -> Option { @@ -54,7 +56,15 @@ impl Desktop { let reduce_motion = Arc::new(AtomicBool::new(false)); let flag = reduce_motion.clone(); std::thread::spawn(move || flag.store(system_reduce_motion(), Ordering::Relaxed)); - Self { clipboard: arboard::Clipboard::new().ok(), dir, prefs, reduce_motion, language: system_language(), recovery } + Self { + clipboard: arboard::Clipboard::new().ok(), + dir, + prefs, + reduce_motion, + language: system_language(), + recovery, + last_folder: Arc::default(), + } } } @@ -262,19 +272,57 @@ fn save_filters<'a>(exts: &'a [&'a str]) -> Vec<(&'static str, &'a [&'a str])> { } } +/// The folder to start the next dialog in once `path` was picked: the file's folder, or the folder +/// picked itself. +pub(crate) fn folder_to_remember(path: &Path, is_folder: bool) -> Option { + let folder = if is_folder { Some(path) } else { path.parent() }; + folder.filter(|f| !f.as_os_str().is_empty()).map(Path::to_path_buf) +} + +/// The remembered folder, unless it is gone since: then the system's own choice. +pub(crate) fn start_folder(last: Option) -> Option { + last.filter(|f| f.is_dir()) +} + +impl Desktop { + /// A dialog that starts in the last folder used. The Linux portal does not remember it between + /// dialogs as macOS and Windows do. + fn dialog_in_last_folder(&self, title: &str) -> rfd::AsyncFileDialog { + let dialog = dialog(title); + match start_folder(self.last_folder.lock().ok().and_then(|l| l.clone())) { + Some(folder) => dialog.set_directory(folder), + None => dialog, + } + } +} + +/// `pick`, with the folder of what it picks kept in `last` for the next dialog. +pub(crate) fn remembering(last: Arc>>, pick: DialogFuture, is_folder: bool) -> DialogFuture { + Box::pin(async move { + let path = pick.await?; + if let (Some(folder), Ok(mut last)) = (folder_to_remember(&path, is_folder), last.lock()) { + *last = Some(folder); + } + Some(path) + }) +} + impl Services for Desktop { fn pick_open(&mut self, title: &str, exts: &[&str]) -> DialogFuture { - picked(dialog(title).add_filter("Supported files", exts).pick_file()) + let pick = picked(self.dialog_in_last_folder(title).add_filter("Supported files", exts).pick_file()); + remembering(self.last_folder.clone(), pick, false) } fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> DialogFuture { - let mut dialog = dialog(title).set_file_name(name); + let mut dialog = self.dialog_in_last_folder(title).set_file_name(name); for (label, extensions) in save_filters(exts) { dialog = dialog.add_filter(label, extensions); } - picked(dialog.save_file()) + let pick = picked(dialog.save_file()); + remembering(self.last_folder.clone(), pick, false) } fn pick_folder(&mut self, title: &str) -> DialogFuture { - picked(dialog(title).pick_folder()) + let pick = picked(self.dialog_in_last_folder(title).pick_folder()); + remembering(self.last_folder.clone(), pick, true) } fn clipboard_set_text(&mut self, s: &str) { if let Some(c) = self.clipboard.as_mut() { diff --git a/apps/figcraft/src/services_tests.rs b/apps/figcraft/src/services_tests.rs index 2abec5bb..970f1729 100644 --- a/apps/figcraft/src/services_tests.rs +++ b/apps/figcraft/src/services_tests.rs @@ -117,3 +117,28 @@ fn quitting_keeps_the_preferences_and_ends_the_live_sessions() { assert_eq!(start(&dir).session.prefs.user_name, "Ada", "the preferences were kept"); let _ = std::fs::remove_dir_all(&dir); } + +/// #1290: the Linux portal opens every dialog where it likes, so the app keeps the folder itself. +#[test] +fn the_next_dialog_starts_in_the_folder_of_the_last_pick() { + use crate::services::{folder_to_remember, remembering, start_folder}; + use std::path::PathBuf; + let dir = root("last-folder"); + let file = dir.join("Poster.figcraft"); + assert_eq!(folder_to_remember(&file, false), Some(dir.clone()), "a file: its folder"); + assert_eq!(folder_to_remember(&dir, true), Some(dir.clone()), "a folder pick: the folder itself"); + assert_eq!(folder_to_remember(std::path::Path::new("Poster.figcraft"), false), None, "a bare name has no folder"); + + let last = Arc::new(Mutex::new(None::)); + let mut pick = remembering(last.clone(), Box::pin(std::future::ready(Some(file.clone()))), false); + let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); + assert_eq!(pick.as_mut().poll(&mut cx), std::task::Poll::Ready(Some(file))); + assert_eq!(*last.lock().unwrap(), Some(dir.clone()), "a pick is remembered"); + let mut cancelled = remembering(last.clone(), Box::pin(std::future::ready(None)), false); + assert_eq!(cancelled.as_mut().poll(&mut cx), std::task::Poll::Ready(None)); + assert_eq!(*last.lock().unwrap(), Some(dir.clone()), "a cancelled dialog keeps the folder"); + + assert_eq!(start_folder(Some(dir.clone())), Some(dir.clone())); + let _ = std::fs::remove_dir_all(&dir); + assert_eq!(start_folder(Some(dir)), None, "a folder deleted since: the system's own"); +} diff --git a/contributors/people.toml b/contributors/people.toml index 6a92a2a9..7491a05c 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -76,3 +76,7 @@ sync_github_name = true real_name = "Gert Pellin" display_name = "Gert Pellin" sync_github_name = false + +[people.dotCooCoo] +real_name = "Robert Lee" +sync_github_name = true diff --git a/crates/collab-net/src/code_tests.rs b/crates/collab-net/src/code_tests.rs index 7fe766ec..4cf10f6a 100644 --- a/crates/collab-net/src/code_tests.rs +++ b/crates/collab-net/src/code_tests.rs @@ -87,7 +87,10 @@ fn a_local_share_is_joined_by_its_code_and_edits_sync() { let mut late = session("Cy"); late.execute("collab.join", &json!({"code": shown})).unwrap(); pump(&mut [&mut late], "the refusal", |s| matches!(s[0].live().status, Status::Failed { .. })); - assert!(late.live().status.label().contains("Couldn't join"), "{:?}", late.live().status); + // The port is free again: another program's host may have taken it, and that host refuses + // the code's secret. + let label = late.live().status.label(); + assert!(label.contains("Couldn't join") || label.contains("The host refused"), "{label}"); } #[test] @@ -103,7 +106,10 @@ fn a_local_code_with_the_wrong_secret_or_a_typo_doesnt_join() { assert!(host.live().peers.is_empty()); // A typing mistake is caught before anything connects. let mut typo: Vec = local.code().chars().collect(); - typo[7] = if typo[7] == 'A' { 'B' } else { 'A' }; + // The last symbol contains only checksum bits. Changing payload bits can collide with + // the eight-bit checksum, so it does not guarantee a malformed code. + let last = typo.last_mut().unwrap(); + *last = if *last == 'A' { 'B' } else { 'A' }; let e = guest.execute("collab.join", &json!({"code": typo.iter().collect::()})).unwrap_err().to_string(); assert!(e.contains("typing mistake"), "{e}"); // A view-only code doesn't exist here, and says where it does. diff --git a/crates/collab-net/src/join.rs b/crates/collab-net/src/join.rs index cc3be839..43b0b7b8 100644 --- a/crates/collab-net/src/join.rs +++ b/crates/collab-net/src/join.rs @@ -189,9 +189,9 @@ pub(crate) enum Taken { /// in answer must go out on this connection. pub(crate) fn take(m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mut Seen, resume: &Resuming, welcomed: &dyn Fn()) -> Taken { match m { - ServerMsg::Welcome { session, client_id, seq, ref peers, access, .. } => { + ServerMsg::Welcome { session, client_id, seq, ref peers, access, secret, .. } => { if let Ok(mut r) = resume.lock() { - *r = Some(Resume { session, client_id, seq }); + *r = Some(Resume { session, client_id, seq, secret }); } // The sync client opens the document (and fills in its name and pages). let peers = peers.clone(); diff --git a/crates/collab-net/src/loopback_tests.rs b/crates/collab-net/src/loopback_tests.rs index d5a38d9d..fe9b4782 100644 --- a/crates/collab-net/src/loopback_tests.rs +++ b/crates/collab-net/src/loopback_tests.rs @@ -488,6 +488,21 @@ fn silent_connections_time_out() { host.stop(); } +#[test] +fn connections_without_a_hello_are_told_why_they_end() { + // A socket's read timeout can end a moment before the hello's deadline; the connection still + // gets the refusal. Twelve at once, so that happens to some of them. + 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(); + std::thread::scope(|s| { + let waits: Vec<_> = (0..12).map(|_| s.spawn(move || Raw::connect(addr).refusal())).collect(); + for w in waits { + assert_eq!(w.join().unwrap().as_deref(), Some("no hello in time")); + } + }); + host.stop(); +} + #[test] fn guests_reconnect_after_a_network_failure() { let (mut host, mut hp, invite) = start(); @@ -540,7 +555,9 @@ fn quiet_guests_stay_and_silent_connections_are_dropped() { let (mut host, mut hp, invite) = start_with(limits, Limits::default()); let probe = Probe::new(); let mut cfg = quick(GuestConfig::new(invite.clone(), "Ada".into())); - cfg.limits = limits; + // The guest pings as often as the host expects. Its own idle timeout stays long: it also bounds + // the wait for the welcome, which a loaded machine can take longer than 600 ms to send. + cfg.limits.keepalive = limits.keepalive; let _g = Guest::start(cfg, probe.inbox.clone()).unwrap(); let mut gp = probe; gp.wait("welcome", welcomed); @@ -672,34 +689,42 @@ fn a_welcome_over_the_size_limit_says_the_file_is_too_large() { } /// 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. +/// 300 ms (told whether it's the first time); `None`: still open after 6 s. A thread of its own +/// reads the socket and notes when the host ends the stream, so pongs queued ahead of the close +/// and a slow poke loop don't delay it. 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 mut watch = r.0.get_ref().try_clone().unwrap(); let t0 = Instant::now(); + let (ended_tx, ended) = std::sync::mpsc::channel(); + let reader = std::thread::spawn(move || { + let mut buf = [0u8; 1024]; + let until = Instant::now() + WAIT; + loop { + match watch.read(&mut buf) { + Ok(0) => break, + Ok(_) => {} + Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) && Instant::now() < until => {} + Err(_) => break, + } + } + let _ = ended_tx.send(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; - } - _ => {} + if let Ok(at) = ended.recv_timeout(Duration::from_millis(300)) { + closed = Some(at.saturating_duration_since(t0)); + break; } } host.stop(); + let _ = reader.join(); closed } diff --git a/crates/collab-net/src/net.rs b/crates/collab-net/src/net.rs index c6628662..43e56001 100644 --- a/crates/collab-net/src/net.rs +++ b/crates/collab-net/src/net.rs @@ -195,17 +195,25 @@ 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()); + let n = loop { + 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))?; } - 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), - }?; + let read = match &self.tls { + Some(t) => tls::read(t, &mut self.read, &self.write, buf), + None => self.read.read(buf), + }; + match read { + // The socket's timeout can run out a moment before the deadline (it counts whole + // milliseconds): wait out the rest, so a read times out only once it has passed. + Err(e) if self.deadline.is_some() && matches!(e.kind(), io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut) => {} + read => break read?, + } + }; if let Some(m) = self.meter.as_mut() { m.add(n)?; } diff --git a/crates/collab-net/src/sessions_tests.rs b/crates/collab-net/src/sessions_tests.rs index 27523fce..9e76e54e 100644 --- a/crates/collab-net/src/sessions_tests.rs +++ b/crates/collab-net/src/sessions_tests.rs @@ -89,6 +89,9 @@ fn rect(s: &mut Session) -> u64 { s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap()["id"].as_u64().unwrap() } +/// Asserts two files are hosted and joined independently: each has its own port, session and +/// edits, presence stays in its file's session, and closing one file's session leaves the +/// other live. #[test] fn two_files_are_hosted_and_joined_independently() { let mut host = session("Hana"); @@ -123,8 +126,15 @@ fn two_files_are_hosted_and_joined_independently() { host.execute("doc.switch", &json!({"index": 0})).unwrap(); assert_eq!(host.live_summary()["link"], a["link"]); assert_eq!(host.live_summary()["document"], 0); + // Each joined file opens in front when its welcome arrives, and the two joins race: the file + // whose welcome came last is in front. + assert_eq!(guest.ds().unwrap().uid, guest.docs[2].uid, "the file that arrived last is in front"); + guest.execute("doc.switch", &json!({"index": guest.docs.iter().position(|d| d.uid == gb).unwrap()})).unwrap(); // An edit in A reaches the guest's copy of A only, while B is in front at the guest. - assert_eq!(guest.ds().unwrap().uid, gb, "the file joined last is in front"); + // Concurrent joins may complete in either order. Put B in front explicitly so the + // following assertions exercise A's background session independent of network timing. + guest.execute("doc.switch", &json!({"index": guest.docs.iter().position(|d| d.uid == gb).unwrap()})).unwrap(); + assert_eq!(guest.ds().unwrap().uid, gb, "B is in front while A receives edits"); let in_a = rect(&mut host); host.execute("doc.switch", &json!({"index": 1})).unwrap(); let in_b = rect(&mut host); diff --git a/crates/collab/src/change.rs b/crates/collab/src/change.rs index 57bb9cf2..b273ba34 100644 --- a/crates/collab/src/change.rs +++ b/crates/collab/src/change.rs @@ -181,6 +181,7 @@ node_props! { Annotations => annotations, CodeLinks => code_links, DevResources => dev_resources, + Measurements => measurements, Board => board, } diff --git a/crates/collab/src/client.rs b/crates/collab/src/client.rs index 8f2a9841..a84a4ffd 100644 --- a/crates/collab/src/client.rs +++ b/crates/collab/src/client.rs @@ -41,6 +41,8 @@ pub struct Client { /// The session joined (see [`ServerMsg::Welcome`]). session: u64, id: u32, + /// What this client resumes its id with (see [`Resume::secret`]). + secret: u64, /// The server's state as of `seq`. confirmed: Replica, seq: u64, @@ -84,7 +86,7 @@ impl Client { /// Start from the server's `Welcome` (which must carry a snapshot). pub fn join(welcome: ServerMsg) -> Result { - let ServerMsg::Welcome { session, client_id, seq, catch_up: CatchUp::Snapshot(snapshot), processed, peers, access } = welcome else { + let ServerMsg::Welcome { session, client_id, seq, catch_up: CatchUp::Snapshot(snapshot), processed, peers, access, secret } = welcome else { return Err(Error::Protocol("expected a welcome with the document".into())); }; let mut confirmed = Replica::from_snapshot(&snapshot)?; @@ -93,6 +95,7 @@ impl Client { Ok(Client { session, id: client_id, + secret, view: confirmed.clone(), confirmed, seq, @@ -415,9 +418,10 @@ impl Client { ClientMsg::Hello(Hello { resume: Some(self.resume()), ..hello }) } - /// What a reconnect resumes with: this client's session, id and last sequence number. + /// What a reconnect resumes with: this client's session, id, last sequence number and the + /// secret its id was given with. pub fn resume(&self) -> Resume { - Resume { session: self.session, client_id: self.id, seq: self.seq } + Resume { session: self.session, client_id: self.id, seq: self.seq, secret: self.secret } } /// Handle a message from the server. Returns messages to send: batches that were waiting for @@ -458,10 +462,11 @@ impl Client { Ok(Vec::new()) } ServerMsg::Welcome { .. } if self.online => Err(Error::Protocol("already joined".into())), - ServerMsg::Welcome { session, client_id, seq, catch_up, processed, peers, access } => { + ServerMsg::Welcome { session, client_id, seq, catch_up, processed, peers, access, secret } => { if session != self.session || client_id != self.id { - return self.restart(ServerMsg::Welcome { session, client_id, seq, catch_up, processed, peers, access }); + return self.restart(ServerMsg::Welcome { session, client_id, seq, catch_up, processed, peers, access, secret }); } + self.secret = secret; self.access = access; self.rejoin(seq, catch_up, &processed, peers, recompute) } diff --git a/crates/collab/src/codec_tests.rs b/crates/collab/src/codec_tests.rs index 9f5d12fb..e4b48aac 100644 --- a/crates/collab/src/codec_tests.rs +++ b/crates/collab/src/codec_tests.rs @@ -11,6 +11,16 @@ use crate::message::{ use crate::sim::Rng; use crate::{Error, Replica, diff}; +#[test] +fn encoding_into_a_prefix_keeps_the_wire_body() { + let msg = moved_layer(); + let prefix = b"container header".to_vec(); + let bytes = crate::message::encode_into(&msg, prefix.clone()).unwrap(); + assert_eq!(&bytes[..prefix.len()], prefix); + assert_eq!(&bytes[prefix.len()..], encode(&msg).unwrap()); + assert_eq!(decode::(&bytes[prefix.len()..], bytes.len()).unwrap(), msg); +} + /// A typical edit: one layer dragged (its transform). fn moved_layer() -> ClientMsg { let mut doc = Document::new("f"); @@ -38,7 +48,7 @@ fn samples() -> (Vec, Vec) { name: "Ada".into(), color: "#FF8800".into(), client_version: "0.2.0".into(), - resume: Some(Resume { session: 77, client_id: 4, seq: 10 }), + resume: Some(Resume { session: 77, client_id: 4, seq: 10, secret: 0x5EC2E7 }), }), moved_layer(), cursor(), @@ -102,6 +112,7 @@ fn samples() -> (Vec, Vec) { processed: Processed::default(), peers: vec![peer.clone()], access: Access::Edit, + secret: 0x5EC2E7, }, ServerMsg::Welcome { session: 77, @@ -111,6 +122,7 @@ fn samples() -> (Vec, Vec) { processed: Processed { last_batch: 7, refused: vec![3, 6] }, peers: vec![], access: Access::View, + secret: 1, }, ServerMsg::Changes(Accepted { seq: 11, author: 4, batch_id: 2, changes: vec![set.clone(), Change::Delete { id: NodeId(5) }] }), ServerMsg::Reject { batch_id: 3, reason: "no".into(), fixes: vec![set] }, diff --git a/crates/collab/src/docprops.rs b/crates/collab/src/docprops.rs index 9b35e05d..e4ff32c2 100644 --- a/crates/collab/src/docprops.rs +++ b/crates/collab/src/docprops.rs @@ -132,18 +132,15 @@ impl List { let keys: Vec = value.read("order")?; let list = (self.get_mut)(doc); let mut slots: Vec> = std::mem::take(list).into_iter().map(Some).collect(); - let mut at: BTreeMap> = BTreeMap::new(); + let mut at: BTreeMap> = BTreeMap::new(); for (i, e) in slots.iter().enumerate() { if let Some(e) = e { - at.entry(e.key()).or_default().push(i); + at.entry(e.key()).or_default().push_back(i); } } for k in keys { let Some(idx) = at.get_mut(&k) else { continue }; - if idx.is_empty() { - continue; - } - let i = idx.remove(0); + let Some(i) = idx.pop_front() else { continue }; if let Some(e) = slots.get_mut(i).and_then(Option::take) { list.push(e); } diff --git a/crates/collab/src/merge.rs b/crates/collab/src/merge.rs index 5132ce75..acd40397 100644 --- a/crates/collab/src/merge.rs +++ b/crates/collab/src/merge.rs @@ -6,11 +6,12 @@ //! values, or a layer deleted on one side while the other side edited it or something inside it //! (including adding layers to it or moving layers into or out of it). The layer lists whose items //! stand on their own (fills, strokes, effects, layout grids, export settings, guides, prototype -//! interactions, accessibility annotations and Dev Mode resources) merge item by item: both sides -//! changing different items of the list (or one adding items at its end) is no conflict, only the -//! same item changed both ways or the list shortened on a side while the other changed it. Nothing -//! that conflicts is merged until it is resolved: each conflict is resolved for one side, and the -//! other side's changes to it are left out. Everything else from both sides is kept. +//! interactions, accessibility annotations, Dev Mode resources and measurements) merge item by +//! item: both sides changing different items of the list (or one adding items at its end) is no +//! conflict, only the same item changed both ways or the list shortened on a side while the other +//! changed it. Nothing that conflicts is merged until it is resolved: each conflict is resolved for +//! one side, and the other side's changes to it are left out. Everything else from both sides is +//! kept. use std::collections::{BTreeMap, BTreeSet}; use std::fmt; @@ -176,6 +177,7 @@ const PER_ITEM: &[Prop] = &[ // Not `CodeLinks`: there is one per language, and lining them up by position could leave two // for one language (the second hidden), so they merge whole. Prop::DevResources, + Prop::Measurements, ]; /// Both sides' writes `b` and `m` of list property `key` merged item by item against the base diff --git a/crates/collab/src/message.rs b/crates/collab/src/message.rs index 4fda776a..a6346162 100644 --- a/crates/collab/src/message.rs +++ b/crates/collab/src/message.rs @@ -94,6 +94,10 @@ pub struct Resume { pub client_id: u32, /// The last sequence number it applied. pub seq: u64, + /// The secret the server gave with this client id ([`ServerMsg::Welcome`]'s `secret`): only + /// the client the id was given to can resume it (0: none). + #[serde(default)] + pub secret: u64, } /// Someone in the session. @@ -635,7 +639,8 @@ pub enum ServerMsg { /// Joined: the session's identity (a server that restarted without its saved state is a new /// session), this connection's client id, the current sequence number, the document (or the /// batches missed since a reconnecting client's last sequence number), what the server did - /// with this client's batches so far, who else is here and what this client may do. + /// with this client's batches so far, who else is here, what this client may do and the + /// secret it resumes its id with ([`Resume::secret`]; only this client is told). Welcome { session: u64, client_id: u32, @@ -644,6 +649,8 @@ pub enum ServerMsg { processed: Processed, peers: Vec, access: Access, + #[serde(default)] + secret: u64, }, /// Accepted changes, in order, to everyone (the author's own come back as confirmation). Changes(Accepted), @@ -732,11 +739,13 @@ pub fn utc_date(secs: u64) -> String { /// The binary form of `msg`: [`WIRE_VERSION`], then postcard. pub fn encode(msg: &T) -> Result, Error> { - let body = postcard::to_allocvec(msg).map_err(|e| Error::Encode(e.to_string()))?; - let mut out = Vec::with_capacity(body.len() + 1); - out.push(WIRE_VERSION); - out.extend(body); - Ok(out) + encode_into(msg, Vec::new()) +} + +/// Append the binary form of `msg` to an owned prefix, without a second payload-sized buffer. +pub fn encode_into(msg: &T, mut prefix: Vec) -> Result, Error> { + prefix.push(WIRE_VERSION); + postcard::to_extend(msg, prefix).map_err(|e| Error::Encode(e.to_string())) } /// Read a binary message of at most `max_bytes` (untrusted input: never panics, allocates at most diff --git a/crates/collab/src/reconnect_tests.rs b/crates/collab/src/reconnect_tests.rs index 95ebbb2b..5c7c4205 100644 --- a/crates/collab/src/reconnect_tests.rs +++ b/crates/collab/src/reconnect_tests.rs @@ -253,3 +253,22 @@ fn random_drops_and_reconnects_converge() { } assert!(snapshots > 0, "no reconnect needed a snapshot"); } + +#[test] +fn resume_needs_the_issued_secret() { + let mut s = Server::new(base(), "tok", Limits { max_peers: 2, ..Limits::default() }); + let mut host = join(&mut s); + let guest = join(&mut s); + // A participant who knows the (public) session and the host's id can't take that id over, + // whether the session is full or not, and with view-only access or not. + let forged = message::Resume { client_id: host.id(), ..guest.resume() }; + assert_eq!(forged.session, host.resume().session); + let refused = s.join_as(Hello { resume: Some(forged), ..hello() }, message::Access::View); + assert!(matches!(refused, Err(ServerMsg::Refused { .. })), "a forged resume of a live id is refused: {refused:?}"); + s.leave(guest.id()); + let (id, _) = s.join_as(Hello { resume: Some(forged), ..hello() }, message::Access::View).unwrap(); + assert_ne!(id, host.id(), "a forged resume joins as new"); + // The real client still comes back as itself. + let resend = come_back(&mut s, &mut host); + assert!(resend.is_empty()); +} diff --git a/crates/collab/src/server.rs b/crates/collab/src/server.rs index 38ed3de8..52f112ad 100644 --- a/crates/collab/src/server.rs +++ b/crates/collab/src/server.rs @@ -7,7 +7,7 @@ use figcraft_doc::{Document, NodeId}; use serde::{Deserialize, Serialize}; use crate::change::{Change, Entry, Prop, Target}; -use crate::message::{Accepted, Access, CatchUp, ClientMsg, Hello, PROTOCOL, Peer, Presence, Processed, ServerMsg, Signal, reads_signals}; +use crate::message::{Accepted, Access, CatchUp, ClientMsg, Hello, PROTOCOL, Peer, Presence, Processed, Resume, ServerMsg, Signal, reads_signals}; use crate::replica::{Mode, Replica, create_change}; use crate::{Error, Limits, docprops, same_secret}; @@ -61,6 +61,9 @@ pub struct Server { /// Every client id handed out (a reconnecting client keeps its id) and what was done with /// its batches. issued: BTreeMap, + /// The secret each client id resumes with (sent only in that client's welcome). Not saved: + /// after a restart, the first to come back for an id that isn't connected sets it. + secrets: BTreeMap, /// The latest accepted batches, oldest first (a reconnecting client gets what it missed). log: VecDeque, /// Changes in `log`, and about how many bytes it holds ([`weight`]). @@ -116,6 +119,7 @@ impl Server { deleted: BTreeSet::new(), removed: BTreeSet::new(), issued: BTreeMap::new(), + secrets: BTreeMap::new(), log: VecDeque::new(), log_changes: 0, log_bytes: 0, @@ -219,6 +223,7 @@ impl Server { && let Some(old) = self.issued.keys().copied().find(|k| !self.peers.contains_key(k)) { self.issued.remove(&old); + self.secrets.remove(&old); } } @@ -266,7 +271,7 @@ impl Server { if !same_secret(&hello.token, &self.token) { return Err(refuse("wrong session token")); } - let resume = hello.resume.filter(|r| r.session == self.session && self.issued.contains_key(&r.client_id)); + let resume = hello.resume.filter(|r| r.session == self.session && self.issued.contains_key(&r.client_id) && self.may_resume(r)); if self.peers.len() >= self.limits.max_peers && resume.is_none_or(|r| !self.peers.contains_key(&r.client_id)) { return Err(refuse("the session is full")); } @@ -282,15 +287,17 @@ impl Server { Some(r) if r.seq <= self.seq && r.seq >= floor => CatchUp::Delta(self.log.iter().filter(|a| a.seq > r.seq).cloned().collect()), _ => CatchUp::Snapshot(self.state.snapshot().map_err(|e| refuse(&e.to_string()))?), }; - let id = match resume { - Some(r) => r.client_id, + let (id, secret) = match resume { + Some(r) => (r.client_id, *self.secrets.entry(r.client_id).or_insert(r.secret)), None => { let id = self.next_client; self.next_client += 1; self.issued.insert(id, Processed::default()); + let secret = new_session_id().max(1); + self.secrets.insert(id, secret); self.forget_oldest(); self.note(Journal::Issued { client: id }); - id + (id, secret) } }; let processed = self.issued.get(&id).cloned().unwrap_or_default(); @@ -298,7 +305,7 @@ impl Server { let signals = reads_signals(&hello.client_version); let peer = Peer { client_id: id, name: hello.name, color: hello.color }; let others = self.peers.values().filter(|p| p.client_id != id).cloned().collect(); - let welcome = ServerMsg::Welcome { session: self.session, client_id: id, seq: self.seq, catch_up, processed, peers: others, access }; + let welcome = ServerMsg::Welcome { session: self.session, client_id: id, seq: self.seq, catch_up, processed, peers: others, access, secret }; self.peers.insert(id, peer.clone()); if access == Access::View { self.viewers.insert(id); @@ -318,6 +325,16 @@ impl Server { Ok((id, outs)) } + /// Whether `r` may take its client id back: it carries the secret that id was given with, or + /// (after a restart, which forgets secrets) a secret for an id nobody is connected as. + fn may_resume(&self, r: &Resume) -> bool { + r.secret != 0 + && match self.secrets.get(&r.client_id) { + Some(s) => *s == r.secret, + None => !self.peers.contains_key(&r.client_id), + } + } + /// Client `id`'s connection closed. pub fn leave(&mut self, id: u32) -> Vec { self.presence.remove(&id); @@ -541,11 +558,14 @@ impl Server { nodes.extend(t.write(self.state.value(&t)?.as_ref())); } } + let mut restored = std::collections::BTreeSet::new(); for id in gone { match doc.node(id) { Some(_) => { for d in doc.subtree(id) { - if let Some(n) = doc.node(d) { + if restored.insert(d) + && let Some(n) = doc.node(d) + { nodes.push(create_change(n, self.state.key(d))?); } } diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 1fff7667..7ce1df5e 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -122,7 +122,8 @@ impl Color { /// Parse hex the way the colour field does: `#` optional, 1, 2, 3, 4, 6 or 8 digits /// (`F` → `FFFFFF`, `AB` → `ABABAB`, `ABC` → `AABBCC`). pub fn from_hex(s: &str) -> Option { - let s = s.trim().trim_start_matches('#'); + let s = s.trim(); + let s = s.strip_prefix('#').unwrap_or(s); if !s.chars().all(|c| c.is_ascii_hexdigit()) { return None; } @@ -355,7 +356,7 @@ impl ImageFilters { } /// The built-in adjustment presets: `(id, label, adjustments)`. "Reset" is the identity. - pub const PRESETS: [(&'static str, &'static str, ImageFilters); 7] = [ + pub const PRESETS: [(&str, &str, ImageFilters); 7] = [ ("vivid", "Vivid", ImageFilters { contrast: 0.2, saturation: 0.45, highlights: -0.1, shadows: 0.1, ..ImageFilters::NONE }), ("muted", "Muted", ImageFilters { contrast: -0.15, saturation: -0.45, ..ImageFilters::NONE }), ("warm", "Warm", ImageFilters { temperature: 0.5, tint: 0.1, saturation: 0.1, ..ImageFilters::NONE }), diff --git a/crates/color/src/tests.rs b/crates/color/src/tests.rs index 2216b6fa..6fedad83 100644 --- a/crates/color/src/tests.rs +++ b/crates/color/src/tests.rs @@ -115,6 +115,15 @@ fn infinite_channels_clamp() { assert_eq!((c.r, c.g, c.b, c.a), (1.0, 0.0, 0.0, 1.0)); } +#[test] +fn hex_allows_at_most_one_leading_hash() { + assert!(Color::from_hex("##ff0000").is_none()); + assert!(Color::from_hex("###fff").is_none()); + assert!(Color::from_hex_or_name("##ff0000").is_none()); + assert_eq!(Color::from_hex("#ff0000").unwrap().to_hex(), "FF0000"); + assert_eq!(Color::from_hex("ff0000").unwrap().to_hex(), "FF0000"); +} + #[test] fn malformed_alpha_or_extra_args_rejected() { assert_eq!(Color::parse_css("rgba(0,0,0,abc)"), None); diff --git a/crates/doc/src/colors.rs b/crates/doc/src/colors.rs index d480f4ea..93d32f0b 100644 --- a/crates/doc/src/colors.rs +++ b/crates/doc/src/colors.rs @@ -35,6 +35,9 @@ impl Effect { impl TextStyle { pub fn for_each_color_mut(&mut self, f: &mut ColorVisitor) { self.fills.iter_mut().flatten().for_each(|p| p.for_each_color_mut(f)); + if let Some(c) = self.decoration_look.as_deref_mut().and_then(|l| l.color.as_mut()) { + f(c); + } } } diff --git a/crates/doc/src/colors_tests.rs b/crates/doc/src/colors_tests.rs index 3a97a5d0..5cadfbbe 100644 --- a/crates/doc/src/colors_tests.rs +++ b/crates/doc/src/colors_tests.rs @@ -134,3 +134,11 @@ fn profile_and_presets_serialize_only_when_set() { assert_eq!(back.color_profile, ColorProfile::DisplayP3); assert_eq!(back.image_presets, d.image_presets); } + +#[test] +fn text_decoration_colour_is_visited() { + let mut s = TextStyle::default(); + s.set_look(|l| l.color = Some(RED)); + s.for_each_color_mut(&mut |c| *c = Color::BLACK); + assert_eq!(s.look().color, Some(Color::BLACK), "decoration colour not visited"); +} diff --git a/crates/doc/src/geometry.rs b/crates/doc/src/geometry.rs index b4f89dbc..1ee885f5 100644 --- a/crates/doc/src/geometry.rs +++ b/crates/doc/src/geometry.rs @@ -17,8 +17,10 @@ pub fn shape_path(n: &Node) -> Option { | NodeKind::Section { .. } => figcraft_geom::rounded_rect(w, h, c.radii, c.smoothing), NodeKind::Slice => figcraft_geom::rounded_rect(w, h, [0.0; 4], 0.0), NodeKind::Ellipse { start, sweep, inner } => figcraft_geom::ellipse(w, h, *start, *sweep, *inner), - NodeKind::Polygon { count } => figcraft_geom::rounded_polygon(&figcraft_geom::polygon_points(w, h, *count), c.radii[0]), - NodeKind::Star { count, inner_ratio } => figcraft_geom::rounded_polygon(&figcraft_geom::star_points(w, h, *count, *inner_ratio), c.radii[0]), + NodeKind::Polygon { count } => figcraft_geom::smoothed_polygon(&figcraft_geom::polygon_points(w, h, *count), c.radii[0], c.smoothing), + NodeKind::Star { count, inner_ratio } => { + figcraft_geom::smoothed_polygon(&figcraft_geom::star_points(w, h, *count, *inner_ratio), c.radii[0], c.smoothing) + } NodeKind::Line => { let mut p = BezPath::new(); p.move_to((0.0, 0.0)); @@ -126,7 +128,7 @@ pub fn hit_leaf(doc: &Document, page: NodeId, pt: Point, tol: f64, result: BoolR /// Whether child `c` of `parent` is masked at `pt`: a visible mask below it (the nearest mask /// below it, a hidden one masks nothing) doesn't cover the point, so nothing of `c` shows there. -fn masked_out(doc: &Document, parent: &Node, c: NodeId, pt: Point) -> bool { +pub fn masked_out(doc: &Document, parent: &Node, c: NodeId, pt: Point) -> bool { if !doc.has_masks() { return false; } diff --git a/crates/doc/src/handoff.rs b/crates/doc/src/handoff.rs index 9700b56c..04bf5e95 100644 --- a/crates/doc/src/handoff.rs +++ b/crates/doc/src/handoff.rs @@ -71,8 +71,38 @@ impl Document { if let (Some(now), Some(r)) = (self.node(root), d.nodes.get_mut(&root)) { Arc::make_mut(r).parent = now.parent; } + // A damaged file can give a historical layer itself (or an ancestor) as a child; a copy + // with a cycle under the root would send every walk of it round forever. + if !d.is_tree_under(root) { + return None; + } d.dev_snapshots.clear(); d.dirty = crate::Dirty::default(); Some(d) } + + /// Whether every layer under `root` is reached once (no layer is its own descendant or listed + /// by two parents), walking iteratively and stopping at the first repeat. + fn is_tree_under(&self, root: NodeId) -> bool { + let mut seen = BTreeSet::new(); + let mut stack = vec![root]; + while let Some(id) = stack.pop() { + if !seen.insert(id) { + return false; + } + let Some(n) = self.nodes.get(&id) else { + return false; + }; + for &c in &n.children { + let Some(child) = self.nodes.get(&c) else { + return false; + }; + if child.parent != Some(id) { + return false; + } + stack.push(c); + } + } + true + } } diff --git a/crates/doc/src/handoff_tests.rs b/crates/doc/src/handoff_tests.rs index 7fc26898..79b97194 100644 --- a/crates/doc/src/handoff_tests.rs +++ b/crates/doc/src/handoff_tests.rs @@ -34,3 +34,54 @@ fn a_snapshot_shows_the_layer_as_it_was() { doc.dev_snapshots.clear(); assert!(serde_json::to_value(&doc).unwrap().get("dev_snapshots").is_none()); } + +#[test] +fn a_snapshot_with_a_layer_inside_itself_is_refused() { + let mut doc = Document::new("d"); + let page = doc.first_page().unwrap(); + let f = doc.create(page, NodeKind::Frame, "Screen", 0.0, 0.0, 100.0, 100.0).unwrap(); + let mut snap = doc.snapshot_subtree(f).unwrap(); + // A damaged file: the historical frame lists itself as a child. + let mut bad = (*snap.nodes[0]).clone(); + bad.children.push(f); + snap.nodes[0] = Arc::new(bad); + doc.dev_snapshots.insert(f, Arc::new(snap)); + assert!(doc.with_dev_snapshot(f).is_none()); +} + +#[test] +fn a_snapshot_with_missing_or_nonreciprocal_child_is_refused() { + use crate::NodeId; + let mut doc = Document::new("d"); + let page = doc.first_page().unwrap(); + let f = doc.create(page, NodeKind::Frame, "Screen", 0.0, 0.0, 100.0, 100.0).unwrap(); + let _c1 = doc.create(f, NodeKind::Rectangle, "R1", 0.0, 0.0, 10.0, 10.0).unwrap(); + let snap = doc.snapshot_subtree(f).unwrap(); + + let mut missing = snap.clone(); + let mut bad_root = (*missing.nodes[0]).clone(); + bad_root.children.push(NodeId(99999)); + missing.nodes[0] = Arc::new(bad_root); + doc.dev_snapshots.insert(f, Arc::new(missing)); + assert!(doc.with_dev_snapshot(f).is_none()); + + let mut nonrecip = snap.clone(); + let mut bad_child = (*nonrecip.nodes[1]).clone(); + bad_child.parent = Some(NodeId(88888)); + nonrecip.nodes[1] = Arc::new(bad_child); + doc.dev_snapshots.insert(f, Arc::new(nonrecip)); + assert!(doc.with_dev_snapshot(f).is_none()); +} + +#[test] +fn validate_is_linear_in_a_wide_parent() { + let mut doc = Document::new("d"); + let page = doc.first_page().unwrap(); + for i in 0..5_000 { + doc.create(page, NodeKind::Rectangle, "R", f64::from(i), 0.0, 1.0, 1.0).unwrap(); + } + assert!(doc.validate().is_empty()); + let kid = doc.node(page).unwrap().children[0]; + doc.get_mut(page).unwrap().children.retain(|c| *c != kid); + assert!(doc.validate().iter().any(|m| m.contains("not listed in parent"))); +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index d4c556b9..9ff1d968 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -856,6 +856,12 @@ impl Document { { *component = *m; } + // A measurement copied with the layer it measures to measures to the copy; one to a + // layer left behind isn't copied. + c.measurements.retain_mut(|m| match m.to { + Some(t) => map.get(&t).map(|n| m.to = Some(*n)).is_some(), + None => true, + }); // A connector (or label) copied with what it joins joins the copies. if let Some(b) = c.board.as_mut() { b.remap(map); @@ -1017,6 +1023,10 @@ impl Document { } } let mut issues = Vec::new(); + // Every (parent, child) edge and the page list, indexed once so each node's checks are + // lookups: a wide parent or a long page list doesn't make validation quadratic. + let edges: std::collections::BTreeSet<(NodeId, NodeId)> = self.nodes.iter().flat_map(|(p, n)| n.children.iter().map(|c| (*p, *c))).collect(); + let page_ids: std::collections::BTreeSet = self.pages.iter().copied().collect(); for (id, n) in self.nodes.iter() { if n.id != *id { note(&mut issues, || format!("node key {id} has id {}", n.id)); @@ -1033,12 +1043,12 @@ impl Document { } } if let Some(p) = n.parent - && !self.node(p).is_some_and(|pn| pn.children.contains(id)) + && !(self.node(p).is_some() && edges.contains(&(p, *id))) { 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)); + let listed = page_ids.contains(id) || (n.is_page() && self.libraries.store == Some(*id)); if n.is_page() != listed { note(&mut issues, || format!("page list mismatch for {id}")); } diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 649d5e56..2a284b05 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -237,6 +237,17 @@ pub struct DevResource { pub name: String, } +/// A measurement kept on the canvas for developers, stored on the layer it starts from: to another +/// layer (`to`), or across one of the layer's own sides (`side`). Dev Mode draws it from the layers' +/// current bounds, so it follows them. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Measurement { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub to: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub side: Option, +} + /// A kind of annotation (Development, Accessibility…): its stable id, name and the colour its /// callouts are drawn in. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -386,6 +397,11 @@ impl NodeKind { self.has_corner_radius() && !matches!(self, NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Vector { .. }) } + /// Kinds whose rounded corners are drawn with corner smoothing (a vector's rounded points are drawn without it). + pub fn has_corner_smoothing(&self) -> bool { + self.has_corner_radius() && !matches!(self, NodeKind::Vector { .. }) + } + pub fn text(&self) -> Option<&TextData> { match self { NodeKind::Text(t) => Some(t), @@ -518,6 +534,9 @@ pub struct Node { /// Dev resources: links for developers (docs, code, tickets…), in the order they were added. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub dev_resources: Vec, + /// Dev Mode measurements that start from this layer, in the order they were added. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub measurements: Vec, /// On a board: what this object is (a sticky note, a connector, a stamp…). #[serde(default, skip_serializing_if = "Option::is_none")] pub board: Option, @@ -631,6 +650,7 @@ impl Node { annotations: Vec::new(), code_links: Vec::new(), dev_resources: Vec::new(), + measurements: Vec::new(), board: None, } } diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index a646ae62..f6187589 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -795,7 +795,7 @@ fn yes() -> bool { impl Effect { /// Effect types in the order the Design panel lists them: (label, type name). - pub const TYPES: [(&'static str, &'static str); 7] = [ + pub const TYPES: [(&str, &str); 7] = [ ("Drop shadow", "dropShadow"), ("Inner shadow", "innerShadow"), ("Layer blur", "layerBlur"), diff --git a/crates/doc/src/proto.rs b/crates/doc/src/proto.rs index a844774c..61b3628e 100644 --- a/crates/doc/src/proto.rs +++ b/crates/doc/src/proto.rs @@ -1003,7 +1003,7 @@ impl PrototypeSettings { ]; /// The device whose screen is [`custom_size`](Self::custom_size) (no body is drawn around it). - pub const CUSTOM_DEVICE: &'static str = "Custom size"; + pub const CUSTOM_DEVICE: &str = "Custom size"; /// A new custom size. pub const CUSTOM_DEFAULT: [f64; 2] = [1280.0, 720.0]; /// The largest side a custom size may have (points). diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 1a006c26..41e233c4 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -559,3 +559,70 @@ fn is_ancestor_holds_for_very_deep_chains() { assert!(d.is_ancestor(root, leaf)); assert!(matches!(d.reparent(root, leaf, None), Err(DocError::Cycle(..)))); } + +#[test] +fn text_insert_at_run_boundary_keeps_runs_disjoint() { + let mut t = TextData::new("0123456789"); + t.restyle(0, 5, |s| s.font_size = 20.0); + t.restyle(5, 10, |s| s.font_size = 30.0); + t.replace_range(5, 5, "xx"); + let runs: Vec<(usize, usize)> = t.runs.iter().map(|r| (r.start, r.end)).collect(); + assert_eq!(runs, vec![(0, 7), (7, 12)]); +} + +#[test] +fn many_small_vector_subpaths_build_and_stroke_without_rescans() { + // Many closed subpaths whose last point repeats the start (#F-332), a long open chain (#F-334) + // and a big rounded polygon (#F-333): each used to scan the whole network per step. + let mut p = figcraft_geom::kurbo::BezPath::new(); + for i in 0..3_000 { + let x = f64::from(i) * 10.0; + p.move_to((x, 0.0)); + p.line_to((x + 5.0, 0.0)); + p.line_to((x + 5.0, 5.0)); + p.line_to((x, 0.0)); + p.close_path(); + } + let net = VectorNetwork::from_path(&p); + assert_eq!(net.vertices.len(), 3 * 3_000); + assert_eq!(net.regions[0].loops.len(), 3_000); + assert!(net.segments.iter().all(|s| s.a < net.vertices.len() && s.b < net.vertices.len())); + assert_eq!(net.stroke_runs().len(), 3_000); + + let pts: Vec = (0..20_000).map(|i| Point::new(f64::from(i), f64::from(i % 7))).collect(); + let chain = VectorNetwork::from_points(&pts, false); + let runs = chain.stroke_runs(); + assert_eq!(runs.len(), 1); + assert!(!runs[0].closed); + + let ring: Vec = (0..4_000) + .map(|i| { + let t = f64::from(i) / 4_000.0 * std::f64::consts::TAU; + Point::new(1000.0 * t.cos(), 1000.0 * t.sin() * if i % 2 == 0 { 1.0 } else { 0.9 }) + }) + .collect(); + let mut poly = VectorNetwork::from_points(&ring, true); + for v in &mut poly.vertices { + v.radius = 0.5; + } + let rounded = poly.rounded().unwrap(); + assert!(rounded.regions[0].loops[0].len() > poly.segments.len(), "arcs are walked between the sides"); + assert_eq!(rounded.regions[0].loops[0].len(), rounded.segments.len()); +} + +#[test] +fn two_segment_closed_loop_rounded_corners() { + let mut v0 = Vertex::new(0.0, 0.0); + v0.radius = 2.0; + let mut v1 = Vertex::new(10.0, 0.0); + v1.radius = 2.0; + let net = VectorNetwork { + vertices: vec![v0, v1], + segments: vec![Segment { a: 0, b: 1, ta: (0.0, 0.0), tb: (0.0, 0.0) }, Segment { a: 1, b: 0, ta: (0.0, 5.0), tb: (0.0, -5.0) }], + regions: vec![Region { loops: vec![vec![0, 1]], winding: WindingRule::NonZero }], + }; + let r = net.rounded().unwrap(); + assert_eq!(r.regions[0].loops[0].len(), 4, "both arcs are in the loop"); + let path = net.fill_path(); + assert!(!path.elements().is_empty()); +} diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 458426ad..d2abfed9 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -579,7 +579,14 @@ impl TextData { } self.runs.sort_by_key(|r| r.start); let mut merged: Vec = Vec::with_capacity(self.runs.len()); - for r in std::mem::take(&mut self.runs) { + for mut r in std::mem::take(&mut self.runs) { + // Runs never overlap: a later run starts where the previous one ends at the earliest. + if let Some(prev) = merged.last() { + r.start = r.start.max(prev.end); + if r.start >= r.end { + continue; + } + } match merged.last_mut() { Some(prev) if prev.end == r.start && prev.style == r.style => prev.end = r.end, _ => merged.push(r), diff --git a/crates/doc/src/vector.rs b/crates/doc/src/vector.rs index 1165bd60..b3040b1e 100644 --- a/crates/doc/src/vector.rs +++ b/crates/doc/src/vector.rs @@ -134,6 +134,16 @@ pub struct VectorNetwork { pub regions: Vec, } +/// Point the subpath's last segment (the only one ending at the fresh vertex `from`) at `to`, +/// so merging a duplicate endpoint doesn't rescan the segments of earlier subpaths. +fn redirect_last_segment(net: &mut VectorNetwork, loop_segs: &[usize], from: usize, to: usize) { + if let Some(s) = loop_segs.last().and_then(|i| net.segments.get_mut(*i)) + && s.b == from + { + s.b = to; + } +} + impl VectorNetwork { /// A network from a polyline / polygon. pub fn from_points(points: &[Point], closed: bool) -> Self { @@ -169,11 +179,7 @@ impl VectorNetwork { if a == f || loop_segs.len() < 2 || (pa - pf).hypot() > 1e-6 || a + 1 != net.vertices.len() { return; } - for s in net.segments.iter_mut() { - if s.b == a { - s.b = f; - } - } + redirect_last_segment(net, loop_segs, a, f); net.vertices.pop(); all_loops.push(std::mem::take(loop_segs)); }; @@ -219,11 +225,7 @@ impl VectorNetwork { let same = matches!((close_pt, first_pt), (Some(c), Some(f)) if (c - f).hypot() <= 1e-6); if same && a != f { // Merge the duplicate end vertex into the first one. - for s in net.segments.iter_mut() { - if s.b == a { - s.b = f; - } - } + redirect_last_segment(&mut net, &loop_segs, a, f); if a + 1 == net.vertices.len() { net.vertices.pop(); } @@ -385,6 +387,7 @@ impl VectorNetwork { s.ta = (q.p1.x - q.p0.x, q.p1.y - q.p0.y); s.tb = (q.p2.x - q.p3.x, q.p2.y - q.p3.y); } + let mut by_vertex: std::collections::BTreeMap = std::collections::BTreeMap::new(); for c in corners { if let Some(v) = out.vertices.get_mut(c.v) { (v.x, v.y) = (c.a.x, c.a.y); @@ -400,15 +403,28 @@ impl VectorNetwork { } out.segments.push(Segment { a: c.v, b: nb, ta: c.ta, tb: c.tb }); let arc = out.segments.len() - 1; - // The arc goes between the two segments wherever a loop walks through the point. - for lp in out.regions.iter_mut().flat_map(|r| r.loops.iter_mut()) { - let n = lp.len(); - let between = - |k: usize| matches!((lp.get(k), lp.get((k + 1) % n)), (Some(&x), Some(&y)) if (x, y) == (c.s1, c.s2) || (x, y) == (c.s2, c.s1)); - if let Some(k) = (0..n).find(|k| between(*k)) { - lp.insert(k + 1, arc); + by_vertex.insert(c.v, arc); + } + // The arc goes between the two segments wherever a loop walks through the point. Each loop + // is rebuilt in one pass, so a polygon with many rounded corners isn't quadratic. + for lp in out.regions.iter_mut().flat_map(|r| r.loops.iter_mut()) { + let Some(&first_idx) = lp.first() else { continue }; + let start_v = match (self.segments.get(first_idx), lp.get(1).and_then(|&i| self.segments.get(i))) { + (Some(first), Some(second)) if first.a == second.a || first.a == second.b => first.b, + (Some(first), _) => first.a, + _ => 0, + }; + let mut rebuilt = Vec::with_capacity(lp.len() + by_vertex.len().min(lp.len())); + let mut cur = start_v; + for &x in lp.iter() { + rebuilt.push(x); + let Some(s) = self.segments.get(x) else { continue }; + cur = if s.a == cur { s.b } else { s.a }; + if let Some(&arc) = by_vertex.get(&cur) { + rebuilt.push(arc); } } + *lp = rebuilt; } Some(out) } @@ -547,25 +563,40 @@ impl VectorNetwork { } } let degree = |v: usize| degrees.get(v).copied().unwrap_or(0); - let next_from = |v: usize, used: &[bool]| -> Option<(usize, bool)> { - self.segments.iter().enumerate().find_map(|(i, s)| { - if used.get(i).copied().unwrap_or(true) { - return None; + // Each vertex's segments in segment order, and how far down that list the used ones reach, + // so finding the next unused segment never rescans the whole network. + let mut adjacent: Vec> = vec![Vec::new(); self.vertices.len()]; + for (i, s) in self.segments.iter().enumerate() { + if let Some(l) = adjacent.get_mut(s.a) { + l.push((i, false)); + } + if s.b != s.a + && let Some(l) = adjacent.get_mut(s.b) + { + l.push((i, true)); + } + } + let mut cursor = vec![0usize; self.vertices.len()]; + let mut remaining = self.segments.len(); + let mut next_from = |v: usize, used: &[bool]| -> Option<(usize, bool)> { + let list = adjacent.get(v)?; + let at = cursor.get_mut(v)?; + while let Some(&(i, rev)) = list.get(*at) { + if !used.get(i).copied().unwrap_or(true) { + return Some((i, rev)); } - if s.a == v { - Some((i, false)) - } else if s.b == v { - Some((i, true)) - } else { - None - } - }) + *at += 1; + } + None }; let stop = |v: usize| degree(v) != 2 || breaks(v); // Start runs at endpoints, junctions and breaks first, then whatever remains (pure loops). let mut starts: Vec = (0..self.vertices.len()).filter(|v| stop(*v)).collect(); starts.extend((0..self.vertices.len()).filter(|v| !stop(*v))); for v0 in starts { + if remaining == 0 { + break; + } while let Some((si, rev)) = next_from(v0, &used) { let Some(start) = self.vertices.get(v0) else { break }; let mut path = BezPath::new(); @@ -574,8 +605,11 @@ impl VectorNetwork { let mut closed = false; let mut seg = Some((si, rev)); while let Some((i, r)) = seg { - if let Some(u) = used.get_mut(i) { + if let Some(u) = used.get_mut(i) + && !*u + { *u = true; + remaining = remaining.saturating_sub(1); } let Some(s) = self.segments.get(i) else { break }; self.push_seg(&mut path, s, r); diff --git a/crates/engine/src/al_behaviour_tests.rs b/crates/engine/src/al_behaviour_tests.rs index 4659a597..a7a4d1d7 100644 --- a/crates/engine/src/al_behaviour_tests.rs +++ b/crates/engine/src/al_behaviour_tests.rs @@ -221,3 +221,15 @@ fn canvas_stacking_paints_and_exports_the_first_child_on_top() { s.undo().unwrap(); assert_eq!(pixel(&s, f, 40, 10), [0, 0, 255, 255]); } + +#[test] +fn a_size_given_with_a_new_path_fixes_that_axis() { + let mut s = Session::new(); + let f = al_frame(&mut s, json!({})); + let v = id(s.execute("node.create", &json!({"type": "vector", "path": "M0 0 L10 10", "closed": false, "parent": f})).unwrap()); + set(&mut s, v, json!({"sizingH": "fill"})); + assert_eq!(sizing(&s, v).0, Sizing::Fill); + set(&mut s, v, json!({"path": "M0 0 L20 5", "width": 30})); + assert_eq!(sizing(&s, v).0, Sizing::Fixed); + assert!((size(&s, v).0 - 30.0).abs() < 1e-6, "{:?}", size(&s, v)); +} diff --git a/crates/engine/src/batch.rs b/crates/engine/src/batch.rs new file mode 100644 index 00000000..5c4550ec --- /dev/null +++ b/crates/engine/src/batch.rs @@ -0,0 +1,192 @@ +//! Step references in `command.batch`: a step's params can use what an earlier step returned. +//! +//! `{"$step": n}` stands for the `id` in the result of step `n` (zero-based), and `{"$ref": "name"}` +//! for the `id` in the result of the step that called itself `name` with `"as": "name"`. Either +//! takes a `field`, a key of that result or a slash path into it (`ids/0`, `map/3`, `bounds/x`), the +//! way `variable.bind` names `fills/0/color`. The CLI's script runner resolves its steps the same way. + +use std::collections::HashMap; + +use serde_json::{Map, Value}; + +use crate::{Result, bad}; + +/// Deepest params nesting the resolver walks before giving up. +const MAX_DEPTH: usize = 64; + +/// One step of a batch: a command, its params and the name later steps can call it by. +#[derive(Clone, Debug, PartialEq)] +pub struct BatchStep { + pub command: String, + pub params: Value, + /// The step's `as`, for `{"$ref": name}` in later steps. + pub name: Option, +} + +impl BatchStep { + /// A step without a name. + pub fn new(command: impl Into, params: Value) -> Self { + Self { command: command.into(), params, name: None } + } +} + +/// A step's `as` (`step` is its index, for the error): `None` when it has none. +pub fn step_name(step: usize, json: &Value) -> Result> { + match json.get("as") { + None | Some(Value::Null) => Ok(None), + Some(Value::String(name)) if !name.is_empty() => Ok(Some(name.clone())), + Some(_) => Err(bad("command.batch", format!("step {step}: `as` must be a name (a non-empty string)"))), + } +} + +/// The steps of one batch run so far: their results, and which step each `as` name belongs to. +/// A nested `command.batch` step gets its own scope, so its references are to its own steps. +#[derive(Clone, Debug, Default)] +pub struct BatchScope { + names: HashMap, + results: Vec, +} + +impl BatchScope { + /// The scope for running `steps`. Two steps with the same name fail here, before any step runs. + pub fn new(steps: &[BatchStep]) -> Result { + let mut names = HashMap::new(); + for (i, step) in steps.iter().enumerate() { + let Some(name) = &step.name else { continue }; + if let Some(first) = names.insert(name.clone(), i) { + return Err(bad("command.batch", format!("step {i}: `as` \"{name}\" is already step {first}'s name"))); + } + } + Ok(Self { names, results: Vec::with_capacity(steps.len()) }) + } + + /// The results of the steps run so far, in order. + pub fn results(&self) -> &[Value] { + &self.results + } + + pub fn into_results(self) -> Vec { + self.results + } + + /// The index of the step that runs next. + pub fn next_step(&self) -> usize { + self.results.len() + } + + pub(crate) fn push(&mut self, result: Value) { + self.results.push(result); + } + + /// `params` of the next step with every reference in it replaced. + pub(crate) fn resolve(&self, params: &Value) -> Result { + self.resolve_in(params, 0) + } + + fn resolve_in(&self, v: &Value, depth: usize) -> Result { + let step = self.next_step(); + if depth > MAX_DEPTH { + return Err(bad("command.batch", format!("step {step}: params nest deeper than {MAX_DEPTH} levels"))); + } + match v { + Value::Object(o) if o.contains_key("$step") || o.contains_key("$ref") => self.reference(o), + Value::Object(o) => { + let mut out = Map::with_capacity(o.len()); + for (k, x) in o { + out.insert(k.clone(), self.resolve_in(x, depth + 1)?); + } + Ok(Value::Object(out)) + } + Value::Array(a) => a.iter().map(|x| self.resolve_in(x, depth + 1)).collect::>>().map(Value::Array), + _ => Ok(v.clone()), + } + } + + /// The value a `{"$step": n}` or `{"$ref": "name"}` object (with an optional `field`) stands for. + fn reference(&self, o: &Map) -> Result { + let step = self.next_step(); + let fail = |msg: String| bad("command.batch", format!("step {step}: {msg}")); + let key = if o.contains_key("$step") { "$step" } else { "$ref" }; + if let Some(k) = o.keys().find(|k| *k != key && *k != "field") { + return Err(fail(format!("a `{key}` reference takes only `field`, not `{k}`"))); + } + let (n, who) = if key == "$step" { + let n = o.get("$step").and_then(Value::as_u64).ok_or_else(|| fail("`$step` must be a step index (0, 1, …)".into()))?; + let n = usize::try_from(n).ok().filter(|n| *n < step).ok_or_else(|| { + let range = if step == 0 { "there is none before step 0".to_string() } else { format!("0 to {}", step - 1) }; + fail(format!("`$step` {n} must be an earlier step ({range})")) + })?; + (n, format!("step {n}")) + } else { + let name = o.get("$ref").and_then(Value::as_str).ok_or_else(|| fail("`$ref` must be a step name (a string)".into()))?; + let n = *self.names.get(name).ok_or_else(|| fail(format!("`$ref` \"{name}\" names no step ({})", self.known_names())))?; + if n >= step { + return Err(fail(format!("`$ref` \"{name}\" is step {n}, not an earlier step"))); + } + (n, format!("step {n} (\"{name}\")")) + }; + let path = match o.get("field") { + None => "id", + Some(Value::String(f)) => f.as_str(), + Some(_) => return Err(fail("`field` must be a string".into())), + }; + let result = self.results.get(n).ok_or_else(|| fail(format!("{who} has no result")))?; + lookup(result, path).cloned().map_err(|why| fail(format!("{who}'s result has no `{path}`{why}"))) + } + + fn known_names(&self) -> String { + let mut names: Vec<_> = self.names.iter().collect(); + names.sort_by_key(|(_, i)| **i); + if names.is_empty() { + return "no step has an `as` name".into(); + } + let list: Vec<_> = names.iter().map(|(name, i)| format!("\"{name}\" is step {i}")).collect(); + list.join(", ") + } +} + +/// `path` in `v`: a key as written, else a slash path (`ids/0`, `bounds/x`; a leading `/` as in +/// JSON Pointer is allowed). The error is the detail after "has no `path`". +fn lookup<'a>(v: &'a Value, path: &str) -> std::result::Result<&'a Value, String> { + // A key as written first, so a key holding `/` still works as it did before paths. + if let Some(x) = v.get(path) { + return Ok(x); + } + let hint = if path.contains('.') && !path.contains('/') { " (paths use `/`, as in `ids/0`)" } else { "" }; + walk(v, path.strip_prefix('/').unwrap_or(path)).map_err(|why| format!("{why}{hint}")) +} + +fn walk<'a>(v: &'a Value, path: &str) -> std::result::Result<&'a Value, String> { + let mut cur = v; + let mut walked = String::new(); + for seg in path.split('/') { + let here = if walked.is_empty() { "the result".to_string() } else { format!("`{walked}`") }; + cur = match cur { + Value::Object(o) => o.get(seg).ok_or_else(|| if walked.is_empty() { String::new() } else { format!(": {here} has no `{seg}`") })?, + Value::Array(a) => match seg.parse::() { + Ok(i) => a.get(i).ok_or_else(|| { + let items = if a.len() == 1 { "1 item".to_string() } else { format!("{} items", a.len()) }; + format!(": {here} has {items}") + })?, + Err(_) => return Err(format!(": {here} is a list, so `{seg}` must be an index (0, 1, …)")), + }, + _ => return Err(format!(": {here} is {}", kind(cur))), + }; + if !walked.is_empty() { + walked.push('/'); + } + walked.push_str(seg); + } + Ok(cur) +} + +fn kind(v: &Value) -> &'static str { + match v { + Value::Null => "null", + Value::Bool(_) => "a boolean", + Value::Number(_) => "a number", + Value::String(_) => "a string", + Value::Array(_) => "a list", + Value::Object(_) => "an object", + } +} diff --git a/crates/engine/src/batch_step_ref_tests.rs b/crates/engine/src/batch_step_ref_tests.rs index ac69118e..451a6300 100644 --- a/crates/engine/src/batch_step_ref_tests.rs +++ b/crates/engine/src/batch_step_ref_tests.rs @@ -110,3 +110,151 @@ fn a_nested_batch_refers_to_its_own_steps() { let rect = s.doc().unwrap().node(id(&inner[1])).unwrap().clone(); assert_eq!(rect.parent, Some(id(&inner[0])), "in the nested batch's own first frame, not the outer one"); } + +/// Runs `commands` as a batch and returns the error message, which must be `BadParams`. +fn batch_error(s: &mut Session, commands: serde_json::Value) -> String { + match s.execute("command.batch", &json!({"commands": commands})) { + Err(EngineError::BadParams { msg, .. }) => msg, + other => panic!("expected BadParams, got {other:?}"), + } +} + +#[test] +fn a_named_step_is_referred_to_by_its_name() { + let mut s = Session::new(); + let results = s + .execute( + "command.batch", + &json!({"commands": [ + {"command": "node.create", "as": "card", "params": {"type": "frame", "width": 200, "height": 100}}, + {"command": "node.create", "as": "swatch", "params": {"type": "rectangle", "parent": {"$ref": "card"}, "width": 20, "height": 20}}, + {"command": "node.set", "params": {"ids": [{"$ref": "swatch", "field": "id"}], "props": {"name": "Swatch"}}}, + {"command": "node.create", "params": {"type": "text", "text": "hi", "parent": {"$step": 0}}} + ]}), + ) + .unwrap(); + let (frame, rect, text) = (id(&results[0]), id(&results[1]), id(&results[3])); + let doc = s.doc().unwrap(); + assert_eq!(doc.node(rect).unwrap().parent, Some(frame)); + assert_eq!(doc.node(rect).unwrap().name, "Swatch"); + assert_eq!(doc.node(text).unwrap().parent, Some(frame), "`$step` and `$ref` mix in one batch"); + assert_eq!(s.ds().unwrap().history.undo.len(), 1, "still one undo step"); +} + +/// `edit.duplicate` answers `{"ids": [...]}`: a slash path in `field` takes one of them. +#[test] +fn a_field_path_indexes_into_a_list_in_the_result() { + let mut s = Session::new(); + let results = s + .execute( + "command.batch", + &json!({"commands": [ + {"command": "node.create", "as": "a", "params": {"type": "frame", "name": "A", "width": 10, "height": 10}}, + {"command": "node.create", "as": "b", "params": {"type": "frame", "name": "B", "width": 10, "height": 10}}, + {"command": "edit.duplicate", "as": "copies", "params": {"ids": [{"$ref": "a"}, {"$ref": "b"}]}}, + {"command": "node.set", "params": {"ids": [{"$ref": "copies", "field": "ids/1"}], "props": {"name": "B copy"}}}, + {"command": "node.set", "params": {"ids": [{"$step": 2, "field": "/ids/0"}], "props": {"name": "A copy"}}} + ]}), + ) + .unwrap(); + let copies = results[2]["ids"].as_array().unwrap().clone(); + let doc = s.doc().unwrap(); + assert_eq!(doc.node(crate::params::id_of(&copies[0]).unwrap()).unwrap().name, "A copy", "a leading `/` works too"); + assert_eq!(doc.node(crate::params::id_of(&copies[1]).unwrap()).unwrap().name, "B copy"); +} + +#[test] +fn a_field_path_walks_nested_objects_and_takes_whole_values() { + let mut scope = crate::BatchScope::default(); + scope.push(json!({"ids": [7, 8], "bounds": {"x": 1.5, "corners": [{"r": 4}]}, "a/b": "slash key"})); + let resolved = scope + .resolve(&json!({ + "x": {"$step": 0, "field": "bounds/x"}, + "r": {"$step": 0, "field": "bounds/corners/0/r"}, + "all": {"$step": 0, "field": "ids"}, + "key": {"$step": 0, "field": "a/b"}, + "pointer": {"$step": 0, "field": "/ids"} + })) + .unwrap(); + assert_eq!(resolved, json!({"x": 1.5, "r": 4, "all": [7, 8], "key": "slash key", "pointer": [7, 8]}), "a key holding `/` is still read as a key"); +} + +#[test] +fn bad_names_and_paths_fail_and_roll_back() { + let mut s = Session::new(); + let before = s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len(); + let frame = |name: &str| json!({"command": "node.create", "as": name, "params": {"type": "frame", "width": 10, "height": 10}}); + let dup = json!({"command": "edit.duplicate", "as": "copy", "params": {"ids": [{"$ref": "card"}]}}); + let set = |r: serde_json::Value| json!({"command": "node.set", "params": {"ids": [r], "props": {"name": "x"}}}); + let later = json!({"command": "node.create", "as": "later", "params": {"type": "frame", "width": 10, "height": 10}}); + for (commands, needle) in [ + (json!([frame("card"), frame("card")]), "step 1: `as` \"card\" is already step 0's name"), + (json!([frame("card"), set(json!({"$ref": "nope"}))]), "step 1: `$ref` \"nope\" names no step (\"card\" is step 0)"), + (json!([{"command": "node.create", "params": {"type": "frame"}}, set(json!({"$ref": "nope"}))]), "no step has an `as` name"), + (json!([frame("card"), set(json!({"$ref": "later"})), later]), "step 1: `$ref` \"later\" is step 2, not an earlier step"), + ( + json!([frame("card"), {"command": "node.set", "as": "self", "params": {"ids": [{"$ref": "self"}], "props": {}}}]), + "is step 1, not an earlier step", + ), + (json!([frame("card"), set(json!({"$ref": 0}))]), "`$ref` must be a step name"), + (json!([frame("card"), set(json!({"$ref": "card", "id": 3}))]), "a `$ref` reference takes only `field`, not `id`"), + (json!([frame("card"), set(json!({"$ref": "card", "$step": 0}))]), "a `$step` reference takes only `field`, not `$ref`"), + (json!([frame("card"), {"command": "node.create", "as": "", "params": {}}]), "step 1: `as` must be a name"), + (json!([frame("card"), {"command": "node.create", "as": 3, "params": {}}]), "step 1: `as` must be a name"), + ( + json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "ids/1"}))]), + "step 2: step 1 (\"copy\")'s result has no `ids/1`: `ids` has 1 item", + ), + (json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "ids/first"}))]), "`ids` is a list, so `first` must be an index"), + (json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "ids/0/x"}))]), "has no `ids/0/x`: `ids/0` is a number"), + (json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "map/0"}))]), "step 1 (\"copy\")'s result has no `map/0`"), + (json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "ids.0"}))]), "has no `ids.0` (paths use `/`, as in `ids/0`)"), + (json!([frame("card"), set(json!({"$step": 0, "field": "id/0"}))]), "has no `id/0`: `id` is a number"), + ] { + let msg = batch_error(&mut s, commands); + assert!(msg.contains(needle), "{msg:?} should contain {needle:?}"); + assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len(), before, "rolled back"); + } +} + +#[test] +fn text_that_looks_like_a_named_reference_is_left_alone() { + let mut s = Session::new(); + let text = "{\"$ref\": \"card\"} $ref"; + let results = s.execute("command.batch", &json!({"commands": [{"command": "node.create", "params": {"type": "text", "text": text}}]})).unwrap(); + let n = s.doc().unwrap().node(id(&results[0])).unwrap().clone(); + assert!(matches!(n.kind, figcraft_doc::NodeKind::Text(t) if t.characters == text)); +} + +/// A nested batch has its own names: it can't see the outer batch's, may reuse them, and the +/// outer batch can't see its. +#[test] +fn a_nested_batch_has_its_own_names() { + let mut s = Session::new(); + let frame = |name: &str| json!({"command": "node.create", "as": "frame", "params": {"type": "frame", "name": name, "width": 100, "height": 100}}); + let results = s + .execute( + "command.batch", + &json!({"commands": [ + frame("Outer"), + {"command": "command.batch", "as": "inner", "params": {"commands": [ + frame("Inner"), + {"command": "node.create", "params": {"type": "rectangle", "parent": {"$ref": "frame"}, "width": 10, "height": 10}} + ]}}, + {"command": "node.set", "params": {"ids": [{"$ref": "inner", "field": "1/id"}], "props": {"name": "In inner"}}} + ]}), + ) + .unwrap(); + let inner = &results[1]; + let rect = s.doc().unwrap().node(id(&inner[1])).unwrap().clone(); + assert_eq!(rect.parent, Some(id(&inner[0])), "in the nested batch's own frame, not the outer one"); + assert_eq!(rect.name, "In inner", "a path indexes into a nested batch's result list"); + + let msg = batch_error( + &mut s, + json!([frame("Outer"), {"command": "command.batch", "params": {"commands": [ + {"command": "node.create", "params": {"type": "rectangle", "parent": {"$ref": "frame"}, "width": 10, "height": 10}} + ]}}]), + ); + assert!(msg.contains("`$ref` \"frame\" names no step"), "the outer names aren't visible inside: {msg}"); +} diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index 10ad13e5..4278f66c 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -206,8 +206,9 @@ fn distribute(s: &mut Session, p: &Value, horizontal: bool) -> Result { let mut items: Vec<(NodeId, Rect)> = ids.iter().filter_map(|i| doc.world_bounds(*i).map(|b| (*i, b))).collect(); items.sort_by(|a, b| if horizontal { a.1.x0.total_cmp(&b.1.x0) } else { a.1.y0.total_cmp(&b.1.y0) }); let total: f64 = items.iter().map(|(_, b)| if horizontal { b.width() } else { b.height() }).sum(); - let (Some(first), Some(last)) = (items.first(), items.last()) else { return ok() }; - let span = if horizontal { last.1.x1 - first.1.x0 } else { last.1.y1 - first.1.y0 }; + let Some(first) = items.first() else { return ok() }; + let end = items.iter().map(|(_, b)| if horizontal { b.x1 } else { b.y1 }).fold(f64::MIN, f64::max); + let span = if horizontal { end - first.1.x0 } else { end - first.1.y0 }; let gap = num(p, "spacing").unwrap_or((span - total) / (items.len().saturating_sub(1).max(1)) as f64); let mut pos = if horizontal { first.1.x0 } else { first.1.y0 }; for (i, b) in items.clone() { diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index 78609771..428738d1 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -217,14 +217,14 @@ pub fn specs() -> Vec { spec!( "component.addProperty", "Add component property", - "{id? (component, set, or a layer inside a component: it gets bound), name, type: boolean|text|instanceSwap|variant|slot, default?, layer? (node bound to it; a frame for slots), preferred? (instance swap and slot properties: component or set ids offered first)}", + "{id? (component, set, or a layer inside a component: it gets bound), name, type: boolean|text|instanceSwap|variant|slot, default? (boolean: the bound layer's visibility in the main; without it, the layer's current visibility), layer? (node bound to it; a frame for slots), preferred? (instance swap and slot properties: component or set ids offered first)}", true, add_prop ), spec!( "component.editProperty", "Edit component property", - "{id? (component or set), name, newName?, default? (variant properties: that variant takes the top-left place), value? + newValue? (rename a variant value), index? (variant properties: move to this place; the variants' names follow), preferred? (instance swap and slot properties: component or set ids offered first)}", + "{id? (component or set), name, newName?, default? (variant properties: that variant takes the top-left place; boolean: the main's bound layers show or hide to match), value? + newValue? (rename a variant value), index? (variant properties: move to this place; the variants' names follow), preferred? (instance swap and slot properties: component or set ids offered first)}", true, edit_prop ), @@ -1151,7 +1151,8 @@ fn add_prop_for(cmd: &str, s: &mut Session, p: &Value) -> Result { } let layer_node = layer.and_then(|l| doc.node(l)).cloned(); let def = match t.to_ascii_lowercase().as_str() { - "boolean" | "bool" => PropDef::Boolean { default: boolean(p, "default").unwrap_or(true) }, + // Without a default, a bound layer keeps showing as it does: its visibility is the default. + "boolean" | "bool" => PropDef::Boolean { default: boolean(p, "default").or_else(|| layer_node.as_ref().map(|n| n.visible)).unwrap_or(true) }, "text" => PropDef::Text { default: string(p, "default") .or_else(|| layer_node.as_ref().and_then(|n| n.kind.text()).map(|t| t.characters.clone())) @@ -1255,7 +1256,12 @@ fn bind(cmd: &str, doc: &mut Document, layer: NodeId, property: Option<&str>, fi } _ => {} } - doc.get_mut(layer)?.prop_refs.insert(field.to_string(), property.to_string()); + let n = doc.get_mut(layer)?; + n.prop_refs.insert(field.to_string(), property.to_string()); + // On the main, a boolean property's value is the visibility of the layers it controls. + if let PropDef::Boolean { default } = def { + n.visible = *default; + } Ok(()) } @@ -1424,6 +1430,10 @@ fn edit_prop(s: &mut Session, p: &Value) -> Result { rename_prop_refs(doc, *c, &name, Some(nn))?; } } + // The main's bound layers show the new default. + if let PropDef::Boolean { default } = new_def { + set_boolean_default(doc, &comps, new_name.as_deref().unwrap_or(&name), default); + } ok() } diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 33c611a9..a6e7932a 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -167,6 +167,42 @@ pub fn parse_path_data(d: &str) -> Option { figcraft_geom::BezPath::from_svg(d).ok() } +/// A vector's geometry made from SVG path data (see [`path_network`]). +pub(crate) struct PathShape { + /// The network, with the path's own box moved to the origin and scaled to the size asked for. + pub network: VectorNetwork, + pub width: f64, + pub height: f64, + /// Where the path's own box starts in its coordinates (unscaled). + pub offset: (f64, f64), +} + +/// The network SVG path data `d` draws, for `node.create` and `node.set`: the path's own box moves +/// to the origin, and `width`/`height` scale it as [`vector_scale`] says. Data that doesn't parse +/// or draws nothing is refused. +pub(crate) fn path_network(cmd: &str, d: &str, width: Option, height: Option) -> Result { + use figcraft_geom::Shape; + let empty = || bad(cmd, "`path` draws nothing: a vector needs a visible line or area"); + let mut path = parse_path_data(d).ok_or_else(|| bad(cmd, "`path` is not valid SVG path data"))?; + let bb = path.bounding_box(); + if !(bb.x0.is_finite() && bb.y0.is_finite() && bb.width().is_finite() && bb.height().is_finite()) { + return Err(bad(cmd, "`path` has coordinates too large to draw")); + } + if bb.width() <= 1e-9 && bb.height() <= 1e-9 { + return Err(empty()); + } + path.apply_affine(figcraft_geom::Affine::translate((-bb.x0, -bb.y0))); + let (sx, sy) = vector_scale(width, height, bb.width(), bb.height(), "a path").map_err(|e| bad(cmd, e))?; + if sx != 1.0 || sy != 1.0 { + path.apply_affine(figcraft_geom::Affine::scale_non_uniform(sx, sy)); + } + let network = VectorNetwork::from_path(&path); + if network.segments.is_empty() { + return Err(empty()); + } + Ok(PathShape { network, width: bb.width() * sx, height: bb.height() * sy, offset: (bb.x0, bb.y0) }) +} + /// How much a vector's path is scaled to take the size asked for: with both sides, each scales /// independently (as resizing a vector does); with one, the other keeps its proportion. A size on /// an axis the geometry has none of is an error unless it is 0 (a line's height), and a size of 0 @@ -229,27 +265,12 @@ pub fn create(s: &mut Session, p: &Value) -> Result { let asked = (num(p, "width"), num(p, "height")); if let NodeKind::Vector { network } = &mut kind { if let Some(d) = string(p, "path") { - let mut path = parse_path_data(&d).ok_or_else(|| bad(cmd, "`path` is not valid SVG path data"))?; - use figcraft_geom::Shape; - let bb = path.bounding_box(); - if bb.width() <= 1e-9 && bb.height() <= 1e-9 { - return Err(bad(cmd, "`path` draws nothing: a vector needs a visible line or area")); - } - path.apply_affine(figcraft_geom::Affine::translate((-bb.x0, -bb.y0))); - let (sx, sy) = vector_scale(asked.0, asked.1, bb.width(), bb.height(), "a path").map_err(|e| bad(cmd, e))?; - if sx != 1.0 || sy != 1.0 { - path.apply_affine(figcraft_geom::Affine::scale_non_uniform(sx, sy)); - } - *network = VectorNetwork::from_path(&path); - if network.segments.is_empty() { - return Err(bad(cmd, "`path` draws nothing: a vector needs a visible line or area")); - } + let shape = path_network(cmd, &d, asked.0, asked.1)?; + *network = shape.network; if boolean(p, "closed") == Some(false) { network.regions.clear(); } - w = bb.width() * sx; - h = bb.height() * sy; - offset = (bb.x0, bb.y0); + (w, h, offset) = (shape.width, shape.height, shape.offset); } else if let Some(Value::Array(pts)) = get(p, "points") { let pts: Vec = pts.iter().filter_map(|v| Some(Point::new(f64_of(v.get(0)?)?, f64_of(v.get(1)?)?))).collect(); if pts.len() < 2 { diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 5ed92aec..c3514ac5 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -4,11 +4,11 @@ use std::collections::{BTreeMap, BTreeSet}; use figcraft_doc::*; use figcraft_geom::Affine; -use serde_json::{Value, json}; +use serde_json::{Map, Value, json}; use super::{CommandSpec, ok}; use crate::params::*; -use crate::{EngineError, Result, Session, bad, spec}; +use crate::{BatchStep, EngineError, Result, Session, bad, spec}; pub fn specs() -> Vec { vec![ @@ -216,7 +216,13 @@ pub fn specs() -> Vec { let i = req_id("node.get", p, "id")?; Ok(crate::inspect::node_json(s.doc()?, i, 1)) }), - spec!("node.reparent", "Move layer into", "{ids?, parent, index?}", true, reparent), + spec!( + "node.reparent", + "Move layer into", + "{ids?, parent, index? (final position among parent's children, bottom first; default top)}", + true, + reparent + ), spec!("node.reorder", "Reorder layer", "{id, index}", true, |s, p| { let i = req_id("node.reorder", p, "id")?; let idx = req_num("node.reorder", p, "index")?.max(0.0) as usize; @@ -336,15 +342,16 @@ pub fn specs() -> Vec { } ok() }), - spec!("command.batch", "Run several commands", "{label?, commands: [{command, params}]} (one undo step)", false, |s, p| { + spec!("command.batch", "Run several commands", "{label?, commands: [{command, params, as?}]} (one undo step)", false, |s, p| { let label = string(p, "label").unwrap_or_else(|| "Batch".into()); - let cmds: Vec<(String, Value)> = match get(p, "commands") { + let cmds: Vec = match get(p, "commands") { Some(Value::Array(a)) => { let mut out = Vec::new(); for (i, c) in a.iter().enumerate() { let cmd = c.get("command").and_then(Value::as_str).ok_or_else(|| bad("command.batch", format!("step {i} has no `command`")))?; - out.push((cmd.to_string(), c.get("params").cloned().unwrap_or(json!({})))); + let params = c.get("params").cloned().unwrap_or(json!({})); + out.push(BatchStep { name: crate::batch::step_name(i, c)?, ..BatchStep::new(cmd, params) }); } out } @@ -535,6 +542,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(()) } @@ -594,6 +602,10 @@ pub fn design_clipboard_buffer(html: &str) -> Option> { figcraft_format::fig::is_fig(&bytes).then_some(bytes) } +/// Paste design layers from clipboard HTML or base64 `data`: decode and reject missing, +/// invalid or oversized data, load a temporary `.fig`, take its top-level page layers, +/// store them as a foreign-file clipboard source with a bumped layers generation, then +/// delegate to `paste` and append import warnings. fn paste_design_layers(s: &mut Session, p: &Value) -> Result { const CMD: &str = "edit.pasteDesignLayers"; let bytes = match (string(p, "html"), string(p, "data")) { @@ -610,8 +622,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); @@ -1169,6 +1191,9 @@ fn find_replace(s: &mut Session, p: &Value) -> Result { Ok(json!({"matches": hits})) } +/// Keys only some layer types take, the test for those layers, and why the others skip them. +type TypedKeys<'a> = (&'a [&'a str], &'a dyn Fn(&Node) -> bool, &'a str); + fn set(s: &mut Session, p: &Value) -> Result { let ids = req_targets("node.set", s, p)?; let props = match get(p, "props") { @@ -1199,34 +1224,44 @@ fn set(s: &mut Session, p: &Value) -> Result { None => Vec::new(), }; let mut warnings = font_style_warnings(&s.ds()?.doc, &ids, &props); - // Auto layout keys go to the layers that can have auto layout; with none among the targets, - // the first refuses them (`props::layout_mut`). + // Type-specific keys go to the layers that take them and are skipped, with a warning, on the + // others: auto layout keys to frame-like layers, `path` to vectors. With none among the targets + // that take them, the first refuses them (`props::layout_mut`, `props::apply`). + let doc = &s.ds()?.doc; let layout_keys: Vec<&str> = props.keys().map(String::as_str).filter(|k| super::props::LAYOUT_KEYS.contains(k)).collect(); - let mut without_layout = props.clone(); - let mut skipped = BTreeSet::new(); - if !layout_keys.is_empty() { - let doc = &s.ds()?.doc; - let frame_like = |i: &NodeId| doc.node(*i).is_some_and(|n| n.kind.is_frame_like()); - if ids.iter().any(frame_like) { - skipped = ids.iter().copied().filter(|i| !frame_like(i)).collect(); - without_layout.retain(|k, _| !layout_keys.contains(&k.as_str())); + let path_keys: Vec<&str> = props.keys().map(String::as_str).filter(|k| *k == "path").collect(); + let frame_like = |n: &Node| n.kind.is_frame_like(); + let vector = |n: &Node| matches!(n.kind, NodeKind::Vector { .. }); + let typed: [TypedKeys; 2] = [ + (&layout_keys, &frame_like, "only frames, components and instances have auto layout"), + (&path_keys, &vector, "only vector layers have a path"), + ]; + let mut skipped: BTreeMap> = BTreeMap::new(); + for (keys, takes, why) in typed { + let takes = |i: &NodeId| doc.node(*i).is_some_and(takes); + if keys.is_empty() || !ids.iter().any(takes) { + continue; } - if !skipped.is_empty() { - let names: Vec = - skipped.iter().take(10).filter_map(|i| doc.node(*i)).map(|n| format!("{} “{}” ({})", n.id, n.name, n.kind.type_name())).collect(); - let more = if skipped.len() > names.len() { format!(" and {} more", skipped.len() - names.len()) } else { String::new() }; - warnings.push(format!( - "{} skipped on {}{more}: only frames, components and instances have auto layout", - layout_keys.iter().map(|k| format!("`{k}`")).collect::>().join(", "), - names.join(", ") - )); + let others: Vec = ids.iter().copied().filter(|i| !takes(i)).collect(); + if others.is_empty() { + continue; } + for i in &others { + skipped.entry(*i).or_insert_with(|| props.clone()).retain(|k, _| !keys.contains(&k.as_str())); + } + let names: Vec = + others.iter().take(10).filter_map(|i| doc.node(*i)).map(|n| format!("{} “{}” ({})", n.id, n.name, n.kind.type_name())).collect(); + let more = if others.len() > names.len() { format!(" and {} more", others.len() - names.len()) } else { String::new() }; + warnings.push(format!( + "{} skipped on {}{more}: {why}", + keys.iter().map(|k| format!("`{k}`")).collect::>().join(", "), + names.join(", ") + )); } let doc = s.doc_mut()?; let before = super::styles::bound_before(doc, &ids); for i in ids { - let props = if skipped.contains(&i) { &without_layout } else { &props }; - super::props::apply("node.set", doc, i, props)?; + super::props::apply("node.set", doc, i, skipped.get(&i).unwrap_or(&props))?; } super::styles::detach_edited(doc, &before); if let Some(o) = pivot { @@ -1312,9 +1347,10 @@ fn reparent(s: &mut Session, p: &Value) -> Result { let index = num(p, "index").map(|i| i.max(0.0) as usize); let doc = s.doc_mut()?; let ids = z_sorted(doc, &roots(doc, &ids)); - // The layers land together under the first sibling at or above `index` that stays put (none: on - // top), wherever they came from. - let above = index.and_then(|at| doc.get(parent).ok()?.children.iter().skip(at).find(|c| !ids.contains(c)).copied()); + // `index` is where the first moved layer ends up among the parent's children (bottom first), + // counted after the move, the way `node.reorder` counts it. The layers land together, keeping + // their order, under the sibling that stays put and ends up just after them (none: on top). + let above = index.and_then(|at| doc.get(parent).ok()?.children.iter().filter(|c| !ids.contains(c)).nth(at).copied()); for i in &ids { // Counted with the layer still in place, as `reparent` expects. let at = above.and_then(|a| doc.node(parent)?.children.iter().position(|c| *c == a)); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index ec579d51..97419c60 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -63,10 +63,17 @@ pub fn specs() -> Vec { spec!( "file.saveAs", "Save as", - "{path} (no extension adds .figcraft); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview", + "{path | data: true (no path: returns the file as base64 `data` with its `name`, for a download)} (no extension adds .figcraft); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview", false, save_as ), + spec!( + "file.markSaved", + "Mark saved", + "{} clears the unsaved changes of the active tab's file and its branch tabs: run it once the file from `file.saveAs {data: true}` was delivered (a refused download keeps them)", + false, + mark_saved + ), spec!( "file.export", "Export", @@ -77,7 +84,7 @@ pub fn specs() -> Vec { spec!( "file.exportLayers", "Export layers", - "{ids? (default the selection, else every layer on the page with export settings), assets? (true: the Inspect tab's assets in and under them instead: layers with export settings, icons as SVG, images as PNG), dir? (folder to write into; else each file comes back as base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options): for every PDF instead of each setting's own} every layer once per export setting, named `.` (\"@2x\" when a scaled export has no suffix; repeats get \" 2\"; slashes in a layer name make folders). Returns {files: [{name, id, bytes, path | data}], skipped (layers without settings)}", + "{ids? (default the selection, else every layer on the page with export settings), assets? (true: the Inspect tab's assets in and under them instead: layers with export settings, icons as SVG, images as PNG), dir? (folder to write into; else each file comes back as base64 `data`), zip? (no dir: the files come back together as one base64 zip `data` instead), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options): for every PDF instead of each setting's own} every layer once per export setting, named `.` (\"@2x\" when a scaled export has no suffix; repeats get \" 2\"; slashes in a layer name make folders). Returns {files: [{name, id, bytes, path | data}], skipped (layers without settings), data? (zip)}", false, export_layers ), @@ -855,37 +862,59 @@ fn color_profile(s: &mut Session, p: &Value) -> Result { pub(crate) fn save_ends_preview(s: &Session, cmd: &str, p: &Value) -> bool { let path = string(p, "path").or_else(|| if cmd == "file.save" { s.docs.get(crate::branches::file_tab(s)).and_then(|ds| ds.path.clone()) } else { None }); - path.is_some_and(|path| ext(&path) != "fig") + path.is_some_and(|path| ext(&path) != "fig") || (cmd == "file.saveAs" && wants_data(p)) +} + +/// `file.saveAs` asked for the file's bytes instead of a path to write (the browser's download). +fn wants_data(p: &Value) -> bool { + p.get("data").and_then(Value::as_bool) == Some(true) } /// Save the file of the active tab (the main file for a branch tab, with its branches), or export /// the active document as an experimental `.fig` copy without changing its native save state. -fn save_to(s: &mut Session, path: String) -> Result { - if ext(&path) == "fig" { +/// Without a path the file comes back as base64 `data` for the caller to deliver (#969). +fn save_to(s: &mut Session, path: Option) -> Result { + if let Some(path) = path.as_deref().filter(|p| ext(p) == "fig") { return export_fig(s, &json!({"path": path})); } let i = crate::branches::file_tab(s); let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?; let mut snapshot = FileSnapshot::of(ds); let follows_path = ds.path.as_deref().map_or(ds.doc.name == "Untitled", |old| ds.doc.name == file_stem(old)); - if follows_path && ds.path.as_deref() != Some(path.as_str()) { - Arc::make_mut(&mut snapshot.doc).name = file_stem(&path); + if let Some(path) = path.as_deref() + && follows_path + && ds.path.as_deref() != Some(path) + { + Arc::make_mut(&mut snapshot.doc).name = file_stem(path); } let bytes = snapshot.encode()?; + let Some(path) = path else { + // The download can still be refused: the caller marks the file saved once it was delivered. + let name = format!("{}.figcraft", ds.doc.name); + return Ok(json!({"name": name, "bytes": bytes.len(), "data": base64::engine::general_purpose::STANDARD.encode(&bytes)})); + }; write(&path, &bytes)?; ds.doc = snapshot.doc; ds.path = Some(path.clone()); - ds.mark_saved(); - ds.revision += 1; - let uid = ds.uid; - branch_tabs_saved(s, uid); + mark_saved(s, &json!({}))?; remember(s, &path); Ok(json!({"path": path, "bytes": bytes.len()})) } +/// The file of the active tab (with its branch tabs) now matches what was written or delivered. +fn mark_saved(s: &mut Session, _: &Value) -> Result { + let i = crate::branches::file_tab(s); + let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?; + ds.mark_saved(); + ds.revision += 1; + let uid = ds.uid; + branch_tabs_saved(s, uid); + Ok(json!({"saved": true})) +} + fn save(s: &mut Session, p: &Value) -> Result { if let Some(path) = string(p, "path") { - return save_to(s, path); + return save_to(s, Some(path)); } match s.docs.get(crate::branches::file_tab(s)).ok_or(EngineError::NoDocument)?.path.clone() { // Earlier builds wrote native data under a .fig name: never overwrite that file with an export. @@ -893,7 +922,7 @@ fn save(s: &mut Session, p: &Value) -> Result { s.request_ui("dialog.saveAs", json!({})); Err(EngineError::Disabled("file.save".into(), "the saved path ends in .fig: choose Save as or pass `path`".into())) } - Some(path) => save_to(s, path), + Some(path) => save_to(s, Some(path)), None => { s.request_ui("dialog.saveAs", json!({})); Err(EngineError::Disabled("file.save".into(), "the file has no path yet: pass `path`".into())) @@ -907,8 +936,9 @@ fn save_as(s: &mut Session, p: &Value) -> Result { if ext(&path).is_empty() { path.push_str(".figcraft"); } - save_to(s, path) + save_to(s, Some(path)) } + None if wants_data(p) => save_to(s, None), None => { s.request_ui("dialog.saveAs", json!({})); Err(EngineError::Disabled("file.saveAs".into(), "pass `path`".into())) @@ -941,14 +971,37 @@ pub fn export_area(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> O .reduce(|a, b| a.union(b)) } -/// What an export of `ids` draws, bottom to top: the layers themselves, or (contents only off, -/// and always for slices) every top-level layer of their page, so whatever shows in the area is in. -fn drawn_layers(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> Vec { - let slice = ids.iter().any(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Slice))); - match ids.first().and_then(|i| doc.page_of(*i)).and_then(|p| doc.node(p)) { - Some(page) if slice || !setting.contents_only => page.children.clone(), - _ => super::edit::z_sorted(doc, ids), +/// What an export of `ids` draws, bottom to top: the layers themselves, or (contents only off) +/// every top-level layer of their page, so whatever shows in the area is in. A slice paints nothing +/// itself: with contents only on, a slice inside a frame or group draws that frame or group, and a +/// slice anywhere else draws every top-level layer of its page. +pub fn drawn_layers(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> Vec { + let Some(page) = ids.first().and_then(|i| doc.page_of(*i)).and_then(|p| doc.node(p)) else { + return super::edit::z_sorted(doc, ids); + }; + if !setting.contents_only { + return page.children.clone(); } + let mut own = Vec::with_capacity(ids.len()); + for &i in ids { + if !doc.node(i).is_some_and(|n| matches!(n.kind, NodeKind::Slice)) { + own.push(i); + continue; + } + match slice_container(doc, i) { + Some(c) => own.push(c), + None => return page.children.clone(), + } + } + own.sort_unstable(); + own.dedup(); + super::edit::z_sorted(doc, &super::edit::roots(doc, &own)) +} + +/// The frame or group a slice sits in: None on the page or in a section. +fn slice_container(doc: &Document, slice: NodeId) -> Option { + let parent = doc.node(slice)?.parent?; + doc.node(parent).is_some_and(|p| p.kind.is_frame_like() || matches!(p.kind, NodeKind::Group)).then_some(parent) } /// [`export_bytes`] with an export setting's format and options (its size is `scale`: see @@ -1136,6 +1189,20 @@ fn size_param(cmd: &str, p: &Value) -> Result> { /// Most files one `file.exportLayers` writes. const MAX_EXPORT_FILES: usize = 1000; +/// Most bytes the files of one zip export add up to: the files, the archive and its base64 copy +/// are all in memory at once (in the browser too). +const MAX_ZIP_BYTES: usize = 256 << 20; + +/// Keep `bytes` as zip member `name`, unless the members would pass `limit` bytes together. +fn add_member<'a>(members: &mut Vec<(&'a str, Vec)>, name: &'a str, bytes: Vec, limit: usize) -> Result<()> { + let total = members.iter().map(|(_, b)| b.len()).sum::().saturating_add(bytes.len()); + if total > limit { + let mb = limit >> 20; + return Err(bad("file.exportLayers", format!("the files would pass {mb} MB as one zip: export fewer layers at a time, or to a folder"))); + } + members.push((name, bytes)); + Ok(()) +} /// One file of a layer export: the layer, its setting and the file name. pub struct ExportJob { @@ -1207,6 +1274,9 @@ fn export_layers(s: &mut Session, p: &Value) -> Result { return Err(EngineError::Disabled(cmd.into(), "no export settings (add one with export.add)".into())); } let dir = string(p, "dir"); + // The browser saves one download at a time: several files go as one zip (#969). + let zip = dir.is_none() && boolean(p, "zip").unwrap_or(false); + let mut members = Vec::new(); // Print options passed here stand in for every PDF setting's own. let print = has_print_param(p).then(|| print_param(cmd, p, PrintSettings::default())).transpose()?; let mut files = Vec::with_capacity(jobs.len()); @@ -1226,12 +1296,18 @@ fn export_layers(s: &mut Session, p: &Value) -> Result { write(&path, &bytes)?; f["path"] = json!(path); } + None if zip => add_member(&mut members, job.file.as_str(), bytes, MAX_ZIP_BYTES)?, None => f["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&bytes)), } files.push(f); } let with_settings: std::collections::BTreeSet = jobs.iter().map(|j| j.id).collect(); - Ok(json!({"files": files, "skipped": ids.len().saturating_sub(with_settings.len())})) + let mut r = json!({"files": files, "skipped": ids.len().saturating_sub(with_settings.len())}); + if zip { + let archive = figcraft_format::zip_files(members.iter().map(|(n, b)| (*n, b.as_slice()))).map_err(|e| EngineError::Other(e.to_string()))?; + r["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&archive)); + } + Ok(r) } /// A PDF with one page per group of layers (an sRGB document), titled after the file; `area` @@ -1578,6 +1654,25 @@ mod rest_import_tests { assert_eq!(s.docs.len(), open, "a refused open adds nothing"); } + #[test] + fn save_as_data_hands_back_the_file_and_marks_it_saved_once_delivered() { + // The browser has no paths: Save as returns the file for a download (#969). + let mut s = Session::new(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + assert!(s.execute("file.saveAs", &json!({})).is_err(), "no path and no data: still asks for a path"); + let recent = s.prefs.recent_files.clone(); + let r = s.execute("file.saveAs", &json!({"data": true})).unwrap(); + assert_eq!(r["name"], "Untitled.figcraft"); + assert!(s.ds().unwrap().is_dirty(), "not delivered yet: a refused download must not lose the edits"); + s.execute("file.markSaved", &json!({})).unwrap(); + assert!(!s.ds().unwrap().is_dirty()); + assert_eq!(s.ds().unwrap().path, None, "still no path to save over"); + assert_eq!(s.prefs.recent_files, recent, "nothing on disk to remember"); + let mut t = Session::new(); + t.execute("file.open", &json!({"data": r["data"], "name": r["name"]})).unwrap(); + assert!(t.doc().unwrap().nodes().any(|n| matches!(n.kind, NodeKind::Rectangle)), "the bytes reopen"); + } + #[test] fn open_and_import_rest_json() { let dir = std::env::temp_dir().join(format!("figcraft-rest-{}", std::process::id())); @@ -1745,3 +1840,18 @@ mod staging_write_tests { let _ = std::fs::remove_dir_all(&d); } } + +#[cfg(test)] +mod zip_cap_tests { + use super::add_member; + + #[test] + fn zip_members_stop_at_the_limit_and_keep_what_fit() { + let mut members = Vec::new(); + add_member(&mut members, "a.png", vec![0; 6], 10).unwrap(); + add_member(&mut members, "b.png", vec![0; 4], 10).unwrap(); + let err = add_member(&mut members, "c.png", vec![0; 1], 10).unwrap_err().to_string(); + assert!(err.contains("as one zip"), "{err}"); + assert_eq!(members.iter().map(|(n, _)| *n).collect::>(), ["a.png", "b.png"]); + } +} diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index da1301ec..5a0a4000 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -19,6 +19,9 @@ pub fn specs() -> Vec { )] } +/// Same cap the layout pass applies to grid track counts. +const MAX_TRACKS: usize = 1000; + fn set_track(s: &mut Session, p: &Value) -> Result { const CMD: &str = "layout.setTrack"; let ids = req_targets(CMD, s, p)?; @@ -38,7 +41,7 @@ fn set_track(s: &mut Session, p: &Value) -> Result { let Some(l) = n.layout.as_mut().filter(|l| l.mode == LayoutMode::Grid) else { return Err(bad(CMD, format!("node {id} is not a grid auto layout frame"))); }; - let count = tracks_now.len().max(if columns { l.grid_columns } else { l.grid_rows } as usize); + let count = tracks_now.len().max(if columns { l.grid_columns } else { l.grid_rows } as usize).min(MAX_TRACKS); if !(0.0..count as f64).contains(&index) { return Err(bad(CMD, format!("`index` must be 0..{count}"))); } @@ -249,6 +252,17 @@ mod tests { assert!(s.execute("node.set", &json!({"id": f, "props": {"rowTracks": 5}})).is_err()); } + #[test] + fn set_track_clamps_hostile_grid_counts() { + let mut s = Session::new(); + let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 320, "height": 200, "props": {"layout": "grid"}}))); + // A crafted file can carry a count node.set would have clamped. + s.doc_mut().unwrap().get_mut(f).unwrap().layout.as_mut().unwrap().grid_columns = 5_000_000; + let p = json!({"ids": [f], "axis": "columns", "index": 4_999_999, "sizing": "hug"}); + assert!(s.execute("layout.setTrack", &p).is_err()); + assert!(node(&s, f).layout.unwrap().column_tracks.len() <= 1000); + } + #[test] fn layout_props_survive_hostile_values() { let junk = [json!(null), json!("NaN"), json!(-1e308), json!(1e308), json!([]), json!({}), json!(true), json!("-5fr")]; diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 60d08963..c7b2c5b3 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -99,6 +99,7 @@ fn registry() -> &'static Registry { list.extend(crate::code_connect::specs()); list.extend(crate::annotations::specs()); list.extend(crate::dev_resources::specs()); + list.extend(crate::dev_measurements::specs()); list.extend(crate::distance::specs()); list.extend(crate::collab::specs()); list.extend(crate::branches::specs()); @@ -109,6 +110,8 @@ fn registry() -> &'static Registry { list.extend(crate::online::specs()); list.extend(crate::invite_links::specs()); list.extend(crate::templates::specs()); + list.extend(crate::datafill::specs()); + list.extend(crate::textfit::specs()); list.extend(crate::board::specs()); let mut index = HashMap::new(); for (i, s) in list.iter().enumerate() { diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 2eedab45..9ee499fc 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -26,7 +26,9 @@ fontSize, lineHeight (auto|px|\"150%\"), letterSpacing (px|\"2%\"), textAlign (l textDecorationColor (colour, or auto/null: the text's), textCase (original|upper|lower|title|smallCaps), paragraphSpacing, paragraphIndent, \ listStyle (none|bulleted|numbered; with start/end: the paragraphs those chars touch), indentation (list nesting level 0-8, same range rule), \ listSpacing (px between list items, in place of paragraphSpacing), hangingPunctuation, hangingList (markers outside the box), leadingTrim (vertical trim: none|capHeight), maxLines, features ({tag: bool}, e.g. {\"liga\": false, \"tnum\": true}), \ -fontVariations / variationSettings ({axis: number}, e.g. {\"wght\": 550, \"wdth\": 85}; null = none), pointCount, innerRadius, arcStart, arcSweep, \ +fontVariations / variationSettings ({axis: number}, e.g. {\"wght\": 550, \"wdth\": 85}; null = none), \ +path (a vector's SVG path data: replaces its geometry, keeping the layer, its paints, styles and bindings; width/height in the same call scale it as \ +node.create does; its own box goes at the layer's x/y, or with x/y given, at x + its left edge and y + its top as node.create places it), pointCount, innerRadius, arcStart, arcSweep, \ arcInner, booleanOperation (a boolean group's operation, or an operand's own; null: the group's), operandOperation (a layer in a boolean \ group: how it combines with the operands below it; null: the group's), constrainProportions, description, interactions, overflow, pluginData {k: v}"; @@ -142,7 +144,9 @@ pub(crate) fn sizing(v: &Value) -> Option { pub(crate) fn line_height(v: &Value) -> Option { match v { Value::String(s) if s.eq_ignore_ascii_case("auto") => Some(LineHeight::Auto), - Value::String(s) if s.trim().ends_with('%') => s.trim().trim_end_matches('%').trim().parse().ok().map(LineHeight::Percent), + Value::String(s) if s.trim().ends_with('%') => { + s.trim().trim_end_matches('%').trim().parse().ok().filter(|f: &f64| f.is_finite()).map(LineHeight::Percent) + } Value::Object(_) => serde_json::from_value(v.clone()).ok(), other => f64_of(other).map(LineHeight::Pixels), } @@ -150,7 +154,9 @@ pub(crate) fn line_height(v: &Value) -> Option { pub(crate) fn letter_spacing(v: &Value) -> Option { match v { - Value::String(s) if s.trim().ends_with('%') => s.trim().trim_end_matches('%').trim().parse().ok().map(LetterSpacing::Percent), + Value::String(s) if s.trim().ends_with('%') => { + s.trim().trim_end_matches('%').trim().parse().ok().filter(|f: &f64| f.is_finite()).map(LetterSpacing::Percent) + } Value::Object(_) => serde_json::from_value(v.clone()).ok(), other => f64_of(other).map(LetterSpacing::Pixels), } @@ -382,7 +388,7 @@ fn paint_none(v: &Value) -> bool { /// paints set in the same call don't detach them. fn apply_order(k: &str) -> u8 { match k { - "width" | "height" | "w" | "h" => 0, + "path" | "width" | "height" | "w" | "h" => 0, "rotation" => 2, "x" | "y" => 3, "fillStyle" | "strokeStyle" | "effectStyle" | "textStyle" | "gridStyle" => 4, @@ -391,16 +397,35 @@ fn apply_order(k: &str) -> u8 { } } +/// An explicit width (`is_w`) or height makes hugging or filling on that axis fixed, like typing +/// into W or H. +fn fix_axis(doc: &mut Document, id: NodeId, is_w: bool) -> Result<()> { + let n = doc.get_mut(id)?; + if is_w { + n.child_layout.sizing_h = Sizing::Fixed; + } else { + n.child_layout.sizing_v = Sizing::Fixed; + } + Ok(()) +} + /// Apply a property map to one node. `x`/`y` are relative to the parent. pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map) -> Result<()> { let mut unknown = Vec::new(); let mut keys: Vec<(&String, &Value)> = props.iter().collect(); keys.sort_by_key(|(k, _)| apply_order(k)); + // Where a new `path` had its own box, for `x`/`y` in the same call (as `node.create` places it). + let mut path_offset = (0.0, 0.0); for (k, v) in keys { let k = k.as_str(); match k { + "path" => path_offset = set_path(cmd, doc, id, v, props)?, + // A new path takes the size asked for as its scale (`set_path`); the size still fixes + // its auto layout axis. + "width" | "height" | "w" | "h" if props.contains_key("path") => fix_axis(doc, id, k.starts_with('w'))?, "x" | "y" => { let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?; + let val = val + if k == "x" { path_offset.0 } else { path_offset.1 }; // X/Y are the node's own origin (its rotated top-left for rotated nodes), in the // frame it is in (groups have no space of their own). let n = doc.get(id)?; @@ -442,13 +467,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map { let deg = f64_of(v).ok_or_else(|| bad(cmd, "`rotation` must be degrees"))?; @@ -939,6 +958,33 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map) -> Result<(f64, f64)> { + let d = v.as_str().ok_or_else(|| bad(cmd, "`path` must be SVG path data (a string)"))?; + let n = doc.get(id)?; + if !matches!(n.kind, NodeKind::Vector { .. }) { + return Err(bad(cmd, format!("`path` applies to vector layers, and layer {} “{}” is a {}", n.id, n.name, n.kind.type_name()))); + } + if crate::components::inside_instance(doc, id) { + return Err(bad(cmd, format!("`path`: layer {} “{}” is inside an instance: edit its main component, or detach the instance", n.id, n.name))); + } + let size = |long: &str, short: &str| -> Result> { + props.get(long).or_else(|| props.get(short)).map(|v| f64_of(v).ok_or_else(|| bad(cmd, format!("`{long}` must be a number")))).transpose() + }; + let shape = super::create::path_network(cmd, d, size("width", "w")?, size("height", "h")?)?; + let n = doc.get_mut(id)?; + if let NodeKind::Vector { network } = &mut n.kind { + *network = shape.network; + } + n.width = shape.width; + n.height = shape.height; + Ok(shape.offset) +} + /// The override group a property key belongs to. pub fn override_group(k: &str) -> &'static str { match k { @@ -1235,3 +1281,17 @@ pub fn describe(doc: &Document, id: NodeId) -> Value { let _ = Color::BLACK; v } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn non_finite_percent_strings_are_rejected() { + for s in ["NaN%", "inf%", "-inf%"] { + assert!(line_height(&json!(s)).is_none(), "line_height accepted {s}"); + assert!(letter_spacing(&json!(s)).is_none(), "letter_spacing accepted {s}"); + } + assert!(matches!(line_height(&json!("150%")), Some(LineHeight::Percent(p)) if p == 150.0)); + } +} diff --git a/crates/engine/src/cmd/proto.rs b/crates/engine/src/cmd/proto.rs index be6907b5..23e24aac 100644 --- a/crates/engine/src/cmd/proto.rs +++ b/crates/engine/src/cmd/proto.rs @@ -155,6 +155,17 @@ pub fn specs() -> Vec { ok() } ), + spec!( + "proto.linkNotice", + "Notice before leaving for a link", + "{show? (default true; false skips it)} whether a presented prototype's Open link to a website first says that it leaves the prototype, with Open link and Cancel; kept with the preferences → {shown}", + false, + |s, p| { + let show = boolean(p, "show").unwrap_or(true); + s.prefs.link_notice_skipped = !show; + Ok(json!({"shown": show})) + } + ), ] } diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index a2f76115..813a6036 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -126,7 +126,9 @@ fn step_weight(s: &mut Session, p: &Value, up: bool) -> Result { } /// Put a text layer on a path: the text takes the path layer's place, geometry and transform, and -/// the path layer is removed. +/// the path layer is removed. The path's effects move to the text when it has any, and its fills +/// when one of them shows, each with its style and variable bindings; otherwise the text keeps +/// its own. fn on_path(s: &mut Session, p: &Value) -> Result { const CMD: &str = "text.onPath"; let doc = s.doc()?; @@ -144,6 +146,14 @@ fn on_path(s: &mut Session, p: &Value) -> Result { } let (parent, index) = (pn.parent.ok_or_else(|| bad(CMD, "pages have no path"))?, doc.index_in_parent(path)); let (transform, w, h) = (pn.transform, pn.width, pn.height); + let fills = pn.fills.iter().any(|f| f.visible).then(|| (pn.fills.clone(), pn.styles.fill.clone())); + let effects = (!pn.effects.is_empty()).then(|| (pn.effects.clone(), pn.styles.effect.clone())); + let moved = |k: &str| match super::lists::list_binding(k) { + Some((super::lists::BoundList::Fills, ..)) => fills.is_some(), + Some((super::lists::BoundList::Effects, ..)) => effects.is_some(), + _ => false, + }; + let bound: Vec<(String, VariableId)> = pn.bound.iter().filter(|(k, _)| moved(k)).map(|(k, v)| (k.clone(), v.clone())).collect(); let mut tp = TextPath::new(&geom); tp.start = num(p, "start").filter(|v| v.is_finite()).unwrap_or(0.0).clamp(0.0, 1.0); tp.flip = boolean(p, "flip").unwrap_or(false); @@ -153,6 +163,14 @@ fn on_path(s: &mut Session, p: &Value) -> Result { let n = doc.get_mut(text)?; n.transform = transform; (n.width, n.height) = (w, h); + n.bound.retain(|k, _| !moved(k)); + n.bound.extend(bound); + if let Some((fills, style)) = fills { + (n.fills, n.styles.fill) = (fills, style); + } + if let Some((effects, style)) = effects { + (n.effects, n.styles.effect) = (effects, style); + } if let NodeKind::Text(t) = &mut n.kind { t.path = Some(Box::new(tp)); } @@ -165,7 +183,7 @@ pub fn specs() -> Vec { spec!( "text.onPath", "Text on path", - "{text? (text layer), path? (vector or shape layer; default: the selected text and other layer), start? (0..1 along the path), flip?} the text follows the path and the path layer is removed → {id}", + "{text? (text layer), path? (vector or shape layer; default: the selected text and other layer), start? (0..1 along the path), flip?} the text follows the path and takes its effects and its fills when one shows, and the path layer is removed → {id}", true, on_path ), diff --git a/crates/engine/src/cmd/variables_io.rs b/crates/engine/src/cmd/variables_io.rs index f3b9a820..0f55ea2c 100644 --- a/crates/engine/src/cmd/variables_io.rs +++ b/crates/engine/src/cmd/variables_io.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { spec!( "variable.export", "Export variables", - "{collections? [ids or names; default every collection of this file, not library copies], format? (json (default): the local variables JSON that variable.import reads | css: custom properties, the default mode on :root and each other mode on [data-=\"\"]), path? (write it there)} → {text}, or {path} when written", + "{collections? [ids or names; default every collection of this file, not library copies], format? (json (default): the local variables JSON that variable.import reads | css: custom properties, the default mode on :root and each other mode on [data-=\"\"]; numbers in px unless layers or scopes use them as opacity or font weight), path? (write it there)} → {text}, or {path} when written", false, export ), @@ -46,7 +46,7 @@ fn export(s: &mut Session, p: &Value) -> Result { let text = match string(p, "format").as_deref().unwrap_or("json") { "json" => serde_json::to_string_pretty(&figcraft_format::rest::export_variables(&doc.variables, &colls)) .map_err(|e| EngineError::Other(e.to_string()))?, - "css" => css(&doc.variables, &colls), + "css" => css(doc, &colls), _ => return Err(bad(cmd, "format: json|css")), }; match string(p, "path") { @@ -66,7 +66,11 @@ fn css_safe(text: &str) -> String { /// Each collection's variables as custom properties (named as generated code names them): the /// default mode's values on `:root`, every other mode's on `[data-=""]`; /// aliases are `var(--name)`. -fn css(vars: &Variables, colls: &[&Collection]) -> String { +fn css(doc: &Document, colls: &[&Collection]) -> String { + let vars = &doc.variables; + // Lengths need a unit where CSS reads them (`border-radius: var(--radius)`); opacity and + // font weight don't. + let roles = crate::token_roles::Roles::new(doc); let mut out = String::new(); for c in colls { let attr = css_var_name(&c.name); @@ -76,7 +80,8 @@ fn css(vars: &Variables, colls: &[&Collection]) -> String { for v in vars.variables.iter().filter(|v| v.collection == c.id) { let value = match v.values.get(&m.id) { Some(VariableValue::Color(col)) => col.to_css(), - Some(VariableValue::Number(x)) => trim_num(*x), + Some(VariableValue::Number(x)) if roles.role(v) == crate::token_roles::NumberRole::Unitless => trim_num(*x), + Some(VariableValue::Number(x)) => format!("{}px", trim_num(*x)), Some(VariableValue::String(t)) => format!("\"{}\"", css_safe(t)), Some(VariableValue::Boolean(b)) => b.to_string(), Some(VariableValue::Alias(a)) => match vars.get(a) { diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 17f71b49..a81a222c 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -535,6 +535,9 @@ pub fn specs() -> Vec { spec!("tool.pen", "Pen", "{}", false, |s, _| tool(s, ToolId::Pen)), spec!("tool.pencil", "Pencil", "{}", false, |s, _| tool(s, ToolId::Pencil)), spec!("tool.text", "Text", "{}", false, |s, _| tool(s, ToolId::Text)), + spec!("tool.textOnPath", "Text on a path", "{} a click on a shape's or vector's path puts new text on it", false, |s, _| { + tool(s, ToolId::TextOnPath) + }), spec!("tool.comment", "Comment", "{}", false, |s, _| tool(s, ToolId::Comment)), spec!("tool.shapeBuilder", "Shape builder", "{}", false, |s, _| tool(s, ToolId::ShapeBuilder)), // `apply: false` is for the UI colour picker: the eyedropper only samples (into diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs index 7ed81b30..0a4a5d2e 100644 --- a/crates/engine/src/codegen.rs +++ b/crates/engine/src/codegen.rs @@ -109,7 +109,12 @@ pub(crate) fn num(v: f64) -> String { return "0".into(); } let r = (v * 100.0).round() / 100.0; - if (r - r.round()).abs() < 1e-9 { format!("{}", r.round() as i64) } else { format!("{r}") } + if (r - r.round()).abs() < 1e-9 { + // `as i64` saturates past 2^63, so larger whole values are printed from the float. + if r.abs() < 9e18 { format!("{}", r.round() as i64) } else { format!("{:.0}", r.round()) } + } else { + format!("{r}") + } } /// `code` with every pixel length (`12px`) in rem of `root` pixels (`0.75rem`); names that end @@ -1142,6 +1147,14 @@ mod tests { use crate::params::id_of; use serde_json::json; + #[test] + fn num_keeps_huge_whole_numbers() { + assert_eq!(num(1e20), "100000000000000000000"); + assert_eq!(num(-1e20), "-100000000000000000000"); + assert_eq!(num(1e18), "1000000000000000000"); + assert_eq!(num(-0.0), "0"); + } + fn card(s: &mut Session) -> NodeId { let f = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})).unwrap()["id"]).unwrap(); s.execute("node.set", &json!({"id": f, "props": {"fills": ["#FFFFFF"], "cornerRadius": 12}})).unwrap(); diff --git a/crates/engine/src/collab_live_tests.rs b/crates/engine/src/collab_live_tests.rs index 5b759391..d53eeff2 100644 --- a/crates/engine/src/collab_live_tests.rs +++ b/crates/engine/src/collab_live_tests.rs @@ -246,9 +246,9 @@ fn a_group_of_commands_syncs_as_one_step() { let (f, r) = frame_and_rect(room.server.document()); let start = room.server.replica().clone(); let cmds = vec![ - ("node.set".to_string(), json!({"id": r, "props": {"name": "A"}})), - ("node.set".to_string(), json!({"id": f, "props": {"name": "B"}})), - ("shape.rectangle".to_string(), json!({"x": 0, "y": 0, "width": 5, "height": 5})), + crate::BatchStep::new("node.set", json!({"id": r, "props": {"name": "A"}})), + crate::BatchStep::new("node.set", json!({"id": f, "props": {"name": "B"}})), + crate::BatchStep::new("shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5})), ]; room.s(0).batch("Three", &cmds).unwrap(); room.pump(); diff --git a/crates/engine/src/component_codegen.rs b/crates/engine/src/component_codegen.rs index a8217be1..11f5e7da 100644 --- a/crates/engine/src/component_codegen.rs +++ b/crates/engine/src/component_codegen.rs @@ -225,7 +225,9 @@ pub fn type_name(name: &str) -> String { /// Words no generated parameter may be called (keywords of TypeScript, Swift and Kotlin, and /// names the generated code uses). const RESERVED: &[&str] = &[ + "arguments", "as", + "await", "body", "break", "case", @@ -239,6 +241,7 @@ const RESERVED: &[&str] = &[ "do", "else", "enum", + "eval", "export", "extends", "extension", @@ -249,6 +252,7 @@ const RESERVED: &[&str] = &[ "function", "guard", "if", + "implements", "import", "in", "init", @@ -264,6 +268,7 @@ const RESERVED: &[&str] = &[ "operator", "package", "private", + "protected", "protocol", "public", "ref", diff --git a/crates/engine/src/component_codegen_tests.rs b/crates/engine/src/component_codegen_tests.rs index 4cd3d776..54ca80c1 100644 --- a/crates/engine/src/component_codegen_tests.rs +++ b/crates/engine/src/component_codegen_tests.rs @@ -43,6 +43,18 @@ fn button_set(s: &mut Session) -> (NodeId, NodeId, NodeId) { (set, variants[0], variants[1]) } +#[test] +fn strict_mode_reserved_words_are_not_parameter_names() { + let mut s = Session::new(); + let (c, title, _) = card(&mut s); + ex(&mut s, "component.addProperty", json!({"id": c, "name": "Protected", "type": "text", "layer": title})); + let react = code(&mut s, c, "react"); + assert!(react.contains("protectedValue"), "React renames the property:\n{react}"); + for bad in [" protected?", " protected =", "{protected}"] { + assert!(!react.contains(bad), "reserved word used as a binding ({bad:?}):\n{react}"); + } +} + #[test] fn components_take_their_properties_as_parameters() { let mut s = Session::new(); diff --git a/crates/engine/src/components.rs b/crates/engine/src/components.rs index 91402b58..c85c6b8b 100644 --- a/crates/engine/src/components.rs +++ b/crates/engine/src/components.rs @@ -302,6 +302,8 @@ pub fn create_instance_at(doc: &mut Document, component: NodeId, parent: NodeId, root.code_links.clear(); // The main's dev resources show on its instances without being copied. root.dev_resources.clear(); + // Measurements belong to the layer they were placed on. + root.measurements.clear(); // The main's interactions play on its instances in prototypes; the instance's own start empty. root.interactions.clear(); // So do the variable modes the main sets (see `Document::inherited_modes`). @@ -720,6 +722,7 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT let prev = old.get(&nid); // Annotations belong to the layer they were added to, not to its copies. n.annotations = prev.map(|o| o.annotations.clone()).unwrap_or_default(); + n.measurements = prev.map(|o| o.measurements.clone()).unwrap_or_default(); if let Some(o) = prev { carry_overrides(o, &mut n, Carry::Values); keep_laid_out(doc, orig, o, &mut n); @@ -797,6 +800,7 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT fresh.code_links.clear(); // An instance's own dev resources (its main's are listed with them, not copied). fresh.dev_resources = root.dev_resources.clone(); + fresh.measurements = root.measurements.clone(); fresh.locked = root.locked; fresh.visible = root.visible; fresh.var_modes = root.var_modes.clone(); @@ -1223,9 +1227,73 @@ fn geometry_derived(doc: &Document, before: &Document, id: NodeId, group: &str) false } +/// The components sharing the properties of component `c`: the variants of its set, or `c` alone. +pub fn property_owners(doc: &Document, c: NodeId) -> Vec { + match variant_set(doc, c).and_then(|s| doc.node(s)) { + Some(set) => set.children.iter().copied().filter(|v| doc.node(*v).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))).collect(), + None => vec![c], + } +} + +/// Make `value` the default of boolean property `name` on components `comps`, showing or hiding +/// their layers bound to it: on a main component, a boolean property's value is the visibility of +/// its layers. Returns the components and layers it changed. +pub fn set_boolean_default(doc: &mut Document, comps: &[NodeId], name: &str, value: bool) -> Vec { + let mut out = Vec::new(); + for &c in comps { + let Some(NodeKind::Component { props, .. }) = doc.node(c).map(|n| &n.kind) else { continue }; + let Some(PropDef::Boolean { default }) = props.get(name) else { continue }; + if *default != value + && let Some(NodeKind::Component { props, .. }) = doc.node_mut(c).map(|n| &mut n.kind) + { + props.insert(name.to_string(), PropDef::Boolean { default: value }); + out.push(c); + } + // The main's own layers (not those of instances nested in it, which follow their mains). + let layers: Vec = doc + .subtree(c) + .into_iter() + .filter(|d| *d != c && !inside_instance(doc, *d)) + .filter(|d| doc.node(*d).is_some_and(|n| n.visible != value && n.prop_refs.get("visible").map(String::as_str) == Some(name))) + .collect(); + for d in layers { + if let Some(n) = doc.node_mut(d) { + n.visible = value; + out.push(d); + } + } + } + out +} + +/// A main component's layer bound to a boolean property was shown or hidden: that is the +/// property's new default, which its other bound layers (in every variant) take too. Returns the +/// components and layers that changed as a result. +fn follow_boolean_defaults(doc: &mut Document, before: &Document, changed: &[NodeId]) -> Vec { + let mut out = Vec::new(); + for &id in changed { + let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue }; + if n.visible == b.visible || inside_instance(doc, id) { + continue; + } + let Some(name) = n.prop_refs.get("visible").cloned() else { continue }; + let visible = n.visible; + let Some(c) = owning_component(doc, id).filter(|c| *c != id) else { continue }; + out.extend(set_boolean_default(doc, &property_owners(doc, c), &name, visible)); + } + out +} + /// After an edit: record overrides inside instances, then rebuild instances whose main changed, /// whose properties or main were switched, or whose overrides were reset. pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId], touched: &BTreeSet) { + let mut changed = changed.to_vec(); + for id in follow_boolean_defaults(doc, before, &changed) { + if !changed.contains(&id) { + changed.push(id); + } + } + let changed = changed.as_slice(); // 0. A slot whose layers were added, removed or reordered holds the instance's own content // from now on; copies of instance layers placed elsewhere stop mirroring their main. for &id in changed { @@ -1591,6 +1659,7 @@ fn copy_layers(doc: &mut Document, src: NodeId, parent: NodeId) -> Option Option { + match &node(s, c).kind { + NodeKind::Component { props, .. } => match props.get(name) { + Some(PropDef::Boolean { default }) => Some(*default), + _ => None, + }, + _ => None, + } +} + +#[test] +fn boolean_property_default_is_the_mains_visibility() { + let mut s = Session::new(); + let (c, bg, title, dot) = card(&mut s); + // #993: a `false` default hides the bound layer in the main, not only in instances. + ex(&mut s, "component.addProperty", json!({"id": c, "name": "Show dot", "type": "boolean", "default": false, "layer": dot})); + assert!(!node(&s, dot).visible); + let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0}))); + assert!(!node(&s, copy_of(&s, i, dot)).visible); + // Without a default, the property takes the layer's visibility. + ex(&mut s, "node.set", json!({"id": bg, "props": {"visible": false}})); + ex(&mut s, "component.addProperty", json!({"id": c, "name": "Show bg", "type": "boolean", "layer": bg})); + assert_eq!(bool_default(&s, c, "Show bg"), Some(false)); + assert!(!node(&s, bg).visible); + // Binding a layer to an existing property shows it as the default says. + ex(&mut s, "component.bindProperty", json!({"layer": title, "property": "Show dot"})); + assert!(!node(&s, title).visible); + // A new default shows or hides every bound layer of the main; instances keep their values. + ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Show dot": false}})); + ex(&mut s, "component.editProperty", json!({"id": c, "name": "Show dot", "default": true})); + assert!(node(&s, dot).visible && node(&s, title).visible); + assert!(!node(&s, copy_of(&s, i, dot)).visible, "the instance's own value stays"); + // Renamed with a new default in one go. + ex(&mut s, "component.editProperty", json!({"id": c, "name": "Show dot", "newName": "Dot", "default": false})); + assert!(!node(&s, dot).visible && !node(&s, title).visible); + assert_eq!(bool_default(&s, c, "Dot"), Some(false)); + // Showing a bound layer on the main is the property's new default, for its other layers too. + ex(&mut s, "node.set", json!({"id": dot, "props": {"visible": true}})); + assert_eq!(bool_default(&s, c, "Dot"), Some(true)); + assert!(node(&s, title).visible); + let j = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 600, "y": 0}))); + assert!(node(&s, copy_of(&s, j, dot)).visible && node(&s, copy_of(&s, j, title)).visible); + assert!(!node(&s, copy_of(&s, i, dot)).visible, "existing instances keep their values"); + // Hiding a layer inside an instance is an override there, not a new default. + let jdot = copy_of(&s, j, dot); + ex(&mut s, "node.set", json!({"id": jdot, "props": {"visible": false}})); + assert_eq!(bool_default(&s, c, "Dot"), Some(true)); + assert!(node(&s, dot).visible); + // Undo puts the default and the layers back together. + s.undo().unwrap(); + s.undo().unwrap(); + s.undo().unwrap(); + assert_eq!(bool_default(&s, c, "Dot"), Some(false)); + assert!(!node(&s, dot).visible && !node(&s, title).visible); + healthy(&s); +} + +#[test] +fn boolean_property_default_follows_across_variants() { + let mut s = Session::new(); + let mut variants = Vec::new(); + let mut labels = Vec::new(); + for (k, state) in ["Default", "Hover"].iter().enumerate() { + let f = + idv(&ex(&mut s, "frame.create", json!({"x": k as f64 * 150.0, "y": 0, "width": 100, "height": 40, "name": format!("State={state}")}))); + labels.push(idv(&ex(&mut s, "text.create", json!({"text": *state, "x": 10, "y": 10, "parent": f, "props": {"name": "Label"}})))); + variants.push(idv(&ex(&mut s, "component.create", json!({"ids": [f]})))); + } + let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": variants.clone()}))); + ex(&mut s, "component.addProperty", json!({"id": set, "name": "Show label", "type": "boolean", "default": false, "layer": labels[0]})); + ex(&mut s, "component.bindProperty", json!({"layer": labels[1], "property": "Show label"})); + assert!(labels.iter().all(|l| !node(&s, *l).visible)); + ex(&mut s, "component.editProperty", json!({"id": set, "name": "Show label", "default": true})); + assert!(labels.iter().all(|l| node(&s, *l).visible)); + // Hiding the label in one variant is the set's default: the other variant's label follows. + ex(&mut s, "node.set", json!({"id": labels[1], "props": {"visible": false}})); + assert!(variants.iter().all(|v| bool_default(&s, *v, "Show label") == Some(false))); + assert!(!node(&s, labels[0]).visible); + let i = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 200}))); + assert!(!node(&s, kids(&s, i)[0]).visible); + healthy(&s); +} + #[test] fn push_changes_to_main() { let mut s = Session::new(); diff --git a/crates/engine/src/cursor_tests.rs b/crates/engine/src/cursor_tests.rs index 7a9e0621..6ffb33e4 100644 --- a/crates/engine/src/cursor_tests.rs +++ b/crates/engine/src/cursor_tests.rs @@ -70,6 +70,7 @@ fn scene() -> (Session, NodeId, NodeId) { const EMPTY: (f64, f64) = (700.0, 600.0); const ON_SQUARE: (f64, f64) = (150.0, 150.0); +const ON_SQUARE_EDGE: (f64, f64) = (101.0, 150.0); const ON_TEXT: (f64, f64) = (420.0, 120.0); #[test] @@ -101,6 +102,9 @@ fn each_tool_has_its_cursor_by_modifier_and_what_is_under_the_pointer() { (ToolId::Text, NONE, EMPTY, Crosshair, "the Text tool makes a text where it is pressed"), (ToolId::Text, NONE, ON_SQUARE, Crosshair, "over a shape too"), (ToolId::Text, NONE, ON_TEXT, Text, "the I-beam over a text: a click edits it there"), + (ToolId::TextOnPath, NONE, EMPTY, Default, "text on a path: a click on empty canvas does nothing"), + (ToolId::TextOnPath, NONE, ON_SQUARE, Default, "nor inside a shape, away from its path"), + (ToolId::TextOnPath, NONE, ON_SQUARE_EDGE, Text, "the I-beam over a shape's path: a click puts text on it"), (ToolId::Pen, NONE, EMPTY, Pen(PenCursor::New), "the pen: a new point"), (ToolId::Comment, NONE, EMPTY, Comment, "the comment pin"), (ToolId::Eyedropper, NONE, ON_SQUARE, Eyedropper, "the eyedropper"), diff --git a/crates/engine/src/datafill.rs b/crates/engine/src/datafill.rs new file mode 100644 index 00000000..b93e5f59 --- /dev/null +++ b/crates/engine/src/datafill.rs @@ -0,0 +1,219 @@ +//! `data.fill`: one frame repeated per row of data (a carousel, a set of posts, cards from a +//! list): each copy gets its row's texts, colours and visibility on the layers named by the row's +//! keys, in one undo step, and optionally each copy exported as a file. Agents make a series with +//! one call instead of a duplicate, a lookup and an edit per layer and an export per copy. + +use figcraft_doc::NodeId; +use serde_json::{Value, json}; + +use crate::cmd::CommandSpec; +use crate::params::{get, id, id_of, num_or, string}; +use crate::{EngineError, Result, Session, bad, spec}; + +const CMD: &str = "data.fill"; +const MAX_ROWS: usize = 500; + +pub fn specs() -> Vec { + vec![spec!( + "data.fill", + "Fill copies with data", + "{id (the frame to repeat), rows: [{: text | {text?, fill? (#rrggbb), visible?, fit? (true or text.fit's {maxLines?, minSize?, orphans?}: size the text to its box)}, name? (the copy's name)}] (every layer with that name in the copy; up to 500 rows), gap? (between copies, placed in a row to the right; default 100), formats? ([{name?, width, height}]: each row once per size, the copy resized so its auto layout reflows and named -; e.g. feed 1080×1350, story 1080×1920, square 1080×1080), dir? (export each copy there as .), format? (png|jpg|webp|svg|pdf; default png), scale? (default 1)} one undo step → {ids (the copies, in row order), missing (keys no layer is named), files? (paths written, one per copy: same file names get a numbered suffix), errors? ([{id, path, error}] for exports that failed; the copies stay)}", + false, + fill + )] +} + +/// A file name from a layer name: letters, digits, spaces, `-` and `_` kept, the rest `-`. +fn file_name(name: &str) -> String { + let s: String = name.chars().take(80).map(|c| if c.is_alphanumeric() || matches!(c, ' ' | '-' | '_') { c } else { '-' }).collect(); + let s = s.trim().to_string(); + if s.is_empty() { "copy".into() } else { s } +} + +fn fill(s: &mut Session, p: &Value) -> Result { + // Refused before any copy or file is made: a rollback afterwards wouldn't take files back. + if let Some(why) = s.live_read_only(s.ds()?.uid) { + return Err(EngineError::Disabled(CMD.into(), why.into())); + } + let src = id(p, "id").ok_or_else(|| bad(CMD, "`id` (the frame to repeat) required"))?; + let rows = match get(p, "rows") { + Some(Value::Array(a)) if a.iter().all(Value::is_object) && a.len() <= MAX_ROWS => a.clone(), + _ => return Err(bad(CMD, "rows: an array of objects (up to 500)")), + }; + let gap = num_or(p, "gap", 100.0); + if !gap.is_finite() { + return Err(bad(CMD, "gap: a number")); + } + let format = string(p, "format").unwrap_or_else(|| "png".into()).to_ascii_lowercase(); + if !["png", "jpg", "webp", "svg", "pdf"].contains(&format.as_str()) { + return Err(bad(CMD, "format: png|jpg|webp|svg|pdf")); + } + let (width, base) = { + let doc = s.doc()?; + let n = doc.node(src).ok_or_else(|| bad(CMD, "no such layer"))?; + (n.width, n.name.clone()) + }; + let formats = formats(p)?; + let column = formats.iter().flatten().map(|f| f.1).fold(width, f64::max); + let grid = Grid { src, base, column, gap }; + let mut missing: Vec = Vec::new(); + let ids = s.grouped("Fill copies with data", |s| { + let mut ids = Vec::with_capacity(rows.len() * formats.len()); + for (i, row) in rows.iter().enumerate() { + let mut dy = 0.0; + for fmt in &formats { + if let Some(made) = copy_row(s, &grid, (i, row), fmt.as_ref(), &mut dy, &mut missing)? { + ids.push(made); + } + } + } + Ok(ids) + })?; + let mut out = json!({"ids": ids.iter().map(|(i, _)| i.0).collect::>(), "missing": missing}); + // Exports after the edits landed: a failed edit writes no files. A failed export keeps the + // copies (one undo step removes them) and is reported with the files that were written. + if let Some(dir) = string(p, "dir") { + export_copies(s, &ids, &dir, (&format, num_or(p, "scale", 1.0)), &mut out); + } + Ok(out) +} + +/// A size the copies are made in: its name, width and height. +type Format = (String, f64, f64); + +/// `formats`: each row once per size, the copy resized so its auto layout reflows (`None`: the +/// frame's own size). +fn formats(p: &Value) -> Result>> { + match get(p, "formats") { + None | Some(Value::Null) => Ok(vec![None]), + Some(Value::Array(a)) if !a.is_empty() && a.len() <= 12 => a.iter().map(format_of).collect(), + Some(_) => Err(bad(CMD, "formats: [{name?, width, height}] (1 to 12 sizes)")), + } +} + +/// One `formats` entry, named `x` when it has no name. +fn format_of(f: &Value) -> Result> { + let (w, h) = (f.get("width").and_then(Value::as_f64), f.get("height").and_then(Value::as_f64)); + let name = f.get("name").and_then(Value::as_str).unwrap_or_default().trim().to_string(); + match (w, h) { + (Some(w), Some(h)) if w.is_finite() && h.is_finite() && (1.0..=20_000.0).contains(&w) && (1.0..=20_000.0).contains(&h) => { + Ok(Some((if name.is_empty() { format!("{}x{}", w as i64, h as i64) } else { name }, w, h))) + } + _ => Err(bad(CMD, "formats: [{name?, width, height}] with sizes from 1 to 20000")), + } +} + +/// Where the copies go: the frame repeated, its name, the column width and the gap. +struct Grid { + src: NodeId, + base: String, + column: f64, + gap: f64, +} + +/// Row `i` in format `fmt`: duplicate the frame (rows go right; a row's formats stack downwards +/// from `dy`), name the copy and fill its layers. The copy and its name. +fn copy_row( + s: &mut Session, + g: &Grid, + (i, row): (usize, &Value), + fmt: Option<&Format>, + dy: &mut f64, + missing: &mut Vec, +) -> Result> { + let dx = (g.column + g.gap) * (i as f64 + 1.0); + let dup = s.execute("edit.duplicate", &json!({"ids": [g.src], "dx": dx, "dy": *dy}))?; + let copy = dup["ids"].get(0).and_then(id_of).ok_or_else(|| bad(CMD, "the frame couldn't be duplicated"))?; + let Some(fields) = row.as_object() else { return Ok(None) }; + let name = fields.get("name").and_then(Value::as_str).map_or_else(|| format!("{} {}", g.base, i + 1), str::to_string); + let name = match fmt { + Some((suffix, w, h)) => { + s.execute("node.set", &json!({"id": copy, "props": {"width": w, "height": h}}))?; + *dy += h + g.gap; + format!("{name}-{suffix}") + } + None => name, + }; + s.execute("node.set", &json!({"id": copy, "props": {"name": name}}))?; + for (key, value) in fields.iter().filter(|(k, _)| k.as_str() != "name") { + fill_layers(s, copy, (key, value), missing)?; + } + Ok(Some((copy, name))) +} + +/// Give every layer named `key` in `copy` the row's `value`: its text, fill, visibility and fit. +/// A key no layer has goes on `missing` (once). +fn fill_layers(s: &mut Session, copy: NodeId, (key, value): (&str, &Value), missing: &mut Vec) -> Result<()> { + let doc = s.doc()?; + let targets: Vec<_> = doc.subtree(copy).into_iter().filter(|t| *t != copy && doc.node(*t).is_some_and(|n| n.name == key)).collect(); + if targets.is_empty() { + if !missing.iter().any(|m| m == key) { + missing.push(key.to_string()); + } + return Ok(()); + } + let (text, fill, visible, fit) = match value { + Value::Object(o) => ( + o.get("text").map(text_of), + o.get("fill").and_then(Value::as_str), + o.get("visible").and_then(Value::as_bool), + o.get("fit").cloned().filter(|f| !matches!(f, Value::Bool(false) | Value::Null)), + ), + v => (Some(text_of(v)), None, None, None), + }; + let mut texts = Vec::new(); + for t in targets { + let is_text = s.doc()?.node(t).is_some_and(|n| matches!(n.kind, figcraft_doc::NodeKind::Text(_))); + if let Some(text) = text.as_ref().filter(|_| is_text) { + s.execute("text.set", &json!({"id": t, "characters": text}))?; + } + if is_text { + texts.push(t.0); + } + if let Some(f) = fill { + s.execute("node.set", &json!({"id": t, "props": {"fills": [f]}}))?; + } + if let Some(v) = visible { + s.execute("node.set", &json!({"id": t, "props": {"visible": v}}))?; + } + } + // `fit: true` or `{maxLines?, minSize?, orphans?}`: size the text to its box. + if let Some(f) = fit.filter(|_| !texts.is_empty()) { + let mut params = if f.is_object() { f } else { json!({}) }; + params["ids"] = json!(texts); + s.execute("text.fit", ¶ms)?; + } + Ok(()) +} + +/// Export each copy into `dir` as `.` at `scale`, adding `files` (and `errors`) to +/// `out`. +fn export_copies(s: &mut Session, ids: &[(NodeId, String)], dir: &str, (format, scale): (&str, f64), out: &mut Value) { + let (mut files, mut errors, mut used) = (Vec::new(), Vec::new(), Vec::::new()); + for (copy, name) in ids { + // One file per copy: same or same-looking names get " 2", " 3"… (case-insensitively, + // as macOS and Windows file systems compare names). + let base = file_name(name); + let stem = + (1..).map(|k| if k == 1 { base.clone() } else { format!("{base} {k}") }).find(|n| !used.contains(&n.to_lowercase())).unwrap_or(base); + used.push(stem.to_lowercase()); + let path = std::path::Path::new(dir).join(format!("{stem}.{format}")).to_string_lossy().to_string(); + match s.execute("file.export", &json!({"ids": [copy.0], "format": format, "scale": scale, "path": path})) { + Ok(_) => files.push(path), + Err(e) => errors.push(json!({"id": copy.0, "path": path, "error": e.to_string()})), + } + } + out["files"] = json!(files); + if !errors.is_empty() { + out["errors"] = json!(errors); + } +} + +/// A cell as text: strings as they are, numbers and booleans as written, null as empty. +fn text_of(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Null => String::new(), + v => v.to_string(), + } +} diff --git a/crates/engine/src/datafill_tests.rs b/crates/engine/src/datafill_tests.rs new file mode 100644 index 00000000..3e0cff7f --- /dev/null +++ b/crates/engine/src/datafill_tests.rs @@ -0,0 +1,142 @@ +//! `data.fill`: a frame repeated per row with that row's texts, fills and visibility, in one undo +//! step, optionally exported. + +use crate::Session; +use crate::components::tests::{ex, idv}; +use figcraft_doc::{NodeId, NodeKind}; +use serde_json::json; + +/// A 1080×1350 "Post" with "Title" and "Price" texts and a "Badge" rectangle. Returns the frame. +fn post(s: &mut Session) -> NodeId { + let f = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 1080, "height": 1350, "name": "Post"}))); + ex(s, "text.create", json!({"text": "Title", "x": 80, "y": 80, "parent": f, "props": {"name": "Title"}})); + ex(s, "text.create", json!({"text": "R$ 0", "x": 80, "y": 1200, "parent": f, "props": {"name": "Price"}})); + ex(s, "shape.rectangle", json!({"x": 80, "y": 40, "width": 200, "height": 32, "parent": f, "props": {"name": "Badge", "fill": "#3DDC97"}})); + f +} + +fn text(s: &Session, frame: NodeId, name: &str) -> String { + let doc = s.doc().unwrap(); + doc.subtree(frame) + .into_iter() + .filter_map(|i| doc.node(i)) + .find(|n| n.name == name) + .and_then(|n| match &n.kind { + NodeKind::Text(t) => Some(t.characters.clone()), + _ => None, + }) + .unwrap_or_default() +} + +#[test] +fn each_row_fills_a_copy_in_one_undo_step() { + let mut s = Session::new(); + let f = post(&mut s); + let r = ex( + &mut s, + "data.fill", + json!({"id": f, "rows": [ + {"name": "Slide 1", "Title": "Site em 7 dias", "Price": 5500}, + {"name": "Slide 2", "Title": "WhatsApp com IA", "Badge": {"visible": false}}, + {"Title": "Agenda sozinha", "Badge": {"fill": "#7C5CFF"}, "Subtitle": "nope"} + ]}), + ); + let ids: Vec = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect(); + assert_eq!(ids.len(), 3); + assert_eq!(r["missing"], json!(["Subtitle"])); + assert_eq!(text(&s, ids[0], "Title"), "Site em 7 dias"); + assert_eq!(text(&s, ids[0], "Price"), "5500"); + assert_eq!(text(&s, ids[2], "Title"), "Agenda sozinha"); + assert_eq!(text(&s, f, "Title"), "Title", "the source frame is untouched"); + let doc = s.doc().unwrap(); + assert_eq!(doc.node(ids[0]).unwrap().name, "Slide 1"); + assert_eq!(doc.node(ids[2]).unwrap().name, "Post 3"); + // Copies sit in a row to the right, 100 apart. + let x = |i: NodeId| doc.node(i).unwrap().transform.as_coeffs()[4]; + assert_eq!((x(ids[0]), x(ids[1])), (1180.0, 2360.0)); + let badge = |i: NodeId| doc.subtree(i).into_iter().filter_map(|k| doc.node(k)).find(|n| n.name == "Badge").cloned().unwrap(); + assert!(!badge(ids[1]).visible && badge(ids[0]).visible); + assert_eq!(badge(ids[2]).fills[0].solid_color().map(|c| c.to_hex()), Some("7C5CFF".into())); + // One undo removes every copy. + ex(&mut s, "edit.undo", json!({})); + assert!(ids.iter().all(|i| !s.doc().unwrap().contains(*i))); +} + +#[test] +fn copies_export_to_a_folder_and_bad_input_is_refused() { + let mut s = Session::new(); + let f = post(&mut s); + let dir = std::env::temp_dir().join(format!("figcraft-datafill-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let r = ex( + &mut s, + "data.fill", + json!({"id": f, "rows": [{"name": "Post/1: hero", "Title": "A"}, {"Title": "B"}], "dir": dir.to_string_lossy(), "scale": 0.25}), + ); + let files: Vec = r["files"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect(); + assert_eq!(files.len(), 2); + assert!(files[0].ends_with("Post-1- hero.png"), "{files:?}"); + assert!(files.iter().all(|p| std::fs::metadata(p).is_ok_and(|m| m.len() > 0))); + let _ = std::fs::remove_dir_all(&dir); + for bad in [json!({"id": f}), json!({"id": f, "rows": [1, 2]}), json!({"id": f, "rows": [], "format": "gif"}), json!({"id": 999_999, "rows": []})] + { + assert!(s.execute("data.fill", &bad).is_err(), "{bad}"); + } +} + +#[test] +fn each_row_comes_in_every_format() { + let mut s = Session::new(); + let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 1080, "height": 1350, "name": "Post"}))); + ex(&mut s, "layout.add", json!({"ids": [f], "direction": "vertical"})); + ex(&mut s, "node.set", json!({"id": f, "props": {"sizingV": "fixed", "padding": 80}})); + let t = idv(&ex(&mut s, "text.create", json!({"text": "Título", "x": 0, "y": 0, "parent": f, "props": {"name": "Title", "fontSize": 96}}))); + ex(&mut s, "node.set", json!({"id": t, "props": {"sizingH": "fill", "textAutoResize": "height"}})); + let r = ex( + &mut s, + "data.fill", + json!({"id": f, "formats": [ + {"name": "feed", "width": 1080, "height": 1350}, {"name": "story", "width": 1080, "height": 1920}, {"width": 1080, "height": 1080} + ], "rows": [{"name": "capa", "Title": "Site em 7 dias"}, {"name": "prova", "Title": "Lighthouse 100"}]}), + ); + let ids: Vec = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect(); + assert_eq!(ids.len(), 6); + let doc = s.doc().unwrap(); + let names: Vec = ids.iter().map(|i| doc.node(*i).unwrap().name.clone()).collect(); + assert_eq!(names, ["capa-feed", "capa-story", "capa-1080x1080", "prova-feed", "prova-story", "prova-1080x1080"]); + let size = |i: NodeId| (doc.node(i).unwrap().width, doc.node(i).unwrap().height); + assert_eq!(size(ids[1]), (1080.0, 1920.0)); + assert_eq!(size(ids[2]), (1080.0, 1080.0)); + assert_eq!(text(&s, ids[4], "Title"), "Lighthouse 100"); + // Formats of a row stack downwards; rows go right without overlapping. + let at = |i: NodeId| (doc.node(i).unwrap().transform.as_coeffs()[4], doc.node(i).unwrap().transform.as_coeffs()[5]); + assert_eq!(at(ids[0]).0, at(ids[1]).0); + assert!(at(ids[1]).1 >= at(ids[0]).1 + 1350.0); + assert!(at(ids[3]).0 >= at(ids[0]).0 + 1080.0); + for bad in [json!([]), json!([{"width": 0, "height": 10}]), json!("feed")] { + assert!(s.execute("data.fill", &json!({"id": f, "rows": [{}], "formats": bad})).is_err(), "{bad}"); + } +} + +#[test] +fn review_export_names_are_unique_and_failures_are_reported() { + let mut s = Session::new(); + let f = post(&mut s); + let dir = std::env::temp_dir().join(format!("figcraft-datafill-names-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let r = ex( + &mut s, + "data.fill", + json!({"id": f, "rows": [{"name": "Post"}, {"name": "post"}, {"name": "a/b"}, {"name": "a:b"}], "dir": dir.to_string_lossy(), "scale": 0.1}), + ); + let files: Vec = r["files"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect(); + let lower: std::collections::HashSet = files.iter().map(|f| f.to_lowercase()).collect(); + assert_eq!(lower.len(), 4, "one file per copy: {files:?}"); + assert!(files.iter().all(|p| std::fs::metadata(p).is_ok()), "{files:?}"); + let _ = std::fs::remove_dir_all(&dir); + // A folder that can't be written: the copies stay and every failure is reported. + let r = ex(&mut s, "data.fill", json!({"id": f, "rows": [{"name": "x"}], "dir": "/nonexistent-figcraft-dir/out"})); + assert_eq!(r["ids"].as_array().unwrap().len(), 1); + assert_eq!(r["errors"].as_array().map(Vec::len), Some(1), "{r}"); + assert!(s.doc().unwrap().contains(NodeId(r["ids"][0].as_u64().unwrap()))); +} diff --git a/crates/engine/src/design_paste_tests.rs b/crates/engine/src/design_paste_tests.rs index 89de80ef..52dc4230 100644 --- a/crates/engine/src/design_paste_tests.rs +++ b/crates/engine/src/design_paste_tests.rs @@ -4,7 +4,7 @@ use base64::Engine as _; use serde_json::json; -use super::*; +use super::{NodeId, Session, Value, params}; use crate::cmd::edit::{CLIP_CLOSE, CLIP_OPEN, design_clipboard_buffer}; const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD; diff --git a/crates/engine/src/dev_measurements.rs b/crates/engine/src/dev_measurements.rs new file mode 100644 index 00000000..c5df56ea --- /dev/null +++ b/crates/engine/src/dev_measurements.rs @@ -0,0 +1,222 @@ +//! Measurements kept on the canvas (Dev Mode): a developer places one between two layers, or +//! across one side of a layer, and everyone who opens the file sees it. Each is stored on the layer +//! it starts from (`Node::measurements`: saved, synced, undoable) and drawn from the layers' +//! current bounds, so it follows them when they move or resize. One whose other layer was deleted +//! or moved to another page is neither drawn nor listed (undoing the deletion brings it back). + +use figcraft_doc::{Document, Measurement, Node, NodeId, Side}; +use figcraft_geom::{Point, Rect}; +use serde_json::{Value, json}; + +use crate::cmd::CommandSpec; +use crate::distance::{between, measured}; +use crate::params::{get, opt_id, opt_index}; +use crate::snap::Guide; +use crate::{EngineError, Result, Session, bad, spec}; + +/// Most measurements starting from one layer. +pub const MAX_PER_LAYER: usize = 64; +/// Most measurements listed or drawn for one page. +pub const MAX_LISTED: usize = 2_000; + +/// A measurement on a page: the layer it starts from, its index there, what it measures and the +/// lines it draws (page space). +#[derive(Clone, Debug, PartialEq)] +pub struct Placed { + pub from: NodeId, + pub index: usize, + pub measurement: Measurement, + pub lines: Vec, +} + +impl Placed { + /// Whether it starts or ends at layer `id`. + pub fn touches(&self, id: NodeId) -> bool { + self.from == id || self.measurement.to == Some(id) + } + + /// The distances it shows, as on its labels. + pub fn labels(&self) -> Vec<&str> { + self.lines.iter().filter_map(|g| g.label.as_deref()).collect() + } +} + +/// The line along side `side` of box `b`, labelled with that side's length. +fn side_line(b: Rect, side: Side) -> Vec { + let (p, q) = match side { + Side::Top => (Point::new(b.x0, b.y0), Point::new(b.x1, b.y0)), + Side::Bottom => (Point::new(b.x0, b.y1), Point::new(b.x1, b.y1)), + Side::Left => (Point::new(b.x0, b.y0), Point::new(b.x0, b.y1)), + Side::Right => (Point::new(b.x1, b.y0), Point::new(b.x1, b.y1)), + Side::Auto => return Vec::new(), + }; + let mut out = Vec::new(); + measured(&mut out, p, q); + out +} + +/// The lines measurement `m` from layer `from` draws, in page space (none when a layer is gone). +pub fn lines(doc: &Document, from: NodeId, m: Measurement) -> Vec { + let Some(a) = doc.world_bounds(from) else { return Vec::new() }; + match (m.to, m.side) { + (Some(to), _) => doc.world_bounds(to).map(|b| between(a, b)).unwrap_or_default(), + (None, Some(side)) => side_line(a, side), + (None, None) => Vec::new(), + } +} + +/// The measurements on `page` whose layers are both there, in layer order (bounded). +pub fn on_page(doc: &Document, page: NodeId) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![page]; + let mut visited = 0usize; + while let Some(id) = stack.pop() { + visited += 1; + if out.len() >= MAX_LISTED || visited > 200_000 { + break; + } + let Some(n) = doc.node(id) else { continue }; + for (index, m) in n.measurements.iter().enumerate() { + if m.to.is_some_and(|t| t == id || doc.page_of(t) != Some(page)) { + continue; + } + out.push(Placed { from: id, index, measurement: *m, lines: lines(doc, id, *m) }); + } + stack.extend(n.children.iter().rev().copied()); + } + out +} + +fn placed_json(doc: &Document, p: &Placed) -> Value { + let name = |id: NodeId| doc.node(id).map(|n| n.name.clone()).unwrap_or_default(); + let mut j = json!({"from": p.from, "fromName": name(p.from), "index": p.index, "labels": p.labels(), "lines": p.lines}); + if let Some(t) = p.measurement.to { + j["to"] = json!(t); + j["toName"] = json!(name(t)); + } + if let Some(s) = p.measurement.side { + j["side"] = json!(s); + } + j +} + +/// The layer a command starts from: `from`, else the first selected layer; never a page. +fn from_layer(cmd: &str, s: &Session, p: &Value) -> Result { + let id = match opt_id(cmd, p, "from")? { + Some(id) => id, + None => s.selection().first().copied().ok_or_else(|| bad(cmd, "select a layer or pass `from`"))?, + }; + let n = s.doc()?.node(id).ok_or(EngineError::NoNode(id))?; + if n.is_page() { + return Err(bad(cmd, "measure from a layer, not a page")); + } + Ok(id) +} + +/// The `side` parameter: top, right, bottom or left. +fn side_param(cmd: &str, p: &Value) -> Result> { + match get(p, "side") { + None | Some(Value::Null) => Ok(None), + Some(Value::String(v)) => Side::from_id(v).filter(|s| *s != Side::Auto).map(Some).ok_or_else(|| bad(cmd, "side: top, right, bottom or left")), + Some(_) => Err(bad(cmd, "side: top, right, bottom or left")), + } +} + +fn add(s: &mut Session, p: &Value) -> Result { + let cmd = "devMode.addMeasurement"; + let from = from_layer(cmd, s, p)?; + let (to, side) = (opt_id(cmd, p, "to")?, side_param(cmd, p)?); + let doc = s.doc()?; + let n = doc.node(from).ok_or(EngineError::NoNode(from))?; + let to = match (to, side) { + (Some(_), Some(_)) => return Err(bad(cmd, "pass `to` or `side`, not both")), + (Some(t), None) => Some(t), + (None, Some(_)) => None, + // Neither: to the layer around it. + (None, None) => Some( + n.parent + .filter(|q| doc.node(*q).is_some_and(|q| !q.is_page())) + .ok_or_else(|| bad(cmd, "pass `to` or `side` (the layer is at the top of its page)"))?, + ), + }; + if let Some(t) = to { + let target = doc.node(t).ok_or(EngineError::NoNode(t))?; + if t == from { + return Err(bad(cmd, "measure to another layer, or across a `side` of this one")); + } + if target.is_page() || doc.page_of(t) != doc.page_of(from) { + return Err(bad(cmd, "measure to a layer on the same page")); + } + } + let m = Measurement { to, side }; + // The same two layers measured from the other end are the same measurement. + let back = to.and_then(|t| doc.node(t)).is_some_and(|t| t.measurements.contains(&Measurement { to: Some(from), side: None })); + if n.measurements.contains(&m) || back { + return Err(bad(cmd, "that measurement is already there")); + } + if n.measurements.len() >= MAX_PER_LAYER { + return Err(bad(cmd, format!("a layer starts at most {MAX_PER_LAYER} measurements"))); + } + let drawn = lines(doc, from, m); + let n = s.doc_mut()?.get_mut(from)?; + n.measurements.push(m); + Ok(json!({"from": from, "index": n.measurements.len() - 1, "lines": drawn})) +} + +fn remove(s: &mut Session, p: &Value) -> Result { + let cmd = "devMode.removeMeasurement"; + let from = from_layer(cmd, s, p)?; + let index = opt_index(cmd, p, "index")?; + let n = s.doc_mut()?.get_mut(from)?; + let count = n.measurements.len(); + if count == 0 { + return Err(bad(cmd, "no measurement starts from this layer")); + } + let removed = match index { + Some(i) if i < count => { + n.measurements.remove(i); + 1 + } + Some(i) => return Err(bad(cmd, format!("no measurement {i} (the layer starts {count})"))), + None => std::mem::take(&mut n.measurements).len(), + }; + Ok(json!({"removed": removed})) +} + +fn list(s: &mut Session, p: &Value) -> Result { + let cmd = "devMode.measurements"; + let doc = s.doc()?; + let page = match opt_id(cmd, p, "page")? { + Some(pg) if doc.node(pg).is_some_and(Node::is_page) => pg, + Some(_) => return Err(bad(cmd, "`page` must be a page id")), + None => s.page()?, + }; + let only = opt_id(cmd, p, "id")?; + Ok(Value::Array(on_page(doc, page).iter().filter(|m| only.is_none_or(|id| m.touches(id))).map(|m| placed_json(doc, m)).collect())) +} + +pub fn specs() -> Vec { + vec![ + spec!( + "devMode.addMeasurement", + "Add measurement", + "{from? (default the first selected layer), to? (a layer on the same page), side? (top|right|bottom|left)} keeps a measurement on the canvas: between `from` and `to`, across one side of `from`, or (neither) to the layer around it; Dev Mode draws it and it follows the layers → {from, index, lines}", + true, + add + ), + spec!( + "devMode.removeMeasurement", + "Remove measurement", + "{from? (default the first selected layer), index? (omit: every measurement starting from the layer)} → {removed}", + true, + remove + ), + spec!( + "devMode.measurements", + "List measurements", + "{page? (default the current page), id? (only those from or to this layer)} → [{from, fromName, index, to?, toName?, side?, labels, lines: [{x0, y0, x1, y1, label, dashed}]}] (page space)", + false, + list + ), + ] +} diff --git a/crates/engine/src/dev_measurements_tests.rs b/crates/engine/src/dev_measurements_tests.rs new file mode 100644 index 00000000..695b8a7e --- /dev/null +++ b/crates/engine/src/dev_measurements_tests.rs @@ -0,0 +1,204 @@ +//! Measurements kept on the canvas: placed between two layers, across a side or to the layer +//! around one, following the layers, removed, undoable, copied with what they measure, refused on +//! bad input, synced, saved (old files without them still open) and allowed in Dev Mode. + +use figcraft_collab::{Change, Prop, Replica}; +use figcraft_doc::NodeId; +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; + +fn ok(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) +} + +fn rect(s: &mut Session, x: f64, w: f64, parent: Option) -> NodeId { + let mut p = json!({"x": x, "y": 20, "width": w, "height": 40}); + if let Some(parent) = parent { + p["parent"] = json!(parent); + } + id_of(&ok(s, "shape.rectangle", p)["id"]).unwrap() +} + +/// A frame with two rectangles 30 apart: (frame, a, b). +fn setup() -> (Session, NodeId, NodeId, NodeId) { + let mut s = Session::new(); + let f = id_of(&ok(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 100}))["id"]).unwrap(); + let a = rect(&mut s, 20.0, 50.0, Some(f)); + let b = rect(&mut s, 100.0, 60.0, Some(f)); + (s, f, a, b) +} + +fn labels(s: &mut Session, id: Option) -> Vec> { + let p = id.map_or(json!({}), |i| json!({"id": i})); + ok(s, "devMode.measurements", p) + .as_array() + .unwrap() + .iter() + .map(|m| m["labels"].as_array().unwrap().iter().map(|l| l.as_str().unwrap().to_string()).collect()) + .collect() +} + +#[test] +fn measurements_are_placed_follow_their_layers_and_are_removed() { + let (mut s, f, a, b) = setup(); + let m = ok(&mut s, "devMode.addMeasurement", json!({"from": a, "to": b})); + assert_eq!((&m["from"], &m["index"]), (&json!(a), &json!(0))); + assert_eq!(m["lines"][0]["label"], "30", "the gap between them"); + // Across a side: the side's length; with neither, to the frame around it. + ok(&mut s, "devMode.addMeasurement", json!({"from": b, "side": "top"})); + ok(&mut s, "select.set", json!({"ids": [a]})); + ok(&mut s, "devMode.addMeasurement", json!({})); + assert_eq!(labels(&mut s, None), [vec!["30"], vec!["20", "230", "20", "40"], vec!["60"]]); + let listed = ok(&mut s, "devMode.measurements", json!({"id": b})); + assert_eq!(listed.as_array().unwrap().len(), 2, "from or to the layer"); + assert_eq!((&listed[1]["side"], &listed[1]["to"]), (&json!("top"), &Value::Null)); + assert_eq!(listed[0]["toName"], s.doc().unwrap().node(b).unwrap().name.as_str()); + // They follow the layers. + ok(&mut s, "node.set", json!({"id": b, "props": {"x": 110, "width": 80}})); + assert_eq!(labels(&mut s, Some(b)), [vec!["40"], vec!["80"]]); + // Undo takes back an edit, and a placement. + ok(&mut s, "edit.undo", json!({})); + assert_eq!(labels(&mut s, Some(b)), [vec!["30"], vec!["60"]]); + ok(&mut s, "edit.undo", json!({})); + assert_eq!(labels(&mut s, None).len(), 2); + ok(&mut s, "edit.redo", json!({})); + // A deleted layer takes its measurements off the canvas; undo brings them back. + ok(&mut s, "edit.delete", json!({"ids": [b]})); + assert_eq!(labels(&mut s, None), [vec!["20", "230", "20", "40"]]); + ok(&mut s, "edit.undo", json!({})); + assert_eq!(labels(&mut s, None).len(), 3); + // Removed one by one, or all of a layer's. + assert_eq!(ok(&mut s, "devMode.removeMeasurement", json!({"from": a, "index": 1}))["removed"], 1); + assert_eq!(labels(&mut s, None), [vec!["30"], vec!["60"]]); + ok(&mut s, "devMode.addMeasurement", json!({"from": a, "side": "left"})); + assert_eq!(ok(&mut s, "devMode.removeMeasurement", json!({"from": a}))["removed"], 2); + assert!(s.execute("devMode.removeMeasurement", &json!({"from": a})).is_err(), "nothing left"); + assert_eq!(labels(&mut s, None), [vec!["60"]]); + let _ = f; +} + +#[test] +fn bad_measurements_are_refused() { + let (mut s, f, a, b) = setup(); + let page = s.page().unwrap(); + let other = id_of(&ok(&mut s, "page.add", json!({}))["id"]).unwrap(); + let far = rect(&mut s, 0.0, 10.0, Some(other)); + ok(&mut s, "page.select", json!({"id": page})); + let top = rect(&mut s, 400.0, 10.0, None); + ok(&mut s, "select.none", json!({})); + for p in [ + json!({"from": a, "to": a}), + json!({"from": a, "to": page}), + json!({"from": a, "to": far}), + json!({"from": a, "to": 99_999}), + json!({"from": page, "side": "top"}), + json!({"from": a, "to": b, "side": "top"}), + json!({"from": a, "side": "middle"}), + json!({"from": a, "side": "auto"}), + json!({"from": a, "side": 3}), + json!({"from": "x", "side": "top"}), + json!({"from": top}), + json!({"side": "top"}), + ] { + assert!(s.execute("devMode.addMeasurement", &p).is_err(), "{p}"); + } + ok(&mut s, "devMode.addMeasurement", json!({"from": a, "to": b})); + assert!(s.execute("devMode.addMeasurement", &json!({"from": a, "to": b})).is_err(), "already there"); + assert!(s.execute("devMode.addMeasurement", &json!({"from": b, "to": a})).is_err(), "the same two layers"); + assert!(s.execute("devMode.removeMeasurement", &json!({"from": a, "index": 5})).is_err()); + assert!(s.execute("devMode.measurements", &json!({"page": a})).is_err()); + assert_eq!(ok(&mut s, "devMode.measurements", json!({"page": other})), json!([])); + // A layer starts a bounded number. + for _ in 1..crate::dev_measurements::MAX_PER_LAYER { + let t = rect(&mut s, 200.0, 5.0, Some(f)); + ok(&mut s, "devMode.addMeasurement", json!({"from": a, "to": t})); + } + assert!(s.execute("devMode.addMeasurement", &json!({"from": a, "side": "top"})).is_err(), "capped"); + assert_eq!(s.doc().unwrap().node(a).unwrap().measurements.len(), crate::dev_measurements::MAX_PER_LAYER); +} + +#[test] +fn copies_measure_what_was_copied_with_them() { + let (mut s, f, a, b) = setup(); + ok(&mut s, "devMode.addMeasurement", json!({"from": a, "to": b})); + ok(&mut s, "devMode.addMeasurement", json!({"from": a, "side": "bottom"})); + // The frame copied: the copy of `a` measures to the copy of `b`. + ok(&mut s, "edit.duplicate", json!({"ids": [f]})); + let copy = s.selection()[0]; + let doc = s.doc().unwrap(); + let kids = doc.node(copy).unwrap().children.clone(); + let ca = doc.node(kids[0]).unwrap(); + assert_eq!(ca.measurements.len(), 2); + assert_eq!(ca.measurements[0].to, Some(kids[1])); + // `a` alone copied: only its side measurement comes along. + ok(&mut s, "edit.duplicate", json!({"ids": [a]})); + let lone = s.selection()[0]; + let m = &s.doc().unwrap().node(lone).unwrap().measurements; + assert_eq!((m.len(), m[0].to, m[0].side), (1, None, Some(figcraft_doc::Side::Bottom))); +} + +#[test] +fn measurements_sync_save_and_old_files_open() { + let (mut s, _, a, b) = setup(); + let before = s.doc().unwrap().clone(); + ok(&mut s, "devMode.addMeasurement", json!({"from": a, "to": b})); + ok(&mut s, "devMode.addMeasurement", json!({"from": b, "side": "right"})); + // A change collaborators receive. + let d = figcraft_collab::diff(&Replica::new(before), s.doc().unwrap()).unwrap(); + assert!(d.changes.iter().any(|c| matches!(c, Change::Set { id, prop: Prop::Measurements, .. } if *id == a)), "{:?}", d.changes); + // What agents inspect. + assert_eq!(crate::inspect::node_json(s.doc().unwrap(), b, 0)["measurements"], json!([{"side": "right"}])); + // Saved and opened again. + let doc = s.doc().unwrap(); + let bytes = figcraft_format::save_bytes(doc, None).unwrap(); + let (back, _) = figcraft_format::load_bytes(&bytes).unwrap(); + assert_eq!(back.node(a).unwrap().measurements, doc.node(a).unwrap().measurements); + assert_eq!(back.node(b).unwrap().measurements, doc.node(b).unwrap().measurements); + // A file from before measurements (no such field) opens with none. + fn strip(v: &mut Value) { + match v { + Value::Object(o) => { + o.remove("measurements"); + o.values_mut().for_each(strip); + } + Value::Array(a) => a.iter_mut().for_each(strip), + _ => {} + } + } + let mut old = figcraft_format::to_json(doc).unwrap(); + assert!(old.to_string().contains("\"measurements\"")); + strip(&mut old); + let (back, _) = figcraft_format::from_json(&old.to_string()).unwrap(); + assert!(back.node(a).unwrap().measurements.is_empty() && back.node(b).unwrap().name == doc.node(b).unwrap().name); +} + +#[test] +fn dev_mode_places_measurements() { + use crate::devinspect::dev_mode_allows; + for id in ["devMode.addMeasurement", "devMode.removeMeasurement", "devMode.measurements"] { + assert!(dev_mode_allows(id), "{id}"); + } +} + +#[test] +fn instances_keep_their_own_measurements_across_main_edits() { + let mut s = Session::new(); + let f = id_of(&ok(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 40}))["id"]).unwrap(); + let r = rect(&mut s, 5.0, 10.0, Some(f)); + ok(&mut s, "devMode.addMeasurement", json!({"from": r, "side": "top"})); + let main = id_of(&ok(&mut s, "component.create", json!({"ids": [f]}))["id"]).unwrap(); + let inst = id_of(&ok(&mut s, "instance.create", json!({"component": main, "x": 200, "y": 0}))["id"]).unwrap(); + let inner = s.doc().unwrap().node(inst).unwrap().children[0]; + // The main's measurements aren't copied onto its instances. + assert!(s.doc().unwrap().node(inner).unwrap().measurements.is_empty()); + ok(&mut s, "devMode.addMeasurement", json!({"from": inner})); + ok(&mut s, "devMode.addMeasurement", json!({"from": inst, "side": "left"})); + ok(&mut s, "node.set", json!({"id": r, "props": {"opacity": 0.5}})); + ok(&mut s, "node.set", json!({"id": main, "props": {"width": 120}})); + let doc = s.doc().unwrap(); + assert_eq!(doc.node(inner).unwrap().measurements[0].to, Some(inst), "kept across a main edit"); + assert_eq!(doc.node(inst).unwrap().measurements.len(), 1); + assert_eq!(doc.node(r).unwrap().measurements.len(), 1); +} diff --git a/crates/engine/src/devinspect.rs b/crates/engine/src/devinspect.rs index 16f4bbdc..78aa5553 100644 --- a/crates/engine/src/devinspect.rs +++ b/crates/engine/src/devinspect.rs @@ -219,14 +219,16 @@ pub struct DevItem { } /// Whether Dev Mode runs command `id` from the menus and shortcuts: commands that don't edit the -/// document, and Dev Mode's own edits (annotations, statuses, dev resources, code connections, -/// comments). Choosing a tool other than moving around and commenting is refused too, and so is -/// Revert to saved, which isn't an undo step but throws the design's unsaved edits away. +/// document, and Dev Mode's own edits (annotations, statuses, dev resources, measurements, code +/// connections, comments). Choosing a tool other than moving around and commenting is refused too, +/// and so is Revert to saved, which isn't an undo step but throws the design's unsaved edits away. pub fn dev_mode_allows(id: &str) -> bool { - const OWN: [&str; 5] = ["annotation.", "dev.", "devResource.", "codeConnect.", "comment."]; + const OWN: [&str; 6] = ["annotation.", "dev.", "devMode.", "devResource.", "codeConnect.", "comment."]; + // Edits recorded as one grouped undo step, so their spec says no history of its own. + const GROUPED: [&str; 1] = ["data.fill"]; match id.strip_prefix("tool.") { Some(t) => matches!(t, "move" | "hand" | "comment"), - None if id == "file.revert" => false, + None if id == "file.revert" || GROUPED.contains(&id) => false, None => OWN.iter().any(|p| id.starts_with(p)) || crate::cmd::find_command(id).is_none_or(|c| !c.history), } } diff --git a/crates/engine/src/devmode_behaviour_tests.rs b/crates/engine/src/devmode_behaviour_tests.rs index 3d85fdb1..38d1a9fb 100644 --- a/crates/engine/src/devmode_behaviour_tests.rs +++ b/crates/engine/src/devmode_behaviour_tests.rs @@ -124,6 +124,7 @@ fn dev_mode_runs_its_own_edits_and_refuses_design_edits_from_menus_and_shortcuts "tool.place", "file.import", "file.revert", + "data.fill", ] { assert!(!dev_mode_allows(id), "{id} edits the design"); } diff --git a/crates/engine/src/distance.rs b/crates/engine/src/distance.rs index c86aeaf0..62679e38 100644 --- a/crates/engine/src/distance.rs +++ b/crates/engine/src/distance.rs @@ -34,7 +34,7 @@ pub fn label(d: f64) -> String { } /// A labelled distance line, unless it has no length. -fn measured(out: &mut Vec, a: Point, b: Point) { +pub(crate) fn measured(out: &mut Vec, a: Point, b: Point) { let d = (b - a).hypot(); if d > EPS && d.is_finite() { out.push(Guide { label: Some(label(d)), ..Guide::new(a, b) }); diff --git a/crates/engine/src/draw_tools_tests.rs b/crates/engine/src/draw_tools_tests.rs index d67b0af4..607de266 100644 --- a/crates/engine/src/draw_tools_tests.rs +++ b/crates/engine/src/draw_tools_tests.rs @@ -60,6 +60,149 @@ fn text_goes_onto_a_path_and_off_again() { assert!(s.doc().unwrap().node(v).is_some() && text_path(&s, t).is_none(), "undo brings the path back"); } +/// A click (press and release) at (`x`, `y`) at 100%. +fn click(s: &mut Session, x: f64, y: f64) { + use crate::tools::{PointerEvent, PointerKind}; + s.pointer(&PointerEvent::at(PointerKind::Down, x, y, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, x, y, 1.0)).unwrap(); +} + +fn editing(s: &Session) -> Option { + s.ds().unwrap().text_edit.as_ref().map(|te| te.id) +} + +#[test] +fn the_text_on_a_path_tool_types_new_text_onto_the_path_it_clicks() { + let mut s = Session::new(); + let v = id(s.execute("vector.create", &json!({"path": "M0 0 L200 0", "x": 40, "y": 100, "props": {"fill": "#FF0000"}})).unwrap()); + s.execute("effect.add", &json!({"ids": [v], "type": "dropShadow"})).unwrap(); + let (vxf, vw, fills, effects) = { + let n = s.doc().unwrap().node(v).unwrap(); + (n.transform, n.width, n.fills.clone(), n.effects.clone()) + }; + assert!(!fills.is_empty() && !effects.is_empty()); + s.set_tool(crate::tools::ToolId::TextOnPath); + // Away from the path a click does nothing. + click(&mut s, 140.0, 160.0); + assert!(editing(&s).is_none() && s.doc().unwrap().node(v).is_some(), "only a path takes text"); + // A quarter of the way along, a few pixels off the line. + click(&mut s, 90.0, 103.0); + let t = editing(&s).expect("typing starts on the path"); + assert!(s.doc().unwrap().node(v).is_none(), "the path layer became the text's path"); + crate::textedit::insert(&mut s, "Along").unwrap(); + crate::textedit::end(&mut s).unwrap(); + let doc = s.doc().unwrap(); + let n = doc.node(t).unwrap(); + assert_eq!((n.transform, n.width), (vxf, vw), "in the path's place"); + assert_eq!(n.kind.text().map(|t| t.characters.as_str()), Some("Along")); + assert_eq!((&n.fills, &n.effects), (&fills, &effects), "the path's fill and effects carry over to the text"); + let tp = text_path(&s, t).unwrap(); + assert!((tp.start - 0.25).abs() < 0.01, "the text starts where the path was clicked: {}", tp.start); + assert_eq!(s.tool.tool, crate::tools::ToolId::Move, "back to Move after placing, as with the Text tool"); + s.execute("edit.undo", &json!({})).unwrap(); + let doc = s.doc().unwrap(); + assert!(doc.node(v).is_some() && doc.node(t).is_none(), "one undo step brings the path back"); +} + +#[test] +fn a_shape_in_front_whose_path_is_away_from_the_click_does_not_hide_the_path_behind() { + let mut s = Session::new(); + let back = id(s.execute("vector.create", &json!({"path": "M0 0 L200 0", "x": 40, "y": 100})).unwrap()); + // A filled square over the line: the click is inside it, 20 or more from its outline. + let front = id(s.execute("shape.rectangle", &json!({"x": 60, "y": 80, "width": 100, "height": 60})).unwrap()); + s.set_tool(crate::tools::ToolId::TextOnPath); + click(&mut s, 90.0, 100.0); + assert!(editing(&s).is_some(), "typing starts on the path behind"); + let doc = s.doc().unwrap(); + assert!(doc.node(back).is_none() && doc.node(front).is_some(), "the clicked path took the text"); +} + +#[test] +fn a_path_scaled_more_one_way_is_reached_where_it_shows() { + let mut s = Session::new(); + let v = id(s.execute("vector.create", &json!({"path": "M0 0 L100 100", "x": 0, "y": 0})).unwrap()); + // Ten times wider than tall: on the page the line runs from (0, 0) to (1000, 100). + s.doc_mut().unwrap().get_mut(v).unwrap().transform = figcraft_geom::Affine::scale_non_uniform(10.0, 1.0); + s.set_tool(crate::tools::ToolId::TextOnPath); + // 5 below the line on the page, halfway along it. + click(&mut s, 500.0, 55.0); + let t = editing(&s).expect("typing starts on the path"); + let tp = text_path(&s, t).unwrap(); + assert!((tp.start - 0.5).abs() < 0.01, "{}", tp.start); + // A layer shown at a tenth of its size: its outline is still reached 5 screen pixels away. + let mut s = Session::new(); + let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 1000, "height": 1000})).unwrap()); + s.doc_mut().unwrap().get_mut(r).unwrap().transform = figcraft_geom::Affine::scale(0.1); + s.set_tool(crate::tools::ToolId::TextOnPath); + click(&mut s, 105.0, 50.0); + assert!(editing(&s).is_some() && s.doc().unwrap().node(r).is_none(), "typing starts on the small square's path"); +} + +#[test] +fn paths_a_mask_or_a_turned_clipping_frame_hides_take_no_text() { + let mut s = Session::new(); + // A square masked by a circle in its top-left corner: its right edge doesn't show. + let m = id(s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()); + let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); + s.execute("object.mask", &json!({"ids": [m, r]})).unwrap(); + // A clipping frame turned 45°, with a line outside it: inside the frame's bounding box, but clipped. + let f = id(s.execute("frame.create", &json!({"x": 300, "y": 0, "width": 100, "height": 100, "props": {"clip": true}})).unwrap()); + let l = id(s.execute("vector.create", &json!({"path": "M0 0 L40 0", "x": -60, "y": 50, "parent": f})).unwrap()); + s.execute("node.set", &json!({"id": f, "props": {"rotation": 45}})).unwrap(); + let hidden = s.doc().unwrap().world_transform(l) * figcraft_geom::Point::new(20.0, 0.0); + let frame_box = s.doc().unwrap().world_bounds(f).unwrap(); + assert!(frame_box.contains(hidden), "the line's middle is inside the frame's bounding box"); + s.set_tool(crate::tools::ToolId::TextOnPath); + for (x, y) in [(100.0, 50.0), (hidden.x, hidden.y)] { + click(&mut s, x, y); + assert!(editing(&s).is_none(), "nothing shows at ({x}, {y})"); + } + let doc = s.doc().unwrap(); + assert!(doc.node(r).is_some() && doc.node(l).is_some()); +} + +#[test] +fn text_on_a_stroke_only_path_gets_the_usual_fill() { + let mut s = Session::new(); + let v = id(s.execute("vector.create", &json!({"path": "M0 0 C50 -50 150 -50 200 0", "x": 40, "y": 100, "props": {"fills": []}})).unwrap()); + assert!(!s.doc().unwrap().node(v).unwrap().fills.iter().any(|f| f.visible), "a path with a stroke only"); + s.set_tool(crate::tools::ToolId::TextOnPath); + click(&mut s, 40.0, 100.0); + let t = editing(&s).expect("typing starts on the path"); + assert!(s.doc().unwrap().node(t).unwrap().fills.iter().any(|f| f.visible), "the text can be seen"); +} + +#[test] +fn text_on_a_path_left_empty_puts_the_path_back_without_an_undo_step() { + let mut s = Session::new(); + let r = id(s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()); + let locked = id(s.execute("shape.rectangle", &json!({"x": 400, "y": 100, "width": 100, "height": 100, "props": {"locked": true}})).unwrap()); + let before = (s.doc().unwrap().clone(), s.undo_labels(100).len()); + s.set_tool(crate::tools::ToolId::TextOnPath); + click(&mut s, 400.0, 150.0); + assert!(editing(&s).is_none(), "a locked layer's path doesn't take text"); + // Inside the square, away from its outline, nothing happens either. + click(&mut s, 150.0, 150.0); + assert!(editing(&s).is_none()); + click(&mut s, 100.0, 150.0); + assert!(editing(&s).is_some() && s.doc().unwrap().node(r).is_none()); + crate::textedit::end(&mut s).unwrap(); + assert_eq!(*s.doc().unwrap(), before.0, "the square is back as it was"); + assert_eq!(s.undo_labels(100).len(), before.1, "no undo step"); + assert!(s.doc().unwrap().node(locked).is_some()); + // A double-click on the path, then nothing typed: the same (with this tool and the Text tool). + use crate::tools::{PointerEvent, PointerKind, ToolId}; + for tool in [ToolId::TextOnPath, ToolId::Text] { + s.set_tool(tool); + click(&mut s, 100.0, 150.0); + s.pointer(&PointerEvent::at(PointerKind::DoubleClick, 100.0, 150.0, 1.0)).unwrap(); + assert!(editing(&s).is_some(), "{tool:?}"); + crate::textedit::end(&mut s).unwrap(); + assert_eq!(*s.doc().unwrap(), before.0, "{tool:?}: the file is back as it was"); + assert_eq!(s.undo_labels(100).len(), before.1, "{tool:?}: no undo step"); + } +} + #[test] fn svg_text_paths_import_as_text_on_a_path() { // SVG text is parsed with Inter standing in for every family (CI has no fonts). @@ -79,6 +222,83 @@ fn svg_text_paths_import_as_text_on_a_path() { assert_eq!(t.align_h, figcraft_doc::TextAlignH::Center); } +/// A text layer with its own green fill bound to `ink` and a layer blur, and a curve; the +/// variables `ink` and `shadow` exist. +fn text_and_curve(s: &mut Session, path_props: Value) -> (NodeId, NodeId) { + let t = id(s.execute("text.create", &json!({"text": "Around", "x": 0, "y": 0})).unwrap()); + let v = id(s.execute("vector.create", &json!({"path": "M0 0 C50 -50 150 -50 200 0", "x": 40, "y": 100, "props": path_props})).unwrap()); + s.execute("variable.createCollection", &json!({"name": "Paint"})).unwrap(); + for (name, value) in [("ink", "#00FF00"), ("shadow", "#FF000080")] { + s.execute("variable.create", &json!({"collection": "Paint", "name": name, "type": "color", "value": value})).unwrap(); + } + s.execute("variable.bind", &json!({"ids": [t], "field": "fills/0/color", "variable": "ink"})).unwrap(); + s.execute("effect.add", &json!({"ids": [t], "type": "layerBlur"})).unwrap(); + (t, v) +} + +#[test] +fn text_put_on_a_filled_path_takes_its_fill_and_effects() { + let mut s = Session::new(); + let (t, v) = text_and_curve(&mut s, json!({"fill": "#FF0000"})); + let brand = s.execute("style.create", &json!({"type": "paint", "name": "Brand", "value": "#3366FF"})).unwrap()["id"].clone(); + s.execute("style.apply", &json!({"ids": [v], "style": brand})).unwrap(); + s.execute("effect.add", &json!({"ids": [v], "type": "dropShadow"})).unwrap(); + s.execute("variable.bind", &json!({"ids": [v], "field": "effects/0/color", "variable": "shadow"})).unwrap(); + let path = s.doc().unwrap().node(v).unwrap().clone(); + let text = s.doc().unwrap().node(t).unwrap().clone(); + assert!(path.styles.fill.is_some() && path.bound.contains_key("effects/0/color")); + s.execute("text.onPath", &json!({"ids": [t, v]})).unwrap(); + let n = s.doc().unwrap().node(t).unwrap(); + // "the vector object's fill and effects are automatically transferred to the text layer" + assert_eq!((&n.fills, &n.styles.fill), (&path.fills, &path.styles.fill), "the path's fill and its style"); + assert_eq!((&n.effects, &n.styles.effect), (&path.effects, &path.styles.effect), "the path's effects"); + assert_eq!(n.bound, path.bound, "the path's bindings replace the text's fill binding"); + s.execute("edit.undo", &json!({})).unwrap(); + let n = s.doc().unwrap().node(t).unwrap(); + assert_eq!((&n.fills, &n.styles.fill), (&text.fills, &text.styles.fill), "undo gives the text its own fill back"); + assert_eq!((&n.effects, &n.styles.effect), (&text.effects, &text.styles.effect), "undo gives the text its own effects back"); + assert_eq!(n.bound, text.bound, "undo gives the text its own fill binding back"); +} + +#[test] +fn text_put_on_a_stroke_only_path_keeps_its_own_fill_and_effects() { + let mut s = Session::new(); + let (t, v) = text_and_curve(&mut s, json!({"fills": []})); + let text = s.doc().unwrap().node(t).unwrap().clone(); + assert!(!text.fills.is_empty() && !text.effects.is_empty()); + s.execute("text.onPath", &json!({"ids": [t, v]})).unwrap(); + let n = s.doc().unwrap().node(t).unwrap(); + assert_eq!((&n.fills, &n.effects, &n.bound), (&text.fills, &text.effects, &text.bound), "a path with no fill or effects to give"); +} + +#[test] +fn text_put_on_a_path_with_an_empty_effect_style_keeps_its_own_effects() { + let mut s = Session::new(); + let (t, v) = text_and_curve(&mut s, json!({"fills": []})); + let none = s.execute("style.create", &json!({"type": "effect", "name": "None", "value": []})).unwrap()["id"].clone(); + s.execute("style.apply", &json!({"ids": [v], "style": none})).unwrap(); + let path = s.doc().unwrap().node(v).unwrap().clone(); + assert!(path.effects.is_empty() && path.styles.effect.is_some()); + let text = s.doc().unwrap().node(t).unwrap().clone(); + s.execute("text.onPath", &json!({"ids": [t, v]})).unwrap(); + let n = s.doc().unwrap().node(t).unwrap(); + assert_eq!((&n.effects, &n.styles.effect), (&text.effects, &text.styles.effect), "a path whose effect style draws no effects"); +} + +#[test] +fn text_put_on_a_path_takes_a_fill_binding_made_through_fills() { + let mut s = Session::new(); + let (t, v) = text_and_curve(&mut s, json!({"fill": "#FF0000"})); + s.execute("variable.bind", &json!({"ids": [v], "field": "fills", "variable": "shadow"})).unwrap(); + s.execute("variable.bind", &json!({"ids": [t], "field": "fills", "variable": "ink"})).unwrap(); + let path = s.doc().unwrap().node(v).unwrap().clone(); + assert!(path.bound.contains_key("fills")); + s.execute("text.onPath", &json!({"ids": [t, v]})).unwrap(); + let n = s.doc().unwrap().node(t).unwrap(); + assert_eq!(n.bound, path.bound, "the path's `fills` binding replaces the text's"); + assert_eq!(n.fills, path.fills); +} + #[test] fn pattern_fills_tile_their_source_live() { let mut s = Session::new(); @@ -131,3 +351,18 @@ fn a_pattern_of_itself_draws_nothing_instead_of_recursing() { assert!(figcraft_svg::export_svg(doc, &[r], &figcraft_svg::SvgOptions::default()).is_some()); assert!(figcraft_pdf::export_nodes_pdf(doc, &[r]).is_ok()); } + +#[test] +fn the_text_on_a_path_tool_carries_a_fill_bound_through_fills() { + let mut s = Session::new(); + let v = id(s.execute("vector.create", &json!({"path": "M0 0 L200 0", "x": 40, "y": 100, "props": {"fill": "#FF0000"}})).unwrap()); + s.execute("variable.createCollection", &json!({"name": "Paint"})).unwrap(); + s.execute("variable.create", &json!({"collection": "Paint", "name": "ink", "type": "color", "value": "#00FF00"})).unwrap(); + s.execute("variable.bind", &json!({"ids": [v], "field": "fills", "variable": "ink"})).unwrap(); + let bound = s.doc().unwrap().node(v).unwrap().bound.clone(); + assert!(bound.contains_key("fills")); + s.set_tool(crate::tools::ToolId::TextOnPath); + click(&mut s, 90.0, 103.0); + let t = editing(&s).expect("typing starts on the path"); + assert_eq!(s.doc().unwrap().node(t).unwrap().bound, bound, "the fill keeps following its variable"); +} diff --git a/crates/engine/src/export_behaviour_tests.rs b/crates/engine/src/export_behaviour_tests.rs index 6896ce3e..ec1a56e9 100644 --- a/crates/engine/src/export_behaviour_tests.rs +++ b/crates/engine/src/export_behaviour_tests.rs @@ -256,6 +256,43 @@ fn masks_and_slices_export_what_shows() { let _ = under; } +/// Ignore overlapping layers (contents only) on a slice: inside a frame or group the slice exports +/// only what is in that container; off, or on the page, everything in its area. +#[test] +fn a_slice_in_a_frame_or_group_exports_only_that_container() { + let mut s = Session::new(); + let slice_in = |s: &mut Session, parent: Option, x: f64| { + let mut p = json!({"type": "slice", "x": x, "y": 25, "width": 50, "height": 50}); + if let Some(parent) = parent { + p["parent"] = json!(parent); + } + id(&ex(s, "node.create", p)) + }; + let at = |s: &mut Session, p: Value| pixels(&export(s, p)).get_pixel(40, 40).0; + // A frame holding a red square and a slice, with a blue square on the page above it. + let f = frame(&mut s, 0.0, 0.0, 100.0, 100.0, false); + rect(&mut s, 0.0, 0.0, 100.0, 100.0, "#FF0000", Some(f)); + let in_frame = slice_in(&mut s, Some(f), 25.0); + rect(&mut s, 50.0, 50.0, 100.0, 100.0, "#0000FF", None); + assert_eq!(png_size(&export(&mut s, json!({"ids": [in_frame]}))), (50, 50), "the slice still exports its own area"); + assert_eq!(at(&mut s, json!({"ids": [in_frame]})), [255, 0, 0, 255], "on by default: only the frame's content, the blue square left out"); + assert_eq!(at(&mut s, json!({"ids": [in_frame], "contentsOnly": false})), [0, 0, 255, 255], "off: everything in the slice's area"); + // The same in a group. + let red = rect(&mut s, 300.0, 0.0, 100.0, 100.0, "#FF0000", None); + let grouped = slice_in(&mut s, None, 325.0); + let g = id(&ex(&mut s, "object.group", json!({"ids": [red, grouped]}))); + assert_eq!(s.doc().unwrap().node(grouped).unwrap().parent, Some(g)); + rect(&mut s, 350.0, 50.0, 100.0, 100.0, "#0000FF", None); + assert_eq!(at(&mut s, json!({"ids": [grouped]})), [255, 0, 0, 255], "in a group: only the group's content"); + assert_eq!(at(&mut s, json!({"ids": [grouped], "contentsOnly": false})), [0, 0, 255, 255], "off: everything in its area"); + // On the page the option has no effect. + rect(&mut s, 600.0, 0.0, 100.0, 100.0, "#FF0000", None); + let top = slice_in(&mut s, None, 625.0); + rect(&mut s, 650.0, 50.0, 100.0, 100.0, "#0000FF", None); + assert_eq!(at(&mut s, json!({"ids": [top]})), [0, 0, 255, 255], "on the page: everything in its area, with the option on"); + assert_eq!(at(&mut s, json!({"ids": [top], "contentsOnly": false})), [0, 0, 255, 255], "on the page: everything in its area, with it off"); +} + #[test] fn several_layers_and_frames_to_pdf() { let mut s = Session::new(); diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs index 672d4f2c..bdda411c 100644 --- a/crates/engine/src/fonts.rs +++ b/crates/engine/src/fonts.rs @@ -64,16 +64,20 @@ pub fn missing(doc: &Document) -> Vec { used.into_values().filter(|(family, _)| !figcraft_text::has_family(family)).map(|(family, layers)| Missing { family, layers }).collect() } -/// Every string value of variable `id` in any mode, following aliases (at most 32 deep). +/// Every string value of variable `id` in any mode, following aliases (each variable once). fn string_values(vars: &Variables, id: &str) -> Vec { let mut out = Vec::new(); - let mut todo = vec![(id.to_string(), 0)]; - while let Some((id, depth)) = todo.pop() { + let mut seen = BTreeSet::new(); + let mut todo = vec![id.to_string()]; + while let Some(id) = todo.pop() { + if !seen.insert(id.clone()) { + continue; + } let Some(var) = vars.get(&id) else { continue }; for v in var.values.values() { match v { VariableValue::String(s) => out.push(s.clone()), - VariableValue::Alias(a) if depth < 32 => todo.push((a.clone(), depth + 1)), + VariableValue::Alias(a) => todo.push(a.clone()), _ => {} } } diff --git a/crates/engine/src/fonts_tests.rs b/crates/engine/src/fonts_tests.rs index 0fe6dcb3..5df9a854 100644 --- a/crates/engine/src/fonts_tests.rs +++ b/crates/engine/src/fonts_tests.rs @@ -119,6 +119,25 @@ fn blank_families_and_aliases_are_handled() { assert_eq!(families(&mut s), vec![("FigCraft Missing F".to_string(), 1), ("FigCraft Missing G".to_string(), 1)]); } +/// Aliases in several modes are followed once per variable: a chain that branches in every mode +/// would otherwise grow to billions of steps (a file can hold one). +#[test] +fn branching_alias_chains_are_walked_once_per_variable() { + let mut s = Session::new(); + s.execute("variable.createCollection", &json!({"name": "Type"})).unwrap(); + for i in 0..32 { + s.execute("variable.create", &json!({"collection": "Type", "name": format!("V{i}"), "type": "string", "value": "x"})).unwrap(); + } + let doc = s.doc_mut().unwrap(); + let ids: Vec = doc.variables.variables.iter().map(|v| v.id.clone()).collect(); + for (i, var) in doc.variables.variables.iter_mut().enumerate() { + let next = ids.get(i + 1).cloned().unwrap_or_else(|| "FigCraft Missing H".into()); + let leaf = |v: String| if i + 1 < ids.len() { VariableValue::Alias(v) } else { VariableValue::String(v) }; + var.values = [("m1".to_string(), leaf(next.clone())), ("m2".to_string(), leaf(next))].into(); + } + assert_eq!(string_values(&doc.variables, &ids[0]), vec!["FigCraft Missing H", "FigCraft Missing H"]); +} + #[test] fn needs_an_open_document() { let mut s = Session::empty(); diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index d30f5f51..33162314 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -144,6 +144,9 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value { if !n.code_links.is_empty() { o.insert("codeLinks".into(), camel(serde_json::to_value(&n.code_links).unwrap_or(Value::Null))); } + if !n.measurements.is_empty() { + o.insert("measurements".into(), serde_json::to_value(&n.measurements).unwrap_or(Value::Null)); + } let dev = crate::dev_resources::list_json(doc, n.id); if dev.as_array().is_some_and(|a| !a.is_empty()) { o.insert("devResources".into(), dev); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2cd224e7..652df959 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -13,6 +13,7 @@ pub mod al_keys; pub mod annotations; pub mod arc_handles; pub mod background; +pub mod batch; pub mod board; pub mod booleans; pub mod branches; @@ -23,6 +24,8 @@ pub mod codegen; pub mod collab; pub mod component_codegen; pub mod components; +pub mod datafill; +pub mod dev_measurements; pub mod dev_resources; pub mod devcompare; pub mod devinspect; @@ -60,11 +63,14 @@ pub mod synthetic; pub mod synthetic_ds; pub mod templates; pub mod textedit; +pub mod textfit; pub mod textpreview; +pub mod token_roles; pub mod tools; pub mod vectoredit; pub mod widgets; +pub use batch::{BatchScope, BatchStep}; pub use cmd::{CommandSpec, all_commands, find_command}; pub use figcraft_collab; pub use figcraft_doc as doc; @@ -116,47 +122,6 @@ pub fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.to_string(), msg: msg.into() } } -/// Deepest params nesting `resolve_step_refs` walks before giving up. -const STEP_REF_MAX_DEPTH: usize = 64; - -/// Replace every `{"$step": n}` / `{"$step": n, "field": "…"}` in `v` with that field (default -/// `id`) of `results[n]`, the result of an earlier batch step. -fn resolve_step_refs(v: &Value, results: &[Value], step: usize, depth: usize) -> Result { - if depth > STEP_REF_MAX_DEPTH { - return Err(bad("command.batch", format!("step {step}: params nest deeper than {STEP_REF_MAX_DEPTH} levels"))); - } - match v { - Value::Object(o) if o.contains_key("$step") => { - if let Some(k) = o.keys().find(|k| *k != "$step" && *k != "field") { - return Err(bad("command.batch", format!("step {step}: a `$step` reference takes only `field`, not `{k}`"))); - } - let n = o - .get("$step") - .and_then(Value::as_u64) - .ok_or_else(|| bad("command.batch", format!("step {step}: `$step` must be a step index (0, 1, …)")))?; - let field = match o.get("field") { - None => "id", - Some(Value::String(f)) => f.as_str(), - Some(_) => return Err(bad("command.batch", format!("step {step}: `field` must be a string"))), - }; - let Some(result) = usize::try_from(n).ok().and_then(|n| results.get(n)) else { - let range = if step == 0 { "there is none before step 0".to_string() } else { format!("0 to {}", step - 1) }; - return Err(bad("command.batch", format!("step {step}: `$step` {n} must be an earlier step ({range})"))); - }; - result.get(field).cloned().ok_or_else(|| bad("command.batch", format!("step {step}: step {n}'s result has no `{field}`"))) - } - Value::Object(o) => { - let mut out = serde_json::Map::with_capacity(o.len()); - for (k, x) in o { - out.insert(k.clone(), resolve_step_refs(x, results, step, depth + 1)?); - } - Ok(Value::Object(out)) - } - Value::Array(a) => a.iter().map(|x| resolve_step_refs(x, results, step, depth + 1)).collect::>>().map(Value::Array), - _ => Ok(v.clone()), - } -} - /// One undo step. #[derive(Clone, Debug)] pub struct HistoryEntry { @@ -325,6 +290,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, @@ -403,7 +379,8 @@ pub const PREVIEW_READ_ONLY: &str = "you're viewing an earlier version: restore /// Commands that first end a version preview: they act on the document itself. Saving does so /// only with a native destination; export copies and pending format choices keep the preview. -const ENDS_PREVIEW: &[&str] = &["file.save", "file.saveAs", "file.close", "file.revert", "file.saveVersion", "file.duplicate", "version.restore"]; +const ENDS_PREVIEW: &[&str] = + &["file.save", "file.saveAs", "file.markSaved", "file.close", "file.revert", "file.saveVersion", "file.duplicate", "version.restore"]; static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -687,6 +664,9 @@ pub struct Prefs { /// The Getting started tips on the home screen were closed (Help ▸ Getting started shows them /// again). pub getting_started_dismissed: bool, + /// A prototype's Open link goes to a website without first telling the viewer that it leaves + /// the prototype (the notice's "Don't show this again"; `proto.linkNotice` shows it again). + pub link_notice_skipped: bool, /// How each home screen section shows its files, by section id (`"recents"`): `"grid"` (cards /// with the picture each file carries) or `"list"` (a table). Missing: the grid. #[serde(skip_serializing_if = "BTreeMap::is_empty")] @@ -736,6 +716,7 @@ impl Default for Prefs { recent_files: Vec::new(), online_files: Vec::new(), getting_started_dismissed: false, + link_notice_skipped: false, home_views: BTreeMap::new(), home_sort: "modified".into(), home_order: "desc".into(), @@ -766,6 +747,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, @@ -962,6 +947,8 @@ impl Session { ds.end_preview(); } let before = self.ds().ok().map(|d| (d.doc.clone(), d.selection.clone(), d.page, d.uid)); + // Cut and copy write the clipboard before a later check can refuse the command. + let clipboard_before = self.clipboard.clone(); let result = guard::run(id, || (spec.run)(self, params)) .and_then(|v| self.check_instance_structure(id, &before).map(|()| v)) .and_then(|v| self.check_preview(id, &before).map(|()| v)) @@ -994,6 +981,7 @@ impl Session { ds.selection = sel; ds.page = page; } + self.clipboard = clipboard_before; } } result @@ -1248,28 +1236,30 @@ impl Session { } /// Run several commands as one undo step. A param value `{"$step": n}` is replaced by the `id` - /// in the result of the earlier step `n` (`{"$step": n, "field": "…"}` picks another field), - /// so a step can target a layer that an earlier step of the same batch created. Any object - /// with a `$step` key in a step's params is such a reference, except inside a nested - /// `command.batch` step: its references are to its own steps. - pub fn batch(&mut self, label: &str, cmds: &[(String, Value)]) -> Result> { + /// in the result of the earlier step `n`, and `{"$ref": "name"}` by the `id` in the result of + /// the earlier step whose `as` is `name`; either takes a `field`, a key or slash path (`ids/0`) + /// into that result. So a step can target a layer that an earlier step of the same batch + /// created. Any object with a `$step` or `$ref` key in a step's params is such a reference, + /// except inside a nested `command.batch` step: its references are to its own steps. + pub fn batch(&mut self, label: &str, steps: &[BatchStep]) -> Result> { + let mut scope = BatchScope::new(steps)?; self.grouped(label, |s| { - let mut results: Vec = Vec::with_capacity(cmds.len()); - for (step, (id, p)) in cmds.iter().enumerate() { - let v = s.batch_step(&results, step, id, p)?; - results.push(v); + for step in steps { + s.batch_step(&mut scope, &step.command, &step.params)?; } - Ok(results) + Ok(scope.into_results()) }) } - /// One step of a batch: `{"$step": n}` in `params` resolves against `results` (the results of - /// the steps before it), then the command runs. [`Session::batch`] runs a list of these as one - /// undo step; a caller with its own list — the CLI's script runner — uses this so its steps - /// resolve `$step` the same way, inside its own [`Session::grouped`]. - pub fn batch_step(&mut self, results: &[Value], step: usize, cmd: &str, params: &Value) -> Result { - let p = if cmd == "command.batch" { params.clone() } else { resolve_step_refs(params, results, step, 0)? }; - self.execute(cmd, &p) + /// The next step of a batch: the references in `params` resolve against `scope` (the steps + /// before it), the command runs and its result joins `scope`. [`Session::batch`] runs a list + /// of these as one undo step; a caller with its own list — the CLI's script runner — uses this + /// so its steps resolve references the same way, inside its own [`Session::grouped`]. + pub fn batch_step(&mut self, scope: &mut BatchScope, cmd: &str, params: &Value) -> Result<()> { + let p = if cmd == "command.batch" { params.clone() } else { scope.resolve(params)? }; + let v = self.execute(cmd, &p)?; + scope.push(v); + Ok(()) } /// Run `f` as one undo step called `label`: the commands it executes push no steps of their @@ -1655,8 +1645,12 @@ mod cursor_tests; #[cfg(test)] mod dark_page_text_tests; #[cfg(test)] +mod datafill_tests; +#[cfg(test)] mod design_paste_tests; #[cfg(test)] +mod dev_measurements_tests; +#[cfg(test)] mod dev_resources_tests; #[cfg(test)] mod devcompare_tests; @@ -1885,6 +1879,8 @@ mod text_style_picker_tests; #[cfg(test)] mod text_tail_tests; #[cfg(test)] +mod textfit_tests; +#[cfg(test)] mod theme_pages_tests; #[cfg(test)] mod tiff_tests; @@ -1911,6 +1907,8 @@ mod vector_multi_behaviour_tests; #[cfg(test)] mod vector_offset_tests; #[cfg(test)] +mod vector_path_set_tests; +#[cfg(test)] mod version_preview_tests; #[cfg(test)] mod version_tests; diff --git a/crates/engine/src/live_access_tests.rs b/crates/engine/src/live_access_tests.rs index c2c75bb5..52a08cfd 100644 --- a/crates/engine/src/live_access_tests.rs +++ b/crates/engine/src/live_access_tests.rs @@ -105,6 +105,13 @@ fn a_view_link_makes_the_file_read_only() { refused(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5}), VIEW_ONLY); refused(&mut s, "node.set", json!({"ids": [card.0], "props": {"name": "Mine"}}), VIEW_ONLY); refused(&mut s, "edit.delete", json!({"ids": [card.0]}), VIEW_ONLY); + // Refused before it makes copies or writes files, which no rollback takes back. + let dir = std::env::temp_dir().join(format!("figcraft-view-fill-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + refused(&mut s, "data.fill", json!({"id": card.0, "rows": [{"name": "Copy"}], "dir": dir.to_string_lossy()}), VIEW_ONLY); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "no file was written"); + let _ = std::fs::remove_dir_all(&dir); refused(&mut s, "tool.set", json!({"tool": "text"}), VIEW_ONLY); assert!(matches!(s.begin_interaction("Move"), Err(EngineError::Disabled(..)))); assert_eq!(*s.doc().unwrap(), before); diff --git a/crates/engine/src/measure_tests.rs b/crates/engine/src/measure_tests.rs index 539386bd..9f793721 100644 --- a/crates/engine/src/measure_tests.rs +++ b/crates/engine/src/measure_tests.rs @@ -250,3 +250,21 @@ fn measure_distances_command() { assert!(s.execute("measure.distances", &json!({"ids": [f]})).is_err(), "top level: pass `to`"); assert!(s.execute("measure.distances", &json!({"ids": [r], "to": 99999})).is_err()); } + +#[test] +fn leaving_the_canvas_clears_the_hover_measurement() { + let mut s = Session::new(); + let a = rect(&mut s, 0.0, 0.0, 50.0, 50.0, None); + rect(&mut s, 100.0, 0.0, 50.0, 50.0, None); + ex(&mut s, "select.set", json!({"ids": [a]})); + assert!(!hover(&mut s, 125.0, 25.0, true, false, false).is_empty()); + assert!(s.tool.overlay.measure_box.is_some()); + s.pointer_left(); + assert!(s.tool.overlay.guides.is_empty(), "{:?}", s.tool.overlay.guides); + assert_eq!(s.tool.overlay.measure_box, None); + // Dev Mode measures without Alt; leaving clears those too. + s.tool.dev_mode = true; + assert!(!hover(&mut s, 125.0, 25.0, false, false, false).is_empty()); + s.pointer_left(); + assert!(s.tool.overlay.guides.is_empty()); +} diff --git a/crates/engine/src/online.rs b/crates/engine/src/online.rs index 66713732..c2a0868d 100644 --- a/crates/engine/src/online.rs +++ b/crates/engine/src/online.rs @@ -521,7 +521,8 @@ fn recover_file(s: &mut Session, p: &Value) -> Result { let i = named(s, CMD, p)?; let num = |key: &str| p.get(key).and_then(Value::as_u64).ok_or_else(|| bad(CMD, format!("`{key}` must be a whole number"))); let client_id = u32::try_from(num("client")?).map_err(|_| bad(CMD, "`client` must be a client id"))?; - let at = Resume { session: num("session")?, client_id, seq: num("seq")? }; + let secret = p.get("secret").and_then(Value::as_u64).unwrap_or(0); + let at = Resume { session: num("session")?, client_id, seq: num("seq")?, secret }; if s.prefs.online_files.get(i).is_some_and(|f| s.online_session(f).is_some()) { return Err(bad(CMD, "this file is open already")); } @@ -642,7 +643,7 @@ pub fn specs() -> Vec { spec!( "collab.recoverFile", "Recover online file", - "{path (a recovery copy of one of your online files), file, server?, session, client, seq (who the app was in the file when the copy was written, and the server's last change it had: from the note beside the copy)} → {session, status: \"connecting\"}: joins the file again as its owner and keeps the copy until the file arrives. Then: the server has everything in the copy → nothing more; the copy has changes of yours the server never confirmed and nothing else changed there → they are applied on top as one edit (\"Recovered changes\", synced to everyone); anything else changed meanwhile → the copy opens beside the file as “ (recovered copy)”; the file can't be joined → the copy opens as an unsaved document. The session's message says which", + "{path (a recovery copy of one of your online files), file, server?, session, client, seq, secret? (who the app was in the file when the copy was written, and the server's last change it had: from the note beside the copy)} → {session, status: \"connecting\"}: joins the file again as its owner and keeps the copy until the file arrives. Then: the server has everything in the copy → nothing more; the copy has changes of yours the server never confirmed and nothing else changed there → they are applied on top as one edit (\"Recovered changes\", synced to everyone); anything else changed meanwhile → the copy opens beside the file as “ (recovered copy)”; the file can't be joined → the copy opens as an unsaved document. The session's message says which", false, recover_file ), diff --git a/crates/engine/src/panels_behaviour_tests.rs b/crates/engine/src/panels_behaviour_tests.rs index d299b0df..9870aa31 100644 --- a/crates/engine/src/panels_behaviour_tests.rs +++ b/crates/engine/src/panels_behaviour_tests.rs @@ -99,16 +99,18 @@ fn scene(s: &mut Session) -> Scene { #[test] fn dragging_in_the_layers_panel_reorders_by_the_documented_rules() { - // The panel drops layers with `node.reparent {ids, parent, index}`: `index` counts the - // parent's children bottom first, as they are before the move. + // The panel drops layers with `node.reparent {ids, parent, index}`: `index` is where the first + // moved layer ends up among the parent's children, bottom first, counted after the move. // (moved, index in F, F's children after (bottom first), derivation) - let rows: [(&[&str], usize, [&str; 4], &str); 6] = [ - (&["a"], 4, ["b", "c", "d", "a"], "a layer dragged to the top of its parent's list comes to the front"), + let rows: [(&[&str], usize, [&str; 4], &str); 8] = [ + (&["a"], 3, ["b", "c", "d", "a"], "a layer dragged to the top of its parent's list comes to the front"), + (&["a"], 9, ["b", "c", "d", "a"], "an index past the end is the front"), (&["d"], 0, ["d", "a", "b", "c"], "a layer dragged to the bottom of the list goes to the back"), - (&["a"], 2, ["b", "a", "c", "d"], "a layer dropped between two layers lands between them"), - (&["a", "b"], 4, ["c", "d", "a", "b"], "several selected layers move together, keeping their order"), - (&["a", "d"], 2, ["b", "a", "d", "c"], "several layers from apart land together where they are dropped"), - (&["b", "c"], 1, ["a", "b", "c", "d"], "silent: dropping a block of layers onto itself changes nothing (pinned)"), + (&["a"], 1, ["b", "a", "c", "d"], "a layer moved up one place goes up one place (#992)"), + (&["a"], 0, ["a", "b", "c", "d"], "silent: a layer asked to stay where it is stays (pinned)"), + (&["a", "b"], 2, ["c", "d", "a", "b"], "several selected layers move together, keeping their order"), + (&["a", "d"], 1, ["b", "a", "d", "c"], "several layers from apart land together where they are asked to"), + (&["b", "c"], 1, ["a", "b", "c", "d"], "silent: moving a block of layers to where it is changes nothing (pinned)"), ]; for (moved, index, after, why) in rows { let mut s = Session::new(); @@ -172,6 +174,32 @@ fn dragging_into_frames_groups_and_auto_layout() { ); } +#[test] +fn reparent_index_is_the_final_position_in_auto_layout_and_across_parents() { + // #992: `index` is the layer's place among the parent's children after the move. + let mut s = Session::new(); + let row = id(&ex(&mut s, "node.create", json!({"type": "frame", "name": "Row", "props": {"layout": "horizontal"}}))); + let text = |s: &mut Session, t: &str, parent: NodeId| id(&ex(s, "node.create", json!({"type": "text", "name": t, "text": t, "parent": parent}))); + let (first, second, third) = (text(&mut s, "first", row), text(&mut s, "second", row), text(&mut s, "third", row)); + let names = |s: &Session, p: NodeId| kids(s, p).iter().map(|i| name(s, *i)).collect::>(); + ex(&mut s, "node.reparent", json!({"ids": [first], "parent": row, "index": 1})); + assert_eq!(names(&s, row), ["second", "first", "third"], "moved down one place"); + ex(&mut s, "node.reparent", json!({"ids": [first], "parent": row, "index": 2})); + assert_eq!(names(&s, row), ["second", "third", "first"], "moved to the end"); + ex(&mut s, "node.reparent", json!({"ids": [first], "parent": row, "index": 0})); + assert_eq!(names(&s, row), ["first", "second", "third"], "moved to the start"); + let x = |s: &Session, i: NodeId| s.doc().unwrap().node(i).unwrap().transform.as_coeffs()[4]; + assert!(x(&s, first) < x(&s, second) && x(&s, second) < x(&s, third), "the flow follows the new order"); + // Into another parent, `index` means the same thing. + let other = id(&ex(&mut s, "node.create", json!({"type": "frame", "name": "Other", "props": {"layout": "horizontal"}}))); + let (p, q) = (text(&mut s, "p", other), text(&mut s, "q", other)); + ex(&mut s, "node.reparent", json!({"ids": [second], "parent": other, "index": 1})); + assert_eq!(kids(&s, other), [p, second, q], "lands at index 1 of the new parent"); + ex(&mut s, "node.reparent", json!({"ids": [first, third], "parent": other, "index": 3})); + assert_eq!(kids(&s, other), [p, second, q, first, third], "several layers start at index 3, keeping their order"); + assert!(kids(&s, row).is_empty()); +} + #[test] fn renaming_several_layers_by_the_documented_codes() { // Three layers named `names`, bottom to top; (names, params, new names, derivation). diff --git a/crates/engine/src/plugins.rs b/crates/engine/src/plugins.rs index b7c12227..18fd64ef 100644 --- a/crates/engine/src/plugins.rs +++ b/crates/engine/src/plugins.rs @@ -113,6 +113,8 @@ const DENIED: &[&str] = &[ "edit.redo", "edit.repeat", "command.batch", + // Runs other commands inside (duplicate, edit, export), past this policy, like a batch. + "data.fill", // Network sessions, online files and their recovery copies on disk. "collab.", // Opens files in new windows. @@ -391,6 +393,7 @@ pub(crate) fn register(s: &mut Session, entry: InstalledPlugin, wasm: Vec) - match list.iter_mut().find(|x| x.manifest.id == id) { Some(slot) => { *slot = entry; + s.prefs.plugin_settings.remove(&id); true } None => { diff --git a/crates/engine/src/plugins_tests.rs b/crates/engine/src/plugins_tests.rs index 7f6c3740..172fd477 100644 --- a/crates/engine/src/plugins_tests.rs +++ b/crates/engine/src/plugins_tests.rs @@ -170,6 +170,7 @@ fn files_dialogs_history_and_plugins_are_off_limits() { ("plugin.remove", json!({"id": "org.test.deny"})), ("edit.undo", json!({})), ("command.batch", json!({"commands": [{"command": "file.saveAs", "params": {"path": target}}]})), + ("data.fill", json!({"id": a, "rows": [{}], "dir": dir.to_string_lossy()})), ("variable.export", json!({"format": "css", "path": target})), ("codeConnect.export", json!({"path": target})), ("codeConnect.import", json!({"path": "/etc/passwd"})), @@ -405,3 +406,13 @@ fn a_full_undo_history_keeps_its_steps_and_tampered_manifests_are_refused() { s.prefs.plugins[0].manifest.main = "../../elsewhere.wasm".into(); assert!(matches!(s.execute("plugin.run", &json!({"id": "org.test.many"})), Err(EngineError::BadParams { .. }))); } + +#[test] +fn reinstalling_under_an_existing_id_drops_the_old_settings() { + let mut s = Session::new(); + let m = manifest("org.test.reuse", "read", &[]); + install(&mut s, m.clone(), &script(&[], OnRefusal::Fail, "")); + s.prefs.plugin_settings.entry("org.test.reuse".into()).or_default().insert("key".into(), json!("v")); + install(&mut s, m, &script(&[], OnRefusal::Fail, "")); + assert!(!s.prefs.plugin_settings.contains_key("org.test.reuse"), "{:?}", s.prefs.plugin_settings); +} diff --git a/crates/engine/src/rename.rs b/crates/engine/src/rename.rs index 8d44f5ab..b4952475 100644 --- a/crates/engine/src/rename.rs +++ b/crates/engine/src/rename.rs @@ -81,6 +81,9 @@ impl BatchRename { out.push_str(name.get(last..m.start()).unwrap_or_default()); let (before, after) = (name.get(..m.start()).unwrap_or_default(), name.get(m.end()..).unwrap_or_default()); expand(&self.replace, &Ctx { matched: m.as_str(), before, after, groups: Some(&caps), up, down }, &mut out); + if out.len() >= MAX_NAME * 4 { + break; + } last = m.end(); } out.push_str(name.get(last..).unwrap_or_default()); @@ -110,6 +113,9 @@ impl BatchRename { fn expand(template: &str, c: &Ctx, out: &mut String) { let mut it = template.chars().peekable(); while let Some(ch) = it.next() { + if out.len() >= MAX_NAME * 4 { + break; + } if ch != '$' { out.push(ch); continue; diff --git a/crates/engine/src/selection_colors.rs b/crates/engine/src/selection_colors.rs index 875c6416..56bc4b35 100644 --- a/crates/engine/src/selection_colors.rs +++ b/crates/engine/src/selection_colors.rs @@ -5,11 +5,12 @@ use std::collections::{BTreeMap, BTreeSet}; use figcraft_color::{Paint, PaintKind}; -use figcraft_doc::{Document, NodeId, NodeKind, StyleKind}; +use figcraft_doc::{Document, NodeId, NodeKind, StyleKind, VariableType, VariableValue}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use crate::cmd::lists::{BoundList, list_binding, rebind_edited_paint}; +use crate::cmd::styles::rebind_paint_alpha; use crate::params::*; use crate::{CommandSpec, Result, Session, bad, spec}; @@ -130,6 +131,20 @@ pub fn specs() -> Vec { true, apply_style ), + spec!( + "selection.bindColorVariable", + "Apply selection color variable", + "{targets, variable (id or name)}; binds the color variable to matching solid fill/stroke slots, replacing a color style there (text ranges with fills of their own take its value in the layer's modes)", + true, + bind_variable + ), + spec!( + "selection.detachColor", + "Detach selection color", + "{targets}; unlinks the color style or variable of matching fill/stroke lists, keeping their values", + true, + detach + ), spec!( "selection.selectColor", "Select layers with color", @@ -210,6 +225,117 @@ fn apply_style(s: &mut Session, p: &Value) -> Result { Ok(json!({"ok": true})) } +/// The binding key (`fills//color` or `strokes//color`) of the slot a target stands for, when the +/// target is the node's own list or a text range that shows the node's fills. Returns None for a range +/// with fills of its own, since those fills hold no link. +fn list_field(doc: &Document, target: &PaintTarget) -> Option { + if let Some((start, _)) = target.range + && doc.node(target.id)?.kind.text()?.style_at(start).fills.is_some() + { + return None; + } + let list = if target.fills { BoundList::Fills } else { BoundList::Strokes }; + Some(format!("{}/{}/color", list.prefix(), target.index)) +} + +/// Remove every binding key of the slot (`fill` and `fills/0/color` name the same one); true when +/// one was there. +fn unbind_slot(n: &mut figcraft_doc::Node, fills: bool, index: usize) -> bool { + let list = if fills { BoundList::Fills } else { BoundList::Strokes }; + let before = n.bound.len(); + n.bound.retain(|key, _| list_binding(key).is_none_or(|(l, i, field)| !(l == list && i == index && field == "color"))); + n.bound.len() != before +} + +/// Why `variable` can't be applied to `targets` (`selection.bindColorVariable`), if it can't: a +/// variable that isn't a color, or a gradient. Scopes don't refuse, as with `variable.bind`; they +/// only filter what the menu offers ([`in_scope`]). +pub fn bind_refusal(doc: &Document, targets: &[PaintTarget], variable: &str) -> Option { + let Some(var) = doc.variables.get(variable) else { return Some("no such variable".into()) }; + if var.kind != VariableType::Color { + return Some("a color variable is required".into()); + } + for target in targets { + if !target.paint(doc).is_some_and(|paint| matches!(paint.kind, PaintKind::Solid { .. })) { + return Some("color variables bind to solid paints".into()); + } + let list = if target.fills { BoundList::Fills } else { BoundList::Strokes }; + let field = format!("{}/{}/color", list.prefix(), target.index); + if !crate::cmd::variables::field_takes(doc, target.id, &field, var.kind) { + return Some(format!("`{field}` doesn't take a color variable")); + } + } + None +} + +/// Whether `variable`'s scopes cover every slot of `targets` (a variable hidden from pickers has +/// none), so the Selection colors menu offers it there. +pub fn in_scope(doc: &Document, targets: &[PaintTarget], variable: &str) -> bool { + let Some(var) = doc.variables.get(variable) else { return false }; + targets.iter().all(|target| { + let list = if target.fills { BoundList::Fills } else { BoundList::Strokes }; + let field = format!("{}/{}/color", list.prefix(), target.index); + crate::cmd::variables::field_scope(doc, target.id, &field).is_none_or(|scope| var.in_scope(scope)) + }) +} + +fn bind_variable(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "selection.bindColorVariable"; + let targets = checked_targets(s, p, CMD)?; + let name = req_string(CMD, p, "variable")?; + let doc = s.doc()?; + if let Some(why) = bind_refusal(doc, &targets, &name) { + return Err(bad(CMD, why)); + } + let vid = doc.variables.get(&name).map(|v| v.id.clone()).ok_or_else(|| bad(CMD, "no such variable"))?; + for target in &targets { + if let Some(field) = list_field(s.doc()?, target) { + let n = s.doc_mut()?.get_mut(target.id)?; + if target.fills { + n.styles.fill = None; + } else { + n.styles.stroke = None; + } + if !unbind_slot(n, target.fills, target.index) { + rebind_paint_alpha(&mut n.fills, &mut n.strokes, &field, true); + } + n.bound.insert(field, vid.clone()); + continue; + } + // A range's own fills can't hold a binding: they take the value, as a range edit does. + let doc = s.doc()?; + let Some(VariableValue::Color(color)) = doc.variables.resolve(&vid, &doc.modes_for(target.id)) else { continue }; + let mut paints = target.paints(doc).ok_or_else(|| bad(CMD, "invalid target"))?; + let Some(slot) = paints.get_mut(target.index) else { return Err(bad(CMD, "invalid paint index")) }; + let own = if slot.color_alpha > 0.0 { (slot.opacity / slot.color_alpha).clamp(0.0, 1.0) } else { 1.0 }; + slot.kind = PaintKind::Solid { color: color.with_alpha(1.0) }; + slot.opacity = own * color.a; + slot.color_alpha = color.a; + let Some((start, end)) = target.range else { continue }; + if let NodeKind::Text(t) = &mut s.doc_mut()?.get_mut(target.id)?.kind { + t.restyle(start, end, |style| style.fills = Some(paints.clone())); + } + } + Ok(json!({"ok": true})) +} + +fn detach(s: &mut Session, p: &Value) -> Result { + let targets = checked_targets(s, p, "selection.detachColor")?; + for target in &targets { + let Some(field) = list_field(s.doc()?, target) else { continue }; + let n = s.doc_mut()?.get_mut(target.id)?; + if target.fills { + n.styles.fill = None; + } else { + n.styles.stroke = None; + } + if unbind_slot(n, target.fills, target.index) { + rebind_paint_alpha(&mut n.fills, &mut n.strokes, &field, false); + } + } + Ok(json!({"ok": true})) +} + fn select_color(s: &mut Session, p: &Value) -> Result { let ts = checked_targets(s, p, "selection.selectColor")?; let ids: Vec<_> = ts.iter().map(|t| t.id).collect::>().into_iter().collect(); diff --git a/crates/engine/src/shapes_behaviour_tests.rs b/crates/engine/src/shapes_behaviour_tests.rs index 62069da3..e628e168 100644 --- a/crates/engine/src/shapes_behaviour_tests.rs +++ b/crates/engine/src/shapes_behaviour_tests.rs @@ -205,6 +205,42 @@ fn corner_radius_follows_the_documented_rules() { ); } +#[test] +fn corner_smoothing_follows_the_documented_rules() { + // Smoothing applies to any shape a radius applies to, and only to the entire shape. + // (layer, radius, derivation) + let rows: Vec<(&str, f64, &str)> = vec![ + ("shape.rectangle", 20.0, "rectangles smooth their rounded corners"), + ("shape.polygon", 12.0, "polygons smooth their rounded corners"), + ("shape.star", 4.0, "stars smooth their rounded corners"), + ]; + for (cmd, radius, why) in rows { + let mut s = Session::new(); + let id = create(&mut s, cmd, json!({"x": 0, "y": 0, "width": 100, "height": 100})); + ex(&mut s, "node.set", json!({"ids": [id], "props": {"cornerRadius": radius}})); + let round = figcraft_doc::geometry::shape_path(&node(&s, id)).unwrap().area().abs(); + ex(&mut s, "node.set", json!({"ids": [id], "props": {"cornerSmoothing": 0.6}})); + let smooth = figcraft_doc::geometry::shape_path(&node(&s, id)).unwrap().area().abs(); + assert!((smooth - round).abs() > 1.0, "{why}: {round} then {smooth}"); + ex(&mut s, "node.set", json!({"ids": [id], "props": {"cornerRadius": 0}})); + let sharp = figcraft_doc::geometry::shape_path(&node(&s, id)).unwrap(); + ex(&mut s, "node.set", json!({"ids": [id], "props": {"cornerSmoothing": 0}})); + assert_eq!(sharp, figcraft_doc::geometry::shape_path(&node(&s, id)).unwrap(), "{why}: smoothing needs a radius"); + } + // (kind, takes smoothing, derivation) + let doc_rows: Vec<(NodeKind, bool, &str)> = vec![ + (NodeKind::Rectangle, true, "rectangles"), + (NodeKind::Frame, true, "frames"), + (NodeKind::Polygon { count: 5 }, true, "polygons"), + (NodeKind::Star { count: 5, inner_ratio: 0.4 }, true, "stars"), + (NodeKind::Vector { network: Default::default() }, false, "documented for closed vectors; their points round without it here"), + (NodeKind::Line, false, "lines take no radius, so no smoothing"), + ]; + for (kind, smoothing, why) in doc_rows { + assert_eq!(kind.has_corner_smoothing(), smoothing, "{why}"); + } +} + #[test] fn rounded_vector_points_are_drawn() { // A converted 100 × 100 square: a point's radius rounds that corner only (it wasn't drawn). diff --git a/crates/engine/src/style_vars.rs b/crates/engine/src/style_vars.rs index 97226aed..b2e5c182 100644 --- a/crates/engine/src/style_vars.rs +++ b/crates/engine/src/style_vars.rs @@ -42,6 +42,29 @@ pub fn field_type(kind: &StyleKind, key: &str) -> Option { } } +/// The variable scopes a picker offers for `key` of a style of `kind`: a variable scoped to any one +/// of them fits. A paint style's colors take fill and stroke variables, an effect style's fields +/// take effect variables, and a text style's fields take the variables of their font property. +/// None when every variable of the field's type fits (a grid's fields, as on layers) or the style +/// has no such field. +pub fn field_scopes(kind: &StyleKind, key: &str) -> Option<&'static [&'static str]> { + field_type(kind, key)?; + Some(match (kind, list_binding(key)) { + (StyleKind::Paint { .. }, _) => &["FRAME_FILL", "SHAPE_FILL", "TEXT_FILL", "STROKE_COLOR"], + (StyleKind::Effect { .. }, Some((_, _, "color"))) => &["EFFECT_COLOR"], + (StyleKind::Effect { .. }, _) => &["EFFECT_FLOAT"], + (StyleKind::Text { .. }, _) => match key { + "fontSize" => &["FONT_SIZE"], + "lineHeight" => &["LINE_HEIGHT"], + "letterSpacing" => &["LETTER_SPACING"], + "fontFamily" => &["FONT_FAMILY"], + "fontStyle" => &["FONT_STYLE"], + _ => return None, + }, + _ => return None, + }) +} + /// The key a style stores a binding of `key` under: the first paint's colour has one spelling. pub fn canonical(key: &str) -> String { match list_binding(key) { diff --git a/crates/engine/src/style_vars_tests.rs b/crates/engine/src/style_vars_tests.rs index dae9ef56..66768daa 100644 --- a/crates/engine/src/style_vars_tests.rs +++ b/crates/engine/src/style_vars_tests.rs @@ -182,6 +182,46 @@ fn what_a_style_cannot_bind_is_refused() { } } +#[test] +fn style_fields_offer_their_properties_scopes() { + let mut s = Session::new(); + let (_, _, a, _, sid) = two_modes(&mut s); + run(&mut s, "effect.add", json!({"ids": [a], "type": "dropShadow"})); + let fx = run(&mut s, "style.create", json!({"type": "effect", "name": "Drop", "from": a}))["id"].as_str().unwrap().to_string(); + let light = node(&s, a).parent.unwrap(); + run(&mut s, "grid.add", json!({"ids": [light]})); + let grid = run(&mut s, "style.create", json!({"type": "grid", "name": "Base", "from": light}))["id"].as_str().unwrap().to_string(); + let body = run(&mut s, "style.create", json!({"type": "text", "name": "Body", "value": {"fontFamily": "Inter", "fontSize": 14}}))["id"] + .as_str() + .unwrap() + .to_string(); + let doc = s.doc().unwrap(); + let scopes = |st: &str, key: &str| crate::style_vars::field_scopes(&doc.style(st).unwrap().kind, key); + let fills: &[&str] = &["FRAME_FILL", "SHAPE_FILL", "TEXT_FILL", "STROKE_COLOR"]; + for (st, key, want) in [ + (&sid, "fill", Some(fills)), + (&sid, "fills/0/color", Some(fills)), + (&fx, "effects/0/color", Some(&["EFFECT_COLOR"][..])), + (&fx, "effects/0/radius", Some(&["EFFECT_FLOAT"][..])), + (&fx, "effects/0/offsetX", Some(&["EFFECT_FLOAT"][..])), + (&body, "fontSize", Some(&["FONT_SIZE"][..])), + (&body, "lineHeight", Some(&["LINE_HEIGHT"][..])), + (&body, "letterSpacing", Some(&["LETTER_SPACING"][..])), + (&body, "fontFamily", Some(&["FONT_FAMILY"][..])), + (&body, "fontStyle", Some(&["FONT_STYLE"][..])), + // Grid fields take any number variable, as on layers; fields a style lacks take none. + (&grid, "layoutGrids/0/size", None), + (&sid, "fills/3/color", None), + (&body, "opacity", None), + ] { + assert_eq!(scopes(st, key), want, "{st} {key}"); + } + // As on layers, scopes limit pickers only: an out-of-scope variable still binds. + run(&mut s, "variable.update", json!({"variable": "size", "scopes": ["CORNER_RADIUS"]})); + run(&mut s, "style.bind", json!({"style": body, "field": "fontSize", "variable": "size"})); + assert!(s.doc().unwrap().style(&body).unwrap().bound.contains_key("fontSize")); +} + #[test] fn rest_and_fig_carry_style_bindings() { let mut s = Session::new(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 646c7c4b..78443fcd 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -275,6 +275,20 @@ fn align_distribute_zorder() { assert_eq!(s.doc().unwrap().node(page).unwrap().children[0], c); } +#[test] +fn distribute_spans_the_outermost_edge() { + let mut s = sess(); + let a = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()); + let b = id(&s.execute("shape.rectangle", &json!({"x": 150, "y": 150, "width": 500, "height": 500})).unwrap()); + let c = id(&s.execute("shape.rectangle", &json!({"x": 160, "y": 160, "width": 10, "height": 10})).unwrap()); + s.execute("distribute.horizontal", &json!({"ids": [a, b, c]})).unwrap(); + let xs: Vec = [a, b, c].iter().map(|i| s.doc().unwrap().world_bounds(*i).unwrap().x0).collect(); + assert_eq!(xs, [0.0, 75.0, 640.0]); + s.execute("distribute.vertical", &json!({"ids": [a, b, c]})).unwrap(); + let ys: Vec = [a, b, c].iter().map(|i| s.doc().unwrap().world_bounds(*i).unwrap().y0).collect(); + assert_eq!(ys, [0.0, 75.0, 640.0]); +} + #[test] fn a_layer_alone_on_the_page_has_nothing_to_align_to() { use crate::cmd::arrange::can_align; @@ -395,6 +409,10 @@ fn every_command_survives_junk_params() { // Links (dev resources) and indexes as junk. json!({"id": 2, "url": "javascript:alert(1)", "index": 1e308, "name": {"x": 1}}), json!({"id": 2, "url": "https://\u{0}x", "index": -1, "name": ""}), + // Dev Mode measurements: layers, sides, pages and indexes as junk. + json!({"from": "x", "to": -1, "side": [1], "index": 1e308, "page": {"x": 1}}), + json!({"from": 2, "to": 2, "side": "middle", "index": -1, "page": 2, "id": "nope"}), + json!({"from": 2, "to": 1, "side": "auto"}), // Widgets: plugins, targets and fuel as junk. json!({"id": 1, "plugin": {"x": 1}, "target": [1], "fuel": -1e308, "x": 1e308}), // Plugin directories: ids, versions, servers and confirmations as junk. @@ -419,12 +437,22 @@ fn every_command_survives_junk_params() { // Selection color slots: malformed indexes, ranges and paints. json!({"targets": [{"id": 2, "fills": true, "index": -1, "range": [0, 1e308]}], "paint": {"type": "solid", "color": "invalid"}}), json!({"targets": [{"id": 2, "fills": "yes", "index": 0, "range": [4, 1]}], "style": 5}), + json!({"targets": [{"id": 2, "fills": false, "index": 99999999999u64, "range": null}], "variable": ["x"]}), // Smart-selection reorder targets and modes as junk. json!({"ids": [2, 3], "source": -1, "target": {"id": 3}, "swap": [], "fromGesture": true}), json!({"source": 1e308, "target": "NaN", "swap": "yes", "fromGesture": []}), // Rotation origins and placing with a tool as junk. json!({"tool": [1], "x": 1e308, "y": "NaN", "reset": "maybe", "on": {"x": 1}}), json!({"tool": "frame", "x": -1e308, "y": 1e308, "reset": true, "on": "yes"}), + // Vector paths (`node.set {path}`) as junk. (Not a string `path` at the top: file commands + // would write to it.) + json!({"ids": [2, 3], "type": "vector", "path": [5], "width": -1e308, "props": {"path": "M0 0 L1e308 0 L-1e308 1e308", "height": "NaN", "x": 1e308}}), + json!({"id": 2, "type": "vector", "path": {"d": 1}, "props": {"path": "M NaN 0 Z", "width": 1e-308}}), + // Batch step names and reference paths as junk. + json!({"commands": [{"command": "select.none", "as": "a"}, {"command": "select.none", "as": [1]}, {"command": "select.none", "as": "a"}]}), + json!({"commands": [{"command": "select.all", "as": "\u{0}"}, {"command": "node.set", "params": {"ids": [ + {"$ref": "\u{0}", "field": "ids/18446744073709551616/x"}, {"$ref": {"x": 1}}, {"$step": 0, "field": "//"}, {"$ref": "\u{0}", "field": "/"}], + "props": {"name": {"$ref": "nope", "field": 5}}}}]}), ]; for spec in all_commands() { if matches!( diff --git a/crates/engine/src/text_behaviour_tests.rs b/crates/engine/src/text_behaviour_tests.rs index b9a76fbc..f15817e8 100644 --- a/crates/engine/src/text_behaviour_tests.rs +++ b/crates/engine/src/text_behaviour_tests.rs @@ -60,11 +60,14 @@ fn arrows_and_deletes_step_over_whole_clusters() { key(&mut s, "Right", true, false, false).unwrap(); assert_eq!(caret(&s), (1, 6)); key(&mut s, "Left", false, false, false).unwrap(); - // Delete and Backspace remove a whole cluster. + // Delete removes a whole cluster; Backspace takes a letter's marks one at a time (#1220) and + // an emoji cluster whole. key(&mut s, "Delete", false, false, false).unwrap(); assert_eq!(chars(&s, t), "ab\u{1F1EF}\u{1F1F5}e\u{0301}"); key(&mut s, "Right", false, false, true).unwrap(); // to the end key(&mut s, "Backspace", false, false, false).unwrap(); + assert_eq!(chars(&s, t), "ab\u{1F1EF}\u{1F1F5}e"); + key(&mut s, "Backspace", false, false, false).unwrap(); assert_eq!(chars(&s, t), "ab\u{1F1EF}\u{1F1F5}"); key(&mut s, "Backspace", false, false, false).unwrap(); assert_eq!(chars(&s, t), "ab"); @@ -134,6 +137,63 @@ fn ime_composition_with_cjk() { assert!(s.ds().unwrap().text_edit.as_ref().unwrap().preedit.is_none()); } +/// Asserts `delete_surrounding_bytes` removes an exact UTF-8 byte range across CJK and a +/// supplementary emoji, leaves nothing past the start, and the whole edit is one undo step. +#[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"); +} + +/// Asserts a byte range that only partly covers a character leaves it in place, while an +/// exact range deletes it, and a selection survives for the paired commit with its +/// direction preserved. +#[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..e236e322 100644 --- a/crates/engine/src/textedit.rs +++ b/crates/engine/src/textedit.rs @@ -1,9 +1,85 @@ //! 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)); + +/// Return the next editing-session token from a mutex-protected counter (`wrapping_add`). +/// Zero is a valid token; the mutex also works on wasm32 without 64-bit atomics. +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()), @@ -17,11 +93,13 @@ pub fn begin(s: &mut Session, id: NodeId, caret: Option, created: bool) - let n = t.char_count(); s.begin_interaction("Edit text")?; let ds = s.ds_mut()?; + // Editing the same layer again (a double-click into a text just made) keeps it new. + let created = created || ds.text_edit.as_ref().is_some_and(|te| te.id == id && te.created); let (caret, anchor) = match caret { 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); @@ -44,9 +122,13 @@ pub fn end(s: &mut Session) -> Result<()> { // Nothing to undo for a text box that was never filled. The gesture snapshot was taken // after the layer was added, so it still holds it: re-base it on the document without the // layer, and committing then records no step (and leaves the redo stack alone). + // A snapshot taken before the layer was added (the Text on a path tool, which also took + // the path layer away) is put back whole instead. let ds = s.ds_mut()?; let doc = ds.doc.clone(); - if let Some(it) = ds.interaction.as_mut() { + if ds.interaction.as_ref().is_some_and(|it| it.doc.node(te.id).is_none()) { + s.cancel_interaction()?; + } else if let Some(it) = ds.interaction.as_mut() { it.doc = doc; } } @@ -63,14 +145,33 @@ fn range(te: &TextEdit) -> (usize, usize) { (te.caret.min(te.anchor), te.caret.max(te.anchor)) } +/// Apply an unconditional text-model mutation through `edit_if`, whose success clears the +/// composition, clamps caret and anchor to the character count, marks a `characters` override +/// when the node is source-backed, normalizes the document and bumps the revision. 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 +183,83 @@ 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) +} + +/// Insert committed text, optionally as an IME commit: normalize line endings, drop control +/// characters except `\n`/`\t`, ignore a bare newline/CR IME commit, clear an active +/// composition for an empty or control-only IME commit, and turn a committed space after a +/// line-start `-`, `*` or `1.` into a bulleted/numbered list. Reports whether the insertion was +/// plain, transformed, or a no-op. +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 +276,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 @@ -243,7 +413,7 @@ pub fn key(s: &mut Session, key: &str, shift: bool, word: bool, line: bool) -> R } else if word { word_left(&chars, a) } else { - figcraft_text::grapheme_step(&t.characters, a, false) + figcraft_text::backspace_step(&t.characters, a) }; (from, a) } else { @@ -339,6 +509,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 @@ -574,12 +898,25 @@ mod tests { end(&mut s).unwrap(); } + #[test] + fn backspace_removes_only_the_final_thai_mark() { + let (mut s, id) = setup("น้ำ"); + begin(&mut s, id, Some(3), false).unwrap(); + + key(&mut s, "Backspace", false, false, false).unwrap(); + + assert_eq!(data(&s, id).characters, "น้"); + } + fn ev(kind: PointerKind, x: f64, y: f64, shift: bool) -> PointerEvent { let mut e = PointerEvent::at(kind, x, y, 1.0); e.shift = shift; e } + /// Asserts double-clicking a text layer selects all text, a second double-click selects the + /// word without changing the editing session, and drag and Shift+click extend the + /// selection. #[test] fn pointer_double_click_drag_and_shift_click() { let (mut s, id) = setup("Hello world"); @@ -596,10 +933,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 +1154,203 @@ mod tests { } } } + + /// Asserts surrounding text is logical UTF-8 text with byte offsets and keeps a reversed + /// selection reversed. + #[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); + } + + /// Asserts an active composition is excluded from surrounding text and an empty text layer + /// reports an available, empty payload. + #[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"); + } + + /// Asserts surrounding text is capped at `MAX_IME_SURROUNDING_BYTES`, an exactly budget-sized + /// selection is describable, and one byte more is `SelectionTooWide`. + #[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)); + } + + /// Asserts edits and caret moves keep the same IME owner, while ending and beginning a new + /// edit session changes the generation. + #[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"); + } + + /// Asserts byte deletion around a reversed selection removes whole characters on both + /// sides, keeps the selected text and direction, and a final no-op leaves the text alone. + #[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"); + } + + /// Asserts character deletion keeps and shifts the selection, and a no-op delete leaves an + /// active composition and the revision untouched. + #[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); + } + + /// Asserts a text layer in another document has a different IME owner. + #[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); + } + + /// Asserts a space committed after a line-start `-` becomes a bulleted list and reports + /// `Transformed`; a plain `"x"` commit reports `Plain`, and an empty commit reports `Nothing`. + #[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); + } + + /// Asserts CRLF normalization reports `Transformed`, control-only input reports `Nothing`, + /// and bare newline/CR commits are ignored while a live composition remains. + #[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"); + } + + /// Asserts an empty or control-only IME commit ends a live composition (bumping the revision + /// once), inserts nothing, and is a harmless no-op when no composition is active. + #[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/textfit.rs b/crates/engine/src/textfit.rs new file mode 100644 index 00000000..4ab6c50e --- /dev/null +++ b/crates/engine/src/textfit.rs @@ -0,0 +1,139 @@ +//! `text.fit`: text sized to fit its box, for posts and cards whose copy changes (a carousel +//! filled from data, a translated headline). The largest font size, down to a floor, at which the +//! text stays within `maxLines` lines and its box's height; then a last line that is a single word +//! (an orphan) is avoided by shrinking a little more, else by keeping the last two words together +//! with a no-break space. Every style run scales by the same factor, so mixed sizes keep their +//! proportions. + +use figcraft_doc::{NodeKind, TextAutoResize, TextData}; +use serde_json::{Value, json}; + +use crate::cmd::CommandSpec; +use crate::params::{boolean, num, req_targets}; +use crate::{Result, Session, bad, spec}; + +const CMD: &str = "text.fit"; + +pub fn specs() -> Vec { + vec![spec!( + "text.fit", + "Fit text to its box", + "{ids? (text layers; default the selection), maxLines? (default: no limit but the box height), minSize? (smallest font size, px; default 60% of the current), orphans? (avoid a one-word last line; default true)} needs a fixed width (fixed or auto-height text) → {fitted: [{id, fontSize, lines, fits, orphan}]}", + true, + fit + )] +} + +/// The layout of `t` with every run's font size times `k`, at `width`: (line count, height, +/// whether the last line is an orphan: one word, wrapped from the line above rather than a +/// paragraph of its own after a hard break, which no size can fix). +fn measure(t: &TextData, k: f64, width: f64) -> (usize, f64, bool) { + let mut t = t.clone(); + let n = t.char_count(); + t.restyle(0, n, |s| s.font_size = (s.font_size * k).clamp(1.0, 5000.0)); + let l = figcraft_text::layout(&t, Some(width), None); + let orphan = l.lines.len() > 1 + && l.lines.last().is_some_and(|line| { + let mut before = t.characters.chars().skip(line.char_start.saturating_sub(1)); + let wrapped = line.char_start > 0 && before.next() != Some('\n'); + wrapped + && t.characters + .chars() + .skip(line.char_start) + .take(line.char_end.saturating_sub(line.char_start)) + .collect::() + .split_whitespace() + .count() + == 1 + }); + (l.lines.len(), l.height, orphan) +} + +fn fit(s: &mut Session, p: &Value) -> Result { + let ids = req_targets(CMD, s, p)?; + let max_lines = num(p, "maxLines").filter(|v| v.is_finite() && *v >= 1.0).map(|v| v.min(1000.0) as usize); + let orphans = boolean(p, "orphans").unwrap_or(true); + let mut out = Vec::new(); + let doc = s.doc_mut()?; + for id in ids { + let n = doc.node(id).ok_or_else(|| bad(CMD, format!("no layer {id}")))?; + let NodeKind::Text(t) = &n.kind else { return Err(bad(CMD, format!("{id} is not text"))) }; + if t.auto_resize == TextAutoResize::WidthAndHeight { + return Err(bad(CMD, format!("{id} grows to its text: give it a fixed width (auto height or fixed) first"))); + } + let (width, height_cap) = (n.width, if t.auto_resize == TextAutoResize::None { n.height } else { f64::INFINITY }); + let size = t.style.font_size.max(1.0); + let min = num(p, "minSize").filter(|v| v.is_finite() && *v >= 1.0).unwrap_or(size * 0.6).min(size); + let fits = |m: (usize, f64, bool)| max_lines.is_none_or(|ml| m.0 <= ml) && m.1 <= height_cap + 0.5; + let k = largest_fit(t, width, (size, min), &fits); + let (k, nbsp) = if orphans { avoid_orphan(t, k, width, (size, min)) } else { (k, false) }; + let n = doc.get_mut(id)?; + let NodeKind::Text(t) = &mut n.kind else { continue }; + let count = t.char_count(); + if k < 1.0 { + // Each run rounds to a tenth, but not below its share of the floor. + t.restyle(0, count, |st| { + let floor = st.font_size * min / size; + st.font_size = ((st.font_size * k * 10.0).round() / 10.0).max(floor).clamp(1.0, 5000.0) + }); + } + let after = if nbsp { keep_together(t, width, &fits) } else { measure(t, 1.0, width) }; + // Reported from the final text (rounded sizes, the no-break space), which callers rely on. + out.push(json!({"id": id.0, "fontSize": (t.style.font_size * 10.0).round() / 10.0, "lines": after.0, "fits": fits(after), "orphan": orphans && after.2})); + } + Ok(json!({"fitted": out})) +} + +/// The largest factor in [min/size, 1] at which `t` fits: a binary search to a tenth of a pixel, +/// then rounded down to a whole-tenth size (which reads better in the panel) but never below the +/// floor. Text that fits keeps its exact size. +fn largest_fit(t: &TextData, width: f64, (size, min): (f64, f64), fits: &dyn Fn((usize, f64, bool)) -> bool) -> f64 { + let (mut lo, mut hi) = (min / size, 1.0); + if fits(measure(t, 1.0, width)) { + return 1.0; + } + if fits(measure(t, lo, width)) { + for _ in 0..24 { + let mid = (lo + hi) / 2.0; + if fits(measure(t, mid, width)) { + lo = mid + } else { + hi = mid + } + if (hi - lo) * size < 0.1 { + break; + } + } + } + ((size * lo * 10.0).floor() / 10.0).max(min) / size +} + +/// Up to 8% smaller than `k` (not below the floor) may pull a word down to an orphaned last line. +/// The factor, and whether an orphan is left (to be kept together with a no-break space). +fn avoid_orphan(t: &TextData, mut k: f64, width: f64, (size, min): (f64, f64)) -> (f64, bool) { + let mut orphan = measure(t, k, width).2; + let mut tries = 0; + while orphan && tries < 8 && size * k * 0.99 >= min { + k *= 0.99; + orphan = measure(t, k, width).2; + tries += 1; + } + (k, orphan) +} + +/// Keep the last two words together with a no-break space, unless that breaks the limit (an +/// orphan is better). Same character count, so style runs keep their ranges. The final layout. +fn keep_together(t: &mut TextData, width: f64, fits: &dyn Fn((usize, f64, bool)) -> bool) -> (usize, f64, bool) { + // Only inside the last paragraph: a space before a hard break joins nothing on the last line. + let para = t.characters.rfind('\n').map_or(0, |i| i + 1); + let Some(i) = t.characters.trim_end().get(para..).and_then(|p| p.rfind(' ')).map(|i| para + i) else { + return measure(t, 1.0, width); + }; + t.characters.replace_range(i..i + 1, "\u{a0}"); + let after = measure(t, 1.0, width); + if fits(after) { + return after; + } + t.characters.replace_range(i..i + '\u{a0}'.len_utf8(), " "); + measure(t, 1.0, width) +} diff --git a/crates/engine/src/textfit_tests.rs b/crates/engine/src/textfit_tests.rs new file mode 100644 index 00000000..e35f810c --- /dev/null +++ b/crates/engine/src/textfit_tests.rs @@ -0,0 +1,135 @@ +//! `text.fit`: the largest size that fits the box and a line limit, orphans avoided, and +//! data.fill sizing each copy's text. + +use crate::Session; +use crate::components::tests::{ex, idv}; +use figcraft_doc::{NodeId, NodeKind}; +use serde_json::json; + +/// A fixed-width (auto-height) headline of `width` px at 64px. Returns its id. +fn headline(s: &mut Session, text: &str, width: f64) -> NodeId { + figcraft_text::scan_system_fonts(); + let t = idv(&ex(s, "text.create", json!({"text": text, "x": 0, "y": 0, "props": {"name": "Headline", "fontSize": 64}}))); + ex(s, "node.set", json!({"id": t, "props": {"width": width, "textAutoResize": "height"}})); + t +} + +fn size(s: &Session, id: NodeId) -> f64 { + match &s.doc().unwrap().node(id).unwrap().kind { + NodeKind::Text(t) => t.style.font_size, + _ => 0.0, + } +} + +#[test] +fn text_shrinks_to_its_line_limit_and_no_further() { + let mut s = Session::new(); + let t = headline(&mut s, "Seu site e seu WhatsApp com IA, prontos em 7 dias", 600.0); + let r = ex(&mut s, "text.fit", json!({"ids": [t], "maxLines": 2})); + let f = &r["fitted"][0]; + assert_eq!(f["lines"], 2, "{r}"); + assert!(f["fits"].as_bool().unwrap()); + let fitted = size(&s, t); + assert!((64.0 * 0.6..64.0).contains(&fitted), "{fitted}"); + // The largest that fits: a pixel more breaks the limit. + ex(&mut s, "node.set", json!({"id": t, "props": {"fontSize": fitted + 1.0}})); + let again = ex(&mut s, "text.fit", json!({"ids": [t], "maxLines": 2})); + assert!((again["fitted"][0]["fontSize"].as_f64().unwrap() - fitted).abs() <= 0.2, "{again}"); + // Text that already fits keeps its size; one undo restores the original. + let short = headline(&mut s, "7 dias", 600.0); + ex(&mut s, "text.fit", json!({"ids": [short], "maxLines": 1})); + assert_eq!(size(&s, short), 64.0); + // A floor that can't fit reports it instead of going below (fit never grows text). + ex(&mut s, "node.set", json!({"id": t, "props": {"fontSize": 64}})); + let r = ex(&mut s, "text.fit", json!({"ids": [t], "maxLines": 1, "minSize": 50})); + assert_eq!(r["fitted"][0]["fits"], false); + assert_eq!(size(&s, t), 50.0); + // Text that grows to its content has no box to fit. + let auto = idv(&ex(&mut s, "text.create", json!({"text": "x", "x": 0, "y": 0}))); + assert!(s.execute("text.fit", &json!({"ids": [auto]})).is_err()); +} + +#[test] +fn a_one_word_last_line_is_avoided() { + let mut s = Session::new(); + // At 64px in 520px this breaks with "1s." alone on the last line. + let t = headline(&mut s, "Lighthouse 100, abre em menos de 1s.", 520.0); + let r = ex(&mut s, "text.fit", json!({"ids": [t]})); + assert_eq!(r["fitted"][0]["orphan"], false, "{r}"); + let doc = s.doc().unwrap(); + let NodeKind::Text(tx) = &doc.node(t).unwrap().kind else { panic!() }; + let l = figcraft_text::layout(tx, Some(520.0), None); + let last = l.lines.last().unwrap(); + let words = tx.characters.chars().skip(last.char_start).take(last.char_end - last.char_start).collect::(); + assert!(l.lines.len() == 1 || words.split_whitespace().count() > 1 || words.contains('\u{a0}'), "{words:?}"); +} + +/// Asserts `data.fill` keeps a short copy's font size, shrinks a long copy, and leaves the +/// source text layer untouched. +#[test] +fn data_fill_sizes_each_copys_text() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 1080, "height": 1350, "name": "Post"}))); + let t = idv(&ex(&mut s, "text.create", json!({"text": "Curto", "x": 80, "y": 80, "parent": f, "props": {"name": "Headline", "fontSize": 96}}))); + ex(&mut s, "node.set", json!({"id": t, "props": {"width": 920, "textAutoResize": "height"}})); + let r = ex( + &mut s, + "data.fill", + json!({"id": f, "rows": [ + {"Headline": {"text": "Curto", "fit": {"maxLines": 2}}}, + {"Headline": {"text": "Landing page e atendente de WhatsApp com IA, prontos em sete dias úteis", "fit": {"maxLines": 2}}} + ]}), + ); + let ids: Vec = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect(); + let head = |s: &Session, frame: NodeId| s.doc().unwrap().node(frame).unwrap().children[0]; + assert_eq!(size(&s, head(&s, ids[0])), 96.0, "short copy keeps its size"); + assert!(size(&s, head(&s, ids[1])) < 96.0, "long copy shrinks"); + assert_eq!(size(&s, t), 96.0, "the source is untouched"); +} + +#[test] +fn review_fits_is_reported_from_the_final_text() { + let mut s = Session::new(); + for (text, width, lines) in [ + ("Seu site e seu WhatsApp com IA, prontos em 7 dias", 600.0, 2.0), + ("Lighthouse 100, abre em menos de 1s.", 520.0, 2.0), + ("Curto", 300.0, 1.0), + ] { + let t = headline(&mut s, text, width); + let r = ex(&mut s, "text.fit", json!({"ids": [t], "maxLines": lines})); + let f = &r["fitted"][0]; + let doc = s.doc().unwrap(); + let NodeKind::Text(tx) = &doc.node(t).unwrap().kind else { panic!() }; + let real = figcraft_text::layout(tx, Some(width), None).lines.len(); + assert_eq!(f["lines"].as_u64().unwrap() as usize, real, "{r}"); + assert_eq!(f["fits"].as_bool().unwrap(), real as f64 <= lines, "{r}"); + } +} + +#[test] +fn review_sizes_off_the_tenth_grid_keep_their_size_and_floor() { + let mut s = Session::new(); + // Fits already: 14.25px stays 14.25px, not 14.2 or 14.3. + let t = headline(&mut s, "Curto", 600.0); + ex(&mut s, "node.set", json!({"id": t, "props": {"fontSize": 14.25}})); + ex(&mut s, "text.fit", json!({"ids": [t], "maxLines": 1})); + assert_eq!(size(&s, t), 14.25); + // Can't fit: it stops at the floor, not a tenth below it. + let long = headline(&mut s, "Seu site e seu WhatsApp com IA, prontos em 7 dias", 200.0); + let r = ex(&mut s, "text.fit", json!({"ids": [long], "maxLines": 1, "minSize": 12.34})); + assert_eq!(r["fitted"][0]["fits"], false, "{r}"); + assert!(size(&s, long) >= 12.34, "{}", size(&s, long)); +} + +#[test] +fn review_a_one_word_paragraph_is_not_an_orphan() { + let mut s = Session::new(); + let t = headline(&mut s, "Two words\nOne", 600.0); + let r = ex(&mut s, "text.fit", json!({"ids": [t]})); + assert_eq!(r["fitted"][0]["orphan"], false, "{r}"); + assert_eq!(size(&s, t), 64.0, "nothing to fix, so nothing shrinks"); + let doc = s.doc().unwrap(); + let NodeKind::Text(tx) = &doc.node(t).unwrap().kind else { panic!() }; + assert_eq!(tx.characters, "Two words\nOne", "no no-break space in an earlier paragraph"); +} diff --git a/crates/engine/src/token_roles.rs b/crates/engine/src/token_roles.rs new file mode 100644 index 00000000..3827ecbe --- /dev/null +++ b/crates/engine/src/token_roles.rs @@ -0,0 +1,99 @@ +//! What a file's number variables measure, so exported tokens carry the right unit: a radius, +//! font size, spacing or other length is in pixels, opacity and font weight are bare numbers. +//! A number's role comes from the layer fields bound to it, else its scopes, and an alias target +//! with no role of its own takes the role of the variables that read it (the target of an opacity +//! alias stays unitless). A number nothing tells about is a length: design tokens mostly are. + +use std::collections::HashMap; + +use figcraft_doc::{Document, Variable, VariableId, VariableType, VariableValue}; + +use crate::codegen::num; + +/// What a number variable measures. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NumberRole { + Radius, + FontSize, + Spacing, + /// Another length (stroke, line height, letter spacing, effects): pixels. + Length, + /// Opacity or font weight: no unit. + Unitless, +} + +fn role_of_field(field: &str) -> Option { + Some(match field { + "cornerRadius" | "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => NumberRole::Radius, + "fontSize" => NumberRole::FontSize, + "opacity" | "fontWeight" => NumberRole::Unitless, + f if f == "gap" || f == "counterAxisSpacing" || f.starts_with("padding") || f == "width" || f == "height" => NumberRole::Spacing, + f if f.starts_with("min") || f.starts_with("max") => NumberRole::Spacing, + "strokeWeight" | "lineHeight" | "letterSpacing" | "paragraphSpacing" | "paragraphIndent" => NumberRole::Length, + _ => return None, + }) +} + +fn role_of_scope(scope: &str) -> Option { + Some(match scope { + "CORNER_RADIUS" => NumberRole::Radius, + "FONT_SIZE" => NumberRole::FontSize, + "GAP" | "WIDTH_HEIGHT" => NumberRole::Spacing, + "OPACITY" | "FONT_WEIGHT" => NumberRole::Unitless, + "STROKE_FLOAT" | "EFFECT_FLOAT" | "LINE_HEIGHT" | "LETTER_SPACING" | "PARAGRAPH_SPACING" | "PARAGRAPH_INDENT" => NumberRole::Length, + _ => return None, + }) +} + +/// The roles of a file's number variables, computed once per export. +#[derive(Default)] +pub struct Roles(HashMap); + +impl Roles { + pub fn new(doc: &Document) -> Self { + let vars = &doc.variables.variables; + let mut roles: HashMap = HashMap::new(); + for n in doc.nodes() { + for (field, id) in &n.bound { + if let Some(r) = role_of_field(field) { + roles.entry(id.clone()).or_insert(r); + } + } + } + for v in vars.iter().filter(|v| v.kind == VariableType::Number) { + if let Some(r) = v.scopes.as_ref().and_then(|s| s.iter().find_map(|s| role_of_scope(s))) { + roles.entry(v.id.clone()).or_insert(r); + } + } + // Until a pass adds nothing: each pass adds a role or stops, so this ends within one pass + // per variable, however long a chain is. + loop { + let mut changed = false; + for v in vars { + let Some(r) = roles.get(&v.id).copied() else { continue }; + for value in v.values.values() { + if let VariableValue::Alias(target) = value + && !roles.contains_key(target) + { + roles.insert(target.clone(), r); + changed = true; + } + } + } + if !changed { + break; + } + } + Roles(roles) + } + + /// What `v` measures; a number nothing tells about is a length. + pub fn role(&self, v: &Variable) -> NumberRole { + self.0.get(&v.id).copied().unwrap_or(NumberRole::Length) + } + + /// A number variable's CSS value: pixels for lengths, bare for opacity and font weight. + pub fn number_css(&self, v: &Variable, x: f64) -> String { + if self.role(v) == NumberRole::Unitless { num(x) } else { format!("{}px", num(x)) } + } +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 86feed4f..b6a6d101 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -36,6 +36,7 @@ pub enum ToolId { Pen, Pencil, Text, + TextOnPath, Comment, Eyedropper, ShapeBuilder, @@ -50,7 +51,7 @@ pub enum ToolId { } impl ToolId { - pub const ALL: [ToolId; 25] = [ + pub const ALL: [ToolId; 26] = [ ToolId::Move, ToolId::Scale, ToolId::Hand, @@ -66,6 +67,7 @@ impl ToolId { ToolId::Pen, ToolId::Pencil, ToolId::Text, + ToolId::TextOnPath, ToolId::Comment, ToolId::Eyedropper, ToolId::ShapeBuilder, @@ -94,6 +96,7 @@ impl ToolId { ToolId::Pen => "pen", ToolId::Pencil => "pencil", ToolId::Text => "text", + ToolId::TextOnPath => "textOnPath", ToolId::Comment => "comment", ToolId::Eyedropper => "eyedropper", ToolId::ShapeBuilder => "shapeBuilder", @@ -126,6 +129,7 @@ impl ToolId { ToolId::Pen => "Pen", ToolId::Pencil => "Pencil", ToolId::Text => "Text", + ToolId::TextOnPath => "Text on a path", ToolId::Comment => "Comment", ToolId::Eyedropper => "Pick color", ToolId::ShapeBuilder => "Shape builder", @@ -277,7 +281,8 @@ impl ToolId { ToolId::Pen => Cursor::Pen(PenCursor::New), ToolId::Comment => Cursor::Comment, ToolId::Eyedropper => Cursor::Eyedropper, - ToolId::Move | ToolId::Scale => Cursor::Default, + // Text on a path acts only over a path, where it shows the I-beam. + ToolId::Move | ToolId::Scale | ToolId::TextOnPath => Cursor::Default, // The other tools make something where they are pressed: shapes, frames, slices, pencil // strokes, a new text (over a text the Text tool shows the I-beam instead). _ => Cursor::Crosshair, @@ -643,6 +648,31 @@ pub(crate) const SNAP_PX: f64 = 5.0; /// Screen pixels the pointer travels after a press before a move or resize starts: a click or a /// double-click made with an unsteady hand doesn't drag what it lands on. const DRAG_SLOP_PX: f64 = 3.0; +/// Screen pixels from a path within which the Text on a path tool takes it. +const PATH_REACH_PX: f64 = 6.0; + +/// The visible layers under `id` whose box, grown by `reach` (page units), holds `p`, front-most +/// first: what the Text on a path tool measures its distance to. Content a clipping frame or a +/// mask hides at `p` and the operands of boolean groups are left out. +fn path_candidates(doc: &Document, id: NodeId, p: Point, reach: f64, depth: usize, out: &mut Vec) { + let Some(n) = doc.node(id) else { return }; + if !n.visible || depth > 512 || matches!(n.kind, NodeKind::BooleanOp { .. }) { + return; + } + if n.clip && n.kind.is_frame_like() && !n.local_rect().contains(doc.world_transform(id).inverse() * p) { + return; + } + for c in n.paint_order().rev() { + let before = out.len(); + path_candidates(doc, c, p, reach, depth + 1, out); + if out.len() > before && figcraft_doc::geometry::masked_out(doc, n, c, p) { + out.truncate(before); + } + } + if n.children.is_empty() && !n.is_mask && doc.world_bounds(id).is_some_and(|b| b.inflate(reach, reach).contains(p)) { + out.push(id); + } +} /// Corners of the selection's handle box in page coordinates (tl, tr, br, bl). pub fn selection_box(doc: &Document, sel: &[NodeId]) -> Option<[Point; 4]> { @@ -941,6 +971,16 @@ pub(crate) fn scale_factor(handle: Handle, q: Point, w0: f64, h0: f64, anchor: ( } impl Session { + /// The pointer left the canvas: what hovering there showed goes (the distances Alt or Dev Mode + /// measured and the box measured to, a vector region's highlight), unless a gesture holds it. + pub fn pointer_left(&mut self) { + if !self.tool.busy() { + self.tool.overlay.guides.clear(); + self.tool.overlay.measure_box = None; + } + self.vector_pointer_left(); + } + /// What a marquee starting at page point `p` selects inside: the top-level frame it starts in /// (its children), or the focus view's frame. pub(crate) fn marquee_within(&self, p: Point) -> Result> { @@ -996,7 +1036,7 @@ impl Session { let _ = crate::shapebuilder::arrangement(self, &sel); } if let Ok(ds) = self.ds_mut() { - if t != ToolId::Text && t != ToolId::Move { + if !matches!(t, ToolId::Text | ToolId::TextOnPath | ToolId::Move) { ds.text_edit = None; } ds.revision += 1; @@ -1056,6 +1096,7 @@ impl Session { Ok(()) } ToolId::Text => self.text_down(ev), + ToolId::TextOnPath => self.text_on_path_down(ev), ToolId::Pen => self.pen_down(ev), ToolId::Pencil => { self.tool.gesture = Gesture::Pencil { points: vec![ev.p()], pressure: vec![ev.pressure] }; @@ -1111,6 +1152,9 @@ impl Session { if tool == ToolId::Text && (on_edited || on_text(self)) { cursor = Cursor::Text; } + if tool == ToolId::TextOnPath && (on_edited || self.path_at(&doc, page, ev).is_some()) { + cursor = Cursor::Text; + } let mut hover = None; let mut guides = Vec::new(); let mut measure_box = None; @@ -1976,6 +2020,10 @@ impl Session { Ok(()) } + /// Handle a double-click gesture: finish a pen path, rename or click labels/widgets, + /// cancel an in-progress move/resize/draw/marquee, handle board double-clicks, enter text + /// editing (select-all on entry, word-select inside the running edit), enter vector editing + /// for selected shapes, and step groups/frames one level deeper. fn double_click(&mut self, ev: &PointerEvent) -> Result<()> { let ds = self.ds()?; let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page); @@ -2025,13 +2073,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(()); @@ -2124,6 +2174,115 @@ impl Session { Ok(()) } + /// The layer whose path is under `ev` for the Text on a path tool and where along it the press + /// is (0..1 by length): the front-most shape or vector layer within a few screen pixels of its + /// first subpath (the one text follows), not locked and not inside an instance. Layers in front + /// whose path is further away don't hide it. + pub(crate) fn path_at(&self, doc: &Document, page: NodeId, ev: &PointerEvent) -> Option<(NodeId, f64)> { + let reach = ev.tol() * PATH_REACH_PX; + let near = |id: NodeId| -> Option<(NodeId, f64)> { + let n = doc.node(id)?; + let shape = matches!( + n.kind, + NodeKind::Rectangle + | NodeKind::Ellipse { .. } + | NodeKind::Polygon { .. } + | NodeKind::Star { .. } + | NodeKind::Line + | NodeKind::Vector { .. } + ); + if !shape || locked_in(doc, id) || crate::components::inside_instance(doc, id) { + return None; + } + let geom = figcraft_doc::geometry::stroke_geometry(n)?; + let line = figcraft_geom::measure::polylines(&geom)?.into_iter().next()?; + // Measured on the page (a layer scaled more one way than the other is still reached + // where it shows), the start by length along the layer's own path. + let world = doc.world_transform(id); + let p = ev.p(); + let mut best: Option<(f64, f64)> = None; + for (i, w) in line.points.windows(2).enumerate() { + let (a, b) = (world * w[0], world * w[1]); + let d = b - a; + let l2 = d.hypot2(); + let u = if l2 > 1e-18 { ((p - a).dot(d) / l2).clamp(0.0, 1.0) } else { 0.0 }; + let dist = (a.lerp(b, u) - p).hypot(); + if best.is_none_or(|(_, bd)| dist < bd) { + let (la, lb) = (*line.lengths.get(i)?, *line.lengths.get(i + 1)?); + best = Some((la + u * (lb - la), dist)); + } + } + let (along, dist) = best?; + let len = line.length(); + (len > 1e-9 && dist <= reach).then_some((id, along / len)) + }; + let mut candidates = Vec::new(); + path_candidates(doc, page, ev.p(), reach, 0, &mut candidates); + candidates.into_iter().find_map(near) + } + + /// A press with the Text on a path tool: on a path, a new empty text layer goes onto it, + /// starting where it was pressed, and typing starts. The text takes the path layer's place, + /// geometry, transform, constraints and effects, and its fills when one of them shows; the path + /// layer is removed. Ending the edit with nothing typed puts the path back without an undo step + /// ([`textedit::end`]). Elsewhere a press does nothing. + fn text_on_path_down(&mut self, ev: &PointerEvent) -> Result<()> { + if let Some(te) = self.ds()?.text_edit.clone() { + if self.ds()?.doc.world_bounds(te.id).is_some_and(|b| b.contains(ev.p())) { + return self.text_click(te.id, ev, ev.shift); + } + textedit::end(self)?; + } + let ds = self.ds()?; + let (doc, page) = (ds.doc.clone(), ds.page); + let Some((path, start)) = self.path_at(&doc, page, ev) else { return Ok(()) }; + let Some(pn) = doc.node(path) else { return Ok(()) }; + let (Some(parent), Some(geom)) = (pn.parent, figcraft_doc::geometry::stroke_geometry(pn)) else { return Ok(()) }; + let index = doc.index_in_parent(path); + self.begin_interaction("Text on a path")?; + let ds = self.ds_mut()?; + let d = ds.doc_mut(); + let id = d.alloc_id(); + let mut n = Node::new(id, NodeKind::Text(TextData::default()), "Text", pn.width, pn.height); + n.name = String::new(); + n.transform = pn.transform; + n.constraints = pn.constraints; + n.child_layout = pn.child_layout.clone(); + let effects = !pn.effects.is_empty(); + if effects { + n.effects = pn.effects.clone(); + n.styles.effect = pn.styles.effect.clone(); + } + // A path drawn with a stroke only has no fill to give: the text gets the usual one. + let filled = pn.fills.iter().any(|f| f.visible); + if filled { + n.fills = pn.fills.clone(); + n.styles.fill = pn.styles.fill.clone(); + } else if let Some(fills) = readable_text_fills(d, parent) { + n.fills = fills; + } + // As `text.onPath`: a binding goes with the list it binds into (`fill`, `fills`, `fills/N/…`). + let carried = |k: &str| match crate::cmd::lists::list_binding(k) { + Some((crate::cmd::lists::BoundList::Fills, ..)) => filled, + Some((crate::cmd::lists::BoundList::Effects, ..)) => effects, + _ => false, + }; + n.bound = pn.bound.iter().filter(|(k, _)| carried(k)).map(|(k, v)| (k.clone(), v.clone())).collect(); + if let NodeKind::Text(t) = &mut n.kind { + let mut tp = TextPath::new(&geom); + tp.start = start; + t.path = Some(Box::new(tp)); + } + d.insert(n, parent, index)?; + d.remove(path)?; + ds.selection = vec![id]; + textedit::begin(self, id, Some(0), true)?; + if !self.prefs.keep_tool { + self.tool.tool = ToolId::Move; + } + Ok(()) + } + /// Drag out a new text box: past the drag slop it has the drag's size (at least one line /// tall) and its top-left, and that size is fixed. fn text_box_drag(&mut self, ev: &PointerEvent, id: NodeId, start: Point, line: f64) -> Result<()> { diff --git a/crates/engine/src/variables_io_tests.rs b/crates/engine/src/variables_io_tests.rs index 3a57d9ea..5eea5b30 100644 --- a/crates/engine/src/variables_io_tests.rs +++ b/crates/engine/src/variables_io_tests.rs @@ -214,9 +214,9 @@ fn variables_export_and_import_round_trip() { let css = ex(&mut s, "variable.export", json!({"format": "css", "collections": ["Theme"]}))["text"].as_str().unwrap().to_string(); for want in [ ":root {\n", - " --radius: 4;\n", + " --radius: 4px;\n", "[data-theme=\"Dark\"] {\n", - " --radius: 6.5;\n", + " --radius: 6.5px;\n", " --accent: var(--color-primary);\n", " --label: \"Say hi \";\n", ] { @@ -397,3 +397,39 @@ fn paints_replaced_through_node_set_keep_unchanged_stop_bindings() { assert_eq!(stops(&s, r), ["FFFFFF", "0000FF"]); healthy(&s); } + +#[test] +fn css_export_gives_lengths_a_unit_and_keeps_opacity_bare() { + let mut s = Session::new(); + ex(&mut s, "variable.createCollection", json!({"name": "Brand"})); + ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "card", "type": "number", "value": 12})); + ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "fade", "type": "number", "value": 0.5})); + ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "half", "type": "number", "value": 0.5})); + ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "dim", "type": "number", "value": 1})); + ex(&mut s, "variable.set", json!({"variable": "dim", "value": {"alias": "half"}})); + let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100}))); + let g = idv(&ex(&mut s, "frame.create", json!({"x": 200, "y": 0, "width": 100, "height": 100}))); + ex(&mut s, "node.set", json!({"id": f, "props": {"cornerRadius": 12, "opacity": 0.5}})); + ex(&mut s, "variable.bind", json!({"ids": [f], "field": "cornerRadius", "variable": "card"})); + ex(&mut s, "variable.bind", json!({"ids": [f], "field": "opacity", "variable": "fade"})); + ex(&mut s, "variable.bind", json!({"ids": [g], "field": "opacity", "variable": "dim"})); + let css = ex(&mut s, "variable.export", json!({"format": "css"}))["text"].as_str().unwrap_or_default().to_string(); + // A radius is a length; opacity stays a number, also through an alias it reads. + assert!(css.contains("--card: 12px;") && css.contains("--fade: 0.5;") && css.contains("--half: 0.5;"), "{css}"); +} + +#[test] +fn review_a_long_alias_chain_keeps_its_role_to_the_end() { + let mut s = Session::new(); + ex(&mut s, "variable.createCollection", json!({"name": "Brand"})); + ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "o0", "type": "number", "value": 0.5})); + // o12 -> o11 -> … -> o0: longer than any fixed number of rounds would reach. + for i in 1..=12 { + ex(&mut s, "variable.create", json!({"collection": "Brand", "name": format!("o{i}"), "type": "number", "value": 1})); + ex(&mut s, "variable.set", json!({"variable": format!("o{i}"), "value": {"alias": format!("o{}", i - 1)}})); + } + let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100}))); + ex(&mut s, "variable.bind", json!({"ids": [f], "field": "opacity", "variable": "o12"})); + let css = ex(&mut s, "variable.export", json!({"format": "css"}))["text"].as_str().unwrap_or_default().to_string(); + assert!(css.contains("--o0: 0.5;"), "{css}"); +} diff --git a/crates/engine/src/vector_path_set_tests.rs b/crates/engine/src/vector_path_set_tests.rs new file mode 100644 index 00000000..7e714853 --- /dev/null +++ b/crates/engine/src/vector_path_set_tests.rs @@ -0,0 +1,263 @@ +//! `node.set {path}` replaces a vector's geometry and keeps the layer (#1001): its id, name, place in +//! the layer list, paints, effects, styles, bindings and constraints. The path is read as +//! `node.create` reads it (`width`/`height` scale it), its own box goes at the layer's origin (or at +//! `x`/`y` plus the box's offset, as create places it), undo brings the old path back, and data that +//! can't draw, layers that aren't vectors and layers inside an instance are refused. + +use figcraft_doc::{NodeId, NodeKind, VectorNetwork}; +use serde_json::{Value, json}; + +use crate::{EngineError, Session}; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn id(v: &Value) -> NodeId { + crate::params::id_of(&v["id"]).unwrap() +} + +fn vector(s: &mut Session, p: Value) -> NodeId { + let mut p = p; + p["type"] = json!("vector"); + id(&ex(s, "node.create", p)) +} + +fn net(s: &Session, v: NodeId) -> VectorNetwork { + crate::vectoredit::network(s.doc().unwrap(), v).unwrap().clone() +} + +/// The layer's x, y, width and height as `get_node` reports them. +fn frame(s: &Session, v: NodeId) -> (f64, f64, f64, f64) { + let d = crate::cmd::props::describe(s.doc().unwrap(), v); + let f = |k: &str| d[k].as_f64().unwrap(); + (f("x"), f("y"), f("width"), f("height")) +} + +fn bad_msg(r: crate::Result) -> String { + match r { + Err(EngineError::BadParams { msg, .. }) => msg, + other => panic!("expected BadParams, got {other:?}"), + } +} + +#[test] +fn a_new_path_keeps_the_layer() { + let mut s = Session::new(); + ex(&mut s, "variable.createCollection", json!({"name": "T"})); + ex(&mut s, "variable.create", json!({"collection": "T", "name": "brand", "type": "color", "value": "#FF0000"})); + let below = vector(&mut s, json!({"path": "M0 0 L5 5"})); + let v = vector(&mut s, json!({"name": "Chart line", "path": "M0 0 L10 10", "x": 40, "y": 30})); + let above = vector(&mut s, json!({"path": "M0 0 L5 5"})); + ex( + &mut s, + "node.set", + json!({"id": v, "props": {"stroke": "#00FF00", "strokeWeight": 3, "opacity": 0.5, + "effects": [{"type": "dropShadow", "color": "#00000040", "offset": [0, 2], "radius": 4}], + "constraints": {"horizontal": "right", "vertical": "bottom"}}}), + ); + ex(&mut s, "variable.bind", json!({"ids": [v], "field": "fill", "variable": "brand"})); + let before = s.doc().unwrap().node(v).unwrap().clone(); + + let out = ex(&mut s, "node.set", json!({"ids": [v], "props": {"path": "M0 0 L20 0 L20 8 Z"}})); + assert!(out.get("warnings").is_none(), "{out}"); + let doc = s.doc().unwrap(); + let n = doc.node(v).unwrap(); + assert_eq!((n.id, n.name.as_str()), (v, "Chart line")); + assert_eq!((&n.fills, &n.strokes, &n.stroke, &n.effects), (&before.fills, &before.strokes, &before.stroke, &before.effects)); + assert_eq!((n.opacity, &n.bound, &n.styles, &n.constraints), (before.opacity, &before.bound, &before.styles, &before.constraints)); + assert!(n.bound.contains_key("fill"), "the fill stays bound"); + let page = doc.node(n.parent.unwrap()).unwrap(); + assert_eq!(page.children, [below, v, above], "same place in the layer list"); + // The new geometry, at the layer's origin. + assert_eq!(frame(&s, v), (40.0, 30.0, 20.0, 8.0)); + let net = net(&s, v); + assert_eq!((net.vertices.len(), net.regions.len()), (3, 1), "a closed triangle: {net:?}"); +} + +#[test] +fn width_height_and_x_y_follow_node_create() { + // Each row: props given with the path, and the frame node.set gives a layer at (100, 50). + let rows: [(Value, (f64, f64, f64, f64)); 6] = [ + // The path's own box goes at the layer's origin: its offset (5, 5) doesn't move it. + (json!({}), (100.0, 50.0, 10.0, 20.0)), + // Both sides scale independently; one keeps the proportion. + (json!({"width": 30, "height": 10}), (100.0, 50.0, 30.0, 10.0)), + (json!({"w": 20}), (100.0, 50.0, 20.0, 40.0)), + // With x/y the path's offset is added, as create places it. + (json!({"x": 0, "y": 0}), (5.0, 5.0, 10.0, 20.0)), + (json!({"x": 10, "height": 10}), (15.0, 50.0, 5.0, 10.0)), + (json!({"x": 10, "y": 20, "width": 20, "height": 10}), (15.0, 25.0, 20.0, 10.0)), + ]; + for (extra, want) in rows { + let mut s = Session::new(); + let v = vector(&mut s, json!({"path": "M0 0 L1 1", "x": 100, "y": 50})); + let mut props = json!({"path": "M5 5 L15 5 L15 25 Z"}); + props.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + ex(&mut s, "node.set", json!({"ids": [v], "props": props})); + assert_eq!(frame(&s, v), want, "{extra}"); + // With both x and y, create makes the same layer. (With one, the other axis keeps the + // layer's current place.) + if extra.get("x").is_some() && extra.get("y").is_some() { + let c = vector(&mut s, props.clone()); + assert_eq!(frame(&s, c), want, "create {extra}"); + } + } +} + +#[test] +fn rotation_stays_and_turns_about_the_new_box() { + let mut s = Session::new(); + let v = vector(&mut s, json!({"path": "M0 0 L10 10", "x": 0, "y": 0})); + ex(&mut s, "node.set", json!({"ids": [v], "props": {"rotation": 90}})); + let t = s.doc().unwrap().node(v).unwrap().transform; + ex(&mut s, "node.set", json!({"ids": [v], "props": {"path": "M0 0 L40 0 L40 20 Z"}})); + let n = s.doc().unwrap().node(v).unwrap(); + assert_eq!(n.transform, t, "the layer keeps its origin and rotation"); + assert_eq!((n.width, n.height), (40.0, 20.0)); +} + +#[test] +fn undo_brings_the_old_path_back() { + let mut s = Session::new(); + let v = vector(&mut s, json!({"path": "M0 0 L10 10", "x": 3, "y": 4})); + let (old_net, old_frame) = (net(&s, v), frame(&s, v)); + ex(&mut s, "node.set", json!({"ids": [v], "props": {"path": "M0 0 C10 0 20 10 20 20", "width": 50}})); + let (new_net, new_frame) = (net(&s, v), frame(&s, v)); + assert_ne!(new_net, old_net); + ex(&mut s, "edit.undo", json!({})); + assert_eq!((net(&s, v), frame(&s, v)), (old_net, old_frame)); + ex(&mut s, "edit.redo", json!({})); + assert_eq!((net(&s, v), frame(&s, v)), (new_net, new_frame)); +} + +#[test] +fn data_that_cant_draw_is_refused_and_nothing_changes() { + let mut s = Session::new(); + let v = vector(&mut s, json!({"path": "M0 0 L10 10", "name": "V"})); + let before = s.doc().unwrap().node(v).unwrap().clone(); + for (props, says) in [ + (json!({"path": "not a path"}), "not valid SVG path data"), + (json!({"path": "M0 0 L10"}), "not valid SVG path data"), + (json!({"path": ""}), "draws nothing"), + (json!({"path": "M5 5"}), "draws nothing"), + (json!({"path": "M5 5 L5 5 Z"}), "draws nothing"), + (json!({"path": "M0 0 L1e308 0 L-1e308 0"}), "too large"), + (json!({"path": 5}), "must be SVG path data"), + (json!({"path": null}), "must be SVG path data"), + (json!({"path": "M0 0 L10 10", "width": 0}), "positive"), + (json!({"path": "M0 0 L10 10", "height": -4}), "positive"), + (json!({"path": "M0 0 L10 10", "width": "wide"}), "must be a number"), + // A size on an axis the path has none of. + (json!({"path": "M0 0 L10 0", "height": 5}), "no vertical extent"), + (json!({"path": "M0 0 L10 10", "name": "renamed", "width": -1}), "positive"), + ] { + let msg = bad_msg(s.execute("node.set", &json!({"ids": [v], "props": props}))); + assert!(msg.contains(says), "{props}: {msg}"); + assert_eq!(s.doc().unwrap().node(v).unwrap(), &before, "{props} changed nothing"); + } + // A horizontal line can still be given height 0, as create allows. + ex(&mut s, "node.set", json!({"ids": [v], "props": {"path": "M0 0 L10 0", "height": 0}})); + assert_eq!(frame(&s, v).2, 10.0); +} + +#[test] +fn layers_that_arent_vectors_are_refused_alone_and_skipped_among_vectors() { + let mut s = Session::new(); + let r = id(&ex(&mut s, "node.create", json!({"type": "rectangle", "name": "Box"}))); + let star = id(&ex(&mut s, "node.create", json!({"type": "star"}))); + let msg = bad_msg(s.execute("node.set", &json!({"ids": [r], "props": {"path": "M0 0 L5 5"}}))); + assert!(msg.contains("vector") && msg.contains("Box"), "{msg}"); + assert!(s.execute("node.set", &json!({"ids": [r, star], "props": {"path": "M0 0 L5 5"}})).is_err(), "no vector among them"); + assert!(matches!(s.doc().unwrap().node(r).unwrap().kind, NodeKind::Rectangle)); + + let v = vector(&mut s, json!({"path": "M0 0 L10 10"})); + let out = ex(&mut s, "node.set", json!({"ids": [r, v], "props": {"path": "M0 0 L30 0", "opacity": 0.5}})); + let w = out["warnings"][0].as_str().unwrap(); + assert!(w.contains("`path` skipped on") && w.contains("Box") && w.contains("vector"), "{w}"); + let doc = s.doc().unwrap(); + assert_eq!((doc.node(r).unwrap().width, doc.node(r).unwrap().opacity), (100.0, 0.5), "the rectangle gets the other keys"); + assert_eq!(frame(&s, v).2, 30.0); +} + +#[test] +fn a_vector_inside_an_instance_is_refused_and_its_main_reaches_it() { + let mut s = Session::new(); + let f = id(&ex(&mut s, "node.create", json!({"type": "frame", "width": 50, "height": 50}))); + let v = vector(&mut s, json!({"path": "M0 0 L10 10", "parent": f})); + let comp = id(&ex(&mut s, "component.create", json!({"ids": [f]}))); + let inst = id(&ex(&mut s, "instance.create", json!({"component": comp, "x": 200, "y": 0}))); + let copy = s.doc().unwrap().node(inst).unwrap().children[0]; + assert!(matches!(s.doc().unwrap().node(copy).unwrap().kind, NodeKind::Vector { .. })); + let msg = bad_msg(s.execute("node.set", &json!({"ids": [copy], "props": {"path": "M0 0 L30 0"}}))); + assert!(msg.contains("inside an instance"), "{msg}"); + // The main's vector takes it, and the instance follows. + let main_vector = s.doc().unwrap().node(comp).unwrap().children[0]; + assert_eq!(main_vector, v); + ex(&mut s, "node.set", json!({"ids": [main_vector], "props": {"path": "M0 0 L30 0 L30 12 Z"}})); + assert_eq!(net(&s, copy), net(&s, main_vector)); + assert_eq!((frame(&s, copy).2, frame(&s, copy).3), (30.0, 12.0)); +} + +#[test] +fn a_boolean_operand_takes_a_new_path_and_the_group_follows() { + let mut s = Session::new(); + let a = vector(&mut s, json!({"path": "M0 0 L40 0 L40 40 L0 40 Z"})); + let b = vector(&mut s, json!({"path": "M0 0 L40 0 L40 40 L0 40 Z", "x": 20, "y": 20})); + let g = id(&ex(&mut s, "bool.union", json!({"ids": [a, b]}))); + assert_eq!(s.doc().unwrap().node(b).unwrap().parent, Some(g)); + ex(&mut s, "node.set", json!({"ids": [b], "props": {"path": "M0 0 L100 0 L100 100 L0 100 Z"}})); + let bounds = s.doc().unwrap().world_bounds(g).unwrap(); + assert_eq!((bounds.width(), bounds.height()), (120.0, 120.0), "{bounds:?}"); +} + +#[test] +fn junk_paths_never_panic() { + let long = "M0 0 ".to_string() + &"L1 1 ".repeat(5000); + let junk = [ + json!("M"), + json!("Z"), + json!("M0 0 Z Z Z"), + json!("M NaN 0 L 1 1"), + json!("M inf 0 L 1 1"), + json!("M0 0 L1e308 1e308"), + json!("M-1e308 -1e308 L1e308 1e308"), + json!("M0 0 A 1e308 1e308 0 1 1 10 10"), + json!("M0 0 A 0 0 0 0 0 10 10"), + json!("m0 0 l1 1 h-1e308 v1e308 z"), + json!("M0 0 Q 5 5"), + json!("M0,0L10,10\u{0}"), + json!("\u{1F4A5}"), + json!(long), + json!([1, 2]), + json!({"d": "M0 0 L1 1"}), + json!(true), + ]; + let sizes = [json!(null), json!(1e308), json!(-1e308), json!(1e-308), json!("NaN"), json!([1])]; + let mut s = Session::new(); + let v = vector(&mut s, json!({"path": "M0 0 L10 10"})); + for path in &junk { + for size in &sizes { + for props in [json!({"path": path, "width": size}), json!({"path": path, "height": size, "x": size, "rotation": size})] { + if let Err(EngineError::Internal { msg, .. }) = s.execute("node.set", &json!({"ids": [v], "props": props})) { + panic!("node.set panicked on {props}: {msg}"); + } + let n = s.doc().unwrap().node(v).unwrap(); + assert!(n.width.is_finite() && n.height.is_finite() && n.transform.as_coeffs().iter().all(|c| c.is_finite()), "{props}"); + } + } + } +} + +#[test] +fn a_new_path_while_editing_drops_points_that_are_gone() { + let mut s = Session::new(); + let v = vector(&mut s, json!({"path": "M0 0 L10 0 L10 10 L0 10 Z"})); + ex(&mut s, "vector.edit.begin", json!({"id": v})); + ex(&mut s, "vector.edit.selectAll", json!({})); + ex(&mut s, "node.set", json!({"ids": [v], "props": {"path": "M0 0 L20 0"}})); + let ve = s.ds().unwrap().vector_edit.clone().expect("still editing"); + let n = net(&s, v); + assert!(ve.vertices.iter().all(|i| *i < n.vertices.len()) && ve.segments.iter().all(|i| *i < n.segments.len()), "{ve:?}"); + ex(&mut s, "vector.edit.state", json!({})); +} diff --git a/crates/engine/src/version_preview_tests.rs b/crates/engine/src/version_preview_tests.rs index e910006c..9423c34e 100644 --- a/crates/engine/src/version_preview_tests.rs +++ b/crates/engine/src/version_preview_tests.rs @@ -97,3 +97,22 @@ fn preview_keeps_the_unsaved_state_and_saving_saves_the_document() { assert_eq!(count(&s), three); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn a_refused_cut_leaves_the_clipboard_as_it_was() { + let mut s = Session::new(); + let a = rect(&mut s, 0.0); + s.execute("file.saveVersion", &json!({"name": "One"})).unwrap(); + let b = rect(&mut s, 20.0); + s.execute("edit.copy", &json!({"ids": [b]})).unwrap(); + let roots = |s: &Session| s.clipboard.nodes.iter().map(|(r, _)| *r).collect::>(); + assert_eq!(roots(&s), vec![b]); + let source = s.clipboard.source.clone().unwrap(); + let depth = s.undo_depth(); + s.execute("version.preview", &json!({"index": 0})).unwrap(); + let err = s.execute("edit.cut", &json!({"ids": [a]})).unwrap_err(); + assert!(matches!(err, EngineError::Disabled(..)), "{err:?}"); + assert_eq!(roots(&s), vec![b], "failed edit.cut changed the clipboard"); + assert!(std::sync::Arc::ptr_eq(s.clipboard.source.as_ref().unwrap(), &source)); + assert_eq!(s.undo_depth(), depth, "history untouched"); +} diff --git a/crates/engine/tests/it/selection_colors.rs b/crates/engine/tests/it/selection_colors.rs index 4e559d27..c3fc4f97 100644 --- a/crates/engine/tests/it/selection_colors.rs +++ b/crates/engine/tests/it/selection_colors.rs @@ -156,7 +156,9 @@ fn invalid_or_stale_targets_roll_back_atomically() { assert!(s.execute("selection.setPaint", &json!({"targets":red.targets,"paint":"#0000ff"})).is_err()); assert_eq!(fill(&s, a), "FF0000"); assert_eq!(s.undo_depth(), before); - for command in ["selection.setPaint", "selection.selectColor", "selection.applyColorStyle"] { + for command in + ["selection.setPaint", "selection.selectColor", "selection.applyColorStyle", "selection.bindColorVariable", "selection.detachColor"] + { for params in [json!({}), json!({"targets":[]}), json!({"targets":[{"id":a,"fills":true,"index":0,"range":[0,1]}]})] { assert!(s.execute(command, ¶ms).is_err()); } @@ -270,3 +272,152 @@ fn whole_text_fill_keeps_a_color_style_link() { run(&mut s, "style.update", json!({"style":style,"value":"#00ff00"})); assert_eq!(fill(&s, id), "00FF00"); } + +fn theme_variable(s: &mut Session, name: &str, hex: &str) -> Value { + if s.doc().unwrap().variables.collection("Theme").is_none() { + run(s, "variable.createCollection", json!({"name":"Theme"})); + } + run(s, "variable.create", json!({"collection":"Theme","name":name,"type":"color","value":hex}))["id"].clone() +} + +#[test] +fn applying_a_color_variable_binds_matching_fill_and_stroke_slots_only() { + let mut s = Session::new(); + let a = rectangle(&mut s, "#ff0000"); + let b = rectangle(&mut s, "#0000ff"); + run(&mut s, "stroke.add", json!({"id":b,"color":"#ff0000"})); + let v = theme_variable(&mut s, "Brand", "#00ff00"); + let red = group(&s, &[a, b], "FF0000"); + let before = s.undo_depth(); + run(&mut s, "selection.bindColorVariable", json!({"targets":red.targets,"variable":"Brand"})); + assert_eq!(fill(&s, a), "00FF00"); + assert_eq!(fill(&s, b), "0000FF"); + assert_eq!(s.doc().unwrap().node(b).unwrap().strokes[0].solid_color().unwrap().to_hex(), "00FF00"); + assert_eq!(s.doc().unwrap().node(a).unwrap().bound.get("fills/0/color").map(String::as_str), v.as_str()); + assert_eq!(s.doc().unwrap().node(b).unwrap().bound.get("strokes/0/color").map(String::as_str), v.as_str()); + assert_eq!(s.undo_depth().0, before.0 + 1); + run(&mut s, "variable.set", json!({"variable":v,"value":"#ffff00"})); + assert_eq!(fill(&s, a), "FFFF00"); + assert_eq!(fill(&s, b), "0000FF"); + let bound = collect(s.doc().unwrap(), &[a, b]).into_iter().find(|g| g.variable.is_some()).unwrap(); + assert_eq!(bound.targets.len(), 2); + s.undo().unwrap(); + s.undo().unwrap(); + assert_eq!(fill(&s, a), "FF0000"); + assert!(s.doc().unwrap().node(a).unwrap().bound.is_empty()); +} + +#[test] +fn a_color_variable_replaces_a_style_link_and_an_earlier_binding() { + let mut s = Session::new(); + let a = rectangle(&mut s, "#ff0000"); + let b = rectangle(&mut s, "#ff0000"); + let style = run(&mut s, "style.create", json!({"type":"paint","name":"Brand","value":"#ff0000"}))["id"].clone(); + run(&mut s, "style.apply", json!({"id":a,"style":style,"target":"fill"})); + let old = theme_variable(&mut s, "Old", "#ff0000"); + run(&mut s, "variable.bind", json!({"id":b,"field":"fill","variable":old})); + let new = theme_variable(&mut s, "New", "#0000ff"); + let targets: Vec<_> = collect(s.doc().unwrap(), &[a, b]).into_iter().flat_map(|g| g.targets).collect(); + run(&mut s, "selection.bindColorVariable", json!({"targets":targets,"variable":new})); + for id in [a, b] { + let n = s.doc().unwrap().node(id).unwrap(); + assert!(n.styles.fill.is_none()); + assert_eq!(n.bound.len(), 1, "one binding for the slot: {:?}", n.bound); + assert_eq!(n.bound.values().next().map(String::as_str), new.as_str()); + assert_eq!(fill(&s, id), "0000FF"); + } + assert_eq!(collect(s.doc().unwrap(), &[a, b]).len(), 1); +} + +#[test] +fn a_color_variable_on_a_text_range_with_its_own_fills_takes_its_value() { + let mut s = Session::new(); + let id = NodeId(run(&mut s, "text.create", json!({"text":"abc","props":{"fill":"#ff0000"}}))["id"].as_u64().unwrap()); + run(&mut s, "text.style", json!({"id":id,"start":1,"end":2,"fontSize":30,"textFills":["#0000ff"]})); + theme_variable(&mut s, "Brand", "#00ff0080"); + let blue = group(&s, &[id], "0000FF"); + run(&mut s, "selection.bindColorVariable", json!({"targets":blue.targets,"variable":"Brand"})); + let n = s.doc().unwrap().node(id).unwrap(); + let t = n.kind.text().unwrap(); + let paint = &t.style_at(1).fills.as_ref().unwrap()[0]; + assert_eq!(paint.solid_color().unwrap().to_css_hex(), "#00FF0080"); + assert_eq!(t.style_at(1).font_size, 30.0); + assert!(n.bound.is_empty()); + assert_eq!(fill(&s, id), "FF0000"); +} + +#[test] +fn detaching_a_selection_color_keeps_its_value() { + let mut s = Session::new(); + let a = rectangle(&mut s, "#ff0000"); + let b = rectangle(&mut s, "#ff0000"); + let c = rectangle(&mut s, "#0000ff"); + let style = run(&mut s, "style.create", json!({"type":"paint","name":"Brand","value":"#00ff00"}))["id"].clone(); + run(&mut s, "style.apply", json!({"id":a,"style":style,"target":"fill"})); + let v = theme_variable(&mut s, "Accent", "#ffff0080"); + run(&mut s, "variable.bind", json!({"id":b,"field":"fill","variable":v})); + run(&mut s, "variable.bind", json!({"id":c,"field":"fill","variable":v})); + let colors = collect(s.doc().unwrap(), &[a, b]); + assert_eq!(colors.len(), 2); + let before = s.undo_depth(); + for g in &colors { + run(&mut s, "selection.detachColor", json!({"targets":g.targets})); + } + assert_eq!(s.undo_depth().0, before.0 + 2); + assert!(s.doc().unwrap().node(a).unwrap().styles.fill.is_none()); + assert!(s.doc().unwrap().node(b).unwrap().bound.is_empty()); + assert!(!s.doc().unwrap().node(c).unwrap().bound.is_empty()); + assert_eq!(fill(&s, a), "00FF00"); + assert_eq!(s.doc().unwrap().node(b).unwrap().fills[0].solid_color().unwrap().to_css_hex(), "#FFFF0080"); + run(&mut s, "variable.set", json!({"variable":v,"value":"#000000"})); + assert_eq!(fill(&s, b), "FFFF00"); + assert!(collect(s.doc().unwrap(), &[a, b]).iter().all(|g| g.style.is_none() && g.variable.is_none())); +} + +#[test] +fn color_variables_refuse_gradients_other_types_and_unknown_names() { + use figcraft_color::{Color, Gradient, GradientKind}; + let mut s = Session::new(); + let a = rectangle(&mut s, "#ff0000"); + let gradient = Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::BLACK)); + run(&mut s, "fill.add", json!({"id":a,"paint":gradient})); + theme_variable(&mut s, "Brand", "#00ff00"); + run(&mut s, "variable.create", json!({"collection":"Theme","name":"Gap","type":"number","value":8})); + let colors = collect(s.doc().unwrap(), &[a]); + let solid = colors.iter().find(|g| g.paint.solid_color().is_some()).unwrap(); + let ramp = colors.iter().find(|g| g.paint.solid_color().is_none()).unwrap(); + let before = s.undo_depth(); + for (targets, variable) in [(&ramp.targets, "Brand"), (&solid.targets, "Gap"), (&solid.targets, "Missing")] { + assert!(s.execute("selection.bindColorVariable", &json!({"targets":targets,"variable":variable})).is_err()); + } + assert!(s.execute("selection.bindColorVariable", &json!({"targets":solid.targets})).is_err()); + assert!(s.doc().unwrap().node(a).unwrap().bound.is_empty()); + assert_eq!(s.undo_depth(), before); + run(&mut s, "selection.bindColorVariable", json!({"targets":solid.targets,"variable":"Brand"})); + assert_eq!(s.doc().unwrap().node(a).unwrap().bound.len(), 1); + assert_eq!(s.undo_depth().0, before.0 + 1); +} + +#[test] +fn scopes_filter_the_menu_but_the_command_binds_anyway() { + let mut s = Session::new(); + let a = rectangle(&mut s, "#ff0000"); + run(&mut s, "stroke.add", json!({"id":a,"color":"#ff0000"})); + theme_variable(&mut s, "Line", "#00ff00"); + theme_variable(&mut s, "Hidden", "#0000ff"); + run(&mut s, "variable.update", json!({"variable":"Line","scopes":["STROKE_COLOR"]})); + run(&mut s, "variable.update", json!({"variable":"Hidden","scopes":[]})); + let red = group(&s, &[a], "FF0000"); + let fill_slot: Vec<_> = red.targets.iter().filter(|t| t.fills).cloned().collect(); + let stroke_slot: Vec<_> = red.targets.iter().filter(|t| !t.fills).cloned().collect(); + let doc = s.doc().unwrap(); + let offered = |targets: &[figcraft_engine::selection_colors::PaintTarget], v: &str| figcraft_engine::selection_colors::in_scope(doc, targets, v); + assert!(offered(&stroke_slot, "Line")); + assert!(!offered(&red.targets, "Line") && !offered(&fill_slot, "Line") && !offered(&stroke_slot, "Hidden")); + run(&mut s, "selection.bindColorVariable", json!({"targets":stroke_slot,"variable":"Line"})); + assert_eq!(s.doc().unwrap().node(a).unwrap().strokes[0].solid_color().unwrap().to_hex(), "00FF00"); + assert_eq!(fill(&s, a), "FF0000"); + // As with `variable.bind`, the command itself binds outside the scopes. + run(&mut s, "selection.bindColorVariable", json!({"targets":fill_slot,"variable":"Line"})); + assert_eq!(fill(&s, a), "00FF00"); +} diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index 234e1459..dad09d95 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -1134,8 +1134,13 @@ impl<'d> Exporter<'d> { if !n.guides.is_empty() { self.warn("ruler guides were left out"); } - if !n.annotations.is_empty() || !n.dev_resources.is_empty() || !n.code_links.is_empty() || !n.dev_status.is_none() { - self.warn("Dev Mode annotations, statuses, resources and code links were left out"); + if !n.annotations.is_empty() + || !n.dev_resources.is_empty() + || !n.code_links.is_empty() + || !n.measurements.is_empty() + || !n.dev_status.is_none() + { + self.warn("Dev Mode annotations, statuses, resources, measurements and code links were left out"); } if !n.plugin_data.is_empty() { self.warn("plugin data was left out"); diff --git a/crates/format/src/fig/kiwi.rs b/crates/format/src/fig/kiwi.rs index 5c735b28..ab8f3218 100644 --- a/crates/format/src/fig/kiwi.rs +++ b/crates/format/src/fig/kiwi.rs @@ -359,6 +359,10 @@ impl<'s> Decoder<'s, '_> { fn field(&mut self, f: &Field, depth: usize) -> Result, KiwiError> { let ty = f.ty.ok_or_else(|| KiwiError("message: untyped field".into()))?; if !f.array { + // A nested definition spends its own value in `def`. + if !matches!(ty, Type::Def(_)) { + self.spend(1)?; + } return self.value(ty, depth); } let n = self.r.var_uint()? as usize; diff --git a/crates/format/src/fig/layer_cap_tests.rs b/crates/format/src/fig/layer_cap_tests.rs new file mode 100644 index 00000000..1b869ea2 --- /dev/null +++ b/crates/format/src/fig/layer_cap_tests.rs @@ -0,0 +1,148 @@ +//! Large `.fig` files at the layer cap (#981): the budget a design-system file gets, the library +//! copies its instances need kept ahead of the pages, honest notes for what the cap kept out, and +//! one note for the cap itself. + +use figcraft_doc::{Document, NodeKind}; + +use super::kiwi::{Kv, Schema}; +use super::{MAX_FIG_NODES, export, import, layer_budget, map}; + +type Fields = Vec<(&'static str, Kv<'static>)>; + +fn o(fields: Fields) -> Kv<'static> { + Kv::Object(fields) +} + +fn g(id: u64) -> Kv<'static> { + o(vec![("sessionID", Kv::Uint(1)), ("localID", Kv::Uint(id))]) +} + +fn xy(x: f64, y: f64) -> Kv<'static> { + o(vec![("x", Kv::Float(x as f32)), ("y", Kv::Float(y as f32))]) +} + +/// A layer under `parent`; siblings come in id order. +fn node(id: u64, parent: Option, ty: &'static str, name: &str, more: Fields) -> Kv<'static> { + let mut fields = vec![("guid", g(id)), ("phase", Kv::Enum("CREATED")), ("type", Kv::Enum(ty)), ("name", Kv::Str(name.into()))]; + if let Some(parent) = parent { + fields.push(("parentIndex", o(vec![("guid", g(parent)), ("position", Kv::Str(format!("{id:08}")))]))); + } + fields.extend(more); + o(fields) +} + +fn instance(id: u64, page: u64, main: u64) -> Kv<'static> { + node(id, Some(page), "INSTANCE", "Instance", vec![("symbolData", o(vec![("symbolID", g(main))]))]) +} + +/// A main `id` of `layers` rectangles under `parent`. +fn main_component(id: u64, parent: u64, name: &str, layers: u64) -> Vec> { + let mut out = vec![node(id, Some(parent), "SYMBOL", name, vec![("size", xy(10.0, 10.0))])]; + out.extend((1..=layers).map(|i| node(id * 10_000 + i, Some(id), "RECTANGLE", "R", vec![("size", xy(1.0, 1.0))]))); + out +} + +/// A page (2) of `count` instances of a main kept on the file's hidden library canvas (3), as a +/// design system's file carries the library components it uses. +fn library_message(count: u64, main_layers: u64) -> Kv<'static> { + let mut nodes = vec![ + node(1, None, "DOCUMENT", "Document", vec![]), + node(2, Some(1), "CANVAS", "Screens", vec![]), + node(3, Some(1), "CANVAS", "Internal", vec![("internalOnly", Kv::Bool(true))]), + ]; + nodes.extend(main_component(20, 3, "Library main", main_layers)); + nodes.extend((0..count).map(|i| instance(1_000 + i, 2, 20))); + o(vec![("nodeChanges", Kv::Array(nodes))]) +} + +/// The `.fig` import's two stages at layer cap `cap`. +fn import_at(msg: &Kv, cap: usize) -> (Document, Vec) { + let (json, mut warnings, networks) = map::to_rest(msg, "Capped", cap); + let (doc, more) = crate::rest::import_with_networks(&json, &|_: &str| None, networks, cap).unwrap(); + warnings.extend(more); + (doc, warnings) +} + +fn linked_to(doc: &Document, main_name: &str) -> usize { + doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { component, .. } if doc.node(component).is_some_and(|c| c.name == main_name))).count() +} + +#[test] +fn a_design_system_sized_canvas_gets_room() { + // The file in #981 stopped at 244 248 layers: a 7.8 MB canvas at 32 bytes a layer. + let reported = 244_248 * 32; + assert!(layer_budget(reported) >= 4 * 244_248, "{}", layer_budget(reported)); + // Small files still can't claim more than the REST import's cap, nor any file the ceiling. + assert_eq!(layer_budget(1 << 20), crate::rest::MAX_NODES); + assert_eq!(layer_budget(usize::MAX), MAX_FIG_NODES); +} + +#[test] +fn library_mains_come_before_the_pages_at_the_cap() { + // 40 instances of an 11-layer library main on a page, with room for 100 layers: mapped after + // the pages, the main found no room and every instance became a frame of "a component that + // isn't in the file". + let (doc, warnings) = import_at(&library_message(40, 10), 100); + assert!(warnings.iter().any(|w| w.contains("more than 100 layers")), "{warnings:?}"); + assert!(!warnings.iter().any(|w| w.contains("aren't in the file")), "{warnings:?}"); + let linked = linked_to(&doc, "Library main"); + assert!(linked >= 5, "{linked} instances linked to the library main: {warnings:?}"); + assert!(doc.nodes().any(|n| n.name == "Library main" && doc.is_remote(n.id))); +} + +#[test] +fn instances_of_a_main_the_cap_kept_out_say_so() { + // The page's instances come before their main: at the cap, the main is skipped though the + // file has it. + let mut nodes = vec![node(1, None, "DOCUMENT", "Document", vec![]), node(2, Some(1), "CANVAS", "Page", vec![])]; + nodes.extend((0..20).map(|i| instance(100 + i, 2, 900))); + nodes.extend(main_component(900, 2, "Local main", 10)); + let (doc, warnings) = import_at(&o(vec![("nodeChanges", Kv::Array(nodes))]), 60); + assert!(warnings.iter().any(|w| w.starts_with("instances whose component was past the layer limit were imported as frames")), "{warnings:?}"); + assert!(!warnings.iter().any(|w| w.contains("aren't in the file")), "{warnings:?}"); + assert!(!doc.nodes().any(|n| n.name == "Local main")); +} + +#[test] +fn swap_defaults_the_cap_kept_out_say_so() { + // A kept main's instance swap default names a main with no instances, which comes after + // enough layers to be skipped at the cap. + let swap = o(vec![ + ("id", g(70)), + ("name", Kv::Str("Icon".into())), + ("type", Kv::Enum("INSTANCE_SWAP")), + ("initialValue", o(vec![("guidValue", g(900))])), + ]); + let mut nodes = vec![node(1, None, "DOCUMENT", "Document", vec![]), node(2, Some(1), "CANVAS", "Page", vec![])]; + nodes.push(node(50, Some(2), "SYMBOL", "Card", vec![("size", xy(10.0, 10.0)), ("componentPropDefs", Kv::Array(vec![swap]))])); + nodes.extend(main_component(60, 2, "Filler", 40)); + nodes.extend(main_component(900, 2, "Icon main", 10)); + let (doc, warnings) = import_at(&o(vec![("nodeChanges", Kv::Array(nodes))]), 30); + assert!(doc.nodes().any(|n| n.name == "Card")); + assert!(!doc.nodes().any(|n| n.name == "Icon main")); + assert!(warnings.iter().any(|w| w.starts_with("instance swap properties whose default component was past the layer limit")), "{warnings:?}"); + assert!(!warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}"); +} + +#[test] +fn a_whole_file_past_the_cap_keeps_its_library_links_and_says_so_once() { + // A 1 001-layer library main used 210 times: about 210 000 layers in a file far too small to + // lift the cap above the REST import's 200 000. + let file = super::tests::canvas(&Schema::parse(export::SCHEMA).unwrap(), &library_message(210, 1_000)); + assert_eq!(layer_budget(file.len()), crate::rest::MAX_NODES); + let (doc, warnings) = import(&file, "Design system.fig").unwrap(); + let cap: Vec<_> = warnings.iter().filter(|w| w.contains("layers; the rest were skipped")).collect(); + assert_eq!(cap.len(), 1, "{warnings:?}"); + assert!(!warnings.iter().any(|w| w.contains("aren't in the file")), "{warnings:?}"); + assert!(linked_to(&doc, "Library main") >= 190, "{warnings:?}"); +} + +#[test] +fn a_library_main_larger_than_the_cap_leaves_the_pages_room() { + // A main of 300 layers with room for 100: mapped first, it took the whole budget and the page + // came out empty. + let (doc, warnings) = import_at(&library_message(5, 300), 100); + let page = doc.nodes().find(|n| n.name == "Screens").expect("the page"); + let top = page.children.len(); + assert!(top >= 1, "the page kept {top} layers: {warnings:?}"); +} diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index ec0dd8e4..3ad61edb 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -42,6 +42,14 @@ pub(super) fn to_rest(msg: &Kv, name: &str, max_nodes: usize) -> (Value, Vec= m.max_nodes { + let capped: Vec = m.referenced_mains.difference(&m.mapped_mains).map(|id| json!(id)).collect(); + if !capped.is_empty() { + out[crate::rest::FIG_CAPPED_MAINS_FIELD] = Value::Array(capped); + } + } if !m.styles.is_empty() { out["styles"] = Value::Object(std::mem::take(&mut m.styles)); } @@ -58,7 +66,7 @@ pub(super) fn to_rest(msg: &Kv, name: &str, max_nodes: usize) -> (Value, Vec { library_contexts: Vec, /// Layers visited while scanning internal canvases, across all of them. library_scan_count: usize, - warnings: Vec, + warnings: crate::rest::Warnings, count: usize, + /// The mains in the file that instances or instance swap defaults name, and the mains mapped + /// as layers: the difference is what the layer cap kept out. + referenced_mains: BTreeSet, + mapped_mains: BTreeSet, /// Most layers produced or scanned, instance expansion included; the one place the cap comes /// from (see [`super::layer_budget`]). max_nodes: usize, + /// Layers kept back for the visible pages' top level while library copies are mapped, so the + /// copies can't take the whole budget and leave the pages empty. + reserved: usize, /// Vector networks, each built once: the layers that show one (every instance of a main /// holding it) name it by `vectorNetworkRef`. Kept out of the JSON, where a large file's /// networks took gigabytes. @@ -464,9 +479,12 @@ impl<'a> Mapper<'a> { override_scope_entries: 0, library_contexts: Vec::new(), library_scan_count: 0, - warnings: Vec::new(), + warnings: crate::rest::Warnings::default(), count: 0, + referenced_mains: BTreeSet::new(), + mapped_mains: BTreeSet::new(), max_nodes, + reserved: 0, networks: Vec::new(), network_ids: HashMap::new(), } @@ -493,12 +511,14 @@ impl<'a> Mapper<'a> { .cloned() } + /// A note about what this file's mapping left out or changed. Counted, as the REST importer + /// counts its own: these notes are met once per layer, so the kinds were named once each with + /// no sign of how much of the file they covered -- a file whose every text layer lost a + /// setting read the same as one where a single layer did (#467). The collector keeps the + /// first-seen order and the same cap, since some notes name fields of the file (image + /// filters). fn warn(&mut self, w: impl Into) { - let w = w.into(); - // Capped like the REST importer's list: some warnings name fields of the file (image filters). - if self.warnings.len() < crate::rest::MAX_WARNINGS && !self.warnings.contains(&w) { - self.warnings.push(w); - } + self.warnings.add(w.into()); } fn children(&self, id: &str) -> Vec { @@ -549,6 +569,11 @@ impl<'a> Mapper<'a> { scope } + /// How many layers may be produced now: the cap, less what is kept for the visible pages. + fn budget(&self) -> usize { + self.max_nodes.saturating_sub(self.reserved) + } + /// The DOCUMENT with its pages. fn document(&mut self) -> Value { let doc_id = self.nodes.iter().find(|(_, n)| n.get("type").and_then(Kv::str) == Some("DOCUMENT")).map(|(id, _)| id.clone()); @@ -556,9 +581,8 @@ impl<'a> Mapper<'a> { Some(d) => self.children(d), None => self.nodes.iter().filter(|(_, n)| n.get("type").and_then(Kv::str) == Some("CANVAS")).map(|(id, _)| id.clone()).collect(), }; - let mut out = Vec::new(); let mut internal = Vec::new(); - let mut visible_pages = Vec::new(); + let mut visible = Vec::new(); for p in pages { let Some(&n) = self.nodes.get(&p) else { continue }; if n.get("type").and_then(Kv::str) != Some("CANVAS") { @@ -568,17 +592,24 @@ impl<'a> Mapper<'a> { // The hidden page that holds styles, variables and library copies. if n.get("internalOnly").and_then(Kv::bool) == Some(true) { internal.push((p, n)); - continue; + } else { + visible.push((p, n)); } - visible_pages.push(p.clone()); - out.push(self.page(&p, n)); } + // The library copies the pages use come first: in a file past the layer cap, the pages' + // last layers are skipped rather than the mains every instance of them needs (#981). + let visible_pages: Vec = visible.iter().map(|(p, _)| p.clone()).collect(); let required_mains = self.referenced_component_mains(&visible_pages); + // The pages and their top-level layers, up to half the cap, stay for the pages. + let top: usize = visible_pages.iter().map(|p| 1 + self.children(p).len()).sum(); + self.reserved = top.min(self.max_nodes / 2); for (id, canvas) in internal { if let Some(context) = self.library_context(&id, canvas, &required_mains) { self.library_contexts.push(context); } } + self.reserved = 0; + let out: Vec = visible.into_iter().map(|(p, n)| self.page(&p, n)).collect(); if out.is_empty() { self.warn("the file has no pages"); } @@ -822,7 +853,7 @@ impl<'a> Mapper<'a> { self.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped")); return None; } - if self.count >= self.max_nodes { + if self.count >= self.budget() { let max = self.max_nodes; self.warn(format!("the file has more than {max} layers; the rest were skipped")); return None; @@ -859,7 +890,7 @@ impl<'a> Mapper<'a> { let connector_snapshot = own == "CONNECTOR" && self.connector_snapshot_supported(&view); // Only a connector with a saved label needs the frame wrapper; an unlabelled one stays a single vector. let connector_terminal = - connector_snapshot && (!connector_has_saved_label(&view) || depth >= MAX_DEPTH || self.count.saturating_add(1) >= self.max_nodes); + connector_snapshot && (!connector_has_saved_label(&view) || depth >= MAX_DEPTH || self.count.saturating_add(1) >= self.budget()); // A supported connector snapshot has its own note below instead of the generic stand-in one. let stand = stand_in(own).filter(|_| !connector_snapshot); let ty = match own { @@ -879,6 +910,9 @@ impl<'a> Mapper<'a> { self.warn("connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained"); } self.count += 1; + if own == "SYMBOL" && inst.is_none() { + self.mapped_mains.insert(id.to_string()); + } let mut o = Obj::new(); o.insert("id".into(), json!(rest_id)); // Private provenance for the .fig importer; ordinary REST import ignores it. @@ -1004,7 +1038,7 @@ impl<'a> Mapper<'a> { fn shape_with_text(&mut self, o: &mut Obj, v: &View<'a>, own: &str, rest_id: &str) -> Option { let shape = v.str("shapeWithTextType").unwrap_or("SQUARE"); let outlined = !v.list("fillGeometry").is_empty() || !v.list("strokeGeometry").is_empty(); - if own != "SHAPE_WITH_TEXT" || matches!(shape, "SQUARE" | "ROUNDED_RECTANGLE") || !outlined || self.count >= self.max_nodes { + if own != "SHAPE_WITH_TEXT" || matches!(shape, "SQUARE" | "ROUNDED_RECTANGLE") || !outlined || self.count >= self.budget() { return None; } let mut s = Obj::new(); @@ -1036,7 +1070,7 @@ impl<'a> Mapper<'a> { /// layer filling it, inset a little; centred unless it's a note. fn inner_text(&mut self, o: &Obj, v: &View<'a>, rest_id: &str, note: bool) -> Option { let chars = own_text(v)?; - if self.count >= self.max_nodes { + if self.count >= self.budget() { return None; } self.count += 1; @@ -1171,7 +1205,7 @@ impl<'a> Mapper<'a> { out.insert("opacity".into(), json!(root_opacity * line_opacity)); } if connector_has_saved_label(v) { - let reason = if self.count >= self.max_nodes { "node budget" } else { "depth limit" }; + let reason = if self.count >= self.budget() { "node budget" } else { "depth limit" }; self.warn(format!("connector label was skipped: {reason} prevents nested snapshot children")); } } @@ -1201,7 +1235,7 @@ impl<'a> Mapper<'a> { /// Snapshot the straight, manual connector contract carried by its generated children. fn connector_snapshot_children(&mut self, v: &View<'a>, rest_id: &str) -> Vec { - if self.count >= self.max_nodes { + if self.count >= self.budget() { self.warn(format!("the file has more than {} layers; connector snapshot children were skipped", self.max_nodes)); return Vec::new(); } @@ -1264,7 +1298,7 @@ impl<'a> Mapper<'a> { }; let background_view = View::of(background).with([background_style]); if !background_view.list("fillPaints").is_empty() { - if self.count >= self.max_nodes { + if self.count >= self.budget() { self.warn(format!("the file has more than {} layers; connector label background was skipped", self.max_nodes)); } else { let mut rect = Obj::new(); @@ -1292,7 +1326,7 @@ impl<'a> Mapper<'a> { transform: Affine, view: &View<'a>, ) { - if self.count >= self.max_nodes { + if self.count >= self.budget() { self.warn(format!("the file has more than {} layers; connector label was skipped", self.max_nodes)); return; } @@ -1850,7 +1884,12 @@ impl<'a> Mapper<'a> { "BOOL" => json!({"type": "BOOLEAN", "defaultValue": init.and_then(|i| i.get("boolValue")).and_then(Kv::bool).unwrap_or(true)}), "TEXT" => json!({"type": "TEXT", "defaultValue": init.and_then(prop_text).unwrap_or("")}), "INSTANCE_SWAP" => match init.and_then(|i| i.get("guidValue")).and_then(guid) { - Some(g) => json!({"type": "INSTANCE_SWAP", "defaultValue": g}), + Some(g) => { + if self.nodes.contains_key(&g) { + self.referenced_mains.insert(g.clone()); + } + json!({"type": "INSTANCE_SWAP", "defaultValue": g}) + } None => continue, }, "VARIANT" => json!({"type": "VARIANT"}), @@ -1994,6 +2033,9 @@ impl<'a> Mapper<'a> { self.warn("instances of components that aren't in the file show no layers"); return None; } + if !self.referenced_mains.contains(&sym) { + self.referenced_mains.insert(sym.clone()); + } if outer.is_some_and(|i| i.symbols.contains(&sym)) { return None; } diff --git a/crates/format/src/fig/map_connector_snapshot_budget_tests.rs b/crates/format/src/fig/map_connector_snapshot_budget_tests.rs index ca47578d..8727c31b 100644 --- a/crates/format/src/fig/map_connector_snapshot_budget_tests.rs +++ b/crates/format/src/fig/map_connector_snapshot_budget_tests.rs @@ -115,14 +115,14 @@ fn connector_snapshot_respects_last_and_zero_child_slots() { assert!(!children[0]["fillGeometry"].as_array().unwrap().is_empty(), "saved outline survives the budget limit"); assert_eq!(children[0]["fillGeometry"][0]["path"], "M 0 0 L 20 10"); assert_eq!(mapper.count, MAX_NODES); - assert!(mapper.warnings.iter().any(|warning| warning == &format!("the file has more than {MAX_NODES} layers; connector label was skipped"))); + assert!(mapper.warnings.seen().any(|warning| warning == format!("the file has more than {MAX_NODES} layers; connector label was skipped"))); let mut mapper = Mapper::new(&message, MAX_NODES); mapper.count = MAX_NODES; let children = mapper.connector_snapshot_children(&view, "1:123"); assert!(children.is_empty()); assert_eq!(mapper.count, MAX_NODES, "the node budget is never exceeded"); - assert!(mapper.warnings.iter().any(|warning| warning.contains("connector snapshot children were skipped"))); + assert!(mapper.warnings.seen().any(|warning| warning.contains("connector snapshot children were skipped"))); } #[test] @@ -133,7 +133,7 @@ fn connector_snapshot_flattens_vector_at_depth_and_node_limits() { assert_eq!(depth_limited["type"], "VECTOR"); assert!(!depth_limited["fillGeometry"].as_array().unwrap().is_empty()); assert!(depth_limited.get("children").is_none()); - assert!(mapper.warnings.iter().any(|warning| warning == "connector label was skipped: depth limit prevents nested snapshot children")); + assert!(mapper.warnings.seen().any(|warning| warning == "connector label was skipped: depth limit prevents nested snapshot children")); let mut mapper = Mapper::new(&message, MAX_NODES); mapper.count = MAX_NODES - 1; @@ -142,5 +142,37 @@ fn connector_snapshot_flattens_vector_at_depth_and_node_limits() { assert!(!budget_limited["fillGeometry"].as_array().unwrap().is_empty()); assert!(budget_limited.get("children").is_none()); assert_eq!(mapper.count, MAX_NODES); - assert!(mapper.warnings.iter().any(|warning| warning == "connector label was skipped: node budget prevents nested snapshot children")); + assert!(mapper.warnings.seen().any(|warning| warning == "connector label was skipped: node budget prevents nested snapshot children")); +} + +/// A mapper note is met once per layer, so the kinds were named once each with no sign of how much +/// of the file they covered: a file whose every layer lost a setting read the same as one where a +/// single layer did (#467). They are counted now, with the `(×n)` the REST importer already uses +/// and the import report already reads back off before translating a note. +#[test] +fn a_note_met_more_than_once_carries_its_count() { + let message = message(); + + let mut mapper = Mapper::new(&message, MAX_NODES); + for _ in 0..2 { + mapper.layer("1:123", None, Affine::IDENTITY, None, None, MAX_DEPTH).expect("terminal connector"); + } + let notes = std::mem::take(&mut mapper.warnings).finish(); + let counted: Vec<&String> = notes.iter().filter(|n| n.contains("(×")).collect(); + assert!( + counted.iter().any(|n| n.ends_with("(×2)") && n.contains("connector label was skipped: depth limit")), + "the note met twice says so: {notes:?}" + ); + assert!(notes.iter().all(|n| !n.ends_with("(×1)")), "a count of one is left off: {notes:?}"); + + // One layer, and the same notes have no count at all. + let mut mapper = Mapper::new(&message, MAX_NODES); + mapper.layer("1:123", None, Affine::IDENTITY, None, None, MAX_DEPTH).expect("terminal connector"); + let once = std::mem::take(&mut mapper.warnings).finish(); + assert!(once.iter().all(|n| !n.contains("(×")), "{once:?}"); + assert_eq!( + once.len(), + counted.len() + notes.iter().filter(|n| !n.contains("(×")).count(), + "the same kinds, counted or not: {once:?} vs {notes:?}" + ); } diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index 4c103d08..c04043ae 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -23,6 +23,8 @@ mod export_tests; #[cfg(test)] mod interop_tests; pub mod kiwi; +#[cfg(test)] +mod layer_cap_tests; mod map; mod network; #[cfg(test)] @@ -64,8 +66,11 @@ const MAX_CHUNKS: usize = 16; /// compressed canvas, at least the REST import's cap and at most [`MAX_FIG_NODES`]. Instances /// expand into a layer per layer of their component, and chunks inflate up to [`MAX_CHUNK`], so the /// budget follows what the file takes, not what it unpacks to: a small file can't claim a large -/// count, however it's built; a large one gets room. -const PACKED_BYTES_PER_LAYER: usize = 32; +/// count, however it's built; a large one gets room. Design systems are mostly instances, which +/// pack tightly: a 7.8 MB canvas stopped at 244 248 layers at 32 bytes a layer (#981). At 8, files +/// under about 1.6 MB keep the floor and the ceiling comes at a 16 MB canvas (about 11 GB and 40 s +/// to import 2 million synthetic layers; memory grows with the count, about 5.4 KB a layer). +const PACKED_BYTES_PER_LAYER: usize = 8; const MAX_FIG_NODES: usize = 2_000_000; /// Most layers imported from a `.fig` file whose compressed canvas is `packed` bytes. @@ -108,7 +113,13 @@ pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec), Forma drop(msg); let images = |r: &str| archive.image(r).or_else(|| embedded.get(r).cloned()); let (doc, more) = crate::rest::import_with_networks(&json, &images, networks, max_nodes)?; - warnings.extend(more.into_iter().filter_map(map::adapt_warning)); + // Both stages stop at the same cap and say so in the same words: one note (#981). + for w in more.into_iter().filter_map(map::adapt_warning) { + let base = w.rsplit_once(" (×").map_or(w.as_str(), |(b, _)| b); + if !warnings.iter().any(|seen| seen == base) { + warnings.push(w); + } + } Ok((doc, warnings)) } diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index b0c3c5fe..35d8d08a 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -895,6 +895,18 @@ fn the_mapper_stops_at_the_layer_cap_it_is_given() { assert!(!warnings.iter().any(|w| w.contains("more than")), "{warnings:?}"); } +#[test] +fn scalar_fields_count_against_the_value_budget() { + // A message of 1000 scalar fields (id 1, `true`) is 1001 values: a budget of 10 refuses it. + let fields = vec![Field { name: "b".into(), ty: Some(Type::Bool), array: false, value: 1 }]; + let sc = Schema { defs: vec![Def { name: "Message".into(), kind: Kind::Message, fields }] }; + let mut data = [1u8, 1].repeat(1000); + data.push(0); + let e = sc.decode_message_within(0, &data, 10).unwrap_err(); + assert_eq!(e.0, "message: too many values"); + assert!(sc.decode_message_within(0, &data, 1001).is_ok()); +} + #[test] fn hostile_input_is_refused() { // A message that contains itself, nested past the depth cap. diff --git a/crates/format/src/image_tests.rs b/crates/format/src/image_tests.rs index bc925efb..e58a4ab1 100644 --- a/crates/format/src/image_tests.rs +++ b/crates/format/src/image_tests.rs @@ -86,3 +86,29 @@ fn many_lost_images_are_counted_not_listed_one_by_one() { assert_eq!(issues.len(), MAX_IMAGE_ISSUES + 1, "{issues:?}"); assert_eq!(issues.last().map(String::as_str), Some("20 more images couldn't be loaded")); } + +#[test] +fn image_members_with_path_like_names_are_dropped_and_not_saved_back() { + let (d, hash) = doc_with_image(); + let bytes = save_bytes(&d, None).unwrap(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap(); + let doc_json = read_member(&mut z, "document.json", u64::MAX).unwrap(); + let mut buf = std::io::Cursor::new(Vec::new()); + let mut w = zip::ZipWriter::new(&mut buf); + let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + w.start_file("document.json", opts).unwrap(); + w.write_all(&doc_json).unwrap(); + for n in ["images/../../x", "images/a/b"] { + w.start_file(n, opts).unwrap(); + w.write_all(&png()).unwrap(); + } + w.finish().unwrap(); + let hostile = buf.into_inner(); + let loaded = load_bytes_with_versions(&hostile).unwrap(); + assert!(loaded.doc.images.keys().all(|k| k != "../../x" && k != "a/b"), "{:?}", loaded.doc.images.keys()); + assert!(!loaded.issues.is_empty()); + let saved = save_bytes_with_history(&loaded.doc, None, &loaded.versions, &loaded.branches).unwrap(); + let z = zip::ZipArchive::new(std::io::Cursor::new(saved)).unwrap(); + assert!(z.file_names().all(|n| !n.contains("..") && n.matches('/').count() <= 1), "{:?}", z.file_names().collect::>()); + let _ = hash; +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 9f7e51bc..f8a6120c 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -343,6 +343,20 @@ pub(crate) fn put( z.write_all(bytes).map_err(io_err) } +/// `files` (name, bytes; a `/` in a name makes a folder) as one zip archive: several exports +/// delivered as a single download (#969). +pub fn zip_files<'a>(files: impl IntoIterator) -> Result, FormatError> { + let mut buf = std::io::Cursor::new(Vec::new()); + { + let mut z = zip::ZipWriter::new(&mut buf); + for (name, bytes) in files { + put(&mut z, name, zip::write::SimpleFileOptions::default(), bytes)?; + } + z.finish().map_err(io_err)?; + } + Ok(buf.into_inner()) +} + /// Add `doc` as a `document.json`-style member, serialized straight into the compressor. fn put_json(z: &mut zip::ZipWriter, name: &str, doc: &Document) -> Result<(), FormatError> { z.start_file(name, json_options()).map_err(io_err)?; @@ -524,10 +538,18 @@ fn read_images( own: Option<&BTreeSet>, budget: ImageBudget, ) -> (BTreeMap, Vec<(String, FormatError)>) { - let mut hashes: Vec = z.file_names().filter_map(|n| n.strip_prefix("images/")).map(str::to_string).collect(); + let mut hashes: Vec = z.file_names().filter_map(|n| n.strip_prefix("images/")).filter(|h| !h.is_empty()).map(str::to_string).collect(); hashes.sort_by_key(|h| !own.is_some_and(|o| o.contains(h))); let mut images = BTreeMap::new(); let mut unread = Vec::new(); + // Member names that aren't plain hashes (`../x`, `a/b`) are never read, so never written back. + hashes.retain(|h| { + let ok = library::is_valid_hash(h); + if !ok { + unread.push((h.clone(), FormatError::Corrupt("its name isn't a valid image hash".to_string()))); + } + ok + }); let mut left = budget.bytes; for (i, hash) in hashes.into_iter().enumerate() { let name = format!("images/{hash}"); diff --git a/crates/format/src/library.rs b/crates/format/src/library.rs index 06dcc99e..daec05e2 100644 --- a/crates/format/src/library.rs +++ b/crates/format/src/library.rs @@ -39,7 +39,7 @@ pub fn is_valid_library_id(id: &str) -> bool { } /// Image hashes become archive member names: only plain hex-like names are looked up. -fn is_valid_hash(h: &str) -> bool { +pub(crate) fn is_valid_hash(h: &str) -> bool { (1..=128).contains(&h.len()) && h.bytes().all(|b| b.is_ascii_alphanumeric()) } diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index 78279e91..ba2e3e99 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -65,6 +65,8 @@ const MAX_DEPTH: usize = 256; pub(crate) const FIG_SOURCE_FIELD: &str = "_figSource"; /// Hidden .fig library context; accepted only by the `.fig` import ([`import_with_networks`]). pub(crate) const FIG_LIBRARY_CONTEXTS_FIELD: &str = "_figLibraryContexts"; +/// Ids of mains in a `.fig` file that its layer cap kept out; read only by the `.fig` import. +pub(crate) const FIG_CAPPED_MAINS_FIELD: &str = "_figCappedMains"; /// Longest SVG path string read from one geometry entry. const MAX_PATH_BYTES: usize = 4 << 20; /// Most path elements in one vector. @@ -215,6 +217,14 @@ fn import_parts( cx.style_definitions(v); cx.networks = networks; cx.max_nodes = max_nodes; + // The `.fig` mapper already kept the layers, library copies included, within the cap; each + // library copy's wrapper frame is the one layer here it doesn't count (#981). + let contexts = if fig_sources { v.get(FIG_LIBRARY_CONTEXTS_FIELD).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default() } else { &[] }; + if fig_sources { + cx.capped_mains = + v.get(FIG_CAPPED_MAINS_FIELD).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_owned).collect(); + cx.max_nodes = max_nodes.saturating_add(contexts.len()); + } for e in entries { let Some(root) = e.get("document") else { continue }; match text(root, "type") { @@ -236,11 +246,9 @@ fn import_parts( None => cx.warn("entries without a node type were skipped"), } } - if fig_sources { - for context in v.get(FIG_LIBRARY_CONTEXTS_FIELD).and_then(Value::as_array).into_iter().flatten() { - let store = cx.doc.ensure_library_store(); - cx.build(context, store, Space::default(), None, 0, false); - } + for context in contexts { + let store = cx.doc.ensure_library_store(); + cx.build(context, store, Space::default(), None, 0, false); } // Drop the empty page the new document started with once real pages exist. if let Some(p) = blank @@ -329,15 +337,16 @@ fn children(v: &Value) -> &[Value] { v.get("children").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default() } -/// Distinct warnings in first-seen order, with repeat counts. +/// Distinct warnings in first-seen order, with repeat counts. Shared with the `.fig` mapper +/// (`crate::fig::map`), whose notes repeat per layer in the same way. #[derive(Default)] -struct Warnings { +pub(crate) struct Warnings { list: Vec<(String, usize)>, index: BTreeMap, } impl Warnings { - fn add(&mut self, msg: String) { + pub(crate) fn add(&mut self, msg: String) { if let Some(i) = self.index.get(&msg) { if let Some(e) = self.list.get_mut(*i) { e.1 += 1; @@ -348,7 +357,17 @@ impl Warnings { } } - fn finish(self) -> Vec { + /// The notes met so far, without their counts, for the tests that ask whether one was met + /// (the product reads them through [`Self::finish`], with the counts). + #[cfg(test)] + pub(crate) fn seen(&self) -> impl Iterator { + self.list.iter().map(|(msg, _)| msg.as_str()) + } + + /// The notes, each with `(×n)` after it where it was met more than once. The import report + /// reads that suffix off before it looks the note up in the catalogue, so a counted note is + /// still translated (`ui-egui`'s `note_text`). + pub(crate) fn finish(self) -> Vec { self.list.into_iter().map(|(m, n)| if n > 1 { format!("{m} (×{n})") } else { m }).collect() } } @@ -404,6 +423,8 @@ pub(crate) struct Ctx<'a> { networks: Vec>, /// Most layers imported: [`MAX_NODES`], or the `.fig` import's own cap. max_nodes: usize, + /// Mains the `.fig` file has but its layer cap kept out ([`FIG_CAPPED_MAINS_FIELD`]). + capped_mains: BTreeSet, } impl<'a> Ctx<'a> { @@ -432,6 +453,7 @@ impl<'a> Ctx<'a> { var_modes: BTreeMap::new(), networks: Vec::new(), max_nodes: MAX_NODES, + capped_mains: BTreeSet::new(), } } @@ -998,7 +1020,11 @@ impl<'a> Ctx<'a> { fn resolve_swap_defaults(&mut self) { for (owner, name, rest_id) in std::mem::take(&mut self.swap_defaults) { let Some(c) = self.component(&rest_id) else { - self.warn("instance swap properties whose default component isn't in the file were skipped"); + if self.capped_mains.contains(&rest_id) { + self.warn("instance swap properties whose default component was past the layer limit were skipped"); + } else { + self.warn("instance swap properties whose default component isn't in the file were skipped"); + } continue; }; let def = PropDef::InstanceSwap { default: c, preferred: Vec::new() }; @@ -1017,7 +1043,11 @@ impl<'a> Ctx<'a> { let frames: BTreeSet = self.instances.iter().map(|(i, _)| *i).collect(); let mut links: Vec<(NodeId, NodeId, &'a Value)> = Vec::new(); for (inst, j) in std::mem::take(&mut self.instances) { - match text(j, "componentId").and_then(|r| self.component(r)) { + let main = text(j, "componentId"); + match main.and_then(|r| self.component(r)) { + None if main.is_some_and(|r| self.capped_mains.contains(r)) => { + self.warn("instances whose component was past the layer limit were imported as frames"); + } None => self.warn("instances of components that aren't in the file were imported as frames"), Some(c) if self.doc.is_ancestor(c, inst) => self.warn("instances inside their own main component were imported as frames"), Some(c) => links.push((inst, c, j)), diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index e0a4dde0..012fca42 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -2,7 +2,7 @@ //! //! Re-exports [`kurbo`] and adds the shape builders a design tool needs: rectangles with independent //! corner radii and corner smoothing (the "squircle" continuous-curvature corner), regular polygons -//! and stars (with rounded vertices), and ellipse arcs / rings. Coordinates are y-down. +//! and stars (with rounded and smoothed vertices), and ellipse arcs / rings. Coordinates are y-down. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub use kurbo; @@ -230,6 +230,95 @@ pub fn rounded_polygon(points: &[Point], radius: f64) -> BezPath { path } +/// [`rounded_polygon`] with corner smoothing (0..=1, as [`rounded_rect`] takes it): each rounded +/// vertex keeps a circular arc over `1 - smoothing` of its turn and eases into both edges with +/// curves that start `1 + smoothing` times the plain corner's length from the vertex. As in +/// [`rounded_rect`], when a smoothed corner doesn't fit in half of its shorter edge, smoothing is +/// reduced first, then the radius. Without smoothing this is [`rounded_polygon`]. +pub fn smoothed_polygon(points: &[Point], radius: f64, smoothing: f64) -> BezPath { + let s = finite(smoothing).clamp(0.0, 1.0); + let radius = finite(radius).max(0.0); + let n = points.len(); + if s <= 0.0 || radius <= 0.0 || n < 3 { + return rounded_polygon(points, radius); + } + let mut path = BezPath::new(); + for i in 0..n { + let (Some(prev), Some(cur), Some(next)) = (points.get((i + n - 1) % n), points.get(i), points.get((i + 1) % n)) else { + continue; + }; + let (v1, v2) = (*prev - *cur, *next - *cur); + let (l1, l2) = (v1.hypot(), v2.hypot()); + let angle = if l1 < 1e-9 || l2 < 1e-9 { 0.0 } else { (v1 / l1).dot(v2 / l2).clamp(-1.0, 1.0).acos() }; + if angle < 1e-6 || (PI - angle) < 1e-6 { + line_or_move(&mut path, *cur); + continue; + } + let (u1, u2) = (v1 / l1, v2 / l2); + // The plain corner's length along each edge, then the fit: smoothing first, then the radius. + let max_p = l1.min(l2) / 2.0; + let mut r = radius; + let mut t = r / (angle / 2.0).tan(); + let mut sm = s; + if (1.0 + sm) * t > max_p { + sm = (max_p / t - 1.0).clamp(0.0, sm); + } + if t > max_p { + r *= max_p / t; + t = max_p; + } + let p = ((1.0 + sm) * t).min(max_p); + // Each easing curve covers `beta` of the turn and ends on the arc at `back` along the edge + // and `side` across it; its last handle points along the arc there. + let beta = (PI - angle) * sm / 2.0; + let back = t - r * beta.sin(); + let side = r * (1.0 - beta.cos()); + let c = r * (beta / 2.0).tan() * beta.cos(); + let b = ((p - back - c) / 3.0).max(0.0); + let a = 2.0 * b; + let bisector = (u1 + u2).normalize(); + let center = *cur + bisector * (r / (angle / 2.0).sin()); + // A point `along` an edge from the vertex and `across` it toward the arc's center. + let at = |u: Vec2, along: f64, across: f64| *cur + u * along + (bisector - u * bisector.dot(u)).normalize() * across; + line_or_move(&mut path, at(u1, p, 0.0)); + let p3 = at(u1, back, side); + let p4 = at(u2, back, side); + if a + b + c > 1e-9 { + path.curve_to(at(u1, p - a, 0.0), at(u1, p - a - b, 0.0), p3); + } else { + path.line_to(p3); + } + let a0 = (p3 - center).atan2(); + let mut sweep = (p4 - center).atan2() - a0; + while sweep <= -PI { + sweep += TAU; + } + while sweep > PI { + sweep -= TAU; + } + if sweep.abs() > 1e-9 { + kurbo::Arc::new(center, (r, r), a0, sweep, 0.0).to_cubic_beziers(0.01, |p1, p2, q| path.curve_to(p1, p2, q)); + } + let end = at(u2, p, 0.0); + if a + b + c > 1e-9 { + path.curve_to(at(u2, p - a - b, 0.0), at(u2, p - a, 0.0), end); + } else { + path.line_to(end); + } + } + path.close_path(); + path +} + +/// Starts `path` at `p`, or continues it there with a straight segment. +fn line_or_move(path: &mut BezPath, p: Point) { + if path.elements().is_empty() { + path.move_to(p); + } else { + path.line_to(p); + } +} + /// An ellipse in the `width`×`height` box; with arc data, a pie/arc/ring: `start` and `sweep` in /// radians (clockwise from 3 o'clock, y-down) and `inner` radius ratio 0..1. pub fn ellipse(width: f64, height: f64, start: f64, sweep: f64, inner: f64) -> BezPath { diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index 15e24d49..37e6ccab 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -47,6 +47,10 @@ fn degenerate_inputs_dont_panic() { let _ = star_points(10.0, 10.0, 1, -3.0); let _ = rounded_polygon(&[], 4.0); let _ = rounded_polygon(&[Point::ZERO, Point::ZERO, Point::ZERO], 4.0); + let _ = smoothed_polygon(&[Point::ZERO, Point::ZERO, Point::ZERO], 4.0, 0.6); + let _ = smoothed_polygon(&[Point::ZERO, Point::new(10.0, 0.0), Point::new(20.0, 0.0)], 4.0, 1.0); + let _ = smoothed_polygon(&polygon_points(10.0, 10.0, 5), f64::INFINITY, f64::NAN); + let _ = smoothed_polygon(&polygon_points(10.0, 10.0, 5), 3.0, f64::INFINITY); let _ = ellipse(f64::NAN, 10.0, f64::NAN, 100.0, 2.0); } @@ -70,6 +74,56 @@ fn rounded_polygon_shrinks_area() { assert!(round < sharp && round > sharp * 0.8); } +#[test] +fn smoothed_polygon_matches_the_rectangle_corner() { + // A four-sided polygon is a square turned 45°: smoothed, it has the rounded rectangle's area. + let side = 50.0 * std::f64::consts::SQRT_2; + let pts = polygon_points(100.0, 100.0, 4); + for (r, s) in [(10.0, 0.0), (10.0, 0.6), (12.0, 1.0), (30.0, 0.6), (1000.0, 0.6)] { + let poly = area(&smoothed_polygon(&pts, r, s)); + let rect = area(&rounded_rect(side, side, [r; 4], s)); + assert!((poly - rect).abs() < 0.05, "r={r} s={s}: {poly} vs {rect}"); + } +} + +#[test] +fn smoothed_polygon_smooths_every_corner() { + use kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest}; + let near = |p: &BezPath, at: Point| p.segments().map(|seg| seg.nearest(at, 1e-9).distance_sq).fold(f64::INFINITY, f64::min); + // (points, convex: smoothing only cuts area away) + let shapes = [(polygon_points(100.0, 100.0, 3), true), (polygon_points(120.0, 80.0, 6), true), (star_points(100.0, 100.0, 5, 0.38), false)]; + for (pts, convex) in shapes { + let plain = smoothed_polygon(&pts, 8.0, 0.0); + assert_eq!(plain, rounded_polygon(&pts, 8.0), "no smoothing draws the plain rounded corners"); + let sharp = area(&rounded_polygon(&pts, 0.0)); + let mut last = area(&plain); + for s in [0.3, 0.6, 1.0] { + let p = smoothed_polygon(&pts, 8.0, s); + let a = area(&p); + assert!(if convex { a < last } else { (a - last).abs() > 1e-3 }, "s={s}: more smoothing changes the area ({a} after {last})"); + assert!((a - sharp).abs() < sharp * 0.2, "s={s}: the shape stays close to its points"); + last = a; + // The outline has no kinks: each piece leaves in the direction the one before arrives. + let segs: Vec = p.segments().filter(|seg| seg.arclen(1e-6) > 1e-6).collect(); + for (i, seg) in segs.iter().enumerate() { + let next = segs[(i + 1) % segs.len()]; + let out = (seg.eval(1.0) - seg.eval(1.0 - 1e-6)).normalize(); + let into = (next.eval(1e-6) - next.eval(0.0)).normalize(); + assert!(out.dot(into) > 0.999, "s={s}: piece {i} joins the next smoothly ({out:?} then {into:?})"); + } + let b = p.bounding_box(); + assert!(b.x0 >= -1e-6 && b.y0 >= -1e-6 && b.x1 <= 120.0 + 1e-6 && b.y1 <= 100.0 + 1e-6, "s={s} {b:?}"); + // Every vertex is cut off, and the middle of every edge is still on the outline. + for (i, v) in pts.iter().enumerate() { + let next = pts[(i + 1) % pts.len()]; + let mid = v.midpoint(next); + assert!(near(&p, mid) < 1e-6, "s={s}: edge {i} keeps its middle"); + assert!(near(&p, *v) > 0.25, "s={s}: vertex {i} is rounded"); + } + } + } +} + #[test] fn ellipse_ring_area() { let full = area(&ellipse(100.0, 100.0, 0.0, TAU, 0.0)); diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 7714da5a..982119a8 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -128,10 +128,10 @@ pub fn tool_definitions() -> Vec { tool( "batch", "Run commands as one step", - "Run several commands in order as ONE undo step (engine `command.batch`). If any command fails, everything is rolled back and the error names the failing step. Returns an array of the results in order. A later step can use a layer an earlier step created: anywhere in its params, {\"$step\": n} stands for the `id` in step n's result (zero-based; {\"$step\": n, \"field\": \"…\"} picks another field). Example: [{\"command\":\"node.create\",\"params\":{\"type\":\"frame\"}}, {\"command\":\"node.create\",\"params\":{\"type\":\"text\",\"text\":\"Hi\",\"parent\":{\"$step\":0}}}] builds a frame and a text inside it in one call.", + "Run several commands in order as ONE undo step (engine `command.batch`). If any command fails, everything is rolled back and the error names the failing step. Returns an array of the results in order. A later step can use what an earlier step returned: name a step with \"as\": \"card\", then anywhere in a later step's params {\"$ref\": \"card\"} stands for the `id` in its result ({\"$step\": n} does the same by zero-based position). Add \"field\" for another part of the result: a key or a slash path, e.g. {\"$ref\": \"copy\", \"field\": \"ids/0\"} for the first id edit.duplicate returned. Any object with a `$ref` or `$step` key is a reference. Example: [{\"command\":\"node.create\",\"as\":\"card\",\"params\":{\"type\":\"frame\"}}, {\"command\":\"node.create\",\"params\":{\"type\":\"text\",\"text\":\"Hi\",\"parent\":{\"$ref\":\"card\"}}}] builds a frame and a text inside it in one call.", obj( json!({ - "commands": {"type": "array", "minItems": 1, "description": "Steps to run", "items": obj(json!({"command": string("Command id"), "params": {"type": "object"}}), &["command"])}, + "commands": {"type": "array", "minItems": 1, "description": "Steps to run", "items": obj(json!({"command": string("Command id"), "params": {"type": "object"}, "as": string("Name for this step, unique in the batch; later steps use its result with {\"$ref\": name}")}), &["command"])}, "label": string("Undo history label (default \"Batch\")"), }), &["commands"], @@ -173,9 +173,15 @@ pub fn tool_definitions() -> Vec { tool( "inspect_document", "Inspect document", - "The active document as JSON: name, path, dirty flag, current page, selection, the current page's layer tree (id, name, type, x/y relative to the parent frame (groups don't count), width, height, absoluteBounds in page coordinates, fills, strokes, effects, cornerRadius, layout, sizing, text properties, component links, overrides, bound variables), other pages summarised, styles, variable count and the undo history. Use it to verify every change.", + "The active document as JSON: name, path, dirty flag, current page, selection, the current page's layer tree (id, name, type, x/y relative to the parent frame (groups don't count), width, height, absoluteBounds in page coordinates, fills, strokes, effects, cornerRadius, layout, sizing, text properties, component links, overrides, bound variables), other pages summarised, styles, variable count and the undo history. Use it to verify every change. Each level of `depth` multiplies the response, and `allPages` multiplies it again: on a large document the tree is reported at a shallower depth than asked for, with a `truncated` object saying so. Use get_node to read one subtree deeply instead.", obj( - json!({"depth": int("Tree depth for the current page (default 4)"), "allPages": boolean("Expand every page, not only the current one")}), + json!({ + "depth": int("Tree depth for the current page (default 4)"), + "allPages": boolean("Expand every page, not only the current one"), + "maxBytes": int( + "Largest response to send, in bytes of the whole JSON-RPC message (default 8388608, which fits the usual 10 MiB client buffer); 1024 of it is kept for the envelope and the request id" + ), + }), &[], ), true, @@ -183,8 +189,18 @@ pub fn tool_definitions() -> Vec { tool( "get_node", "Get layer", - "One layer as JSON (same fields as inspect_document) with its children to `depth`.", - obj(json!({"id": id_schema("Layer id"), "depth": int("Child depth (default 1)")}), &["id"]), + "One layer as JSON (same fields as inspect_document) with its children to `depth`. A subtree too large for one message is reported at a shallower depth, or with a window of the children, and a `truncated` object says so and gives the `offset` to ask for next.", + obj( + json!({ + "id": id_schema("Layer id"), + "depth": int("Child depth (default 1)"), + "offset": int("First child to report (default 0), for reading a node with more children than fit in one message"), + "maxBytes": int( + "Largest response to send, in bytes of the whole JSON-RPC message (default 8388608, which fits the usual 10 MiB client buffer); 1024 of it is kept for the envelope and the request id" + ), + }), + &["id"], + ), true, ), tool( @@ -497,7 +513,11 @@ fn batch(b: &mut dyn Backend, a: &Map) -> Result) -> Result) -> Result<(usize, usize), String> { + // A caller that knows its own buffer can say so; 1 KiB is below the shallowest tree, so the + // floor only keeps the arithmetic sane. + let max_bytes = match n_arg(a, "maxBytes")? { + Some(v) if v < 1024.0 => return Err("`maxBytes` must be at least 1024".into()), + Some(v) => v.min(usize::MAX as f64) as usize, + None => MAX_RESPONSE, + }; + Ok((max_bytes, max_bytes.saturating_sub(ENVELOPE_RESERVE))) +} + +/// What a value's text block would take on the wire. It is sent as a text block, so it is measured +/// pretty-printed *and* escaped -- escaping adds about a tenth. +fn wire_size(value: &Value) -> usize { + let text = serde_json::to_string_pretty(value).unwrap_or_default(); + serde_json::to_string(&text).map_or(text.len(), |s| s.len()) +} + +/// The document at `depth`, and what its text block would take on the wire. The tree is sent as a +/// text block, so it is measured pretty-printed *and* escaped -- escaping adds about a tenth. +fn inspect_at(b: &mut dyn Backend, depth: u64, all: bool) -> Result<(Value, usize), String> { + let value = b.call("document.inspect", json!({"depth": depth, "allPages": all}))?; + let wire = wire_size(&value); + Ok((value, wire)) +} + +/// One layer's subtree at `depth`, and what its text block would take on the wire. +fn node_at(b: &mut dyn Backend, id: u64, depth: u64) -> Result<(Value, usize), String> { + let value = b.call("node.get", json!({"id": id, "depth": depth}))?; + let wire = wire_size(&value); + Ok((value, wire)) +} + fn inspect_document(b: &mut dyn Backend, a: &Map) -> Result { let depth = n_arg(a, "depth")?.unwrap_or(4.0).clamp(0.0, 64.0) as u64; let all = match a.get("allPages") { @@ -569,17 +644,224 @@ fn inspect_document(b: &mut dyn Backend, a: &Map) -> Result *x, Some(_) => return Err("`allPages` must be a boolean".into()), }; - b.call("document.inspect", json!({"depth": depth, "allPages": all})).map(|v| ToolResult::json(&v)) + let (max_bytes, budget) = budget(a)?; + + let (value, wire) = inspect_at(b, depth, all)?; + if wire <= budget { + return Ok(ToolResult::json(&value)); + } + + let (used, mut value, used_wire) = if depth == 0 { + // Depth 0 is the shallowest tree there is, so there is nothing smaller to send: it goes + // out over the limit with the marker saying so, the same as a bisection that reaches + // depth 0. An error would leave the caller with nothing at all. + (0, value, wire) + } else { + // The deepest tree that fits, by bisection: a deeper tree holds every node of a shallower + // one, so the size does not fall as the depth grows. Bisecting keeps this to about six + // more walks of the document rather than one per level. + let (mut lo, mut hi) = (0u64, depth - 1); + let mut best: Option<(u64, Value, usize)> = None; + while lo <= hi { + let mid = lo + (hi - lo) / 2; + let (v, w) = inspect_at(b, mid, all)?; + if w + MARKER_RESERVE <= budget { + best = Some((mid, v, w)); + if mid == hi { + break; + } + lo = mid + 1; + } else if mid == 0 { + best = Some((0, v, w)); + break; + } else { + hi = mid - 1; + } + } + match best { + Some(found) => found, + None => return Ok(ToolResult::json(&value)), + } + }; + if let Some(obj) = value.as_object_mut() { + obj.insert( + "truncated".into(), + json!({ + "requestedDepth": depth, + "depth": used, + "requestedDepthBytes": wire, + "bytes": used_wire, + "maxBytes": max_bytes, + "reason": format!( + "the tree at depth {depth} would be {wire} bytes on the wire, over the {max_bytes}-byte limit a client can read in one message; it is reported at depth {used} instead" + ), + "hint": "get_node({id, depth}) walks one subtree at a time, within the same limit: a node with more children than fit answers with a window of them and the offset to ask for next", + }), + ); + } + Ok(ToolResult::json(&value)) } +/// One layer's subtree, kept inside what the caller can read. +/// +/// `inspect_document` sends a shallower tree when the document's is too large and points here for +/// the rest, so this tool is the way out of that limit and must not be a way past it: a frame with +/// fifteen thousand children answers with 10.5 MiB at depth 1, over the buffer the report in #912 +/// is about, and no depth makes that smaller because the width is at depth 1. So the subtree is +/// cut by depth first, as the document's is, and then by a window over the children. fn get_node(b: &mut dyn Backend, a: &Map) -> Result { let id = id_arg(a.get("id").ok_or("`id` is required")?)?; let depth = n_arg(a, "depth")?.unwrap_or(1.0).clamp(0.0, 64.0) as u64; - let v = b.call("node.get", json!({"id": id, "depth": depth}))?; - if v.is_null() { + let offset = n_arg(a, "offset")?.unwrap_or(0.0).max(0.0).min(u32::MAX as f64) as usize; + let (max_bytes, budget) = budget(a)?; + + let (value, wire) = node_at(b, id, depth)?; + if value.is_null() { return Err(format!("no such layer {id}")); } - Ok(ToolResult::json(&v)) + if wire <= budget && offset == 0 { + return Ok(ToolResult::json(&value)); + } + + // The deepest subtree that fits, by bisection, as the document's tree is cut. A node asked for + // at a depth of its own children only (1) cannot be made shallower without losing them all, + // so that is where the window takes over. + let (mut used, mut value, mut used_wire) = (depth, value, wire); + if depth > 1 && offset == 0 { + let (mut lo, mut hi) = (1u64, depth - 1); + while lo <= hi { + let mid = lo + (hi - lo) / 2; + let (v, w) = node_at(b, id, mid)?; + if w + MARKER_RESERVE <= budget { + (used, value, used_wire) = (mid, v, w); + if mid == hi { + break; + } + lo = mid + 1; + } else if mid == 1 { + (used, value, used_wire) = (1, v, w); + break; + } else { + hi = mid - 1; + } + } + if used_wire + MARKER_RESERVE <= budget { + return Ok(ToolResult::json(&with_marker(value, depth, used, wire, used_wire, max_bytes, true, None))); + } + } + + // Still over, or a window was asked for: the children from `offset` that fit. + let total = value.get("children").and_then(Value::as_array).map_or(0, Vec::len); + let window = children_window(&mut value, offset, budget.saturating_sub(MARKER_RESERVE)); + let shown = value.get("children").and_then(Value::as_array).map_or(0, Vec::len); + // A child too large to fit on its own is passed over (a page of just the marker), so paging + // moves on and every answer stays within maxBytes; `get_node` on the child reads it. + let after = offset + shown.max(1); + let next = (after < total).then_some(after); + let used_wire = wire_size(&value); + if used_wire + MARKER_RESERVE > budget { + // Even with every child left out, the layer's own fields (a very long name, say) pass the + // limit: an answer would be larger than the caller can read, so there is none. + return Err(format!( + "layer {id} takes {used_wire} bytes on the wire without its children, more than fit in maxBytes {max_bytes}; ask again with a larger `maxBytes`" + )); + } + // Children left out after the window were cut by size; a window that only `offset` moved, of a + // subtree within the limit, is paging. + let over = wire > budget || next.is_some(); + let mut out = with_marker( + value, + depth, + used, + wire, + used_wire, + max_bytes, + over, + Some(json!({ + "from": offset, + "shown": shown, + "total": total, + "nextOffset": next, + })), + ); + if window && shown == total && offset == 0 && used == depth { + // Nothing was left out after all (an offset of zero that fitted whole). + out.as_object_mut().map(|o| o.remove("truncated")); + } + Ok(ToolResult::json(&out)) +} + +/// Keep the children from `offset` that fit in `budget`, and say whether any were left out. The +/// window is found by bisection: a node with thousands of children would otherwise be serialised +/// once per child. +fn children_window(value: &mut Value, offset: usize, budget: usize) -> bool { + let Some(all) = value.get("children").and_then(Value::as_array).cloned() else { return false }; + let rest: Vec = all.iter().skip(offset).cloned().collect(); + let fits = |value: &mut Value, n: usize| { + if let Some(obj) = value.as_object_mut() { + obj.insert("children".into(), Value::Array(rest.iter().take(n).cloned().collect())); + } + wire_size(value) <= budget + }; + if fits(value, rest.len()) { + return rest.len() != all.len(); + } + // The largest window that fits, between none and all of the rest. + let (mut lo, mut hi) = (0usize, rest.len()); + while lo < hi { + let mid = lo + (hi - lo).div_ceil(2); + if fits(value, mid) { lo = mid } else { hi = mid - 1 } + } + fits(value, lo); + true +} + +/// `value` with the `truncated` object saying how it was cut. `over`: the subtree asked for passed +/// the limit (else the window is only the `offset` asked for). `children`: the window, when the +/// children were cut as well as (or instead of) the depth. +fn with_marker( + mut value: Value, + asked: u64, + used: u64, + asked_wire: usize, + used_wire: usize, + max_bytes: usize, + over: bool, + children: Option, +) -> Value { + if let Some(obj) = value.as_object_mut() { + let mut marker = json!({ + "requestedDepth": asked, + "depth": used, + "requestedDepthBytes": asked_wire, + "bytes": used_wire, + "maxBytes": max_bytes, + }); + match &children { + Some(window) => { + marker["children"] = window.clone(); + let from = window.get("from").and_then(Value::as_u64).unwrap_or(0); + marker["reason"] = json!(if over { + format!( + "the subtree at depth {asked} would be {asked_wire} bytes on the wire, over the {max_bytes}-byte limit a client can read in one message; its children are reported from {from} instead" + ) + } else { + format!( + "its children are reported from {from}, the `offset` asked for; the subtree at depth {asked} ({asked_wire} bytes) is within the {max_bytes}-byte limit" + ) + }); + marker["hint"] = json!("ask again with the `offset` in `children.nextOffset` for the rest, or get_node({id, depth}) on one child"); + } + None => { + marker["reason"] = json!(format!( + "the subtree at depth {asked} would be {asked_wire} bytes on the wire, over the {max_bytes}-byte limit a client can read in one message; it is reported at depth {used} instead" + )); + marker["hint"] = json!("get_node({id, depth}) on a child reads deeper, one subtree at a time"); + } + } + obj.insert("truncated".into(), marker); + } + value } fn select(b: &mut dyn Backend, a: &Map) -> Result { diff --git a/crates/mcp/tests/it/agent_tasks.rs b/crates/mcp/tests/it/agent_tasks.rs index b83fc3b8..ae576e12 100644 --- a/crates/mcp/tests/it/agent_tasks.rs +++ b/crates/mcp/tests/it/agent_tasks.rs @@ -343,3 +343,32 @@ fn task_card_in_one_batch() { ); assert!(e.contains("step 0") && e.contains("earlier step"), "{e}"); } + +/// Named steps and a path into a list result: duplicate a frame, then rename the copy through +/// `{"$ref": "copy", "field": "ids/0"}` instead of counting steps. +#[test] +fn task_named_steps_and_a_duplicate_in_one_batch() { + let mut c = Client::new(); + let out = c.ok( + "batch", + json!({"label": "Copy", "commands": [ + {"command": "node.create", "as": "card", "params": {"type": "frame", "name": "Card", "width": 100, "height": 50}}, + {"command": "edit.duplicate", "as": "copy", "params": {"ids": [{"$ref": "card"}]}}, + {"command": "node.set", "params": {"ids": [{"$ref": "copy", "field": "ids/0"}], "props": {"name": "Card copy"}}}, + ]}), + ); + let results = ids(&out); + let copy = results[1]["ids"][0].clone(); + assert_eq!(c.node(©)["name"], "Card copy"); + assert_eq!(c.node(&results[0]["id"])["name"], "Card", "the original keeps its name"); + assert_eq!(c.doc()["history"]["undo"][0], "Copy", "one undo step"); + // An unknown name fails the whole batch, names the step and lists the names there are. + let e = c.err( + "batch", + json!({"commands": [ + {"command": "node.create", "as": "card", "params": {"type": "frame"}}, + {"command": "node.set", "params": {"ids": [{"$ref": "crad"}], "props": {"name": "x"}}}, + ]}), + ); + assert!(e.contains("step 1") && e.contains("\"crad\" names no step") && e.contains("\"card\" is step 0"), "{e}"); +} diff --git a/crates/mcp/tests/it/inspect_response_limit.rs b/crates/mcp/tests/it/inspect_response_limit.rs new file mode 100644 index 00000000..347aa96e --- /dev/null +++ b/crates/mcp/tests/it/inspect_response_limit.rs @@ -0,0 +1,218 @@ +//! `inspect_document` must not emit a message larger than a client can read in one piece: MCP +//! stdio is newline-delimited JSON, so one result is one message, and a client whose read buffer +//! it exceeds closes the pipe and kills the server for the rest of the session (#912). +use crate::common; + +use common::Client; +use serde_json::{Value, json}; + +/// The allowance `inspect_document` keeps for the content object, the JSON-RPC envelope and the +/// request id (`ENVELOPE_RESERVE`). +const ENVELOPE: usize = 1024; + +/// A document whose tree is far larger than the limits used below. +fn busy() -> Client { + let mut c = Client::new(); + for i in 0..40 { + let frame = c.create(json!({"type": "frame", "x": i * 10, "y": 0, "width": 200, "height": 200})); + for j in 0..12 { + c.create(json!({"type": "rectangle", "parent": frame, "x": j * 5, "y": 5, "width": 20, "height": 20})); + } + } + c +} + +/// The response and what its text block takes on the wire, escaped as the transport sends it. +fn inspect(c: &mut Client, args: Value) -> (Value, usize) { + let r = c.call("inspect_document", args.clone()); + assert_eq!(r["isError"], false, "{args}: {}", r["content"][0]["text"]); + let text = r["content"][0]["text"].as_str().expect("a text block"); + let wire = serde_json::to_string(&text).unwrap().len(); + (serde_json::from_str(text).expect("the block is JSON"), wire) +} + +#[test] +fn a_tree_too_large_for_the_client_is_reported_shallower_and_says_so() { + let mut c = busy(); + let (full, full_wire) = inspect(&mut c, json!({"depth": 6, "allPages": true})); + assert!(full["truncated"].is_null(), "a tree within the limit is not truncated: {}", full["truncated"]); + assert!(full_wire > 30_000, "the fixture has to outgrow the limits below: {full_wire}"); + + // Asked for more than the caller can read: a shallower tree, inside the limit, and a marker. + let (cut, cut_wire) = inspect(&mut c, json!({"depth": 6, "allPages": true, "maxBytes": 30_000})); + assert!(cut_wire <= 30_000, "the response still exceeds maxBytes: {cut_wire}"); + let t = &cut["truncated"]; + assert_eq!(t["requestedDepth"], 6, "{t}"); + assert!(t["depth"].as_u64().unwrap() < 6, "the depth was reduced: {t}"); + assert_eq!(t["maxBytes"], 30_000, "{t}"); + assert_eq!(t["requestedDepthBytes"], full_wire, "the marker says what the full tree would have taken: {t}"); + // The marker points at the way to read the rest. + assert!(t["hint"].as_str().unwrap_or("").contains("get_node"), "{t}"); + // Still a usable document, not just a marker: the page and its top-level layers are there. + assert!(!cut["currentPage"].is_null(), "{cut}"); + assert!(cut["pages"][0]["children"].as_array().is_some_and(|c| !c.is_empty()), "{cut}"); + + // The deepest tree that fits is the one sent: a limit that admits the whole tree is not cut. + // maxBytes is the whole message, so the tree fits under it with room for the envelope, not + // at it: a text block of exactly maxBytes would be sent inside a larger message than that. + let (whole, _) = inspect(&mut c, json!({"depth": 6, "allPages": true, "maxBytes": full_wire + ENVELOPE})); + assert!(whole["truncated"].is_null(), "a limit that leaves room for the envelope is not a truncation: {}", whole["truncated"]); + let (tight, _) = inspect(&mut c, json!({"depth": 6, "allPages": true, "maxBytes": full_wire})); + assert_eq!(tight["truncated"]["requestedDepth"], 6, "a limit at the exact size of the tree leaves none for the envelope: {tight}"); +} + +/// Every over-limit response carries the marker, however the depth was arrived at: a caller that +/// asked for depth 0 has no other way to learn that its limit was passed. +#[test] +fn a_depth_0_response_over_the_limit_says_so_too() { + let mut c = busy(); + let (deep, _) = inspect(&mut c, json!({"depth": 0, "allPages": true})); + assert!(deep["truncated"].is_null(), "within the default limit: {}", deep["truncated"]); + let (v, wire) = inspect(&mut c, json!({"depth": 0, "allPages": true, "maxBytes": 1024})); + let t = &v["truncated"]; + assert_eq!((t["requestedDepth"].as_u64(), t["depth"].as_u64()), (Some(0), Some(0)), "{t}"); + // `bytes` is the tree, measured before the marker was added to it, so the message that goes + // out is larger still -- there being nothing smaller to send. + assert_eq!(t["bytes"], t["requestedDepthBytes"], "{t}"); + assert!(wire > 1024, "the depth-0 tree is sent over the limit, with the marker saying so: {wire}"); + assert!(!v["pages"].is_null(), "the tree is still sent, there being nothing smaller: {v}"); +} + +#[test] +fn a_limit_below_the_shallowest_tree_still_answers() { + // Nothing smaller than depth 0 exists, so it is sent with the marker rather than an error: + // an error would leave the caller with nothing at all. + let mut c = busy(); + let (v, _) = inspect(&mut c, json!({"depth": 6, "allPages": true, "maxBytes": 1024})); + assert_eq!(v["truncated"]["depth"], 0, "{}", v["truncated"]); + assert_eq!(v["truncated"]["requestedDepth"], 6, "{}", v["truncated"]); +} + +#[test] +fn maxbytes_is_checked() { + let mut c = Client::new(); + let r = c.call("inspect_document", json!({"depth": 1, "maxBytes": 10})); + assert_eq!(r["isError"], true, "{r}"); + assert!(r["content"][0]["text"].as_str().unwrap_or("").contains("at least 1024"), "{r}"); +} + +/// `inspect_document` sends a shallower tree and points at `get_node` for the rest, so `get_node` +/// must keep to the same limit -- it was unbounded. Measured on `main`: a frame with 15,000 +/// children answers with a 10.5 MiB message at depth 1, over the 10 MiB buffer the report is +/// about, and no depth makes that smaller because the width is at depth 1. The children are +/// windowed instead, with the offset to ask for next. +#[test] +fn a_node_with_more_children_than_fit_is_windowed() { + let mut c = Client::new(); + let frame = c.create(json!({"type": "frame", "x": 0, "y": 0, "width": 400, "height": 400})); + for i in 0..40 { + c.create(json!({"type": "rectangle", "parent": frame, "x": i * 5, "y": 5, "width": 20, "height": 20})); + } + + // Whole, to measure against: every child, no marker. + let (whole, whole_wire) = get_node(&mut c, json!({"id": frame, "depth": 1})); + let total = whole["children"].as_array().expect("children").len(); + assert_eq!(total, 40, "{whole}"); + assert!(whole["truncated"].is_null(), "within the default limit: {}", whole["truncated"]); + + // A limit that holds only some of them: a window, and the marker says how many and what next. + let limit = whole_wire / 3; + assert!(limit >= 1024, "the fixture has to outgrow the floor: {whole_wire}"); + let (cut, cut_wire) = get_node(&mut c, json!({"id": frame, "depth": 1, "maxBytes": limit})); + assert!(cut_wire <= limit, "the response still exceeds maxBytes: {cut_wire} > {limit}"); + let window = &cut["truncated"]["children"]; + let shown = cut["children"].as_array().expect("children").len(); + assert_eq!(window["from"], json!(0), "{window}"); + assert_eq!(window["shown"], json!(shown), "{window}"); + assert_eq!(window["total"], json!(total), "{window}"); + assert!(shown > 0 && shown < total, "some children, not all: {shown} of {total}"); + assert_eq!(window["nextOffset"], json!(shown), "the offset to ask for next: {window}"); + assert!(cut["truncated"]["reason"].as_str().unwrap_or("").contains("over the"), "cut by size: {}", cut["truncated"]); + // The layer itself is still there, not just a marker. + assert_eq!(cut["id"], whole["id"]); + + // Paging through with that offset reaches every child exactly once, in order. + let mut seen: Vec = Vec::new(); + let mut offset = Some(0u64); + while let Some(at) = offset { + let (page, wire) = get_node(&mut c, json!({"id": frame, "depth": 1, "maxBytes": limit, "offset": at})); + assert!(wire <= limit, "page at {at} exceeds maxBytes: {wire}"); + seen.extend(page["children"].as_array().cloned().unwrap_or_default()); + offset = page["truncated"]["children"]["nextOffset"].as_u64(); + assert!(seen.len() <= total, "a page repeated a child: {} of {total}", seen.len()); + } + assert_eq!(seen.len(), total, "every child was reported once"); + assert_eq!(seen, whole["children"].as_array().cloned().unwrap_or_default(), "in the same order"); +} + +/// The response and what its text block takes on the wire, for one `get_node` call. +fn get_node(c: &mut Client, args: Value) -> (Value, usize) { + let r = c.call("get_node", args.clone()); + assert_eq!(r["isError"], false, "{args}: {}", r["content"][0]["text"]); + let text = r["content"][0]["text"].as_str().expect("a text block"); + let wire = serde_json::to_string(&text).unwrap().len() + ENVELOPE; + (serde_json::from_str(text).expect("the block is JSON"), wire) +} + +/// A child too large for the limit on its own is passed over, one per page of just the marker, +/// so paging moves on and every page stays within the limit. +#[test] +fn children_too_large_to_fit_page_one_at_a_time() { + let mut c = Client::new(); + let frame = c.create(json!({"type": "frame", "x": 0, "y": 0, "width": 400, "height": 400})); + for i in 0..3 { + c.create(json!({"type": "rectangle", "parent": frame, "x": i * 5, "y": 5, "width": 20, "height": 20, "name": "n".repeat(3000)})); + } + let mut offset = Some(0u64); + let mut pages = 0; + while let Some(at) = offset { + let (page, wire) = get_node(&mut c, json!({"id": frame, "depth": 1, "maxBytes": 2500, "offset": at})); + assert!(wire <= 2500, "page at {at} exceeds maxBytes: {wire}"); + assert_eq!(page["children"].as_array().map_or(0, Vec::len), 0, "{}", page["truncated"]); + assert!(!page["truncated"].is_null(), "the marker stays: page at {at}"); + offset = page["truncated"]["children"]["nextOffset"].as_u64(); + assert!(offset.is_none_or(|o| o > at), "paging moves on from {at}"); + pages += 1; + assert!(pages <= 3); + } + assert_eq!(pages, 3); + + // A sole child passed over still says the answer was cut. + let single = c.create(json!({"type": "frame", "x": 500, "y": 0, "width": 40, "height": 40})); + c.create(json!({"type": "rectangle", "parent": single, "x": 0, "y": 0, "width": 20, "height": 20, "name": "n".repeat(3000)})); + let (page, _) = get_node(&mut c, json!({"id": single, "depth": 1, "maxBytes": 2500})); + assert_eq!(page["children"].as_array().map_or(0, Vec::len), 0); + assert_eq!(page["truncated"]["children"]["total"], json!(1), "{}", page["truncated"]); +} + +/// A layer whose own fields pass the limit with every child left out is refused: an answer would +/// be over maxBytes, and the marker would only make it larger. +#[test] +fn a_layer_too_large_on_its_own_is_refused() { + let mut c = Client::new(); + let rect = c.create(json!({"type": "rectangle", "x": 0, "y": 0, "width": 20, "height": 20, "name": "n".repeat(3000)})); + let r = c.call("get_node", json!({"id": rect, "maxBytes": 2500})); + assert_eq!(r["isError"], true, "{r}"); + assert!(r["content"][0]["text"].as_str().unwrap_or("").contains("maxBytes"), "{r}"); + // A layer of ordinary size answers within the same limit. + let small = c.create(json!({"type": "rectangle", "x": 0, "y": 0, "width": 20, "height": 20})); + let (v, wire) = get_node(&mut c, json!({"id": small, "maxBytes": 2500})); + assert!(wire <= 2500 && v["truncated"].is_null(), "{wire}: {v}"); +} + +/// A window that only an `offset` made, of a subtree within the limit, is paging: the marker +/// doesn't say the limit was passed. +#[test] +fn an_offset_alone_pages_without_claiming_the_limit() { + let mut c = Client::new(); + let frame = c.create(json!({"type": "frame", "x": 0, "y": 0, "width": 400, "height": 400})); + for i in 0..3 { + c.create(json!({"type": "rectangle", "parent": frame, "x": i * 5, "y": 5, "width": 20, "height": 20})); + } + let (page, _) = get_node(&mut c, json!({"id": frame, "depth": 1, "offset": 1})); + let t = &page["truncated"]; + assert_eq!(page["children"].as_array().map_or(0, Vec::len), 2, "{t}"); + assert!(t["children"]["nextOffset"].is_null(), "{t}"); + let reason = t["reason"].as_str().unwrap_or(""); + assert!(reason.contains("offset") && !reason.contains("over the"), "{reason}"); +} diff --git a/crates/mcp/tests/it/main.rs b/crates/mcp/tests/it/main.rs index b5e3d89d..b16fc98e 100644 --- a/crates/mcp/tests/it/main.rs +++ b/crates/mcp/tests/it/main.rs @@ -13,6 +13,8 @@ mod export_hidden_roots; #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod garbage; #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod inspect_response_limit; +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod protocol; #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod screenshot_pixel_limit; diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index 324af4d4..f4b30e10 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -20,7 +20,7 @@ pub mod width; #[cfg(test)] mod width_tests; -use figcraft_doc::{BoolOp, StrokeAlign, StrokeCap, StrokeJoin, StrokeProps, VectorNetwork}; +use figcraft_doc::{BoolOp, StrokeAlign, StrokeCap, StrokeJoin, StrokeProps, VectorNetwork, WindingRule}; use figcraft_geom::kurbo::{self, BezPath, Cap, Join, PathEl, PathSeg, Point, Shape, Vec2}; #[derive(Debug, PartialEq)] @@ -69,13 +69,18 @@ fn sweep(f: impl FnOnce() -> Result) -> Result Result { + binary_by(a, b, linesweeper::FillRule::NonZero, op) +} + +/// [`binary`] with both operands read by fill rule `rule`. +fn binary_by(a: &BezPath, b: &BezPath, rule: linesweeper::FillRule, op: linesweeper::BinaryOp) -> Result { if a.elements().is_empty() && b.elements().is_empty() { return Ok(BezPath::new()); } if !finite(a) || !finite(b) { return Err(PathOpsError::BadInput); } - let contours = sweep(|| linesweeper::binary_op(&closed(a), &closed(b), linesweeper::FillRule::NonZero, op).map_err(|_| PathOpsError::BadInput))?; + let contours = sweep(|| linesweeper::binary_op(&closed(a), &closed(b), rule, op).map_err(|_| PathOpsError::BadInput))?; let mut out = BezPath::new(); for c in contours.contours() { out.extend(c.path.elements().iter().copied()); @@ -323,16 +328,20 @@ pub fn stroke_outline_of(path: &BezPath, props: &StrokeProps, closed_shape: bool (None, None, None) => stroke_center(path, width, props), }; let align = if closed_shape { props.align } else { StrokeAlign::Center }; + // Inside/Outside clip against the shape's own fill, so an even-odd shape's holes stay holes. + let even_odd = net.is_some_and(|n| n.regions.iter().any(|r| r.winding == WindingRule::EvenOdd)); + let clip = |s: BezPath, op: linesweeper::BinaryOp| { + if even_odd { + // The stroke overlaps itself (non-zero): flatten it to even-odd contours first. + simplify(&s).and_then(|s| binary_by(&s, path, linesweeper::FillRule::EvenOdd, op)).unwrap_or(s) + } else { + binary(&s, path, op).unwrap_or(s) + } + }; let mut out = match align { StrokeAlign::Center => centred(w), - StrokeAlign::Inside => { - let s = centred(w * 2.0); - binary(&s, path, linesweeper::BinaryOp::Intersection).unwrap_or(s) - } - StrokeAlign::Outside => { - let s = centred(w * 2.0); - binary(&s, path, linesweeper::BinaryOp::Difference).unwrap_or(s) - } + StrokeAlign::Inside => clip(centred(w * 2.0), linesweeper::BinaryOp::Intersection), + StrokeAlign::Outside => clip(centred(w * 2.0), linesweeper::BinaryOp::Difference), }; if let Some(st) = styled.as_ref().filter(|_| !closed_shape) { out.extend(st.arrowheads(w, props).elements().iter().copied()); @@ -709,4 +718,28 @@ mod tests { let s = stroke_center(&line, 2.0, &props); assert!((s.area().abs() - 100.0).abs() < 2.0, "{}", s.area()); } + + #[test] + fn inside_and_outside_strokes_respect_an_even_odd_hole() { + // A ring: both loops run the same way, so only the even-odd rule leaves the hole open. + let mut ring = sq(0.0, 0.0, 100.0); + ring.extend(sq(30.0, 30.0, 40.0).elements().iter().copied()); + let mut net = VectorNetwork::from_path(&ring); + for r in &mut net.regions { + r.winding = figcraft_doc::WindingRule::EvenOdd; + } + let (material, hole) = (Point::new(29.0, 50.0), Point::new(31.0, 50.0)); + let inside = StrokeProps { weight: 4.0, align: StrokeAlign::Inside, ..Default::default() }; + let s = stroke_outline_of(&ring, &inside, true, Some(&net)); + assert_ne!(s.winding(material), 0, "inside stroke misses the material"); + assert_eq!(s.winding(hole), 0, "inside stroke leaks into the hole"); + let outside = StrokeProps { align: StrokeAlign::Outside, ..inside.clone() }; + let s = stroke_outline_of(&ring, &outside, true, Some(&net)); + assert_eq!(s.winding(material), 0, "outside stroke covers the material"); + assert_ne!(s.winding(hole), 0, "outside stroke misses the hole"); + // Non-zero: the inner loop doesn't cut a hole, so its inside stroke covers both sides. + let nz = VectorNetwork::from_path(&ring); + let s = stroke_outline_of(&ring, &inside, true, Some(&nz)); + assert!(s.winding(material) != 0 && s.winding(hole) != 0); + } } diff --git a/crates/render/src/boolean_cache_tests.rs b/crates/render/src/boolean_cache_tests.rs index 08a89d3a..6e10f746 100644 --- a/crates/render/src/boolean_cache_tests.rs +++ b/crates/render/src/boolean_cache_tests.rs @@ -153,3 +153,103 @@ fn deep_boolean_nesting_stops_at_the_outline_depth_limit() { d.create(inner, NodeKind::Rectangle, "R", 40.0, 10.0, 20.0, 20.0).unwrap(); assert!((outline(&d, b).area().abs() - 2.0 * 20.0 * 20.0).abs() < 0.5); } + +#[test] +fn stroke_only_boolean_operands_keep_their_holes() { + use figcraft_doc::StrokeAlign; + use figcraft_geom::Point; + + for kind in [NodeKind::Polygon { count: 6 }, NodeKind::Rectangle, NodeKind::Line] { + for align in [StrokeAlign::Inside, StrokeAlign::Center, StrokeAlign::Outside] { + let mut d = Document::new("stroke operands"); + let page = d.first_page().unwrap(); + let b = d.create(page, NodeKind::BooleanOp { op: BoolOp::Subtract }, "B", 0.0, 0.0, 100.0, 100.0).unwrap(); + d.get_mut(b).unwrap().fills = vec![Paint::solid(Color::BLACK)]; + // A nested group exercises the same path as a direct operand. + let g = d.create(b, NodeKind::Group, "G", 0.0, 0.0, 100.0, 100.0).unwrap(); + let id = d.create(g, kind.clone(), "stroke", 10.0, 10.0, 80.0, 80.0).unwrap(); + let n = d.get_mut(id).unwrap(); + let mut hidden_fill = Paint::solid(Color::BLACK); + hidden_fill.visible = false; + n.fills = vec![hidden_fill]; + n.strokes = vec![Paint::solid(Color::BLACK)]; + n.stroke.weight = 6.0; + n.stroke.align = align; + let path = outline(&d, b); + assert_eq!(path.winding(Point::new(50.0, 45.0)), 0, "{kind:?} {align:?}: empty interior"); + assert!(path.area().abs() > 100.0, "{kind:?} {align:?}: stroke survives"); + assert!(!dark(&render(&d, page), 50, 45)); + if !matches!(kind, NodeKind::Line) { + // Changing only stroke weight invalidates the boolean result. + let area = path.area().abs(); + d.get_mut(id).unwrap().stroke.weight = 3.0; + assert!(outline(&d, b).area().abs() < area); + d.get_mut(id).unwrap().fills[0].visible = true; + assert_ne!(outline(&d, b).winding(Point::new(50.0, 45.0)), 0, "visible fills keep the interior"); + } + } + } +} + +#[test] +fn boolean_operands_preserve_side_borders_and_container_children() { + use figcraft_doc::StrokeAlign; + use figcraft_geom::Point; + for kind in [NodeKind::Rectangle, NodeKind::Frame] { + let mut d = Document::new("borders"); + let page = d.first_page().unwrap(); + let b = d.create(page, NodeKind::BooleanOp { op: BoolOp::Union }, "B", 0.0, 0.0, 100.0, 100.0).unwrap(); + let id = d.create(b, kind.clone(), "operand", 0.0, 0.0, 100.0, 100.0).unwrap(); + let n = d.get_mut(id).unwrap(); + n.fills.clear(); + n.strokes = vec![Paint::solid(Color::BLACK)]; + n.stroke.weight = 8.0; + n.stroke.align = StrokeAlign::Inside; + n.stroke.sides = Some([8.0, 0.0, 0.0, 0.0]); + if matches!(kind, NodeKind::Frame) { + d.create(id, NodeKind::Rectangle, "child", 40.0, 40.0, 20.0, 20.0).unwrap(); + } + let p = outline(&d, b); + assert_ne!(p.winding(Point::new(50.0, 4.0)), 0, "top border"); + assert_eq!(p.winding(Point::new(98.0, 50.0)), 0, "no right border"); + assert_eq!(p.winding(Point::new(50.0, 98.0)), 0, "no bottom border"); + if matches!(kind, NodeKind::Frame) { + assert_ne!(p.winding(Point::new(50.0, 50.0)), 0, "child retained"); + } + d.get_mut(id).unwrap().stroke.sides = Some([0.0, 8.0, 0.0, 0.0]); + let p = outline(&d, b); + assert_eq!(p.winding(Point::new(50.0, 4.0)), 0, "side edit removes top"); + assert_ne!(p.winding(Point::new(98.0, 50.0)), 0, "side edit adds right"); + d.get_mut(id).unwrap().stroke.sides = None; + assert_ne!(outline(&d, b).winding(Point::new(50.0, 4.0)), 0, "uniform container border"); + } +} + +#[test] +fn stroke_only_text_operands_use_glyphs_instead_of_the_text_box() { + use figcraft_doc::{StrokeAlign, TextData}; + let mut d = Document::new("text strokes"); + let page = d.first_page().unwrap(); + let b = d.create(page, NodeKind::BooleanOp { op: BoolOp::Union }, "B", 0.0, 0.0, 400.0, 100.0).unwrap(); + let mut text = TextData::new("Hi"); + text.style.font_size = 32.0; + let id = d.create(b, NodeKind::Text(text), "text", 0.0, 0.0, 400.0, 100.0).unwrap(); + let n = d.get_mut(id).unwrap(); + n.fills.clear(); + n.strokes = vec![Paint::solid(Color::BLACK)]; + n.stroke.weight = 2.0; + n.stroke.align = StrokeAlign::Center; + let glyphs = outline(&d, id); + let result = outline(&d, b); + assert_eq!(result.elements().is_empty(), glyphs.elements().is_empty()); + if !glyphs.elements().is_empty() { + let expected = glyphs.bounding_box().inflate(3.0, 3.0); + let actual = result.bounding_box(); + assert!(actual.x0 >= expected.x0 && actual.x1 <= expected.x1); + assert!(actual.y0 >= expected.y0 && actual.y1 <= expected.y1); + assert!(actual.x1 < 200.0, "the unused text box must not contribute a border"); + } + let NodeKind::Text(text) = &mut d.get_mut(id).unwrap().kind else { panic!() }; + text.characters = " ".into(); + assert!(outline(&d, b).elements().is_empty(), "spaces have no stroked glyphs"); +} diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 3718df26..f36c51fa 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -209,6 +209,18 @@ pub fn stroke_area(n: &Node, path: &BezPath, closed: bool) -> Arc { return Arc::new(o); } } + cached_stroke_outline(n, path, closed) +} + +/// A non-zero stroke outline, shared by painting and boolean operands. +fn cached_stroke_outline(n: &Node, path: &BezPath, closed: bool) -> Arc { + let props = &n.stroke; + 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 mut h = std::collections::hash_map::DefaultHasher::new(); hash_path(path, &mut h); (props.weight.to_bits(), props.align as u8, props.cap_start as u8, props.cap_end as u8, props.join as u8, props.miter_limit.to_bits(), closed) @@ -217,11 +229,12 @@ pub fn stroke_area(n: &Node, path: &BezPath, closed: bool) -> Arc { d.to_bits().hash(&mut h); } props.dashes.len().hash(&mut h); - // Points' own caps and joins (vector networks). + // Points' own caps and joins and the fill rule Inside/Outside clip by (vector networks). let net = match &n.kind { - NodeKind::Vector { network } if network.has_point_styles(props.join) => Some(network), + NodeKind::Vector { network } => Some(network), _ => None, }; + net.is_some_and(|n| n.regions.iter().any(|r| r.winding == figcraft_doc::WindingRule::EvenOdd)).hash(&mut h); for v in net.map(|n| n.vertices.as_slice()).unwrap_or_default() { (v.cap.map(|c| c as u8), v.join.map(|j| j as u8)).hash(&mut h); } @@ -280,7 +293,7 @@ fn node_outline_at(doc: &Document, n: &Node, depth: usize) -> Option<(BezPath, F .filter_map(|c| doc.node(*c)) .filter(|c| c.visible) .filter_map(|c| { - let (mut p, _) = subtree_outline(doc, c, depth + 1)?; + let (mut p, _) = subtree_outline_at(doc, c, depth + 1, true)?; p.apply_affine(c.transform); Some((p, c.bool_op.unwrap_or(*op))) }) @@ -304,6 +317,11 @@ fn node_outline_at(doc: &Document, n: &Node, depth: usize) -> Option<(BezPath, F /// The union outline of a node and its descendants (for booleans of groups/frames). pub fn subtree_outline(doc: &Document, n: &Node, depth: usize) -> Option<(BezPath, FillRule)> { + subtree_outline_at(doc, n, depth, false) +} + +/// Boolean operands with only a stroke contribute the painted band, not their interior. +fn subtree_outline_at(doc: &Document, n: &Node, depth: usize, stroke_only: bool) -> Option<(BezPath, FillRule)> { if depth > 64 { return None; } @@ -312,21 +330,32 @@ pub fn subtree_outline(doc: &Document, n: &Node, depth: usize) -> Option<(BezPat let mut out = BezPath::new(); if n.kind.is_frame_like() && let Some(p) = figcraft_doc::geometry::shape_path(n) - && n.fills.iter().any(|f| f.visible) { - out.extend(p.elements().iter().copied()); + if n.fills.iter().any(|f| f.visible) { + out.extend(p.elements().iter().copied()); + } else if stroke_only && n.has_visible_stroke() { + out.extend(cached_stroke_outline(n, &p, true).elements().iter().copied()); + } } for c in n.children.iter().filter_map(|c| doc.node(*c)) { if !c.visible { continue; } - if let Some((mut p, _)) = subtree_outline(doc, c, depth + 1) { + if let Some((mut p, _)) = subtree_outline_at(doc, c, depth + 1, stroke_only) { p.apply_affine(c.transform); out.extend(p.elements().iter().copied()); } } Some((out, FillRule::Winding)) } + _ if stroke_only && !n.fills.iter().any(|p| p.visible) && n.has_visible_stroke() => { + let (path, _) = node_outline_at(doc, n, depth)?; + let path = if matches!(n.kind, NodeKind::Text(_)) { path } else { figcraft_doc::geometry::stroke_geometry(n).unwrap_or(path) }; + let closed = figcraft_doc::geometry::is_closed(n) || matches!(n.kind, NodeKind::BooleanOp { .. }); + // Boolean folding uses non-zero winding; the canvas stroke cache also contains + // even-odd rectangle rings, so use the winding-correct stroke outline here. + Some(((*cached_stroke_outline(n, &path, closed)).clone(), FillRule::Winding)) + } _ => node_outline_at(doc, n, depth), } } diff --git a/crates/svg/src/import/budget.rs b/crates/svg/src/import/budget.rs index e7ae3fc6..75a5e673 100644 --- a/crates/svg/src/import/budget.rs +++ b/crates/svg/src/import/budget.rs @@ -74,7 +74,7 @@ fn embedded(used: &Used, limits: &Limits, data: &[u8], opts: &Options) -> Option let left = limits.embedded.saturating_sub(used.bytes.load(Ordering::Relaxed)); let plain = inflate(data, left).ok()?; used.bytes.fetch_add(plain.len(), Ordering::Relaxed); - if super::nesting(&plain, super::MAX_NESTING) > super::MAX_NESTING { + if super::too_deep(&plain) { return None; } used.depth.fetch_add(1, Ordering::Relaxed); diff --git a/crates/svg/src/import/hostile_tests.rs b/crates/svg/src/import/hostile_tests.rs index bb750286..0af1297d 100644 --- a/crates/svg/src/import/hostile_tests.rs +++ b/crates/svg/src/import/hostile_tests.rs @@ -166,3 +166,18 @@ fn nesting_is_measured_before_parsing() { let p = d.first_page().unwrap(); assert!(import_svg(&mut d, &svg(&deep), p, 0.0, 0.0, "x").is_err()); } + +#[test] +fn entity_expansion_cannot_bypass_the_nesting_limit() { + // An internal-DTD entity whose value nests 300 deep, referenced inside a body nesting 300 deep: + // the parsed tree is ~600 deep although the raw bytes never are. + let inner = format!("{}{}", "".repeat(300), "".repeat(300)); + let body = format!("{}&deep;{}", "".repeat(300), "".repeat(300)); + let data = format!( + "]>{body}" + ); + let mut d = Document::new("t"); + let p = d.first_page().unwrap(); + let err = import_svg(&mut d, data.as_bytes(), p, 0.0, 0.0, "x").err().map(|e| e.to_string()).unwrap_or_default(); + assert!(err.contains("nested more than 512 deep"), "{err:?}"); +} diff --git a/crates/svg/src/import/mod.rs b/crates/svg/src/import/mod.rs index 3aed363a..dc936260 100644 --- a/crates/svg/src/import/mod.rs +++ b/crates/svg/src/import/mod.rs @@ -672,6 +672,42 @@ pub(crate) fn nesting(data: &[u8], limit: usize) -> usize { max } +/// Whether `data` nests elements more than [`MAX_NESTING`] deep: the byte scan first, then the +/// parsed tree (walked without recursion), which also counts what DTD entity references expand to. +pub(crate) fn too_deep(data: &[u8]) -> bool { + if nesting(data, MAX_NESTING) > MAX_NESTING { + return true; + } + let Ok(text) = std::str::from_utf8(data) else { return false }; + let opt = usvg::roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }; + let Ok(xml) = usvg::roxmltree::Document::parse_with_options(text, opt) else { return false }; + // Depth-first by sibling and parent links; the root is depth 0, each child one deeper. + let (mut node, mut depth) = (xml.root(), 0usize); + loop { + if let Some(child) = node.first_child() { + node = child; + depth += 1; + if depth > MAX_NESTING { + return true; + } + continue; + } + loop { + if let Some(next) = node.next_sibling() { + node = next; + break; + } + match node.parent() { + Some(up) if depth > 0 => { + node = up; + depth -= 1; + } + _ => return false, + } + } + } +} + fn options(data: &[u8], limits: budget::Limits) -> usvg::Options<'static> { // Only data URIs, within budgets: an opened or pasted SVG never pulls other files off the disk // and never expands without limit. @@ -728,7 +764,7 @@ fn import_within( ) -> Result { // SVGZ is unpacked here, within a budget, so every check below sees the real document. let data = budget::document(data, &limits).map_err(ImportError)?; - if nesting(&data, MAX_NESTING) > MAX_NESTING { + if too_deep(&data) { return Err(ImportError(format!("elements nested more than {MAX_NESTING} deep"))); } let tree = usvg::Tree::from_data(&data, &options(&data, limits)).map_err(|e| ImportError(e.to_string()))?; diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 656307d9..449426d2 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -126,9 +126,24 @@ fn path_d(p: &BezPath) -> String { s } +/// A matrix scale/shear coefficient to 6 significant digits (unlike [`num`], small values survive). +fn coeff(v: f64) -> String { + if !v.is_finite() || v == 0.0 { + return "0".into(); + } + let digits = 5 - v.abs().log10().floor() as i32; + let p = 10f64.powi(digits); + let r = (v * p).round() / p; + format!("{r}") +} + fn matrix(a: Affine) -> String { let c = a.as_coeffs(); - format!("matrix({} {} {} {} {} {})", num(c[0]), num(c[1]), num(c[2]), num(c[3]), num(c[4]), num(c[5])) + // Snap float noise (e.g. 6e-17 from a 90 degree rotation) to zero, relative to the largest term. + let eps = c[..4].iter().fold(0.0f64, |m, v| m.max(v.abs())) * 1e-12; + let eps = eps.max(1e-12); + let k = |v: f64| if v.abs() <= eps { "0".to_string() } else { coeff(v) }; + format!("matrix({} {} {} {} {} {})", k(c[0]), k(c[1]), k(c[2]), k(c[3]), num(c[4]), num(c[5])) } /// A view of page area `b` at `k` device pixels per unit (fewer when that makes it larger than diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs index dedeca3b..bfce1cf4 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -264,3 +264,16 @@ fn a_filled_open_curve_is_closed_for_its_fill() { }; assert_eq!(d.nodes().map(loops).sum::(), 1); } + +#[test] +fn matrix_keeps_small_scale_precision() { + assert_eq!(matrix(Affine::scale(0.0375)), "matrix(0.0375 0 0 0.0375 0 0)"); + assert_eq!(matrix(Affine::scale(0.0001)), "matrix(0.0001 0 0 0.0001 0 0)"); +} + +#[test] +fn matrix_snaps_rotation_noise_and_tiny_values() { + let r = Affine::rotate(std::f64::consts::FRAC_PI_2); + assert_eq!(matrix(r), "matrix(0 1 -1 0 0 0)"); + assert_eq!(matrix(Affine::scale(1e-320)), "matrix(0 0 0 0 0 0)"); +} diff --git a/crates/text/build.rs b/crates/text/build.rs index 05dfc653..ba087983 100644 --- a/crates/text/build.rs +++ b/crates/text/build.rs @@ -1,32 +1,26 @@ //! Embeds the optional craft-fonts build input (`CRAFT_FONTS_DIR`): both `fonts/manifest.txt` and the //! opt-in Latin presentation fonts in `fonts/latin-manifest.txt` (Inter, Roboto, …). Unset or missing, -//! `CRAFT_FONTS` is empty and the app uses system fonts. Recipe: craft-fonts `docs/integration.md`. +//! `CRAFT_FONTS` is empty and native apps use system fonts. Required web releases enforce Inter +//! Regular/SemiBold and Noto Sans Georgian Regular after filtering. Recipe: craft-fonts `docs/integration.md`. //! Noto Sans CJK only with `FIGCRAFT_EMBED_CJK` set (the web build's only way to show CJK UI text). use std::fmt::Write as _; use std::path::PathBuf; +/// Font build script: emit `craft_fonts.rs` from `input_fonts`, passing the +/// `CRAFT_FONTS_DIR`, `CRAFT_FONTS_REQUIRED`, wasm target and `FIGCRAFT_EMBED_CJK` inputs; +/// report generation and write failures with `cargo::error`. fn main() { println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR"); println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED"); println!("cargo::rerun-if-env-changed=FIGCRAFT_EMBED_CJK"); let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n"); - 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"] { - match craft_fonts(&dir, manifest) { - Ok(entries) => { - ok = true; - src.push_str(&entries); - } - Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => { - println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display()); - } - Err(e) => println!("cargo::warning=craft-fonts {manifest} skipped: CRAFT_FONTS_DIR={}: {e}", dir.display()), - } - } - if !ok { - println!("cargo::warning=building without craft-fonts (once it is there, `cargo clean -p figcraft-text` makes the next build use it)"); - } + let dir = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from); + let required = std::env::var_os("CRAFT_FONTS_REQUIRED").is_some(); + let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32"); + let embed_cjk = std::env::var_os("FIGCRAFT_EMBED_CJK").is_some(); + match input_fonts(dir.as_deref(), wasm, required, embed_cjk) { + Ok(entries) => src.push_str(&entries), + Err(e) => println!("cargo::error={e}"), } src.push_str("];\n"); let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs"); @@ -35,13 +29,45 @@ fn main() { } } +/// Collect the effective catalog and enforce the required web release faces after filtering. +fn input_fonts(dir: Option<&std::path::Path>, wasm: bool, required: bool, embed_cjk: bool) -> Result { + let Some(dir) = dir else { + return if required { Err("CRAFT_FONTS_REQUIRED is set but CRAFT_FONTS_DIR is missing".into()) } else { Ok(String::new()) }; + }; + let mut out = String::new(); + let mut faces = Vec::new(); + for manifest in ["fonts/latin-manifest.txt", "fonts/manifest.txt"] { + let path = dir.join(manifest); + if manifest == "fonts/latin-manifest.txt" && !path.exists() { + watch(&path); + continue; + } + match craft_fonts(dir, manifest, wasm, embed_cjk) { + Ok(catalog) => { + out.push_str(&catalog.entries); + faces.extend(catalog.faces); + } + Err(e) if required => return Err(format!("CRAFT_FONTS_DIR={}: {e}", dir.display())), + Err(e) => println!("cargo::warning=craft-fonts {manifest} skipped: CRAFT_FONTS_DIR={}: {e}", dir.display()), + } + } + if required && wasm { + for (family, style) in [("Inter", "Regular"), ("Inter", "SemiBold"), ("Noto Sans Georgian", "Regular")] { + if !faces.iter().any(|(f, s)| f == family && s == style) { + return Err(format!("required web font missing: {family} {style}; CRAFT_FONTS_DIR must include both Latin and core release faces")); + } + } + } + Ok(out) +} + /// Rebuild when `manifest` changes or, while it is missing, when it is added to its folder. /// /// Cargo takes a watched path that doesn't exist as changed on every build, and then rebuilds this /// crate and every crate that depends on it each time. So a missing manifest is never watched /// itself: its folder is, when there is one (a craft-fonts checkout without the Latin manifest). /// With no craft-fonts at all there is nothing to watch, so the build can't notice it arriving: -/// the warning in `main` says how to pick it up. +/// rebuild this crate after adding a previously absent checkout. fn watch(manifest: &std::path::Path) { let watched = if manifest.exists() { Some(manifest) } else { manifest.parent().filter(|folder| folder.is_dir()) }; if let Some(path) = watched { @@ -49,20 +75,33 @@ fn watch(manifest: &std::path::Path) { } } -fn craft_fonts(dir: &std::path::Path, manifest_rel: &str) -> Result { +struct Catalog { + entries: String, + faces: Vec<(String, String)>, +} + +/// Parse a font manifest of `family | style | file | scripts` rows into `CraftFont` +/// entries: skip Adobe-authored families, skip CJK unless requested, keep the web catalog +/// to its budgeted families, canonicalize and watch each face, and emit `include_bytes!` +/// items. +fn craft_fonts(dir: &std::path::Path, manifest_rel: &str, wasm: bool, embed_cjk: bool) -> Result { let manifest = dir.join(manifest_rel); watch(&manifest); let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?; - let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32"); let mut out = String::new(); + let mut faces = Vec::new(); for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) { let f: Vec<&str> = line.split(" | ").map(str::trim).collect(); let [family, style, file, scripts, ..] = f.as_slice() else { return Err(format!("malformed manifest line: {line}")); }; + // FigCraft's asset policy excludes Adobe-authored visual assets on every target. + if matches!(*family, "Source Sans 3" | "Source Serif 4") { + continue; + } // CJK fonts are large; the desktop app finds system CJK fonts instead unless asked. let cjk = scripts.contains("Hans"); - if cjk && std::env::var_os("FIGCRAFT_EMBED_CJK").is_none() { + if cjk && !embed_cjk { continue; } // Web builds have a size budget: Inter, Noto Sans Georgian (small, for the Georgian UI), only the @@ -73,7 +112,109 @@ fn craft_fonts(dir: &std::path::Path, manifest_rel: &str) -> Result Self { + let dir = std::env::temp_dir().join(format!("fig-font-input-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); + std::fs::create_dir_all(dir.join("fonts")).unwrap(); + // The build script validates manifest/file presence; font bytes are checked centrally. + std::fs::write(dir.join("fonts/face.ttf"), b"synthetic build-input fixture").unwrap(); + let fixture = Self(dir); + fixture.manifest("manifest.txt", &[("Noto Sans Georgian", "Regular")]); + fixture + } + + /// Test fixture: write manifest rows that all point at the fixture's `fonts/face.ttf` face. + fn manifest(&self, name: &str, faces: &[(&str, &str)]) { + let text = faces.iter().map(|(family, style)| format!("{family} | {style} | fonts/face.ttf | Latn\n")).collect::(); + std::fs::write(self.0.join("fonts").join(name), text).unwrap(); + } + } + + impl Drop for Fixture { + /// Remove the fixture's temporary directory, ignoring errors. + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + /// Asserts the native Latin catalog keeps its 43 permitted presentation faces, the web + /// catalog keeps only the four Inter faces, Adobe-authored families are absent, and + /// permitted native faces remain. + #[test] + fn restored_latin_catalog_preserves_permitted_native_faces_and_web_budget() { + let f = Fixture::new(); + std::fs::write(f.0.join("fonts/latin-manifest.txt"), include_str!("tests/fixtures/latin-families.txt")).unwrap(); + let native = craft_fonts(&f.0, "fonts/latin-manifest.txt", false, false).unwrap(); + assert_eq!(native.faces.len(), 43, "all prior permitted presentation faces remain available"); + let web = craft_fonts(&f.0, "fonts/latin-manifest.txt", true, false).unwrap(); + assert_eq!(web.faces.len(), 4, "the web catalog keeps only the four Inter faces"); + for catalog in [&native, &web] { + assert!(!catalog.entries.contains("Source Sans 3")); + assert!(!catalog.entries.contains("Source Serif 4")); + } + assert!(native.faces.iter().any(|(family, _)| family == "Carlito")); + assert!(native.faces.iter().any(|(family, _)| family == "Liberation Serif")); + assert!(input_fonts(Some(&f.0), true, true, false).is_ok()); + } + + /// Asserts required web input rejects a catalog missing Inter Regular or Inter SemiBold. + #[test] + fn required_web_rejects_core_only_and_partial_latin_catalogs() { + let f = Fixture::new(); + assert!(input_fonts(Some(&f.0), true, true, false).unwrap_err().contains("Inter Regular")); + f.manifest("latin-manifest.txt", &[("Inter", "Regular"), ("Unrelated", "SemiBold")]); + assert!(input_fonts(Some(&f.0), true, true, false).unwrap_err().contains("Inter SemiBold")); + } + + /// Asserts required web input embeds the Inter and Georgian catalogs (three entries) and + /// rejects a manifest without Noto Sans Georgian Regular. + #[test] + fn required_web_embeds_both_catalogs_and_requires_georgian() { + let f = Fixture::new(); + f.manifest("latin-manifest.txt", &[("Inter", "Regular"), ("Inter", "SemiBold")]); + let entries = input_fonts(Some(&f.0), true, true, false).unwrap(); + assert_eq!(entries.matches("CraftFont {").count(), 3); + assert!(entries.contains("family: \"Inter\"")); + assert!(entries.contains("family: \"Noto Sans Georgian\"")); + f.manifest("manifest.txt", &[("Other", "Regular")]); + assert!(input_fonts(Some(&f.0), true, true, false).unwrap_err().contains("Noto Sans Georgian Regular")); + } + + /// Asserts absent font input is empty when optional and an error when required, while present + /// input includes Noto Sans Georgian. + #[test] + fn optional_and_native_inputs_keep_their_existing_policy() { + let f = Fixture::new(); + assert!(input_fonts(None, true, false, false).unwrap().is_empty()); + assert!(input_fonts(None, true, true, false).unwrap_err().contains("CRAFT_FONTS_DIR is missing")); + assert!(input_fonts(Some(&f.0), true, false, false).unwrap().contains("Noto Sans Georgian")); + assert!(input_fonts(Some(&f.0), false, true, false).unwrap().contains("Noto Sans Georgian")); + } + + /// Asserts required web input rejects a manifest whose face file is missing, even when every + /// face row is listed. + #[test] + fn required_web_rejects_missing_asset_even_with_all_face_rows() { + let f = Fixture::new(); + f.manifest("latin-manifest.txt", &[("Inter", "Regular"), ("Inter", "SemiBold")]); + std::fs::remove_file(f.0.join("fonts/face.ttf")).unwrap(); + assert!(input_fonts(Some(&f.0), true, true, false).unwrap_err().contains("fonts/face.ttf")); + } } diff --git a/crates/text/src/behaviour_tests.rs b/crates/text/src/behaviour_tests.rs index b3cb4884..bc0c1d80 100644 --- a/crates/text/src/behaviour_tests.rs +++ b/crates/text/src/behaviour_tests.rs @@ -265,6 +265,28 @@ fn hangul_comes_from_a_korean_font_when_one_is_installed() { } } +#[test] +fn symbols_come_from_a_symbol_font_when_one_is_installed() { + // ⌖ (U+2316) and ⤒ (U+2912) are in no Latin text face. With a symbol font that has them (Apple + // Symbols on macOS, Segoe UI Symbol on Windows, Noto Sans Symbols 2 and Noto Sans Math on + // Linux) the fallback draws them instead of the missing-glyph box (#1156). Each character + // skips on a machine with no installed fallback family that has it. + let Some(f) = face() else { return }; + let families = ["Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols", "Noto Sans Symbols 2", "Noto Sans Symbols2", "Noto Sans Math"]; + for c in ['\u{2316}', '\u{2912}'] { + if f.has_char(c) || !families.iter().any(|k| find_face(k, "Regular").is_some_and(|k| k.has_char(c))) { + continue; + } + let t = text(&format!("A {c}"), TextAutoResize::WidthAndHeight); + let l = layout(&t, None, None); + let drawn: Vec<_> = l.glyphs.iter().filter(|g| g.char_start == 2).collect(); + assert!(!drawn.is_empty(), "U+{:04X} has a glyph", c as u32); + for g in drawn { + assert!(g.face.has_char(c), "U+{:04X} drawn by {} {}, which lacks it", c as u32, g.face.family, g.face.style); + } + } +} + #[test] fn break_opportunities() { // (before, after, may break between, derivation) @@ -361,6 +383,18 @@ fn grapheme_clusters() { assert!(grapheme_starts("plain ascii").iter().all(|b| *b)); } +#[test] +fn backspace_removes_marks_but_keeps_other_graphemes_atomic() { + assert_eq!(crate::backspace_step("น้ำ", 3), 2); // Thai spacing mark + assert_eq!(crate::backspace_step("e\u{0301}", 2), 1); // Latin combining mark + assert_eq!(crate::backspace_step("👨‍👩‍👧", 5), 0); // Emoji ZWJ sequence + assert_eq!(crate::backspace_step("🇯🇵", 2), 0); // Flag + assert_eq!(crate::backspace_step("x", 1), 0); // Ordinary character + assert_eq!(crate::backspace_step("\u{1112}\u{1161}\u{11AB}", 3), 0); // Hangul syllable as conjoining jamo + assert_eq!(crate::backspace_step("a\u{200D}", 2), 0); // A letter ending in ZWJ + assert_eq!(crate::backspace_step("1\u{FE0F}\u{20E3}", 3), 0); // Keycap +} + #[test] fn a_cluster_is_shaped_with_one_face() { if face().is_none() { diff --git a/crates/text/src/breaks.rs b/crates/text/src/breaks.rs index 52284d0b..5d1ceaec 100644 --- a/crates/text/src/breaks.rs +++ b/crates/text/src/breaks.rs @@ -89,7 +89,7 @@ pub(crate) fn grapheme_starts(text: &str) -> Vec { } /// The char index of the grapheme cluster boundary after (`forward`) or before char `index` in -/// `text`: where an arrow key, Delete or Backspace moves the caret to. +/// `text`: where an arrow key or Delete moves the caret to. pub fn grapheme_step(text: &str, index: usize, forward: bool) -> usize { let mut at = 0; for g in text.graphemes(true) { @@ -102,6 +102,29 @@ pub fn grapheme_step(text: &str, index: usize, forward: bool) -> usize { at.min(index) } +/// The start of the text deleted by Backspace at char `index`. +/// +/// A letter's trailing mark (Grapheme_Cluster_Break Extend or SpacingMark, such as Thai MAI THO or +/// SARA AM) is deleted on its own; other grapheme clusters remain atomic, so emoji sequences, flags, +/// keycaps and Hangul syllables written as conjoining jamo stay intact. +pub fn backspace_step(text: &str, index: usize) -> usize { + let end = text.chars().count().min(index); + let start = grapheme_step(text, end, false); + if start == end { + return end; + } + + let cluster = text.chars().skip(start).take(end - start).collect::>(); + let letter_cluster = cluster.len() > 1 && cluster.first().is_some_and(|c| c.is_alphabetic()); + if letter_cluster && cluster.last().is_some_and(|&c| is_trailing_mark(c)) { end - 1 } else { start } +} + +/// Whether `c` is Extend or SpacingMark: it joins a plain letter's cluster. ZWJ also joins one but +/// is a joiner, not a mark. +fn is_trailing_mark(c: char) -> bool { + c != '\u{200D}' && format!("a{c}").graphemes(true).count() == 1 +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/text/src/color.rs b/crates/text/src/color.rs index ca927a94..0eaca197 100644 --- a/crates/text/src/color.rs +++ b/crates/text/src/color.rs @@ -29,8 +29,10 @@ const MAX_RASTER_BYTES: usize = 32 * 1024 * 1024; const MAX_RASTER_ENTRIES: usize = 8192; /// COLR layer lists kept per face (24 bytes a layer, so a count is enough). const MAX_LAYERS: usize = 4096; +/// Most COLR layers read for one glyph. +const MAX_COLR_LAYERS: usize = 256; /// Widest and tallest PNG strike decoded (strikes are at most a few hundred pixels). -const MAX_PNG_SIDE: u32 = 4096; +const MAX_PNG_SIDE: u32 = 1024; /// Largest colour raster per em (device pixels). Past this the renderer scales one up instead. pub const MAX_PX_PER_EM: f64 = 512.0; @@ -171,6 +173,9 @@ impl Face { let colors = palette.as_ref().map(|p| p.colors()).unwrap_or(&[]); let mut out = Vec::new(); for index in range { + if out.len() >= MAX_COLR_LAYERS { + break; + } let (layer, color_index) = colr.v0_layer(index)?; let rgba = colors.get(usize::from(color_index)).map(|c| [c.red(), c.green(), c.blue(), c.alpha()]).unwrap_or([0, 0, 0, 255]); out.push(ColorLayer { gid: layer.to_u32(), rgba }); @@ -230,7 +235,11 @@ fn decode_png(png: &[u8]) -> Option<(Vec, u32, u32)> { if w == 0 || h == 0 || w > MAX_PNG_SIDE || h > MAX_PNG_SIDE { return None; } - let mut buf = vec![0u8; reader.output_buffer_size()?]; + let buf_size = reader.output_buffer_size()?; + if buf_size > 4 * 1024 * 1024 { + return None; + } + let mut buf = vec![0u8; buf_size]; let info = reader.next_frame(&mut buf).ok()?; let (width, height) = (info.width, info.height); let data = buf.get(..info.buffer_size())?; diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 079a7efe..b250b122 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -405,8 +405,10 @@ fn names(f: &skrifa::FontRef) -> (Option, Option) { (family, style) } -/// Families tried, in order, for characters a text's own font lacks. -const FALLBACK_FAMILIES: [&str; 20] = [ +/// Families tried, in order, for characters a text's own font lacks. The symbol fonts come last: +/// Apple Symbols (macOS), Segoe UI Symbol (Windows) and the Noto symbol and math fonts (Linux), for +/// signs such as ⌖ and ⤒ that no text face has. +const FALLBACK_FAMILIES: [&str; 24] = [ "Inter", "Helvetica Neue", "Arial", @@ -427,6 +429,10 @@ const FALLBACK_FAMILIES: [&str; 20] = [ "Noto Sans Arabic", "Apple Symbols", "Segoe UI Symbol", + "Noto Sans Symbols", + "Noto Sans Symbols 2", + "Noto Sans Symbols2", + "Noto Sans Math", ]; /// A system font file entry not yet loaded. diff --git a/crates/text/src/fontdb_tests.rs b/crates/text/src/fontdb_tests.rs index f2456d28..f227910e 100644 --- a/crates/text/src/fontdb_tests.rs +++ b/crates/text/src/fontdb_tests.rs @@ -192,6 +192,19 @@ fn fallback_faces_follow_the_runs_style() { } } +#[test] +fn every_platform_has_a_symbol_font_in_the_fallbacks() { + // Symbols such as ⌖ (U+2316) and ⤒ (U+2912) are in no text face (#1156): macOS has Apple + // Symbols, Windows Segoe UI Symbol, and Linux the Noto symbol and math fonts (fontconfig names + // "Noto Sans Symbols 2"; some packages name it "Noto Sans Symbols2"). + for family in ["Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols", "Noto Sans Symbols 2", "Noto Sans Symbols2", "Noto Sans Math"] { + assert!(FALLBACK_FAMILIES.contains(&family), "{family} is a fallback"); + } + // Text faces come first: a symbol font only draws what they lack. + let at = |f: &str| FALLBACK_FAMILIES.iter().position(|x| *x == f).unwrap(); + assert!(at("Noto Sans") < at("Noto Sans Symbols") && at("DejaVu Sans") < at("Noto Sans Math")); +} + #[cfg(all(unix, not(target_os = "macos")))] #[test] fn user_font_dirs_follow_xdg_data_home() { diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index f844b571..3c64d4ab 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -100,7 +100,7 @@ pub struct Decoration { } /// Most dots or wave samples drawn for one decoration. -const MAX_DECO_STEPS: usize = 20_000; +const MAX_DECO_STEPS: usize = 2_500; impl Decoration { /// The line's outline: a bar, a row of dots or a wave, `thickness` thick. @@ -257,6 +257,11 @@ impl Item { fn is_space(&self) -> bool { crate::breaks::is_space(self.ch) } + /// A space justified text widens: not U+200B, which only marks where a line may break (between + /// the words of Thai, Lao, Khmer or Burmese text, written without spaces). + fn stretches(&self) -> bool { + self.is_space() && self.ch != '\u{200B}' + } } /// Bidi embedding levels per char, and whether each char's paragraph runs right to left. @@ -587,7 +592,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) items.get(vis_end.wrapping_sub(1)).filter(|it| hang_end && !rtl_line && vis_end > *s + 1 && closes(it.ch)).map_or(0.0, |it| it.adv); let gstart = out.glyphs.len(); // Justify: extra space per gap. - let gaps = items.get(*s..vis_end).unwrap_or(&[]).iter().filter(|it| it.is_space()).count(); + let gaps = items.get(*s..vis_end).unwrap_or(&[]).iter().filter(|it| it.stretches()).count(); let justify_extra = match (text.align_h, wrap) { (TextAlignH::Justified, Some(maxw)) if !para_end && !break_after(*e) && gaps > 0 => { ((maxw - line_indent + hang_l + hang_r - width) / gaps as f64).max(0.0) @@ -603,7 +608,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) .unwrap_or(&[]) .iter() .filter(|it| !is_line_break(it.ch)) - .map(|it| it.adv + if it.is_space() { justify_extra } else { 0.0 }) + .map(|it| it.adv + if it.stretches() { justify_extra } else { 0.0 }) .sum(); let mut pen = if rtl_line { -trailing } else { line_indent - hang_l }; // List marker (before the line's text; zero-length char range so carets skip it). @@ -698,7 +703,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) }); } pen += it.adv; - if it.is_space() { + if it.stretches() { pen += justify_extra; } } diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 50cd28dd..26ffdb7a 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -13,7 +13,7 @@ pub mod fontdb; mod layout; mod onpath; -pub use breaks::grapheme_step; +pub use breaks::{backspace_step, grapheme_step}; pub use color::{ColorBitmap, ColorInk, ColorLayer}; pub use fontdb::{ Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, Rescan, axis_label, db, face_with_char, family_names, family_styles, find_face, @@ -42,7 +42,7 @@ struct LayoutCache { /// Layouts kept before the cache starts over (a page of distinct texts stays cached while it /// renders, pans and zooms). -const LAYOUT_CACHE_MAX: usize = 32_768; +const LAYOUT_CACHE_MAX: usize = 4_096; /// Entries per fingerprint (texts that compare unequal to themselves, e.g. NaN sizes, can't pile up). const BUCKET_MAX: usize = 8; diff --git a/crates/text/src/onpath.rs b/crates/text/src/onpath.rs index 2b506237..49378504 100644 --- a/crates/text/src/onpath.rs +++ b/crates/text/src/onpath.rs @@ -23,7 +23,14 @@ impl OnPath { /// Text on `tp` whose layout x = 0 sits `shift` before the start point and whose layout y /// `baseline` sits on the path. `None` for an empty or unmeasurable path. pub fn new(tp: &TextPath, shift: f64, baseline: f64) -> Option { - let mut line = polylines(&tp.path())?.into_iter().next()?; + let p = tp.path(); + if p.elements().len() > 20_000 { + return None; + } + let mut line = polylines(&p)?.into_iter().next()?; + if line.points.len() > 40_000 { + return None; + } // A subpath ending where it starts (an ellipse) goes round like a closed one. line.closed |= matches!((line.points.first(), line.points.last()), (Some(a), Some(b)) if (*a - *b).hypot() < 1e-6); Some(Self { origin: tp.start() * line.length() - shift, line, flip: tp.flip, baseline }) diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 4475fbbc..d36c1589 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -519,3 +519,43 @@ fn lower_case_writes_final_sigma() { 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"); } + +/// Thai is written without spaces between words; U+200B ZERO WIDTH SPACE marks where a line may +/// break. ภาษา|ไทย|เขียน|ติดกัน ("Thai is written joined up") ไม่มี|ช่องว่าง|ระหว่าง|คำ ("with no +/// spaces between words"), with one real space between the two phrases. +#[test] +fn justified_lines_dont_widen_zero_width_spaces() { + if !have_fonts() { + return; + } + let lay_out = |s: &str, align, w| { + let mut t = TextData::new(s); + t.style.font_size = 20.0; + t.align_h = align; + t.auto_resize = figcraft_doc::TextAutoResize::Height; + layout(&t, w, None) + }; + let chars_before = |s: &str, pat: &str| s.find(pat).map(|b| s[..b].chars().count()).unwrap(); + for (s, why) in [ + ("ภาษา\u{200B}ไทย\u{200B}เขียน\u{200B}ติดกัน ไม่มี\u{200B}ช่องว่าง\u{200B}ระหว่าง\u{200B}คำ", "the real space takes the extra room"), + ("ภาษา\u{200B}ไทย\u{200B}เขียน\u{200B}ติดกัน\u{200B}ไม่มี\u{200B}ช่องว่าง\u{200B}ระหว่าง\u{200B}คำ", "no space to widen: as left-aligned"), + ] { + // A box that ends inside ช่องว่าง: the first line breaks at the U+200B before it. + let word = chars_before(s, "ช่องว่าง"); + let natural = lay_out(s, TextAlignH::Left, None); + let (a, b) = (natural.caret(word).0, natural.caret(word + 4).0); + assert!(b > a, "{why}: chars need advances"); + let w = a + (b - a) / 2.0; + let left = lay_out(s, TextAlignH::Left, Some(w)); + let just = lay_out(s, TextAlignH::Justified, Some(w)); + assert!(just.lines.len() >= 2 && just.lines[0].char_end == word, "{why}: {:?}", just.lines); + let space = s.chars().position(|c| c == ' '); + let extra = w - left.lines[0].width; + assert!(extra > 0.0, "{why}"); + assert_eq!(left.glyphs.len(), just.glyphs.len()); + for (l, j) in left.glyphs.iter().zip(&just.glyphs).filter(|(g, _)| g.char_end <= word) { + let want = if space.is_some_and(|sp| l.char_start > sp) { extra } else { 0.0 }; + assert!((j.x - l.x - want).abs() < 1e-6, "{why}: char {} moved {} (want {want})", l.char_start, j.x - l.x); + } + } +} diff --git a/crates/text/tests/build_input.rs b/crates/text/tests/build_input.rs new file mode 100644 index 00000000..6f87ac1f --- /dev/null +++ b/crates/text/tests/build_input.rs @@ -0,0 +1,4 @@ +//! Exercise the actual build-script catalog policy without changing process environment. +#[allow(dead_code)] +#[path = "../build.rs"] +mod build_script; diff --git a/crates/text/tests/fixtures/latin-families.txt b/crates/text/tests/fixtures/latin-families.txt new file mode 100644 index 00000000..e9db6f41 --- /dev/null +++ b/crates/text/tests/fixtures/latin-families.txt @@ -0,0 +1,52 @@ +# Synthetic paths; family/style inventory from craft-fonts 76ba5a0496503c0e2841abd626295038cbe07cde. +Inter | Regular | fonts/face.ttf | Latn +Inter | Italic | fonts/face.ttf | Latn +Inter | SemiBold | fonts/face.ttf | Latn +Inter | Bold | fonts/face.ttf | Latn +Carlito | Regular | fonts/face.ttf | Latn +Carlito | Bold | fonts/face.ttf | Latn +Carlito | Italic | fonts/face.ttf | Latn +Carlito | Bold Italic | fonts/face.ttf | Latn +Caladea | Regular | fonts/face.ttf | Latn +Caladea | Bold | fonts/face.ttf | Latn +Caladea | Italic | fonts/face.ttf | Latn +Caladea | Bold Italic | fonts/face.ttf | Latn +Liberation Sans | Regular | fonts/face.ttf | Latn +Liberation Sans | Bold | fonts/face.ttf | Latn +Liberation Sans | Italic | fonts/face.ttf | Latn +Liberation Sans | Bold Italic | fonts/face.ttf | Latn +Liberation Serif | Regular | fonts/face.ttf | Latn +Liberation Serif | Bold | fonts/face.ttf | Latn +Liberation Serif | Italic | fonts/face.ttf | Latn +Liberation Serif | Bold Italic | fonts/face.ttf | Latn +Liberation Mono | Regular | fonts/face.ttf | Latn +Liberation Mono | Bold | fonts/face.ttf | Latn +Liberation Mono | Italic | fonts/face.ttf | Latn +Liberation Mono | Bold Italic | fonts/face.ttf | Latn +Source Sans 3 | Regular | fonts/face.ttf | Latn +Source Sans 3 | Italic | fonts/face.ttf | Latn +Source Sans 3 | Semibold | fonts/face.ttf | Latn +Source Sans 3 | Bold | fonts/face.ttf | Latn +Source Serif 4 | Regular | fonts/face.ttf | Latn +Source Serif 4 | Italic | fonts/face.ttf | Latn +Source Serif 4 | Semibold | fonts/face.ttf | Latn +Source Serif 4 | Bold | fonts/face.ttf | Latn +Montserrat | Variable | fonts/face.ttf | Latn +Montserrat | Italic Variable | fonts/face.ttf | Latn +Lato | Regular | fonts/face.ttf | Latn +Lato | Bold | fonts/face.ttf | Latn +Lato | Italic | fonts/face.ttf | Latn +Open Sans | Variable | fonts/face.ttf | Latn +Open Sans | Italic Variable | fonts/face.ttf | Latn +Roboto | Variable | fonts/face.ttf | Latn +Roboto | Italic Variable | fonts/face.ttf | Latn +Merriweather | Regular | fonts/face.ttf | Latn +Merriweather | Bold | fonts/face.ttf | Latn +Merriweather | Italic | fonts/face.ttf | Latn +Playfair Display | Variable | fonts/face.ttf | Latn +Playfair Display | Italic Variable | fonts/face.ttf | Latn +Poppins | Regular | fonts/face.ttf | Latn +Poppins | Bold | fonts/face.ttf | Latn +Poppins | Italic | fonts/face.ttf | Latn +Nunito Sans | Variable | fonts/face.ttf | Latn +Nunito Sans | Italic Variable | fonts/face.ttf | Latn diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 09cd2226..04f09a37 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,9 @@ 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"] } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +craft-update = { workspace = true } diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml index bc7c948c..c3453096 100644 --- a/crates/ui-egui/locales/bn.toml +++ b/crates/ui-egui/locales/bn.toml @@ -55,6 +55,7 @@ status = "draft — needs native review" "Add library file…" = "লাইব্রেরি ফাইল যোগ করুন..." "Add max height" = "সর্বোচ্চ উচ্চতা যোগ করুন" "Add max width" = "সর্বোচ্চ প্রস্থ যোগ করুন" +"Add measurement" = "মাপ যোগ করুন" "Add min height" = "সর্বনিম্ন উচ্চতা যোগ করুন" "Add min width" = "সর্বনিম্ন প্রস্থ যোগ করুন" "Add mode" = "মোড যোগ করুন" @@ -94,6 +95,10 @@ status = "draft — needs native review" "All supported properties" = "সমর্থিত সব প্রপার্টি" "All the fonts this file uses are installed" = "এই ফাইলে ব্যবহৃত সব ফন্ট ইনস্টল করা আছে" "All variables" = "সব ভেরিয়েবল" +"Along the bottom" = "নিচের কিনারা বরাবর" +"Along the left" = "বাম কিনারা বরাবর" +"Along the right" = "ডান কিনারা বরাবর" +"Along the top" = "উপরের কিনারা বরাবর" "Alpha" = "আলফা" "Alphabetical" = "বর্ণানুক্রমিক" "Alt+drag" = "Alt+ড্র্যাগ" @@ -158,6 +163,7 @@ status = "draft — needs native review" "Basics" = "মৌলিক বিষয়" "Before" = "আগে" "Bend" = "বাঁক" +"Between the selected layers" = "নির্বাচিত লেয়ারগুলোর মাঝে" "Bevel" = "বেভেল" "Binary assets added" = "বাইনারি অ্যাসেট যোগ করা হয়েছে" "Binary assets removed" = "বাইনারি অ্যাসেট সরানো হয়েছে" @@ -298,7 +304,6 @@ status = "draft — needs native review" "Constraints" = "সীমাবদ্ধতা (Constraints)" "Content" = "কন্টেন্ট" "Contents hidden" = "কন্টেন্ট লুকানো আছে" -"Contents only" = "শুধু কন্টেন্ট" "Contextual alternates" = "প্রসঙ্গভিত্তিক বিকল্প" "Contrast" = "কনট্রাস্ট (Contrast)" "Contributors" = "অবদানকারীগণ" @@ -622,6 +627,7 @@ status = "draft — needs native review" "If" = "যদি (If)" "If {condition}" = "যদি {condition}" "Ignore auto layout (absolute position)" = "অটো লেআউট উপেক্ষা করুন (অ্যাবসোলিউট পজিশন)" +"Ignore overlapping layers" = "ওভারল্যাপ করা লেয়ার উপেক্ষা করুন" "Image" = "ছবি" "Images" = "ছবিসমূহ" "Import" = "ইম্পোর্ট" @@ -683,6 +689,7 @@ status = "draft — needs native review" "Jun" = "জুন" "Just now" = "এইমাত্র" "Justify" = "উভয়মুখী সারিবদ্ধকরণ (Justify)" +"Keep a measurement on the canvas for developers" = "ডেভেলপারদের জন্য ক্যানভাসে একটি মাপ রাখুন" "Keep appearance" = "চেহারা অপরিবর্তিত রাখুন" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "চেহারা অপরিবর্তিত রাখলে প্রতিটি রঙের মান {profile}-এ রূপান্তরিত হয় যাতে রঙগুলো একই দেখায়। মান অপরিবর্তিত রাখলে সংখ্যাগুলো যেমন আছে তেমনই থাকে, ফলে রঙ পরিবর্তিত হয়।" "Keep tool selected after use" = "ব্যবহারের পরেও টুল নির্বাচিত রাখুন" @@ -772,6 +779,7 @@ status = "draft — needs native review" "Maximize" = "বড় করুন" "May" = "মে" "Measure to the selection" = "নির্বাচন পর্যন্ত পরিমাপ করুন" +"Measurements" = "মাপ" "Merge into main" = "প্রধান ফাইলের সাথে মার্জ করুন" "Merge regions" = "অঞ্চলগুলো মার্জ করুন" "Merge the branch into the main file (one undo step) and archive it" = "ব্রাঞ্চটিকে প্রধান ফাইলের সাথে মার্জ করুন (একটি আনডু ধাপ) এবং আর্কাইভ করুন" @@ -1087,6 +1095,7 @@ status = "draft — needs native review" "Remove guide" = "গাইড সরান" "Remove interaction" = "ইন্টারঅ্যাকশন সরান" "Remove library from this file" = "এই ফাইল থেকে লাইব্রেরি সরান" +"Remove measurement" = "মাপ সরান" "Remove min and max" = "সর্বনিম্ন এবং সর্বোচ্চ সরান" "Remove min and max height" = "সর্বনিম্ন এবং সর্বোচ্চ উচ্চতা সরান" "Remove min and max width" = "সর্বনিম্ন এবং সর্বোচ্চ প্রস্থ সরান" @@ -1420,6 +1429,7 @@ status = "draft — needs native review" "Title" = "শিরোনাম" "Title case" = "টাইটেল কেস" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "আপনার টিমের সাথে এই লাইব্রেরি শেয়ার করতে, ফাইলটি একটি সার্ভারে প্রকাশ করুন (Share ▸ Publish to server…), এরপর সেখানে লাইব্রেরি প্রকাশ করুন।" +"To the layer around it" = "চারপাশের লেয়ার পর্যন্ত" "Toggle" = "টগল" "Toggle light/dark theme" = "লাইট/ডার্ক থিম টগল করুন" "Toggle visibility" = "দৃশ্যমানতা টগল করুন" @@ -1776,6 +1786,9 @@ status = "draft — needs native review" "board.shape" = "লেখাসহ আকৃতি" "board.stamp" = "স্ট্যাম্প" "board.sticky" = "স্টিকি নোট" +"devMode.addMeasurement" = "মাপ যোগ করুন" +"devMode.measurements" = "মাপের তালিকা" +"devMode.removeMeasurement" = "মাপ সরান" "file.newBoard" = "নতুন বোর্ড" "tool.connector" = "সংযোগকারী" "tool.eraser" = "ইরেজার" diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index e7a2dec1..139a195a 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -51,6 +51,7 @@ status = "draft — needs native review" "Add library file…" = "Přidat soubor knihovny…" "Add max height" = "Přidat maximální výšku" "Add max width" = "Přidat maximální šířku" +"Add measurement" = "Přidat měření" "Add min height" = "Přidat minimální výšku" "Add min width" = "Přidat minimální šířku" "Add mode" = "Přidat režim" @@ -88,6 +89,10 @@ status = "draft — needs native review" "All fonts" = "Všechna písma" "All supported properties" = "Všechny podporované vlastnosti" "All variables" = "Všechny proměnné" +"Along the bottom" = "Podél dolní hrany" +"Along the left" = "Podél levé hrany" +"Along the right" = "Podél pravé hrany" +"Along the top" = "Podél horní hrany" "Alpha" = "Alfa" "Alphabetical" = "Abecedně" "Alt+drag" = "Alt+tažení" @@ -152,6 +157,7 @@ status = "draft — needs native review" "Basics" = "Základy" "Before" = "Před" "Bend" = "Ohnout" +"Between the selected layers" = "Mezi vybranými vrstvami" "Bevel" = "Zkosený" "Binary assets added" = "Přidané binární soubory" "Binary assets removed" = "Odstraněné binární soubory" @@ -289,7 +295,6 @@ status = "draft — needs native review" "Constraints" = "Omezení" "Content" = "Obsah" "Contents hidden" = "Obsah skryt" -"Contents only" = "Pouze obsah" "Contextual alternates" = "Kontextové alternativy" "Contrast" = "Kontrast" "Contributors" = "Přispěvatelé" @@ -607,6 +612,7 @@ status = "draft — needs native review" "If" = "Pokud" "If {condition}" = "Pokud {condition}" "Ignore auto layout (absolute position)" = "Ignorovat automatické rozvržení (absolutní poloha)" +"Ignore overlapping layers" = "Ignorovat překrývající se vrstvy" "Image" = "Obrázek" "Images" = "Obrázky" "Import" = "Import" @@ -668,6 +674,7 @@ status = "draft — needs native review" "Jun" = "června" "Just now" = "Právě teď" "Justify" = "Do bloku" +"Keep a measurement on the canvas for developers" = "Ponechat měření na plátně pro vývojáře" "Keep appearance" = "Zachovat vzhled" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Zachovat vzhled převede všechny hodnoty barev do profilu {profile}, aby barvy vypadaly stejně. Zachovat hodnoty ponechá čísla beze změny, takže se barvy posunou." "Keep tool selected after use" = "Po použití ponechat nástroj vybraný" @@ -757,6 +764,7 @@ status = "draft — needs native review" "Maximize" = "Maximalizovat" "May" = "května" "Measure to the selection" = "Měřit k výběru" +"Measurements" = "Měření" "Merge into main" = "Sloučit do hlavního souboru" "Merge regions" = "Sloučit oblasti" "Merge the branch into the main file (one undo step) and archive it" = "Sloučit větev do hlavního souboru (jeden krok zpět) a archivovat ji" @@ -1066,6 +1074,7 @@ status = "draft — needs native review" "Remove guide" = "Odebrat vodítko" "Remove interaction" = "Odebrat interakci" "Remove library from this file" = "Odebrat knihovnu z tohoto souboru" +"Remove measurement" = "Odebrat měření" "Remove min and max" = "Odebrat minimum a maximum" "Remove min and max height" = "Odebrat minimální a maximální výšku" "Remove min and max width" = "Odebrat minimální a maximální šířku" @@ -1391,6 +1400,7 @@ status = "draft — needs native review" "Title" = "Název" "Title case" = "Velká počáteční písmena slov" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Chcete-li sdílet tuto knihovnu s týmem, publikujte soubor na server (Sdílet ▸ Publikovat na server…) a pak tam publikujte knihovnu." +"To the layer around it" = "K vrstvě, která ji obsahuje" "Toggle" = "Přepnout" "Toggle light/dark theme" = "Přepnout světlý/tmavý motiv" "Toggle visibility" = "Přepnout viditelnost" @@ -1840,6 +1850,9 @@ status = "draft — needs native review" "dev.focus" = "Zaměřený pohled" "dev.resolveChange" = "Vyřešit změnu" "dev.unfocus" = "Opustit zaměřený pohled" +"devMode.addMeasurement" = "Přidat měření" +"devMode.measurements" = "Vypsat měření" +"devMode.removeMeasurement" = "Odebrat měření" "devResource.add" = "Přidat zdroj pro vývojáře" "devResource.edit" = "Upravit zdroj pro vývojáře" "devResource.list" = "Vypsat zdroje pro vývojáře" @@ -2073,7 +2086,9 @@ status = "draft — needs native review" "select.sameText" = "Vybrat stejné vlastnosti textu" "select.set" = "Vybrat" "selection.applyColorStyle" = "Použít barevný styl na výběr" +"selection.bindColorVariable" = "Použít barevnou proměnnou na výběr" "selection.colors" = "Barvy výběru" +"selection.detachColor" = "Odpojit barvu výběru" "selection.selectColor" = "Vybrat vrstvy s touto barvou" "selection.setPaint" = "Změnit barvu výběru" "shape.arrow" = "Šipka" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 816fecf1..dbdcab51 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -52,6 +52,7 @@ status = "draft — needs native review" "Add library file…" = "Bibliotheksdatei hinzufügen…" "Add max height" = "Max. Höhe hinzufügen" "Add max width" = "Max. Breite hinzufügen" +"Add measurement" = "Messung hinzufügen" "Add min height" = "Min. Höhe hinzufügen" "Add min width" = "Min. Breite hinzufügen" "Add mode" = "Modus hinzufügen" @@ -90,6 +91,10 @@ status = "draft — needs native review" "All fonts" = "Alle Schriften" "All supported properties" = "Alle unterstützten Eigenschaften" "All variables" = "Alle Variablen" +"Along the bottom" = "Entlang der Unterkante" +"Along the left" = "Entlang der linken Kante" +"Along the right" = "Entlang der rechten Kante" +"Along the top" = "Entlang der Oberkante" "Alpha" = "Alpha" "Alt+drag" = "Alt+Ziehen" "An invite link that opens FigCraft and joins" = "Ein Einladungslink, der FigCraft öffnet und beitritt" @@ -149,6 +154,7 @@ status = "draft — needs native review" "Basics" = "Grundlagen" "Before" = "Vorher" "Bend" = "Biegen" +"Between the selected layers" = "Zwischen den ausgewählten Ebenen" "Bevel" = "Abgeflacht" "Binary assets added" = "Binärdateien hinzugefügt" "Binary assets removed" = "Binärdateien entfernt" @@ -285,7 +291,6 @@ status = "draft — needs native review" "Constraints" = "Constraints" "Content" = "Inhalt" "Contents hidden" = "Inhalt ausgeblendet" -"Contents only" = "Nur Inhalt" "Contextual alternates" = "Kontextbedingte Varianten" "Contrast" = "Kontrast" "Contributors" = "Mitwirkende" @@ -594,6 +599,7 @@ status = "draft — needs native review" "If" = "Wenn" "If {condition}" = "Wenn {condition}" "Ignore auto layout (absolute position)" = "Auto-Layout ignorieren (absolute Position)" +"Ignore overlapping layers" = "Überlappende Ebenen ignorieren" "Image" = "Bild" "Images" = "Bilder" "Import" = "Importieren" @@ -652,6 +658,7 @@ status = "draft — needs native review" "Join|stroke" = "Ecke" "Just now" = "Gerade eben" "Justify" = "Blocksatz" +"Keep a measurement on the canvas for developers" = "Eine Messung für Entwickler auf der Arbeitsfläche behalten" "Keep appearance" = "Aussehen beibehalten" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "„Aussehen beibehalten“ wandelt jeden Farbwert in {profile} um, damit die Farben gleich aussehen. „Werte beibehalten“ lässt die Zahlen unverändert, sodass sich die Farben verschieben." "Keep tool selected after use" = "Werkzeug nach Verwendung ausgewählt lassen" @@ -734,6 +741,7 @@ status = "draft — needs native review" "Max lines" = "Max. Zeilen" "Maximize" = "Maximieren" "Measure to the selection" = "Bis zur Auswahl messen" +"Measurements" = "Messungen" "Merge into main" = "In Hauptdatei zusammenführen" "Merge regions" = "Bereiche vereinen" "Merge the branch into the main file (one undo step) and archive it" = "Führt den Branch in die Hauptdatei zusammen (ein Rückgängig-Schritt) und archiviert ihn" @@ -1034,6 +1042,7 @@ status = "draft — needs native review" "Remove guide" = "Hilfslinie entfernen" "Remove interaction" = "Interaktion entfernen" "Remove library from this file" = "Bibliothek aus dieser Datei entfernen" +"Remove measurement" = "Messung entfernen" "Remove min and max" = "Min. und Max. entfernen" "Remove min and max height" = "Min. und max. Höhe entfernen" "Remove min and max width" = "Min. und max. Breite entfernen" @@ -1352,6 +1361,7 @@ status = "draft — needs native review" "Title" = "Titel" "Title case" = "Großschreibung am Wortanfang" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Um diese Bibliothek mit Ihrem Team zu teilen, veröffentlichen Sie die Datei auf einem Server (Teilen ▸ Auf Server veröffentlichen…) und veröffentlichen Sie dort dann die Bibliothek." +"To the layer around it" = "Bis zur umgebenden Ebene" "Toggle" = "Umschalten" "Toggle light/dark theme" = "Zwischen hellem und dunklem Design wechseln" "Toggle visibility" = "Sichtbarkeit umschalten" @@ -1795,6 +1805,9 @@ status = "draft — needs native review" "dev.focus" = "Fokusansicht" "dev.resolveChange" = "Änderung erledigen" "dev.unfocus" = "Fokusansicht verlassen" +"devMode.addMeasurement" = "Messung hinzufügen" +"devMode.measurements" = "Messungen auflisten" +"devMode.removeMeasurement" = "Messung entfernen" "devResource.add" = "Entwicklerressource hinzufügen" "devResource.edit" = "Entwicklerressource bearbeiten" "devResource.list" = "Entwicklerressourcen auflisten" @@ -2026,7 +2039,9 @@ status = "draft — needs native review" "select.sameText" = "Gleiche Texteigenschaften auswählen" "select.set" = "Auswählen" "selection.applyColorStyle" = "Farbstil auf Auswahl anwenden" +"selection.bindColorVariable" = "Farbvariable auf Auswahl anwenden" "selection.colors" = "Auswahlfarben" +"selection.detachColor" = "Auswahlfarbe lösen" "selection.selectColor" = "Ebenen mit dieser Farbe auswählen" "selection.setPaint" = "Farbe der Auswahl ändern" "shape.arrow" = "Pfeil" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 83cfcbe1..7a32f76e 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -54,6 +54,7 @@ name = "English" "Add library file…" = "Add library file…" "Add max height" = "Add max height" "Add max width" = "Add max width" +"Add measurement" = "Add measurement" "Add min height" = "Add min height" "Add min width" = "Add min width" "Add mode" = "Add mode" @@ -93,6 +94,10 @@ name = "English" "All supported properties" = "All supported properties" "All the fonts this file uses are installed" = "All the fonts this file uses are installed" "All variables" = "All variables" +"Along the bottom" = "Along the bottom" +"Along the left" = "Along the left" +"Along the right" = "Along the right" +"Along the top" = "Along the top" "Alpha" = "Alpha" "Alphabetical" = "Alphabetical" "Alt+drag" = "Alt+drag" @@ -157,6 +162,7 @@ name = "English" "Basics" = "Basics" "Before" = "Before" "Bend" = "Bend" +"Between the selected layers" = "Between the selected layers" "Bevel" = "Bevel" "Binary assets added" = "Binary assets added" "Binary assets removed" = "Binary assets removed" @@ -201,6 +207,9 @@ name = "English" "CMYK colour" = "CMYK colour" "Can view" = "Can view" "Check" = "Check" +"Check for Updates" = "Check for Updates" +"Check for updates…" = "Check for updates…" +"Checking for updates…" = "Checking for updates…" "Checking the fonts installed on this computer…" = "Checking the fonts installed on this computer…" "Checking the server…" = "Checking the server…" "Chevron" = "Chevron" @@ -299,7 +308,6 @@ name = "English" "Constraints" = "Constraints" "Content" = "Content" "Contents hidden" = "Contents hidden" -"Contents only" = "Contents only" "Contextual alternates" = "Contextual alternates" "Contrast" = "Contrast" "Contributors" = "Contributors" @@ -322,7 +330,9 @@ name = "English" "Copy {value}" = "Copy {value}" "Corner radius" = "Corner radius" "Corner smoothing" = "Corner smoothing" +"Could not check for updates:\n{error}" = "Could not check for updates:\n{error}" "Couldn't download the variables" = "Couldn't download the variables" +"Couldn't download {name}" = "Couldn't download {name}" "Couldn't reach {server}: showing what this computer remembers." = "Couldn't reach {server}: showing what this computer remembers." "Couldn't read {name}." = "Couldn't read {name}." "Couldn't save automatically: {error}" = "Couldn't save automatically: {error}" @@ -413,6 +423,7 @@ name = "English" "Distribute vertical spacing" = "Distribute vertical spacing" "Divider" = "Divider" "Don't Save" = "Don't Save" +"Don't show this again" = "Don't show this again" "Don't truncate" = "Don't truncate" "Done" = "Done" "Done editing" = "Done editing" @@ -500,6 +511,7 @@ name = "English" "Fetching from the server…" = "Fetching from the server…" "FigCraft couldn't open its window: {error}" = "FigCraft couldn't open its window: {error}" "FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." +"FigCraft {version} is currently the newest version available." = "FigCraft {version} is currently the newest version available." "File" = "File" "File name" = "File name" "File not found" = "File not found" @@ -625,6 +637,7 @@ name = "English" "If" = "If" "If {condition}" = "If {condition}" "Ignore auto layout (absolute position)" = "Ignore auto layout (absolute position)" +"Ignore overlapping layers" = "Ignore overlapping layers" "Image" = "Image" "Images" = "Images" "Import" = "Import" @@ -688,6 +701,7 @@ name = "English" "Jun" = "Jun" "Just now" = "Just now" "Justify" = "Justify" +"Keep a measurement on the canvas for developers" = "Keep a measurement on the canvas for developers" "Keep appearance" = "Keep appearance" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." "Keep tool selected after use" = "Keep tool selected after use" @@ -714,6 +728,7 @@ name = "English" "Layout grids" = "Layout grids" "Leave session" = "Leave session" "Leaves" = "Leaves" +"Leaving the prototype" = "Leaving the prototype" "Left" = "Left" "Left & right" = "Left & right" "Left padding" = "Left padding" @@ -778,6 +793,7 @@ name = "English" "Maximize" = "Maximize" "May" = "May" "Measure to the selection" = "Measure to the selection" +"Measurements" = "Measurements" "Merge into main" = "Merge into main" "Merge regions" = "Merge regions" "Merge the branch into the main file (one undo step) and archive it" = "Merge the branch into the main file (one undo step) and archive it" @@ -1097,6 +1113,7 @@ name = "English" "Remove guide" = "Remove guide" "Remove interaction" = "Remove interaction" "Remove library from this file" = "Remove library from this file" +"Remove measurement" = "Remove measurement" "Remove min and max" = "Remove min and max" "Remove min and max height" = "Remove min and max height" "Remove min and max width" = "Remove min and max width" @@ -1240,6 +1257,7 @@ name = "English" "Server address" = "Server address" "Server for online sharing" = "Server for online sharing" "Set as default" = "Set as default" +"Set corner smoothing to 60%" = "Set corner smoothing to 60%" "Set variable" = "Set variable" "Set variable mode" = "Set variable mode" "Set variable {name}" = "Set variable {name}" @@ -1392,6 +1410,7 @@ name = "English" "Text" = "Text" "Text align" = "Text align" "Text content" = "Text content" +"Text on a path" = "Text on a path" "Text size" = "Text size" "Text style" = "Text style" "Text styles" = "Text styles" @@ -1426,6 +1445,7 @@ name = "English" "This file is on {server}, open only to you. Share it to let others in with a new code." = "This file is on {server}, open only to you. Share it to let others in with a new code." "This frame is new on the branch." = "This frame is new on the branch." "This frame isn't on the branch." = "This frame isn't on the branch." +"This link opens a website outside the prototype:" = "This link opens a website outside the prototype:" "This server is open to the public: no key needed." = "This server is open to the public: no key needed." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." "This server takes files only with its key: see Advanced." = "This server takes files only with its key: see Advanced." @@ -1439,6 +1459,7 @@ name = "English" "Title" = "Title" "Title case" = "Title case" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." +"To the layer around it" = "To the layer around it" "Toggle" = "Toggle" "Toggle light/dark theme" = "Toggle light/dark theme" "Toggle visibility" = "Toggle visibility" @@ -1479,6 +1500,7 @@ name = "English" "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" +"Update check is not supported in the web browser." = "Update check is not supported in the web browser." "Update plugin" = "Update plugin" "Update" = "Update" "Update from main" = "Update from main" @@ -1629,10 +1651,12 @@ name = "English" "image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "image filters are ambiguous because both filter containers are present; filter conversion was skipped" "image filters use FigCraft's approximate renderer" = "image filters use FigCraft's approximate renderer" "instance swap properties whose default component isn't in the file were skipped" = "instance swap properties whose default component isn't in the file were skipped" +"instance swap properties whose default component was past the layer limit were skipped" = "instance swap properties whose default component was past the layer limit were skipped" "instances inside their own main component were imported as frames" = "instances inside their own main component were imported as frames" "instances of components that aren't in the file show no layers" = "instances of components that aren't in the file show no layers" "instances of components that aren't in the file were imported as frames" = "instances of components that aren't in the file were imported as frames" "instances that would nest a component inside itself were imported as frames" = "instances that would nest a component inside itself were imported as frames" +"instances whose component was past the layer limit were imported as frames" = "instances whose component was past the layer limit were imported as frames" "instances without a main component were imported as empty frames" = "instances without a main component were imported as empty frames" "invalid internal .fig text path data was ignored" = "invalid internal .fig text path data was ignored" "invalid or oversized vector networks import as outlines" = "invalid or oversized vector networks import as outlines" diff --git a/crates/ui-egui/locales/es-419.toml b/crates/ui-egui/locales/es-419.toml index 7756fba7..6ef81633 100644 --- a/crates/ui-egui/locales/es-419.toml +++ b/crates/ui-egui/locales/es-419.toml @@ -35,6 +35,7 @@ status = "draft — needs native review" "Add library file…" = "Agregar archivo de biblioteca…" "Add max height" = "Agregar alto máximo" "Add max width" = "Agregar ancho máximo" +"Add measurement" = "Agregar medición" "Add min height" = "Agregar alto mínimo" "Add min width" = "Agregar ancho mínimo" "Add mode" = "Agregar modo" @@ -95,6 +96,7 @@ status = "draft — needs native review" "comment.add" = "Agregar comentario" "component.addProperty" = "Agregar propiedad de componente" "component.addVariant" = "Agregar variante" +"devMode.addMeasurement" = "Agregar medición" "devResource.add" = "Agregar recurso de desarrollo" "effect.add" = "Agregar efecto" "export.add" = "Agregar configuración de exportación" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index e172584c..3118e8ea 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -8,12 +8,19 @@ name = "Español" status = "draft — needs native review" [ui] +"Add measurement" = "Añadir medición" +"Along the bottom" = "A lo largo del borde inferior" +"Along the left" = "A lo largo del borde izquierdo" +"Along the right" = "A lo largo del borde derecho" +"Along the top" = "A lo largo del borde superior" "Arrow at the end" = "Flecha al final" +"Between the selected layers" = "Entre las capas seleccionadas" "Check" = "Visto" "Chevron" = "Cheurón" "Compatibility notes" = "Notas de compatibilidad" "Connector" = "Conector" "Couldn't download the variables" = "No se pudieron descargar las variables" +"Couldn't download {name}" = "No se pudo descargar {name}" "% of all commits" = "% del total de commits" "(no longer published)" = "(ya no publicado)" @@ -299,7 +306,6 @@ status = "draft — needs native review" "Constraints" = "Restricciones" "Content" = "Contenido" "Contents hidden" = "Contenido oculto" -"Contents only" = "Solo el contenido" "Contextual alternates" = "Alternativas contextuales" "Contrast" = "Contraste" "Contributors" = "Colaboradores" @@ -626,6 +632,7 @@ status = "draft — needs native review" "If" = "Si" "If {condition}" = "Si {condition}" "Ignore auto layout (absolute position)" = "Ignorar diseño automático (posición absoluta)" +"Ignore overlapping layers" = "Ignorar capas superpuestas" "Image" = "Imagen" "Images" = "Imágenes" "Import" = "Importar" @@ -689,6 +696,7 @@ status = "draft — needs native review" "Jun" = "jun" "Just now" = "Justo ahora" "Justify" = "Justificar" +"Keep a measurement on the canvas for developers" = "Mantén una medición en el lienzo para desarrolladores" "Keep appearance" = "Mantener apariencia" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Mantener apariencia convierte cada valor de color a {profile} para que los colores se vean igual. Mantener valores deja los números como están, así que los colores cambian." "Keep tool selected after use" = "Mantener la herramienta tras usarla" @@ -779,6 +787,7 @@ status = "draft — needs native review" "Maximize" = "Maximizar" "May" = "may" "Measure to the selection" = "Medir hasta la selección" +"Measurements" = "Mediciones" "Merge into main" = "Fusionar en la principal" "Merge regions" = "Fusionar regiones" "Merge the branch into the main file (one undo step) and archive it" = "Fusiona la rama en el archivo principal (un paso de deshacer) y la archiva" @@ -1098,6 +1107,7 @@ status = "draft — needs native review" "Remove guide" = "Quitar guía" "Remove interaction" = "Quitar interacción" "Remove library from this file" = "Quitar biblioteca de este archivo" +"Remove measurement" = "Quitar medición" "Remove min and max" = "Quitar mín. y máx." "Remove min and max height" = "Quitar alto mín. y máx." "Remove min and max width" = "Quitar ancho mín. y máx." @@ -1440,6 +1450,7 @@ status = "draft — needs native review" "Title" = "Título" "Title case" = "Tipo título" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Para compartir esta biblioteca con tu equipo, publica el archivo en un servidor (Compartir ▸ Publicar en servidor…) y luego publica allí la biblioteca." +"To the layer around it" = "Hasta la capa que la contiene" "Toggle" = "Activar/desactivar" "Toggle light/dark theme" = "Cambiar tema claro/oscuro" "Toggle visibility" = "Mostrar/ocultar" @@ -1630,10 +1641,12 @@ status = "draft — needs native review" "image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "los filtros de imagen son ambiguos porque están los dos contenedores de filtros; se omitió la conversión de filtros" "image filters use FigCraft's approximate renderer" = "los filtros de imagen usan el renderizado aproximado de FigCraft" "instance swap properties whose default component isn't in the file were skipped" = "se omitieron las propiedades de intercambio de instancia cuyo componente predeterminado no está en el archivo" +"instance swap properties whose default component was past the layer limit were skipped" = "se omitieron las propiedades de intercambio de instancia cuyo componente predeterminado quedó fuera del límite de capas" "instances inside their own main component were imported as frames" = "las instancias dentro de su propio componente principal se importaron como marcos" "instances of components that aren't in the file show no layers" = "las instancias de componentes que no están en el archivo no muestran capas" "instances of components that aren't in the file were imported as frames" = "las instancias de componentes que no están en el archivo se importaron como marcos" "instances that would nest a component inside itself were imported as frames" = "las instancias que anidarían un componente dentro de sí mismo se importaron como marcos" +"instances whose component was past the layer limit were imported as frames" = "las instancias cuyo componente quedó fuera del límite de capas se importaron como marcos" "instances without a main component were imported as empty frames" = "las instancias sin componente principal se importaron como marcos vacíos" "invalid internal .fig text path data was ignored" = "se ignoraron datos internos no válidos de texto en trazado del .fig" "invalid or oversized vector networks import as outlines" = "las redes vectoriales no válidas o demasiado grandes se importan como contornos" @@ -1904,6 +1917,9 @@ status = "draft — needs native review" "dev.focus" = "Vista de enfoque" "dev.resolveChange" = "Resolver cambio" "dev.unfocus" = "Salir de la vista de enfoque" +"devMode.addMeasurement" = "Añadir medición" +"devMode.measurements" = "Listar mediciones" +"devMode.removeMeasurement" = "Quitar medición" "devResource.add" = "Añadir recurso de desarrollo" "devResource.edit" = "Editar recurso de desarrollo" "devResource.list" = "Listar recursos de desarrollo" @@ -1963,6 +1979,7 @@ status = "draft — needs native review" "file.revert" = "Volver a lo guardado" "file.save" = "Guardar" "file.saveAs" = "Guardar como" +"file.markSaved" = "Marcar como guardado" "file.saveVersion" = "Guardar en el historial de versiones" "file.versionHistory" = "Mostrar historial de versiones" "fill.add" = "Añadir relleno" @@ -2141,7 +2158,9 @@ status = "draft — needs native review" "select.sameText" = "Seleccionar mismas propiedades de texto" "select.set" = "Seleccionar" "selection.applyColorStyle" = "Aplicar estilo de color a la selección" +"selection.bindColorVariable" = "Aplicar variable de color a la selección" "selection.colors" = "Colores de la selección" +"selection.detachColor" = "Separar color de la selección" "selection.selectColor" = "Seleccionar capas con este color" "selection.setPaint" = "Cambiar color de la selección" "shape.arrow" = "Flecha" @@ -2215,6 +2234,8 @@ status = "draft — needs native review" "text.removeFromPath" = "Quitar texto del trazado" "text.rescanFonts" = "Volver a buscar fuentes instaladas" "text.set" = "Establecer texto" +"data.fill" = "Rellenar copias con datos" +"text.fit" = "Ajustar texto a su caja" "text.setFeatures" = "Funciones OpenType" "text.setVariations" = "Ejes de fuente variable" "text.sizeDown" = "Reducir tamaño de fuente" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index f18ffeaf..635b501f 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -56,6 +56,7 @@ status = "draft — needs native review" "Add library file…" = "Lisää kirjastotiedosto…" "Add max height" = "Lisää enimmäiskorkeus" "Add max width" = "Lisää enimmäisleveys" +"Add measurement" = "Lisää mittaus" "Add min height" = "Lisää vähimmäiskorkeus" "Add min width" = "Lisää vähimmäisleveys" "Add mode" = "Lisää tila" @@ -95,6 +96,10 @@ status = "draft — needs native review" "All supported properties" = "Kaikki tuetut ominaisuudet" "All the fonts this file uses are installed" = "Kaikki tämän tiedoston käyttämät fontit on asennettu" "All variables" = "Kaikki muuttujat" +"Along the bottom" = "Alareunaa pitkin" +"Along the left" = "Vasenta reunaa pitkin" +"Along the right" = "Oikeaa reunaa pitkin" +"Along the top" = "Yläreunaa pitkin" "Alpha" = "Alfa" "Alphabetical" = "Aakkosjärjestys" "Alt+drag" = "Alt+drag" @@ -159,6 +164,7 @@ status = "draft — needs native review" "Basics" = "Perusteet" "Before" = "Ennen" "Bend" = "Taivutus" +"Between the selected layers" = "Valittujen tasojen välillä" "Bevel" = "Viiste" "Binary assets added" = "Lisätyt binääriresurssit" "Binary assets removed" = "Poistetut binääriresurssit" @@ -297,7 +303,6 @@ status = "draft — needs native review" "Constraints" = "Rajoitteet" "Content" = "Sisältö" "Contents hidden" = "Sisältö kätketty" -"Contents only" = "Vain sisältö" "Contextual alternates" = "Kontekstuaaliset vaihtoehdot" "Contrast" = "Kontrasti" "Contributors" = "Tekijät" @@ -624,6 +629,7 @@ status = "draft — needs native review" "If it keeps happening, please report it at {link} with this message." = "Jos ongelma jatkuu, ilmoita siitä osoitteessa {link} ja liitä tämä viesti mukaan." "If {condition}" = "Jos {condition}" "Ignore auto layout (absolute position)" = "Ohita automaattinen asettelu (absoluuttinen sijainti)" +"Ignore overlapping layers" = "Ohita päällekkäiset tasot" "Image" = "Kuva" "Images" = "Kuvat" "Import" = "Tuo" @@ -685,6 +691,7 @@ status = "draft — needs native review" "Jun" = "kesäk." "Just now" = "Juuri nyt" "Justify" = "Tasaa molemmat reunat" +"Keep a measurement on the canvas for developers" = "Säilytä mittaus piirtoalueella kehittäjille" "Keep appearance" = "Säilytä ulkoasu" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Säilytä ulkoasu muuntaa kaikki väriarvot profiiliin {profile}, jotta värit näyttävät samoilta. Säilytä arvot jättää lukuarvot ennalleen, jolloin värit muuttuvat." "Keep tool selected after use" = "Pidä työkalu valittuna käytön jälkeen" @@ -774,6 +781,7 @@ status = "draft — needs native review" "Maximize" = "Suurenna" "May" = "toukok." "Measure to the selection" = "Mittaa valintaan" +"Measurements" = "Mittaukset" "Merge into main" = "Yhdistä päähaaraan" "Merge regions" = "Yhdistä alueet" "Merge the branch into the main file (one undo step) and archive it" = "Yhdistä haara päätiedostoon (yksi kumottava toiminto) ja arkistoi se" @@ -1091,6 +1099,7 @@ status = "draft — needs native review" "Remove guide" = "Poista apuviiva" "Remove interaction" = "Poista vuorovaikutus" "Remove library from this file" = "Poista kirjasto tästä tiedostosta" +"Remove measurement" = "Poista mittaus" "Remove min and max" = "Poista vähimmäis- ja enimmäisrajat" "Remove min and max height" = "Poista korkeuden vähimmäis- ja enimmäisrajat" "Remove min and max width" = "Poista leveyden vähimmäis- ja enimmäisrajat" @@ -1425,6 +1434,7 @@ status = "draft — needs native review" "Title" = "Otsikko" "Title case" = "Sanojen isot alkukirjaimet" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Jaa tämä kirjasto tiimillesi julkaisemalla tiedosto palvelimelle (Jaa ▸ Julkaise palvelimelle…) ja julkaisemalla kirjasto siellä." +"To the layer around it" = "Ympäröivään tasoon" "Toggle" = "Vaihda tila" "Toggle light/dark theme" = "Vaihda vaalea/tumma teema" "Toggle visibility" = "Vaihda näkyvyys" @@ -1883,6 +1893,9 @@ status = "draft — needs native review" "dev.focus" = "Kohdistusnäkymä" "dev.resolveChange" = "Ratkaise muutos" "dev.unfocus" = "Poistu kohdistusnäkymästä" +"devMode.addMeasurement" = "Lisää mittaus" +"devMode.measurements" = "Luettele mittaukset" +"devMode.removeMeasurement" = "Poista mittaus" "devResource.add" = "Lisää kehittäjäresurssi" "devResource.edit" = "Muokkaa kehittäjäresurssia" "devResource.list" = "Luettele kehittäjäresurssit" @@ -2119,7 +2132,9 @@ status = "draft — needs native review" "select.sameText" = "Valitse samat tekstiominaisuudet" "select.set" = "Valitse" "selection.applyColorStyle" = "Käytä valinnan värityyliä" +"selection.bindColorVariable" = "Käytä valinnan värimuuttujaa" "selection.colors" = "Valinnan värit" +"selection.detachColor" = "Irrota valinnan väri" "selection.selectColor" = "Valitse tämänväriset tasot" "selection.setPaint" = "Muuta valinnan väriä" "shape.arrow" = "Nuoli" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index e5823983..b8d64b43 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -55,6 +55,7 @@ status = "draft — needs native review" "Add library file…" = "Ajouter un fichier de bibliothèque…" "Add max height" = "Ajouter une hauteur max." "Add max width" = "Ajouter une largeur max." +"Add measurement" = "Ajouter une mesure" "Add min height" = "Ajouter une hauteur min." "Add min width" = "Ajouter une largeur min." "Add mode" = "Ajouter un mode" @@ -93,6 +94,10 @@ status = "draft — needs native review" "All fonts" = "Toutes les polices" "All supported properties" = "Toutes les propriétés prises en charge" "All variables" = "Toutes les variables" +"Along the bottom" = "Le long du bord inférieur" +"Along the left" = "Le long du bord gauche" +"Along the right" = "Le long du bord droit" +"Along the top" = "Le long du bord supérieur" "Alpha" = "Alpha" "Alphabetical" = "Alphabétique" "Alt+drag" = "Alt+glisser" @@ -157,6 +162,7 @@ status = "draft — needs native review" "Basics" = "Essentiel" "Before" = "Avant" "Bend" = "Courber" +"Between the selected layers" = "Entre les calques sélectionnés" "Bevel" = "Biseau" "Binary assets added" = "Ressources binaires ajoutées" "Binary assets removed" = "Ressources binaires supprimées" @@ -295,7 +301,6 @@ status = "draft — needs native review" "Constraints" = "Contraintes" "Content" = "Contenu" "Contents hidden" = "Contenu masqué" -"Contents only" = "Contenu uniquement" "Contextual alternates" = "Variantes contextuelles" "Contrast" = "Contraste" "Contributors" = "Contributeurs" @@ -623,6 +628,7 @@ status = "draft — needs native review" "If" = "Si" "If {condition}" = "Si {condition}" "Ignore auto layout (absolute position)" = "Ignorer la mise en page auto (position absolue)" +"Ignore overlapping layers" = "Ignorer les calques superposés" "Image" = "Image" "Images" = "Images" "Import" = "Importer" @@ -685,6 +691,7 @@ status = "draft — needs native review" "Jun" = "juin" "Just now" = "À l’instant" "Justify" = "Justifier" +"Keep a measurement on the canvas for developers" = "Garder une mesure sur le canevas pour les développeurs" "Keep appearance" = "Conserver l’apparence" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Conserver l’apparence convertit chaque valeur de couleur en {profile} pour que les couleurs restent identiques. Conserver les valeurs laisse les nombres tels quels, si bien que les couleurs changent." "Keep tool selected after use" = "Garder l’outil sélectionné après usage" @@ -774,6 +781,7 @@ status = "draft — needs native review" "Maximize" = "Agrandir" "May" = "mai" "Measure to the selection" = "Mesurer jusqu’à la sélection" +"Measurements" = "Mesures" "Merge into main" = "Fusionner dans le principal" "Merge regions" = "Fusionner les régions" "Merge the branch into the main file (one undo step) and archive it" = "Fusionne la branche dans le fichier principal (une seule étape d’annulation) et l’archive" @@ -1091,6 +1099,7 @@ status = "draft — needs native review" "Remove guide" = "Retirer le repère" "Remove interaction" = "Retirer l’interaction" "Remove library from this file" = "Retirer la bibliothèque de ce fichier" +"Remove measurement" = "Retirer la mesure" "Remove min and max" = "Retirer min. et max." "Remove min and max height" = "Retirer la hauteur min. et max." "Remove min and max width" = "Retirer la largeur min. et max." @@ -1426,6 +1435,7 @@ status = "draft — needs native review" "Title" = "Titre" "Title case" = "Casse de titre" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Pour partager cette bibliothèque avec votre équipe, publiez le fichier sur un serveur (Partager ▸ Publier sur un serveur…), puis publiez-y la bibliothèque." +"To the layer around it" = "Jusqu’au calque qui le contient" "Toggle" = "Activer/désactiver" "Toggle light/dark theme" = "Basculer entre thème clair et sombre" "Toggle visibility" = "Afficher/masquer" @@ -1884,6 +1894,9 @@ status = "draft — needs native review" "dev.focus" = "Vue ciblée" "dev.resolveChange" = "Résoudre la modification" "dev.unfocus" = "Quitter la vue ciblée" +"devMode.addMeasurement" = "Ajouter une mesure" +"devMode.measurements" = "Lister les mesures" +"devMode.removeMeasurement" = "Retirer la mesure" "devResource.add" = "Ajouter une ressource de développement" "devResource.edit" = "Modifier la ressource de développement" "devResource.list" = "Lister les ressources de développement" @@ -2121,7 +2134,9 @@ status = "draft — needs native review" "select.sameText" = "Sélectionner les mêmes propriétés de texte" "select.set" = "Sélectionner" "selection.applyColorStyle" = "Appliquer un style de couleur à la sélection" +"selection.bindColorVariable" = "Appliquer une variable de couleur à la sélection" "selection.colors" = "Couleurs de la sélection" +"selection.detachColor" = "Détacher la couleur de la sélection" "selection.selectColor" = "Sélectionner les calques de cette couleur" "selection.setPaint" = "Changer la couleur de la sélection" "shape.arrow" = "Flèche" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index c06f2dff..601cb2c3 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -55,6 +55,7 @@ status = "draft — needs native review" "Add library file…" = "Tambah file pustaka…" "Add max height" = "Tambah tinggi maks" "Add max width" = "Tambah lebar maks" +"Add measurement" = "Tambah pengukuran" "Add min height" = "Tambah tinggi min" "Add min width" = "Tambah lebar min" "Add mode" = "Tambah mode" @@ -92,6 +93,10 @@ status = "draft — needs native review" "All fonts" = "Semua font" "All supported properties" = "Semua properti yang didukung" "All variables" = "Semua variabel" +"Along the bottom" = "Sepanjang sisi bawah" +"Along the left" = "Sepanjang sisi kiri" +"Along the right" = "Sepanjang sisi kanan" +"Along the top" = "Sepanjang sisi atas" "Alpha" = "Alfa" "Alphabetical" = "Alfabetis" "Alt+drag" = "Alt+seret" @@ -156,6 +161,7 @@ status = "draft — needs native review" "Basics" = "Dasar" "Before" = "Sebelum" "Bend" = "Lengkungan" +"Between the selected layers" = "Di antara layer yang dipilih" "Bevel" = "Bevel" "Binary assets added" = "Aset biner ditambahkan" "Binary assets removed" = "Aset biner dihapus" @@ -293,7 +299,6 @@ status = "draft — needs native review" "Constraints" = "Batasan" "Content" = "Konten" "Contents hidden" = "Konten disembunyikan" -"Contents only" = "Hanya konten" "Contextual alternates" = "Alternatif kontekstual" "Contrast" = "Kontras" "Contributors" = "Kontributor" @@ -611,6 +616,7 @@ status = "draft — needs native review" "If" = "Jika" "If {condition}" = "Jika {condition}" "Ignore auto layout (absolute position)" = "Abaikan auto layout (posisi absolut)" +"Ignore overlapping layers" = "Abaikan layer yang tumpang tindih" "Image" = "Gambar" "Images" = "Gambar" "Import" = "Impor" @@ -672,6 +678,7 @@ status = "draft — needs native review" "Jun" = "Jun" "Just now" = "Baru saja" "Justify" = "Rata kiri-kanan" +"Keep a measurement on the canvas for developers" = "Simpan pengukuran di kanvas untuk developer" "Keep appearance" = "Pertahankan tampilan" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Pertahankan tampilan mengonversi setiap nilai warna ke {profile} agar warna terlihat sama. Pertahankan nilai membiarkan angkanya apa adanya, sehingga warna bergeser." "Keep tool selected after use" = "Tetap pilih alat setelah dipakai" @@ -761,6 +768,7 @@ status = "draft — needs native review" "Maximize" = "Maksimalkan" "May" = "Mei" "Measure to the selection" = "Ukur ke pilihan" +"Measurements" = "Pengukuran" "Merge into main" = "Merge ke main" "Merge regions" = "Gabungkan area" "Merge the branch into the main file (one undo step) and archive it" = "Merge branch ke file utama (satu langkah urungkan) lalu arsipkan" @@ -1070,6 +1078,7 @@ status = "draft — needs native review" "Remove guide" = "Hapus garis bantu" "Remove interaction" = "Hapus interaksi" "Remove library from this file" = "Hapus pustaka dari file ini" +"Remove measurement" = "Hapus pengukuran" "Remove min and max" = "Hapus min dan maks" "Remove min and max height" = "Hapus tinggi min dan maks" "Remove min and max width" = "Hapus lebar min dan maks" @@ -1395,6 +1404,7 @@ status = "draft — needs native review" "Title" = "Judul" "Title case" = "Huruf Awal Kapital" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Untuk membagikan pustaka ini kepada tim Anda, publikasikan file ke server (Bagikan ▸ Publikasikan ke server…), lalu publikasikan pustaka di sana." +"To the layer around it" = "Ke layer yang membungkusnya" "Toggle" = "Alihkan" "Toggle light/dark theme" = "Alihkan tema terang/gelap" "Toggle visibility" = "Alihkan visibilitas" @@ -1844,6 +1854,9 @@ status = "draft — needs native review" "dev.focus" = "Tampilan fokus" "dev.resolveChange" = "Selesaikan perubahan" "dev.unfocus" = "Keluar dari tampilan fokus" +"devMode.addMeasurement" = "Tambah pengukuran" +"devMode.measurements" = "Daftar pengukuran" +"devMode.removeMeasurement" = "Hapus pengukuran" "devResource.add" = "Tambah sumber daya dev" "devResource.edit" = "Edit sumber daya dev" "devResource.list" = "Daftar sumber daya dev" @@ -2077,7 +2090,9 @@ status = "draft — needs native review" "select.sameText" = "Pilih properti teks yang sama" "select.set" = "Pilih" "selection.applyColorStyle" = "Terapkan gaya warna ke pilihan" +"selection.bindColorVariable" = "Terapkan variabel warna ke pilihan" "selection.colors" = "Warna pilihan" +"selection.detachColor" = "Lepas warna pilihan" "selection.selectColor" = "Pilih layer dengan warna ini" "selection.setPaint" = "Ubah warna pilihan" "shape.arrow" = "Panah" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index d7ee7287..3fc535ae 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -57,6 +57,7 @@ status = "draft — needs native review" "Add library file…" = "ライブラリファイルを追加…" "Add max height" = "最大の高さを追加" "Add max width" = "最大の幅を追加" +"Add measurement" = "測定を追加" "Add min height" = "最小の高さを追加" "Add min width" = "最小の幅を追加" "Add mode" = "モードを追加" @@ -94,6 +95,10 @@ status = "draft — needs native review" "All fonts" = "すべてのフォント" "All supported properties" = "サポートされているすべてのプロパティ" "All variables" = "すべての変数" +"Along the bottom" = "下辺に沿って" +"Along the left" = "左辺に沿って" +"Along the right" = "右辺に沿って" +"Along the top" = "上辺に沿って" "Alpha" = "アルファ" "Alphabetical" = "名前順" "Alt+drag" = "Alt+ドラッグ" @@ -158,6 +163,7 @@ status = "draft — needs native review" "Basics" = "基本" "Before" = "変更前" "Bend" = "曲げ" +"Between the selected layers" = "選択したレイヤーの間" "Bevel" = "ベベル" "Binary assets added" = "追加されたバイナリアセット" "Binary assets removed" = "削除されたバイナリアセット" @@ -296,7 +302,6 @@ status = "draft — needs native review" "Constraints" = "制約" "Content" = "コンテンツ" "Contents hidden" = "コンテンツ非表示" -"Contents only" = "コンテンツのみ" "Contextual alternates" = "前後関係に依存する字形" "Contrast" = "コントラスト" "Contributors" = "貢献者" @@ -618,6 +623,7 @@ status = "draft — needs native review" "If" = "条件" "If {condition}" = "条件:{condition}" "Ignore auto layout (absolute position)" = "オートレイアウトを無視(絶対位置)" +"Ignore overlapping layers" = "重なっているレイヤーを無視" "Image" = "画像" "Images" = "画像" "Import" = "インポート" @@ -679,6 +685,7 @@ status = "draft — needs native review" "Jun" = "6月" "Just now" = "たった今" "Justify" = "両端揃え" +"Keep a measurement on the canvas for developers" = "開発者向けに測定をキャンバスに残す" "Keep appearance" = "見た目を保持" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "「見た目を保持」はすべてのカラー値を {profile} に変換し、色の見た目を保つ。「値を保持」は数値をそのまま残すため、色が変わる。" "Keep tool selected after use" = "使用後もツールを選択したままにする" @@ -768,6 +775,7 @@ status = "draft — needs native review" "Maximize" = "最大化" "May" = "5月" "Measure to the selection" = "選択範囲までの距離を測定" +"Measurements" = "測定" "Merge into main" = "メインにマージ" "Merge regions" = "領域を結合" "Merge the branch into the main file (one undo step) and archive it" = "ブランチをメインファイルにマージし(1 回の取り消し単位)、アーカイブする" @@ -1078,6 +1086,7 @@ status = "draft — needs native review" "Remove guide" = "ガイドを削除" "Remove interaction" = "インタラクションを削除" "Remove library from this file" = "このファイルからライブラリを削除" +"Remove measurement" = "測定を削除" "Remove min and max" = "最小と最大を削除" "Remove min and max height" = "最小と最大の高さを削除" "Remove min and max width" = "最小と最大の幅を削除" @@ -1409,6 +1418,7 @@ status = "draft — needs native review" "Title" = "タイトル" "Title case" = "タイトルケース" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "このライブラリをチームと共有するには、ファイルをサーバーに公開し(共有 ▸ サーバーに公開…)、そこでライブラリを公開する。" +"To the layer around it" = "親レイヤーまで" "Toggle" = "切り替え" "Toggle light/dark theme" = "ライト/ダークテーマを切り替え" "Toggle visibility" = "表示を切り替え" @@ -1864,6 +1874,9 @@ status = "draft — needs native review" "dev.focus" = "フォーカスビュー" "dev.resolveChange" = "変更を解決" "dev.unfocus" = "フォーカスビューを終了" +"devMode.addMeasurement" = "測定を追加" +"devMode.measurements" = "測定を一覧表示" +"devMode.removeMeasurement" = "測定を削除" "devResource.add" = "開発リソースを追加" "devResource.edit" = "開発リソースを編集" "devResource.list" = "開発リソースを一覧表示" @@ -2099,7 +2112,9 @@ status = "draft — needs native review" "select.sameText" = "同じテキストプロパティを選択" "select.set" = "選択" "selection.applyColorStyle" = "選択範囲にカラースタイルを適用" +"selection.bindColorVariable" = "選択範囲にカラー変数を適用" "selection.colors" = "選択範囲のカラー" +"selection.detachColor" = "選択範囲のカラーを切り離す" "selection.selectColor" = "このカラーのレイヤーを選択" "selection.setPaint" = "選択範囲のカラーを変更" "shape.arrow" = "矢印" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index 42f78be6..eefb2dd3 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -57,6 +57,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Add library file…" = "ბიბლიოთეკის ფაილის დამატება…" "Add max height" = "მაქსიმალური სიმაღლის დამატება" "Add max width" = "მაქსიმალური სიგანის დამატება" +"Add measurement" = "გაზომვის დამატება" "Add min height" = "მინიმალური სიმაღლის დამატება" "Add min width" = "მინიმალური სიგანის დამატება" "Add mode" = "რეჟიმის დამატება" @@ -96,6 +97,10 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "All supported properties" = "ყველა მხარდაჭერილი თვისება" "All the fonts this file uses are installed" = "ამ ფაილის ყველა შრიფტი დაინსტალირებულია" "All variables" = "ყველა ცვლადი" +"Along the bottom" = "ქვედა კიდის გასწვრივ" +"Along the left" = "მარცხენა კიდის გასწვრივ" +"Along the right" = "მარჯვენა კიდის გასწვრივ" +"Along the top" = "ზედა კიდის გასწვრივ" "Alpha" = "ალფა" "Alphabetical" = "ანბანის მიხედვით" "Alt+drag" = "Alt+გადათრევა" @@ -160,6 +165,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Basics" = "საფუძვლები" "Before" = "მანამდე" "Bend" = "მოხრა" +"Between the selected layers" = "მონიშნულ შრეებს შორის" "Bevel" = "წაკვეთილი" "Binary assets added" = "დამატებული ბინარული ფაილები" "Binary assets removed" = "წაშლილი ბინარული ფაილები" @@ -300,7 +306,6 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Constraints" = "შეზღუდვები" "Content" = "შიგთავსი" "Contents hidden" = "შიგთავსი დამალულია" -"Contents only" = "მხოლოდ შიგთავსი" "Contextual alternates" = "კონტექსტური ალტერნატივები" "Contrast" = "კონტრასტი" "Contributors" = "მონაწილეები" @@ -624,6 +629,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "If" = "თუ" "If {condition}" = "თუ {condition}" "Ignore auto layout (absolute position)" = "ავტოგანლაგების იგნორირება (აბსოლუტური პოზიცია)" +"Ignore overlapping layers" = "გადაფარული შრეების იგნორირება" "Image" = "გამოსახულება" "Images" = "გამოსახულებები" "Import" = "იმპორტი" @@ -685,6 +691,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Jun" = "ივნ" "Just now" = "ახლახან" "Justify" = "ორმხრივი სწორება" +"Keep a measurement on the canvas for developers" = "გაზომვის შენახვა ტილოზე დეველოპერებისთვის" "Keep appearance" = "იერსახის შენარჩუნება" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "„იერსახის შენარჩუნება“ ყველა ფერის მნიშვნელობას {profile}-ში გარდაქმნის, რომ ფერები იგივენაირად გამოჩნდეს. „მნიშვნელობების შენარჩუნება“ რიცხვებს უცვლელად ტოვებს, ამიტომ ფერები იცვლება." "Keep tool selected after use" = "ხელსაწყოს არჩეულად დატოვება გამოყენების შემდეგ" @@ -774,6 +781,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Maximize" = "გაშლა" "May" = "მაი" "Measure to the selection" = "მონიშნულამდე გაზომვა" +"Measurements" = "გაზომვები" "Merge into main" = "მთავარში შერწყმა" "Merge regions" = "არეების შერწყმა" "Merge the branch into the main file (one undo step) and archive it" = "განშტოების მთავარ ფაილთან შერწყმა (დაბრუნების ერთი ნაბიჯი) და მისი დაარქივება" @@ -1089,6 +1097,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Remove guide" = "დამხმარე ხაზის მოცილება" "Remove interaction" = "ურთიერთქმედების წაშლა" "Remove library from this file" = "ბიბლიოთეკის ამ ფაილიდან ამოშლა" +"Remove measurement" = "გაზომვის წაშლა" "Remove min and max" = "მინ. და მაქს. მოხსნა" "Remove min and max height" = "მინ. და მაქს. სიმაღლის მოხსნა" "Remove min and max width" = "მინ. და მაქს. სიგანის მოხსნა" @@ -1422,6 +1431,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Title" = "დასახელება" "Title case" = "სათაურის რეგისტრი" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "ამ ბიბლიოთეკის გუნდთან გასაზიარებლად გამოაქვეყნეთ ფაილი სერვერზე (გაზიარება ▸ სერვერზე გამოქვეყნება…), შემდეგ იქ გამოაქვეყნეთ ბიბლიოთეკა." +"To the layer around it" = "გარშემო მყოფ შრემდე" "Toggle" = "გადართვა" "Toggle light/dark theme" = "ღია/ბნელი თემის გადართვა" "Toggle visibility" = "ხილვადობის ჩართვა/გამორთვა" @@ -1879,6 +1889,9 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "dev.focus" = "ფოკუსის ხედი" "dev.resolveChange" = "ცვლილების გადაწყვეტა" "dev.unfocus" = "ფოკუსის ხედიდან გასვლა" +"devMode.addMeasurement" = "გაზომვის დამატება" +"devMode.measurements" = "გაზომვების სია" +"devMode.removeMeasurement" = "გაზომვის წაშლა" "devResource.add" = "დეველოპერის რესურსის დამატება" "devResource.edit" = "დეველოპერის რესურსის რედაქტირება" "devResource.list" = "დეველოპერის რესურსების სია" @@ -2113,7 +2126,9 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "select.sameText" = "ტექსტის იგივე თვისებების მონიშვნა" "select.set" = "მონიშვნა" "selection.applyColorStyle" = "მონიშნულის ფერის სტილის გამოყენება" +"selection.bindColorVariable" = "მონიშნულზე ფერის ცვლადის გამოყენება" "selection.colors" = "მონიშნულის ფერები" +"selection.detachColor" = "მონიშნულის ფერის გამოყოფა" "selection.selectColor" = "ამ ფერის შრეების მონიშვნა" "selection.setPaint" = "მონიშნულის ფერის შეცვლა" "shape.arrow" = "ისარი" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 66eb54c2..35e5a59a 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -53,6 +53,7 @@ status = "draft — needs native review" "Add library file…" = "라이브러리 파일 추가…" "Add max height" = "최대 높이 추가" "Add max width" = "최대 너비 추가" +"Add measurement" = "측정 추가" "Add min height" = "최소 높이 추가" "Add min width" = "최소 너비 추가" "Add mode" = "모드 추가" @@ -90,6 +91,10 @@ status = "draft — needs native review" "All fonts" = "모든 글꼴" "All supported properties" = "지원되는 모든 속성" "All variables" = "모든 변수" +"Along the bottom" = "아래쪽 가장자리를 따라" +"Along the left" = "왼쪽 가장자리를 따라" +"Along the right" = "오른쪽 가장자리를 따라" +"Along the top" = "위쪽 가장자리를 따라" "Alpha" = "알파" "Alt+drag" = "Alt+드래그" "An invite link that opens FigCraft and joins" = "FigCraft를 열고 참여하는 초대 링크" @@ -149,6 +154,7 @@ status = "draft — needs native review" "Basics" = "기본" "Before" = "변경 전" "Bend" = "구부리기" +"Between the selected layers" = "선택한 레이어 사이" "Bevel" = "베벨" "Binary assets added" = "추가된 바이너리 에셋" "Binary assets removed" = "제거된 바이너리 에셋" @@ -285,7 +291,6 @@ status = "draft — needs native review" "Constraints" = "제약" "Content" = "콘텐츠" "Contents hidden" = "콘텐츠 숨김" -"Contents only" = "콘텐츠만" "Contextual alternates" = "문맥 대체 글자" "Contrast" = "대비" "Contributors" = "기여자" @@ -590,6 +595,7 @@ status = "draft — needs native review" "If" = "조건" "If {condition}" = "조건: {condition}" "Ignore auto layout (absolute position)" = "자동 레이아웃 무시(절대 위치)" +"Ignore overlapping layers" = "겹치는 레이어 무시" "Image" = "이미지" "Images" = "이미지" "Import" = "가져오기" @@ -648,6 +654,7 @@ status = "draft — needs native review" "Join|stroke" = "선 연결" "Just now" = "방금" "Justify" = "양쪽 정렬" +"Keep a measurement on the canvas for developers" = "개발자를 위해 캔버스에 측정 유지" "Keep appearance" = "모양 유지" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "\"모양 유지\"는 모든 색상 값을 {profile}(으)로 변환해 색이 똑같아 보이게 함. \"값 유지\"는 숫자를 그대로 두므로 색이 달라짐." "Keep tool selected after use" = "사용 후에도 도구 선택 유지" @@ -730,6 +737,7 @@ status = "draft — needs native review" "Max lines" = "최대 줄 수" "Maximize" = "최대화" "Measure to the selection" = "선택 항목까지 측정" +"Measurements" = "측정" "Merge into main" = "메인에 병합" "Merge regions" = "영역 병합" "Merge the branch into the main file (one undo step) and archive it" = "브랜치를 메인 파일에 병합(실행 취소 한 번)하고 보관" @@ -1028,6 +1036,7 @@ status = "draft — needs native review" "Remove guide" = "안내선 제거" "Remove interaction" = "인터랙션 제거" "Remove library from this file" = "이 파일에서 라이브러리 제거" +"Remove measurement" = "측정 제거" "Remove min and max" = "최소 및 최대 제거" "Remove min and max height" = "최소 및 최대 높이 제거" "Remove min and max width" = "최소 및 최대 너비 제거" @@ -1343,6 +1352,7 @@ status = "draft — needs native review" "Title" = "제목" "Title case" = "제목 대소문자" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "이 라이브러리를 팀과 공유하려면 파일을 서버에 게시(공유 ▸ 서버에 게시…)한 다음 그곳에서 라이브러리를 게시하세요." +"To the layer around it" = "감싸는 레이어까지" "Toggle" = "전환" "Toggle light/dark theme" = "라이트/다크 테마 전환" "Toggle visibility" = "표시 여부 전환" @@ -1786,6 +1796,9 @@ status = "draft — needs native review" "dev.focus" = "포커스 보기" "dev.resolveChange" = "변경 사항 해결" "dev.unfocus" = "포커스 보기 종료" +"devMode.addMeasurement" = "측정 추가" +"devMode.measurements" = "측정 목록" +"devMode.removeMeasurement" = "측정 제거" "devResource.add" = "개발 리소스 추가" "devResource.edit" = "개발 리소스 편집" "devResource.list" = "개발 리소스 목록" @@ -2017,7 +2030,9 @@ status = "draft — needs native review" "select.sameText" = "같은 텍스트 속성 선택" "select.set" = "선택" "selection.applyColorStyle" = "선택 항목에 색상 스타일 적용" +"selection.bindColorVariable" = "선택 항목에 색상 변수 적용" "selection.colors" = "선택 항목 색상" +"selection.detachColor" = "선택 항목 색상 분리" "selection.selectColor" = "이 색상의 레이어 선택" "selection.setPaint" = "선택 항목 색상 변경" "shape.arrow" = "화살표" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index 84f13545..484909ea 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -56,6 +56,7 @@ status = "draft — needs native review" "Add library file…" = "Bibliotheekbestand toevoegen…" "Add max height" = "Maximale hoogte toevoegen" "Add max width" = "Maximale breedte toevoegen" +"Add measurement" = "Meting toevoegen" "Add min height" = "Minimale hoogte toevoegen" "Add min width" = "Minimale breedte toevoegen" "Add mode" = "Modus toevoegen" @@ -95,6 +96,10 @@ status = "draft — needs native review" "All supported properties" = "Alle ondersteunde eigenschappen" "All the fonts this file uses are installed" = "Alle lettertypen die dit bestand gebruikt zijn geïnstalleerd" "All variables" = "Alle variabelen" +"Along the bottom" = "Langs de onderkant" +"Along the left" = "Langs de linkerkant" +"Along the right" = "Langs de rechterkant" +"Along the top" = "Langs de bovenkant" "Alpha" = "Alpha" "Alphabetical" = "Alfabetisch" "Alt+drag" = "Alt+drag" @@ -159,6 +164,7 @@ status = "draft — needs native review" "Basics" = "Basis" "Before" = "Voor" "Bend" = "Buiging" +"Between the selected layers" = "Tussen de geselecteerde lagen" "Bevel" = "Afgeschuind" "Binary assets added" = "Binaire assets toegevoegd" "Binary assets removed" = "Verwijderde binaire assets" @@ -297,7 +303,6 @@ status = "draft — needs native review" "Constraints" = "Constraints" "Content" = "Inhoud" "Contents hidden" = "Inhoud verborgen" -"Contents only" = "Alleen de inhoud" "Contextual alternates" = "Contextuele alternatieven" "Contrast" = "Contrast" "Contributors" = "Bijdragers" @@ -624,6 +629,7 @@ status = "draft — needs native review" "If it keeps happening, please report it at {link} with this message." = "Als dit blijft gebeuren, meld het dan op {link} met dit bericht." "If {condition}" = "Als {condition}" "Ignore auto layout (absolute position)" = "Auto layout negeren (absolute positie)" +"Ignore overlapping layers" = "Overlappende lagen negeren" "Image" = "Afbeelding" "Images" = "Afbeeldingen" "Import" = "Importeren" @@ -685,6 +691,7 @@ status = "draft — needs native review" "Jun" = "jun." "Just now" = "Zojuist" "Justify" = "Uitvullen" +"Keep a measurement on the canvas for developers" = "Een meting voor ontwikkelaars op het canvas houden" "Keep appearance" = "Uiterlijk behouden" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Kleur behouden converteert elke kleurwaarde naar {profile}, zodat kleuren er hetzelfde uitzien. Waarden behouden laat de getallen staan, waardoor kleuren verschuiven." "Keep tool selected after use" = "Tool na gebruik geselecteerd houden" @@ -774,6 +781,7 @@ status = "draft — needs native review" "Maximize" = "Maximaliseren" "May" = "mei" "Measure to the selection" = "Meten tot de selectie" +"Measurements" = "Metingen" "Merge into main" = "Samenvoegen in main" "Merge regions" = "Gebieden samenvoegen" "Merge the branch into the main file (one undo step) and archive it" = "Voeg de branch samen met het hoofdbestand (één keer ongedaan maken) en archiveer hem" @@ -1091,6 +1099,7 @@ status = "draft — needs native review" "Remove guide" = "Hulplijn verwijderen" "Remove interaction" = "Interactie verwijderen" "Remove library from this file" = "Bibliotheek uit dit bestand verwijderen" +"Remove measurement" = "Meting verwijderen" "Remove min and max" = "Min en max verwijderen" "Remove min and max height" = "Minimale en maximale hoogte verwijderen" "Remove min and max width" = "Minimale en maximale breedte verwijderen" @@ -1425,6 +1434,7 @@ status = "draft — needs native review" "Title" = "Titel" "Title case" = "Hoofdletter per woord" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Om deze bibliotheek met je team te delen, publiceer het bestand naar een server (Delen ▸ Publiceren naar server…), publiceer daarna de bibliotheek." +"To the layer around it" = "Tot de laag eromheen" "Toggle" = "Omschakelen" "Toggle light/dark theme" = "Wisselen tussen licht/donker thema" "Toggle visibility" = "Zichtbaarheid omschakelen" @@ -1882,6 +1892,9 @@ status = "draft — needs native review" "dev.focus" = "Focusweergave" "dev.resolveChange" = "Wijziging oplossen" "dev.unfocus" = "Focusweergave verlaten" +"devMode.addMeasurement" = "Meting toevoegen" +"devMode.measurements" = "Metingen weergeven" +"devMode.removeMeasurement" = "Meting verwijderen" "devResource.add" = "Dev-resource toevoegen" "devResource.edit" = "Dev-resource bewerken" "devResource.list" = "Dev-resources weergeven" @@ -2118,7 +2131,9 @@ status = "draft — needs native review" "select.sameText" = "Dezelfde teksteigenschappen selecteren" "select.set" = "Selecteren" "selection.applyColorStyle" = "Kleurstijl op selectie toepassen" +"selection.bindColorVariable" = "Kleurvariabele op selectie toepassen" "selection.colors" = "Selectiekleuren" +"selection.detachColor" = "Selectiekleur loskoppelen" "selection.selectColor" = "Lagen op kleur selecteren" "selection.setPaint" = "Selectiekleur wijzigen" "shape.arrow" = "Pijl" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index 7f301a6e..2f6c63b2 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -1,10 +1,11 @@ # FigCraft interface strings: Polish. # -# Draft: machine-drafted, waiting for a native speaker's review. +# Wording reviewed and approved by Lucas (@lukasz-sklad). +# Complete Polish localization for FigCraft. +# See docs/translating.md; plural forms follow Polish CLDR integer rules. [meta] name = "Polski" -status = "draft — needs native review" [ui] "% of all commits" = "% wszystkich commitów" @@ -53,6 +54,7 @@ status = "draft — needs native review" "Add library file…" = "Dodaj plik biblioteki…" "Add max height" = "Dodaj maksymalną wysokość" "Add max width" = "Dodaj maksymalną szerokość" +"Add measurement" = "Dodaj pomiar" "Add min height" = "Dodaj minimalną wysokość" "Add min width" = "Dodaj minimalną szerokość" "Add mode" = "Dodaj tryb" @@ -92,6 +94,10 @@ status = "draft — needs native review" "All supported properties" = "Wszystkie obsługiwane właściwości" "All the fonts this file uses are installed" = "Wszystkie czcionki używane w tym pliku są zainstalowane" "All variables" = "Wszystkie zmienne" +"Along the bottom" = "Wzdłuż dolnej krawędzi" +"Along the left" = "Wzdłuż lewej krawędzi" +"Along the right" = "Wzdłuż prawej krawędzi" +"Along the top" = "Wzdłuż górnej krawędzi" "Alpha" = "Alfa" "Alphabetical" = "Alfabetycznie" "Alt+drag" = "Alt+przeciągnięcie" @@ -156,6 +162,7 @@ status = "draft — needs native review" "Basics" = "Podstawy" "Before" = "Przed" "Bend" = "Wygnij" +"Between the selected layers" = "Między zaznaczonymi warstwami" "Bevel" = "Ścięte" "Binary assets added" = "Dodane pliki binarne" "Binary assets removed" = "Usunięte pliki binarne" @@ -172,6 +179,7 @@ status = "draft — needs native review" "Both directions" = "W obu kierunkach" "Bottom" = "Dół" "Bottom left radius" = "Promień lewego dolnego narożnika" +"Bottom padding" = "Margines dolny" "Bottom right radius" = "Promień prawego dolnego narożnika" "Branch" = "Gałąź" "Branch archived" = "Gałąź zarchiwizowana" @@ -296,7 +304,6 @@ status = "draft — needs native review" "Constraints" = "Więzy" "Content" = "Zawartość" "Contents hidden" = "Zawartość ukryta" -"Contents only" = "Tylko zawartość" "Contextual alternates" = "Alternatywy kontekstowe" "Contrast" = "Kontrast" "Contributors" = "Współtwórcy" @@ -507,7 +514,7 @@ status = "draft — needs native review" "Fill container" = "Wypełnij kontener" "Fill window" = "Wypełnij okno" "Fills" = "Wypełnienia" -"Fill|sizing" = "Fill|sizing" +"Fill|sizing" = "Wypełnij" "Filter and sort" = "Filtruj i sortuj" "Find" = "Znajdź" "Find and replace" = "Znajdź i zamień" @@ -621,6 +628,7 @@ status = "draft — needs native review" "If" = "Jeżeli" "If {condition}" = "Jeżeli {condition}" "Ignore auto layout (absolute position)" = "Ignoruj automatyczny układ (pozycja bezwzględna)" +"Ignore overlapping layers" = "Ignoruj nakładające się warstwy" "Image" = "Obraz" "Images" = "Obrazy" "Import" = "Importuj" @@ -640,6 +648,7 @@ status = "draft — needs native review" "Included" = "Uwzględniono" "Included in layout" = "Uwzględniono w układzie" "Individual corners" = "Niezależne narożniki" +"Individual padding" = "Niezależne marginesy" "Individual strokes" = "Niezależne obrysy" "Ink" = "Farba" "Inner shadow" = "Cień wewnętrzny" @@ -661,6 +670,7 @@ status = "draft — needs native review" "Interaction" = "Interakcja" "Interactions" = "Interakcje" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Projektowanie interfejsów i prototypowanie. Darmowe, otwartoźródłowe, czysto implementowane narzędzie projektowe napisane od podstaw w Rust, część ArtCraft Crafting Apps." +"Interface size" = "Rozmiar interfejsu" "Intersect" = "Przetnij" "Intersects" = "Przecina" "Invalid color" = "Nieprawidłowy kolor" @@ -677,11 +687,12 @@ status = "draft — needs native review" "Joined" = "Dołączono" "Joined {file}" = "Dołączono do: {file}" "Joining…" = "Dołączanie…" -"Join|stroke" = "Join|stroke" +"Join|stroke" = "Łączenie" "Jul" = "Lip" "Jun" = "Cze" "Just now" = "Przed chwilą" "Justify" = "Wyjustuj" +"Keep a measurement on the canvas for developers" = "Zachowaj pomiar na płótnie dla programistów" "Keep appearance" = "Zachowaj wygląd" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Zachowanie wyglądu konwertuje każdą wartość koloru na {profile}, aby kolory wyglądały tak samo. Zachowanie wartości pozostawia liczby bez zmian, co może zmienić odcienie barw." "Keep tool selected after use" = "Pozostaw narzędzie wybrane po użyciu" @@ -710,6 +721,7 @@ status = "draft — needs native review" "Leaves" = "Opuszcza" "Left" = "Do lewej" "Left & right" = "Lewa i prawa" +"Left padding" = "Margines lewy" "Left side" = "Lewa strona" "Left side width" = "Szerokość lewej strony" "Letter case" = "Wielkość liter" @@ -720,9 +732,9 @@ status = "draft — needs native review" "Library: click to collapse" = "Biblioteka: kliknij, aby zwinąć" "Library: click to expand" = "Biblioteka: kliknij, aby rozwinąć" "Library: its variables are read-only here; edit them in the library file and publish it" = "Biblioteka: jej zmienne są tutaj tylko do odczytu; edytuj je w pliku biblioteki i opublikuj" -"Light" = "Lekki" +"Light" = "Jasny" "Lighten" = "Jaśniej" -"Light|glass" = "Light|glass" +"Light|glass" = "Światło" "Line" = "Linia" "Line arrow" = "Strzałka liniowa" "Line delta" = "Różnica wierszy" @@ -771,6 +783,7 @@ status = "draft — needs native review" "Maximize" = "Maksymalizuj" "May" = "maja" "Measure to the selection" = "Zmierz odległość do zaznaczenia" +"Measurements" = "Pomiary" "Merge into main" = "Scal z gałęzią główną" "Merge regions" = "Scal obszary" "Merge the branch into the main file (one undo step) and archive it" = "Scal gałąź z plikiem głównym (jeden krok cofnięcia) i zarchiwizuj ją" @@ -938,7 +951,7 @@ status = "draft — needs native review" "Opened “{name}” in a new tab" = "Otwarto „{name}” w nowej karcie" "Opening the file…" = "Otwieranie pliku…" "Opening {n} files as new files" = { one = "Otwieranie jako nowy plik", few = "Otwieranie {n} plików jako nowe pliki", many = "Otwieranie {n} plików jako nowe pliki", other = "Otwieranie {n} plików jako nowe pliki" } -"Open|comments" = "Open|comments" +"Open|comments" = "Otwarte" "Options" = "Opcje" "Orange" = "Pomarańczowy" "Order" = "Kolejność" @@ -961,6 +974,7 @@ status = "draft — needs native review" "PDF print settings" = "Ustawienia druku PDF" "Padding" = "Marginesy wewnętrzne" "Page" = "Strona" +"Page deleted" = "Strona została usunięta" "Pages" = "Strony" "Pages added to the document" = "Strony dodane do dokumentu" "Pink" = "Różowy" @@ -1075,6 +1089,7 @@ status = "draft — needs native review" "Reduce motion" = "Ogranicz ruch" "Refraction" = "Załamanie światła (refrakcja)" "Regular expression" = "Wyrażenie regularne" +"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Względem skalowania ekranu Twojego systemu (100% to domyślny rozmiar systemu). Powiększenie dokumentu jest niezależne." "Remove" = "Usuń" "Remove all" = "Usuń wszystko" "Remove annotation" = "Usuń adnotację" @@ -1087,6 +1102,7 @@ status = "draft — needs native review" "Remove guide" = "Usuń linię pomocniczą" "Remove interaction" = "Usuń interakcję" "Remove library from this file" = "Usuń bibliotekę z tego pliku" +"Remove measurement" = "Usuń pomiar" "Remove min and max" = "Usuń min. i maks." "Remove min and max height" = "Usuń minimalną i maksymalną wysokość" "Remove min and max width" = "Usuń minimalną i maksymalną szerokość" @@ -1113,6 +1129,7 @@ status = "draft — needs native review" "Rename on the server…" = "Zmień nazwę na serwerze…" "Rename page" = "Zmień nazwę strony" "Rename to" = "Zmień nazwę na" +"Reorder selection" = "Zmień kolejność zaznaczenia" "Replace all" = "Zamień wszystko" "Replace mixed export settings" = "Zastąp zróżnicowane ustawienia eksportu" "Replace with" = "Zamień na" @@ -1148,6 +1165,7 @@ status = "draft — needs native review" "Review and merge" = "Przejrzyj i scal" "Ribbon" = "Wstążka" "Right" = "Do prawej" +"Right padding" = "Margines prawy" "Right side" = "Prawa strona" "Right side width" = "Szerokość prawej strony" "Rotate 90°" = "Obróć o 90°" @@ -1199,6 +1217,7 @@ status = "draft — needs native review" "Search results" = "Wyniki wyszukiwania" "Search fonts" = "Szukaj czcionek" "Search layers" = "Szukaj warstw" +"Search pages" = "Szukaj stron" "Search shortcuts" = "Szukaj skrótów" "Section" = "Sekcja" "Section color" = "Kolor sekcji" @@ -1317,6 +1336,7 @@ status = "draft — needs native review" "Start from a blank canvas" = "Zacznij od pustego płótna" "Start from a template" = "Zacznij od szablonu" "Start on path" = "Początek na ścieżce" +"Start on the Home screen" = "Rozpoczynaj od ekranu głównego" "Start point" = "Punkt początkowy" "Start sharing" = "Rozpocznij udostępnianie" "Start sharing with the Share button, then pass on the code it gives" = "Rozpocznij udostępnianie przyciskiem Udostępnij, a następnie przekaż wygenerowany kod" @@ -1395,6 +1415,7 @@ status = "draft — needs native review" "The selection uses several text styles: click to apply one" = "Zaznaczenie używa kilku stylów tekstu: kliknij, aby zastosować jeden" "The server deletes this file if nobody opens it before {date}." = "Serwer usunie ten plik, jeśli nikt go nie otworzy przed {date}." "The whole file" = "Cały plik" +"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "Okno jest zbyt małe dla {size}%, więc interfejs jest wyświetlany w {drawn}%. Powiększ okno, aby użyć {size}%." "Theme" = "Motyw" "There's no destination for that action" = "Brak miejsca docelowego dla tej akcji" "Thickness" = "Grubość" @@ -1405,6 +1426,7 @@ status = "draft — needs native review" "This condition is edited as an expression" = "Ten warunek jest edytowany jako wyrażenie" "This file" = "Ten plik" "This file has unsaved changes" = "Ten plik ma niezapisane zmiany" +"This file is too large to join here (up to {mb} MB)" = "Ten plik jest zbyt duży, aby do niego dołączyć (maksymalnie {mb} MB)" "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Ten plik używa czcionek, które nie są zainstalowane na tym komputerze. Do czasu ich zainstalowania tekst jest wyświetlany czcionką zastępczą." "This file is on {server}, open only to you. Share it to let others in with a new code." = "Ten plik znajduje się na {server}, otwarty tylko dla Ciebie. Udostępnij go, aby wpuścić innych nowym kodem." "This frame is new on the branch." = "Ta ramka jest nowa w gałęzi." @@ -1420,6 +1442,7 @@ status = "draft — needs native review" "Title" = "Tytuł" "Title case" = "Jak W Tytule" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Aby udostępnić tę bibliotekę zespołowi, opublikuj plik na serwerze (Udostępnij ▸ Publikuj na serwerze…), a następnie opublikuj tam bibliotekę." +"To the layer around it" = "Do warstwy nadrzędnej" "Toggle" = "Przełącz" "Toggle light/dark theme" = "Przełącz motyw jasny/ciemny" "Toggle visibility" = "Przełącz widoczność" @@ -1427,6 +1450,7 @@ status = "draft — needs native review" "Top" = "Góra" "Top & bottom" = "Góra i dół" "Top left radius" = "Promień górnego lewego narożnika" +"Top padding" = "Margines górny" "Top right radius" = "Promień górnego prawego narożnika" "Transform" = "Przekształć" "Triangle" = "Trójkąt" @@ -1476,6 +1500,7 @@ status = "draft — needs native review" "Use number keys for opacity" = "Użyj klawiszy numerycznych dla nieprzezroczystości" "Use the .figcraft or .fig extension." = "Użyj rozszerzenia .figcraft lub .fig." "Use the layer's fill" = "Użyj wypełnienia warstwy" +"Use your browser's zoom to change the interface size." = "Użyj powiększenia przeglądarki, aby zmienić rozmiar interfejsu." "Used in {files} · {instances}" = "Używane w {files} · {instances}" "Username" = "Nazwa użytkownika" "Value" = "Wartość" @@ -1747,6 +1772,7 @@ status = "draft — needs native review" "{n}m" = "{n} min" "{n}w" = "{n} tyg." "{page}, nothing selected" = "{page}, nic nie zaznaczono" +"{percent}%" = "{percent}%" "{profile} (current)" = "{profile} (bieżący)" "{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} to jej wartość; {property ? \"a\" : \"b\"} wybiera tekst. Pozostałe wyrażenia {…} pozostają bez zmian." "{server} can't say what became of your files (it is older than this app)." = "{server} nie może określić stanu Twoich plików (jest starszy niż ta aplikacja)." @@ -1789,6 +1815,7 @@ status = "draft — needs native review" "arrange.front" = "Przesuń na sam wierzch" "arrange.packH" = "Upakuj w poziomie" "arrange.packV" = "Upakuj w pionie" +"arrange.reorder" = "Zmień kolejność zaznaczenia" "arrange.roundToPixels" = "Zaokrąglij do pikseli" "arrange.spacing" = "Odstępy" "arrange.tidy" = "Uporządkuj" @@ -1878,6 +1905,9 @@ status = "draft — needs native review" "dev.focus" = "Widok skupienia" "dev.resolveChange" = "Rozwiąż zmianę" "dev.unfocus" = "Wyjdź z widoku skupienia" +"devMode.addMeasurement" = "Dodaj pomiar" +"devMode.measurements" = "Lista pomiarów" +"devMode.removeMeasurement" = "Usuń pomiar" "devResource.add" = "Dodaj zasób programistyczny" "devResource.edit" = "Edytuj zasób programistyczny" "devResource.list" = "Lista zasobów programistycznych" @@ -1925,6 +1955,7 @@ status = "draft — needs native review" "file.exportLayers" = "Eksportuj warstwy" "file.exportSvg" = "Eksportuj zaznaczenie jako SVG" "file.import" = "Importuj z pliku…" +"file.moveTab" = "Przenieś kartę" "file.new" = "Nowy plik projektu" "file.newBoard" = "Nowa tablica" "file.newFromTemplate" = "Nowy z szablonu" @@ -2068,6 +2099,8 @@ status = "draft — needs native review" "pref.reduceMotion" = "Ogranicz animacje" "pref.renameDuplicates" = "Zmieniaj nazwy duplikatów" "pref.showDimensions" = "Pokazuj wymiary przy zaznaczeniu" +"pref.startOnHome" = "Rozpoczynaj od ekranu głównego" +"pref.uiScale" = "Rozmiar interfejsu" "pref.userName" = "Twoja nazwa w komentarzach" "proto.action.add" = "Dodaj akcję prototypu" "proto.action.move" = "Zmień kolejność akcji prototypu" @@ -2112,7 +2145,9 @@ status = "draft — needs native review" "select.sameText" = "Zaznacz elementy z takimi samymi właściwościami tekstu" "select.set" = "Zaznacz" "selection.applyColorStyle" = "Zastosuj styl koloru do zaznaczenia" +"selection.bindColorVariable" = "Zastosuj zmienną koloru do zaznaczenia" "selection.colors" = "Kolory zaznaczenia" +"selection.detachColor" = "Odłącz kolor zaznaczenia" "selection.selectColor" = "Zaznacz warstwy z kolorem" "selection.setPaint" = "Zmień kolor zaznaczenia" "shape.arrow" = "Strzałka" @@ -2174,6 +2209,7 @@ status = "draft — needs native review" "text.listIndent" = "Zwiększ wcięcie listy" "text.listOutdent" = "Zmniejsz wcięcie listy" "text.listStyle" = "Styl listy" +"text.loadFont" = "Wczytaj plik czcionki" "text.maxLines" = "Maksymalna liczba wierszy" "text.missingFonts" = "Brakujące czcionki" "text.numberList" = "Lista numerowana" @@ -2182,7 +2218,9 @@ status = "draft — needs native review" "text.paragraphSpacing" = "Odstęp między akapitami" "text.pathOptions" = "Opcje ścieżki tekstu" "text.preview" = "Podgląd zmiany tekstu" +"text.refit" = "Dopasuj tekst do czcionek" "text.removeFromPath" = "Usuń tekst ze ścieżki" +"text.rescanFonts" = "Wyszukaj ponownie zainstalowane czcionki" "text.set" = "Ustaw tekst" "text.setFeatures" = "Funkcje OpenType" "text.setVariations" = "Osie czcionki zmiennej (Variable Font)" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 22fffb17..ece93fbd 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -51,6 +51,7 @@ status = "draft — needs native review" "Add library file…" = "Adicionar arquivo de biblioteca…" "Add max height" = "Adicionar altura máx." "Add max width" = "Adicionar largura máx." +"Add measurement" = "Adicionar medição" "Add min height" = "Adicionar altura mín." "Add min width" = "Adicionar largura mín." "Add mode" = "Adicionar modo" @@ -88,6 +89,10 @@ status = "draft — needs native review" "All fonts" = "Todas as fontes" "All supported properties" = "Todas as propriedades compatíveis" "All variables" = "Todas as variáveis" +"Along the bottom" = "Ao longo da borda inferior" +"Along the left" = "Ao longo da borda esquerda" +"Along the right" = "Ao longo da borda direita" +"Along the top" = "Ao longo da borda superior" "Alpha" = "Alfa" "Alt+drag" = "Alt+arrastar" "An invite link that opens FigCraft and joins" = "Um link de convite que abre o FigCraft e entra na sessão" @@ -147,6 +152,7 @@ status = "draft — needs native review" "Basics" = "Básico" "Before" = "Antes" "Bend" = "Curvar" +"Between the selected layers" = "Entre as camadas selecionadas" "Bevel" = "Chanfro" "Binary assets added" = "Arquivos binários adicionados" "Binary assets removed" = "Arquivos binários removidos" @@ -283,7 +289,6 @@ status = "draft — needs native review" "Constraints" = "Restrições" "Content" = "Conteúdo" "Contents hidden" = "Conteúdo oculto" -"Contents only" = "Somente o conteúdo" "Contextual alternates" = "Alternativas contextuais" "Contrast" = "Contraste" "Contributors" = "Colaboradores" @@ -589,6 +594,7 @@ status = "draft — needs native review" "If" = "Se" "If {condition}" = "Se {condition}" "Ignore auto layout (absolute position)" = "Ignorar layout automático (posição absoluta)" +"Ignore overlapping layers" = "Ignorar camadas sobrepostas" "Image" = "Imagem" "Images" = "Imagens" "Import" = "Importar" @@ -647,6 +653,7 @@ status = "draft — needs native review" "Join|stroke" = "Junção" "Just now" = "Agora mesmo" "Justify" = "Justificar" +"Keep a measurement on the canvas for developers" = "Manter uma medição no canvas para desenvolvedores" "Keep appearance" = "Manter aparência" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Manter aparência converte cada valor de cor para {profile} para que as cores pareçam iguais. Manter valores deixa os números como estão, então as cores mudam." "Keep tool selected after use" = "Manter a ferramenta selecionada após o uso" @@ -729,6 +736,7 @@ status = "draft — needs native review" "Max lines" = "Máx. de linhas" "Maximize" = "Maximizar" "Measure to the selection" = "Medir até a seleção" +"Measurements" = "Medições" "Merge into main" = "Mesclar no principal" "Merge regions" = "Mesclar regiões" "Merge the branch into the main file (one undo step) and archive it" = "Mescla a branch no arquivo principal (uma etapa de desfazer) e a arquiva" @@ -1027,6 +1035,7 @@ status = "draft — needs native review" "Remove guide" = "Remover guia" "Remove interaction" = "Remover interação" "Remove library from this file" = "Remover biblioteca deste arquivo" +"Remove measurement" = "Remover medição" "Remove min and max" = "Remover mín. e máx." "Remove min and max height" = "Remover altura mín. e máx." "Remove min and max width" = "Remover largura mín. e máx." @@ -1342,6 +1351,7 @@ status = "draft — needs native review" "Title" = "Título" "Title case" = "Iniciais maiúsculas" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Para compartilhar esta biblioteca com sua equipe, publique o arquivo em um servidor (Compartilhar ▸ Publicar no servidor…) e depois publique a biblioteca lá." +"To the layer around it" = "Até a camada que a contém" "Toggle" = "Alternar" "Toggle light/dark theme" = "Alternar tema claro/escuro" "Toggle visibility" = "Alternar visibilidade" @@ -1899,6 +1909,9 @@ status = "draft — needs native review" "dev.focus" = "Visualização em foco" "dev.resolveChange" = "Resolver alteração" "dev.unfocus" = "Sair da visualização em foco" +"devMode.addMeasurement" = "Adicionar medição" +"devMode.measurements" = "Listar medições" +"devMode.removeMeasurement" = "Remover medição" "devResource.add" = "Adicionar recurso de desenvolvimento" "devResource.edit" = "Editar recurso de desenvolvimento" "devResource.list" = "Listar recursos de desenvolvimento" @@ -2130,7 +2143,9 @@ status = "draft — needs native review" "select.sameText" = "Selecionar mesmas propriedades de texto" "select.set" = "Selecionar" "selection.applyColorStyle" = "Aplicar estilo de cor à seleção" +"selection.bindColorVariable" = "Aplicar variável de cor à seleção" "selection.colors" = "Cores da seleção" +"selection.detachColor" = "Desvincular cor da seleção" "selection.selectColor" = "Selecionar camadas com esta cor" "selection.setPaint" = "Alterar cor da seleção" "shape.arrow" = "Seta" @@ -2199,6 +2214,8 @@ status = "draft — needs native review" "text.preview" = "Visualizar alteração de texto" "text.removeFromPath" = "Remover texto do caminho" "text.set" = "Definir texto" +"data.fill" = "Preencher cópias com dados" +"text.fit" = "Ajustar texto à caixa" "text.setFeatures" = "Recursos OpenType" "text.setVariations" = "Eixos de fonte variável" "text.sizeDown" = "Diminuir tamanho da fonte" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index baba56fd..1635123c 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -54,6 +54,7 @@ name = "Русский" "Add library file…" = "Добавить файл библиотеки…" "Add max height" = "Добавить максимальную высоту" "Add max width" = "Добавить максимальную ширину" +"Add measurement" = "Добавить измерение" "Add min height" = "Добавить минимальную высоту" "Add min width" = "Добавить минимальную ширину" "Add mode" = "Добавить режим" @@ -93,6 +94,10 @@ name = "Русский" "All supported properties" = "Все поддерживаемые свойства" "All the fonts this file uses are installed" = "Все шрифты, которые использует этот файл, установлены" "All variables" = "Все переменные" +"Along the bottom" = "Вдоль нижнего края" +"Along the left" = "Вдоль левого края" +"Along the right" = "Вдоль правого края" +"Along the top" = "Вдоль верхнего края" "Alpha" = "Альфа" "Alphabetical" = "По алфавиту" "Alt+drag" = "Alt+перетаскивание" @@ -157,6 +162,7 @@ name = "Русский" "Basics" = "Основы" "Before" = "До" "Bend" = "Изгиб" +"Between the selected layers" = "Между выбранными слоями" "Bevel" = "Скошенное" "Binary assets added" = "Добавлено бинарных ресурсов" "Binary assets removed" = "Удалено бинарных ресурсов" @@ -296,7 +302,6 @@ name = "Русский" "Constraints" = "Ограничения" "Content" = "Содержимое" "Contents hidden" = "Содержимое скрыто" -"Contents only" = "Только содержимое" "Contextual alternates" = "Контекстные альтернативы" "Contrast" = "Контраст" "Contributors" = "Участники" @@ -623,6 +628,7 @@ name = "Русский" "If it keeps happening, please report it at {link} with this message." = "Если это повторяется, сообщите об этом по адресу {link}, приложив это сообщение." "If {condition}" = "Если {condition}" "Ignore auto layout (absolute position)" = "Игнорировать автолейаут (абсолютная позиция)" +"Ignore overlapping layers" = "Игнорировать перекрывающиеся слои" "Image" = "Изображение" "Images" = "Изображения" "Import" = "Импорт" @@ -686,6 +692,7 @@ name = "Русский" "Jun" = "июн." "Just now" = "Только что" "Justify" = "По ширине" +"Keep a measurement on the canvas for developers" = "Оставить измерение на холсте для разработчиков" "Keep appearance" = "Сохранить внешний вид" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "«Сохранить внешний вид» преобразует все значения цветов в {profile}, сохраняя их вид. «Сохранить значения» оставляет числа без изменений, поэтому цвета меняются." "Keep tool selected after use" = "Оставлять инструмент выбранным после использования" @@ -776,6 +783,7 @@ name = "Русский" "Maximize" = "Развернуть окно" "May" = "май" "Measure to the selection" = "Измерить до выделения" +"Measurements" = "Измерения" "Merge into main" = "Объединить с основной" "Merge regions" = "Объединить области" "Merge the branch into the main file (one undo step) and archive it" = "Объединить ветку с основным файлом (один шаг отмены) и архивировать её" @@ -1094,6 +1102,7 @@ name = "Русский" "Remove guide" = "Удалить направляющую" "Remove interaction" = "Удалить взаимодействие" "Remove library from this file" = "Удалить библиотеку из этого файла" +"Remove measurement" = "Удалить измерение" "Remove min and max" = "Убрать минимум и максимум" "Remove min and max height" = "Убрать минимальную и максимальную высоту" "Remove min and max width" = "Убрать минимальную и максимальную ширину" @@ -1433,6 +1442,7 @@ name = "Русский" "Title" = "Заголовок" "Title case" = "Каждое Слово С Прописной" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Чтобы поделиться библиотекой с командой, опубликуйте файл на сервере («Поделиться» ▸ «Опубликовать на сервере…»), затем опубликуйте там библиотеку." +"To the layer around it" = "До родительского слоя" "Toggle" = "Переключить" "Toggle light/dark theme" = "Переключить светлую/тёмную тему" "Toggle visibility" = "Переключить видимость" @@ -1895,6 +1905,9 @@ name = "Русский" "dev.focus" = "Режим фокуса" "dev.resolveChange" = "Разрешить изменение" "dev.unfocus" = "Выйти из режима фокуса" +"devMode.addMeasurement" = "Добавить измерение" +"devMode.measurements" = "Список измерений" +"devMode.removeMeasurement" = "Удалить измерение" "devResource.add" = "Добавить ресурс для разработки" "devResource.edit" = "Изменить ресурс для разработки" "devResource.list" = "Список ресурсов для разработки" @@ -2132,7 +2145,9 @@ name = "Русский" "select.sameText" = "Выделить с одинаковыми свойствами текста" "select.set" = "Выделить" "selection.applyColorStyle" = "Применить цветовой стиль к выделению" +"selection.bindColorVariable" = "Применить цветовую переменную к выделению" "selection.colors" = "Цвета выделения" +"selection.detachColor" = "Отвязать цвет выделения" "selection.selectColor" = "Выделить слои с этим цветом" "selection.setPaint" = "Изменить цвет выделения" "shape.arrow" = "Стрелка" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index be25d38e..e4fff1a7 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -54,6 +54,7 @@ name = "Svenska" "Add library file…" = "Lägg till biblioteksfil…" "Add max height" = "Lägg till maxhöjd" "Add max width" = "Lägg till maxbredd" +"Add measurement" = "Lägg till mått" "Add min height" = "Lägg till minhöjd" "Add min width" = "Lägg till minbredd" "Add mode" = "Lägg till läge" @@ -92,6 +93,10 @@ name = "Svenska" "All fonts" = "Alla typsnitt" "All supported properties" = "Alla egenskaper som stöds" "All variables" = "Alla variabler" +"Along the bottom" = "Längs nederkanten" +"Along the left" = "Längs vänsterkanten" +"Along the right" = "Längs högerkanten" +"Along the top" = "Längs överkanten" "Alpha" = "Alfa" "Alphabetical" = "I bokstavsordning" "Alt+drag" = "Alt+dra" @@ -156,6 +161,7 @@ name = "Svenska" "Basics" = "Grunder" "Before" = "Före" "Bend" = "Böj" +"Between the selected layers" = "Mellan de markerade lagren" "Bevel" = "Fasning" "Binary assets added" = "Binära resurser tillagda" "Binary assets removed" = "Binära resurser borttagna" @@ -293,7 +299,6 @@ name = "Svenska" "Constraints" = "Begränsningar" "Content" = "Innehåll" "Contents hidden" = "Innehållet är dolt" -"Contents only" = "Endast innehåll" "Contextual alternates" = "Kontextuella alternativ" "Contrast" = "Kontrast" "Contributors" = "Bidragsgivare" @@ -618,6 +623,7 @@ name = "Svenska" "If" = "Om" "If {condition}" = "Om {condition}" "Ignore auto layout (absolute position)" = "Ignorera automatisk layout (absolut position)" +"Ignore overlapping layers" = "Ignorera överlappande lager" "Image" = "Bild" "Images" = "Bilder" "Import" = "Importera" @@ -679,6 +685,7 @@ name = "Svenska" "Jun" = "jun" "Just now" = "Nyss" "Justify" = "Marginaljustera" +"Keep a measurement on the canvas for developers" = "Behåll ett mått på arbetsytan för utvecklare" "Keep appearance" = "Behåll utseende" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Behåll utseende konverterar varje färgvärde till {profile} så att färgerna ser likadana ut. Behåll värden lämnar siffrorna som de är, så färgerna förskjuts." "Keep tool selected after use" = "Behåll verktyget markerat efter användning" @@ -768,6 +775,7 @@ name = "Svenska" "Maximize" = "Maximera" "May" = "Maj" "Measure to the selection" = "Mät avståndet till markeringen" +"Measurements" = "Mått" "Merge into main" = "Sammanfoga med main" "Merge regions" = "Sammanfoga regioner" "Merge the branch into the main file (one undo step) and archive it" = "Sammanfoga grenen med huvudfilen (ett ångrasteg) och arkivera den" @@ -1079,6 +1087,7 @@ name = "Svenska" "Remove guide" = "Ta bort stödlinje" "Remove interaction" = "Ta bort interaktion" "Remove library from this file" = "Ta bort biblioteket från den här filen" +"Remove measurement" = "Ta bort mått" "Remove min and max" = "Ta bort min och max" "Remove min and max height" = "Ta bort min- och maxhöjd" "Remove min and max width" = "Ta bort min- och maxbredd" @@ -1410,6 +1419,7 @@ name = "Svenska" "Title" = "Titel" "Title case" = "Inledande versaler" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Om du vill dela biblioteket med ditt team publicerar du filen på en server (Dela ▸ Publicera på server…) och publicerar sedan biblioteket där." +"To the layer around it" = "Till lagret runt det" "Toggle" = "Visa/dölj" "Toggle light/dark theme" = "Växla mellan ljust och mörkt tema" "Toggle visibility" = "Visa/dölj" @@ -1864,6 +1874,9 @@ name = "Svenska" "dev.focus" = "Fokusvy" "dev.resolveChange" = "Lös ändring" "dev.unfocus" = "Lämna fokusvy" +"devMode.addMeasurement" = "Lägg till mått" +"devMode.measurements" = "Lista mått" +"devMode.removeMeasurement" = "Ta bort mått" "devResource.add" = "Lägg till utvecklarresurs" "devResource.edit" = "Redigera utvecklarresurs" "devResource.list" = "Lista utvecklarresurser" @@ -2099,7 +2112,9 @@ name = "Svenska" "select.sameText" = "Markera samma textegenskaper" "select.set" = "Markera" "selection.applyColorStyle" = "Använd färgformatmall på markeringen" +"selection.bindColorVariable" = "Använd färgvariabel på markeringen" "selection.colors" = "Markeringsfärger" +"selection.detachColor" = "Koppla bort markeringsfärg" "selection.selectColor" = "Markera lager med den här färgen" "selection.setPaint" = "Ändra markeringens färg" "shape.arrow" = "Pil" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index 5d15c9c9..fbb1f661 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -55,6 +55,7 @@ name = "Türkçe" "Add library file…" = "Kitaplık dosyası ekle…" "Add max height" = "En büyük yükseklik ekle" "Add max width" = "En büyük genişlik ekle" +"Add measurement" = "Ölçüm ekle" "Add min height" = "En küçük yükseklik ekle" "Add min width" = "En küçük genişlik ekle" "Add mode" = "Mod ekle" @@ -92,6 +93,10 @@ name = "Türkçe" "All fonts" = "Tüm yazı tipleri" "All supported properties" = "Desteklenen tüm özellikler" "All variables" = "Tüm değişkenler" +"Along the bottom" = "Alt kenar boyunca" +"Along the left" = "Sol kenar boyunca" +"Along the right" = "Sağ kenar boyunca" +"Along the top" = "Üst kenar boyunca" "Alpha" = "Alfa" "Alphabetical" = "Alfabetik" "Alt+drag" = "Alt+sürükle" @@ -156,6 +161,7 @@ name = "Türkçe" "Basics" = "Temel bilgiler" "Before" = "Önce" "Bend" = "Bük" +"Between the selected layers" = "Seçili katmanlar arasında" "Bevel" = "Eğimli" "Binary assets added" = "Eklenen ikili varlıklar" "Binary assets removed" = "Kaldırılan ikili varlıklar" @@ -293,7 +299,6 @@ name = "Türkçe" "Constraints" = "Kısıtlamalar" "Content" = "İçerik" "Contents hidden" = "İçerik gizli" -"Contents only" = "Yalnızca içerik" "Contextual alternates" = "Bağlamsal alternatifler" "Contrast" = "Kontrast" "Contributors" = "Katkıda bulunanlar" @@ -611,6 +616,7 @@ name = "Türkçe" "If" = "Eğer" "If {condition}" = "Eğer {condition}" "Ignore auto layout (absolute position)" = "Otomatik yerleşimi yok say (mutlak konum)" +"Ignore overlapping layers" = "Çakışan katmanları yok say" "Image" = "Görüntü" "Images" = "Görüntüler" "Import" = "İçe aktar" @@ -672,6 +678,7 @@ name = "Türkçe" "Jun" = "Haz" "Just now" = "Az önce" "Justify" = "İki yana yasla" +"Keep a measurement on the canvas for developers" = "Geliştiriciler için tuvalde bir ölçüm tut" "Keep appearance" = "Görünümü koru" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Görünümü koru, renkler aynı görünsün diye her renk değerini {profile} profiline dönüştürür. Değerleri koru, sayıları olduğu gibi bırakır; bu yüzden renkler kayar." "Keep tool selected after use" = "Kullanımdan sonra aracı seçili tut" @@ -761,6 +768,7 @@ name = "Türkçe" "Maximize" = "Ekranı kapla" "May" = "May" "Measure to the selection" = "Seçime kadar ölç" +"Measurements" = "Ölçümler" "Merge into main" = "Ana dosyaya birleştir" "Merge regions" = "Bölgeleri birleştir" "Merge the branch into the main file (one undo step) and archive it" = "Dalı ana dosyaya birleştir (tek geri alma adımı) ve arşivle" @@ -1070,6 +1078,7 @@ name = "Türkçe" "Remove guide" = "Kılavuzu kaldır" "Remove interaction" = "Etkileşimi kaldır" "Remove library from this file" = "Kitaplığı bu dosyadan kaldır" +"Remove measurement" = "Ölçümü kaldır" "Remove min and max" = "En küçük ve en büyüğü kaldır" "Remove min and max height" = "En küçük ve en büyük yüksekliği kaldır" "Remove min and max width" = "En küçük ve en büyük genişliği kaldır" @@ -1395,6 +1404,7 @@ name = "Türkçe" "Title" = "Başlık" "Title case" = "Başlık biçimi" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Bu kitaplığı ekibinizle paylaşmak için dosyayı bir sunucuya yayımlayın (Paylaş ▸ Sunucuya yayımla…), sonra kitaplığı orada yayımlayın." +"To the layer around it" = "Çevreleyen katmana kadar" "Toggle" = "Aç/Kapat" "Toggle light/dark theme" = "Açık/koyu temayı değiştir" "Toggle visibility" = "Görünürlüğü değiştir" @@ -1844,6 +1854,9 @@ name = "Türkçe" "dev.focus" = "Odak görünümü" "dev.resolveChange" = "Değişikliği çöz" "dev.unfocus" = "Odak görünümünden çık" +"devMode.addMeasurement" = "Ölçüm ekle" +"devMode.measurements" = "Ölçümleri listele" +"devMode.removeMeasurement" = "Ölçümü kaldır" "devResource.add" = "Geliştirici kaynağı ekle" "devResource.edit" = "Geliştirici kaynağını düzenle" "devResource.list" = "Geliştirici kaynaklarını listele" @@ -2077,7 +2090,9 @@ name = "Türkçe" "select.sameText" = "Aynı metin özelliklerini seç" "select.set" = "Seç" "selection.applyColorStyle" = "Seçime renk stili uygula" +"selection.bindColorVariable" = "Seçime renk değişkeni uygula" "selection.colors" = "Seçim renkleri" +"selection.detachColor" = "Seçim rengini ayır" "selection.selectColor" = "Bu renkteki katmanları seç" "selection.setPaint" = "Seçimin rengini değiştir" "shape.arrow" = "Ok" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 7add99c5..acebfc76 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -54,6 +54,7 @@ status = "draft — needs native review" "Add library file…" = "添加库文件…" "Add max height" = "添加最大高度" "Add max width" = "添加最大宽度" +"Add measurement" = "添加测量" "Add min height" = "添加最小高度" "Add min width" = "添加最小宽度" "Add mode" = "添加模式" @@ -91,6 +92,10 @@ status = "draft — needs native review" "All fonts" = "全部字体" "All supported properties" = "所有支持的属性" "All variables" = "全部变量" +"Along the bottom" = "沿底边" +"Along the left" = "沿左边" +"Along the right" = "沿右边" +"Along the top" = "沿顶边" "Alpha" = "Alpha" "Alt+drag" = "Alt+拖动" "An invite link that opens FigCraft and joins" = "可打开 FigCraft 并加入的邀请链接" @@ -150,6 +155,7 @@ status = "draft — needs native review" "Basics" = "基础" "Before" = "之前" "Bend" = "弯曲" +"Between the selected layers" = "所选图层之间" "Bevel" = "斜角" "Binary assets added" = "新增的二进制资源" "Binary assets removed" = "删除的二进制资源" @@ -286,7 +292,6 @@ status = "draft — needs native review" "Constraints" = "约束" "Content" = "内容" "Contents hidden" = "内容已隐藏" -"Contents only" = "仅内容" "Contextual alternates" = "上下文替代字" "Contrast" = "对比度" "Contributors" = "贡献者" @@ -591,6 +596,7 @@ status = "draft — needs native review" "If" = "如果" "If {condition}" = "如果 {condition}" "Ignore auto layout (absolute position)" = "忽略自动布局(绝对定位)" +"Ignore overlapping layers" = "忽略重叠图层" "Image" = "图片" "Images" = "图片" "Import" = "导入" @@ -649,6 +655,7 @@ status = "draft — needs native review" "Join|stroke" = "转角" "Just now" = "刚刚" "Justify" = "两端对齐" +"Keep a measurement on the canvas for developers" = "为开发者在画布上保留测量" "Keep appearance" = "保持外观" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "“保持外观”会将每个颜色值转换为 {profile},使颜色看起来不变。“保持数值”则保留数值不变,颜色会有偏移。" "Keep tool selected after use" = "使用后保持工具选中" @@ -731,6 +738,7 @@ status = "draft — needs native review" "Max lines" = "最大行数" "Maximize" = "最大化" "Measure to the selection" = "测量到所选项的距离" +"Measurements" = "测量" "Merge into main" = "合并到主文件" "Merge regions" = "合并区域" "Merge the branch into the main file (one undo step) and archive it" = "将分支合并到主文件(一步撤销)并归档" @@ -1029,6 +1037,7 @@ status = "draft — needs native review" "Remove guide" = "移除参考线" "Remove interaction" = "移除交互" "Remove library from this file" = "从此文件中移除库" +"Remove measurement" = "移除测量" "Remove min and max" = "移除最小值和最大值" "Remove min and max height" = "移除最小和最大高度" "Remove min and max width" = "移除最小和最大宽度" @@ -1344,6 +1353,7 @@ status = "draft — needs native review" "Title" = "标题" "Title case" = "首字母大写" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "要与团队共享此库,请先将文件发布到服务器(分享 ▸ 发布到服务器…),然后在那里发布库。" +"To the layer around it" = "到外层图层" "Toggle" = "切换显示" "Toggle light/dark theme" = "切换浅色/深色主题" "Toggle visibility" = "切换可见性" @@ -1787,6 +1797,9 @@ status = "draft — needs native review" "dev.focus" = "聚焦视图" "dev.resolveChange" = "解决更改" "dev.unfocus" = "退出聚焦视图" +"devMode.addMeasurement" = "添加测量" +"devMode.measurements" = "列出测量" +"devMode.removeMeasurement" = "移除测量" "devResource.add" = "添加开发资源" "devResource.edit" = "编辑开发资源" "devResource.list" = "列出开发资源" @@ -2018,7 +2031,9 @@ status = "draft — needs native review" "select.sameText" = "选择相同文本属性" "select.set" = "选择" "selection.applyColorStyle" = "将颜色样式应用于所选项" +"selection.bindColorVariable" = "将颜色变量应用于所选项" "selection.colors" = "所选项颜色" +"selection.detachColor" = "分离所选项颜色" "selection.selectColor" = "选择使用此颜色的图层" "selection.setPaint" = "更改所选项颜色" "shape.arrow" = "箭头" diff --git a/crates/ui-egui/src/a11y.rs b/crates/ui-egui/src/a11y.rs index 5fc098a4..3c5fafb6 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, @@ -249,8 +253,10 @@ struct Shared { name: String, } +/// Run `f` on the per-context temporary `Shared` accessibility state keyed by +/// `figcraft_a11y`. 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 +290,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 +654,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..14be637d 100644 --- a/crates/ui-egui/src/a11y_tests.rs +++ b/crates/ui-egui/src/a11y_tests.rs @@ -403,3 +403,31 @@ 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"); } + +/// Asserts side-panel and Pages edges are not focusable, and Tab never stops on the +/// side-panel edges. +#[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..db0cc0f7 100644 --- a/crates/ui-egui/src/assets_panel.rs +++ b/crates/ui-egui/src/assets_panel.rs @@ -164,8 +164,9 @@ enum Act { Hide(NodeId, bool), } +/// Stable egui id for the assets drag payload. 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 @@ -195,19 +196,20 @@ pub(crate) fn thumbnail(ctx: &egui::Context, doc: &Document, id: NodeId, px: f32 Some(ctx.load_texture(format!("asset-thumb-{}", id.0), img, egui::TextureOptions::LINEAR)) } -/// The pixels a thumbnail shows. A slice paints nothing itself, so it shows the page content -/// under it, exactly as exporting one does (`figcraft_engine::cmd::file::drawn_layers`): without -/// this a slice's preview is a blank checkerboard next to a correct export. +/// The pixels a thumbnail shows. A slice paints nothing itself, so it shows the content under it +/// that exporting it with its first export setting writes (`figcraft_engine::cmd::file::drawn_layers`): +/// without this a slice's preview is a blank checkerboard next to a correct export. pub(crate) fn thumbnail_pixmap(doc: &Document, id: NodeId, px: f32) -> Option { - let slice = doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Slice)); - let b = if slice { doc.world_bounds(id)? } else { figcraft_render::export_bounds(doc, id)? }; + let slice = doc.node(id).filter(|n| matches!(n.kind, NodeKind::Slice)); + let b = if slice.is_some() { doc.world_bounds(id)? } else { figcraft_render::export_bounds(doc, id)? }; let side = b.width().max(b.height()); if !(side.is_finite() && side > 0.0) { return None; } let scale = (f64::from(px) / side).min(2.0); - if slice { - let layers = doc.page_of(id).and_then(|p| doc.node(p)).map(|p| p.children.clone()).unwrap_or_default(); + if let Some(n) = slice { + let setting = n.exports.first().cloned().unwrap_or_default(); + let layers = figcraft_engine::cmd::file::drawn_layers(doc, &[id], &setting); return figcraft_render::render_area(doc, &layers, b, scale); } figcraft_render::render_node(doc, id, scale) @@ -275,7 +277,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 +286,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 +300,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 +310,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 +325,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,11 +343,12 @@ 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)))); if crate::widgets::button(&mut child, tr("Update"), true, true).clicked() + && !crate::menus::dev_mode_refuses(app, "library.acceptUpdates") && let Some(v) = app.run("library.acceptUpdates", json!({})) { let (c, s) = (v["components"].as_u64().unwrap_or(0), v["styles"].as_u64().unwrap_or(0)); @@ -436,7 +439,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); @@ -579,6 +582,10 @@ fn render_thumbs(ui: &Ui, w: f32, thumbs: &mut Thumbs, wanted: &[(Source, NodeId } } +/// Draw the Assets panel: refresh libraries, switch to the libraries view when asked, +/// otherwise cache a listing keyed by revision, query and library state, render local and +/// enabled-library assets with reused thumbnails, and dispatch place, style, hide, toggle +/// and drag actions. pub fn show(app: &mut FigApp, ui: &mut Ui) { refresh_libraries(app, ui); if app.ui.libraries { @@ -591,7 +598,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 +610,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 +662,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); @@ -666,7 +673,9 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { } Some(Act::Style(src, style)) => apply_style(app, &src, &style), Some(Act::Hide(id, on)) => { - app.run("component.hideWhenPublishing", json!({"ids": [id], "on": on})); + if !crate::menus::dev_mode_refuses(app, "component.hideWhenPublishing") { + app.run("component.hideWhenPublishing", json!({"ids": [id], "on": on})); + } } Some(Act::Toggle(id)) => { let mut c = collapsed; @@ -683,29 +692,39 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { /// Place component `id` of `src` centred on page point (`x`, `y`). fn place(app: &mut FigApp, src: &Source, id: NodeId, x: f64, y: f64) { - match src { - Source::Local => app.run("instance.place", json!({"component": id, "x": x, "y": y})), - Source::Library(lib) => app.run("library.place", json!({"library": lib, "component": id, "x": x, "y": y})), + let (cmd, params) = match src { + Source::Local => ("instance.place", json!({"component": id, "x": x, "y": y})), + Source::Library(lib) => ("library.place", json!({"library": lib, "component": id, "x": x, "y": y})), }; + if !crate::menus::dev_mode_refuses(app, cmd) { + app.run(cmd, params); + } } fn apply_style(app: &mut FigApp, src: &Source, style: &str) { + let (cmd, params) = match src { + Source::Local => ("style.apply", json!({"style": style})), + Source::Library(lib) => ("library.applyStyle", json!({"library": lib, "style": style})), + }; + if crate::menus::dev_mode_refuses(app, cmd) { + return; + } if app.session.selection().is_empty() { app.toast(tr("Select layers to apply the style to")); return; } - match src { - Source::Local => app.run("style.apply", json!({"style": style})), - Source::Library(lib) => app.run("library.applyStyle", json!({"library": lib, "style": style})), - }; + app.run(cmd, params); } /// Swap the instance at page point (`x`, `y`) to component `id` of `src`. fn swap_at(app: &mut FigApp, src: &Source, id: NodeId, x: f64, y: f64) { - match src { - Source::Local => app.run("instance.swapAt", json!({"component": id, "x": x, "y": y})), - Source::Library(lib) => app.run("library.swapAt", json!({"library": lib, "component": id, "x": x, "y": y})), + let (cmd, params) = match src { + Source::Local => ("instance.swapAt", json!({"component": id, "x": x, "y": y})), + Source::Library(lib) => ("library.swapAt", json!({"library": lib, "component": id, "x": x, "y": y})), }; + if !crate::menus::dev_mode_refuses(app, cmd) { + app.run(cmd, params); + } } /// The component set (by name) a dragged asset belongs to, in the document it comes from: the @@ -759,9 +778,11 @@ 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 +819,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); } @@ -817,8 +838,12 @@ fn libraries_view(app: &mut FigApp, ui: &mut Ui) { }); } +/// Draw the publish form: status, publishable components/styles/collections, description and +/// publish buttons. Run `library.publish` locally or publish to the server in the +/// background, reset the form on success, and report when the file is unsaved so other files +/// cannot yet use it. 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 +912,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); } @@ -922,7 +947,10 @@ fn publish_form(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { None => {} } let params = (publish || to_server).then(|| json!({"description": form.description.clone().unwrap_or(description), "include": include})); - if publish && let Some(r) = params.clone().and_then(|p| app.run("library.publish", p)) { + if publish + && !crate::menus::dev_mode_refuses(app, "library.publish") + && let Some(r) = params.clone().and_then(|p| app.run("library.publish", p)) + { form = PublishForm::default(); // Other files read the saved file. app.save(); @@ -948,7 +976,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| { @@ -993,6 +1021,8 @@ fn usage(app: &mut FigApp, ui: &mut Ui, w: f32, link: &str) { ui.add_space(8.0); } +/// Draw the libraries enabled in this file with version/count/status, remove and swap +/// actions, add-file and pasted-link controls, and background fetch/cancel handling. fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { let t = Tokens::get(ui.ctx()); title(ui, w, tr("Libraries in this file")); @@ -1033,9 +1063,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 +1076,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,10 +1102,12 @@ 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()) { + if let Some(link) = pasted.map(|l| l.trim().to_string()).filter(|l| !l.is_empty()) + && !crate::menus::dev_mode_refuses(app, ADD) + { app.run_background(&ui.ctx().clone(), ADD, json!({"path": link}), Box::new(|app, _, r| added(app, &r))); } if app.session.waiting_for(ADD) { @@ -1087,10 +1119,13 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { } }); } - if let Some(id) = remove { + if let Some(id) = remove + && !crate::menus::dev_mode_refuses(app, "library.remove") + { app.run("library.remove", json!({"id": id})); } if let Some((from, to)) = swap + && !crate::menus::dev_mode_refuses(app, "library.swap") && let Some(r) = app.run("library.swap", json!({"from": from, "to": to})) { let moved = ["components", "styles", "variables"].iter().map(|k| r[*k].as_u64().unwrap_or(0)).sum::(); @@ -1101,7 +1136,7 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { trn("Swapped {n} assets; {missed} had no match and still use the old library", moved as usize, &[("missed", &missed)]) }); } - if add { + if add && !crate::menus::dev_mode_refuses(app, ADD) { app.pick_open(tr("Add library"), &["figcraft"], |app, p| { if let Some(r) = app.run(ADD, json!({"path": p.to_string_lossy()})) { added(app, &r); @@ -1118,7 +1153,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..de38ceb8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -163,12 +163,16 @@ fn pointer_event(app: &FigApp, kind: PointerKind, pos: Pos2, m: &egui::Modifiers } } +/// Run one canvas pass: update the viewport size and first-fit zoom; handle pointer/key input +/// and accessibility; draw the drop overlay, guides, live pointer and preedit; request/poll +/// renders and upload the texture; draw the page background and last texture; then draw overlays, +/// labels, handles, comments, versions/branch/dev/live UI and the cursor. pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { let rect = ui.max_rect(); 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() @@ -183,9 +187,17 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { } } let own_cursor = input(app, ui, &resp); - // A browser names no dragged file until the drop, so documents there still say "import". + // A browser names no dragged file until the drop, so documents there still say "import", or + // in Dev Mode show the Dev Mode note, though they open when dropped. let documents = ctx.input(|i| i.raw.hovered_files.iter().all(|f| f.path.as_deref().is_some_and(crate::is_document))); - crate::drop_overlay(&ctx, ctx.content_rect(), if documents { tr("Drop to open") } else { tr("Drop to import") }); + let label = if documents { + tr("Drop to open") + } else if app.ui.right_tab == crate::RightTab::Inspect { + tr("Dev Mode only inspects: switch to Design (Shift+D) to edit") + } else { + tr("Drop to import") + }; + crate::drop_overlay(&ctx, ctx.content_rect(), label); crate::guides::end_released(&ctx); crate::live::track_pointer(app, &ctx); app.sync_preedit(); @@ -244,6 +256,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { let image_rect = presented_image_rect(tl, v, zoom, ppp, pixel_preview); painter.image(tex.id(), image_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } + crate::dev_mode::refresh_measurements(app); app.session.tool.labels = overlays(app, &painter, &t); crate::frame_labels::rename_field(app, ui); // The zoom tool's area. @@ -509,14 +522,20 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { return false; } // Dropped files import onto the canvas, behind a card while they do ([`crate::pending_open`]); - // a dropped document opens as a file of its own, as on the home screen. + // a dropped document opens as a file of its own, as on the home screen. Dev Mode imports + // nothing into the design, and says so once for the whole drop. let dropped = ctx.input(|i| i.raw.dropped_files.clone()); + let mut refused = false; for f in dropped { let path = f.path().to_path_buf(); if crate::is_document(&path) { crate::pending_open::open_dropped(app, &f); continue; } + if refused || crate::menus::dev_mode_refuses(app, "file.import") { + refused = true; + continue; + } let pt = hover_pos.map(|h| app.to_page(h)).unwrap_or_default(); let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped.png".into()); let (params, read) = if path.exists() { @@ -541,7 +560,7 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { } // Leaving the canvas: nothing is hovered there any more. if !app.canvas.dragging && std::mem::take(&mut app.canvas.hovering) { - app.session.vector_pointer_left(); + app.session.pointer_left(); } // A pan whose button was released off the canvas is over. if !ctx.input(|i| i.pointer.button_down(egui::PointerButton::Middle) || i.pointer.primary_down()) { @@ -639,7 +658,7 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { } else { // Something over the canvas took the pointer. if std::mem::take(&mut app.canvas.hovering) { - app.session.vector_pointer_left(); + app.session.pointer_left(); } None }; @@ -687,6 +706,7 @@ pub fn open_context_menu(app: &mut FigApp, pos: Pos2, deep: bool) { } } app.ui.context_menu = Some((pos, app.session.selection())); + app.ui.context_menu_shown = false; } fn stroke(c: Color32, w: f32) -> Stroke { @@ -1469,7 +1489,9 @@ fn hatch(polys: &[Vec], gap: f32) -> Vec<[Pos2; 2]> { } /// The layers that get canvas labels and layout grids, in z-order: the page's visible top-level -/// layers and, inside sections whose contents show, their visible children (sections nest). +/// layers and, inside sections whose contents show, their visible children (sections nest). A +/// section comes after its children, so its title pill draws over (and takes clicks from) the +/// labels of frames near its top-left (#1150). fn boards(doc: &Document, page: NodeId) -> Vec { fn walk(doc: &Document, id: NodeId, depth: usize, out: &mut Vec) { let Some(n) = doc.node(id) else { return }; @@ -1478,10 +1500,10 @@ fn boards(doc: &Document, page: NodeId) -> Vec { if !cn.visible { continue; } - out.push(*c); if matches!(cn.kind, NodeKind::Section { contents_hidden: false }) && depth < 32 { walk(doc, *c, depth + 1, out); } + out.push(*c); } } let mut out = Vec::new(); 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..76ddf81c --- /dev/null +++ b/crates/ui-egui/src/clipboard_fence_tests.rs @@ -0,0 +1,200 @@ +//! 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 { + /// Record a system-clipboard text write in the test clipboard service. + 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()); + } + /// Return the test system clipboard's last text. + fn clipboard_get_text(&mut self) -> Option { + self.0.lock().unwrap().last_text.clone() + } + /// Count a PNG clipboard write in the test clipboard service. + 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 { + /// Create a test window over a session containing one selected rectangle. + 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 + } + + /// Build a test window around `s` with a recording clipboard service and run three frames. + 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 + } + + /// Run one production UI frame with `events` at the next 60 Hz test time. + 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(); + } + + /// Return the recorded `(text writes, PNG writes)` count. + fn writes(&self) -> (usize, usize) { + let r = self.recorded.lock().unwrap(); + (r.text.len(), r.pngs) + } +} + +/// Asserts that a control-channel copy shortcut writes no system clipboard, still runs the +/// command and is acknowledged. +#[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, + state: 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"); +} + +/// Asserts copy commands run under the synthetic-control fence write nothing, while the same +/// command afterwards writes once. +#[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)); +} + +/// Return the characters of a text node in the test window. +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"), + } +} + +/// Asserts a synthetic Cut deletes the selected text, keeps the edit session active, writes +/// no system clipboard, and ends its fence with the frame. +#[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"); +} + +/// Asserts a programmatic copy leaves the system clipboard alone and invalidates the stale +/// mirror, so a later user paste uses the system text rather than the programmatic layers. +#[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 9d37477a..f1cde02f 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, @@ -339,6 +339,8 @@ fn hue_color(ctx: &egui::Context, h: f32) -> Color32 { doc_c32(ctx, Hsb { h, s: 1.0, b: 1.0 }.to_color()) } +/// Draw the saturation/value square and its knob, updating `hsb.s` and `hsb.b` on click or +/// drag; returns `(changed, done)`. fn sv_square(ui: &mut Ui, rect: Rect, hsb: &mut Hsb) -> (bool, bool) { // White → hue horizontally, then transparent → black vertically. let mut m = Mesh::default(); @@ -362,7 +364,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() @@ -377,6 +379,8 @@ fn sv_square(ui: &mut Ui, rect: Rect, hsb: &mut Hsb) -> (bool, bool) { (changed, resp.drag_stopped() || resp.clicked()) } +/// Draw the hue gradient and knob, updating `hsb.h` clamped below 360; returns +/// `(changed, done)`. fn hue_slider(ui: &mut Ui, rect: Rect, hsb: &mut Hsb) -> (bool, bool) { let n = 12; let mut m = Mesh::default(); @@ -393,7 +397,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) @@ -417,6 +421,8 @@ fn slider_knob(ui: &mut Ui, rect: Rect, t: f32, id: Id) -> Option<(f32, bool)> { None } +/// Draw the checkerboard and transparent-to-opaque alpha gradient with a knob; returns the +/// new alpha and done state, or `None` when the slider is not interacting. fn alpha_slider(ui: &mut Ui, rect: Rect, color: Color) -> Option<(f32, bool)> { widgets::checker(&ui.painter().with_clip_rect(rect), rect, rect.height() / 2.0); let mut m = Mesh::default(); @@ -432,15 +438,18 @@ 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::new(Some(title))`) so the style picker can tell a click inside the picker from one outside. +/// `Id::unique(Some(title))`) so the style picker can tell a click inside the picker from one outside. pub fn window_id() -> Id { - Id::new("color_picker") + Id::unique("color_picker") } +/// Draw the colour picker for the current target: close it when there is no target or the +/// target's selection no longer matches, otherwise apply eyedropper, paint-kind, colour and +/// gradient-stop edits live, positioned beside a plugin field when applicable. pub fn show(app: &mut FigApp, ctx: &egui::Context) { let Some(target) = app.ui.picker.clone() else { // A colour sampled for a picker that has closed has no paint to go to. @@ -477,7 +486,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { 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::new(Some(title))`). + // (`Window::new` alone would key the area by `Id::unique(Some(title))`). .id(window_id()) .title_bar(false) .fixed_pos(pos) @@ -494,7 +503,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { 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; @@ -605,11 +614,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, @@ -773,7 +782,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)); @@ -817,7 +826,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); @@ -983,7 +992,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, @@ -1029,7 +1038,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); @@ -1061,7 +1070,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/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 6caab6d5..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); @@ -91,7 +92,7 @@ fn description_ui(app: &mut FigApp, ui: &mut Ui, id: NodeId, description: &str) 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})); } } @@ -701,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}})); diff --git a/crates/ui-egui/src/context_menu_tests.rs b/crates/ui-egui/src/context_menu_tests.rs new file mode 100644 index 00000000..61df3fdb --- /dev/null +++ b/crates/ui-egui/src/context_menu_tests.rs @@ -0,0 +1,69 @@ +//! The right-click menu on a layer: the copy commands sit in a Copy as submenu under Paste to +//! replace; "Copy link to selection" puts `figcraft://node/` on the clipboard and says so, and +//! with nothing selected the command copies nothing. + +use std::sync::{Arc, Mutex}; + +use egui::Pos2; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; +use crate::live_tests::{flat, has_text, text_center}; +use crate::present_tests::id; + +/// A clipboard that keeps what was copied where the test can read it. +struct Copied(Arc>>); + +impl crate::Services for Copied { + fn clipboard_set_text(&mut self, s: &str) { + if let Ok(mut copied) = self.0.lock() { + copied.push(s.to_string()); + } + } +} + +#[test] +fn the_context_menu_groups_the_copy_commands_under_copy_as() { + let mut s = Session::new(); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 200, "name": "หน้าแรก"})).unwrap()); + let copied = Arc::new(Mutex::new(Vec::new())); + let mut u = Ui::new(s); + u.app.services = Box::new(Copied(copied.clone())); + u.frame(vec![]); + u.app.ui.context_menu = Some((Pos2::new(700.0, 300.0), vec![f])); + u.frame(vec![]); + let shapes = flat(&u.frame_output(vec![]).shapes); + // A row under Paste to replace; the items are in its submenu, not in the menu itself. + let y = |s: &str| text_center(&shapes, s).y; + assert!(y("Paste to replace") < y("Copy as") && y("Copy as") < y("Bring to front"), "Copy as sits under Paste to replace"); + for item in ["Copy as CSS", "Copy as SVG", "Copy as PNG", "Copy link to selection"] { + assert!(!has_text(&shapes, item), "{item} is inside the submenu"); + } + let at = text_center(&shapes, "Copy as"); + u.button(at, true); + u.button(at, false); + let shapes = flat(&u.frame_output(vec![]).shapes); + for item in ["Copy as CSS", "Copy as SVG", "Copy as PNG", "Copy link to selection"] { + assert!(has_text(&shapes, item), "{item} is in the submenu"); + } + let at = text_center(&shapes, "Copy link to selection"); + u.button(at, true); + u.button(at, false); + assert_eq!(*copied.lock().unwrap(), vec![format!("figcraft://node/{}", f.0)], "the link to the frame"); + assert_eq!(u.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Link copied")); + assert!(u.app.ui.context_menu.is_none(), "the menu closes"); +} + +#[test] +fn copying_a_link_with_nothing_selected_copies_nothing() { + let mut s = Session::new(); + s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 200})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + let copied = Arc::new(Mutex::new(Vec::new())); + let mut u = Ui::new(s); + u.app.services = Box::new(Copied(copied.clone())); + crate::menus::invoke(&mut u.app, "edit.copyLink"); + assert!(copied.lock().unwrap().is_empty(), "no link to node 0"); + assert!(u.app.ui.toast.is_none()); +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index edd9fff7..97e62d1f 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,8 @@ //! 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::{Arc, Mutex}; + use egui::Context; use figcraft_engine::cmd::comment::{Filter, Sort}; use figcraft_engine::{PointerEvent, PointerKind}; @@ -39,125 +43,436 @@ pub struct ControlReply { pub result: Result, } +/// Which side has finished a control request. +/// +/// The transport and the UI race to settle one request: while it is `Open`, either may cancel it +/// (the transport's timeout) or commit it (the UI's final reply). Once settled, neither side may +/// change it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum RequestState { + #[default] + Open, + /// The transport stopped waiting: no file may be written and no reply is wanted. + Cancelled, + /// The UI wrote the file (if any) and queued the reply before releasing the gate. + Replied, +} + +/// The gate the transport's timeout and the UI's final reply share for one request. +/// +/// Encoding happens outside it, so a slow PNG never blocks the timeout. The final file write and +/// reply enqueue happen under it, after rechecking cancellation and the deadline, so a timeout +/// cannot overtake a committed file and a cancellation cannot slip between the check and the +/// write. It is never held across socket I/O. +#[derive(Clone, Default)] +pub struct RequestGate { + state: Arc>, +} + +impl RequestGate { + /// Create a request gate in its default `Open` state. + pub fn new() -> Self { + Self::default() + } + + /// Return a copy of the gate's current request state. + pub fn state(&self) -> RequestState { + *self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner) + } + + /// Whether the gate is `Cancelled`. + pub fn is_cancelled(&self) -> bool { + self.state() == RequestState::Cancelled + } + + /// Whether the gate is `Replied`. + pub fn is_replied(&self) -> bool { + self.state() == RequestState::Replied + } + + /// Cancel an open request, as the transport's timeout does. Returns the resulting state so a + /// timeout that lost the race knows to consume the already-queued reply. + pub fn cancel_if_open(&self) -> RequestState { + let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if *state == RequestState::Open { + *state = RequestState::Cancelled; + } + *state + } + + /// Run the final commit (file write and reply enqueue) only while the request is still open, + /// then mark it replied before releasing the gate. `None` means the transport cancelled it or + /// it was already committed; the closure must not have run then. + pub fn commit(&self, finish: impl FnOnce() -> T) -> Option { + let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if *state != RequestState::Open { + return None; + } + let value = finish(); + *state = RequestState::Replied; + Some(value) + } +} + pub struct ControlRequest { pub id: Value, pub method: String, pub params: Value, pub reply: std::sync::mpsc::Sender, - /// The transport stops waiting after its timeout; discard requests still queued then. - pub cancelled: std::sync::Arc, + /// The transport stops waiting after its timeout; discard requests still queued then. The UI + /// passes the same gate to its screenshot's final commit. + pub state: RequestGate, } impl ControlRequest { + /// Whether this request's gate is `Cancelled`. pub fn is_cancelled(&self) -> bool { - self.cancelled.load(std::sync::atomic::Ordering::Relaxed) + self.state.is_cancelled() } } -/// 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 { + /// Create an empty capture slot for `viewport`, holding a clone of the UI context. + 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); + } + }) + } + + /// Store the first delivered image only while the slot is open and untaken, ignoring later + /// or closed deliveries, then wake the viewport after releasing the lock. + 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 { + /// Return the checked pixel byte count (`width × height × 4`), or `None` on overflow. + 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. + /// The transport's timeout and this request's final write share this gate. + pub state: RequestGate, + /// 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 { + /// Close and drop the capture slot, if any, and zero the reserved byte count. + 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 - }); +/// Send one reply carrying the request id, ignoring a disconnected receiver. +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, state } = 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 { - 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) { - ctx.request_repaint_of(crate::windows::viewport(target)); - app.control_queue.push(r); - } else { - let _ = r.reply.send(ControlReply { id: r.id, result: Err(format!("no window {target}")) }); - } - 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())); - continue; - } - let res = handle(app, ctx, &r.method, &r.params); - 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 + 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, + state, + 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, }); - if !list.is_empty() { - ctx.request_repaint(); +} + +/// 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.state.is_cancelled(), 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; + } } } -/// 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 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 { - ctx.input(|i| i.raw.viewports.get(&crate::windows::viewport(id)).and_then(egui::ViewportInfo::visible)) == Some(false) + visibility(ctx, id) == 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) { +/// 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; @@ -167,18 +482,32 @@ pub fn drain_hidden(app: &mut FigApp, ctx: &Context) { 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)); - } + 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 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); + 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; } @@ -186,37 +515,400 @@ pub fn drain_hidden(app: &mut FigApp, ctx: &Context) { // 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, epoch) = pass_geometry(app, ctx, viewport); + expire(app, ctx, now); + + // Drain completed captures first, so their budget is free before new requests are admitted. + drain_completed(app, ctx, viewport, now, epoch); + + // Admit queued captures for the window drawing now; other windows keep waiting for their frame. + admit_queued(app, ctx, viewport, now); + + // 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. + enforce_capture_budget(app, viewport, spec); + + // 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. + settle_and_issue(app, ctx, viewport, now, epoch, spec); + request_paint(app, ctx); +} + +/// Establish this pass's screenshot geometry and epoch: a resize, DPI change or zoom change (even +/// back) bumps the epoch captures issued under the old one were admitted against. +fn pass_geometry(app: &mut FigApp, ctx: &Context, viewport: egui::ViewportId) -> (Option, u64) { + 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); + (spec, epoch) +} + +/// Take the image delivery for the shot at `index`; `None` when no image has arrived yet. +fn arrived_shot(app: &mut FigApp, index: usize) -> Option<(ShotRequest, Arc)> { + let slot = app.capture.shots.get(index)?.slot.clone()?; + let image = slot.take()?; + let mut r = app.capture.shots.remove(index); + r.release(); + Some((r, image)) +} + +/// Drain images delivered for this viewport: reply, re-queue for a geometry retry, or drop a +/// cancelled capture. Each viewport drains its own captures: their geometry and document differ. +fn drain_completed(app: &mut FigApp, ctx: &Context, viewport: egui::ViewportId, now: f64, epoch: u64) { + let mut i = 0; + while i < app.capture.shots.len() { + if app.capture.shots[i].viewport != viewport { + i += 1; + continue; + } + let Some((r, image)) = arrived_shot(app, i) else { + i += 1; + continue; + }; + settle_arrival(app, ctx, viewport, now, epoch, r, &image); + ctx.request_repaint(); + } +} + +/// Whether the pass geometry differs from the one the capture was issued at (even back to an old +/// size): the pixels cannot be accepted for the admitted surface. +fn geometry_moved(r: &ShotRequest, current: CaptureSpec, epoch: u64) -> bool { + r.epoch != epoch || r.spec.is_none_or(|s| s.size_px != current.size_px || s.pixels_per_point != current.pixels_per_point) +} + +/// 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. +fn scale_mismatch(image: &egui::ColorImage, spec: CaptureSpec) -> bool { + image.size[0].abs_diff(spec.size_px[0]) > 1 || image.size[1].abs_diff(spec.size_px[1]) > 1 +} + +/// Re-queue a capture against the new pass, or hand it back when its retries are exhausted. +fn requeue_capture(app: &mut FigApp, mut r: ShotRequest, current: CaptureSpec, epoch: u64) -> Option { + if r.retries >= 2 { + return Some(r); + } + 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); + None +} + +/// Settle an arrived image: reply with it, retry against the new pass on a geometry/scale change, +/// or drop it when the transport cancelled or the window/document moved on. +fn settle_arrival(app: &mut FigApp, ctx: &Context, viewport: egui::ViewportId, now: f64, epoch: u64, mut r: ShotRequest, image: &egui::ColorImage) { + let cancelled = r.state.is_cancelled(); + 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. + return; + } + if now >= r.deadline { + reply(&mut r, Err("the window didn't deliver a screenshot in time (is it visible?)".into())); + return; + } + 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())); + return; + } + let Some(current) = current else { + // 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())); + return; + }; + if geometry_moved(&r, current, epoch) { + // Geometry moved between issuance and arrival (even back): retry against the new pass, + // never accept the old pixels. Document changes are errors above. + if let Some(mut r) = requeue_capture(app, r, current, epoch) { + reply(&mut r, Err("the window kept changing size while the screenshot was captured".into())); + } + return; + } + if let Some(spec) = r.spec + && scale_mismatch(image, spec) + { + if let Some(mut r) = requeue_capture(app, r, current, epoch) { + 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] + )), + ); + } + return; + } + // Encode outside the shared gate: a slow PNG must not hold the transport's timeout. The + // fresh clock read and final write happen under the gate, so a timeout that arrives while the + // image is being encoded either wins (no file, no reply) or finds the committed reply. + let encoded = encode_shot(image, r.path.as_deref()); + commit_shot(&r.state, &r.id, &r.reply, r.deadline, &app.clock, encoded); +} + +/// Admit queued captures for the window drawing now; other windows keep waiting for their frame. +fn admit_queued(app: &mut FigApp, ctx: &Context, viewport: egui::ViewportId, now: f64) { + for r in std::mem::take(&mut app.control_queue) { + route_queued(app, ctx, viewport, now, r); + } +} + +/// Route one queued control request: retarget another window, admit a capture, or run a +/// state-only request for the window drawing now. +fn route_queued(app: &mut FigApp, ctx: &Context, viewport: egui::ViewportId, now: f64, r: ControlRequest) { + if r.is_cancelled() { + return; + } + 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) { + ctx.request_repaint_of(crate::windows::viewport(target)); + app.control_queue.push(r); + } else { + let _ = r.reply.send(ControlReply { id: r.id, result: Err(format!("no window {target}")) }); + } + return; + } + if r.method == "ui.screenshot" { + admit(app, r, target, now); + return; + } + // 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); + 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(); +} + +/// Reserve this request's actual surface against the capture budget; `true` when it must be +/// removed for exceeding it. +fn reserve_for_spec(r: &mut ShotRequest, spec: Option, total: usize) -> bool { + 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; + } + } + let Some(spec) = r.spec else { return false }; + 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"))); + return true; + } + if r.reserved != cost { + r.reserved = cost; + } + false +} + +/// Fill in this pass's geometry and enforce the byte budget (a post-callback check alone would +/// not bound peak memory), releasing anything over it before issuing. +fn enforce_capture_budget(app: &mut FigApp, viewport: egui::ViewportId, spec: Option) { + let mut i = 0; + while i < app.capture.shots.len() { + let total: usize = app.capture.shots.iter().map(|s| s.reserved).sum(); + let remove = { + let r = &mut app.capture.shots[i]; + if r.viewport != viewport { + i += 1; + continue; + } + reserve_for_spec(r, spec, total) + }; + if remove { + let mut r = app.capture.shots.remove(i); + r.release(); + } else { + i += 1; + } + } +} + +/// Count one completed frame into the capture's settling, reporting whether it may now be issued. +fn settled_for_issue(r: &mut ShotRequest, frame_nr: u64) -> bool { + 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); + false + } + Some(seen) if frame_nr > seen => { + r.settle_frame = Some(frame_nr); + r.settle = r.settle.saturating_add(1); + r.settle >= SHOT_SETTLE_PASSES + } + _ => false, + } +} + +/// Settle on completed UI frames only, then issue once (against this pass's surface). +fn settle_and_issue(app: &mut FigApp, ctx: &Context, viewport: egui::ViewportId, now: f64, epoch: u64, spec: Option) { + 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.state.is_cancelled() || now >= r.deadline { + continue; + } + if !settled_for_issue(r, frame_nr) { + 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))); + } +} + +/// An encoded screenshot that has not been written or returned yet. Encoding is the expensive +/// part, so it is done before the request's shared commit gate is taken. +enum EncodedShot { + /// The PNG for a `path` request; writing it is part of the final commit. + File { path: String, png: Vec, width: usize, height: usize }, + /// The complete base64 reply for a request without a path. + Data(Value), +} + +impl EncodedShot { + /// Deliver the encoded shot: for `File`, write the PNG through `file_access` and return its + /// path and dimensions; for `Data`, return the prebuilt result. + fn finish(self) -> Result { + match self { + Self::File { path, png, width, height } => { + figcraft_engine::file_access::write(&path, &png).map_err(|e| e.to_string())?; + Ok(json!({"path": path, "width": width, "height": height})) + } + Self::Data(result) => Ok(result), + } + } } /// A screenshot's reply: the PNG written to `path` (through the automation roots, when the app -/// has them), or returned as base64 `data`. +/// has them), or returned as base64 `data`. Control requests split this into [`encode_shot`] and +/// [`commit_shot`]; encoding and automation-root tests use the whole operation. +#[cfg(test)] fn shot_reply(img: &egui::ColorImage, path: Option<&str>) -> Result { + encode_shot(img, path)?.finish() +} + +/// Encode the straight-alpha PNG and the data reply. No request gate is held here. +fn encode_shot(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) => { - figcraft_engine::file_access::write(path, &png).map_err(|e| e.to_string())?; - Ok(json!({"path": path, "width": w, "height": h})) - } + Some(path) => Ok(EncodedShot::File { path: path.to_string(), png, width: w, height: h }), None => { + 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)})) + Ok(EncodedShot::Data(json!({"format": "png", "width": w, "height": h, "data": base64::engine::general_purpose::STANDARD.encode(&png)}))) } } } +/// Commit an encoded screenshot's file and reply while holding the request's shared gate. The +/// cancellation and a fresh deadline are rechecked under the gate: if the transport gave up while +/// the image was being encoded, no file is written; a commit that gets there first queues its +/// reply before the timeout can mark the request cancelled. +fn commit_shot( + gate: &RequestGate, + id: &Value, + reply: &std::sync::mpsc::Sender, + deadline: f64, + clock: &crate::FrameClock, + encoded: Result, +) -> bool { + gate.commit(|| { + // Read the clock after the gate is held: a request whose deadline passed while it waited + // for the gate must not write its file. + let result = if clock.now() < deadline { + encoded.and_then(EncodedShot::finish) + } else { + Err("the window didn't deliver a screenshot in time (is it visible?)".into()) + }; + // Enqueue before the gate is released and before `Replied` is visible to the transport. + let _ = reply.send(ControlReply { id: id.clone(), result }); + }) + .is_some() +} + /// A controller button given as its standard-layout index, its key trigger name ("Gamepad A") or /// its short name ("A", "LB", "Up"). fn pad_name(v: &Value) -> Option<&'static str> { @@ -238,11 +930,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( @@ -252,7 +949,7 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul .collect(), )), "document.inspect" => { - let depth = p.get("depth").and_then(Value::as_u64).unwrap_or(8) as usize; + let depth = p.get("depth").and_then(Value::as_u64).unwrap_or(8).min(64) as usize; Ok(figcraft_engine::inspect::document(&app.session, depth, b(p, "allPages"))) } "node.get" => { @@ -320,8 +1017,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}`")), @@ -367,6 +1065,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, @@ -397,7 +1096,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)})) } @@ -764,3 +1463,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..6068ce52 --- /dev/null +++ b/crates/ui-egui/src/control_capture_tests.rs @@ -0,0 +1,657 @@ +//! 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; + +/// Queue a `ui.screenshot` request in the test app and return its reply receiver. +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, state: 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 +} + +/// Whether any viewport in the frame output issues a `Screenshot` command. +fn has_screenshot(out: &egui::FullOutput) -> bool { + out.viewport_output.values().any(|v| v.commands.iter().any(|c| matches!(c, egui::ViewportCommand::Screenshot(_)))) +} + +/// Asserts reverse capture completion replies only to the matching request, then drains both +/// requests. +#[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"); +} + +/// Asserts a cancelled capture gets no reply and its slot is released. +#[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].state.cancel_if_open(); + 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"); +} + +/// A unique directory for a commit test; the caller removes it. +fn commit_dir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("figcraft-capture-commit-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("temp dir"); + dir +} + +/// Asserts cancellation after encoding still prevents the final file write and reply. +#[test] +fn a_cancellation_after_encoding_prevents_the_final_write() { + let dir = commit_dir("cancelled"); + let path = dir.join("shot.png"); + let gate = crate::control::RequestGate::new(); + let (tx, rx) = std::sync::mpsc::channel(); + let clock = crate::FrameClock::default(); + let img = egui::ColorImage::filled([2, 2], egui::Color32::RED); + // Encoding is complete before the transport cancels: the final gate must still refuse the file. + let encoded = super::encode_shot(&img, Some(path.to_str().expect("utf-8 path"))).expect("encoded"); + assert!(!path.exists(), "encoding alone does not write"); + assert_eq!(gate.cancel_if_open(), crate::control::RequestState::Cancelled); + assert!(!super::commit_shot(&gate, &json!(40), &tx, 100.0, &clock, Ok(encoded)), "a cancelled request does not commit"); + assert!(!path.exists(), "the cancellation landed before the final write"); + assert!(rx.try_recv().is_err(), "a cancelled transport gets no reply"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Asserts a capture that arrives past its deadline reports a timeout and writes no PNG. +#[test] +fn a_real_arrival_that_crosses_the_deadline_does_not_write_its_png() { + let mut u = Ui::new(Session::empty()); + let dir = commit_dir("settle-expired"); + let path = dir.join("shot.png"); + let rx = push(&mut u, 60, json!({"path": path.to_string_lossy()})); + 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"); + let deadline = u.app.capture.shots[0].deadline; + let epoch = u.app.capture.shots[0].epoch; + slot.complete(Arc::new(egui::ColorImage::filled(spec.size_px, egui::Color32::RED))); + // A frame's saved time may precede encoding; the final write must use a fresh clock. + let stale_now = u.app.clock.now(); + u.app.clock.set_test_now(Some(deadline + 1.0)); + super::drain_completed(&mut u.app, &u.ctx, egui::ViewportId::ROOT, stale_now, epoch); + let reply = rx.try_recv().expect("the late commit reports a timeout"); + assert!(reply.result.unwrap_err().contains("didn't deliver")); + assert!(!path.exists(), "the final deadline check must precede the PNG write"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Asserts an expiry during encoding reports the timeout under the gate, marks the request +/// replied and writes no file. +#[test] +fn an_expiry_during_encoding_prevents_the_final_write_and_reports_timeout() { + let dir = commit_dir("expired"); + let path = dir.join("shot.png"); + let gate = crate::control::RequestGate::new(); + let (tx, rx) = std::sync::mpsc::channel(); + let mut clock = crate::FrameClock::default(); + clock.set_test_now(Some(5.0)); + let img = egui::ColorImage::filled([2, 2], egui::Color32::RED); + let encoded = super::encode_shot(&img, Some(path.to_str().expect("utf-8 path"))).expect("encoded"); + // The gate is open, but the fresh clock read under it is already at the deadline. + assert!(super::commit_shot(&gate, &json!(41), &tx, 5.0, &clock, Ok(encoded)), "the open request is settled once"); + let reply = rx.try_recv().expect("a timeout reply"); + assert_eq!(reply.id, json!(41)); + assert!(reply.result.unwrap_err().contains("didn't deliver"), "the late commit reports the same timeout"); + assert!(!path.exists(), "an expired request never writes its file"); + assert!(gate.is_replied(), "the timeout reply was committed under the gate"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Asserts a commit before the deadline writes the file and queues one reply, and a second +/// commit is refused. +#[test] +fn a_commit_before_the_deadline_writes_and_queues_its_reply_once() { + let dir = commit_dir("committed"); + let path = dir.join("shot.png"); + let gate = crate::control::RequestGate::new(); + let (tx, rx) = std::sync::mpsc::channel(); + let mut clock = crate::FrameClock::default(); + clock.set_test_now(Some(1.0)); + let img = egui::ColorImage::filled([2, 2], egui::Color32::GREEN); + let encoded = super::encode_shot(&img, Some(path.to_str().expect("utf-8 path"))).expect("encoded"); + assert!(super::commit_shot(&gate, &json!(42), &tx, 10.0, &clock, Ok(encoded)), "an open request commits"); + let reply = rx.try_recv().expect("the committed reply"); + assert_eq!(reply.id, json!(42)); + assert_eq!(reply.result.expect("written")["path"], json!(path.to_string_lossy())); + assert!(path.is_file(), "the file is written under the gate"); + assert!(gate.is_replied(), "a timeout now consumes the committed reply"); + // A second commit is refused: the gate is no longer open. + let again = super::encode_shot(&img, Some(path.to_str().expect("utf-8 path"))).expect("encoded"); + assert!(!super::commit_shot(&gate, &json!(43), &tx, 10.0, &clock, Ok(again))); + assert!(rx.try_recv().is_err(), "no duplicate reply"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Asserts a capture past its deadline sends one timeout reply, releases its slot, and late +/// pixels are ignored. +#[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"); +} + +/// Asserts the snapshot count budget rejects an over-budget request before issuing, and every +/// admitted capture still completes under its own id. +#[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(); +} + +/// Asserts an undersized readback is not accepted but reissued with one retry and no reply. +#[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"); +} + +/// Asserts a one-pixel rounding difference is accepted as the same surface. +#[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"); +} + +/// Asserts an A→B→A tab switch invalidates the admitted capture with "document changed". +#[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"); +} + +/// Asserts a geometry change bumps the capture epoch and reissues instead of accepting old +/// pixels. +#[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"); +} + +/// Asserts a translucent pixel round-trips through the PNG as straight RGBA without +/// darkening. +#[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"); +} + +/// Asserts a capture is not issued before three completed real UI frames; the following +/// completed frame issues it. +#[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"); +} + +/// Asserts a discarded sizing pass does not count toward capture settling, and the next +/// completed frame issues it. +#[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"); +} + +/// Asserts pixels delivered while hidden force a draining paint and are answered rather than +/// timed out. +#[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"); +} + +/// Asserts a capture for a window with unknown visibility is parked with its deadline and a +/// paint request, then times out once. +#[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"); +} + +/// Asserts a completed capture is rejected when current geometry is unavailable, failing +/// closed. +#[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"); +} + +/// Asserts a tab switch while a capture settles invalidates it with "document changed". +#[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"); +} + +/// Asserts the capture budget covers the encoding and reply peaks, not just pixels, and an +/// over-budget resize is rejected before readback. +#[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"); +} + +/// Asserts resizing settling captures rechecks aggregate reserved bytes, rejects the request +/// that no longer fits, and makes the survivor reserve its real cost. +#[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"); +} + +/// Asserts an allowed resize restarts settling, issues only after three new-size frames, and +/// reserves the current cost. +#[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, + state: Default::default(), + }); + rx +} + +/// Asserts injected input is queued, applied by the presenting pass, and acknowledged once +/// after that pass. +#[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"); +} + +/// Asserts a discarded pass neither acknowledges nor replays an injected batch; the presented +/// pass acknowledges once. +#[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()); +} + +/// Asserts injected text is applied once across a discarded sizing pass and acknowledged +/// once. +#[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, + state: 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 763f00e6..d3863248 100644 --- a/crates/ui-egui/src/control_roots_tests.rs +++ b/crates/ui-egui/src/control_roots_tests.rs @@ -100,3 +100,23 @@ fn ui_input_rejects_non_finite_coordinates() { } assert!(app.control_input.iter().all(|e| !matches!(e, egui::Event::PointerMoved(p) if !p.is_finite()))); } + +#[test] +fn document_inspect_caps_the_requested_depth() { + let (mut app, _d) = confined_app("inspect-depth"); + let mut parent: Option = None; + for _ in 0..80 { + let mut p = json!({"x": 0, "y": 0, "width": 10, "height": 10}); + if let Some(id) = &parent { + p["parent"] = id.clone(); + } + let r = app.session.execute("frame.create", &p).unwrap(); + parent = Some(r["id"].clone()); + } + let r = handle(&mut app, "document.inspect", json!({"depth": u64::MAX, "allPages": false})).unwrap(); + fn nesting(v: &Value) -> usize { + v["children"].as_array().map_or(0, |c| 1 + c.iter().map(nesting).max().unwrap_or(0)) + } + let n = nesting(&r["pages"][0]); + assert!(n <= 64, "inspect nested {n} levels"); +} diff --git a/crates/ui-egui/src/corner_individual_tests.rs b/crates/ui-egui/src/corner_individual_tests.rs index 5a36f073..c87f4f33 100644 --- a/crates/ui-egui/src/corner_individual_tests.rs +++ b/crates/ui-egui/src/corner_individual_tests.rs @@ -1,5 +1,6 @@ //! 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. +//! their corners are (top left, top right / bottom left, bottom right) and each sets its own; +//! for a polygon or star the panel shows corner smoothing only. use egui::accesskit::Role; use egui::{Event, Id, Key, Modifiers, Pos2, Rect, pos2}; @@ -51,7 +52,22 @@ fn named_button(u: &mut Ui, label: &str) -> Pos2 { /// Corner field `k` (an index into the radii). fn field(u: &Ui, k: usize) -> Rect { - u.ctx.read_response(Id::new(("corner", k)).with("field")).expect("corner field shows").rect + u.ctx.read_response(Id::unique(("corner", k)).with("field")).expect("corner field shows").rect +} + +/// The iOS button next to Corner smoothing sets 60% in one undo step, as the help page's "click +/// iOS to set corner smoothing to 60%" describes. +#[test] +fn ios_button_sets_corner_smoothing_to_60_percent() { + let (mut u, r) = scene(); + let t = named_button(&mut u, "Individual corners"); + click(&mut u, t); + let history = u.app.session.undo_depth().0; + let ios = named_button(&mut u, "iOS"); + click(&mut u, ios); + let smoothing = u.app.session.doc().unwrap().node(r).unwrap().corners.smoothing; + assert!((smoothing - 0.6).abs() < 1e-9, "smoothing is {smoothing}"); + assert_eq!(u.app.session.undo_depth().0, history + 1); } /// The fields sit at their corners, and typing in the bottom right one sets only that corner in a @@ -73,3 +89,29 @@ fn corner_fields_sit_at_their_corners_and_set_their_own() { assert_eq!(radii(&u, r), [30.0, 0.0, 12.0, 30.0]); assert_eq!(u.app.session.undo_depth().0, history + 1); } + +/// For a rounded polygon or star the same panel shows corner smoothing, which applies to the +/// whole shape: no field per corner, and typing a percentage sets the smoothing in one undo step. +#[test] +fn polygons_and_stars_take_corner_smoothing() { + for cmd in ["shape.polygon", "shape.star"] { + let mut s = Session::new(); + let id = NodeId(s.execute(cmd, &json!({"x": 0, "y": 0, "width": 120, "height": 120})).unwrap()["id"].as_u64().unwrap()); + s.execute("node.set", &json!({"ids": [id], "props": {"cornerRadius": 8}})).unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let mut u = Ui::new(s); + // With no corners of their own, the button is named for what it opens. + let t = named_button(&mut u, "Corner smoothing"); + click(&mut u, t); + assert!((0..4).all(|k| u.ctx.read_response(Id::unique(("corner", k)).with("field")).is_none()), "{cmd}: one radius, no corner fields"); + let smoothing = u.ctx.read_response(Id::unique("smoothing").with("field")).expect("the smoothing field shows").rect.center(); + let history = u.app.session.undo_depth().0; + click(&mut u, smoothing); + u.frame(vec![Event::Text("60".into())]); + enter(&mut u); + u.frame(vec![]); + let n = u.app.session.doc().unwrap().node(id).unwrap().clone(); + assert!((n.corners.smoothing - 0.6).abs() < 1e-9, "{cmd}: {}", n.corners.smoothing); + assert_eq!(u.app.session.undo_depth().0, history + 1, "{cmd}: one undo step"); + } +} 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..fb3993ad 100644 --- a/crates/ui-egui/src/credits_tests.rs +++ b/crates/ui-egui/src/credits_tests.rs @@ -51,12 +51,14 @@ impl Ui { u } + /// Store the About table's current view in test context data. 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)); } + /// Read the stored About table view, defaulting when unset. 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 dcece9b2..87ce06bd 100644 --- a/crates/ui-egui/src/dev_mode.rs +++ b/crates/ui-egui/src/dev_mode.rs @@ -3,22 +3,25 @@ //! categories and the filter of the callouts, the handoff status of the selected frames (with the //! focus view and Compare changes), the list of frames and sections that are ready for dev, the //! focus view's bar, Connect to code (a component's link to its code component), dev resources -//! (links for developers) and the plugins that only read the document. +//! (links for developers), measurements kept on the canvas and the plugins that only read the +//! document. use egui::{Align2, Color32, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use figcraft_doc::{Annotation, AnnotationCategory, CodeLink, DevStatus, Document, NodeId}; use figcraft_engine::annotations::{self, PINNABLE, Pinnable}; use figcraft_engine::code_connect; use figcraft_engine::codegen::{CodeLang, code_for}; +use figcraft_engine::dev_measurements::{self, Placed}; use figcraft_engine::devinspect::{self, DevItem, Prop}; use figcraft_engine::plugins::Access; +use figcraft_engine::snap::Guide; use figcraft_engine::{components, dev_resources}; use serde_json::json; use crate::a11y::Tip; use crate::i18n::{tr, tr_noop, trf, trn}; use crate::icons::{self, Icon}; -use crate::right::{menu_check, menu_item_with, menu_popup, prop_row, row}; +use crate::right::{menu_check, menu_item, menu_item_with, menu_popup, prop_row, row}; use crate::theme::{ACCENT, Tokens, doc_c32, regular, semibold}; use crate::widgets::{self, DropLead, PAD}; use crate::{FigApp, RightTab}; @@ -94,6 +97,9 @@ pub struct DevUi { pub compare: Option, annotated: Vec, annotated_key: Option<(u64, u64, NodeId)>, + /// The measurements kept on the current page, with their lines. + measured: Vec, + measured_key: Option<(u64, u64, NodeId)>, ready: Vec, ready_key: Option<(u64, u64, usize)>, /// Every ready-for-dev frame (the focus view's previous / next). @@ -190,6 +196,35 @@ pub fn draw(app: &mut FigApp, painter: &egui::Painter) { } } +/// Bring the current page's measurements up to date (when the document, its revision or the page +/// changed). +pub fn refresh_measurements(app: &mut FigApp) { + let Ok(ds) = app.session.ds() else { return }; + let key = (ds.uid, ds.revision, ds.page); + if app.ui.dev.measured_key != Some(key) { + app.ui.dev.measured = dev_measurements::on_page(&ds.doc, ds.page); + app.ui.dev.measured_key = Some(key); + } +} + +/// The lines of the measurements kept on the canvas, drawn with the hover measurements (Inspect +/// tab only; both layers shown; the focus view's frame only). +pub fn saved_lines(app: &FigApp) -> impl Iterator { + let ds = app.session.ds().ok().filter(|_| active(app)); + let focus = ds.and_then(figcraft_engine::DocState::focused); + app.ui + .dev + .measured + .iter() + .filter(move |m| { + ds.is_some_and(|ds| { + let shown = |id: NodeId| ds.doc.node(id).is_some_and(|n| n.visible); + shown(m.from) && m.measurement.to.is_none_or(shown) && focus.is_none_or(|f| ds.doc.is_ancestor(f, m.from)) + }) + }) + .flat_map(|m| &m.lines) +} + /// One annotation as a card: its category, its note (up to six lines) and its pinned properties /// with their values. struct Callout { @@ -256,7 +291,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 @@ -355,7 +390,7 @@ 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 { @@ -436,10 +471,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)); } }); @@ -550,13 +585,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 { @@ -567,7 +602,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) => { @@ -585,7 +620,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})); @@ -601,7 +636,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); @@ -617,10 +652,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")) { @@ -657,10 +692,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; } @@ -669,7 +704,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() { @@ -690,6 +725,97 @@ pub fn dev_resources_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: ui.add_space(4.0); } +/// The menu label of measuring along `side`. +fn side_label(side: figcraft_doc::Side) -> &'static str { + use figcraft_doc::Side; + match side { + Side::Top => tr("Along the top"), + Side::Right => tr("Along the right"), + Side::Bottom => tr("Along the bottom"), + Side::Left | Side::Auto => tr("Along the left"), + } +} + +/// Measurements kept on the canvas: those from or to the selected layer (with nothing selected, +/// every one on the page), each with its distances; a click selects the layer it starts from and +/// − removes it. With a layer selected, a menu places a new one: between two selected layers, to +/// the layer around it or along one of its sides. +pub fn measurements_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { + use figcraft_doc::Side; + refresh_measurements(app); + let t = Tokens::get(ui.ctx()); + let first = sel.first().copied().filter(|id| doc.node(*id).is_some_and(|n| !n.is_page())); + let rows: Vec<&Placed> = app.ui.dev.measured.iter().filter(|m| first.is_none_or(|id| m.touches(id))).collect(); + if first.is_none() && rows.is_empty() { + return; + } + let mut action: Option<(&str, serde_json::Value)> = None; + widgets::separator(ui); + widgets::section_header(ui, tr("Measurements"), &[], rows.is_empty()); + for m in &rows { + if let Some(a) = measurement_row(ui, &t, doc, m) { + action = Some(a); + } + } + if let Some(id) = first { + let around = doc.node(id).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| !p.is_page()); + let w = ui.available_width() - PAD * 2.0; + row(ui, 32.0, |ui| { + let resp = widgets::dropdown(ui, tr("Add measurement"), w, Some(Icon::Measure)) + .on_hover_text(tr("Keep a measurement on the canvas for developers")); + menu_popup(&resp).show(|ui| { + if let [a, b, ..] = sel + && menu_item(ui, tr("Between the selected layers"), "") + { + action = Some(("devMode.addMeasurement", json!({"from": a, "to": b}))); + } + if around && menu_item(ui, tr("To the layer around it"), "") { + action = Some(("devMode.addMeasurement", json!({"from": id}))); + } + for side in [Side::Top, Side::Right, Side::Bottom, Side::Left] { + if menu_item(ui, side_label(side), "") { + action = Some(("devMode.addMeasurement", json!({"from": id, "side": side}))); + } + } + }); + }); + } + if let Some((cmd, params)) = action { + app.run(cmd, params); + } + ui.add_space(4.0); +} + +/// One measurement in the Inspect tab, on two lines: what it measures, and its distances below. A +/// click selects the layer it starts from, − removes it: the command to run. +fn measurement_row(ui: &mut Ui, t: &Tokens, doc: &Document, m: &Placed) -> Option<(&'static str, serde_json::Value)> { + let name = |id: NodeId| doc.node(id).map_or(String::new(), |n| n.name.clone()); + let mut action = None; + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 36.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r.shrink2(vec2(PAD / 2.0, 1.0)), CornerRadius::same(4), t.hover); + } + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + PAD + 7.0, r.center().y), vec2(14.0, 14.0)), Icon::Measure, t.icon_secondary); + let what = match (m.measurement.to, m.measurement.side) { + (Some(to), _) => format!("{} → {}", name(m.from), name(to)), + (None, side) => format!("{} · {}", name(m.from), side.map_or("", side_label)), + }; + let value = m.labels().join(" · "); + let del = Rect::from_center_size(pos2(r.max.x - PAD - 4.0, r.center().y), vec2(22.0, 22.0)); + let x = r.min.x + PAD + 22.0; + let text_w = del.min.x - 6.0 - x; + ui.painter().text(pos2(x, r.center().y - 7.0), Align2::LEFT_CENTER, widgets::elide(ui, &what, text_w), regular(11.0), t.text); + ui.painter().text(pos2(x, r.center().y + 7.0), Align2::LEFT_CENTER, widgets::elide(ui, &value, text_w), regular(10.0), t.text_secondary); + crate::a11y::button(&resp, &format!("{what}: {value}")); + if resp.clicked() { + action = Some(("select.set", json!({"ids": [m.from]}))); + } + if widgets::icon_button_at(ui, Id::unique(("measure_del", m.from, m.index)), del, Icon::Minus, tr("Remove measurement")) { + action = Some(("devMode.removeMeasurement", json!({"from": m.from, "index": m.index}))); + } + action +} + /// The category of annotation `a` as a dropdown: the one picked (`Value::Null` for none). fn category_menu(ui: &mut Ui, doc: &Document, id: Id, a: &Annotation) -> Option { let current = annotations::category_of(doc, a); @@ -760,7 +886,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() { @@ -783,14 +909,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}))); } } @@ -888,7 +1014,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); } } @@ -932,7 +1058,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 3e2ea9ba..1a8de0e2 100644 --- a/crates/ui-egui/src/dev_mode_tests.rs +++ b/crates/ui-egui/src/dev_mode_tests.rs @@ -126,8 +126,13 @@ fn playground_combo(u: &mut crate::layers_drag_tests::Ui, name: &str) -> Option< if centre(&combo).y < 860.0 { return Some(combo); } - let wheel = - Event::MouseWheel { unit: MouseWheelUnit::Point, delta: vec2(0.0, -100.0), phase: TouchPhase::Move, modifiers: Default::default() }; + 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![]); diff --git a/crates/ui-egui/src/devmode_tail_tests.rs b/crates/ui-egui/src/devmode_tail_tests.rs index 7008e9e5..b7c7a755 100644 --- a/crates/ui-egui/src/devmode_tail_tests.rs +++ b/crates/ui-egui/src/devmode_tail_tests.rs @@ -108,3 +108,344 @@ fn dev_mode_pastes_only_a_comments_link() { crate::menus::paste(&mut app, None, None); assert_eq!(app.ui.comments.open, Some(c), "the link opens its thread"); } + +#[test] +fn dev_mode_layers_panel_selects_and_expands_but_edits_nothing() { + use egui::{Event, Pos2}; + let mut s = Session::new(); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 400, "name": "Login"})).unwrap()); + let a = id(&s.execute("shape.rectangle", &json!({"x": 20, "y": 30, "width": 120, "height": 40, "parent": f})).unwrap()); + let b = id(&s.execute("shape.rectangle", &json!({"x": 20, "y": 90, "width": 120, "height": 40, "parent": f})).unwrap()); + s.execute("select.none", &json!({})).unwrap(); + let mut u = crate::layers_drag_tests::Ui::new(s); + u.app.ui.right_tab = crate::RightTab::Inspect; + u.app.ui.expanded.insert(f); + u.frame(vec![]); + let order = |u: &crate::layers_drag_tests::Ui| u.app.session.doc().unwrap().node(f).unwrap().children.clone(); + let before = order(&u); + let (ra, rb) = (u.row_at(a), u.row_at(b)); + let click = |u: &mut crate::layers_drag_tests::Ui, p: Pos2| { + u.button(p, true); + u.button(p, false); + }; + let rest = |u: &mut crate::layers_drag_tests::Ui| { + for _ in 0..30 { + u.frame(vec![]); + } + }; + // A click selects. + click(&mut u, ra); + assert_eq!(u.app.session.selection(), [a]); + // A double click doesn't rename, and says why. + click(&mut u, ra); + assert!(u.app.ui.rename.is_none(), "no rename"); + assert!(u.app.ui.toast.as_ref().is_some_and(|(m, _)| m.contains("Shift+D"))); + rest(&mut u); + // The eye and padlock spots don't hide or lock (the row is selected instead). + let (eye, padlock) = (272.0, 250.0); + for x in [eye, padlock] { + u.frame(vec![Event::PointerMoved(Pos2::new(x, rb.y))]); + click(&mut u, Pos2::new(x, rb.y)); + rest(&mut u); + } + let doc = u.app.session.doc().unwrap(); + assert!(doc.node(b).unwrap().visible && !doc.node(b).unwrap().locked, "not hidden or locked"); + assert_eq!(u.app.session.selection(), [b]); + // Dragging a row doesn't reorder, and says why. + u.app.ui.toast = None; + u.drag_to(ra, rb + egui::vec2(0.0, 6.0)); + u.button(rb + egui::vec2(0.0, 6.0), false); + rest(&mut u); + assert_eq!(order(&u), before, "not reordered"); + assert!(u.app.ui.toast.as_ref().is_some_and(|(m, _)| m.contains("Shift+D")), "the drag says why"); + // Back in the design, the same double click renames. + u.app.ui.right_tab = crate::RightTab::Design; + u.frame(vec![]); + click(&mut u, ra); + click(&mut u, ra); + assert!(u.app.ui.rename.is_some_and(|r| r.id == a)); + // A drag started in the design and dropped after switching to Dev Mode doesn't reorder either. + u.app.ui.rename = None; + rest(&mut u); + u.app.ui.toast = None; + let rf = u.row_at(f); + u.drag_to(ra, rf); + u.app.ui.right_tab = crate::RightTab::Inspect; + u.frame(vec![]); + u.button(rf, false); + rest(&mut u); + assert_eq!(order(&u), before, "not reordered after the switch"); + assert!(u.app.ui.toast.as_ref().is_some_and(|(m, _)| m.contains("Shift+D")), "the drop says why"); + // So does a sweep down the eye column. + u.app.ui.right_tab = crate::RightTab::Design; + rest(&mut u); + u.app.ui.toast = None; + let (ea, eb) = (Pos2::new(eye, u.row_at(a).y), Pos2::new(eye, u.row_at(b).y)); + u.drag_to(ea, eb); + u.app.ui.right_tab = crate::RightTab::Inspect; + u.frame(vec![]); + u.button(eb, false); + rest(&mut u); + let doc = u.app.session.doc().unwrap(); + assert!(doc.node(a).unwrap().visible && doc.node(b).unwrap().visible, "not hidden after the switch"); + assert!(u.app.ui.toast.as_ref().is_some_and(|(m, _)| m.contains("Shift+D")), "the sweep says why"); +} + +#[test] +fn dev_mode_pages_list_opens_pages_but_edits_none() { + use egui::{Event, Pos2}; + let mut s = Session::new(); + let home = s.page().unwrap(); + s.execute("page.rename", &json!({"id": home, "name": "Home"})).unwrap(); + let specs = id(&s.execute("page.add", &json!({"name": "Specs"})).unwrap()); + s.execute("page.select", &json!({"id": home})).unwrap(); + let mut u = crate::layers_drag_tests::Ui::new(s); + u.app.ui.right_tab = crate::RightTab::Inspect; + let shapes = |u: &mut crate::layers_drag_tests::Ui| crate::live_tests::flat(&u.frame_output(vec![]).shapes); + let s = shapes(&mut u); + let (rh, rs) = (crate::live_tests::text_center(&s, "Home"), crate::live_tests::text_center(&s, "Specs")); + // The add button, found by its name in the AccessKit tree. + u.ctx.enable_accesskit(); + let nodes = u.frame_output(vec![]).platform_output.accesskit_update.unwrap().nodes; + let b = nodes.iter().find(|(_, n)| n.label() == Some("Add new page")).and_then(|(_, n)| n.bounds()).unwrap(); + let add = Pos2::new(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32); + u.ctx.disable_accesskit(); + // The pointer arrives first: a row that also takes drags counts a press there only then. + let click = |u: &mut crate::layers_drag_tests::Ui, p: Pos2| { + let button = |pressed| Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + u.frame(vec![Event::PointerMoved(p)]); + u.frame(vec![button(true)]); + u.frame(vec![button(false)]); + }; + let rest = |u: &mut crate::layers_drag_tests::Ui| { + for _ in 0..30 { + u.frame(vec![]); + } + }; + let pages = |u: &crate::layers_drag_tests::Ui| u.app.session.doc().unwrap().pages.clone(); + let said_why = |u: &mut crate::layers_drag_tests::Ui| u.app.ui.toast.take().is_some_and(|(m, _)| m.contains("Shift+D")); + // A click opens the page. + click(&mut u, rs); + assert_eq!(u.app.session.page().unwrap(), specs); + // A double click doesn't rename, and says why. + click(&mut u, rs); + assert!(u.app.ui.rename_page.is_none(), "no rename"); + assert!(said_why(&mut u)); + rest(&mut u); + // Dragging a page doesn't reorder, and says why. + u.drag_to(rs, rh - egui::vec2(0.0, 10.0)); + u.button(rh - egui::vec2(0.0, 10.0), false); + rest(&mut u); + assert_eq!(pages(&u), [home, specs], "not reordered"); + assert!(said_why(&mut u), "the drag says why"); + // The add button adds no page, and says why. + click(&mut u, add); + assert_eq!(pages(&u), [home, specs], "no page added"); + assert!(said_why(&mut u)); + rest(&mut u); + // The row's Duplicate page and Delete page do nothing, and say why. + for item in ["Duplicate page", "Delete page"] { + u.frame(vec![ + Event::PointerMoved(rs), + Event::PointerButton { pos: rs, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }, + ]); + u.frame(vec![Event::PointerButton { pos: rs, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }]); + let s = shapes(&mut u); + click(&mut u, crate::live_tests::text_center(&s, item)); + assert_eq!(pages(&u), [home, specs], "{item} changes no page"); + assert!(said_why(&mut u), "{item} says why"); + rest(&mut u); + } + // In the design the double click renames and the add button adds a page. + u.app.ui.right_tab = crate::RightTab::Design; + u.frame(vec![]); + click(&mut u, rs); + click(&mut u, rs); + assert!(u.app.ui.rename_page.as_ref().is_some_and(|(p, _)| *p == specs)); + u.app.ui.rename_page = None; + rest(&mut u); + click(&mut u, add); + assert_eq!(pages(&u).len(), 3, "a page is added"); +} + +#[test] +fn measurements_kept_on_the_canvas_draw_in_dev_mode_and_follow_their_layers() { + let mut s = Session::new(); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 400})).unwrap()); + let a = id(&s.execute("shape.rectangle", &json!({"x": 20, "y": 30, "width": 120, "height": 40, "parent": f})).unwrap()); + let b = id(&s.execute("shape.rectangle", &json!({"x": 20, "y": 90, "width": 120, "height": 40, "parent": f})).unwrap()); + let mut u = Ui::new(s); + u.run("devMode.addMeasurement", json!({"from": a, "to": b})); + let labels = |u: &Ui| crate::dev_mode::saved_lines(&u.app).filter_map(|g| g.label.clone()).collect::>(); + u.frame(vec![]); + assert!(labels(&u).is_empty(), "the design doesn't show them"); + u.app.ui.right_tab = crate::RightTab::Inspect; + u.frame(vec![]); + assert_eq!(labels(&u), ["20"]); + u.run("node.set", json!({"id": b, "props": {"y": 110}})); + u.frame(vec![]); + assert_eq!(labels(&u), ["40"], "it follows the layer"); + // Hidden layers' measurements aren't drawn. + u.run("node.set", json!({"id": b, "props": {"visible": false}})); + u.frame(vec![]); + assert!(labels(&u).is_empty()); +} + +/// A file dropped with its contents and no file on disk. +#[derive(Debug)] +struct DroppedBytes(std::path::PathBuf, Vec); + +impl egui::DroppedFile for DroppedBytes { + fn path(&self) -> &std::path::Path { + &self.0 + } + + fn bytes(&self) -> Result, String> { + Ok(self.1.clone()) + } +} + +/// Hover `names` over the canvas (one frame: what it shows), then drop them there with `bytes`. +fn drop_on_canvas(u: &mut crate::layers_drag_tests::Ui, names: &[&str], bytes: &[u8]) -> Vec { + let at = u.app.canvas.rect.center(); + let path = |n: &str| std::path::Path::new("figcraft-dev-mode-drop").join(n); + let hovered = names.iter().map(|n| egui::HoveredFile { path: Some(path(n)), mime: String::new() }).collect(); + let out = u.frame_raw(egui::RawInput { events: vec![egui::Event::PointerMoved(at)], hovered_files: hovered, ..Default::default() }); + let files = names.iter().map(|n| std::sync::Arc::new(DroppedBytes(path(n), bytes.to_vec())) as egui::DroppedFileHandle).collect(); + u.frame_raw(egui::RawInput { events: vec![egui::Event::PointerMoved(at)], dropped_files: files, ..Default::default() }); + crate::live_tests::flat(&out.shapes) +} + +#[test] +fn dev_mode_imports_no_dropped_file_into_the_design_and_says_why() { + let svg = br#""#; + let refusal = "Dev Mode only inspects: switch to Design (Shift+D) to edit"; + let mut u = crate::layers_drag_tests::Ui::new(Session::new()); + let layers = |u: &crate::layers_drag_tests::Ui| u.app.session.doc().unwrap().node(u.app.session.page().unwrap()).unwrap().children.len(); + u.app.ui.right_tab = crate::RightTab::Inspect; + u.frame(vec![]); + let s = drop_on_canvas(&mut u, &["Logo.svg", "Mark.svg"], svg); + assert!(crate::live_tests::has_text(&s, refusal), "dragging images over the canvas says why they won't import"); + assert!(u.app.ui.pending_opens.is_empty(), "nothing waits to import"); + assert!(u.app.ui.toast.as_ref().is_some_and(|(m, _)| m == refusal)); + for _ in 0..3 { + u.frame(vec![]); + } + assert_eq!(layers(&u), 0, "the design is unchanged"); + // A dropped document still opens as a file of its own. + let s = drop_on_canvas(&mut u, &["Other.figcraft"], b""); + assert!(crate::live_tests::has_text(&s, "Drop to open")); + assert_eq!(u.app.ui.pending_opens.iter().map(|p| p.command).collect::>(), ["file.open"]); + u.app.ui.pending_opens.clear(); + // In the design the same drop imports. + u.app.ui.right_tab = crate::RightTab::Design; + u.frame(vec![]); + let s = drop_on_canvas(&mut u, &["Logo.svg"], svg); + assert!(crate::live_tests::has_text(&s, "Drop to import")); + assert_eq!(u.app.ui.pending_opens.iter().map(|p| p.command).collect::>(), ["file.import"]); + for _ in 0..3 { + u.frame(vec![]); + } + assert_eq!(layers(&u), 1, "the image is imported"); +} + +#[test] +fn dev_mode_assets_panel_places_and_applies_nothing() { + let mut s = Session::new(); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 80, "height": 40, "name": "Card"})).unwrap()); + s.execute("component.create", &json!({"ids": [f]})).unwrap(); + s.execute("style.create", &json!({"type": "paint", "name": "Brand", "value": [{"type": "solid", "color": "#0D99FF"}]})).unwrap(); + let r = id(&s.execute("shape.rectangle", &json!({"x": 200, "y": 0, "width": 40, "height": 40})).unwrap()); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let mut u = crate::layers_drag_tests::Ui::new(s); + u.app.ui.left_tab = crate::LeftTab::Assets; + u.app.ui.right_tab = crate::RightTab::Inspect; + let instances = |u: &crate::layers_drag_tests::Ui| { + u.app.session.doc().unwrap().nodes().filter(|n| matches!(n.kind, figcraft_doc::NodeKind::Instance { .. })).count() + }; + let styled = |u: &crate::layers_drag_tests::Ui| u.app.session.doc().unwrap().node(r).is_some_and(|n| n.styles.fill.is_some()); + let click = |u: &mut crate::layers_drag_tests::Ui, text: &str| { + let s = crate::live_tests::flat(&u.frame_output(vec![]).shapes); + let at = crate::live_tests::text_center(&s, text); + u.button(at, true); + u.button(at, false); + u.frame(vec![]); + }; + click(&mut u, "Card"); + click(&mut u, "Brand"); + let s = crate::live_tests::flat(&u.frame_output(vec![]).shapes); + let (tile, canvas) = (crate::live_tests::text_center(&s, "Card"), u.app.canvas.rect.center()); + u.drag_to(tile, canvas); + u.button(canvas, false); + u.frame(vec![]); + assert_eq!(instances(&u), 0, "a click on a component, or a drag onto the canvas, places no instance"); + assert!(!styled(&u), "a click on a style doesn't apply it"); + assert!(u.app.ui.toast.as_ref().is_some_and(|(m, _)| m.contains("Shift+D"))); + // In the design both edit. + u.app.ui.right_tab = crate::RightTab::Design; + click(&mut u, "Card"); + assert_eq!(instances(&u), 1); + u.drag_to(tile, canvas); + u.button(canvas, false); + u.frame(vec![]); + assert_eq!(instances(&u), 2, "the same drag places one"); + u.app.session.execute("select.set", &json!({"ids": [r]})).unwrap(); + click(&mut u, "Brand"); + assert!(styled(&u)); +} + +#[test] +fn dev_mode_assets_panel_accepts_no_library_updates() { + let dir = std::env::temp_dir().join(format!("figcraft-dev-mode-updates-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Library.figcraft"); + let mut lib = Session::new(); + lib.execute("style.create", &json!({"type": "paint", "name": "Brand", "value": [{"type": "solid", "color": "#0000FF"}]})).unwrap(); + lib.execute("library.publish", &json!({})).unwrap(); + lib.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); + let mut s = Session::new(); + let added = s.execute("library.add", &json!({"path": path.to_string_lossy()})).unwrap(); + let r = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 40})).unwrap()); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + s.execute("library.applyStyle", &json!({"library": added["id"], "style": "Brand"})).unwrap(); + lib.execute("style.update", &json!({"style": "Brand", "value": "#00FF00"})).unwrap(); + lib.execute("library.publish", &json!({})).unwrap(); + lib.execute("file.save", &json!({})).unwrap(); + let mut u = crate::layers_drag_tests::Ui::new(s); + u.app.ui.left_tab = crate::LeftTab::Assets; + u.app.ui.right_tab = crate::RightTab::Inspect; + let updates = |u: &mut crate::layers_drag_tests::Ui| u.app.session.execute("library.updates", &json!({})).unwrap()["count"].as_u64(); + let update = |u: &mut crate::layers_drag_tests::Ui| { + let s = crate::live_tests::flat(&u.frame_output(vec![]).shapes); + let at = crate::live_tests::text_center(&s, "Update"); + u.button(at, true); + u.button(at, false); + u.frame(vec![]); + }; + assert_eq!(updates(&mut u), Some(1)); + update(&mut u); + assert_eq!(updates(&mut u), Some(1), "Update in Dev Mode pulls nothing in"); + assert!(u.app.ui.toast.as_ref().is_some_and(|(m, _)| m.contains("Shift+D"))); + u.app.ui.right_tab = crate::RightTab::Design; + update(&mut u); + assert_eq!(updates(&mut u), Some(0), "in the design it does"); + let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_dir(&dir); +} + +#[test] +fn dev_mode_publishes_no_library() { + let mut s = Session::new(); + s.execute("style.create", &json!({"type": "paint", "name": "Brand", "value": [{"type": "solid", "color": "#0D99FF"}]})).unwrap(); + let mut u = crate::layers_drag_tests::Ui::new(s); + u.app.ui.left_tab = crate::LeftTab::Assets; + u.app.ui.libraries = true; + u.app.ui.right_tab = crate::RightTab::Inspect; + let s = crate::live_tests::flat(&u.frame_output(vec![]).shapes); + let at = crate::live_tests::text_center(&s, "Publish"); + u.button(at, true); + u.button(at, false); + u.frame(vec![]); + assert!(u.app.session.doc().unwrap().published.is_none(), "Publish in Dev Mode publishes nothing"); + assert!(u.app.ui.toast.as_ref().is_some_and(|(m, _)| m.contains("Shift+D"))); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 85d8ca15..78cacd62 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); } @@ -179,6 +179,10 @@ pub fn keys(app: &mut FigApp, ctx: &Context) { } } +/// Draw the app's dialogs and cards: pending opens/imports/missing fonts, About, +/// Preferences, shortcuts, find/replace, batch rename, menu input, variables, dev compare, +/// branch, export preview, colour profile, plugin, live join/publish, online dialogs and +/// close confirmation. pub fn show(app: &mut FigApp, ctx: &Context) { // Dropped files waiting to open: their card, then (once it has shown) the files. crate::pending_open::pending_opens(app, ctx); @@ -246,7 +250,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 +260,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 +333,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 +400,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 +410,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) { @@ -573,7 +577,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); @@ -695,7 +699,11 @@ pub(crate) fn file_saved(app: &mut FigApp, result: &Value) { } fn result_file_name(result: &Value) -> String { - result["path"].as_str().and_then(|p| Path::new(p).file_name()).map(|f| f.to_string_lossy().into_owned()).unwrap_or_default() + match result["path"].as_str() { + Some(p) => Path::new(p).file_name().map(|f| f.to_string_lossy().into_owned()).unwrap_or_default(), + // A download (#969). + None => result["name"].as_str().unwrap_or_default().to_owned(), + } } /// The `.fig` result stays an export: native save state is unchanged, and omissions are reported. @@ -711,6 +719,15 @@ pub(crate) fn fig_exported(app: &mut FigApp, result: &Value) { /// Save the whole file as `.fig`; the toast says how much was left out. fn save_fig(app: &mut FigApp, name: &str) { let filename = format!("{name}.fig"); + if app.services.downloads() { + if let Some(mut r) = app.run("file.export", json!({"format": "fig"})) + && app.download(&filename, &r) + { + r["name"] = json!(filename); + fig_exported(app, &r); + } + return; + } app.pick_save(tr("Export"), &filename, &["fig"], |app, p| { if let Some(r) = app.run("file.export", json!({"format": "fig", "path": p.to_string_lossy()})) { fig_exported(app, &r); @@ -802,7 +819,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..580cb5b1 100644 --- a/crates/ui-egui/src/export_panel.rs +++ b/crates/ui-egui/src/export_panel.rs @@ -40,8 +40,9 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { if mixed { widgets::note(ui, tr("Click + to replace mixed content"), t.text_secondary); } + let text = sel.iter().all(|i| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Text(_)))); for (i, e) in list.iter().enumerate() { - setting_row(app, ui, &ids, i, e); + setting_row(app, ui, &ids, i, e, text); } let jobs = export_jobs(doc, sel); if jobs.is_empty() { @@ -61,8 +62,9 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { } /// One export setting: size (typed or picked; images only, SVG and PDF are vector), suffix, -/// format, advanced options and remove; a PDF's print settings under it. -fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &ExportSetting) { +/// format, advanced options and remove; a PDF's print settings under it. `text`: every layer in +/// `ids` is a text layer. +fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &ExportSetting, text: bool) { row(ui, 32.0, |ui| { // Tighter spacing so the advanced options and remove buttons fit beside the fields. ui.spacing_mut().item_spacing.x = 4.0; @@ -72,13 +74,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,11 +96,11 @@ 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| { - if let Some(mut params) = advanced_options(ui, e) { + if let Some(mut params) = advanced_options(ui, e, text) { params["ids"] = json!(ids); params["index"] = json!(i); app.run("export.update", params); @@ -113,17 +115,21 @@ fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Expo } } -/// A setting's advanced options: contents only and include bounding box, and for SVG outline -/// text, the id attribute and simplified strokes. Returns the changed option as command params. -fn advanced_options(ui: &mut Ui, e: &ExportSetting) -> Option { +/// A setting's advanced options: ignore overlapping layers (`contentsOnly`), include bounding box +/// for text layers (and for any setting that has it on, so it can be turned off), and for SVG +/// outline text, the id attribute and simplified strokes. Returns the changed option as command +/// params. +fn advanced_options(ui: &mut Ui, e: &ExportSetting, text: bool) -> Option { let mut out = None; let mut option = |ui: &mut Ui, key: &str, on: bool, label: &str| { if widgets::checkbox(ui, on, label) { out = Some(json!({ key: !on })); } }; - option(ui, "contentsOnly", e.contents_only, tr("Contents only")); - option(ui, "includeBoundingBox", e.include_bounding_box, tr("Include bounding box")); + option(ui, "contentsOnly", e.contents_only, tr("Ignore overlapping layers")); + if text || e.include_bounding_box { + option(ui, "includeBoundingBox", e.include_bounding_box, tr("Include bounding box")); + } if e.format == ExportFormat::Svg { option(ui, "svgOutlineText", e.svg_outline_text, tr("Outline text")); option(ui, "svgIdAttribute", e.svg_id_attribute, tr("Include id attribute")); @@ -136,7 +142,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 +156,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 +264,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); @@ -320,6 +326,16 @@ pub(crate) fn export_layers(app: &mut FigApp, ids: &[NodeId], assets: bool) { [] => {} // The suggested name keeps only the file's own name (a layer's folders apply to folders). [one] => save_one(app, &[one.id], &one.setting, one.file.rsplit('/').next().unwrap_or(&one.file)), + // No folders in the browser, and it lets a page start one download at a time: the files + // arrive together as one zip (#969). + _ if app.services.downloads() => { + let name = format!("{}.zip", app.session.doc().map_or("Export", |d| d.name.as_str())); + if let Some(r) = app.run("file.exportLayers", json!({"ids": ids, "assets": assets, "zip": true})) + && app.download(&name, &r) + { + app.toast(trn("Exported {n} files", r["files"].as_array().map_or(0, Vec::len), &[])); + } + } _ => { let ids = ids.to_vec(); app.pick_folder(tr("Export"), move |app, dir| { @@ -337,6 +353,14 @@ pub(crate) fn save_one(app: &mut FigApp, ids: &[NodeId], setting: &ExportSetting let ext = setting.format.ext(); let mut params = setting_params(setting); params["ids"] = json!(ids); + if app.services.downloads() { + if let Some(r) = app.run("file.export", params) + && app.download(file, &r) + { + app.toast(trf("Exported {name}", &[("name", &file)])); + } + return; + } app.pick_save(tr("Export"), file, &[ext], move |app, p| { params["path"] = json!(p.to_string_lossy()); if app.run("file.export", params).is_some() { diff --git a/crates/ui-egui/src/export_preview_tests.rs b/crates/ui-egui/src/export_preview_tests.rs index d57e294d..4433356f 100644 --- a/crates/ui-egui/src/export_preview_tests.rs +++ b/crates/ui-egui/src/export_preview_tests.rs @@ -1,7 +1,7 @@ //! The Export section's Preview row: a slice paints nothing itself, so its preview must show the //! page content under it — the same thing exporting the slice writes — and not a blank //! transparency checkerboard next to a correct export. And its setting rows: a size only for -//! images. +//! images, and advanced options named as the reference app names them. use figcraft_doc::{Document, NodeId}; use figcraft_engine::Session; @@ -33,6 +33,23 @@ fn a_slice_previews_the_content_under_it() { assert!(paints(&pix), "the rectangle under the slice shows"); } +/// The preview follows the slice's first export setting: with Ignore overlapping layers (contents +/// only) on, a slice in a frame shows only the frame's content, as its export does. +#[test] +fn a_slice_in_a_frame_previews_only_the_frame() { + let mut s = Session::new(); + let new = |s: &mut Session, p: Value| NodeId(s.execute("node.create", &p).unwrap()["id"].as_u64().unwrap()); + let f = new(&mut s, json!({"type": "frame", "x": 0, "y": 0, "width": 200, "height": 100, "props": {"fills": []}})); + let slice = new(&mut s, json!({"type": "slice", "x": 0, "y": 0, "width": 200, "height": 100, "parent": f})); + new(&mut s, json!({"type": "rectangle", "x": 0, "y": 0, "width": 200, "height": 100})); + let pix = thumbnail_pixmap(s.doc().unwrap(), slice, 200.0).expect("the slice previews"); + assert_eq!([pix.width(), pix.height()], [200, 100]); + assert!(!paints(&pix), "the rectangle on the page above the empty frame is left out"); + s.execute("export.add", &json!({"ids": [slice], "setting": {"size": "1x", "contentsOnly": false}})).unwrap(); + let pix = thumbnail_pixmap(s.doc().unwrap(), slice, 200.0).expect("the slice previews"); + assert!(paints(&pix), "with the option off, the rectangle shows"); +} + #[test] fn a_slice_with_nothing_under_it_is_blank() { let (doc, ids) = doc_after(&[("node.create", json!({"type": "slice", "x": 0, "y": 0, "width": 200, "height": 100}))]); @@ -60,7 +77,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"] { @@ -76,3 +93,33 @@ fn export_rows_offer_a_size_for_images_only() { assert!(size_menu(&u), "{format} has sizes again"); } } + +/// The advanced options: Ignore overlapping layers (the `contentsOnly` setting), and Include +/// bounding box for text layers only. A setting that already has the bounding box on keeps it, and +/// the option stays so it can be turned off. +#[test] +fn advanced_options_offer_the_bounding_box_for_text_layers() { + let mut s = Session::new(); + let r = crate::present_tests::id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 80})).unwrap()); + let t = crate::present_tests::id(&s.execute("text.create", &json!({"text": "Hi", "x": 200, "y": 0})).unwrap()); + s.execute("export.add", &json!({"ids": [r]})).unwrap(); + s.execute("export.add", &json!({"ids": [t]})).unwrap(); + let mut u = crate::layers_drag_tests::Ui::new(s); + let options = |u: &mut crate::layers_drag_tests::Ui, id: NodeId| { + u.app.run("select.set", json!({"ids": [id]})); + u.frame(vec![]); + egui::Popup::open_id(&u.ctx, egui::Id::unique(("export_more", 0))); + u.frame(vec![]); + crate::import_report_tests::shown_texts(u.frame_output(vec![]).shapes) + }; + let shown = options(&mut u, r); + assert!(shown.iter().any(|s| s == "Ignore overlapping layers"), "the reference app's name: {shown:?}"); + assert!(!shown.iter().any(|s| s == "Contents only")); + assert!(!shown.iter().any(|s| s == "Include bounding box"), "a rectangle has no bounding box option"); + let shown = options(&mut u, t); + assert!(shown.iter().any(|s| s == "Include bounding box"), "a text layer has it: {shown:?}"); + u.app.run("export.update", json!({"ids": [r], "index": 0, "includeBoundingBox": true})); + let shown = options(&mut u, r); + assert!(shown.iter().any(|s| s == "Include bounding box"), "a rectangle that has it on shows it: {shown:?}"); + assert!(u.app.session.doc().unwrap().node(r).unwrap().exports[0].include_bounding_box, "the setting is kept"); +} diff --git a/crates/ui-egui/src/field_math.rs b/crates/ui-egui/src/field_math.rs index c8318309..f81500e6 100644 --- a/crates/ui-egui/src/field_math.rs +++ b/crates/ui-egui/src/field_math.rs @@ -102,7 +102,15 @@ impl Parser<'_> { } let v = digits.parse::().ok().filter(|v| v.is_finite())?; // The unit: letters (px, pt, ms, x), a degree sign, and a percent sign where that is one. - while self.chars.next_if(|c| c.is_ascii_alphabetic() || *c == '°' || (*c == '%' && self.percent_unit)).is_some() {} + // It may follow spaces ("3 px"). + let percent_unit = self.percent_unit; + let is_unit = |c: &char| c.is_ascii_alphabetic() || *c == '°' || (*c == '%' && percent_unit); + let mut ahead = self.chars.clone(); + while ahead.next_if(|c| c.is_whitespace()).is_some() {} + if ahead.peek().is_some_and(is_unit) { + self.space(); + } + while self.chars.next_if(is_unit).is_some() {} Some(v) } @@ -161,3 +169,17 @@ pub fn stepped(from: f64, steps: f64, step: f64) -> f64 { let v = from + steps * step; if v.is_finite() { (v * 1e6).round() / 1e6 } else { from } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unit_after_a_space() { + assert_eq!(parse("3px", true), Some(Typed::Value(3.0))); + assert_eq!(parse("3 px", true), Some(Typed::Value(3.0))); + assert_eq!(parse("10 °", true), Some(Typed::Value(10.0))); + assert_eq!(parse("50 %", true), Some(Typed::Value(50.0))); + assert_eq!(parse("3 px + 2 px", true), Some(Typed::Value(5.0))); + } +} diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index dd63988a..341e7cca 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -467,29 +467,33 @@ fn lead(u: &Ui, id: Id) -> Pos2 { u.ctx.read_response(id.with("lead")).unwrap_or_else(|| panic!("no field {id:?}")).rect.center() } +/// Asserts scrubbing the colour picker's R and alpha fields updates the colour live, each +/// scrub adding one undo step. #[test] 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}"); assert_eq!(undo_steps(&u), steps + 2, "alpha"); } +/// Asserts scrubbing a prototype delay sets the value once when released and adds one undo +/// step. #[test] fn scrubbing_a_prototype_delay_sets_it_once_when_let_go() { let mut s = Session::new(); @@ -505,7 +509,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, @@ -514,13 +518,14 @@ fn scrubbing_a_prototype_delay_sets_it_once_when_let_go() { assert_eq!((ms, undo_steps(&u)), (800, steps + 1)); } +/// Asserts scrubbing the scale panel's width scales the layer live in one undo step. #[test] fn scrubbing_the_scale_panel_width_scales_live_in_one_undo_step() { let (mut u, a, _) = setup(); 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"); @@ -550,14 +555,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"); } @@ -584,13 +589,15 @@ fn the_intensity_label_fits_whole_in_every_language() { out.textures_delta.clear(); } +/// Asserts a label too long for its number field is elided before the value, while the value +/// stays whole and both remain inside the field. #[test] fn a_label_too_long_for_its_field_ends_in_an_ellipsis_before_the_value() { let ctx = egui::Context::default(); 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(); @@ -623,7 +630,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) } @@ -633,6 +640,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, @@ -655,7 +663,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); @@ -665,7 +673,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"); @@ -683,6 +691,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(); + } +} + +/// Asserts a deferred field save does not retarget a closed, moved or foreign owner: the other +/// file stays untouched, Save as is not opened, and the stale request is consumed once. +#[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] { + prepared_save_cannot_enter_a_replacement(route, save_as, discard); + } + } + } +} + +/// One route/save-as/discard combination of the same-pass replacement test: a save prepared for A +/// must not commit A's cached X editor into B when routing presents B before the fields draw, +/// even though both panels use the same widget ID. +fn prepared_save_cannot_enter_a_replacement(route: &str, save_as: bool, discard: bool) { + 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, + state: 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_label_tests.rs b/crates/ui-egui/src/frame_label_tests.rs index 687669e8..6c15fc00 100644 --- a/crates/ui-egui/src/frame_label_tests.rs +++ b/crates/ui-egui/src/frame_label_tests.rs @@ -156,3 +156,23 @@ fn a_sections_label_renames_it_and_a_click_elsewhere_commits() { assert!(u.app.ui.rename_label.is_none()); assert_eq!(u.app.session.ds().unwrap().history.undo.len(), undo + 1, "one undo step"); } + +#[test] +fn a_sections_pill_is_above_the_labels_of_frames_inside_it() { + // A frame near the section's top-left puts its label over the section's pill (#1150): the pill + // draws last, so pressing where they overlap selects the section. + let mut s = Session::new(); + let sec = id(s.execute("node.create", &json!({"type": "section", "x": 0, "y": 0, "width": 300, "height": 200, "name": "Marketing"})).unwrap()); + let f = id(s.execute("frame.create", &json!({"x": 10, "y": 22, "width": 100, "height": 80, "name": "Icon card", "parent": sec})).unwrap()); + s.execute("select.none", &json!({})).unwrap(); + let mut u = Ui::new(s); + u.frame(vec![]); + let at = mid(&label(&u, sec)); + let frame_label = egui::Rect::from_points(&label(&u, f)); + assert!(frame_label.contains(at), "the frame's label {frame_label:?} overlaps the pill at {at:?}"); + let order: Vec = u.app.session.tool.labels.iter().map(|(id, _)| *id).collect(); + assert_eq!(order, vec![f, sec], "the section's label comes after (above) its frames'"); + u.button(at, true); + u.button(at, false); + assert_eq!(u.app.session.selection(), vec![sec]); +} diff --git a/crates/ui-egui/src/frame_labels.rs b/crates/ui-egui/src/frame_labels.rs index 9e8b2e7a..a87fc9f4 100644 --- a/crates/ui-egui/src/frame_labels.rs +++ b/crates/ui-egui/src/frame_labels.rs @@ -58,10 +58,16 @@ pub fn turn_since(painter: &Painter, from: ShapeIdx, pivot: Pos2, angle: f32) { }); } +/// Recursively rotate shape geometry by `rot`/`angle` about `pivot`: leaves +/// no-op/callback/band shapes alone, rotates points and centers, adds the angle to +/// ellipses/rects/text, and rotates meshes. 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 +172,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..9eb28162 100644 --- a/crates/ui-egui/src/frame_presets.rs +++ b/crates/ui-egui/src/frame_presets.rs @@ -10,6 +10,8 @@ use crate::icons::{self, Icon}; use crate::theme::{Tokens, regular, semibold}; use crate::widgets::{self, PAD}; +/// Draw the expandable frame-preset categories; choosing a preset runs `frame.create`, +/// switches to the Move tool and zooms to the new selection. pub fn panel(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); widgets::section_header(ui, tr("Frame"), &[], false); @@ -22,7 +24,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..c3bb6589 100644 --- a/crates/ui-egui/src/guides.rs +++ b/crates/ui-egui/src/guides.rs @@ -49,13 +49,14 @@ struct Placed { line: Option<[Point; 2]>, } +/// Stable egui id for guide dragging. 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). @@ -268,6 +269,7 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool { app.run("guide.select", owned(g.owner, page, json!({"index": g.index}))); if secondary { app.ui.context_menu = Some((pos, Vec::new())); + app.ui.context_menu_shown = false; } else { ctx.data_mut(|d| { d.insert_temp(drag_id(), GuideDrag::Move { owner: g.owner, index: g.index, vertical: g.vertical, copy: alt, frame }) diff --git a/crates/ui-egui/src/handles.rs b/crates/ui-egui/src/handles.rs index ec489bf8..d29d59ee 100644 --- a/crates/ui-egui/src/handles.rs +++ b/crates/ui-egui/src/handles.rs @@ -67,8 +67,9 @@ fn crop_corners(app: &FigApp, id: NodeId, r: (f64, f64, f64, f64)) -> Option<[Po ]) } +/// Stable egui id for handle dragging. 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..9c809b99 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -27,7 +27,7 @@ pub enum Section { impl Section { /// The sidebar, top to bottom. - const ALL: [(Section, Icon, &'static str); 3] = [ + const ALL: [(Section, Icon, &str); 3] = [ (Section::Recents, Icon::Home, tr_noop("Recents")), (Section::Online, Icon::Online, tr_noop("Online")), (Section::Templates, Icon::Assets, tr_noop("Templates")), @@ -188,6 +188,8 @@ fn dropped(app: &mut FigApp, ctx: &egui::Context) { } } +/// Draw the Home sidebar: app icon and wordmark, file/template search (Escape clears it) +/// and section tabs; choosing a section clears the file query. fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) { let mut y = left.min.y + 20.0; crate::theme::paint_app_icon(ui, Rect::from_min_size(pos2(left.min.x + 12.0, y - 4.0), vec2(32.0, 32.0))); @@ -195,8 +197,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 +212,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 +257,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 +392,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 +430,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)"), @@ -470,10 +477,12 @@ fn matches(template: &Template, query: &str, kind: Option) -> bool { || has(template.size)) } +/// Draw template search and kind filter, show no-match guidance and a "Show all templates" +/// button that clears the query and kind, and lay matching templates out in a grid. 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 +515,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 +538,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 +610,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 8275df5e..a94cdec8 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -40,6 +40,7 @@ icons! { Pen, Pencil, Text, + TextOnPath, Comment, Actions, Chevron, @@ -209,6 +210,8 @@ icons! { Connector, Shapes, Stamp, + /// A measurement: a dimension line with arrowheads between two end ticks. + Measure, } /// Points along a circular arc from angle `a0` to `a1` (radians, y down: increasing is clockwise). @@ -559,6 +562,12 @@ fn draw(p: &P, icon: Icon) { p.line(11.5, 3.0, 11.5, 5.5); p.line(5.5, 12.5, 9.5, 12.5); } + TextOnPath => { + // A small T above the curve it follows. + p.line(4.5, 3.0, 11.5, 3.0); + p.line(8.0, 3.0, 8.0, 8.5); + p.poly(&[(2.0, 13.5), (5.0, 11.5), (8.0, 10.8), (11.0, 11.5), (14.0, 13.5)], false); + } Comment => { let mut b: Vec<(f32, f32)> = arc_pts(8.5, 7.5, 5.0, 2.75, TAU + 1.95).collect(); b.push((2.5, 13.5)); @@ -1180,6 +1189,13 @@ fn draw(p: &P, icon: Icon) { p.circle(8.0, 8.0, 6.0); p.poly(&[(5.0, 8.3), (7.2, 10.5), (11.0, 6.0)], false); } + Measure => { + p.line(2.5, 4.0, 2.5, 12.0); + p.line(13.5, 4.0, 13.5, 12.0); + p.line(3.0, 8.0, 13.0, 8.0); + p.poly(&[(5.5, 5.5), (3.0, 8.0), (5.5, 10.5)], false); + p.poly(&[(10.5, 5.5), (13.0, 8.0), (10.5, 10.5)], false); + } Logo => { // FigCraft's own mark: the curled tail of the chameleon on the app icon. One bold // stroke: a shaft along the bottom, then three half turns, each tighter than the last. @@ -1267,7 +1283,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); @@ -1365,7 +1381,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); diff --git a/crates/ui-egui/src/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs index 192073b3..ee25124a 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; @@ -247,6 +248,8 @@ fn a_finished_background_render_wakes_the_ui() { assert!(worker.poll().is_some(), "the woken frame finds the image"); } +/// Asserts a control request wakes an idle UI and is answered, and screenshots keep frames +/// coming until delivered before the UI rests again. #[test] fn control_requests_and_screenshots_wake_an_idle_ui() { let (mut h, _) = document(); @@ -258,7 +261,7 @@ fn control_requests_and_screenshots_wake_an_idle_ui() { method: "ui.inspect".into(), params: json!({}), reply: tx.clone(), - cancelled: Default::default(), + state: Default::default(), }); assert_eq!(h.frame(vec![]), Duration::ZERO); assert!(rx.try_recv().unwrap().result.is_ok()); @@ -266,15 +269,20 @@ fn control_requests_and_screenshots_wake_an_idle_ui() { // A screenshot keeps frames coming until the window delivers it, then the UI rests again. let path = std::env::temp_dir().join(format!("figcraft-idle-shot-{}.png", std::process::id())); 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() }); + h.app.control_queue.push(ControlRequest { id: json!(2), method: "ui.screenshot".into(), params, reply: tx, state: 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"); } @@ -335,41 +343,52 @@ fn cjk_names_are_looked_for_in_edits_not_on_every_pan() { assert_eq!(h.app.cjk_needed(&ctx, 0), crate::cjk::HANGUL); } +/// Asserts a hidden window answers edits and inspection at once, while a screenshot is +/// parked with a deadline and times out once instead of waiting for a frame. #[test] fn a_hidden_window_answers_at_once() { // #269, #720: minimised or covered, the window draws no frames, but the app's logic still runs: // it answers edits, inspection, saves and the interface's state; what needs a frame (a capture - // of the window) is an error at once instead of waiting for the window to show. + // of the window) queues for a forced hidden paint and otherwise expires at its deadline. let dir = std::env::temp_dir().join(format!("figcraft-hidden-save-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("hidden.figcraft"); let (mut h, _) = document(); h.settle(); + // Exercise admission well after startup, independently of settling speed. + h.time += 120.0; + h.app.clock.set_test_now(Some(h.time)); let (tx, rx) = std::sync::mpsc::channel(); let request = |id: u64, method: &str, params: Value| ControlRequest { id: json!(id), method: method.into(), params, reply: tx.clone(), - cancelled: Default::default(), + state: Default::default(), }; h.app.control_queue.push(request(1, "engine.execute", json!({"command": "file.save", "params": {"path": path.to_string_lossy()}}))); h.app.control_queue.push(request(2, "ui.inspect", json!({}))); 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(h.time + 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 +427,47 @@ fn auto_save_writes_a_changed_file_then_rests() { h.assert_idle("an auto-saved file"); let _ = std::fs::remove_dir_all(&dir); } + +/// Asserts a hidden logic tick runs background work and advances the autosave deadline +/// without counting a UI frame, and is idempotent at the same time. +#[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"); +} + +/// Asserts a discarded sizing pass counts one drawn frame, not one per pass. +#[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..e6c2f698 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,24 @@ 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") +} + +/// Return the available surrounding-text payload of an IME output, panicking when unavailable. +fn available(ime: &IMEOutput) -> &IMESurroundingTextPayload { + match &ime.surrounding_text { + IMESurroundingText::Available(payload) => payload, + IMESurroundingText::Unavailable => panic!("expected available surrounding text"), + } +} + +/// Create a text layer with `text` and return its node id. +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 +104,543 @@ 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!"); } + +/// Asserts an empty IME commit ends the composition, inserts nothing, keeps the editor +/// focused, and the next pass observes the committed text. +#[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"); +} + +/// Asserts byte deletion before the paired commit replaces the exact UTF-8 range, and one undo +/// restores the pre-IME text. +#[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"); +} + +/// Asserts a caret click invalidates the whole stale IME batch (both byte and character +/// deletions) while a later direct commit still works. +#[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"); + } +} + +/// Asserts a control-channel document switch rejects the old composition's delete and commit +/// batch, while a direct commit to the new target remains valid. +#[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, + state: 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"); +} + +/// Asserts the first observation and ordinary typing are `Other`, while an accepted commit is +/// `InputMethod` with UTF-8 offsets, the same owner and generation. +#[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"); +} + +/// Asserts a frame mixing IME and application edits is `Other`, and a later pure commit is +/// `InputMethod` without lingering state. +#[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か"); +} + +/// Asserts a control-channel text edit is an application change (`Other`). +#[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"); +} + +/// Asserts a byte delete followed by a commit is `InputMethod` and describes the +/// post-mutation text and caret. +#[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"); +} + +/// Asserts preedit text is excluded from surrounding text and earns no receipt, while the +/// following commit is `InputMethod`. +#[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); +} + +/// Asserts surrounding text is logical UTF-8 with byte offsets and keeps a reversed +/// selection. +#[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"); +} + +/// Asserts empty text is available while a selection wider than the 4000-byte budget is +/// explicitly unavailable but still has a nonzero IME context generation. +#[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"); +} + +/// Asserts replacing a too-wide selection with a commit is still an `InputMethod` receipt. +#[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)); +} + +/// Asserts another text node is another owner, and re-entering the same node changes the +/// context generation while keeping the owner key. +#[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"); +} + +/// Asserts an accepted commit survives a discarded rerun without inserting twice and keeps +/// its `InputMethod` cause, while a mixed rerun is `Other`. +#[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); +} + +/// Asserts IME context state is scoped per viewport: each viewport has its own generation, +/// the root keeps its context across an aux pass, and a closed aux viewport does not inherit +/// the old generation. +#[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); +} + +/// Asserts the engine's IME surrounding-text byte limit equals egui's wire limit. +#[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); +} + +/// Asserts a frame observes the IME exactly once in both the editor pass and the Home +/// early-return pass. +#[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"); +} + +/// Asserts a double-click inside an existing edit selects the word without changing the IME +/// owner or generation. +#[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"); +} + +/// Asserts a `window.new` that moves the edited file leaves the root window emitting no IME +/// descriptor. +#[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"); +} + +/// Asserts a CRLF-normalized commit is application-origin (`Other`), and bare-newline and +/// control-only commits insert nothing. +#[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(_))); +} + +/// Asserts character deletion keeps the selection for the paired commit, which replaces it +/// and reports `InputMethod`. +#[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..fcc0bbc6 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); @@ -134,6 +134,8 @@ pub fn rail(app: &mut FigApp, ui: &mut Ui) { } } +/// Draw the left panel: versions when open, otherwise the file/Assets header with in-place +/// file rename and collapse control, followed by Pages and Layers or the Assets panel. pub fn left_panel(app: &mut FigApp, ui: &mut Ui) { if app.ui.versions.open { crate::versions::panel(app, ui); @@ -149,13 +151,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); } @@ -173,23 +175,30 @@ pub fn left_panel(app: &mut FigApp, ui: &mut Ui) { } } +/// Draw the page list with open/search/add controls, filtering, selection, rename, +/// reorder/drag and layer drop-to-page; Dev Mode allows opening pages but blocks mutations. fn pages(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); + // Dev Mode only inspects: rows open pages, but don't add, rename, reorder, duplicate or delete. + let dev = app.ui.right_tab == crate::RightTab::Inspect; + if dev { + app.ui.rename_page = None; + } 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,14 +207,16 @@ 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); } icons::paint(ui.painter(), ar.shrink(5.0), Icon::Plus, t.icon); if aresp.clicked() { app.ui.pages_focus = app.session.ds().ok().map(|d| d.uid); - app.run("page.add", json!({})); + if !crate::menus::dev_mode_refuses(app, "page.add") { + app.run("page.add", json!({})); + } } if !app.ui.pages_open { return; @@ -241,13 +252,16 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { } let sense = if filtering { Sense::click() } else { Sense::click_and_drag() }; let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), sense); + if dev && resp.drag_started() { + crate::menus::dev_mode_refuses(app, "page.move"); + } if id == cur { current_row_rendered = true; if scroll_to_cur { ui.scroll_to_rect(r, None); } } - if !filtering { + if !filtering && !dev { reorder.row_by(&resp, k, r, || if divider { tr("Divider").to_string() } else { name.clone() }); } a11y::list_item(&resp, if divider { tr("Divider") } else { &name }, id == cur); @@ -275,7 +289,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 { @@ -287,7 +301,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { app.ui.pages_focus = Some(uid); app.ui.layers_focus = false; } - if resp.double_clicked() { + if resp.double_clicked() && !crate::menus::dev_mode_refuses(app, "page.rename") { app.ui.rename_page = Some((id, name.clone())); // The field takes the focus next frame, once it's drawn: focusing it now would put a // node that isn't there yet in the screen reader tree, which AccessKit aborts on. @@ -295,15 +309,21 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { } crate::right::context_popup(&resp).show(|ui| { if ui.button(tr("Rename page")).clicked() { - app.ui.rename_page = Some((id, name.clone())); + if !crate::menus::dev_mode_refuses(app, "page.rename") { + app.ui.rename_page = Some((id, name.clone())); + } ui.close(); } if ui.button(tr("Duplicate page")).clicked() { - app.run("page.duplicate", json!({"id": id})); + if !crate::menus::dev_mode_refuses(app, "page.duplicate") { + app.run("page.duplicate", json!({"id": id})); + } ui.close(); } if ui.button(tr("Delete page")).clicked() { - delete_page(app, id); + if !crate::menus::dev_mode_refuses(app, "page.delete") { + delete_page(app, id); + } ui.close(); } }); @@ -321,6 +341,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { } if let Some((from, to)) = moved.inner && let Some((id, ..)) = pages.get(from) + && !crate::menus::dev_mode_refuses(app, "page.move") { app.run("page.move", json!({"id": id, "index": to})); } @@ -328,7 +349,9 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { app.ui.rename_page = None; app.run("page.rename", json!({"id": id, "name": new})); } - if let Some((page, name)) = move_to { + if let Some((page, name)) = move_to + && !crate::menus::dev_mode_refuses(app, "object.moveToPage") + { let ids = dragged_layers(dragging, &sel); if app.run("object.moveToPage", json!({"ids": ids, "page": page})).is_some() { app.toast(trf("Moved to {name}", &[("name", &name)])); @@ -372,8 +395,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 +430,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 @@ -424,8 +448,9 @@ struct Sweep { ids: Vec, } +/// Stable egui id for the eye/padlock sweep state. 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 +495,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); @@ -634,8 +659,9 @@ fn rename_layer(app: &mut FigApp, id: NodeId) { app.ui.layers_seen_sel = app.session.selection(); } +/// Stable egui id for a layer row's rename field. 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 +711,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. @@ -702,18 +728,25 @@ fn search_field(ui: &mut Ui, t: &Tokens, w: f32, id: Id, hint: &str, text: &mut } } +/// Draw the layer tree with search, cached rows, reveal-selection expansion and scrolling, +/// rename/Tab navigation, selection and drag/drop, sweep toggles and Dev Mode restrictions. fn layers(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); + // Dev Mode only inspects: rows select and expand, but don't rename, reorder, hide or lock. + let dev = app.ui.right_tab == crate::RightTab::Inspect; + if dev { + app.ui.rename = None; + } let w = ui.available_width(); 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 +898,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); @@ -923,14 +956,17 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { // Lock / eye on hover (or when set); a layer in a locked one shows a faint padlock. let hidden = swept(false).unwrap_or(!n.visible); let locked = swept(true).unwrap_or(n.locked); - let show_lock = over || locked || row.locked_above; - let show_eye = over || hidden; + // In Dev Mode they only show what is set. + let show_lock = (over && !dev) || locked || row.locked_above; + let show_eye = (over && !dev) || hidden; 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()); - 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 show_eye && !dev { + let eresp = ui.interact(er, ui.scope_id().with(("eye", row.id)), Sense::click_and_drag()); + a11y::toggle(&eresp, tr("Hidden"), hidden); if eresp.clicked() { vis = Some(row.id); } @@ -939,10 +975,12 @@ 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()); - 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); + } + if show_lock && !dev { + 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); if lresp.clicked() { lock = Some(row.id); } @@ -953,7 +991,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { if resp.clicked() { clicked = Some((row.id, ui.input(|i| i.modifiers))); } - if resp.double_clicked() { + if resp.double_clicked() && !crate::menus::dev_mode_refuses(app, "object.rename") { app.ui.rename = Some(Rename { id: row.id, start: true }); } if resp.secondary_clicked() { @@ -962,9 +1000,10 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { } let pos = ui.input(|i| i.pointer.hover_pos()).unwrap_or(r.center()); app.ui.context_menu = Some((pos, vec![row.id])); + app.ui.context_menu_shown = false; } // Drag to reorder. - if resp.drag_started() { + if resp.drag_started() && !crate::menus::dev_mode_refuses(app, "node.reparent") { ui.data_mut(|d| d.insert_temp(drag_id, row.id)); } let over = dragging.is_some() && ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| r.contains(p)); @@ -995,7 +1034,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 +1069,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!({})); } }); @@ -1051,7 +1090,10 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { let (released, down) = ui.input(|i| (i.pointer.any_released(), i.pointer.primary_down())); if released { let (cmd, key) = if sw.lock { ("object.toggleLock", "locked") } else { ("object.toggleVisible", "visible") }; - app.run(cmd, json!({"ids": sw.ids, key: if sw.lock { sw.on } else { !sw.on }})); + // A sweep started in the design can end after a switch to Dev Mode. + if !crate::menus::dev_mode_refuses(app, cmd) { + app.run(cmd, json!({"ids": sw.ids, key: if sw.lock { sw.on } else { !sw.on }})); + } } // A sweep whose release the panel never saw (it wasn't shown) is dropped. if released || !down { @@ -1063,9 +1105,12 @@ 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) { + // A drag started in the design can end after a switch to Dev Mode. + if let (Some(_), Some((target, zone))) = (dragging, drop) + && !crate::menus::dev_mode_refuses(app, "node.reparent") + { let ids = moving.clone(); match zone { 0 => { @@ -1075,8 +1120,10 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { // Above in the list = higher z = after the target among siblings. if let Some(parent) = doc.node(target).and_then(|n| n.parent) { let idx = doc.index_in_parent(target).unwrap_or(0); - let index = if z < 0 { idx + 1 } else { idx }; - app.run("node.reparent", json!({"ids": ids, "parent": parent, "index": index})); + let gap = if z < 0 { idx + 1 } else { idx }; + // `index` is the final position: the dragged layers below the gap leave it. + let below = doc.node(parent).map_or(0, |p| p.children.iter().take(gap).filter(|c| ids.contains(c)).count()); + app.run("node.reparent", json!({"ids": ids, "parent": parent, "index": gap - below})); } } } diff --git a/crates/ui-egui/src/layers_behaviour_tests.rs b/crates/ui-egui/src/layers_behaviour_tests.rs index 4d987e66..0f312499 100644 --- a/crates/ui-egui/src/layers_behaviour_tests.rs +++ b/crates/ui-egui/src/layers_behaviour_tests.rs @@ -312,6 +312,7 @@ fn pages_down_to(u: &mut Ui, to: f32) -> Vec { opened } +/// Asserts a 60-page list scrolls with the wheel to its last page, past its first pages. #[test] fn a_long_pages_list_scrolls_and_leaves_room_for_the_layers() { let mut s = Session::new(); @@ -330,6 +331,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(), @@ -382,6 +384,8 @@ fn double_click_checking_focus(u: &mut Ui, p: Pos2) { } } +/// Asserts double-clicking a page selects and renames it, with the keyboard in its name +/// field for the screen-reader tree. #[test] fn double_clicking_a_page_renames_it_with_the_focus_in_the_screen_reader_tree() { let mut s = Session::new(); @@ -394,10 +398,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..978a3b23 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(); @@ -165,3 +192,26 @@ fn dropping_layers_on_another_page_moves_them_there() { let doc = u.app.session.doc().unwrap(); assert_eq!(doc.ancestors(rect).last().copied(), Some(p2), "moved to page 2"); } + +#[test] +fn dropping_a_layer_one_place_over_moves_it_one_place() { + // #992: the panel drops on a gap between rows; the layer ends up in that gap. + let mut s = Session::new(); + let rect = + |s: &mut Session, x: i32| id(s.execute("node.create", &json!({"type": "rectangle", "x": x, "y": 0, "width": 10, "height": 10})).unwrap()); + let (a, b, c) = (rect(&mut s, 0), rect(&mut s, 20), rect(&mut s, 40)); + s.execute("select.none", &json!({})).unwrap(); + let page = s.page().unwrap(); + let mut u = Ui::new(s); + let kids = |u: &Ui| u.app.session.doc().unwrap().node(page).unwrap().children.clone(); + // The list shows c, b, a top to bottom: `a` dragged onto the top edge of `b` goes just above it. + let (from, to) = (u.row_at(a), u.row_at(b) - vec2(0.0, 12.0)); + u.drag_to(from, to); + u.button(to, false); + assert_eq!(kids(&u), [b, a, c], "dropped between b and c"); + // And `c` dragged onto the bottom edge of `a` goes just below it. + let (from, to) = (u.row_at(c), u.row_at(a) + vec2(0.0, 12.0)); + u.drag_to(from, to); + u.button(to, false); + assert_eq!(kids(&u), [b, c, a], "dropped between a and b"); +} diff --git a/crates/ui-egui/src/layers_rename_tests.rs b/crates/ui-egui/src/layers_rename_tests.rs index c4b9211c..c1c229eb 100644 --- a/crates/ui-egui/src/layers_rename_tests.rs +++ b/crates/ui-egui/src/layers_rename_tests.rs @@ -144,6 +144,8 @@ fn rename_without_a_name_renames_one_row_or_opens_the_dialog() { assert!(std::sync::Arc::ptr_eq(&doc, &s.ds().unwrap().doc)); } +/// Asserts a layer rename continues after its row is scrolled out of view and commits on +/// Enter. #[test] fn a_rename_scrolled_out_of_view_goes_on() { let mut s = Session::new(); @@ -158,6 +160,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..0874da2f 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})); @@ -283,6 +283,8 @@ fn add_slot(ui: &mut Ui, label: &str, tip: &str, width: f32) -> egui::Response { resp } +/// Draw the 3×3 main/cross alignment grid, auto-gap modes, grid column/row gaps and the +/// horizontal baseline checkbox. fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { let t = Tokens::get(ui.ctx()); let grid_mode = l.mode == LayoutMode::Grid; @@ -309,7 +311,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 +340,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 +353,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 +365,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 +408,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 +426,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 +442,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 +454,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 +478,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 +503,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 +574,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 +617,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 +630,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 da40bf0c..b7aca146 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -63,6 +63,7 @@ pub mod theme; pub mod toolbar; pub mod typography; pub mod ui_scale; +pub mod updates_ui; pub mod variable_ui; pub mod variables_panel; pub mod vector_panel; @@ -90,6 +91,8 @@ 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; @@ -98,6 +101,8 @@ mod comments_tests; #[cfg(test)] mod component_props_reorder_tests; #[cfg(test)] +mod context_menu_tests; +#[cfg(test)] mod corner_individual_tests; #[cfg(test)] mod cursors_tests; @@ -236,6 +241,8 @@ mod vector_tail_ui_tests; #[cfg(test)] mod web_clipboard_tests; #[cfg(test)] +mod web_save_tests; +#[cfg(test)] mod windows_qa_tests; #[cfg(test)] mod windows_tests; @@ -252,7 +259,7 @@ use figcraft_doc::NodeId; use figcraft_engine::Session; use serde_json::{Value, json}; -pub use control::{ControlReply, ControlRequest}; +pub use control::{ControlReply, ControlRequest, RequestGate, RequestState}; /// Platform services the app crate provides (file dialogs, clipboard, URLs). pub trait Services { @@ -475,6 +482,35 @@ 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 { + /// Start the frame clock at the current web time with no injected test time. + 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; @@ -528,6 +564,8 @@ pub struct UiState { pub palette_hi: usize, /// The palette ignores one outside click after opening (the click that opened it). pub palette_armed: bool, + /// The context menu has been drawn at least once (its opening frame's press is spared). + pub context_menu_shown: bool, /// This frame's Actions toolbar button rect: presses on it toggle, never dismiss. pub actions_rect: Option, /// The Variables editor, when open. @@ -641,7 +679,13 @@ 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, @@ -649,14 +693,14 @@ pub struct UiState { /// 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 the next frame (`shortcuts::handle`). - pub after_field: Option<&'static str>, + /// 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`). @@ -686,6 +730,9 @@ pub struct UiState { } impl Default for UiState { + /// Seed the default UI state (panel widths, tabs, pages, Home, presenters, pickers and + /// dialogs off, and the rest of the transient UI fields); `FigApp::new` overrides panel + /// widths and the pages height from preferences. fn default() -> Self { Self { left_width: PANEL_DEFAULT, @@ -707,6 +754,7 @@ impl Default for UiState { palette_tab: menus::PaletteTab::All, palette_hi: 0, palette_armed: false, + context_menu_shown: false, actions_rect: None, variables: None, about: false, @@ -773,6 +821,7 @@ impl Default for UiState { dev: dev_mode::DevUi::default(), last_copy: None, + last_copy_generation: 0, v_pressed: false, paste_event: false, refocused: false, @@ -795,6 +844,14 @@ impl Default for UiState { } } +/// 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, @@ -833,12 +890,48 @@ 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, + /// The check for a newer release and its prompt (desktop; [`updates_ui`]). + pub(crate) updates_rt: updates_ui::Runtime, + /// 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. pub type Done = Box; impl FigApp { + /// Construct `FigApp` from a session and services, taking panel widths and pages height + /// from preferences and initializing UI, canvas, clocks, capture/IME and transient channel + /// state. pub fn new(session: Session, services: Box) -> Self { // The main window's panels open as wide as they were left. let (left_width, right_width) = session.prefs.panel_widths; @@ -864,6 +957,21 @@ 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(), + updates_rt: updates_ui::Runtime::default(), + #[cfg(test)] + ime_observations: 0, } } @@ -876,12 +984,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 { @@ -1004,6 +1134,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)); } @@ -1169,8 +1376,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)); @@ -1190,6 +1399,15 @@ impl FigApp { } pub fn save_as_dialog(&mut self) { + if self.services.downloads() { + if let Some(r) = self.run("file.saveAs", json!({"data": true})) + && self.download(r["name"].as_str().unwrap_or_default(), &r) + { + self.run("file.markSaved", json!({})); + dialogs::file_saved(self, &r); + } + return; + } 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(OsStr::is_empty) { @@ -1294,6 +1512,18 @@ impl FigApp { self.start_dialog(|services| services.pick_save(title, name, exts), true, done); } + /// Hand a command result's base64 `data` over as a download named `name`, where the platform + /// saves by downloads rather than save dialogs (the browser, #969). Says so when nothing arrived. + pub(crate) fn download(&mut self, name: &str, result: &Value) -> bool { + use base64::Engine as _; + let bytes = result["data"].as_str().and_then(|d| base64::engine::general_purpose::STANDARD.decode(d).ok()); + if bytes.is_some_and(|b| self.services.download(name, &b)) { + return true; + } + self.toast(i18n::trf("Couldn't download {name}", &[("name", &name)])); + false + } + /// Pick a folder; `done` runs as [`FigApp::pick_open`]'s does. pub fn pick_folder(&mut self, title: &str, done: impl FnOnce(&mut FigApp, PathBuf) + 'static) { self.start_dialog(|services| services.pick_folder(title), true, done); @@ -1412,18 +1642,56 @@ 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); + updates_ui::tick(self, ctx); + 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. @@ -1438,12 +1706,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() { @@ -1464,8 +1735,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); @@ -1486,8 +1756,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; @@ -1562,25 +1831,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); @@ -1589,6 +1869,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) { @@ -1613,11 +1897,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); @@ -1626,7 +1916,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); } @@ -1639,15 +1929,21 @@ impl FigApp { 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. dialogs::keys(self, &ctx); present::show(self, ui); dialogs::show(self, &ctx); + updates_ui::show(self, &ctx); // 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 { @@ -1657,8 +1953,11 @@ impl FigApp { self.ui.shortcuts_panel = false; dialogs::keys(self, &ctx); dialogs::show(self, &ctx); + updates_ui::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); @@ -1743,13 +2042,14 @@ 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); self.end_unwanted_text_preview(&ctx); self.end_pen_outside_design(); dialogs::show(self, &ctx); + updates_ui::show(self, &ctx); self.toast_ui(&ctx); // A rename started after the layers panel drew (Cmd/Ctrl+R, a double-click) opens its field // next frame. @@ -1763,8 +2063,33 @@ 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); + } + } + + /// Draw the toast: start its lifetime on the frame **after** the first drawn frame, request + /// that frame and the frame that hides it, expire and remove it at the deadline, and place it + /// at the bottom centre. fn toast_ui(&mut self, ctx: &egui::Context) { let Some((msg, clock)) = &mut self.ui.toast else { return }; let now = ctx.input(|i| i.time); @@ -1793,7 +2118,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) @@ -1923,7 +2248,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); 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..bc1db8ad 100644 --- a/crates/ui-egui/src/live_join_tests.rs +++ b/crates/ui-egui/src/live_join_tests.rs @@ -14,8 +14,9 @@ fn escape(h: &mut Harness) { } } +/// Whether the Join link field currently has focus. 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..014be152 100644 --- a/crates/ui-egui/src/live_server_tests.rs +++ b/crates/ui-egui/src/live_server_tests.rs @@ -20,8 +20,9 @@ fn key(h: &mut Harness, key: Key) { } } +/// Whether the widget with the given id currently has focus. 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..d7fc61c8 100644 --- a/crates/ui-egui/src/live_share_tests.rs +++ b/crates/ui-egui/src/live_share_tests.rs @@ -385,13 +385,16 @@ fn without_a_code_the_link_is_shared_instead() { assert_eq!(script.lock().unwrap().copied.last().map(String::as_str), Some(PLAIN)); } +/// Asserts the join field accepts a typed code or invite link, joins on Enter, clears after +/// joining, shows failure/retry, rejects an invalid code with an error, and accepts local +/// codes and links. #[test] fn the_join_field_takes_a_code_or_a_link_and_enter_joins() { let (mut h, script) = harness(); 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); @@ -450,13 +453,15 @@ fn the_home_screen_joins_with_a_code_and_opens_the_file() { assert_eq!(h.app.session.docs.len(), 2); } +/// Asserts the join dialog uses the same shared join field, focuses it, and closes once the +/// join starts. #[test] fn the_join_dialog_is_the_same_field() { let (mut h, script) = harness(); 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..63c3c42a 100644 --- a/crates/ui-egui/src/live_tests.rs +++ b/crates/ui-egui/src/live_tests.rs @@ -290,6 +290,8 @@ fn cursors_follow_pages_in_a_shared_document() { assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Hana left")); } +/// Asserts clicking a peer's avatar follows that peer, and Escape or scrolling the canvas +/// stops following. #[test] fn clicking_an_avatar_follows_and_escape_stops() { let (mut h, _) = Harness::new(); @@ -317,6 +319,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/measure_overlay.rs b/crates/ui-egui/src/measure_overlay.rs index 053ce861..ed8f019b 100644 --- a/crates/ui-egui/src/measure_overlay.rs +++ b/crates/ui-egui/src/measure_overlay.rs @@ -12,8 +12,9 @@ const GAP: f32 = 4.0; /// Padding inside a label's pill. const PAD: Vec2 = vec2(4.0, 2.0); -/// Draw the tool overlay's guides, measurement labels and measured box; labels keep clear of -/// `avoid` (the selection's size label). +/// Draw the tool overlay's guides, measurement labels and measured box, and in Dev Mode the +/// measurements kept on the canvas; labels keep clear of `avoid` (the selection's size label) and +/// of one another. pub fn draw(app: &FigApp, painter: &egui::Painter, t: &Tokens, avoid: Option) { let ov = &app.session.tool.overlay; let line = Stroke::new(1.0, t.guide); @@ -21,7 +22,7 @@ pub fn draw(app: &FigApp, painter: &egui::Painter, t: &Tokens, avoid: Option = Vec::new(); - for g in &ov.guides { + for g in ov.guides.iter().chain(crate::dev_mode::saved_lines(app)) { let (a, b) = (app.to_screen(g.x0, g.y0), app.to_screen(g.x1, g.y1)); if g.x0 == g.x1 && g.y0 == g.y1 { for d in [vec2(4.0, 4.0), vec2(4.0, -4.0)] { diff --git a/crates/ui-egui/src/menu_aim_tests.rs b/crates/ui-egui/src/menu_aim_tests.rs index 5e2fe076..6f7c4b8a 100644 --- a/crates/ui-egui/src/menu_aim_tests.rs +++ b/crates/ui-egui/src/menu_aim_tests.rs @@ -87,8 +87,9 @@ impl Ui { const TOPS: [&str; 6] = ["File", "Edit", "View", "Object", "Text", "Arrange"]; +/// Stable id for a top-level menu in the menu-aim test harness. 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. @@ -145,6 +146,8 @@ fn a_reopened_main_menu_shows_no_stale_submenu() { assert!(u.tops_shown().is_empty(), "reopened with {:?} open", u.tops_shown()); } +/// Asserts leaving a top menu and returning to it does not restore the submenu that was open +/// before. #[test] fn a_submenu_one_level_down_reopens_closed() { let mut u = Ui::new(); @@ -152,7 +155,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_input_tests.rs b/crates/ui-egui/src/menu_input_tests.rs index f317b65b..6fc65533 100644 --- a/crates/ui-egui/src/menu_input_tests.rs +++ b/crates/ui-egui/src/menu_input_tests.rs @@ -69,6 +69,8 @@ fn batch_rename_opens_its_existing_dialog() { assert!(ui.app.ui.last_error.is_none()); } +/// Asserts `file.rename` opens the left panel's inline file-name editor (not a dialog), +/// focuses it, and commits the typed name on Enter. #[test] fn rename_file_edits_the_file_name_in_place() { let mut ui = Ui::new(Session::new()); @@ -79,7 +81,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..3428844e 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) { @@ -107,10 +129,9 @@ fn clipboard_png(app: &mut FigApp) -> Option<(Vec, u32, u32)> { Some((out.into_inner(), w, h)) } -/// Dev Mode (the Inspect tab) refuses command `id` from the menus, shortcuts and clipboard: it -/// edits the design ([`figcraft_engine::devinspect::dev_mode_allows`]). Says so when it does. -/// shortcut: the layers panel (rename, reorder, hide, lock) and files dropped on the canvas still -/// edit in Dev Mode; route them through here if people trip over it. +/// Dev Mode (the Inspect tab) refuses command `id` from the menus, shortcuts, clipboard, the +/// Pages list, the layers panel, files dropped on the canvas and the Assets panel: it edits the +/// design ([`figcraft_engine::devinspect::dev_mode_allows`]). Says so when it does. pub fn dev_mode_refuses(app: &mut FigApp, id: &str) -> bool { let refused = app.ui.right_tab == crate::RightTab::Inspect && !figcraft_engine::devinspect::dev_mode_allows(id); if refused { @@ -167,6 +188,7 @@ pub fn invoke(app: &mut FigApp, id: &str) { "board.nextSticky" => { app.run(id, json!({"edit": true})); } + "help.checkForUpdates" | "app.checkForUpdates" => crate::updates_ui::check(app), "plugin.import" => crate::plugins_ui::import_dialog(app), "file.import" => app.import_dialog(false), "file.placeImage" => app.import_dialog(true), @@ -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,14 @@ 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))); + if let Some(id) = app.session.selection().first() { + app.services.clipboard_set_text(&format!("figcraft://node/{}", id.0)); + app.toast(tr("Link copied")); + } } "edit.findReplace" => app.ui.find = Some((String::new(), String::new())), // The Actions palette invokes commands without parameters. These values are edited in @@ -260,10 +284,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()) => { @@ -282,6 +311,15 @@ pub fn invoke(app: &mut FigApp, id: &str) { /// Every visible top-level frame of the page as one PDF page each, through a save dialog. fn export_frames_pdf(app: &mut FigApp) { let name = app.session.doc().map(|d| d.name.clone()).unwrap_or_else(|_| "Untitled".into()); + if app.services.downloads() { + let file = format!("{name}.pdf"); + if let Some(r) = app.run("file.exportFramesPdf", json!({})) + && app.download(&file, &r) + { + app.toast(trf("Exported {name}", &[("name", &file)])); + } + return; + } app.pick_save(tr("Export"), &format!("{name}.pdf"), &["pdf"], |app, p| { if app.run("file.exportFramesPdf", json!({"path": p.to_string_lossy()})).is_some() { app.toast(trf("Exported {name}", &[("name", &p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())])); @@ -392,7 +430,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) } // --------------------------------------------------------------------------------------------- @@ -452,12 +490,14 @@ struct At { path: String, } +/// Scoped egui id for the current menu level. fn at_key(ui: &Ui) -> Id { - ui.id().with("menu_level") + ui.scope_id().with("menu_level") } +/// Return the current menu root/path, defaulting to the main menu root. 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 +741,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 +804,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). @@ -791,6 +831,9 @@ const TOP_MENUS: &[&str] = &[ tr_noop("Help"), ]; +/// Draw the main and context menu trees: forget closed/stale submenu state, apply +/// control-channel Select-layer open/close, close on outside click or Escape, then draw the +/// colour picker and palette popups. pub fn popups(app: &mut FigApp, ctx: &egui::Context) { let t = Tokens::get(ctx); // A closed menu forgets its open submenus (and a context menu opened somewhere else starts @@ -799,7 +842,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 +876,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 +897,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| { @@ -895,11 +940,13 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { .ok() .filter(|doc| figcraft_engine::cmd::component::can_combine_variants(doc, &app.session.selection())) .map(|_| i18n::command("component.combineVariants", "Combine as variants")); + let copy_link = i18n::command("edit.copyLink", "Copy link to selection"); let mut entries: Vec<(&str, &str)> = if has_sel { vec![ (tr("Copy"), "edit.copy"), (tr("Paste here"), "edit.paste"), (tr("Paste to replace"), "edit.pasteReplace"), + (tr("Copy as"), COPY_AS), ("-", ""), (tr("Bring to front"), "arrange.front"), (tr("Bring forward"), "arrange.forward"), @@ -924,10 +971,6 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { (tr("Flip horizontal"), "object.flipH"), (tr("Flip vertical"), "object.flipV"), ("-", ""), - (tr("Copy as CSS"), "edit.copyAsCss"), - (tr("Copy as SVG"), "edit.copyAsSvg"), - (tr("Copy as PNG"), "edit.copyAsPng"), - ("-", ""), (tr("Delete"), "edit.delete"), ] } else { @@ -966,6 +1009,29 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { separator(ui); continue; } + if *id == COPY_AS { + let copy_items = [ + (tr("Copy as CSS"), "edit.copyAsCss"), + (tr("Copy as SVG"), "edit.copyAsSvg"), + (tr("Copy as PNG"), "edit.copyAsPng"), + (&*copy_link, "edit.copyLink"), + ]; + let picked = submenu(ui, COPY_AS, label, &t, |ui| { + let mut picked = None; + for (label, id) in copy_items { + let sc = crate::shortcuts::shortcut(id).unwrap_or(""); + if item(ui, label, sc, available(id), false, &t) { + picked = Some(id); + } + } + picked + }); + if let Some(id) = picked { + invoke(app, id); + close = true; + } + continue; + } let sc = crate::shortcuts::shortcut(id).unwrap_or(""); if item(ui, label, sc, available(id), false, &t) { if *id == "edit.paste" { @@ -979,11 +1045,15 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { } }); }); - let clicked_outside = ctx.input(|i| i.pointer.any_pressed()) && !area.response.hovered() && !over_menu(ctx); + // The press of a tap that opened the menu (press and release in one frame) is spared. + let fresh = !std::mem::replace(&mut app.ui.context_menu_shown, true); + let clicked_outside = !fresh && ctx.input(|i| i.pointer.any_pressed()) && !area.response.hovered() && !over_menu(ctx); if close || clicked_outside || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { app.ui.context_menu = None; forget_tree(ctx, ctx_menu_id()); } + } else { + app.ui.context_menu_shown = false; } crate::colorpicker::show(app, ctx); palette(app, ctx); @@ -992,8 +1062,12 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { /// The context menu's Select layer submenu (its key in the context menu's tree). const SELECT_LAYER: &str = "Select layer"; +/// The context menu's Copy as submenu: its key in the tree, and the marker row in the entries. +const COPY_AS: &str = "Copy as"; + +/// Stable egui id for the context menu's pending Select-layer open request. 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 +1096,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 +1162,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/palette_tests.rs b/crates/ui-egui/src/palette_tests.rs index cfe9c1cd..b9827867 100644 --- a/crates/ui-egui/src/palette_tests.rs +++ b/crates/ui-egui/src/palette_tests.rs @@ -161,3 +161,26 @@ fn arrows_move_the_highlight_and_enter_runs_it() { assert!(u.app.ui.palette.is_none(), "Enter runs a row and closes the palette"); assert_eq!(u.app.session.prefs.recent_actions.first(), Some(&want), "the highlighted row ran"); } + +/// A trackpad tap sends the press and the release in one frame; the press must not close a +/// context menu the release just opened, but a later outside press still does. +#[test] +fn context_menu_survives_a_same_frame_tap() { + let mut u = Ui::new(Session::new()); + let p = Pos2::new(1000.0, 600.0); + let b = |pressed| Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() }; + u.frame(vec![Event::PointerMoved(p), b(true), b(false)]); + assert!(u.app.ui.context_menu.is_some(), "the opening tap's press is spared"); + u.frame(vec![]); + assert!(u.app.ui.context_menu.is_some(), "the menu stays open"); + // A second tap elsewhere while the menu is open replaces it and survives too. + let q = Pos2::new(600.0, 400.0); + let b2 = |pressed| Event::PointerButton { pos: q, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() }; + u.frame(vec![Event::PointerMoved(q), b2(true), b2(false)]); + u.frame(vec![]); + assert!(u.app.ui.context_menu.is_some(), "a repeat tap keeps the replaced menu open"); + let far = Pos2::new(100.0, 100.0); + u.button(far, true); + u.button(far, false); + assert!(u.app.ui.context_menu.is_none(), "a later outside press closes the menu"); +} diff --git a/crates/ui-egui/src/panel_remove_tests.rs b/crates/ui-egui/src/panel_remove_tests.rs index 3805dca3..1608902d 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(), @@ -63,10 +64,13 @@ fn remove_button(u: &mut Ui, list: &str, index: usize) -> Pos2 { .unwrap_or_else(|| panic!("no Remove button in {list} row {index} {row:?} {all:?}")) } +/// Read a temporary boolean open-settings flag keyed by `salt` and row index. 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) } +/// Asserts removing a fill keeps the open picker on the surviving paint (with its index +/// shifted), and removing the paint being edited closes the picker. #[test] fn removing_a_fill_keeps_the_picker_on_its_paint() { let mut s = Session::new(); @@ -78,7 +82,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); @@ -92,6 +96,8 @@ fn removing_a_fill_keeps_the_picker_on_its_paint() { assert_eq!(u.app.ui.picker, None); } +/// Asserts removing effects or grids moves open settings and an open colour picker to the +/// surviving row, or closes them when their own row is removed. #[test] fn removing_effects_and_grids_keeps_open_settings_with_their_rows() { let mut s = Session::new(); @@ -105,7 +111,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 +124,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..ffeb948e 100644 --- a/crates/ui-egui/src/panel_reorder_tests.rs +++ b/crates/ui-egui/src/panel_reorder_tests.rs @@ -73,6 +73,8 @@ fn escape_cancels_a_row_drag() { assert_eq!(u.app.session.ds().unwrap().selection, [r], "the Escape only cancels the drag"); } +/// Asserts effect and layout-grid rows reorder by drag, while a mixed fill list exposes no +/// drag handle. #[test] fn effects_and_grids_drag_too_and_mixed_lists_dont() { let (mut u, r) = setup(); @@ -94,6 +96,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..eba5e68c 100644 --- a/crates/ui-egui/src/perf_tests.rs +++ b/crates/ui-egui/src/perf_tests.rs @@ -68,6 +68,8 @@ impl Bench { } } +/// Build and print a synthetic UI benchmark report for the given layer count; it makes no +/// assertions. fn report(layers: usize) { let mut b = Bench::new(layers); let mut rows: Vec<(&str, f64)> = Vec::new(); @@ -77,6 +79,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(), @@ -179,8 +182,15 @@ fn design_system(icons: usize, instances: usize) -> (Document, figcraft_engine:: figcraft_engine::synthetic_ds::design_system_with_instances(&spec, instances).unwrap() } +/// Build a test mouse-wheel event for the given vertical delta. 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..53b1ba56 100644 --- a/crates/ui-egui/src/plugin_panel_tests.rs +++ b/crates/ui-egui/src/plugin_panel_tests.rs @@ -89,14 +89,18 @@ fn an_open_plugin_panel_draws_no_frames_while_idle() { Harness::new(session()).assert_idle("a plugin panel"); } +/// Asserts a plugin colour field's swatch opens the picker on that field, a picked colour +/// reaches the plugin, and a second plugin's panel opens while the first remains (two panels +/// present). #[test] fn a_colour_field_opens_the_colour_picker_and_two_panels_show_side_by_side() { let mut u = Ui::new(session()); 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..26479c4d 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); }); @@ -245,12 +245,15 @@ const INSTALL: &str = "plugin.directory.install"; const MANAGE_W: f32 = 400.0; const ROW_TEXT_W: f32 = 300.0; +/// Draw the Plugins window with installed and browse tabs, dispatching remove, import, +/// browse, confirm and stop actions; closing the window cancels its directory requests and +/// clears confirmation. fn manage_window(app: &mut FigApp, ctx: &Context) { let t = Tokens::get(ctx); 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 +329,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 +354,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 +435,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); @@ -509,6 +512,8 @@ fn summary(p: &InstalledPlugin) -> String { parts.join(" · ") } +/// Draw a plugin command's parameter form: close it when the plugin or command disappeared, +/// edit values and colour picks, run `plugin.run` on Run, and close on Cancel. fn form_window(app: &mut FigApp, ctx: &Context) { let Some(mut form) = app.ui.plugin_form.clone() else { return }; let Some((name, command)) = app @@ -529,7 +534,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 +591,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 +648,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 fae1e1a7..bbaae4b6 100644 --- a/crates/ui-egui/src/present.rs +++ b/crates/ui-egui/src/present.rs @@ -288,6 +288,9 @@ pub struct Presenter { /// The layer the Inspect panel shows (the last one under the pointer). inspected: Option, urls: Vec, + /// A link to a website waiting for the viewer to open it ([`link_notice`]), and whether "Don't + /// show this again" is ticked. + link_notice: Option<(String, bool)>, /// The editor's document as last copied (the player copies it again when it changes). source: Option>, /// Scrolling by dragging: what it scrolls and its offset when the drag began. @@ -368,6 +371,7 @@ impl Presenter { clock: 0.0, inspected: None, urls: Vec::new(), + link_notice: None, source: None, drag_scroll: None, view: PresentView::default(), @@ -630,6 +634,7 @@ impl Presenter { "zoom": self.zoom, "flinging": self.fling.is_some(), "gamepad": self.uses_pads, + "linkNotice": self.link_notice.as_ref().map(|(url, _)| url), }) } @@ -1693,8 +1698,9 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { } poll_pads(app, &mut pres, &ctx); let mut action = None; - // Typing a reply to a comment keeps the keys from the prototype; Shift+C shows the comments. - let typing = ctx.egui_wants_keyboard_input(); + // Typing a reply to a comment keeps the keys from the prototype, and so does the link notice + // while it shows; Shift+C shows the comments. + let typing = ctx.egui_wants_keyboard_input() || pres.link_notice.is_some(); if !typing && ctx.input_mut(|i| i.consume_key(egui::Modifiers::SHIFT, egui::Key::C)) { action = Some(BarAction::Comments); } @@ -1734,6 +1740,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { app.ui.comments.close(); } finish(app, &mut pres); + link_notice(app, &mut pres, &ctx, Id::unique("present_link_notice")); // The options carry over to the next presentation. app.ui.present_view = pres.view; if live && let Some(BarAction::Responsive(on)) = action { @@ -1772,78 +1779,82 @@ fn navigation_bar(p: &mut Presenter, ctx: &egui::Context, now: f64) -> Option bool { + url.trim().split_once(':').is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")) +} + +/// The notice before a link leaves the prototype for a website: the link, "Don't show this +/// again" (saved with the preferences when Open link is pressed), Open link and Cancel. Escape or +/// the close button cancels. +fn link_notice(app: &mut FigApp, p: &mut Presenter, ctx: &egui::Context, id: Id) { + let Some((url, skip)) = p.link_notice.as_mut() else { return }; + let t = Tokens::get(ctx); + let (mut open, mut go, mut cancel) = (true, false, false); + crate::dialogs::window(tr("Leaving the prototype"), ctx).id(id).open(&mut open).show(ctx, |ui| { + ui.set_width(340.0); + widgets::label(ui, tr("This link opens a website outside the prototype:"), t.text); + ui.add(egui::Label::new(egui::RichText::new(url.as_str()).font(regular(11.0)).color(t.text_secondary)).wrap()); + ui.add_space(8.0); + if widgets::checkbox(ui, *skip, tr("Don't show this again")) { + *skip = !*skip; + } + ui.add_space(12.0); + ui.horizontal(|ui| { + go = widgets::button(ui, tr("Open link"), true, true).clicked(); + cancel = widgets::button(ui, tr("Cancel"), false, true).clicked(); + }); + }); + cancel |= !open || ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)); + if go { + let Some((url, skip)) = p.link_notice.take() else { return }; + if skip { + app.run("proto.linkNotice", json!({"show": false})); + app.services.save_prefs(&app.session.prefs); + } + app.services.open_url(&url); + } else if cancel { + p.link_notice = None; + } +} + /// The width of the presenter's Inspect panel. const INSPECT_W: f32 = 280.0; @@ -1950,7 +2006,7 @@ impl Pane { /// The preview pane's window id (its position and size persist under it). pub fn pane_id() -> 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: @@ -1993,6 +2049,7 @@ pub fn show_pane(app: &mut FigApp, ctx: &egui::Context) { action = player(&mut pane.player, ui, rect, now, chrome); }); finish(app, &mut pane.player); + link_notice(app, &mut pane.player, ctx, pane_id().with("link_notice")); match action { Some(BarAction::Close) => {} Some(BarAction::Present) => { @@ -2164,11 +2221,13 @@ 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. - input(p, &ctx, &l, over, chrome.keys && (!chrome.pane || over), chrome.pane, now); + // The pane only takes the keyboard while the pointer is over it. While a link notice shows + // the prototype takes neither keys nor the pointer (Escape there cancels the notice). + let keys = chrome.keys && p.link_notice.is_none() && (!chrome.pane || over); + input(p, &ctx, &l, over && p.link_notice.is_none(), keys, chrome.pane, now); } let Some(l) = layout(p, area, chrome) else { return action }; p.shown = Some((p.current, l.place, l.clip.intersect(area))); @@ -2262,7 +2321,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); } @@ -2314,7 +2373,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); } diff --git a/crates/ui-egui/src/present_links_tests.rs b/crates/ui-egui/src/present_links_tests.rs index 8223890c..31a6b2bc 100644 --- a/crates/ui-egui/src/present_links_tests.rs +++ b/crates/ui-egui/src/present_links_tests.rs @@ -1,7 +1,9 @@ //! A prototype's Open link action carries a URL from the document and fires from whatever trigger //! the prototype sets, including a delay, so the player hands the opener only a web link //! ([`figcraft_engine::doc_urls`]). Without that, a saved document opens `javascript:` in the web -//! build's own origin (stored XSS) and `smb:`/`ms-…:` through the desktop's handlers. +//! build's own origin (stored XSS) and `smb:`/`ms-…:` through the desktop's handlers. A link to a +//! website then waits behind a notice that it leaves the prototype, unless the viewer chose to skip +//! the notice. use std::sync::{Arc, Mutex}; @@ -11,9 +13,9 @@ use serde_json::{Value, json}; use crate::Services; use crate::present_tests::{Ui as Harness, id}; -/// Records every URL the player asks the platform to open. +/// Records every URL the player asks the platform to open, and every save of the preferences. #[derive(Default)] -struct Opened(Arc>>); +struct Opened(Arc>>, Arc>>); impl Services for Opened { fn open_url(&mut self, url: &str) { @@ -21,15 +23,28 @@ impl Services for Opened { v.push(url.to_string()); } } + + fn save_prefs(&mut self, prefs: &figcraft_engine::Prefs) { + if let Ok(mut v) = self.1.lock() { + v.push(prefs.clone()); + } + } } fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) } -/// A presented frame whose layer opens `url` 100ms after the screen appears — no click anywhere. -fn opens_after_a_delay(url: &str) -> Vec { +/// A presented frame whose layer opens `url` 100ms after the screen appears, with no click +/// anywhere, and with the notice skipped or not. Returns the harness and what the player opened. +fn presented(url: &str, skip_notice: bool) -> (Harness, Opened) { + played("proto.present", url, skip_notice) +} + +/// [`presented`], played by `cmd` (`proto.present` or `proto.preview`). +fn played(cmd: &str, url: &str, skip_notice: bool) -> (Harness, Opened) { let mut s = Session::new(); + s.prefs.link_notice_skipped = skip_notice; let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 400, "name": "A"}))); let layer = id(&ex(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 30, "parent": f}))); ex( @@ -37,19 +52,126 @@ fn opens_after_a_delay(url: &str) -> Vec { "proto.interaction.add", json!({"id": layer, "trigger": {"type": "afterDelay", "ms": 100}, "action": {"type": "openUrl", "url": url}, "transition": {"type": "instant"}}), ); - let seen = Arc::new(Mutex::new(Vec::new())); + let seen = Opened::default(); let mut u = Harness::new(s); - u.app.services = Box::new(Opened(seen.clone())); - u.run("proto.present", json!({"start": f})); + u.app.services = Box::new(Opened(seen.0.clone(), seen.1.clone())); + u.ctx.enable_accesskit(); + u.run(cmd, json!({"start": f})); // Well past the delay. u.frames(40, Vec::new); - seen.lock().map(|v| v.clone()).unwrap_or_default() + (u, seen) +} + +fn opened(seen: &Opened) -> Vec { + seen.0.lock().map(|v| v.clone()).unwrap_or_default() +} + +fn opens_after_a_delay(url: &str) -> Vec { + opened(&presented(url, true).1) +} + +/// The URL the presenter's notice waits to open. +fn notice(u: &Harness) -> Value { + u.app.inspect()["presenter"]["linkNotice"].clone() +} + +/// Clicks the control labeled `label`. +fn click(u: &mut Harness, label: &str) { + let tree = u.frame_output(vec![]).platform_output.accesskit_update.unwrap().nodes; + let n = tree.into_iter().map(|(_, n)| n).find(|n| n.label() == Some(label)).unwrap_or_else(|| panic!("no {label:?}")); + let b = n.bounds().unwrap(); + let at = egui::pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32); + u.button(at, true); + u.button(at, false); } #[test] fn a_prototype_opens_web_links() { assert_eq!(opens_after_a_delay("https://example.com/a"), ["https://example.com/a"]); assert_eq!(opens_after_a_delay("mailto:a@b.c"), ["mailto:a@b.c"]); + // A mail link opens the mail app without the notice. + assert_eq!(opened(&presented("mailto:a@b.c", false).1), ["mailto:a@b.c"]); +} + +#[test] +fn a_link_to_a_website_waits_for_the_notice() { + let (mut u, seen) = presented("https://example.com/a", false); + assert!(opened(&seen).is_empty(), "the link waits for the viewer"); + assert_eq!(notice(&u), "https://example.com/a"); + click(&mut u, "Open link"); + assert_eq!(opened(&seen), ["https://example.com/a"]); + assert!(notice(&u).is_null()); + assert!(!u.app.session.prefs.link_notice_skipped && seen.1.lock().unwrap().is_empty(), "the notice shows next time"); +} + +#[test] +fn the_preview_pane_shows_the_notice_too() { + let (mut u, seen) = played("proto.preview", "https://example.com/a", false); + assert!(opened(&seen).is_empty()); + assert_eq!(u.app.inspect()["preview"]["linkNotice"], "https://example.com/a"); + click(&mut u, "Open link"); + assert_eq!(opened(&seen), ["https://example.com/a"]); +} + +#[test] +fn cancel_or_escape_drops_the_link() { + let (mut u, seen) = presented("https://example.com/a", false); + click(&mut u, "Cancel"); + assert!(notice(&u).is_null() && opened(&seen).is_empty()); + let (mut u, seen) = presented("https://example.com/a", false); + u.key(egui::pos2(700.0, 450.0), egui::Key::Escape); + assert!(notice(&u).is_null() && opened(&seen).is_empty()); + assert!(u.app.ui.present.is_some(), "Escape closes the notice, not the presentation"); +} + +#[test] +fn the_prototype_takes_no_keys_while_the_notice_shows() { + let mut s = Session::new(); + let a = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 400, "name": "A"}))); + let b = id(&ex(&mut s, "frame.create", json!({"x": 400, "y": 0, "width": 300, "height": 400, "name": "B"}))); + let layer = id(&ex(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 30, "parent": a}))); + let link = json!({"type": "openUrl", "url": "https://example.com/a"}); + ex(&mut s, "proto.interaction.add", json!({"id": layer, "trigger": {"type": "afterDelay", "ms": 100}, "action": link})); + let to_b = json!({"type": "navigate", "dest": b}); + ex(&mut s, "proto.interaction.add", json!({"id": a, "trigger": {"type": "keyPress", "key": "Escape"}, "action": to_b})); + let mut u = Harness::new(s); + u.app.services = Box::new(Opened::default()); + u.run("proto.present", json!({"start": a})); + u.frames(40, Vec::new); + assert_eq!(notice(&u), "https://example.com/a"); + u.key(egui::pos2(700.0, 450.0), egui::Key::Escape); + assert!(notice(&u).is_null(), "Escape cancels the notice"); + assert_eq!(u.app.inspect()["presenter"]["current"], json!(a), "the prototype's Escape trigger did not run"); +} + +#[test] +fn the_presenter_keys_wait_while_the_notice_shows() { + let (mut u, _) = presented("https://example.com/a", false); + assert_eq!(notice(&u), "https://example.com/a"); + let press = |key, modifiers| { + let k = |pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }; + vec![k(true), k(false)] + }; + let out = u.frame_output(press(egui::Key::F, egui::Modifiers::NONE)); + let cmds = out.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.commands.clone()).unwrap_or_default(); + assert!(!cmds.iter().any(|c| matches!(c, egui::ViewportCommand::Fullscreen(_))), "F: {cmds:?}"); + u.frame(press(egui::Key::C, egui::Modifiers::NONE)); + assert_eq!(u.app.inspect()["presenter"]["comments"], false, "C"); + u.frame(press(egui::Key::C, egui::Modifiers::SHIFT)); + assert_eq!(u.app.inspect()["presenter"]["comments"], false, "Shift+C"); + assert_eq!(notice(&u), "https://example.com/a"); +} + +#[test] +fn dont_show_this_again_is_saved_with_the_preferences() { + let (mut u, seen) = presented("https://example.com/a", false); + click(&mut u, "Don't show this again"); + click(&mut u, "Open link"); + assert_eq!(opened(&seen), ["https://example.com/a"]); + assert!(u.app.session.prefs.link_notice_skipped); + assert!(seen.1.lock().unwrap().last().is_some_and(|p| p.link_notice_skipped), "the choice is saved"); + assert_eq!(u.run("proto.linkNotice", json!({})), json!({"shown": true})); + assert!(!u.app.session.prefs.link_notice_skipped); } #[test] @@ -69,3 +191,35 @@ fn a_prototype_cannot_open_a_script_or_handler_url() { assert!(opens_after_a_delay(url).is_empty(), "{url:?} must never reach the opener"); } } + +#[test] +fn the_prototype_takes_no_clicks_while_the_notice_shows() { + let mut s = Session::new(); + let a = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 400, "name": "A"}))); + let b = id(&ex(&mut s, "frame.create", json!({"x": 400, "y": 0, "width": 300, "height": 400, "name": "B"}))); + let layer = id(&ex(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 30, "parent": a}))); + let link = json!({"type": "openUrl", "url": "https://example.com/a"}); + ex(&mut s, "proto.interaction.add", json!({"id": layer, "trigger": {"type": "afterDelay", "ms": 100}, "action": link})); + let to_b = json!({"type": "navigate", "dest": b}); + ex(&mut s, "proto.interaction.add", json!({"id": a, "trigger": {"type": "onClick"}, "action": to_b, "transition": {"type": "instant"}})); + let mut u = Harness::new(s); + u.app.services = Box::new(Opened::default()); + u.run("proto.present", json!({"start": a})); + u.frames(40, Vec::new); + assert_eq!(notice(&u), "https://example.com/a"); + // On the frame, above the notice. + let at = egui::pos2(700.0, 290.0); + u.button(at, true); + u.button(at, false); + u.frames(5, Vec::new); + assert_eq!(u.app.inspect()["presenter"]["current"], json!(a), "the click behind the notice did not navigate"); + assert_eq!(notice(&u), "https://example.com/a", "the notice still waits"); + let shapes = crate::live_tests::flat(&u.frame_output(vec![]).shapes); + crate::live_tests::text_center(&shapes, "Open link"); + u.key(egui::pos2(700.0, 450.0), egui::Key::Escape); + assert!(notice(&u).is_null()); + u.button(at, true); + u.button(at, false); + u.frames(5, Vec::new); + assert_eq!(u.app.inspect()["presenter"]["current"], json!(b), "with the notice gone the same click navigates"); +} diff --git a/crates/ui-egui/src/present_tests.rs b/crates/ui-egui/src/present_tests.rs index 73bccaed..93729922 100644 --- a/crates/ui-egui/src/present_tests.rs +++ b/crates/ui-egui/src/present_tests.rs @@ -137,6 +137,8 @@ fn preview_pane_opens_follows_edits_and_the_selection() { assert!(u.app.ui.preview.is_none()); } +/// Asserts the preview's device viewport and scroll range, wheel and drag scrolling, the +/// end clamp with spring-back, and that navigation resets scroll to the top. #[test] fn the_frame_scrolls_on_the_device_screen_by_wheel_and_drag() { let (mut u, a, b, _) = setup(); @@ -150,6 +152,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(), @@ -203,6 +206,7 @@ fn a_top_level_frame_taller_than_the_phone_scrolls_without_an_overflow_setting() 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(), diff --git a/crates/ui-egui/src/present_view_tests.rs b/crates/ui-egui/src/present_view_tests.rs index 42647dc0..361a4caf 100644 --- a/crates/ui-egui/src/present_view_tests.rs +++ b/crates/ui-egui/src/present_view_tests.rs @@ -135,6 +135,8 @@ fn num(v: &Value) -> f64 { v.as_f64().unwrap() } +/// Asserts presenter fit/fit-width/fill/actual scaling, pan ranges, wheel and drag panning, +/// option carry-over to the next presentation, and R restarting the flow. #[test] fn presenter_scales_by_its_options_and_pans_what_overflows() { let (mut u, big) = big(); @@ -165,6 +167,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(), @@ -271,6 +274,7 @@ fn scrolling_keeps_its_momentum_and_springs_back_from_the_ends() { assert_eq!(u.preview()["flinging"], json!(false)); } +/// Asserts wheel momentum continues after input stops and then comes to rest. #[test] fn the_wheel_coasts_after_it_stops() { let (mut u, a, ..) = setup(); @@ -281,6 +285,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..b5d402b4 100644 --- a/crates/ui-egui/src/proto_canvas.rs +++ b/crates/ui-egui/src/proto_canvas.rs @@ -20,8 +20,9 @@ struct Drag { pos: Pos2, } +/// Stable egui id for prototype connection dragging. fn drag_id() -> Id { - Id::new("proto_canvas_drag") + Id::unique("proto_canvas_drag") } /// One drawn connection. @@ -59,9 +60,11 @@ struct Sources { ids: Arc>, } +/// Return the cached subtree nodes with interactions for `page`, recomputing only when the +/// document pointer or page changes. 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..7163807e 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)) } } @@ -129,8 +129,9 @@ fn unavailable(label: &str) -> &'static str { } } +/// Stable egui id for a node interaction's open action path. 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..63c152e1 100644 --- a/crates/ui-egui/src/proto_editor_tests.rs +++ b/crates/ui-egui/src/proto_editor_tests.rs @@ -78,6 +78,9 @@ fn condition(u: &Ui, btn: NodeId, block: usize) -> String { blocks[block].condition.as_ref().map(|c| c.display(&doc.variables)).unwrap_or_default() } +/// Asserts the interaction editor shows conditional blocks and nested actions, restarts a +/// condition when a variable of another type is picked, ignores a right-hand-side variable pick +/// for a complex condition, and one undo restores the previous condition. #[test] fn the_interaction_editor_shows_conditionals_and_picks_their_variables() { let (mut s, _, b, btn) = setup(); @@ -95,7 +98,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); @@ -154,6 +157,8 @@ fn the_builder_takes_variable_operator_value_conditions() { } } +/// Asserts invalid typed conditions keep their draft and show errors without saving, a valid +/// one clears the error and saves, and Enter does not leak to shortcuts. #[test] fn typed_conditions_are_checked_and_errors_show_until_fixed() { let (mut s, _, b, btn) = setup(); @@ -169,7 +174,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..1b2e2df9 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})); } } @@ -426,6 +426,8 @@ fn flows_overview(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) { } } +/// Draw prototype settings: device and rotation, device colour, custom size, responsive +/// toggle and page background, running `proto.settings`. fn settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) { if widgets::section_header(ui, tr("Prototype settings"), &[(Icon::Preview, tr("Open prototype preview"))], false).is_some() { app.run("proto.preview", json!({})); @@ -479,7 +481,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 +489,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 +541,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 { @@ -552,6 +554,7 @@ fn custom_size(app: &mut FigApp, ui: &mut Ui, model: Option) { }); } +/// Show, create or preview the flow starting at `frame` and edit its description. fn flow_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, frame: NodeId) { let flows = flows_of(doc, page); let mine = flows.iter().find(|f| f.start == frame).cloned(); @@ -570,7 +573,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})); @@ -579,6 +582,8 @@ fn flow_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, fra } } +/// List a node's interactions, add a default click navigation (or Back), open the editor +/// popover and remove interactions. fn interactions(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, node: &Node) { let t = Tokens::get(ui.ctx()); let id = node.id; @@ -597,7 +602,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 +623,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 +649,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 +662,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); } @@ -696,14 +701,17 @@ fn animates(a: &Action) -> bool { ) } +/// Stable egui id for the prototype key-capture flag. fn capture_id() -> Id { - Id::new("proto_key_capture") + Id::unique("proto_key_capture") } fn capturing(ctx: &egui::Context) -> bool { ctx.data(|d| d.get_temp::(capture_id())).unwrap_or(false) } +/// Draw an interaction's trigger controls: trigger picker, mouse/delay fields, key capture +/// and controller-button selection, patching the interaction. fn trigger_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Interaction) { let t = Tokens::get(ui.ctx()); widgets::sublabels(ui, tr("Trigger"), None); @@ -729,7 +737,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 +750,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}})); @@ -1021,6 +1029,8 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: } } +/// Draw an interaction's animation controls: transition, direction, easing, duration and +/// custom easing fields, plus an animated curve preview. fn animation_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Interaction) { let t = Tokens::get(ui.ctx()); widgets::sublabels(ui, tr("Animation"), None); @@ -1082,7 +1092,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 +1122,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 +1157,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..d69be4f6 100644 --- a/crates/ui-egui/src/proto_reorder_tests.rs +++ b/crates/ui-egui/src/proto_reorder_tests.rs @@ -44,8 +44,9 @@ fn setup() -> (Ui, NodeId) { (u, btn) } +/// Stable id for a prototype node's open action path in the reorder tests. 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 6738f300..a084b9d0 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -78,6 +78,8 @@ impl FigApp { } } +/// Draw the right panel: people, present and share controls, Design/Prototype tabs and zoom +/// menu, then route the scroll area to comments, frame presets, design, prototype or inspect. pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let w = ui.available_width(); @@ -87,7 +89,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 +98,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 +129,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 +147,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)); @@ -393,6 +395,8 @@ fn design(app: &mut FigApp, ui: &mut Ui) { crate::export_panel::section(app, ui, &doc, &sel); } +/// Draw the page section: background and colour profile, local variables/styles, and the +/// page export section. fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) { let t = Tokens::get(ui.ctx()); let Ok(page) = app.session.page() else { return }; @@ -408,7 +412,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); @@ -455,6 +459,8 @@ fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) { crate::export_panel::page_section(app, ui, doc, page); } +/// Draw the selection header: type name (or Mixed), frame/group conversion, orientation +/// swap and frame presets, component/mask/boolean quick actions, and instance details. fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let t = Tokens::get(ui.ctx()); let label = match common(doc, sel, |n| n.kind.label()) { @@ -479,7 +485,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); } @@ -497,7 +503,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); } @@ -536,7 +542,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); } @@ -589,6 +595,8 @@ fn position(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { ui.add_space(8.0); } +/// Draw alignment and distribution, X/Y and rotation with flip/rotate controls, the +/// absolute-position toggle and frame constraints. fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let ids = sel.to_vec(); let col = widgets::col_width(ui.available_width()); @@ -640,9 +648,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 { @@ -662,7 +670,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")), @@ -745,7 +753,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| { @@ -787,10 +795,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))); } }); @@ -807,6 +815,8 @@ fn pretty(s: &str) -> String { } } +/// Draw visibility/blend/mask, opacity, uniform or per-corner radius, corner smoothing and +/// the blend-mode row. fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let ids = sel.to_vec(); let visible = common(doc, sel, |n| n.visible).unwrap_or(true); @@ -827,7 +837,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); @@ -852,11 +862,13 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let radius_ok = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.has_corner_radius())); // Only rectangles and frames round each corner on its own. let indep_ok = radius_ok && sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.has_independent_corners())); + // For polygons and stars the same panel holds only the Corner smoothing row. + let smooth_ok = radius_ok && sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.has_corner_smoothing())); let r = common(doc, sel, |n| n.corners.uniform().map(|r| (r * 100.0).round() / 100.0)); 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( @@ -864,7 +876,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(), "", @@ -873,13 +885,15 @@ 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); - 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)); + let open = ui.data(|d| d.get_temp::(Id::unique("indep_corners"))).unwrap_or(false); + // Polygons and stars have no corners of their own: the button opens only Corner smoothing. + let tip = if indep_ok { tr("Individual corners") } else { tr("Corner smoothing") }; + if smooth_ok && icons::button(ui, Icon::IndependentCorners, 24.0, open, tip).clicked() { + 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 smooth_ok && ui.data(|d| d.get_temp::(Id::unique("indep_corners"))).unwrap_or(false) { let radii = common(doc, sel, |n| n.corners.radii); // `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. @@ -889,7 +903,8 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { ("bottomLeftRadius", tr("Bottom left radius"), Icon::CornerBottomLeft, 3), ("bottomRightRadius", tr("Bottom right radius"), Icon::CornerBottomRight, 2), ]; - for pair in corners.chunks(2) { + // Polygons and stars have one radius, so no field per corner. + for pair in corners.chunks(2).filter(|_| indep_ok) { row(ui, 32.0, |ui| { for (key, name, icon, k) in pair { let e = crate::variable_ui::number_field( @@ -897,7 +912,7 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { ui, &ids, key, - Id::new(("corner", *k)), + Id::unique(("corner", *k)), Lead::Icon(*icon, name), radii.map(|r| r[*k]), "", @@ -910,13 +925,15 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { 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); + if widgets::button(ui, tr("iOS"), false, true).on_hover_text(tr("Set corner smoothing to 60%")).clicked() { + app.set_props(&ids, json!({"cornerSmoothing": 0.6})); + } }); } 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); @@ -985,7 +1002,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| { @@ -1044,13 +1061,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()]})); @@ -1061,7 +1078,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 }); } } @@ -1073,6 +1090,8 @@ fn range_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId, start: }); } +/// Draw a selected-text range fill, or the fill/stroke paint rows with add, style, reorder, +/// stroke align/weight, per-side weights and advanced stroke settings. fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills: bool) { if fills && let Some((id, a, b)) = figcraft_engine::textedit::selected_range(&app.session) @@ -1157,7 +1176,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, "", @@ -1183,7 +1202,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)); @@ -1198,13 +1217,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); } } @@ -1362,16 +1381,16 @@ fn brush_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], brush: Option