mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-28 20:36:55 -06:00
Merge main into #1314
This commit is contained in:
@@ -3,5 +3,13 @@
|
||||
[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. Every Windows GNU
|
||||
# target (x86_64, i686, the gnullvm ones) gets it, as the check in apps/figcraft/src/main.rs asks.
|
||||
[target.'cfg(all(windows, target_env = "gnu"))']
|
||||
rustflags = ["--cfg=windows_slim_errors"]
|
||||
|
||||
[alias]
|
||||
xtask = "run -q -p xtask --"
|
||||
|
||||
Generated
+73
-73
@@ -4,18 +4,18 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "accesskit"
|
||||
version = "0.25.1"
|
||||
version = "0.25.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932"
|
||||
checksum = "4e883d1d28c5fd5568c79ed2d2bd48d9f2fd3945c8974ed0f53690ceddb051be"
|
||||
dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_atspi_common"
|
||||
version = "0.21.0"
|
||||
version = "0.21.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52c182f9c282ac9c5638d876d551d15e5f7d397ec263349a0c6a2b61595dd5e4"
|
||||
checksum = "24f5baf62e34a4a6c30e3d697e4782f821a7337049c44c3b40576a27dfbb580d"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_consumer",
|
||||
@@ -27,9 +27,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_consumer"
|
||||
version = "0.39.1"
|
||||
version = "0.39.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344"
|
||||
checksum = "f3da8f27636015191b2264ee1df681503170a632cafd832fdc1edfdd4e3a2fd3"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"hashbrown 0.17.1",
|
||||
@@ -37,9 +37,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_ios"
|
||||
version = "0.2.1"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937"
|
||||
checksum = "a5280724673bbfa8b30a838c013500b719392989be3b97c5a9964062c252c232"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_consumer",
|
||||
@@ -51,9 +51,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_macos"
|
||||
version = "0.27.1"
|
||||
version = "0.27.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4"
|
||||
checksum = "123828ceeec755b05255e3976cb0852993202de98a0626c97dd8d34246991f84"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_consumer",
|
||||
@@ -65,9 +65,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_unix"
|
||||
version = "0.24.0"
|
||||
version = "0.24.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "202f24df034a7476d07b7f74284de84f6d62aabd858dbe7ee9cad3b7ad6f8f9d"
|
||||
checksum = "30d5192871228c54f1b0fb4a85cef000e11a3804f25d2165f14afa0daa67f3f6"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_atspi_common",
|
||||
@@ -83,9 +83,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_windows"
|
||||
version = "0.35.1"
|
||||
version = "0.35.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce63f35d6bdcf59f26b76b3379063f738e6412cef46999cc772d46aa3de35adb"
|
||||
checksum = "498668b90da9995302a0055aaecad4756932333da2ddef6baee1c00844c430fc"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_consumer",
|
||||
@@ -97,9 +97,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "accesskit_winit"
|
||||
version = "0.34.1"
|
||||
version = "0.34.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee96dd150375c05912d8a473f98c2fb155fb3234fac88292d396548a2460fa77"
|
||||
checksum = "cd4ab4437b520ff03088fbe48676a11e7d44a929535e64b31432c1073e48dbcc"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_ios",
|
||||
@@ -253,7 +253,7 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
@@ -695,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",
|
||||
@@ -748,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",
|
||||
]
|
||||
@@ -907,7 +907,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
|
||||
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
@@ -931,13 +931,13 @@ dependencies = [
|
||||
"verifysign",
|
||||
"winreg",
|
||||
"winsafe",
|
||||
"zip 9.1.0",
|
||||
"zip 9.1.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-window"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
|
||||
dependencies = [
|
||||
"wayland-client",
|
||||
]
|
||||
@@ -1437,7 +1437,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2335,9 +2335,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",
|
||||
@@ -2349,9 +2349,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",
|
||||
@@ -2362,9 +2362,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",
|
||||
@@ -2376,15 +2376,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",
|
||||
@@ -2397,15 +2397,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",
|
||||
@@ -2667,7 +2667,7 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bumpalo",
|
||||
@@ -2810,9 +2810,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",
|
||||
]
|
||||
@@ -3330,9 +3330,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",
|
||||
]
|
||||
@@ -3354,7 +3354,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3975,9 +3975,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",
|
||||
]
|
||||
@@ -4026,7 +4026,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4083,7 +4083,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4431,9 +4431,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",
|
||||
]
|
||||
@@ -4634,10 +4634,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4786,9 +4786,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",
|
||||
@@ -4802,9 +4802,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",
|
||||
]
|
||||
@@ -5088,7 +5088,7 @@ dependencies = [
|
||||
"kurbo",
|
||||
"log",
|
||||
"pico-args",
|
||||
"roxmltree 0.21.1",
|
||||
"roxmltree 0.21.2",
|
||||
"simplecss",
|
||||
"siphasher",
|
||||
"skrifa 0.44.0",
|
||||
@@ -5121,9 +5121,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",
|
||||
@@ -5513,9 +5513,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",
|
||||
@@ -5538,9 +5538,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.10"
|
||||
version = "1.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9"
|
||||
checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
@@ -5750,7 +5750,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6179,9 +6179,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",
|
||||
@@ -6366,9 +6366,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",
|
||||
@@ -6377,9 +6377,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",
|
||||
@@ -6403,9 +6403,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "9.1.0"
|
||||
version = "9.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704"
|
||||
checksum = "518b847fd864440c379748ad684c449f293af68978a485a4ff16f7c901c1f7c4"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
|
||||
+5
-5
@@ -32,14 +32,14 @@ figcraft-mcp = { path = "crates/mcp" }
|
||||
figcraft-collab-net = { path = "crates/collab-net" }
|
||||
figcraft-plugin = { path = "crates/plugin" }
|
||||
|
||||
# craft-libs crates all pin one rev (craftrules standards/shared-libraries.md): bump them together.
|
||||
# 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" }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
|
||||
# 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). 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" }
|
||||
# pull a second egui family version).
|
||||
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] }
|
||||
|
||||
# foundation
|
||||
@@ -155,4 +155,4 @@ winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457
|
||||
|
||||
# craft-libs' vendored krilla 0.8.2: decoded PNGs are capped at 1 GiB and reserved fallibly, and a
|
||||
# panic while drawing unwinds without aborting.
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
|
||||
|
||||
@@ -336,6 +336,7 @@ 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 | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers 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 |
|
||||
|
||||
@@ -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};
|
||||
@@ -339,7 +339,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<Vec<(String, Value)>, String> {
|
||||
pub fn parse_script(text: &str) -> Result<Vec<BatchStep>, String> {
|
||||
let steps: Vec<Value> = match serde_json::from_str::<Value>(text) {
|
||||
Ok(Value::Array(a)) => a,
|
||||
Ok(Value::Object(o)) if o.contains_key("commands") => {
|
||||
@@ -364,7 +364,8 @@ pub fn parse_script(text: &str) -> Result<Vec<(String, Value)>, 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()
|
||||
}
|
||||
@@ -383,31 +384,30 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe
|
||||
None => (Session::new(), 0),
|
||||
};
|
||||
// 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<Value> = 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()))?;
|
||||
@@ -534,6 +534,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() {
|
||||
|
||||
@@ -128,13 +128,14 @@ fn u64_at(b: &[u8], at: usize) -> Option<u64> {
|
||||
|
||||
/// A snapshot file's bytes.
|
||||
pub fn encode_snapshot(generation: u64, state: &SessionState) -> Result<Vec<u8>, 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[SNAP_HEADER - 4..SNAP_HEADER].copy_from_slice(&sum.to_le_bytes());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -129,12 +129,11 @@ fn two_files_are_hosted_and_joined_independently() {
|
||||
// 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.
|
||||
// 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.
|
||||
// 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");
|
||||
// An edit in A reaches the guest's copy of A only, while B is in front at the guest.
|
||||
let in_a = rect(&mut host);
|
||||
host.execute("doc.switch", &json!({"index": 1})).unwrap();
|
||||
let in_b = rect(&mut host);
|
||||
|
||||
@@ -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::<ClientMsg>(&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");
|
||||
|
||||
@@ -739,11 +739,13 @@ pub fn utc_date(secs: u64) -> String {
|
||||
|
||||
/// The binary form of `msg`: [`WIRE_VERSION`], then postcard.
|
||||
pub fn encode<T: Serialize>(msg: &T) -> Result<Vec<u8>, 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<T: Serialize>(msg: &T, mut prefix: Vec<u8>) -> Result<Vec<u8>, 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
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
impl BatchStep {
|
||||
/// A step without a name.
|
||||
pub fn new(command: impl Into<String>, 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<Option<String>> {
|
||||
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<String, usize>,
|
||||
results: Vec<Value>,
|
||||
}
|
||||
|
||||
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<Self> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
self.resolve_in(params, 0)
|
||||
}
|
||||
|
||||
fn resolve_in(&self, v: &Value, depth: usize) -> Result<Value> {
|
||||
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::<Result<Vec<_>>>().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<String, Value>) -> Result<Value> {
|
||||
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::<usize>() {
|
||||
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",
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -495,9 +495,15 @@ pub fn scale_node(doc: &mut Document, id: NodeId, k: f64) -> Result<()> {
|
||||
t.paragraph_spacing *= k;
|
||||
t.paragraph_indent *= k;
|
||||
t.list_spacing *= k;
|
||||
if let Some(tp) = &mut t.path {
|
||||
tp.scale(k, k);
|
||||
}
|
||||
}
|
||||
if let NodeKind::Vector { network } = &mut n.kind {
|
||||
network.scale(k, k);
|
||||
for v in &mut network.vertices {
|
||||
v.radius *= k;
|
||||
}
|
||||
}
|
||||
n.width *= k;
|
||||
n.height *= k;
|
||||
|
||||
@@ -8,7 +8,7 @@ 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<CommandSpec> {
|
||||
vec![
|
||||
@@ -342,15 +342,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
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<BatchStep> = 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
|
||||
}
|
||||
|
||||
+102
-15
@@ -63,10 +63,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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<CommandSpec> {
|
||||
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 `<layer><suffix>.<format>` (\"@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 `<layer><suffix>.<format>` (\"@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
|
||||
),
|
||||
@@ -876,13 +883,19 @@ fn color_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<String>) -> Result<Value> {
|
||||
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);
|
||||
@@ -890,24 +903,40 @@ fn save_to(s: &mut Session, path: String) -> Result<Value> {
|
||||
refuse_dropping_images(ds, &path)?;
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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.
|
||||
@@ -915,7 +944,7 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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()))
|
||||
@@ -929,8 +958,9 @@ fn save_as(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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()))
|
||||
@@ -1181,6 +1211,20 @@ fn size_param(cmd: &str, p: &Value) -> Result<Option<ExportSize>> {
|
||||
|
||||
/// 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<u8>)>, name: &'a str, bytes: Vec<u8>, limit: usize) -> Result<()> {
|
||||
let total = members.iter().map(|(_, b)| b.len()).sum::<usize>().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 {
|
||||
@@ -1252,6 +1296,9 @@ fn export_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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());
|
||||
@@ -1271,12 +1318,18 @@ fn export_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<NodeId> = 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`
|
||||
@@ -1623,6 +1676,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()));
|
||||
@@ -1790,3 +1862,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::<Vec<_>>(), ["a.png", "b.png"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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-<collection>=\"<mode>\"]; numbers in px unless layers or scopes use them as opacity or font weight), 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-<collection>=\"<mode>\"]; numbers in px unless layers or scopes use them as opacity or font weight | tailwind: a Tailwind CSS v4 stylesheet, the default mode as an @theme block named the way inspect.code's tailwind utilities read them: --color-*, --radius-*, --text-*, --spacing-*, --font-*, unique per file), path? (write it there)} → {text}, or {path} when written",
|
||||
false,
|
||||
export
|
||||
),
|
||||
@@ -46,8 +46,9 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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, &colls),
|
||||
_ => return Err(bad(cmd, "format: json|css")),
|
||||
"css" => css(doc, &colls, false),
|
||||
"tailwind" => css(doc, &colls, true),
|
||||
_ => return Err(bad(cmd, "format: json|css|tailwind")),
|
||||
};
|
||||
match string(p, "path") {
|
||||
Some(path) => {
|
||||
@@ -66,16 +67,25 @@ 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-<collection>="<mode>"]`;
|
||||
/// aliases are `var(--name)`.
|
||||
fn css(doc: &Document, colls: &[&Collection]) -> String {
|
||||
fn css(doc: &Document, colls: &[&Collection], tailwind: bool) -> String {
|
||||
let vars = &doc.variables;
|
||||
// Tailwind theme names, unique per file and the same ones page code reads.
|
||||
let theme = crate::tailwind::Theme::new(doc);
|
||||
let name = |v: &Variable| if tailwind { theme.name(v) } else { css_var_of(v) };
|
||||
// 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();
|
||||
let mut out = if tailwind { "@import \"tailwindcss\";\n\n".to_string() } else { String::new() };
|
||||
for c in colls {
|
||||
let attr = css_var_name(&c.name);
|
||||
for (i, m) in c.modes.iter().enumerate() {
|
||||
let sel = if i == 0 { ":root".to_string() } else { format!("[data-{}=\"{}\"]", attr.trim_start_matches('-'), css_safe(&m.name)) };
|
||||
let sel = match i {
|
||||
// `static`: Tailwind otherwise drops theme variables no utility uses, and pages also
|
||||
// read them through `var()` and `(--name)` utilities.
|
||||
0 if tailwind => "@theme static".to_string(),
|
||||
0 => ":root".to_string(),
|
||||
_ => format!("[data-{}=\"{}\"]", attr.trim_start_matches('-'), css_safe(&m.name)),
|
||||
};
|
||||
out.push_str(&format!("/* {} · {} */\n{sel} {{\n", css_safe(&c.name), css_safe(&m.name)));
|
||||
for v in vars.variables.iter().filter(|v| v.collection == c.id) {
|
||||
let value = match v.values.get(&m.id) {
|
||||
@@ -85,12 +95,12 @@ fn css(doc: &Document, colls: &[&Collection]) -> String {
|
||||
Some(VariableValue::String(t)) => format!("\"{}\"", css_safe(t)),
|
||||
Some(VariableValue::Boolean(b)) => b.to_string(),
|
||||
Some(VariableValue::Alias(a)) => match vars.get(a) {
|
||||
Some(t) => format!("var({})", css_var_of(t)),
|
||||
Some(t) => format!("var({})", name(t)),
|
||||
None => continue,
|
||||
},
|
||||
None => continue,
|
||||
};
|
||||
out.push_str(&format!(" {}: {value};\n", css_var_of(v)));
|
||||
out.push_str(&format!(" {}: {value};\n", name(v)));
|
||||
}
|
||||
out.push_str("}\n");
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![spec!(
|
||||
"inspect.code",
|
||||
"Code for layer",
|
||||
"{id?, language?: css|html|react|leptos|swiftui|compose (default css; html, react and leptos are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead), unit?: px|rem (CSS, HTML, React and Leptos; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}",
|
||||
"{id?, language?: css|html|react|leptos|tailwind|swiftui|compose (default css; html, react and leptos are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead; tailwind is React with Tailwind CSS v4 utilities for pages: fluid auto layout boxes, instances inlined with their overrides, bound variables as theme utilities), mobile? (tailwind: a mobile frame with the same layer names; the code is mobile first with the desktop layer's classes behind lg:), unit?: px|rem (CSS, HTML, React and Leptos; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}",
|
||||
false,
|
||||
|s, p| {
|
||||
let cmd = "inspect.code";
|
||||
@@ -40,7 +40,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
return Err(bad(cmd, "no such layer"));
|
||||
}
|
||||
let connected = crate::code_connect::link(doc, id, lang).is_some();
|
||||
let code = code_for(doc, id, lang);
|
||||
let mobile = match get(p, "mobile") {
|
||||
None | Some(serde_json::Value::Null) => None,
|
||||
Some(v) => Some(id_of(v).filter(|m| doc.contains(*m)).ok_or_else(|| bad(cmd, "mobile: the id of a mobile frame"))?),
|
||||
};
|
||||
let code = match (lang, mobile) {
|
||||
(CodeLang::Tailwind, Some(m)) => crate::tailwind::code(doc, id, Some(m)),
|
||||
_ => code_for(doc, id, lang),
|
||||
};
|
||||
let code = match rem {
|
||||
Some(root) if lang.is_web() => px_to_rem(&code, root),
|
||||
_ => code,
|
||||
@@ -61,18 +68,22 @@ pub enum CodeLang {
|
||||
React,
|
||||
/// A Leptos component (`view!` markup) with its stylesheet.
|
||||
Leptos,
|
||||
/// A React function component styled with Tailwind CSS v4 utilities (no stylesheet).
|
||||
Tailwind,
|
||||
SwiftUi,
|
||||
Compose,
|
||||
}
|
||||
|
||||
impl CodeLang {
|
||||
pub const ALL: [CodeLang; 6] = [CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::Leptos, CodeLang::SwiftUi, CodeLang::Compose];
|
||||
pub const ALL: [CodeLang; 7] =
|
||||
[CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::Leptos, CodeLang::Tailwind, CodeLang::SwiftUi, CodeLang::Compose];
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
CodeLang::Css => "css",
|
||||
CodeLang::Html => "html",
|
||||
CodeLang::React => "react",
|
||||
CodeLang::Leptos => "leptos",
|
||||
CodeLang::Tailwind => "tailwind",
|
||||
CodeLang::SwiftUi => "swiftui",
|
||||
CodeLang::Compose => "compose",
|
||||
}
|
||||
@@ -83,11 +94,13 @@ impl CodeLang {
|
||||
CodeLang::Html => "HTML + CSS",
|
||||
CodeLang::React => "React",
|
||||
CodeLang::Leptos => "Leptos",
|
||||
CodeLang::Tailwind => "React + Tailwind",
|
||||
CodeLang::SwiftUi => "SwiftUI",
|
||||
CodeLang::Compose => "Compose",
|
||||
}
|
||||
}
|
||||
/// Languages whose lengths are CSS pixels (they can be shown in rem).
|
||||
/// Languages whose lengths are CSS pixels that can be shown in rem (not Tailwind: its lengths
|
||||
/// are utility classes).
|
||||
pub fn is_web(self) -> bool {
|
||||
matches!(self, CodeLang::Css | CodeLang::Html | CodeLang::React | CodeLang::Leptos)
|
||||
}
|
||||
@@ -199,6 +212,7 @@ pub fn code_for(doc: &Document, id: NodeId, lang: CodeLang) -> String {
|
||||
match lang {
|
||||
CodeLang::Css => css(doc, id),
|
||||
CodeLang::Html => html(doc, id),
|
||||
CodeLang::Tailwind => crate::tailwind::code(doc, id, None),
|
||||
CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose => crate::component_codegen::code(doc, id, lang),
|
||||
}
|
||||
}
|
||||
@@ -321,25 +335,32 @@ pub(crate) fn indent(depth: usize) -> String {
|
||||
|
||||
/// The CSS custom properties generated code uses: name → fallback value, in first-use order.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Tokens(pub Vec<(String, String)>);
|
||||
pub(crate) struct Tokens {
|
||||
vars: Vec<(String, String)>,
|
||||
/// Name variables as Tailwind theme variables (`--color-accent`), not by their CSS name.
|
||||
pub tailwind: bool,
|
||||
/// The file's theme names (computed on first use).
|
||||
theme: Option<crate::tailwind::Theme>,
|
||||
}
|
||||
|
||||
impl Tokens {
|
||||
/// `var(--name, value)`, remembering the variable.
|
||||
fn var(&mut self, variable: &Variable, value: String) -> String {
|
||||
let name = css_var_of(variable);
|
||||
fn var(&mut self, doc: &Document, variable: &Variable, value: String) -> String {
|
||||
let name =
|
||||
if self.tailwind { self.theme.get_or_insert_with(|| crate::tailwind::Theme::new(doc)).name(variable) } else { css_var_of(variable) };
|
||||
let out = format!("var({name}, {value})");
|
||||
if !self.0.iter().any(|(n, _)| *n == name) {
|
||||
self.0.push((name, value));
|
||||
if !self.vars.iter().any(|(n, _)| *n == name) {
|
||||
self.vars.push((name, value));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A `:root` rule declaring the variables (empty without any).
|
||||
pub fn root_rule(&self) -> String {
|
||||
if self.0.is_empty() {
|
||||
if self.vars.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let decls: String = self.0.iter().map(|(n, v)| format!(" {n}: {v};\n")).collect();
|
||||
let decls: String = self.vars.iter().map(|(n, v)| format!(" {n}: {v};\n")).collect();
|
||||
format!(":root {{\n{decls}}}\n")
|
||||
}
|
||||
}
|
||||
@@ -370,7 +391,7 @@ pub fn css_var_of(v: &Variable) -> String {
|
||||
/// `value`, or a reference to the variable bound to the first of `fields` that has one.
|
||||
fn tok(doc: &Document, n: &Node, fields: &[&str], value: String, t: &mut Tokens) -> String {
|
||||
match fields.iter().find_map(|f| crate::devinspect::bound_variable(doc, n, f)) {
|
||||
Some(v) => t.var(v, value),
|
||||
Some(v) => t.var(doc, v, value),
|
||||
None => value,
|
||||
}
|
||||
}
|
||||
@@ -664,6 +685,14 @@ pub(crate) struct Markup<'c, 'a> {
|
||||
/// Each layer's path in the tree (names with sibling counts), class and declarations, so
|
||||
/// variants can be compared layer by layer.
|
||||
pub rules: Vec<(String, String, String)>,
|
||||
/// Instances as their own (overridden) layers instead of component calls, except those
|
||||
/// connected to code (page code).
|
||||
pub inline: bool,
|
||||
/// Auto layout frames and text as fluid boxes (`width: 100%` up to their width) for pages.
|
||||
pub fluid: bool,
|
||||
/// With `fluid`, other layers at least this share of the root's width are fluid too (a
|
||||
/// divider, a banner image); smaller ones (icons) keep their size. Default: none.
|
||||
pub fluid_leaves: f64,
|
||||
}
|
||||
|
||||
impl<'c, 'a> Markup<'c, 'a> {
|
||||
@@ -678,6 +707,9 @@ impl<'c, 'a> Markup<'c, 'a> {
|
||||
root_class: None,
|
||||
root_attrs: String::new(),
|
||||
rules: Vec::new(),
|
||||
inline: false,
|
||||
fluid: false,
|
||||
fluid_leaves: f64::INFINITY,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -759,7 +791,10 @@ impl<'c, 'a> Markup<'c, 'a> {
|
||||
}
|
||||
}
|
||||
// Nested instances are calls (component code), in a wrapper that places them when needed.
|
||||
if self.code() && !root && matches!(n.kind, NodeKind::Instance { .. }) {
|
||||
// Page code inlines instances, except those connected to code (codeConnect.set): their
|
||||
// snippet, e.g. a design-system `<Button>`.
|
||||
let connected = self.inline && crate::code_connect::link(doc, n.id, CodeLang::React).is_some();
|
||||
if self.code() && (!self.inline || connected) && !root && matches!(n.kind, NodeKind::Instance { .. }) {
|
||||
let at = depth + usize::from(!decls.is_empty());
|
||||
let run = if self.lang == CodeLang::Leptos { ".run()" } else { "" };
|
||||
let inner = match self.ctx.param(n, "mainComponent") {
|
||||
@@ -779,6 +814,11 @@ impl<'c, 'a> Markup<'c, 'a> {
|
||||
let hugs = n.layout.is_some() || matches!(n.kind, NodeKind::Text(_));
|
||||
let hug = (hugs && n.child_layout.sizing_h == Sizing::Hug, hugs && n.child_layout.sizing_v == Sizing::Hug);
|
||||
let mut own = css_decls(doc, n, &mut self.tokens, hug);
|
||||
let page = self.ctx.doc.node(self.ctx.root).map_or(f64::INFINITY, |r| r.width);
|
||||
if self.fluid && (n.layout.is_some() || matches!(n.kind, NodeKind::Text(_)) || (!root && n.width >= page * self.fluid_leaves)) {
|
||||
// Full width, or sized by the flex row (`flex: 1`): no cap needed.
|
||||
own = fluid(&own, n.width >= page - 0.5 || decls.contains("flex: 1 1 0;"), n.layout.is_some());
|
||||
}
|
||||
if n.layout.is_none() && !n.children.is_empty() && !root && in_flow {
|
||||
own.push_str("position: relative;\n");
|
||||
}
|
||||
@@ -845,6 +885,28 @@ impl<'c, 'a> Markup<'c, 'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `decls` for a box that follows the page: a fixed width becomes `width: 100%` capped at that
|
||||
/// width (or just the full width for boxes as wide as the page), and a frame's fixed height a
|
||||
/// minimum, so content can wrap and grow.
|
||||
// ponytail: width-based heuristic; fixed-size leaves (icons, images, shapes) keep their pixels.
|
||||
fn fluid(decls: &str, full: bool, frame: bool) -> String {
|
||||
let mut out = String::with_capacity(decls.len() + 32);
|
||||
for line in decls.lines() {
|
||||
if let Some(w) = line.strip_prefix("width: ") {
|
||||
out.push_str("width: 100%;\n");
|
||||
if !full {
|
||||
out.push_str(&format!("max-width: {w}\n"));
|
||||
}
|
||||
} else if let Some(h) = line.strip_prefix("height: ").filter(|_| frame) {
|
||||
out.push_str(&format!("min-height: {h}\n"));
|
||||
} else {
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ SwiftUI
|
||||
|
||||
fn swift_color(c: Color) -> String {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -203,6 +203,18 @@ fn text_on_a_path_left_empty_puts_the_path_back_without_an_undo_step() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaling_text_scales_its_path() {
|
||||
let mut s = Session::new();
|
||||
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})).unwrap());
|
||||
s.execute("text.onPath", &json!({"ids": [t, v]})).unwrap();
|
||||
let w0 = figcraft_geom::Shape::bounding_box(&text_path(&s, t).unwrap().path()).width();
|
||||
s.execute("transform.scale", &json!({"ids": [t], "factor": 2.0})).unwrap();
|
||||
let w1 = figcraft_geom::Shape::bounding_box(&text_path(&s, t).unwrap().path()).width();
|
||||
assert!((w1 - w0 * 2.0).abs() < 1.0, "path width {w0} -> {w1}");
|
||||
}
|
||||
|
||||
#[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).
|
||||
|
||||
@@ -64,16 +64,20 @@ pub fn missing(doc: &Document) -> Vec<Missing> {
|
||||
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<String> {
|
||||
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()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> = 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();
|
||||
|
||||
+26
-59
@@ -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;
|
||||
@@ -60,6 +61,7 @@ pub mod snap;
|
||||
pub mod style_vars;
|
||||
pub mod synthetic;
|
||||
pub mod synthetic_ds;
|
||||
pub mod tailwind;
|
||||
pub mod templates;
|
||||
pub mod textedit;
|
||||
pub mod textfit;
|
||||
@@ -69,6 +71,7 @@ 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;
|
||||
@@ -120,47 +123,6 @@ pub fn bad(cmd: &str, msg: impl Into<String>) -> 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<Value> {
|
||||
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::<Result<Vec<_>>>().map(Value::Array),
|
||||
_ => Ok(v.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// One undo step.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HistoryEntry {
|
||||
@@ -419,7 +381,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);
|
||||
|
||||
@@ -1285,28 +1248,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<Vec<Value>> {
|
||||
/// 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<Vec<Value>> {
|
||||
let mut scope = BatchScope::new(steps)?;
|
||||
self.grouped(label, |s| {
|
||||
let mut results: Vec<Value> = 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<Value> {
|
||||
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
|
||||
@@ -1912,6 +1877,8 @@ mod styles_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod svg_import_tests;
|
||||
#[cfg(test)]
|
||||
mod tailwind_tests;
|
||||
#[cfg(test)]
|
||||
mod templates_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -240,3 +240,17 @@ fn the_scale_tool_only_acts_on_handles() {
|
||||
assert_rect(bounds(&s, r), [10.0, 10.0, 110.0, 60.0]);
|
||||
assert!(near(s.doc().unwrap().node(r).unwrap().width, 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_node_scales_vector_point_corner_radius() {
|
||||
let mut s = Session::new();
|
||||
let v = id_of(&ex(&mut s, "vector.create", json!({"points": [[0, 0], [100, 0], [100, 100], [0, 100]], "closed": true}))["id"]).unwrap();
|
||||
ex(&mut s, "node.set", json!({"id": v, "props": {"cornerRadius": 10}}));
|
||||
ex(&mut s, "transform.scale", json!({"ids": [v], "factor": 2}));
|
||||
let d = s.doc().unwrap();
|
||||
let NodeKind::Vector { network } = &d.node(v).unwrap().kind else { panic!("not a vector") };
|
||||
assert!(!network.vertices.is_empty());
|
||||
for vx in &network.vertices {
|
||||
assert_eq!(vx.radius, 20.0, "corner radius scaled with the layer");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
//! React + Tailwind CSS v4 for a layer tree, ready to drop into a web project: every layer's look
|
||||
//! as utility classes instead of a stylesheet, bound variables as theme utilities (`bg-accent`,
|
||||
//! `rounded-card`) that `variable.export {format: "tailwind"}` declares in an `@theme` block, auto
|
||||
//! layout frames and text that grow with the page (`w-full max-w-[…]`) instead of fixed pixel
|
||||
//! boxes, and instances inlined with their overrides. Given a mobile frame too, the code is mobile
|
||||
//! first: the mobile frame's classes, and the desktop frame's where they differ behind `lg:`.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use figcraft_doc::{Document, NodeId, NodeKind, Variable, VariableId, VariableType};
|
||||
|
||||
use crate::codegen::{CodeLang, Ctx, Markup, css_var_name, css_var_of, num};
|
||||
use crate::component_codegen::component_type_name;
|
||||
use crate::token_roles::{NumberRole, Roles};
|
||||
|
||||
/// The breakpoint desktop classes sit behind in responsive code.
|
||||
// ponytail: one breakpoint (lg = 1024px); a tablet frame would need a second pass and `md:`.
|
||||
const BREAKPOINT: &str = "lg";
|
||||
|
||||
/// The roles and Tailwind theme names of a file's variables, computed once per export so code
|
||||
/// and the `@theme` block always agree.
|
||||
#[derive(Default)]
|
||||
pub struct Theme {
|
||||
roles: Roles,
|
||||
names: HashMap<VariableId, String>,
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
pub fn new(doc: &Document) -> Self {
|
||||
let vars = &doc.variables.variables;
|
||||
let roles = Roles::new(doc);
|
||||
let mut t = Theme { roles, names: HashMap::new() };
|
||||
// Short names ("accent") where they are unique, the full path ("palette-accent") where two
|
||||
// variables would share one, and a numeric suffix as a last resort.
|
||||
let short: Vec<String> = vars.iter().map(|v| t.candidate(v, false)).collect();
|
||||
// Linear in the number of variables: counts for clashes, a set of names taken and the next
|
||||
// suffix per base, so a file with very many variables can't make this quadratic.
|
||||
let mut count: HashMap<&str, usize> = HashMap::new();
|
||||
for n in &short {
|
||||
*count.entry(n.as_str()).or_default() += 1;
|
||||
}
|
||||
let mut taken: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let mut next: HashMap<String, usize> = HashMap::new();
|
||||
for (v, s) in vars.iter().zip(&short) {
|
||||
let base = if count.get(s.as_str()).copied().unwrap_or(0) > 1 { t.candidate(v, true) } else { s.clone() };
|
||||
let mut name = base.clone();
|
||||
if taken.contains(&name) {
|
||||
let k = next.entry(base.clone()).or_insert(1);
|
||||
loop {
|
||||
*k += 1;
|
||||
name = format!("{base}-{k}");
|
||||
if !taken.contains(&name) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
taken.insert(name.clone());
|
||||
t.names.insert(v.id.clone(), name);
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// What a number variable measures; a number nothing tells about is a length.
|
||||
pub fn role(&self, v: &Variable) -> NumberRole {
|
||||
self.roles.role(v)
|
||||
}
|
||||
|
||||
/// The theme variable for `v`, unique in the file (see [`Theme::new`]).
|
||||
pub fn name(&self, v: &Variable) -> String {
|
||||
self.names.get(&v.id).cloned().unwrap_or_else(|| self.candidate(v, false))
|
||||
}
|
||||
|
||||
/// `--color-*` for colours, `--radius-*`, `--text-*`, `--spacing-*` for numbers by role,
|
||||
/// `--font-*` for font families, others their CSS name; from the last path segment, or the
|
||||
/// whole path (`full`). A name already in the namespace isn't prefixed twice.
|
||||
fn candidate(&self, v: &Variable, full: bool) -> String {
|
||||
let ns = match v.kind {
|
||||
VariableType::Color => Some("color"),
|
||||
VariableType::Number => match self.role(v) {
|
||||
NumberRole::Radius => Some("radius"),
|
||||
NumberRole::FontSize => Some("text"),
|
||||
NumberRole::Spacing => Some("spacing"),
|
||||
NumberRole::Length | NumberRole::Unitless => None,
|
||||
},
|
||||
VariableType::String if v.scopes.as_ref().is_some_and(|s| s.iter().any(|s| s == "FONT_FAMILY")) => Some("font"),
|
||||
_ => None,
|
||||
};
|
||||
let Some(ns) = ns else {
|
||||
return if full { css_var_name(&v.name) } else { css_var_of(v) };
|
||||
};
|
||||
let source = if full { v.name.as_str() } else { v.name.rsplit('/').next().unwrap_or(&v.name) };
|
||||
let slug = css_var_name(source);
|
||||
let slug = slug.trim_start_matches('-');
|
||||
let slug = [format!("{ns}s-"), format!("{ns}-")].iter().find_map(|p| slug.strip_prefix(p.as_str())).unwrap_or(slug);
|
||||
// A variable named after its namespace ("radius", "colors") is the namespace's base value.
|
||||
let slug = if slug == ns || slug == format!("{ns}s") { "base" } else { slug };
|
||||
format!("--{ns}-{slug}")
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ declarations → classes
|
||||
|
||||
/// An arbitrary value: spaces become underscores (Tailwind's escape), underscores are escaped.
|
||||
fn arb(v: &str) -> String {
|
||||
format!("[{}]", v.trim().replace('_', "\\_").replace(' ', "_"))
|
||||
}
|
||||
|
||||
/// The custom property a `var(--name, fallback)` value reads, when that one call is the whole
|
||||
/// value (not `var(--a, 4px) 8px`, four corners of which only the first is a variable).
|
||||
fn var_name(v: &str) -> Option<&str> {
|
||||
let inner = v.trim().strip_prefix("var(")?;
|
||||
let mut depth = 1usize;
|
||||
let close = inner.char_indices().find_map(|(i, c)| {
|
||||
match c {
|
||||
'(' => depth += 1,
|
||||
')' => depth -= 1,
|
||||
_ => {}
|
||||
}
|
||||
(depth == 0).then_some(i)
|
||||
})?;
|
||||
if close + 1 != inner.len() {
|
||||
return None;
|
||||
}
|
||||
Some(inner.split([',', ')']).next()?.trim())
|
||||
}
|
||||
|
||||
/// `util` with `value`: a theme utility when the value reads a variable of the namespace
|
||||
/// (`bg-accent`), else the variable in parentheses (`bg-(--x)`, with a type hint for `text-`
|
||||
/// sizes), else an arbitrary value.
|
||||
fn util(util: &str, ns: &str, value: &str, hint: &str) -> String {
|
||||
match var_name(value) {
|
||||
Some(name) => match name.strip_prefix(&format!("--{ns}-")) {
|
||||
Some(key) if !ns.is_empty() => format!("{util}-{key}"),
|
||||
_ => format!("{util}-({hint}{name})"),
|
||||
},
|
||||
None => format!("{util}-{}", arb(value)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A length on the spacing scale (4px steps) when it fits one, else arbitrary.
|
||||
fn spacing(prefix: &str, value: &str) -> String {
|
||||
if var_name(value).is_some() {
|
||||
return util(prefix, "spacing", value, "");
|
||||
}
|
||||
match value.strip_suffix("px").and_then(|n| n.parse::<f64>().ok()) {
|
||||
Some(0.0) => format!("{prefix}-0"),
|
||||
Some(px) if px > 0.0 && px <= 384.0 && (px / 4.0).fract() == 0.0 => format!("{prefix}-{}", num(px / 4.0)),
|
||||
Some(px) if px < 0.0 && (px / 4.0).fract() == 0.0 => format!("-{prefix}-{}", num(-px / 4.0)),
|
||||
_ => format!("{prefix}-{}", arb(value)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The values of a `padding` shorthand (top right bottom left), as utilities.
|
||||
fn padding(value: &str) -> Vec<(String, String)> {
|
||||
let mut parts = Vec::new();
|
||||
let mut depth = 0usize;
|
||||
let mut cur = String::new();
|
||||
for c in value.chars() {
|
||||
match c {
|
||||
'(' => depth += 1,
|
||||
')' => depth = depth.saturating_sub(1),
|
||||
' ' if depth == 0 => {
|
||||
parts.push(std::mem::take(&mut cur));
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
cur.push(c);
|
||||
}
|
||||
parts.push(cur);
|
||||
let [t, r, b, l] = match parts.as_slice() {
|
||||
[a] => [a, a, a, a],
|
||||
[t, r, b, l] => [t, r, b, l],
|
||||
_ => return vec![("padding".into(), format!("p-{}", arb(value)))],
|
||||
};
|
||||
let zero = |v: &str| v == "0px" || v == "0";
|
||||
let mut out = Vec::new();
|
||||
if t == r && r == b && b == l {
|
||||
if !zero(t) {
|
||||
out.push(("padding".into(), spacing("p", t)));
|
||||
}
|
||||
} else if t == b && l == r {
|
||||
for (k, p, v) in [("padding-y", "py", t), ("padding-x", "px", l)] {
|
||||
if !zero(v) {
|
||||
out.push((k.into(), spacing(p, v)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (k, p, v) in [("padding-top", "pt", t), ("padding-right", "pr", r), ("padding-bottom", "pb", b), ("padding-left", "pl", l)] {
|
||||
if !zero(v) {
|
||||
out.push((k.into(), spacing(p, v)));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn font_weight(v: &str) -> String {
|
||||
let named = match v.trim() {
|
||||
"100" => "thin",
|
||||
"200" => "extralight",
|
||||
"300" => "light",
|
||||
"400" => "normal",
|
||||
"500" => "medium",
|
||||
"600" => "semibold",
|
||||
"700" => "bold",
|
||||
"800" => "extrabold",
|
||||
"900" => "black",
|
||||
_ => return format!("font-{}", arb(v)),
|
||||
};
|
||||
format!("font-{named}")
|
||||
}
|
||||
|
||||
/// A border shorthand (`1px solid c`) on `side` ("" or "t", "r", "b", "l") as width + colour.
|
||||
fn border(side: &str, value: &str) -> Vec<(String, String)> {
|
||||
// `<width> solid <colour>`, where the width may be `var(--w, 2px)`: split at the style.
|
||||
let (width, color) = value.split_once(" solid ").unwrap_or((value, ""));
|
||||
let (width, color) = (width.trim(), color.trim());
|
||||
let b = if side.is_empty() { "border".to_string() } else { format!("border-{side}") };
|
||||
let key = format!("border{}", if side.is_empty() { String::new() } else { format!("-{side}") });
|
||||
let w = match width {
|
||||
"1px" => b.clone(),
|
||||
"0px" => format!("{b}-0"),
|
||||
w => match var_name(w) {
|
||||
// `border-(--w)` would read as a colour; the hint makes it a width.
|
||||
Some(name) => format!("{b}-(length:{name})"),
|
||||
None => format!("{b}-{}", arb(w)),
|
||||
},
|
||||
};
|
||||
let mut out = vec![(format!("{key}-width"), w)];
|
||||
if !color.is_empty() {
|
||||
out.push(("border-color".into(), util("border", "color", color, "")));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One CSS declaration as utilities, keyed by the property they set (so responsive code can tell
|
||||
/// which mobile class a desktop class replaces).
|
||||
fn decl(key: &str, value: &str) -> Vec<(String, String)> {
|
||||
let one = |c: String| vec![(key.to_string(), c)];
|
||||
let kw = |c: &str| vec![(key.to_string(), c.to_string())];
|
||||
match (key, value) {
|
||||
("display", "flex") => kw("flex"),
|
||||
("display", "block") => kw("block"),
|
||||
("flex-direction", "column") => kw("flex-col"),
|
||||
("flex-direction", _) => kw("flex-row"),
|
||||
("flex-wrap", "wrap") => kw("flex-wrap"),
|
||||
// Defaults flexbox already has.
|
||||
("justify-content", "flex-start") | ("gap", "0px") => Vec::new(),
|
||||
("justify-content", v) => one(format!("justify-{}", v.trim_start_matches("flex-").trim_start_matches("space-"))),
|
||||
("align-items", v) => one(format!("items-{}", v.trim_start_matches("flex-"))),
|
||||
("align-content", v) => one(format!("content-{}", v.trim_start_matches("flex-").trim_start_matches("space-"))),
|
||||
("align-self", "stretch") => kw("self-stretch"),
|
||||
("flex", "1 1 0") => kw("flex-1"),
|
||||
("position", v) => one(v.to_string()),
|
||||
("left" | "top" | "right" | "bottom", v) => one(spacing(key, v)),
|
||||
("width", "100%") => kw("w-full"),
|
||||
("width", v) => one(spacing("w", v)),
|
||||
("height", v) => one(spacing("h", v)),
|
||||
("max-width", v) => one(format!("max-w-{}", arb(v))),
|
||||
("min-height", v) => one(format!("min-h-{}", arb(v))),
|
||||
("margin-inline", "auto") => kw("mx-auto"),
|
||||
("gap", v) => one(spacing("gap", v)),
|
||||
("padding", v) => padding(v),
|
||||
("border-radius", "50%") => kw("rounded-full"),
|
||||
("border-radius", v) if v.strip_suffix("px").and_then(|n| n.parse::<f64>().ok()).is_some_and(|r| r >= 999.0) => kw("rounded-full"),
|
||||
("border-radius", v) if v.contains(' ') && var_name(v).is_none() => one(format!("rounded-{}", arb(v))),
|
||||
("border-radius", v) => one(util("rounded", "radius", v, "")),
|
||||
("opacity", v) => match v.parse::<f64>() {
|
||||
Ok(o) if ((o * 100.0).round() - o * 100.0).abs() < 1e-9 => one(format!("opacity-{}", num(o * 100.0))),
|
||||
_ => one(util("opacity", "", v, "")),
|
||||
},
|
||||
("background", v) if v.contains("gradient(") => one(format!("bg-{}", arb(v))),
|
||||
// An image shorthand (`url(…) center / cover`) is a whole `background`, not a bg colour.
|
||||
("background", v) if v.starts_with("url(") => one(format!("[background:{}]", arb(v).trim_matches(['[', ']']))),
|
||||
("background", v) => one(util("bg", "color", v, "color:")),
|
||||
("color", v) => one(util("text", "color", v, "color:")),
|
||||
("border", v) => border("", v),
|
||||
("border-top", v) => border("t", v),
|
||||
("border-right", v) => border("r", v),
|
||||
("border-bottom", v) => border("b", v),
|
||||
("border-left", v) => border("l", v),
|
||||
("box-shadow", v) => one(format!("shadow-{}", arb(v))),
|
||||
("filter", v) => one(match v.strip_prefix("blur(").and_then(|r| r.strip_suffix(')')) {
|
||||
Some(r) => format!("blur-{}", arb(r)),
|
||||
None => format!("[filter:{}]", arb(v).trim_matches(['[', ']'])),
|
||||
}),
|
||||
("backdrop-filter", v) => one(match v.strip_prefix("blur(").and_then(|r| r.strip_suffix(')')) {
|
||||
Some(r) => format!("backdrop-blur-{}", arb(r)),
|
||||
None => format!("[backdrop-filter:{}]", arb(v).trim_matches(['[', ']'])),
|
||||
}),
|
||||
("mix-blend-mode", v) => one(format!("mix-blend-{v}")),
|
||||
("transform", v) => match v.strip_prefix("rotate(").and_then(|r| r.strip_suffix(')')) {
|
||||
Some(r) => one(format!("rotate-{}", arb(r))),
|
||||
None => one(format!("[transform:{}]", arb(v).trim_matches(['[', ']']))),
|
||||
},
|
||||
("transform-origin", "top left" | "0 0") => kw("origin-top-left"),
|
||||
// `font-(--x)` would read as a weight; the hint makes an un-namespaced variable a family.
|
||||
("font-family", v) if var_name(v).is_some() => one(util("font", "font", v, "family-name:")),
|
||||
("font-family", v) => one(format!("font-{}", arb(&format!("'{}'", v.trim_matches('"'))))),
|
||||
("font-size", v) => one(util("text", "text", v, "length:")),
|
||||
("font-weight", v) => one(font_weight(v)),
|
||||
("font-style", "italic") => kw("italic"),
|
||||
("line-height", v) => match v.strip_suffix('%').and_then(|p| p.parse::<f64>().ok()) {
|
||||
Some(p) => one(format!("leading-{}", arb(&num(p / 100.0)))),
|
||||
None => one(util("leading", "", v, "")),
|
||||
},
|
||||
("letter-spacing", v) => one(util("tracking", "", v, "")),
|
||||
("text-align", v) => one(format!("text-{v}")),
|
||||
("text-transform", "uppercase") => kw("uppercase"),
|
||||
("text-transform", "lowercase") => kw("lowercase"),
|
||||
("text-transform", "capitalize") => kw("capitalize"),
|
||||
("text-decoration", "underline") => kw("underline"),
|
||||
("text-decoration", "line-through") => kw("line-through"),
|
||||
_ => one(format!("[{key}:{}]", arb(value).trim_matches(['[', ']']))),
|
||||
}
|
||||
}
|
||||
|
||||
/// A rule's declarations (`key: value;` lines) as (property, utility) pairs in order.
|
||||
pub fn classes(decls: &str) -> Vec<(String, String)> {
|
||||
decls
|
||||
.lines()
|
||||
.filter_map(|l| {
|
||||
let (k, v) = l.trim().trim_end_matches(';').split_once(':')?;
|
||||
Some(decl(k.trim(), v.trim()))
|
||||
})
|
||||
.flatten()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The utility that undoes a mobile class at the breakpoint when desktop sets nothing for it.
|
||||
fn reset(key: &str) -> Option<String> {
|
||||
// Border widths by side: `border-t-width` → `border-t-0`.
|
||||
if let Some(side) = key.strip_prefix("border").and_then(|k| k.strip_suffix("-width")) {
|
||||
return Some(format!("border{side}-0"));
|
||||
}
|
||||
Some(String::from(match key {
|
||||
"display" => "block",
|
||||
"flex-direction" => "flex-row",
|
||||
"flex-wrap" => "flex-nowrap",
|
||||
"flex" => "flex-initial",
|
||||
"align-self" => "self-auto",
|
||||
"width" => "w-auto",
|
||||
"height" => "h-auto",
|
||||
"max-width" => "max-w-none",
|
||||
"min-height" => "min-h-0",
|
||||
"text-align" => "text-left",
|
||||
"justify-content" => "justify-start",
|
||||
"gap" => "gap-0",
|
||||
"position" => "static",
|
||||
"text-transform" => "normal-case",
|
||||
"opacity" => "opacity-100",
|
||||
"border-radius" => "rounded-none",
|
||||
"box-shadow" => "shadow-none",
|
||||
"font-style" => "not-italic",
|
||||
// The whole background: a mobile image or gradient would survive a colour-only reset.
|
||||
"background" => "bg-none bg-transparent",
|
||||
"border-color" => "border-current",
|
||||
"letter-spacing" => "tracking-normal",
|
||||
"line-height" => "leading-normal",
|
||||
"text-decoration" => "no-underline",
|
||||
"align-items" => "items-stretch",
|
||||
"filter" => "filter-none",
|
||||
"backdrop-filter" => "backdrop-filter-none",
|
||||
"transform" => "rotate-0",
|
||||
"left" | "top" | "right" | "bottom" => return Some(format!("{key}-auto")),
|
||||
"mix-blend-mode" => "mix-blend-normal",
|
||||
_ => return None,
|
||||
}))
|
||||
}
|
||||
|
||||
/// The sides (top, right, bottom, left) a padding utility's property sets.
|
||||
fn padding_sides(key: &str) -> [bool; 4] {
|
||||
match key {
|
||||
"padding" => [true; 4],
|
||||
"padding-x" => [false, true, false, true],
|
||||
"padding-y" => [true, false, true, false],
|
||||
"padding-top" => [true, false, false, false],
|
||||
"padding-right" => [false, true, false, false],
|
||||
"padding-bottom" => [false, false, true, false],
|
||||
"padding-left" => [false, false, false, true],
|
||||
_ => [false; 4],
|
||||
}
|
||||
}
|
||||
|
||||
/// Utilities zeroing `sides` (as few as cover them: `py-0`, `px-0`, else per side).
|
||||
fn zero_padding(sides: [bool; 4]) -> Vec<&'static str> {
|
||||
let [t, r, b, l] = sides;
|
||||
let mut out = Vec::new();
|
||||
match (t, b) {
|
||||
(true, true) => out.push("py-0"),
|
||||
(true, false) => out.push("pt-0"),
|
||||
(false, true) => out.push("pb-0"),
|
||||
_ => {}
|
||||
}
|
||||
match (r, l) {
|
||||
(true, true) => out.push("px-0"),
|
||||
(true, false) => out.push("pr-0"),
|
||||
(false, true) => out.push("pl-0"),
|
||||
_ => {}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Mobile classes plus, behind the breakpoint, the desktop ones that differ (and resets for what
|
||||
/// desktop leaves unset). A layer missing on mobile is hidden there.
|
||||
pub(crate) fn merge(mobile: Option<&[(String, String)]>, desktop: &[(String, String)]) -> Vec<String> {
|
||||
let hidden = [("display".to_string(), "hidden".to_string())];
|
||||
// A mobile frame's widths are the phone's, not caps: below the breakpoint boxes fill the page.
|
||||
let fluid: Vec<(String, String)>;
|
||||
let mobile = match mobile {
|
||||
Some(m) => {
|
||||
fluid = m.iter().filter(|(k, _)| k != "max-width").cloned().collect();
|
||||
fluid.as_slice()
|
||||
}
|
||||
None => &hidden,
|
||||
};
|
||||
let mut out: Vec<String> = mobile.iter().map(|(_, c)| c.clone()).collect();
|
||||
let bp = |c: &str| format!("{BREAKPOINT}:{c}");
|
||||
for (_, c) in desktop {
|
||||
if !mobile.iter().any(|(_, m)| m == c) {
|
||||
out.push(bp(c));
|
||||
}
|
||||
}
|
||||
for (k, _) in mobile.iter().filter(|(k, _)| !k.starts_with("padding")) {
|
||||
if !desktop.iter().any(|(d, _)| d == k)
|
||||
&& let Some(r) = reset(k)
|
||||
{
|
||||
out.extend(r.split_whitespace().map(bp));
|
||||
}
|
||||
}
|
||||
// Padding utilities overlap (p, px/py, pt…): compare side by side, and zero on desktop the
|
||||
// sides mobile pads and desktop doesn't.
|
||||
let cover = |list: &[(String, String)]| {
|
||||
list.iter().fold([false; 4], |mut acc, (k, _)| {
|
||||
for (a, s) in acc.iter_mut().zip(padding_sides(k)) {
|
||||
*a |= s;
|
||||
}
|
||||
acc
|
||||
})
|
||||
};
|
||||
let (m, d) = (cover(mobile), cover(desktop));
|
||||
let missing = [m[0] && !d[0], m[1] && !d[1], m[2] && !d[2], m[3] && !d[3]];
|
||||
out.extend(zero_padding(missing).into_iter().map(bp));
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
/// The markup walk for `id` in Tailwind mode: (markup, rules by path).
|
||||
fn walk(doc: &Document, id: NodeId) -> (String, Vec<(String, String, String)>) {
|
||||
let ctx = Ctx::new(doc, id);
|
||||
let mut m = Markup::new(&ctx, CodeLang::React);
|
||||
m.inline = true;
|
||||
m.fluid = true;
|
||||
m.fluid_leaves = 0.5;
|
||||
m.tokens.tailwind = true;
|
||||
m.node(id, None, 2, "");
|
||||
(m.markup, m.rules)
|
||||
}
|
||||
|
||||
/// React + Tailwind for `id` (with `mobile`, mobile first with desktop classes behind `lg:`).
|
||||
pub fn code(doc: &Document, id: NodeId, mobile: Option<NodeId>) -> String {
|
||||
let Some(n) = doc.node(id) else { return String::new() };
|
||||
let (mut markup, rules) = walk(doc, id);
|
||||
let small: Option<BTreeMap<String, Vec<(String, String)>>> =
|
||||
mobile.filter(|m| doc.contains(*m) && *m != id).map(|m| walk(doc, m).1.into_iter().map(|(p, _, d)| (p, classes(&d))).collect());
|
||||
// ponytail: layers only on mobile are listed, not merged in; their place in the tree is ambiguous.
|
||||
let only_mobile: Vec<String> = small
|
||||
.iter()
|
||||
.flat_map(BTreeMap::keys)
|
||||
.filter(|p| !rules.iter().any(|(d, _, _)| d == *p))
|
||||
.map(|p| p.trim_start_matches('/').split('/').map(|seg| seg.rsplit_once('#').map_or(seg, |(n, _)| n)).collect::<Vec<_>>().join(" › "))
|
||||
.take(20)
|
||||
.collect();
|
||||
// Longest class names first, so "card" never rewrites inside "card-2"'s attribute.
|
||||
let mut rules = rules;
|
||||
rules.sort_by_key(|(_, c, _)| std::cmp::Reverse(c.len()));
|
||||
let paths: Vec<String> = rules.iter().map(|(p, _, _)| p.clone()).collect();
|
||||
for (path, class, decls) in &rules {
|
||||
let own = classes(decls);
|
||||
// Layer names may contain "/": the parent is the longest other path this one extends.
|
||||
let parent =
|
||||
paths.iter().filter(|p| p.len() < path.len() && path.starts_with(&format!("{p}/"))).max_by_key(|p| p.len()).map_or("", String::as_str);
|
||||
let list = match &small {
|
||||
// Inside a layer hidden on mobile: desktop's classes as they are.
|
||||
Some(m) if !path.is_empty() && !m.contains_key(parent) => own.into_iter().map(|(_, c)| c).collect(),
|
||||
Some(m) => merge(m.get(path).map(Vec::as_slice), &own),
|
||||
None => own.into_iter().map(|(_, c)| c).collect(),
|
||||
};
|
||||
markup = markup.replace(&format!("className=\"{class}\""), &format!("className=\"{}\"", list.join(" ")));
|
||||
}
|
||||
// Imports of the instances written as their connected code component, once each.
|
||||
let mut imports: Vec<String> = Vec::new();
|
||||
for i in doc.subtree(id).into_iter().take(20_000) {
|
||||
if i != id
|
||||
&& doc.node(i).is_some_and(|k| matches!(k.kind, NodeKind::Instance { .. }))
|
||||
&& let Some(l) = crate::code_connect::link(doc, i, CodeLang::React)
|
||||
{
|
||||
let line = crate::code_connect::import_line(l, CodeLang::React);
|
||||
if !line.is_empty() && !imports.contains(&line) {
|
||||
imports.push(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
let imports = if imports.is_empty() { String::new() } else { format!("{}\n\n", imports.join("\n")) };
|
||||
let name = component_type_name(&n.name);
|
||||
let mut mode = if small.is_some() { format!(", mobile first; desktop from {BREAKPOINT}") } else { String::new() };
|
||||
if !only_mobile.is_empty() {
|
||||
mode.push_str(&format!(". Only in the mobile frame, add by hand with {BREAKPOINT}:hidden: {}", only_mobile.join(", ")));
|
||||
}
|
||||
let header = comment_safe(&format!("{} as React + Tailwind CSS v4{mode}. Theme: variable.export {{format: \"tailwind\"}}.", n.name));
|
||||
format!("// {header}\n{imports}export function {name}() {{\n return (\n{markup} );\n}}\n")
|
||||
}
|
||||
|
||||
/// `text` for one `//` comment line: every JS line terminator (control characters, U+2028,
|
||||
/// U+2029) becomes a space, so layer names in it can't end the comment and inject code.
|
||||
fn comment_safe(text: &str) -> String {
|
||||
text.chars().take(2_000).map(|c| if c.is_control() || matches!(c, '\u{2028}' | '\u{2029}') { ' ' } else { c }).collect()
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
//! React + Tailwind code for pages: utilities instead of a stylesheet, theme utilities for bound
|
||||
//! variables, fluid boxes, inlined instances, per-side borders, mobile-first responsive merges and
|
||||
//! the Tailwind theme export.
|
||||
|
||||
use crate::Session;
|
||||
use crate::components::tests::{ex, idv};
|
||||
use crate::tailwind::{classes, merge};
|
||||
use figcraft_doc::NodeId;
|
||||
use serde_json::json;
|
||||
|
||||
fn code(s: &mut Session, id: NodeId, extra: serde_json::Value) -> String {
|
||||
let mut p = json!({"id": id, "language": "tailwind"});
|
||||
if let (Some(p), Some(e)) = (p.as_object_mut(), extra.as_object()) {
|
||||
p.extend(e.clone());
|
||||
}
|
||||
ex(s, "inspect.code", p)["code"].as_str().unwrap_or_default().to_string()
|
||||
}
|
||||
|
||||
/// A 1440 "Page" (vertical auto layout, bg bound to Brand/Accent) holding a 960 "Hero" text and a
|
||||
/// horizontal "Row" of two "Card" frames that fill it. Returns (page, row).
|
||||
fn page(s: &mut Session, name: &str, width: f64, row_dir: &str) -> (NodeId, NodeId) {
|
||||
let p = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": width, "height": 900, "name": name})));
|
||||
ex(s, "layout.add", json!({"ids": [p], "direction": "vertical"}));
|
||||
ex(s, "node.set", json!({"id": p, "props": {"gap": 24, "padding": 32}}));
|
||||
let t = idv(&ex(s, "text.create", json!({"text": "Seu site em 7 dias", "x": 0, "y": 0, "parent": p, "props": {"name": "Hero"}})));
|
||||
ex(s, "node.set", json!({"id": t, "props": {"width": 960, "textAutoResize": "height"}}));
|
||||
let row = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 800, "height": 200, "parent": p, "name": "Row"})));
|
||||
ex(s, "layout.add", json!({"ids": [row], "direction": row_dir}));
|
||||
for _ in 0..2 {
|
||||
let c = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100, "parent": row, "name": "Card"})));
|
||||
ex(s, "node.set", json!({"id": c, "props": {"sizingH": "fill"}}));
|
||||
}
|
||||
(p, row)
|
||||
}
|
||||
|
||||
fn brand(s: &mut Session) {
|
||||
ex(s, "variable.createCollection", json!({"name": "Brand"}));
|
||||
ex(s, "variable.create", json!({"collection": "Brand", "name": "Brand/Accent", "type": "color", "value": "#3DDC97"}));
|
||||
ex(s, "variable.create", json!({"collection": "Brand", "name": "card", "type": "number", "value": 12}));
|
||||
ex(s, "variable.create", json!({"collection": "Brand", "name": "fade", "type": "number", "value": 0.5}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utilities_replace_the_stylesheet_and_variables_become_theme_utilities() {
|
||||
let mut s = Session::new();
|
||||
brand(&mut s);
|
||||
let (p, row) = page(&mut s, "Landing", 1440.0, "horizontal");
|
||||
ex(&mut s, "variable.bind", json!({"ids": [p], "field": "fill", "variable": "Brand/Accent"}));
|
||||
ex(&mut s, "node.set", json!({"id": row, "props": {"cornerRadius": 12}}));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [row], "field": "cornerRadius", "variable": "card"}));
|
||||
let out = code(&mut s, p, json!({}));
|
||||
assert!(out.contains("export function Landing() {"), "{out}");
|
||||
assert!(!out.contains("import") && !out.contains("<style") && !out.contains(".css"), "no stylesheet: {out}");
|
||||
for want in ["flex flex-col", "gap-6", "p-8", "bg-accent", "rounded-card", "flex-1", "w-full"] {
|
||||
assert!(out.contains(want), "{want} in {out}");
|
||||
}
|
||||
// The page is as wide as itself: just full width; the 960 text is capped, not fixed.
|
||||
assert!(out.contains("max-w-[960px]") && !out.contains("w-[1440px]") && !out.contains("max-w-[1440px]"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_side_strokes_are_borders_on_those_sides() {
|
||||
let mut s = Session::new();
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 60, "name": "Nav"})));
|
||||
ex(&mut s, "node.set", json!({"id": f, "props": {"strokes": ["#262A31"], "strokeWeight": 1, "strokeSides": [0, 0, 1, 0]}}));
|
||||
let css = ex(&mut s, "inspect.code", json!({"id": f, "language": "css"}))["code"].as_str().unwrap_or_default().to_string();
|
||||
assert!(css.contains("border-bottom: 1px solid") && !css.contains("border:"), "{css}");
|
||||
let tw = code(&mut s, f, json!({}));
|
||||
assert!(tw.contains("border-b ") && (tw.contains("border-[#262a31]") || tw.contains("border-[#262A31]")), "{tw}");
|
||||
// Without per-side weights it is one border.
|
||||
ex(&mut s, "node.set", json!({"id": f, "props": {"strokeSides": null, "strokeWeight": 2}}));
|
||||
let css = ex(&mut s, "inspect.code", json!({"id": f, "language": "css"}))["code"].as_str().unwrap_or_default().to_string();
|
||||
assert!(css.contains("border: 2px solid"), "{css}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instances_are_inlined_with_their_overrides() {
|
||||
let mut s = Session::new();
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Benefit"})));
|
||||
ex(&mut s, "layout.add", json!({"ids": [f], "direction": "vertical"}));
|
||||
ex(&mut s, "text.create", json!({"text": "Original", "x": 0, "y": 0, "parent": f, "props": {"name": "Title"}}));
|
||||
let comp = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
|
||||
let holder = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 400, "width": 600, "height": 200, "name": "Benefits"})));
|
||||
ex(&mut s, "layout.add", json!({"ids": [holder], "direction": "horizontal"}));
|
||||
let inst = idv(&ex(&mut s, "instance.create", json!({"component": comp, "x": 0, "y": 0, "parent": holder})));
|
||||
let doc = s.doc().unwrap();
|
||||
let title = doc.node(inst).unwrap().children[0];
|
||||
ex(&mut s, "text.set", json!({"id": title, "characters": "Agenda sozinha"}));
|
||||
let out = code(&mut s, holder, json!({}));
|
||||
assert!(out.contains("Agenda sozinha") && !out.contains("<Benefit"), "inlined with override: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mobile_frame_makes_the_code_mobile_first() {
|
||||
let mut s = Session::new();
|
||||
let (desk, _) = page(&mut s, "Landing", 1440.0, "horizontal");
|
||||
let (mob, _) = page(&mut s, "Landing mobile", 390.0, "vertical");
|
||||
// A layer only on desktop is hidden on mobile.
|
||||
ex(&mut s, "text.create", json!({"text": "Só no desktop", "x": 0, "y": 0, "parent": desk, "props": {"name": "Aside"}}));
|
||||
let out = code(&mut s, desk, json!({"mobile": mob}));
|
||||
assert!(out.contains("mobile first"), "{out}");
|
||||
assert!(out.contains("flex flex-col") && out.contains("lg:flex-row"), "row stacks on mobile: {out}");
|
||||
assert!(out.contains("hidden") && out.contains("lg:block"), "desktop-only layer: {out}");
|
||||
assert!(s.execute("inspect.code", &json!({"id": desk, "language": "tailwind", "mobile": 999_999})).is_err(), "a missing mobile frame is refused");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declarations_map_to_idiomatic_utilities() {
|
||||
let got: Vec<String> = classes(
|
||||
"display: flex;\njustify-content: space-between;\nalign-items: center;\npadding: 8px 16px 8px 16px;\nfont-weight: 600;\nfont-size: 18px;\nline-height: 120%;\nopacity: 0.5;\ncolor: var(--color-muted, #9aa3ae);\nbox-shadow: 0px 4px 12px 0px rgba(0, 0, 0, 0.25);\nwhite-space: nowrap;\n",
|
||||
)
|
||||
.into_iter()
|
||||
.map(|(_, c)| c)
|
||||
.collect();
|
||||
let want = [
|
||||
"flex",
|
||||
"justify-between",
|
||||
"items-center",
|
||||
"py-2",
|
||||
"px-4",
|
||||
"font-semibold",
|
||||
"text-[18px]",
|
||||
"leading-[1.2]",
|
||||
"opacity-50",
|
||||
"text-muted",
|
||||
"shadow-[0px_4px_12px_0px_rgba(0,_0,_0,_0.25)]",
|
||||
"[white-space:nowrap]",
|
||||
];
|
||||
assert_eq!(got, want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variables_export_with_units_and_as_a_tailwind_theme() {
|
||||
let mut s = Session::new();
|
||||
brand(&mut s);
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "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"}));
|
||||
let css = ex(&mut s, "variable.export", json!({"format": "css"}))["text"].as_str().unwrap_or_default().to_string();
|
||||
assert!(css.contains("--card: 12px;") && css.contains("--fade: 0.5;"), "{css}");
|
||||
let tw = ex(&mut s, "variable.export", json!({"format": "tailwind"}))["text"].as_str().unwrap_or_default().to_string();
|
||||
for want in ["@import \"tailwindcss\";", "@theme static {", "--color-accent: #3ddc97;", "--radius-card: 12px;", "--fade: 0.5;"] {
|
||||
assert!(tw.to_lowercase().contains(&want.to_lowercase()), "{want} in {tw}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_padding_on_other_sides_is_not_reset_away() {
|
||||
let mut s = Session::new();
|
||||
let (desk, _) = page(&mut s, "Landing", 1440.0, "horizontal");
|
||||
let (mob, _) = page(&mut s, "Landing mobile", 390.0, "vertical");
|
||||
ex(&mut s, "node.set", json!({"id": desk, "props": {"paddingTop": 24, "paddingBottom": 24, "paddingLeft": 64, "paddingRight": 64}}));
|
||||
ex(&mut s, "node.set", json!({"id": mob, "props": {"padding": 24}}));
|
||||
let out = code(&mut s, desk, json!({"mobile": mob}));
|
||||
assert!(out.contains("p-6 ") && out.contains("lg:py-6 lg:px-16"), "{out}");
|
||||
assert!(!out.contains("lg:p-0"), "a reset would undo lg:px-16: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_name_cant_break_out_of_the_header_comment() {
|
||||
let mut s = Session::new();
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100, "name": "A\rB\u{2028}alert(1)\u{2029}C\nD"})));
|
||||
let out = code(&mut s, f, json!({}));
|
||||
let header = out.lines().next().unwrap_or_default();
|
||||
assert!(header.starts_with("// ") && header.contains("alert(1)"), "{out}");
|
||||
assert!(!out.contains(['\r', '\u{2028}', '\u{2029}']), "{out:?}");
|
||||
let name = out.lines().nth(1).and_then(|l| l.strip_prefix("export function ")).and_then(|l| l.strip_suffix("() {")).unwrap_or_default();
|
||||
assert!(!name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'), "an identifier: {name:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slashed_layer_names_and_phone_widths_merge_right() {
|
||||
let mut s = Session::new();
|
||||
let mut frames = Vec::new();
|
||||
for (name, width, dir, sizing) in [("Landing", 1440.0, "horizontal", "hug"), ("Landing mobile", 390.0, "vertical", "fill")] {
|
||||
let p = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": width, "height": 600, "name": name})));
|
||||
ex(&mut s, "layout.add", json!({"ids": [p], "direction": "vertical"}));
|
||||
let ctas = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 342, "height": 120, "parent": p, "name": "CTAs"})));
|
||||
ex(&mut s, "layout.add", json!({"ids": [ctas], "direction": dir}));
|
||||
let b = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 48, "parent": ctas, "name": "Button/Primary"})));
|
||||
ex(&mut s, "layout.add", json!({"ids": [b], "direction": "horizontal"}));
|
||||
ex(&mut s, "node.set", json!({"id": b, "props": {"sizingH": sizing}}));
|
||||
ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": width * 0.8, "height": 1, "parent": p, "props": {"name": "Divider"}}));
|
||||
frames.push(p);
|
||||
}
|
||||
// A layer only the mobile frame has.
|
||||
ex(&mut s, "text.create", json!({"text": "7 dias", "x": 0, "y": 0, "parent": frames[1], "props": {"name": "Tag"}}));
|
||||
let out = code(&mut s, frames[0], json!({"mobile": frames[1]}));
|
||||
let button =
|
||||
out.lines().find(|l| l.contains("lg:") && l.contains("rounded") || l.contains("self-stretch") && l.contains("flex-row")).unwrap_or_default();
|
||||
assert!(button.contains("self-stretch") && button.contains("lg:self-auto"), "the slashed name keeps its mobile classes: {out}");
|
||||
assert!(!out.contains("max-w-[342px] ") || out.contains("lg:max-w-[342px]"), "no phone-width cap below lg: {out}");
|
||||
assert!(out.contains("Only in the mobile frame") && out.contains("Tag"), "{out}");
|
||||
let divider = out.lines().filter(|l| l.contains("<div") && (l.contains("h-[1px]") || l.contains("h-px"))).count();
|
||||
assert!(out.contains("w-full") && divider <= 2, "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_variable_named_after_its_namespace_is_its_base() {
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "Brand"}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "radius", "type": "number", "value": 12}));
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
|
||||
ex(&mut s, "node.set", json!({"id": f, "props": {"cornerRadius": 12}}));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "cornerRadius", "variable": "radius"}));
|
||||
assert!(code(&mut s, f, json!({})).contains("rounded-base"));
|
||||
let tw = ex(&mut s, "variable.export", json!({"format": "tailwind"}))["text"].as_str().unwrap_or_default().to_string();
|
||||
assert!(tw.contains("--radius-base: 12px;") && !tw.contains("--radius-radius"), "{tw}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mobile_only_layer_names_cant_break_out_of_the_header_comment() {
|
||||
let mut s = Session::new();
|
||||
let (desk, _) = page(&mut s, "Landing", 1440.0, "horizontal");
|
||||
let (mob, _) = page(&mut s, "Landing mobile", 390.0, "vertical");
|
||||
ex(&mut s, "text.create", json!({"text": "x", "x": 0, "y": 0, "parent": mob, "props": {"name": "Tag\nalert(1)\u{2028}y"}}));
|
||||
let out = code(&mut s, desk, json!({"mobile": mob}));
|
||||
let header = out.lines().next().unwrap_or_default();
|
||||
assert!(header.starts_with("// ") && header.contains("alert(1)"), "listed inside the comment: {out}");
|
||||
assert!(out.lines().nth(1).is_some_and(|l| l.starts_with("export function ")), "{out}");
|
||||
assert!(!out.contains(['\r', '\u{2028}', '\u{2029}']), "{out:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_code_leaves_resets_to_tailwind_preflight() {
|
||||
let mut s = Session::new();
|
||||
let (p, _) = page(&mut s, "Landing", 1440.0, "horizontal");
|
||||
let out = code(&mut s, p, json!({}));
|
||||
for leak in ["box-sizing", "[margin:0]", "text-decoration", "cursor"] {
|
||||
assert!(!out.contains(leak), "{leak} in {out}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instances_connected_to_code_are_their_snippet_with_one_import() {
|
||||
let mut s = Session::new();
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 160, "height": 44, "name": "Button"})));
|
||||
ex(&mut s, "layout.add", json!({"ids": [f], "direction": "horizontal"}));
|
||||
let label = idv(&ex(&mut s, "text.create", json!({"text": "Botão", "x": 0, "y": 0, "parent": f, "props": {"name": "Label"}})));
|
||||
let comp = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
|
||||
ex(&mut s, "component.addProperty", json!({"id": comp, "name": "Label", "type": "text", "layer": label}));
|
||||
ex(
|
||||
&mut s,
|
||||
"codeConnect.set",
|
||||
json!({"id": comp, "language": "react", "importPath": "@/components/ui/button", "name": "Button", "template": "<Button size=\"lg\">{Label}</Button>"}),
|
||||
);
|
||||
let p = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 1440, "height": 400, "name": "Landing"})));
|
||||
ex(&mut s, "layout.add", json!({"ids": [p], "direction": "vertical"}));
|
||||
for text in ["Agendar diagnóstico", "Ver exemplos"] {
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": comp, "x": 0, "y": 0, "parent": p})));
|
||||
ex(&mut s, "instance.setProperties", json!({"id": i, "properties": {"Label": text}}));
|
||||
}
|
||||
let out = code(&mut s, p, json!({}));
|
||||
assert_eq!(out.matches("import { Button } from \"@/components/ui/button\";").count(), 1, "{out}");
|
||||
assert!(out.contains("<Button size=\"lg\">") && out.contains("Agendar diagnóstico") && out.contains("Ver exemplos"), "{out}");
|
||||
assert!(!out.contains(">Botão<"), "the component's own layers aren't inlined: {out}");
|
||||
// Unconnected instances are still inlined.
|
||||
ex(&mut s, "codeConnect.remove", json!({"id": comp, "language": "react"}));
|
||||
let out = code(&mut s, p, json!({}));
|
||||
assert!(!out.contains("import ") && out.contains(">Agendar diagnóstico<"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_same_named_variables_keep_distinct_theme_names() {
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "Brand"}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "Palette/Accent", "type": "color", "value": "#3DDC97"}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "Semantic/Accent", "type": "color", "value": "#7C5CFF"}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "Muted", "type": "color", "value": "#9AA3AE"}));
|
||||
let p = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 200, "name": "Page"})));
|
||||
ex(&mut s, "layout.add", json!({"ids": [p], "direction": "vertical"}));
|
||||
let a = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 50, "parent": p, "name": "A"})));
|
||||
let b = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 50, "parent": p, "name": "B"})));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [a], "field": "fill", "variable": "Palette/Accent"}));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [b], "field": "fill", "variable": "Semantic/Accent"}));
|
||||
let out = code(&mut s, p, json!({}));
|
||||
assert!(out.contains("bg-palette-accent") && out.contains("bg-semantic-accent"), "{out}");
|
||||
let tw = ex(&mut s, "variable.export", json!({"format": "tailwind"}))["text"].as_str().unwrap_or_default().to_lowercase();
|
||||
assert!(tw.contains("--color-palette-accent: #3ddc97;") && tw.contains("--color-semantic-accent: #7c5cff;"), "{tw}");
|
||||
assert!(tw.contains("--color-muted:"), "unique short names stay short: {tw}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_an_alias_target_takes_its_readers_role() {
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "Brand"}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "half", "type": "number", "value": 0.5}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "fade", "type": "number", "value": 1}));
|
||||
ex(&mut s, "variable.set", json!({"variable": "fade", "value": {"alias": "half"}}));
|
||||
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": "fade"}));
|
||||
let css = ex(&mut s, "variable.export", json!({"format": "css"}))["text"].as_str().unwrap_or_default().to_string();
|
||||
assert!(css.contains("--half: 0.5;") && !css.contains("0.5px"), "{css}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_borders_images_and_fonts_from_variables_are_valid_utilities() {
|
||||
let got: Vec<String> = classes(
|
||||
"border: var(--stroke, 2px) solid #000000;\nborder-bottom: var(--hair, 1px) solid var(--color-border, #262a31);\nbackground: url(image.png) center / cover;\nfont-family: var(--brand-font, \"Inter\");\n",
|
||||
)
|
||||
.into_iter()
|
||||
.map(|(_, c)| c)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
got,
|
||||
[
|
||||
"border-(length:--stroke)",
|
||||
"border-[#000000]",
|
||||
"border-b-(length:--hair)",
|
||||
"border-border",
|
||||
"[background:url(image.png)_center_/_cover]",
|
||||
"font-(family-name:--brand-font)"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_desktop_defaults_reset_mobile_values_and_padding_per_side() {
|
||||
let mut s = Session::new();
|
||||
let (desk, _) = page(&mut s, "Landing", 1440.0, "horizontal");
|
||||
let (mob, _) = page(&mut s, "Landing mobile", 390.0, "vertical");
|
||||
ex(&mut s, "node.set", json!({"id": mob, "props": {"opacity": 0.5, "padding": 24}}));
|
||||
ex(&mut s, "node.set", json!({"id": desk, "props": {"paddingTop": 0, "paddingBottom": 0, "paddingLeft": 64, "paddingRight": 64}}));
|
||||
let out = code(&mut s, desk, json!({"mobile": mob}));
|
||||
let root = out.lines().nth(3).unwrap_or_default();
|
||||
assert!(root.contains("opacity-50") && root.contains("lg:opacity-100"), "{root}");
|
||||
assert!(root.contains("p-6") && root.contains("lg:px-16") && root.contains("lg:py-0"), "{root}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_desktop_without_fill_clears_a_mobile_image_or_gradient() {
|
||||
let got = merge(Some(&[("background".to_string(), "[background:url(image.png)_center_/_cover]".to_string())]), &[]);
|
||||
assert!(got.contains(&"lg:bg-none".to_string()) && got.contains(&"lg:bg-transparent".to_string()), "{got:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_corner_radii_with_a_variable_first_keep_every_corner() {
|
||||
let got: Vec<String> =
|
||||
classes("border-radius: var(--radius-a, 4px) 8px 8px 8px;\nborder-radius: var(--radius-card, 4px);\n").into_iter().map(|(_, c)| c).collect();
|
||||
assert_eq!(got, ["rounded-[var(--radius-a,_4px)_8px_8px_8px]", "rounded-card"]);
|
||||
}
|
||||
@@ -448,6 +448,11 @@ fn every_command_survives_junk_params() {
|
||||
// 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!(
|
||||
|
||||
@@ -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<u64>, 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<Kv<'static>> {
|
||||
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<String>) {
|
||||
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:?}");
|
||||
}
|
||||
@@ -42,6 +42,14 @@ pub(super) fn to_rest(msg: &Kv, name: &str, max_nodes: usize) -> (Value, Vec<Str
|
||||
if !m.library_contexts.is_empty() {
|
||||
out[crate::rest::FIG_LIBRARY_CONTEXTS_FIELD] = Value::Array(std::mem::take(&mut m.library_contexts));
|
||||
}
|
||||
// Mains in the file that the layer cap kept out: their instances are told apart from those of
|
||||
// components the file doesn't have (#981).
|
||||
if m.count >= m.max_nodes {
|
||||
let capped: Vec<Value> = 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<Str
|
||||
if !styles.is_empty() {
|
||||
out["styleDefinitions"] = Value::Array(styles);
|
||||
}
|
||||
(out, m.warnings, m.networks)
|
||||
(out, m.warnings.finish(), m.networks)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -387,11 +395,18 @@ struct Mapper<'a> {
|
||||
library_contexts: Vec<Value>,
|
||||
/// Layers visited while scanning internal canvases, across all of them.
|
||||
library_scan_count: usize,
|
||||
warnings: Vec<String>,
|
||||
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<String>,
|
||||
mapped_mains: BTreeSet<String>,
|
||||
/// 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<String>) {
|
||||
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<String> {
|
||||
@@ -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<String> = 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<Value> = 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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<String>), 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))
|
||||
}
|
||||
|
||||
|
||||
@@ -344,6 +344,20 @@ pub(crate) fn put<W: Write + Seek>(
|
||||
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<Item = (&'a str, &'a [u8])>) -> Result<Vec<u8>, 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<W: Write + Seek>(z: &mut zip::ZipWriter<W>, name: &str, doc: &Document) -> Result<(), FormatError> {
|
||||
z.start_file(name, json_options()).map_err(io_err)?;
|
||||
|
||||
@@ -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<String, usize>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
/// 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<Item = &str> {
|
||||
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<String> {
|
||||
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<Option<VectorNetwork>>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
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<NodeId> = 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)),
|
||||
|
||||
+18
-17
@@ -128,10 +128,10 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
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"],
|
||||
@@ -513,7 +513,11 @@ fn batch(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult, Stri
|
||||
};
|
||||
let mut params = params;
|
||||
friendly_params(&mut params);
|
||||
steps.push(json!({"command": cmd, "params": params}));
|
||||
let mut step = json!({"command": cmd, "params": params});
|
||||
if let (Some(name), Some(o)) = (c.get("as"), step.as_object_mut()) {
|
||||
o.insert("as".into(), name.clone());
|
||||
}
|
||||
steps.push(step);
|
||||
}
|
||||
let label = s_arg(a, "label")?.unwrap_or("Batch");
|
||||
exec(b, "command.batch", json!({"label": label, "commands": steps})).map(|v| ToolResult::json(&v))
|
||||
@@ -742,7 +746,7 @@ fn get_node(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult, S
|
||||
}
|
||||
}
|
||||
if used_wire + MARKER_RESERVE <= budget {
|
||||
return Ok(ToolResult::json(&with_marker(value, depth, used, wire, used_wire, max_bytes, true, None)));
|
||||
return Ok(ToolResult::json(&with_marker(value, Cut { asked: depth, used, asked_wire: wire, used_wire }, max_bytes, true, None)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -767,10 +771,7 @@ fn get_node(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult, S
|
||||
let over = wire > budget || next.is_some();
|
||||
let mut out = with_marker(
|
||||
value,
|
||||
depth,
|
||||
used,
|
||||
wire,
|
||||
used_wire,
|
||||
Cut { asked: depth, used, asked_wire: wire, used_wire },
|
||||
max_bytes,
|
||||
over,
|
||||
Some(json!({
|
||||
@@ -812,19 +813,19 @@ fn children_window(value: &mut Value, offset: usize, budget: usize) -> bool {
|
||||
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,
|
||||
/// How a subtree was cut: the depth asked for and the depth reported, with their sizes on the wire.
|
||||
struct Cut {
|
||||
asked: u64,
|
||||
used: u64,
|
||||
asked_wire: usize,
|
||||
used_wire: usize,
|
||||
max_bytes: usize,
|
||||
over: bool,
|
||||
children: Option<Value>,
|
||||
) -> Value {
|
||||
}
|
||||
|
||||
/// `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, cut: Cut, max_bytes: usize, over: bool, children: Option<Value>) -> Value {
|
||||
let Cut { asked, used, asked_wire, used_wire } = cut;
|
||||
if let Some(obj) = value.as_object_mut() {
|
||||
let mut marker = json!({
|
||||
"requestedDepth": asked,
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
+98
-27
@@ -292,7 +292,9 @@ pub fn stroke_center(path: &BezPath, width: f64, props: &StrokeProps) -> BezPath
|
||||
.with_miter_limit(props.miter_limit.clamp(1.0, 100.0))
|
||||
.with_start_cap(kcap(props.cap_start))
|
||||
.with_end_cap(kcap(props.cap_end));
|
||||
if let Some(pat) = width::dash_pattern(props) {
|
||||
// Each subpath restarts the pattern, so it can add up to one more pattern's worth of dashes.
|
||||
let subpaths = path.elements().iter().filter(|e| matches!(e, PathEl::MoveTo(_))).count() as f64;
|
||||
if let Some(pat) = width::dash_pattern(props).filter(|p| width::dashes_fit(path.perimeter(1.0) + subpaths * p.iter().sum::<f64>(), p)) {
|
||||
style = style.with_dashes(0.0, pat);
|
||||
}
|
||||
kurbo::stroke(path.iter(), &style, &kurbo::StrokeOpts::default(), 0.05)
|
||||
@@ -359,36 +361,35 @@ pub fn stroke_outline_of(path: &BezPath, props: &StrokeProps, closed_shape: bool
|
||||
|
||||
/// End point and outward direction of an open path at its start or end.
|
||||
fn path_end(path: &BezPath, at_end: bool) -> Option<(Point, Vec2)> {
|
||||
let segs: Vec<PathSeg> = path.segments().collect();
|
||||
let seg = if at_end { segs.last()? } else { segs.first()? };
|
||||
use kurbo::ParamCurve;
|
||||
use kurbo::ParamCurveDeriv;
|
||||
let (p, d) = if at_end {
|
||||
let d = match seg {
|
||||
PathSeg::Line(l) => l.p1 - l.p0,
|
||||
PathSeg::Quad(q) => q.deriv().eval(1.0).to_vec2(),
|
||||
PathSeg::Cubic(c) => {
|
||||
let d = c.deriv().eval(1.0).to_vec2();
|
||||
if d.hypot() < 1e-9 { c.p3 - c.p2 } else { d }
|
||||
}
|
||||
let dir_of = |seg: &PathSeg| -> Option<(Point, Vec2)> {
|
||||
let (p, d) = if at_end {
|
||||
let d = match seg {
|
||||
PathSeg::Line(l) => l.p1 - l.p0,
|
||||
PathSeg::Quad(q) => q.deriv().eval(1.0).to_vec2(),
|
||||
PathSeg::Cubic(c) => {
|
||||
let d = c.deriv().eval(1.0).to_vec2();
|
||||
if d.hypot() < 1e-9 { c.p3 - c.p2 } else { d }
|
||||
}
|
||||
};
|
||||
(seg.end(), d)
|
||||
} else {
|
||||
let d = match seg {
|
||||
PathSeg::Line(l) => l.p0 - l.p1,
|
||||
PathSeg::Quad(q) => -q.deriv().eval(0.0).to_vec2(),
|
||||
PathSeg::Cubic(c) => {
|
||||
let d = -c.deriv().eval(0.0).to_vec2();
|
||||
if d.hypot() < 1e-9 { c.p0 - c.p1 } else { d }
|
||||
}
|
||||
};
|
||||
(seg.start(), d)
|
||||
};
|
||||
(seg.end(), d)
|
||||
} else {
|
||||
let d = match seg {
|
||||
PathSeg::Line(l) => l.p0 - l.p1,
|
||||
PathSeg::Quad(q) => -q.deriv().eval(0.0).to_vec2(),
|
||||
PathSeg::Cubic(c) => {
|
||||
let d = -c.deriv().eval(0.0).to_vec2();
|
||||
if d.hypot() < 1e-9 { c.p0 - c.p1 } else { d }
|
||||
}
|
||||
};
|
||||
(seg.start(), d)
|
||||
let len = d.hypot();
|
||||
(len >= 1e-12).then(|| (p, d / len))
|
||||
};
|
||||
let len = d.hypot();
|
||||
if len < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
Some((p, d / len))
|
||||
// Skip zero-length segments (repeated vertices) and use the nearest one with a direction.
|
||||
if at_end { path.segments().collect::<Vec<_>>().iter().rev().find_map(dir_of) } else { path.segments().find_map(|s| dir_of(&s)) }
|
||||
}
|
||||
|
||||
/// The filled arrowhead shape for `cap` at one end of an open path.
|
||||
@@ -693,6 +694,29 @@ mod tests {
|
||||
assert_ne!(explicit, layer_only);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_end_arrow_is_drawn_when_a_closed_run_follows_the_open_one() {
|
||||
let verts = [(0.0, 0.0), (50.0, 0.0), (0.0, 100.0), (50.0, 100.0), (25.0, 150.0)];
|
||||
let net = VectorNetwork {
|
||||
vertices: verts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &(x, y))| figcraft_doc::Vertex { cap: (i == 0).then_some(StrokeCap::Round), ..figcraft_doc::Vertex::new(x, y) })
|
||||
.collect(),
|
||||
segments: vec![
|
||||
figcraft_doc::Segment::line(0, 1),
|
||||
figcraft_doc::Segment::line(2, 3),
|
||||
figcraft_doc::Segment::line(3, 4),
|
||||
figcraft_doc::Segment::line(4, 2),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let path = BezPath::new();
|
||||
let plain = StrokeProps { weight: 2.0, ..Default::default() };
|
||||
let arrow = StrokeProps { cap_end: StrokeCap::TriangleArrow, ..plain.clone() };
|
||||
assert_ne!(stroke_outline_of(&path, &arrow, false, Some(&net)), stroke_outline_of(&path, &plain, false, Some(&net)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_joins_do_not_break_closed_stroke_runs() {
|
||||
let path = sq(0.0, 0.0, 20.0);
|
||||
@@ -719,6 +743,35 @@ mod tests {
|
||||
assert!((s.area().abs() - 100.0).abs() < 2.0, "{}", s.area());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dash_pattern_too_fine_for_its_path_strokes_solid() {
|
||||
// ~500k dashes on this line (#1060): drawn solid, as a variable width stroke is past
|
||||
// `MAX_DASHES`, rather than one outline per dash.
|
||||
let mut line = BezPath::new();
|
||||
line.move_to((0.0, 0.0));
|
||||
line.line_to((10_000.0, 0.0));
|
||||
let props = StrokeProps { weight: 2.0, dashes: vec![0.01, 0.01], align: StrokeAlign::Center, ..Default::default() };
|
||||
let s = stroke_center(&line, 2.0, &props);
|
||||
assert!(s.elements().len() < 100, "{} elements", s.elements().len());
|
||||
assert!((s.area().abs() - 20_000.0).abs() < 10.0, "{}", s.area());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_subpath_restarting_the_dash_pattern_counts_toward_the_limit() {
|
||||
// The pattern restarts at every subpath: on 10,000 short lines its 199 tiny dashes come
|
||||
// round each time (~1M dashes), though the length alone suggests a few hundred.
|
||||
let mut path = BezPath::new();
|
||||
for i in 0..10_000 {
|
||||
path.move_to((0.0, f64::from(i) * 4.0));
|
||||
path.line_to((2.0, f64::from(i) * 4.0));
|
||||
}
|
||||
let mut dashes = vec![0.01; 199];
|
||||
dashes.push(10_000.0);
|
||||
let props = StrokeProps { weight: 1.0, dashes, align: StrokeAlign::Center, ..Default::default() };
|
||||
let s = stroke_center(&path, 1.0, &props);
|
||||
assert!(s.elements().len() < 100_000, "{} elements", s.elements().len());
|
||||
}
|
||||
|
||||
#[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.
|
||||
@@ -742,4 +795,22 @@ mod tests {
|
||||
let s = stroke_outline_of(&ring, &inside, true, Some(&nz));
|
||||
assert!(s.winding(material) != 0 && s.winding(hole) != 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrowhead_skips_zero_length_end_segments() {
|
||||
let mut p = BezPath::new();
|
||||
p.move_to((0.0, 0.0));
|
||||
p.line_to((100.0, 0.0));
|
||||
assert!(arrowhead(&p, StrokeCap::TriangleArrow, 2.0, true).is_some());
|
||||
p.line_to((100.0, 0.0));
|
||||
let (tip, dir) = path_end(&p, true).expect("end direction");
|
||||
assert_eq!((tip, dir), (Point::new(100.0, 0.0), Vec2::new(1.0, 0.0)));
|
||||
assert!(arrowhead(&p, StrokeCap::TriangleArrow, 2.0, true).is_some());
|
||||
let mut q = BezPath::new();
|
||||
q.move_to((0.0, 0.0));
|
||||
q.line_to((0.0, 0.0));
|
||||
q.line_to((100.0, 0.0));
|
||||
let (tip, dir) = path_end(&q, false).expect("start direction");
|
||||
assert_eq!((tip, dir), (Point::new(0.0, 0.0), Vec2::new(-1.0, 0.0)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,8 +86,8 @@ impl Styled {
|
||||
/// and last end (as without point styles).
|
||||
pub(crate) fn arrowheads(&self, weight: f64, props: &StrokeProps) -> BezPath {
|
||||
let mut out = BezPath::new();
|
||||
let last = self.runs.len().saturating_sub(1);
|
||||
for (k, run) in self.runs.iter().enumerate().filter(|(_, r)| !r.closed) {
|
||||
let last = self.runs.iter().filter(|r| !r.closed).count().saturating_sub(1);
|
||||
for (k, run) in self.runs.iter().filter(|r| !r.closed).enumerate() {
|
||||
let start = self.cap_at(run.start, if k == 0 { props.cap_start } else { StrokeCap::None });
|
||||
let end = self.cap_at(run.end, if k == last { props.cap_end } else { StrokeCap::None });
|
||||
for (at_end, cap) in [(false, start), (true, end)] {
|
||||
|
||||
@@ -17,7 +17,7 @@ pub use figcraft_geom::measure::{Polyline, polylines};
|
||||
/// Every subpath gets at least this many width samples, so the width varies smoothly along long
|
||||
/// straight runs.
|
||||
const SAMPLES_PER_SUBPATH: f64 = 128.0;
|
||||
/// The most dashes a dashed variable width stroke makes.
|
||||
/// The most dashes a dashed stroke makes ([`dashes_fit`]).
|
||||
const MAX_DASHES: usize = 20_000;
|
||||
|
||||
/// The nearest point of the stroke path to `p`: (subpath index, t by length, distance).
|
||||
@@ -242,6 +242,12 @@ pub fn dash_pattern(props: &StrokeProps) -> Option<Vec<f64>> {
|
||||
Some(if dashes.len() % 2 == 1 { [dashes.clone(), dashes].concat() } else { dashes })
|
||||
}
|
||||
|
||||
/// Whether `pattern` (from [`dash_pattern`]) makes at most [`MAX_DASHES`] dashes along `len`;
|
||||
/// past that a stroke draws solid instead of one outline per dash (#1060).
|
||||
pub(crate) fn dashes_fit(len: f64, pattern: &[f64]) -> bool {
|
||||
len / pattern.iter().sum::<f64>() * (pattern.len() as f64) < MAX_DASHES as f64
|
||||
}
|
||||
|
||||
/// Resample a polyline so no step is longer than `max_step`; returns the points and the length at
|
||||
/// each, or `None` past the sample budget.
|
||||
fn resample(line: &Polyline, max_step: f64, budget: &mut usize) -> Option<(Vec<Point>, Vec<f64>)> {
|
||||
@@ -294,7 +300,7 @@ pub fn variable_outline(path: &BezPath, width: f64, props: &StrokeProps, profile
|
||||
})
|
||||
.collect();
|
||||
match &pattern {
|
||||
Some(pat) if len / pat.iter().sum::<f64>() * (pat.len() as f64) < MAX_DASHES as f64 => {
|
||||
Some(pat) if dashes_fit(len, pat) => {
|
||||
for (p, h) in dashes(&pts, &hw, pat) {
|
||||
outline_piece(&mut out, &p, &h, false, style);
|
||||
}
|
||||
|
||||
@@ -153,3 +153,123 @@ 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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounded_side_borders_follow_their_corners_in_booleans() {
|
||||
use figcraft_doc::StrokeAlign;
|
||||
use figcraft_geom::Point;
|
||||
let mut d = Document::new("rounded 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, NodeKind::Rectangle, "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.align = StrokeAlign::Inside;
|
||||
n.stroke.sides = Some([8.0; 4]);
|
||||
n.corners.radii = [30.0; 4];
|
||||
let p = outline(&d, b);
|
||||
assert_ne!(p.winding(Point::new(50.0, 4.0)), 0, "top border");
|
||||
assert_eq!(p.winding(Point::new(3.0, 3.0)), 0, "the rounded corner leaves the square's tip out");
|
||||
assert_eq!(p.winding(Point::new(50.0, 50.0)), 0, "hollow inside");
|
||||
}
|
||||
|
||||
+47
-14
@@ -156,19 +156,26 @@ pub(crate) fn hash_path(p: &BezPath, h: &mut impl Hasher) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-side strokes on rectangles and frames (a ring under non-zero winding too), if `n` has them.
|
||||
fn side_strokes(n: &Node) -> Option<Arc<BezPath>> {
|
||||
let props = &n.stroke;
|
||||
let sides = props.sides?;
|
||||
if !matches!(n.kind, NodeKind::Rectangle | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. }) {
|
||||
return None;
|
||||
}
|
||||
let c = &n.corners;
|
||||
Some(Arc::new(if c.uniform() == Some(0.0) {
|
||||
figcraft_pathops::rect_side_strokes(n.width, n.height, sides, props.align)
|
||||
} else {
|
||||
figcraft_pathops::rounded_side_strokes(n.width, n.height, c.radii, c.smoothing, sides, props.align)
|
||||
}))
|
||||
}
|
||||
|
||||
/// The stroke area of a node in local coordinates.
|
||||
pub fn stroke_area(n: &Node, path: &BezPath, closed: bool) -> Arc<BezPath> {
|
||||
let props = &n.stroke;
|
||||
// Per-side strokes on rectangles/frames.
|
||||
if let Some(sides) = props.sides
|
||||
&& matches!(n.kind, NodeKind::Rectangle | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. })
|
||||
{
|
||||
let c = &n.corners;
|
||||
return Arc::new(if c.uniform() == Some(0.0) {
|
||||
figcraft_pathops::rect_side_strokes(n.width, n.height, sides, props.align)
|
||||
} else {
|
||||
figcraft_pathops::rounded_side_strokes(n.width, n.height, c.radii, c.smoothing, sides, props.align)
|
||||
});
|
||||
if let Some(p) = side_strokes(n) {
|
||||
return p;
|
||||
}
|
||||
// Rect/ellipse inside/outside strokes without booleans: an even-odd ring of two outlines.
|
||||
if props.dashes.is_empty() && props.profile().is_none() && props.brush.is_none() && closed && props.align != StrokeAlign::Center {
|
||||
@@ -209,6 +216,18 @@ pub fn stroke_area(n: &Node, path: &BezPath, closed: bool) -> Arc<BezPath> {
|
||||
return Arc::new(o);
|
||||
}
|
||||
}
|
||||
cached_stroke_outline(n, path, closed)
|
||||
}
|
||||
|
||||
/// The stroke a boolean operand contributes: like [`stroke_area`], but never an even-odd ring
|
||||
/// (boolean folding uses non-zero winding).
|
||||
fn operand_stroke(n: &Node, path: &BezPath, closed: bool) -> Arc<BezPath> {
|
||||
side_strokes(n).unwrap_or_else(|| cached_stroke_outline(n, path, closed))
|
||||
}
|
||||
|
||||
/// A non-zero stroke outline (cached), shared by painting and boolean operands.
|
||||
fn cached_stroke_outline(n: &Node, path: &BezPath, closed: bool) -> Arc<BezPath> {
|
||||
let props = &n.stroke;
|
||||
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)
|
||||
@@ -281,7 +300,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)))
|
||||
})
|
||||
@@ -305,6 +324,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;
|
||||
}
|
||||
@@ -313,21 +337,30 @@ 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(operand_stroke(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 { .. });
|
||||
Some(((*operand_stroke(n, &path, closed)).clone(), FillRule::Winding))
|
||||
}
|
||||
_ => node_outline_at(doc, n, depth),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,9 +48,11 @@ fn an_image_within_the_limits_decodes() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_wider_or_taller_than_the_limit_is_refused() {
|
||||
assert!(images::decode_within(&png(30, 20), ColorProfile::Srgb, 29, u64::MAX).is_none());
|
||||
assert!(images::decode_within(&png(20, 30), ColorProfile::Srgb, 29, u64::MAX).is_none());
|
||||
fn an_image_wider_or_taller_than_the_limit_is_scaled_down_to_fit() {
|
||||
let (p, _) = images::decode_within(&png(30, 20), ColorProfile::Srgb, 15, u64::MAX).unwrap();
|
||||
assert_eq!((p.width(), p.height()), (15, 10));
|
||||
let (p, _) = images::decode_within(&png(20, 30), ColorProfile::Srgb, 15, u64::MAX).unwrap();
|
||||
assert_eq!((p.width(), p.height()), (10, 15));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -63,10 +65,22 @@ fn a_huge_image_is_refused_from_its_header_alone() {
|
||||
// 16 000 × 16 000 is within the side limit; its pixels (1 GB) aren't within a 1 MB budget, and
|
||||
// the file has none to decode: only the header is read.
|
||||
assert!(images::decode_within(&png_header_only(16_000, 16_000), ColorProfile::Srgb, 16_384, 1 << 20).is_none());
|
||||
// Past the side limit, the shipped decode refuses it the same way.
|
||||
// Past the side limit, the shipped decode reads its header and finds no pixels to scale down.
|
||||
assert!(images::decode(&png_header_only(20_000, 10)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_past_the_side_limit_is_drawn_downscaled() {
|
||||
// #1336: one side over 16 384 px drew nothing.
|
||||
let p = images::decode(&png(16_385, 4)).unwrap();
|
||||
assert_eq!(p.width(), 16_384);
|
||||
assert!(p.height() >= 1 && p.height() <= 4);
|
||||
assert!(p.pixels().iter().all(|c| c.alpha() == 255));
|
||||
let p = images::decode(&png(4, 18_078)).unwrap();
|
||||
assert_eq!(p.height(), 16_384);
|
||||
assert!(p.width() >= 1 && p.width() <= 4);
|
||||
}
|
||||
|
||||
fn key(name: &str) -> images::Key {
|
||||
(name.to_string(), [0; 7], ColorProfile::Srgb)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ use tiny_skia::Pixmap;
|
||||
|
||||
pub(crate) type Key = (String, [i32; 7], ColorProfile);
|
||||
|
||||
/// The largest image side decoded.
|
||||
/// The largest side of a decoded image: larger images are scaled down to fit.
|
||||
const MAX_SIDE: u32 = 16_384;
|
||||
/// The most bytes one image's decoding may allocate: the image crate's own default limit (a
|
||||
/// larger image is refused from its header).
|
||||
@@ -83,26 +83,25 @@ fn fkey(f: &ImageFilters) -> [i32; 7] {
|
||||
[q(f.exposure), q(f.contrast), q(f.saturation), q(f.temperature), q(f.tint), q(f.highlights), q(f.shadows)]
|
||||
}
|
||||
|
||||
/// Decode encoded image bytes into a premultiplied sRGB pixmap (max 16k per side).
|
||||
/// Decode encoded image bytes into a premultiplied sRGB pixmap (scaled down to 16k per side).
|
||||
pub fn decode(bytes: &[u8]) -> Option<Pixmap> {
|
||||
decode_in(bytes, ColorProfile::Srgb).map(|(p, _)| p)
|
||||
}
|
||||
|
||||
/// Decode encoded image bytes into a premultiplied pixmap (max 16k per side): converted from the
|
||||
/// Decode encoded image bytes into a premultiplied pixmap (scaled down to 16k per side): converted from the
|
||||
/// image's own profile into `target` (`false`), or as sRGB when it has none (`true`: the sRGB →
|
||||
/// `target` conversion is still to do).
|
||||
fn decode_in(bytes: &[u8], target: ColorProfile) -> Option<(Pixmap, bool)> {
|
||||
decode_within(bytes, target, MAX_SIDE, MAX_DECODED_BYTES)
|
||||
}
|
||||
|
||||
/// [`decode_in`] for images at most `max_side` wide and tall whose decoded pixels take at most
|
||||
/// `max_bytes`; larger ones are refused from their header, before any pixels are decoded.
|
||||
/// [`decode_in`] for images whose decoded pixels take at most `max_bytes` (larger ones are refused
|
||||
/// from their header, before any pixels are decoded), scaled down to at most `max_side` wide and
|
||||
/// tall.
|
||||
pub(crate) fn decode_within(bytes: &[u8], target: ColorProfile, max_side: u32, max_bytes: u64) -> Option<(Pixmap, bool)> {
|
||||
use image::ImageDecoder;
|
||||
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(max_side);
|
||||
limits.max_image_height = Some(max_side);
|
||||
limits.max_alloc = Some(max_bytes);
|
||||
reader.limits(limits);
|
||||
let mut dec = reader.into_decoder().ok()?;
|
||||
@@ -110,7 +109,11 @@ pub(crate) fn decode_within(bytes: &[u8], target: ColorProfile, max_side: u32, m
|
||||
return None;
|
||||
}
|
||||
let source = crate::image_profile::Source::read(bytes, &mut dec);
|
||||
let img = image::DynamicImage::from_decoder(dec).ok()?;
|
||||
let mut img = image::DynamicImage::from_decoder(dec).ok()?;
|
||||
if img.width() > max_side || img.height() > max_side {
|
||||
// `thumbnail` keeps the aspect ratio and works in integers (no float copy of the pixels).
|
||||
img = img.thumbnail(max_side, max_side);
|
||||
}
|
||||
match source.convert(&img, target) {
|
||||
Some(rgba) => Some((premultiplied(&rgba)?, false)),
|
||||
None => Some((premultiplied(&img.to_rgba8())?, true)),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -405,8 +405,10 @@ fn names(f: &skrifa::FontRef) -> (Option<String>, Option<String>) {
|
||||
(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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -332,6 +332,7 @@ name = "English"
|
||||
"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}"
|
||||
@@ -1650,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"
|
||||
|
||||
@@ -20,6 +20,7 @@ status = "draft — needs native review"
|
||||
"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)"
|
||||
@@ -1640,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"
|
||||
@@ -1976,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"
|
||||
|
||||
@@ -21,7 +21,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "ამ ფაილის კოლექცია ამ რეჟიმებითა და ცვლადებით: შეცვალეთ მათი მნიშვნელობები თითოეული რეჟიმისთვის და დაამატეთ საკუთარი ცვლადები"
|
||||
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "ამ სახელით .figcraft ფაილი უკვე არსებობს. ჩანაცვლების დასადასტურებლად აირჩიეთ მისი სრული სახელი."
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "ცალკე შრე თავის ჩარჩოსთან სწორდება: მონიშნეთ მეტი შრე ან ჩარჩოში მყოფი შრე"
|
||||
"A responsive product page: hero, features, figures, a quote and a footer" = "პროდუქტის ადაპტური გვერდი: მთავარი ბლოკი, შესაძლებლობები, ციფრები, ციტატა და ქვედა ზოლი"
|
||||
"A responsive product page: hero, features, figures, a quote and a footer" = "პროდუქტის ადაპტური გვერდი ყველა ძირითადი ბლოკით"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "ზამბარა მოძრაობს, სანამ არ დამშვიდდება: მისი მასა, სიხისტე და მილევა განსაზღვრავს ხანგრძლივობას"
|
||||
"A title, an agenda, a big figure, a chart and a closing slide" = "სათაური, დღის წესრიგი, დიდი ციფრი, დიაგრამა და დასკვნითი სლაიდი"
|
||||
"A to Z" = "ა-დან ჰ-მდე"
|
||||
@@ -104,7 +104,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"Alpha" = "ალფა"
|
||||
"Alphabetical" = "ანბანის მიხედვით"
|
||||
"Alt+drag" = "Alt+გადათრევა"
|
||||
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "ანალიტიკის ეკრანი ბადეზე: ციფრები, ვექტორებით დახატული დიაგრამები და ცხრილი"
|
||||
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "ანალიტიკა ბადეზე: ციფრები, დიაგრამები და ცხრილი"
|
||||
"An invite link that opens FigCraft and joins" = "მოსაწვევი ბმული, რომელიც ხსნის FigCraft-ს და უერთდება სესიას"
|
||||
"Anchor" = "საყრდენი"
|
||||
"Android" = "Android"
|
||||
@@ -215,7 +215,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"Clear from this list" = "ამ სიიდან ამოღება"
|
||||
"Code or invite link" = "კოდი ან მოსაწვევი ბმული"
|
||||
"Color profile: {name}" = "ფერის პროფილი: {name}"
|
||||
"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "ფერისა და ტექსტის სტილები, ღია და მუქი ცვლადები, ღილაკები, შეყვანის ველები და ბარათები"
|
||||
"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "სტილები, ღია და მუქი ცვლადები, ღილაკები და ბარათები"
|
||||
"Connector" = "შემაერთებელი"
|
||||
"Convert to frame" = "ჩარჩოდ გარდაქმნა"
|
||||
"Convert to section" = "სექციად გარდაქმნა"
|
||||
@@ -480,7 +480,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"Expand" = "გაშლა"
|
||||
"Export" = "ექსპორტირება"
|
||||
"Export all" = "ყველაფრის ექსპორტირება"
|
||||
"Export frames and layers as PNG, JPG, SVG or PDF" = "ჩარჩოებისა და შრეების ექსპორტირება PNG, JPG, SVG ან PDF ფორმატში"
|
||||
"Export frames and layers as PNG, JPG, SVG or PDF" = "ჩარჩოები და შრეები PNG, JPG, SVG ან PDF ფორმატში"
|
||||
"Export page as PNG" = "გვერდის PNG ფაილად ექსპორტირება"
|
||||
"Export settings" = "ექსპორტის პარამეტრები"
|
||||
"Export variables" = "ცვლადების ექსპორტირება"
|
||||
@@ -553,7 +553,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"Font size" = "შრიფტის ზომა"
|
||||
"Font style" = "შრიფტის სტილი"
|
||||
"Forget on this computer" = "ამ კომპიუტერზე დავიწყება"
|
||||
"Four screens of a trail app with components and a prototype flow" = "ლაშქრობის აპის ოთხი ეკრანი კომპონენტებითა და პროტოტიპის ნაკადით"
|
||||
"Four screens of a trail app with components and a prototype flow" = "ლაშქრობის აპის ოთხი ეკრანი და პროტოტიპის ნაკადი"
|
||||
"Fractions" = "წილადები"
|
||||
"Frame" = "ჩარჩო"
|
||||
"Frame selection" = "მონიშნულის ჩარჩოში მოქცევა"
|
||||
@@ -576,7 +576,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"Gradient" = "გრადიენტი"
|
||||
"Gray" = "ნაცრისფერი"
|
||||
"Green" = "მწვანე"
|
||||
"Greyscale components and two example screens to sketch a flow with" = "ნაცრისფერი კომპონენტები და ორი სანიმუშო ეკრანი ნაკადის ჩანახატისთვის"
|
||||
"Greyscale components and two example screens to sketch a flow with" = "ნაცრისფერი კომპონენტები ნაკადის ჩანახატისთვის"
|
||||
"Grid" = "ბადე"
|
||||
"Grid ({size})" = "ბადე ({size})"
|
||||
"Grid styles" = "ბადის სტილები"
|
||||
@@ -623,7 +623,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"Hue" = "ფერის ტონი"
|
||||
"Hug" = "შიგთავსზე მორგება"
|
||||
"Hug contents" = "შიგთავსზე მორგება"
|
||||
"Icon components on a 24 px grid with keylines and a size preview" = "ხატულების კომპონენტები 24 px ბადეზე საორიენტაციო ხაზებითა და ზომების გადახედვით"
|
||||
"Icon components on a 24 px grid with keylines and a size preview" = "ხატულები 24 px ბადეზე, საორიენტაციო ხაზებით"
|
||||
"Icon set starter" = "ხატულების ნაკრების საწყისი"
|
||||
"If it keeps happening, please report it at {link} with this message." = "თუ ეს განმეორდება, გთხოვთ, ეს შეტყობინება გამოგვიგზავნეთ აქ: {link}"
|
||||
"If" = "თუ"
|
||||
@@ -745,7 +745,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"Link" = "ბმული"
|
||||
"Link copied" = "ბმული დაკოპირდა"
|
||||
"Link docs, code or tickets for developers." = "მიაბით დოკუმენტაცია, კოდი ან ტიკეტები დეველოპერებისთვის."
|
||||
"Link frames on the Prototype tab, then present the flow" = "დააკავშირეთ ჩარჩოები „პროტოტიპის“ ჩანართზე, შემდეგ წარადგინეთ ნაკადი"
|
||||
"Link frames on the Prototype tab, then present the flow" = "დააკავშირეთ ჩარჩოები და წარადგინეთ ნაკადი"
|
||||
"Link this component to its code component: instances then show your real snippet with their values." = "დააკავშირეთ ეს კომპონენტი მის კოდის კომპონენტთან: ინსტანსები მაშინ თქვენს რეალურ ფრაგმენტს აჩვენებს მათი მნიშვნელობებით."
|
||||
"List spacing" = "სიის დაშორება"
|
||||
"List style" = "სიის სტილი"
|
||||
@@ -921,7 +921,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"On click" = "დაწკაპუნებით"
|
||||
"On drag" = "გადათრევისას"
|
||||
"On this page" = "ამ გვერდზე"
|
||||
"One announcement in five common sizes: square, portrait, story, link and banner" = "ერთი განცხადება ხუთ ხშირად გამოყენებულ ზომაში: კვადრატი, ვერტიკალური, სთორი, ბმული და ბანერი"
|
||||
"One announcement in five common sizes: square, portrait, story, link and banner" = "ერთი განცხადება ხუთ პოპულარულ ზომაში"
|
||||
"Online" = "ონლაინ"
|
||||
"Online files" = "ონლაინ ფაილები"
|
||||
"Online sharing is off: files are shared on the local network only." = "ონლაინ გაზიარება გამორთულია: ფაილები მხოლოდ ლოკალურ ქსელში ზიარდება."
|
||||
@@ -1003,7 +1003,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"Pick color" = "ფერის არჩევა"
|
||||
"Pick color (I)" = "ფერის აღება (I)"
|
||||
"Pick one font family first" = "ჯერ აირჩიეთ ერთი შრიფტის ოჯახი"
|
||||
"Pick the Frame tool, then drag on the canvas or choose a device size" = "აირჩიეთ ჩარჩოს ხელსაწყო, შემდეგ გადაათრიეთ ტილოზე ან აირჩიეთ მოწყობილობის ზომა"
|
||||
"Pick the Frame tool, then drag on the canvas or choose a device size" = "ჩარჩოს ხელსაწყოთი გადაათრიეთ ან აირჩიეთ ზომა"
|
||||
"Pin a property" = "თვისების მიმაგრება"
|
||||
"Pinch" = "მოჭყლეტა"
|
||||
"Pixel grid" = "პიქსელური ბადე"
|
||||
@@ -1182,7 +1182,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"Save files automatically" = "ფაილების ავტომატურად შენახვა"
|
||||
"Search templates" = "შაბლონების ძიება"
|
||||
"See all templates" = "ყველა შაბლონის ნახვა"
|
||||
"Select layers and they space themselves as their content changes" = "მონიშნეთ შრეები და შიგთავსის ცვლილებისას ისინი ავტომატურად დაშორდება ერთმანეთს"
|
||||
"Select layers and they space themselves as their content changes" = "შრეები შიგთავსის ცვლილებისას თავად დაშორდება"
|
||||
"SHA-256: {sha}…" = "SHA-256: {sha}…"
|
||||
"Same as layer" = "შრის მსგავსად"
|
||||
"Saturation" = "გაჯერება"
|
||||
@@ -1330,7 +1330,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"Start on path" = "დასაწყისი ტრაექტორიაზე"
|
||||
"Start point" = "საწყისი წერტილი"
|
||||
"Start sharing" = "გაზიარების დაწყება"
|
||||
"Start sharing with the Share button, then pass on the code it gives" = "გაზიარება დაიწყეთ ღილაკით „გაზიარება“, შემდეგ გადაეცით მის მიერ მოცემული კოდი"
|
||||
"Start sharing with the Share button, then pass on the code it gives" = "დააჭირეთ „გაზიარებას“ და გადაეცით მიღებული კოდი"
|
||||
"Starting to share…" = "გაზიარების დაწყება…"
|
||||
"Status" = "სტატუსი"
|
||||
"Sticky" = "მიმაგრებული"
|
||||
@@ -1451,7 +1451,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"Try another search or filter." = "სცადეთ სხვა ძიება ან ფილტრი."
|
||||
"Try another search, or browse templates." = "სცადეთ სხვა ძიება ან დაათვალიერეთ შაბლონები."
|
||||
"Try another word, or look at every kind." = "სცადეთ სხვა სიტყვა ან ნახეთ ყველა სახეობა."
|
||||
"Turn a layer into a component, reuse it, and change every copy at once" = "შრე აქციეთ კომპონენტად, გამოიყენეთ ხელახლა და შეცვალეთ ყველა ასლი ერთდროულად"
|
||||
"Turn a layer into a component, reuse it, and change every copy at once" = "შრე აქციეთ კომპონენტად და შეცვალეთ ყველა ასლი"
|
||||
"Two digits" = "ორი ციფრი"
|
||||
"Type" = "ტიპი"
|
||||
"Type settings" = "ტექსტის პარამეტრები"
|
||||
|
||||
@@ -131,6 +131,37 @@ fn a_copy_command_invoked_for_a_synthetic_click_still_keeps_the_clipboard() {
|
||||
assert_eq!(w.writes(), (1, 0));
|
||||
}
|
||||
|
||||
/// Asserts a real `ui.menu.invoke` of Copy link is fenced from the system clipboard, while the
|
||||
/// same menu item invoked by the user writes the selected layer's link.
|
||||
#[test]
|
||||
fn a_synthetic_ui_menu_invoke_copy_link_keeps_the_clipboard_then_a_user_copy_writes_the_link() {
|
||||
let mut w = Win::new();
|
||||
let selected = *w.app.session.selection().first().expect("a selected layer");
|
||||
let link = format!("figcraft://node/{}", selected.0);
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
w.app.control_queue.push(ControlRequest {
|
||||
id: json!(1),
|
||||
method: "ui.menu.invoke".into(),
|
||||
params: json!({"id": "edit.copyLink"}),
|
||||
reply: tx,
|
||||
state: Default::default(),
|
||||
});
|
||||
w.frame(vec![]);
|
||||
let reply = rx.try_recv().expect("the menu invocation is acknowledged");
|
||||
assert_eq!(reply.id, json!(1));
|
||||
assert!(reply.result.is_ok(), "the menu item ran: {:?}", reply.result);
|
||||
assert_eq!(w.writes(), (0, 0), "a synthetic menu invocation never replaces the user's clipboard");
|
||||
assert_eq!(w.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Link copied"));
|
||||
assert!(!w.app.control_synthetic, "the fence ends with the frame");
|
||||
|
||||
crate::menus::invoke(&mut w.app, "edit.copyLink");
|
||||
assert_eq!(w.writes(), (1, 0), "the user's invocation writes exactly one text clipboard entry");
|
||||
let recorded = w.recorded.lock().unwrap();
|
||||
assert_eq!(recorded.text, vec![link.clone()]);
|
||||
assert_eq!(recorded.last_text.as_deref(), Some(link.as_str()));
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
|
||||
@@ -699,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.
|
||||
@@ -715,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);
|
||||
|
||||
@@ -326,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| {
|
||||
@@ -343,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() {
|
||||
|
||||
@@ -241,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;
|
||||
@@ -1397,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) {
|
||||
@@ -1501,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);
|
||||
|
||||
@@ -241,8 +241,10 @@ pub fn invoke(app: &mut FigApp, id: &str) {
|
||||
app.toast(tr("Copied as text"));
|
||||
}
|
||||
"edit.copyLink" => {
|
||||
// Only a selected layer has a link, and the copy goes through the control-channel fence
|
||||
// (`clipboard_set_text`): a synthetic gesture never replaces the user's clipboard.
|
||||
if let Some(id) = app.session.selection().first() {
|
||||
app.services.clipboard_set_text(&format!("figcraft://node/{}", id.0));
|
||||
app.clipboard_set_text(&format!("figcraft://node/{}", id.0));
|
||||
app.toast(tr("Link copied"));
|
||||
}
|
||||
}
|
||||
@@ -311,6 +313,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())]));
|
||||
|
||||
@@ -2198,6 +2198,9 @@ fn player(p: &mut Presenter, ui: &mut Ui, rect: Rect, now: f64, chrome: Chrome)
|
||||
p.restart(p.start, now);
|
||||
}
|
||||
if p.doc.as_ref().is_none_or(|d| !d.contains(p.current)) {
|
||||
// Controller buttons pressed with no frame to show are dropped, as they are while a link
|
||||
// notice shows.
|
||||
p.pads.clear();
|
||||
if !chrome.pane {
|
||||
return Some(BarAction::Close);
|
||||
}
|
||||
@@ -2228,6 +2231,8 @@ fn player(p: &mut Presenter, ui: &mut Ui, rect: Rect, now: f64, chrome: Chrome)
|
||||
// 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);
|
||||
} else {
|
||||
p.pads.clear();
|
||||
}
|
||||
let Some(l) = layout(p, area, chrome) else { return action };
|
||||
p.shown = Some((p.current, l.place, l.clip.intersect(area)));
|
||||
@@ -2506,7 +2511,9 @@ fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_area: bool, ke
|
||||
)
|
||||
});
|
||||
let keys = if keys_on { keys } else { Vec::new() };
|
||||
// Controller buttons pressed while a link notice shows are dropped, as keys are.
|
||||
let pads = std::mem::take(&mut p.pads);
|
||||
let pads = if p.link_notice.is_none() { pads } else { Vec::new() };
|
||||
// A transition a drag started follows the pointer while it stays down, then plays on from there.
|
||||
if let Some(a) = p.anim.as_mut()
|
||||
&& let Some((from, dir, len)) = a.follow
|
||||
|
||||
@@ -162,6 +162,34 @@ fn the_presenter_keys_wait_while_the_notice_shows() {
|
||||
assert_eq!(notice(&u), "https://example.com/a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_prototype_takes_no_controller_buttons_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": "Gamepad A"}, "action": to_b}));
|
||||
let mut u = Harness::new(s);
|
||||
u.app.services = Box::new(Opened::default());
|
||||
u.ctx.enable_accesskit();
|
||||
u.run("proto.present", json!({"start": a}));
|
||||
u.frames(40, Vec::new);
|
||||
assert_eq!(notice(&u), "https://example.com/a");
|
||||
let ctx = u.ctx.clone();
|
||||
crate::control::handle(&mut u.app, &ctx, "ui.input", &json!({"events": [{"gamepad": "A"}]})).unwrap();
|
||||
u.frames(2, Vec::new);
|
||||
assert_eq!(u.app.inspect()["presenter"]["current"], json!(a), "the Gamepad A trigger did not run");
|
||||
assert_eq!(notice(&u), "https://example.com/a");
|
||||
click(&mut u, "Cancel");
|
||||
assert!(notice(&u).is_null());
|
||||
crate::control::handle(&mut u.app, &ctx, "ui.input", &json!({"events": [{"gamepad": "A"}]})).unwrap();
|
||||
u.frames(2, Vec::new);
|
||||
assert_eq!(u.app.inspect()["presenter"]["current"], json!(b), "the button plays once the notice closes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dont_show_this_again_is_saved_with_the_preferences() {
|
||||
let (mut u, seen) = presented("https://example.com/a", false);
|
||||
|
||||
@@ -137,6 +137,26 @@ fn preview_pane_opens_follows_edits_and_the_selection() {
|
||||
assert!(u.app.ui.preview.is_none());
|
||||
}
|
||||
|
||||
/// A controller button pressed while the pane has no frame to show is dropped, and does not play
|
||||
/// once the frame comes back.
|
||||
#[test]
|
||||
fn controller_buttons_pressed_with_no_frame_shown_are_dropped() {
|
||||
let (mut u, a, b, _) = setup();
|
||||
let to_b = json!({"type": "navigate", "dest": b});
|
||||
u.run("proto.interaction.add", json!({"id": a, "trigger": {"type": "keyPress", "key": "Gamepad A"}, "action": to_b}));
|
||||
u.run("proto.preview", json!({"start": a}));
|
||||
u.run("edit.delete", json!({"ids": [a]}));
|
||||
let ctx = u.ctx.clone();
|
||||
crate::control::handle(&mut u.app, &ctx, "ui.input", &json!({"events": [{"gamepad": "A"}]})).unwrap();
|
||||
u.frames(2, Vec::new);
|
||||
u.run("edit.undo", json!({}));
|
||||
u.frames(2, Vec::new);
|
||||
assert_eq!(u.preview()["current"], json!(a), "the button pressed with no frame shown did not play");
|
||||
crate::control::handle(&mut u.app, &ctx, "ui.input", &json!({"events": [{"gamepad": "A"}]})).unwrap();
|
||||
u.frames(2, Vec::new);
|
||||
assert_eq!(u.preview()["current"], json!(b), "a button pressed with the frame shown plays");
|
||||
}
|
||||
|
||||
/// 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]
|
||||
|
||||
+184
-128
@@ -715,13 +715,22 @@ fn text_binding(ui: &mut Ui, style: &str, field: &str, kind: VariableType, rect:
|
||||
}
|
||||
}
|
||||
|
||||
/// A text style's typography in the editor: a sample, then font, style, size, line height and
|
||||
/// letter spacing; a change redefines the style (every linked char follows). Each field takes a
|
||||
/// variable: the font and style menus start with Apply variable, the other fields show a variable
|
||||
/// button on hover, and a bound field shows its variable instead of its value.
|
||||
fn text_properties(ui: &mut Ui, doc: &Document, style: &Style, def: &TextStyle, out: &mut Frame) {
|
||||
let id = style.id.as_str();
|
||||
let bound = |field: &str| style.bound.get(field).map(|v| doc.variables.get(v).map_or_else(|| v.clone(), |x| x.name.clone()));
|
||||
/// The text style's variable name for `field`, or None when the field is not bound.
|
||||
fn bound_name(doc: &Document, style: &Style, field: &str) -> Option<String> {
|
||||
style.bound.get(field).map(|v| doc.variables.get(v).map_or_else(|| v.clone(), |x| x.name.clone()))
|
||||
}
|
||||
|
||||
/// Redefines the text style `def` through `f`; the edit is queued only when it changes the style.
|
||||
fn set_text(def: &TextStyle, id: &str, out: &mut Frame, f: impl FnOnce(&mut TextStyle)) {
|
||||
let mut d = def.clone();
|
||||
f(&mut d);
|
||||
if d != *def {
|
||||
out.act = Some(Act::SetText(id.to_string(), d));
|
||||
}
|
||||
}
|
||||
|
||||
/// The sample of the text style, with its font, style and metrics beside it.
|
||||
fn text_sample_header(ui: &mut Ui, def: &TextStyle, out: &mut Frame) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, _) = ui.allocate_exact_size(vec2(W, 40.0), Sense::hover());
|
||||
ui.painter().rect_filled(r, CornerRadius::same(5), Color32::from_white_alpha(14));
|
||||
@@ -730,133 +739,180 @@ fn text_properties(ui: &mut Ui, doc: &Document, style: &Style, def: &TextStyle,
|
||||
let g = ui.painter().layout(summary, regular(10.5), t.menu_dim, W - 80.0);
|
||||
ui.painter().galley(pos2(r.min.x + 72.0, r.center().y - g.size().y / 2.0), g, t.menu_dim);
|
||||
ui.add_space(6.0);
|
||||
let set = |out: &mut Frame, f: &dyn Fn(&mut TextStyle)| {
|
||||
let mut d = def.clone();
|
||||
f(&mut d);
|
||||
if d != *def {
|
||||
out.act = Some(Act::SetText(id.to_string(), d));
|
||||
}
|
||||
|
||||
/// The font field: a variable chip when bound, else a dropdown whose menu is returned.
|
||||
fn font_family_field(ui: &mut Ui, doc: &Document, style: &Style, def: &TextStyle, out: &mut Frame) -> Option<Response> {
|
||||
let id = style.id.as_str();
|
||||
match bound_name(doc, style, "fontFamily") {
|
||||
Some(name) => {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(W, widgets::FIELD_H), Sense::hover());
|
||||
text_binding(ui, id, "fontFamily", VariableType::String, r, &name, widgets::Lead::Named(tr("Font")), out);
|
||||
None
|
||||
}
|
||||
};
|
||||
let half = (W - 8.0) / 2.0;
|
||||
// The dropdowns' menus open outside the dark scope (they're regular popups).
|
||||
let (family_resp, style_resp) = crate::theme::on_dark(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(8.0, 6.0);
|
||||
let family_resp = match bound("fontFamily") {
|
||||
Some(name) => {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(W, widgets::FIELD_H), Sense::hover());
|
||||
text_binding(ui, id, "fontFamily", VariableType::String, r, &name, widgets::Lead::Named(tr("Font")), out);
|
||||
None
|
||||
}
|
||||
None => Some(widgets::dropdown(ui, &def.font_family, W, None).on_hover_text(tr("Font"))),
|
||||
};
|
||||
let mut style_resp = None;
|
||||
ui.horizontal(|ui| {
|
||||
match bound("fontStyle") {
|
||||
Some(name) => {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(half, widgets::FIELD_H), Sense::hover());
|
||||
text_binding(ui, id, "fontStyle", VariableType::String, r, &name, widgets::Lead::Named(tr("Font style")), out);
|
||||
}
|
||||
None => style_resp = Some(widgets::dropdown(ui, &def.font_style, half, None).on_hover_text(tr("Font style"))),
|
||||
}
|
||||
let size = bound("fontSize");
|
||||
let (edit, ev) = widgets::number_field_bindable(
|
||||
ui,
|
||||
Id::unique(("ts_size", id)),
|
||||
widgets::Lead::Text(tr("Size")),
|
||||
Some(def.font_size),
|
||||
"",
|
||||
half,
|
||||
1.0,
|
||||
Some(size.as_deref()),
|
||||
widgets::Empty::Text(tr("Mixed")),
|
||||
);
|
||||
if let Some(widgets::FieldEdit::Set(v) | widgets::FieldEdit::Scrub(v, true)) = edit.and_then(|e| e.settled()) {
|
||||
set(out, &|d| d.font_size = v.clamp(1.0, 5000.0));
|
||||
}
|
||||
match ev {
|
||||
Some(widgets::BindEvent::Open(at)) => crate::variable_ui::open_style(ui, id, "fontSize", VariableType::Number, at),
|
||||
Some(widgets::BindEvent::Detach) => out.act = Some(Act::Unbind(id.to_string(), "fontSize".to_string())),
|
||||
None => {}
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
let area = Rect::from_min_size(ui.cursor().min, vec2(half, widgets::FIELD_H));
|
||||
match bound("lineHeight") {
|
||||
Some(name) => {
|
||||
let (r, _) = ui.allocate_exact_size(area.size(), Sense::hover());
|
||||
text_binding(ui, id, "lineHeight", VariableType::Number, r, &name, widgets::Lead::Named(tr("Line height")), out);
|
||||
}
|
||||
None => {
|
||||
if let Some(v) = widgets::text_field(ui, Id::unique(("ts_lh", id)), &def.line_height.label(), half, tr("Line height"))
|
||||
&& let Some(lh) =
|
||||
crate::typography::parse_line_height(&v, Some(def.line_height)).and_then(|v| crate::typography::line_height_of(&v))
|
||||
{
|
||||
set(out, &|d| d.line_height = lh);
|
||||
}
|
||||
if crate::variable_ui::variable_button(ui, area, Id::unique(("ts_bind", id, "lineHeight"))) {
|
||||
crate::variable_ui::open_style(ui, id, "lineHeight", VariableType::Number, area.left_bottom());
|
||||
}
|
||||
}
|
||||
}
|
||||
let area = Rect::from_min_size(ui.cursor().min, vec2(half, widgets::FIELD_H));
|
||||
match bound("letterSpacing") {
|
||||
Some(name) => {
|
||||
let (r, _) = ui.allocate_exact_size(area.size(), Sense::hover());
|
||||
text_binding(ui, id, "letterSpacing", VariableType::Number, r, &name, widgets::Lead::Named(tr("Letter spacing")), out);
|
||||
}
|
||||
None => {
|
||||
if let Some(v) = widgets::text_field(ui, Id::unique(("ts_ls", id)), &def.letter_spacing.label(), half, tr("Letter spacing"))
|
||||
&& let Some(ls) = crate::typography::parse_letter_spacing(&v, Some(def.letter_spacing))
|
||||
.and_then(|v| crate::typography::letter_spacing_of(&v))
|
||||
{
|
||||
set(out, &|d| d.letter_spacing = ls);
|
||||
}
|
||||
if crate::variable_ui::variable_button(ui, area, Id::unique(("ts_bind", id, "letterSpacing"))) {
|
||||
crate::variable_ui::open_style(ui, id, "letterSpacing", VariableType::Number, area.left_bottom());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
(family_resp, style_resp)
|
||||
});
|
||||
if let Some(family_resp) = family_resp {
|
||||
crate::right::menu_popup(&family_resp).width(260.0).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
|
||||
if crate::right::menu_item(ui, tr("Apply variable"), "") {
|
||||
crate::variable_ui::open_style(ui, id, "fontFamily", VariableType::String, family_resp.rect.left_bottom());
|
||||
ui.close();
|
||||
return;
|
||||
}
|
||||
ui.separator();
|
||||
if let Some(f) = crate::typography::font_picker(ui, Some(&def.font_family), family_resp.clicked()).picked {
|
||||
set(out, &|d| {
|
||||
d.font_family = f.clone();
|
||||
let styles = figcraft_text::family_styles(&f);
|
||||
if !styles.is_empty() && !styles.contains(&d.font_style) {
|
||||
d.set_font_style(styles.iter().find(|s| s.eq_ignore_ascii_case("Regular")).or(styles.first()).cloned().unwrap_or_default());
|
||||
}
|
||||
});
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
None => Some(widgets::dropdown(ui, &def.font_family, W, None).on_hover_text(tr("Font"))),
|
||||
}
|
||||
if let Some(sr) = style_resp {
|
||||
crate::right::menu_popup(&sr).width(200.0).show(|ui| {
|
||||
if crate::right::menu_item(ui, tr("Apply variable"), "") {
|
||||
crate::variable_ui::open_style(ui, id, "fontStyle", VariableType::String, sr.rect.left_bottom());
|
||||
ui.close();
|
||||
return;
|
||||
}
|
||||
|
||||
/// The font style field (a dropdown, whose menu is returned, or a variable chip) and the size field, in one row.
|
||||
fn font_style_size_row(ui: &mut Ui, doc: &Document, style: &Style, def: &TextStyle, half: f32, out: &mut Frame) -> Option<Response> {
|
||||
let id = style.id.as_str();
|
||||
let mut style_resp = None;
|
||||
ui.horizontal(|ui| {
|
||||
match bound_name(doc, style, "fontStyle") {
|
||||
Some(name) => {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(half, widgets::FIELD_H), Sense::hover());
|
||||
text_binding(ui, id, "fontStyle", VariableType::String, r, &name, widgets::Lead::Named(tr("Font style")), out);
|
||||
}
|
||||
ui.separator();
|
||||
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
|
||||
for s in figcraft_text::family_styles(&def.font_family) {
|
||||
let mark = if def.font_style == s { "✓ " } else { " " };
|
||||
if crate::right::menu_item(ui, &format!("{mark}{s}"), "") {
|
||||
set(out, &|d| d.set_font_style(s.clone()));
|
||||
ui.close();
|
||||
}
|
||||
None => style_resp = Some(widgets::dropdown(ui, &def.font_style, half, None).on_hover_text(tr("Font style"))),
|
||||
}
|
||||
font_size_field(ui, doc, style, def, half, out);
|
||||
});
|
||||
style_resp
|
||||
}
|
||||
|
||||
/// The font size field, which also takes a variable.
|
||||
fn font_size_field(ui: &mut Ui, doc: &Document, style: &Style, def: &TextStyle, half: f32, out: &mut Frame) {
|
||||
let id = style.id.as_str();
|
||||
let size = bound_name(doc, style, "fontSize");
|
||||
let (edit, ev) = widgets::number_field_bindable(
|
||||
ui,
|
||||
Id::unique(("ts_size", id)),
|
||||
widgets::Lead::Text(tr("Size")),
|
||||
Some(def.font_size),
|
||||
"",
|
||||
half,
|
||||
1.0,
|
||||
Some(size.as_deref()),
|
||||
widgets::Empty::Text(tr("Mixed")),
|
||||
);
|
||||
if let Some(widgets::FieldEdit::Set(v) | widgets::FieldEdit::Scrub(v, true)) = edit.and_then(|e| e.settled()) {
|
||||
set_text(def, id, out, |d| d.font_size = v.clamp(1.0, 5000.0));
|
||||
}
|
||||
match ev {
|
||||
Some(widgets::BindEvent::Open(at)) => crate::variable_ui::open_style(ui, id, "fontSize", VariableType::Number, at),
|
||||
Some(widgets::BindEvent::Detach) => out.act = Some(Act::Unbind(id.to_string(), "fontSize".to_string())),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The line height field: a variable chip when bound, else a text field with a variable button beside it.
|
||||
fn line_height_field(ui: &mut Ui, doc: &Document, style: &Style, def: &TextStyle, half: f32, area: Rect, out: &mut Frame) {
|
||||
let id = style.id.as_str();
|
||||
match bound_name(doc, style, "lineHeight") {
|
||||
Some(name) => {
|
||||
let (r, _) = ui.allocate_exact_size(area.size(), Sense::hover());
|
||||
text_binding(ui, id, "lineHeight", VariableType::Number, r, &name, widgets::Lead::Named(tr("Line height")), out);
|
||||
}
|
||||
None => {
|
||||
if let Some(v) = widgets::text_field(ui, Id::unique(("ts_lh", id)), &def.line_height.label(), half, tr("Line height"))
|
||||
&& let Some(lh) = crate::typography::parse_line_height(&v, Some(def.line_height)).and_then(|v| crate::typography::line_height_of(&v))
|
||||
{
|
||||
set_text(def, id, out, |d| d.line_height = lh);
|
||||
}
|
||||
if crate::variable_ui::variable_button(ui, area, Id::unique(("ts_bind", id, "lineHeight"))) {
|
||||
crate::variable_ui::open_style(ui, id, "lineHeight", VariableType::Number, area.left_bottom());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The letter spacing field: a variable chip when bound, else a text field with a variable button beside it.
|
||||
fn letter_spacing_field(ui: &mut Ui, doc: &Document, style: &Style, def: &TextStyle, half: f32, area: Rect, out: &mut Frame) {
|
||||
let id = style.id.as_str();
|
||||
match bound_name(doc, style, "letterSpacing") {
|
||||
Some(name) => {
|
||||
let (r, _) = ui.allocate_exact_size(area.size(), Sense::hover());
|
||||
text_binding(ui, id, "letterSpacing", VariableType::Number, r, &name, widgets::Lead::Named(tr("Letter spacing")), out);
|
||||
}
|
||||
None => {
|
||||
if let Some(v) = widgets::text_field(ui, Id::unique(("ts_ls", id)), &def.letter_spacing.label(), half, tr("Letter spacing"))
|
||||
&& let Some(ls) =
|
||||
crate::typography::parse_letter_spacing(&v, Some(def.letter_spacing)).and_then(|v| crate::typography::letter_spacing_of(&v))
|
||||
{
|
||||
set_text(def, id, out, |d| d.letter_spacing = ls);
|
||||
}
|
||||
if crate::variable_ui::variable_button(ui, area, Id::unique(("ts_bind", id, "letterSpacing"))) {
|
||||
crate::variable_ui::open_style(ui, id, "letterSpacing", VariableType::Number, area.left_bottom());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The fields of a text style, in rows: font, then style and size, then line height and letter
|
||||
/// spacing. Returns the font and style dropdowns, whose menus are opened after the fields.
|
||||
fn text_property_fields(ui: &mut Ui, doc: &Document, style: &Style, def: &TextStyle, out: &mut Frame) -> (Option<Response>, Option<Response>) {
|
||||
let half = (W - 8.0) / 2.0;
|
||||
ui.spacing_mut().item_spacing = vec2(8.0, 6.0);
|
||||
let family_resp = font_family_field(ui, doc, style, def, out);
|
||||
let style_resp = font_style_size_row(ui, doc, style, def, half, out);
|
||||
ui.horizontal(|ui| {
|
||||
let area = Rect::from_min_size(ui.cursor().min, vec2(half, widgets::FIELD_H));
|
||||
line_height_field(ui, doc, style, def, half, area, out);
|
||||
let area = Rect::from_min_size(ui.cursor().min, vec2(half, widgets::FIELD_H));
|
||||
letter_spacing_field(ui, doc, style, def, half, area, out);
|
||||
});
|
||||
(family_resp, style_resp)
|
||||
}
|
||||
|
||||
/// The font menu: Apply variable, then the installed fonts. Picking a font keeps its style when the
|
||||
/// family has one, else falls back to Regular or the first style.
|
||||
fn font_family_menu(id: &str, def: &TextStyle, family_resp: &Response, out: &mut Frame) {
|
||||
crate::right::menu_popup(family_resp).width(260.0).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
|
||||
if crate::right::menu_item(ui, tr("Apply variable"), "") {
|
||||
crate::variable_ui::open_style(ui, id, "fontFamily", VariableType::String, family_resp.rect.left_bottom());
|
||||
ui.close();
|
||||
return;
|
||||
}
|
||||
ui.separator();
|
||||
if let Some(f) = crate::typography::font_picker(ui, Some(&def.font_family), family_resp.clicked()).picked {
|
||||
set_text(def, id, out, |d| {
|
||||
d.font_family = f.clone();
|
||||
let styles = figcraft_text::family_styles(&f);
|
||||
if !styles.is_empty() && !styles.contains(&d.font_style) {
|
||||
d.set_font_style(styles.iter().find(|s| s.eq_ignore_ascii_case("Regular")).or(styles.first()).cloned().unwrap_or_default());
|
||||
}
|
||||
});
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The font style menu: Apply variable, then the styles of the current font, the current one checked.
|
||||
fn font_style_menu(id: &str, def: &TextStyle, style_resp: &Response, out: &mut Frame) {
|
||||
crate::right::menu_popup(style_resp).width(200.0).show(|ui| {
|
||||
if crate::right::menu_item(ui, tr("Apply variable"), "") {
|
||||
crate::variable_ui::open_style(ui, id, "fontStyle", VariableType::String, style_resp.rect.left_bottom());
|
||||
ui.close();
|
||||
return;
|
||||
}
|
||||
ui.separator();
|
||||
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
|
||||
for s in figcraft_text::family_styles(&def.font_family) {
|
||||
let mark = if def.font_style == s { "✓ " } else { " " };
|
||||
if crate::right::menu_item(ui, &format!("{mark}{s}"), "") {
|
||||
set_text(def, id, out, |d| d.set_font_style(s.clone()));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// A text style's typography in the editor: a sample, then font, style, size, line height and
|
||||
/// letter spacing; a change redefines the style (every linked char follows). Each field takes a
|
||||
/// variable: the font and style menus start with Apply variable, the other fields show a variable
|
||||
/// button on hover, and a bound field shows its variable instead of its value.
|
||||
fn text_properties(ui: &mut Ui, doc: &Document, style: &Style, def: &TextStyle, out: &mut Frame) {
|
||||
let id = style.id.as_str();
|
||||
text_sample_header(ui, def, out);
|
||||
// The dropdowns' menus open outside the dark scope (they're regular popups).
|
||||
let (family_resp, style_resp) = crate::theme::on_dark(ui, |ui| text_property_fields(ui, doc, style, def, out));
|
||||
if let Some(family_resp) = family_resp {
|
||||
font_family_menu(id, def, &family_resp, out);
|
||||
}
|
||||
if let Some(sr) = style_resp {
|
||||
font_style_menu(id, def, &sr, out);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,12 +9,12 @@ use serde_json::{Value, json};
|
||||
use crate::variables_panel::export_dialog;
|
||||
use crate::{FigApp, NoServices, Services};
|
||||
|
||||
type Downloads = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
pub(crate) type Downloads = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
/// Services that save by downloads and keep them (`deliver` false: the browser refused).
|
||||
struct Browser {
|
||||
got: Downloads,
|
||||
deliver: bool,
|
||||
pub(crate) struct Browser {
|
||||
pub(crate) got: Downloads,
|
||||
pub(crate) deliver: bool,
|
||||
}
|
||||
|
||||
impl Services for Browser {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
//! Save, Save as and Export where the platform saves by downloads (the browser, which has no save
|
||||
//! dialogs or paths): the file arrives as a download instead of nothing happening (#969).
|
||||
|
||||
use base64::Engine as _;
|
||||
use figcraft_doc::{ExportSetting, NodeId};
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::FigApp;
|
||||
use crate::variables_export_ui_tests::{Browser, Downloads};
|
||||
|
||||
fn browser(deliver: bool) -> (FigApp, Downloads, NodeId) {
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
let id = serde_json::from_value(r["id"].clone()).unwrap();
|
||||
let got = Downloads::default();
|
||||
(FigApp::new(s, Box::new(Browser { got: got.clone(), deliver })), got, id)
|
||||
}
|
||||
|
||||
fn toast(app: &FigApp) -> String {
|
||||
app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_downloads_the_figcraft_file() {
|
||||
let (mut app, got, _) = browser(true);
|
||||
app.save();
|
||||
let got = got.lock().unwrap();
|
||||
let [(name, bytes)] = got.as_slice() else { panic!("one download, got {}", got.len()) };
|
||||
assert_eq!(name, "Untitled.figcraft");
|
||||
let mut reopened = Session::new();
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||
reopened.execute("file.open", &json!({"data": data, "name": name})).unwrap();
|
||||
assert!(!app.session.ds().unwrap().is_dirty());
|
||||
assert_eq!(toast(&app), "Saved Untitled.figcraft");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_downloads_the_exported_file() {
|
||||
let (mut app, got, id) = browser(true);
|
||||
crate::export_panel::save_one(&mut app, &[id], &ExportSetting::default(), "Rectangle.png");
|
||||
let got = got.lock().unwrap();
|
||||
let [(name, bytes)] = got.as_slice() else { panic!("one download, got {}", got.len()) };
|
||||
assert_eq!(name, "Rectangle.png");
|
||||
assert!(bytes.starts_with(b"\x89PNG"));
|
||||
assert_eq!(toast(&app), "Exported Rectangle.png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_download_says_so() {
|
||||
let (mut app, got, _) = browser(false);
|
||||
app.save_as_dialog();
|
||||
assert_eq!(got.lock().unwrap().len(), 1);
|
||||
assert_eq!(toast(&app), "Couldn't download Untitled.figcraft");
|
||||
assert!(app.session.ds().unwrap().is_dirty(), "no file arrived: closing must still warn about the edits");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_layers_download_as_one_zip() {
|
||||
// The browser lets a page start one download at a time: two downloads lost the second.
|
||||
let (mut app, got, a) = browser(true);
|
||||
let r = app.session.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
let b: NodeId = serde_json::from_value(r["id"].clone()).unwrap();
|
||||
app.session.execute("export.add", &json!({"ids": [a, b]})).unwrap();
|
||||
crate::export_panel::export_layers(&mut app, &[a, b], false);
|
||||
let got = got.lock().unwrap();
|
||||
let [(name, bytes)] = got.as_slice() else { panic!("one download, got {}", got.len()) };
|
||||
assert_eq!(name, "Untitled.zip");
|
||||
assert!(bytes.starts_with(b"PK\x03\x04"));
|
||||
assert!(bytes.windows(15).any(|w| w == b"Rectangle 2.png"), "both layers are in the zip");
|
||||
assert_eq!(toast(&app), "Exported 2 files");
|
||||
}
|
||||
+9
-5
@@ -116,14 +116,18 @@ Runs commands in a headless session (a new document, or `--in`), printing one JS
|
||||
```
|
||||
|
||||
The whole script is **one undo step**, and it is **all-or-nothing**, exactly like running the same
|
||||
commands through `command.batch` (the MCP tool `batch`, the control channel, plugins): `{"$step": n}`
|
||||
in a step's params is the `id` in an earlier step's result, so a step can target a layer an earlier
|
||||
step of the same script created:
|
||||
commands through `command.batch` (the MCP tool `batch`, the control channel, plugins). A step named
|
||||
with `"as"` can be used by later steps: `{"$ref": "name"}` in a step's params is the `id` in that
|
||||
step's result (`{"$step": n}` does the same by zero-based position), and `"field"` takes another key
|
||||
or a slash path such as `ids/0`. So a step can target a layer an earlier step of the same script
|
||||
created ([Step references](mcp.md#step-references) has the rules):
|
||||
|
||||
```json
|
||||
[
|
||||
{"command": "node.create", "params": {"type": "frame", "name": "Card", "width": 200, "height": 100}},
|
||||
{"command": "node.create", "params": {"type": "text", "parent": {"$step": 0}, "text": "Hi"}}
|
||||
{"command": "node.create", "as": "card", "params": {"type": "frame", "name": "Card", "width": 200, "height": 100}},
|
||||
{"command": "node.create", "params": {"type": "text", "parent": {"$ref": "card"}, "text": "Hi"}},
|
||||
{"command": "edit.duplicate", "as": "copy", "params": {"ids": [{"$ref": "card"}]}},
|
||||
{"command": "node.set", "params": {"ids": [{"$ref": "copy", "field": "ids/0"}], "props": {"name": "Card copy"}}}
|
||||
]
|
||||
```
|
||||
|
||||
|
||||
@@ -106,13 +106,17 @@ JSON or the gradient shorthand `{"type":"linearGradient","stops":["#hex","#hex"]
|
||||
{"id":3,"method":"engine.execute","params":{"command":"command.batch","params":{"label":"Tidy","commands":[{"command":"align.left","params":{"ids":[2,3]}},{"command":"distribute.vertical","params":{"ids":[2,3]}}]}}}
|
||||
```
|
||||
|
||||
Inside `command.batch`, `{"$step": n}` in a step's params stands for the `id` in step `n`'s result
|
||||
(zero-based; `{"$step": n, "field": "…"}` for another field; any object with a `$step` key is read
|
||||
as one, and a nested `command.batch` step keeps its own references to its own steps), so one batch
|
||||
can create a frame and fill it:
|
||||
Inside `command.batch`, a step can name itself with `"as"` and later steps use its result:
|
||||
`{"$ref": "name"}` in a step's params stands for the `id` in that step's result, and `{"$step": n}`
|
||||
for the `id` in step `n`'s result (zero-based). Either takes `"field"` for another key or a slash
|
||||
path into the result, such as `ids/0` for the first copy `edit.duplicate` returned. Any object with a
|
||||
`$ref` or `$step` key is read as a reference, names are unique within a batch, and a nested
|
||||
`command.batch` step keeps its own references to its own steps (see [MCP: Step
|
||||
references](mcp.md#step-references) for the rules and errors). So one batch can create a frame, fill
|
||||
it and duplicate it:
|
||||
|
||||
```json
|
||||
{"id":4,"method":"engine.execute","params":{"command":"command.batch","params":{"commands":[{"command":"node.create","params":{"type":"frame","width":200,"height":100}},{"command":"node.create","params":{"type":"rectangle","parent":{"$step":0},"width":20,"height":20}}]}}}
|
||||
{"id":4,"method":"engine.execute","params":{"command":"command.batch","params":{"commands":[{"command":"node.create","as":"card","params":{"type":"frame","width":200,"height":100}},{"command":"node.create","params":{"type":"rectangle","parent":{"$ref":"card"},"width":20,"height":20}},{"command":"edit.duplicate","as":"copy","params":{"ids":[{"$ref":"card"}]}},{"command":"node.set","params":{"ids":[{"$ref":"copy","field":"ids/0"}],"props":{"name":"Card copy"}}}]}}}
|
||||
```
|
||||
|
||||
### `engine.commands {}`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of
|
||||
`/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's
|
||||
@@ -49,7 +49,7 @@ export limit, not a native document nesting limit.
|
||||
|
||||
| Format | Figma | FigCraft | Notes | Hours |
|
||||
|---|---|---|---|---|
|
||||
| PNG | yes, scales/suffixes | yes | export settings per layer, presets (`2x`, `512w`, `512h`), preview, "Export N layers"; contents only (off: every layer showing in the area) and include bounding box (the layer's own box); slashes in layer names export into folders. The exported area is what the layer paints: shadows towards their offset, outside and centre strokes, unclipped children, a mask group's mask, nothing for hidden layers; slices export everything under them, except that with contents only on a slice inside a frame or group exports only that frame or group; sides round up to whole pixels | — |
|
||||
| PNG | yes, scales/suffixes | yes | export settings per layer, presets (`2x`, `512w`, `512h`), preview, "Export N layers"; ignore overlapping layers (on by default; off: every layer showing in the area) and include bounding box (the layer's own box; the Export section offers it for text layers, and `file.export` and `export.update` take it for any layer); slashes in layer names export into folders. The exported area is what the layer paints: shadows towards their offset, outside and centre strokes, unclipped children, a mask group's mask, nothing for hidden layers; slices export everything under them, except that with ignore overlapping layers on a slice inside a frame or group exports only that frame or group; sides round up to whole pixels | — |
|
||||
| JPG | yes | yes | white where transparent; fixed quality (90) | — |
|
||||
| SVG | yes (outline text option, ids) | yes | alpha/luminance masks kept; noise, texture, progressive and background blur, glass embedded as images; outline text (off: `<text>` with one `<tspan>` per line, one style per layer), unique id attributes from layer names, explicit depth-limit refusal, nested frames clipped by their parent's clip (#327), simplified strokes (stroke attributes for centred strokes and for inside and outside strokes on boxes and ellipses) | — |
|
||||
| PDF | yes | yes | one page per frame, real selectable text in subset fonts, links, vectors and gradients as vectors, effects as images; bleed, crop/bleed marks, page boxes, naive CMYK; checked by rendering the PDF back against PNG exports; explicit depth-limit refusal | CMYK output intent 2–4 |
|
||||
@@ -165,6 +165,7 @@ each.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-25 | patch | PNG: the export settings were listed as "contents only" and "include bounding box (the layer's own box)"; the row now names ignore overlapping layers, on by default, and says that the Export section offers include bounding box for text layers (#1307), while `file.export` and `export.update` take it for any layer. Numbers unchanged |
|
||||
| 2026-10-24 | patch | Fonts: the missing-font row said "warnings only (REST import)"; it now names the Missing fonts window (#752), `text.missingFonts` and the `missing font` lines of `figcraft-cli info`. Replacing a missing font is still missing. Numbers unchanged |
|
||||
| 2026-10-24 | minor | Slices and contents only (Figma's Ignore overlapping layers): with it on, a slice inside a frame or group exports only that frame or group and the layers in it, and its Export preview shows the same; with it off, or for a slice on the page or in a section, the slice exports everything in its area as before |
|
||||
| 2026-10-24 | minor | Boards (M12): `.figcraft` saves the file kind (`kind: "board"`, absent for design files) and each board object's `board` metadata; the `.jam` row points at boards |
|
||||
|
||||
+53
-15
@@ -309,7 +309,7 @@ Or in a project's `.mcp.json`:
|
||||
|---|---|
|
||||
| `list_commands {filter?}` | Every command: id, label, menu, shortcut, undoable, params doc. |
|
||||
| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `component.makeSlot` / `instance.slotInsert` / `instance.resetSlot` (slots, below), `inspect.code` (CSS, HTML + CSS for a whole frame, React, Leptos, SwiftUI or Jetpack Compose for a layer; components take their properties as parameters and instances are calls, see Dev Mode below), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names; `colorFormat`: hex, rgb, css, hsl or hsb), `inspect.assets` (layers under the selection with export settings, icons and images, each with its `kind`), `inspect.code` takes `unit: rem` and `rootFontSize` for CSS, HTML, React and Leptos, `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}`, `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`, `codeConnect.set` (components linked to real code, see Dev Mode below)). |
|
||||
| `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`); all-or-nothing for open documents and their document state. Failed inner commands are removed from the journal; preferences, clipboard, tool state and UI requests are not rolled back. A step can use what an earlier step returned: `{"$step": n}` anywhere in its params becomes the `id` in step n's result (zero-based), and `{"$step": n, "field": "…"}` another field of it (see Step references below). |
|
||||
| `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`); all-or-nothing for open documents and their document state. Failed inner commands are removed from the journal; preferences, clipboard, tool state and UI requests are not rolled back. A step can use what an earlier step returned: name it with `"as": "card"` and `{"$ref": "card"}` anywhere in a later step's params becomes the `id` in its result (`{"$step": n}` does the same by zero-based position); `"field"` takes another key or a slash path such as `ids/0` (see Step references below). |
|
||||
| `create_node {type, x, y, width, height, parent, index, name, text, path, props}` | Create a frame, rectangle, ellipse, polygon, star, line, arrow, text, vector, section, component or slice; returns `{id, node}`. |
|
||||
| `set_properties {ids, props}` | `node.set` on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, masks (`isMask`, `maskType: alpha\|vector\|luminance`), handoff status (`devStatus: none\|readyForDev\|completed`; marking again clears "Changed"), a section's `sectionContentsHidden`, text…); returns the updated layers. |
|
||||
| `inspect_document {depth, allPages}` | The document as JSON (layer tree, styles, selection, history). |
|
||||
@@ -1408,6 +1408,22 @@ permissions: without it nothing is downloaded); `plugin.directory.publish {path
|
||||
|
||||
### Dev Mode
|
||||
|
||||
- **Pages as React + Tailwind:** `inspect.code {id, language: "tailwind"}` writes a layer tree as a
|
||||
React function component styled with Tailwind CSS v4 utilities and no stylesheet. Auto layout
|
||||
becomes `flex`/`flex-col`/`gap-*`/`p-*` on the 4px spacing scale, fixed-width auto layout frames
|
||||
and text become fluid (`w-full max-w-[960px]`; as wide as the frame: `w-full`) and a frame's
|
||||
fixed height a minimum, per-side strokes become `border-b` and the like, and instances are
|
||||
inlined with their overrides (component code is `language: react`), except instances whose component
|
||||
is connected to code (`codeConnect.set`/`codeConnect.import`, React): those are their snippet, e.g.
|
||||
`<Button variant="outline">{"See examples"}</Button>`, with each import once at the top. A design
|
||||
system mapped to shadcn/ui this way exports pages that use the project's own components. Bound variables become theme
|
||||
utilities (`bg-accent`, `rounded-card`, `text-muted`) that `variable.export {format: "tailwind"}`
|
||||
declares: `@import "tailwindcss";` and an `@theme static` block (every token is emitted, also those only read through `var()`) named by namespace (`--color-*`,
|
||||
`--radius-*`, `--text-*`, `--spacing-*`, `--font-*`; a number's namespace and unit come from what
|
||||
layers bind it to), other modes on `[data-<collection>="<mode>"]`. With `mobile: <frame id>` (the
|
||||
same layer names, mobile layout) the code is mobile first: the mobile frame's classes, the
|
||||
desktop frame's that differ behind `lg:`, and layers only on desktop `hidden` plus `lg:flex`
|
||||
(auto layout frames) or `lg:block` (other layers).
|
||||
- **Component code:** `inspect.code {id, language: react|leptos|swiftui|compose}` on a component,
|
||||
a component set or a variant writes the component as a reusable unit: React (TSX) with a props
|
||||
interface, Leptos with a `#[component]` function (`#[prop]` defaults, `view!` markup), SwiftUI
|
||||
@@ -1825,25 +1841,47 @@ Align and distribute as one undo step:
|
||||
|
||||
### Step references
|
||||
|
||||
A step in a `batch` can use what an earlier step returned. `{"$step": n}` anywhere in a step's
|
||||
params (a value, an item in `ids`, a nested field) is replaced by the `id` in the result of step `n`
|
||||
(zero-based) before the step runs; `{"$step": n, "field": "name"}` takes another field of that
|
||||
result. This builds a nested tree in one call and one undo step, without guessing ids:
|
||||
A step in a `batch` can use what an earlier step returned. Give the step a name with `"as"` (next to
|
||||
`command` and `params`), and `{"$ref": "name"}` anywhere in a later step's params (a value, an item
|
||||
in `ids`, a nested field) is replaced by the `id` in that step's result before the later step runs.
|
||||
This builds a nested tree in one call and one undo step, without guessing ids or counting steps:
|
||||
|
||||
```json
|
||||
{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"batch","arguments":{"label":"Card","commands":[
|
||||
{"command":"node.create","params":{"type":"frame","name":"Card","props":{"layout":"vertical","gap":12}}},
|
||||
{"command":"node.create","params":{"type":"text","parent":{"$step":0},"text":"Ada Lovelace"}},
|
||||
{"command":"node.create","params":{"type":"frame","parent":{"$step":0},"name":"Button"}},
|
||||
{"command":"node.create","params":{"type":"text","parent":{"$step":2},"text":"Follow"}}]}}}
|
||||
{"command":"node.create","as":"card","params":{"type":"frame","name":"Card","props":{"layout":"vertical","gap":12}}},
|
||||
{"command":"node.create","params":{"type":"text","parent":{"$ref":"card"},"text":"Ada Lovelace"}},
|
||||
{"command":"node.create","as":"button","params":{"type":"frame","parent":{"$ref":"card"},"name":"Button"}},
|
||||
{"command":"node.create","params":{"type":"text","parent":{"$ref":"button"},"text":"Follow"}}]}}}
|
||||
```
|
||||
|
||||
A reference to the same or a later step, a negative or non-integer index, a missing `field` or any
|
||||
other key next to `$step` fails the whole batch, which is rolled back. Only an object whose keys are
|
||||
`$step` (and optionally `field`) is a reference: text such as `"$step 0"` is left as written. The
|
||||
`$step` key is reserved in step params: an object holding it anywhere (in plugin data or a variable
|
||||
value too) is read as a reference. A step that is itself a `command.batch` is passed on as written,
|
||||
so the references in its steps are to its own steps, not the outer batch's.
|
||||
`{"$step": n}` refers to a step by its zero-based position instead, and works in the same places;
|
||||
the two can be mixed. Names are any non-empty string and must be unique within the batch.
|
||||
|
||||
`"field"` takes something other than the `id`: a key of the result (`{"$ref": "card", "field":
|
||||
"name"}`) or a path into it, with `/` between the parts and list items by zero-based index, the way
|
||||
`variable.bind` names `fills/0/color`. `edit.duplicate` returns `{"ids": [...]}`, so its first copy is
|
||||
`ids/0`:
|
||||
|
||||
```json
|
||||
{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"batch","arguments":{"label":"Copy","commands":[
|
||||
{"command":"edit.duplicate","as":"copy","params":{"ids":[5]}},
|
||||
{"command":"node.set","params":{"ids":[{"$ref":"copy","field":"ids/0"}],"props":{"name":"Card copy"}}}]}}}
|
||||
```
|
||||
|
||||
A path may start with `/`, as in JSON Pointer (`/ids/0`). A key that itself holds `/` is matched as
|
||||
written before it is read as a path. A path ends at whatever value it reaches, so `"field": "ids"` is
|
||||
the whole list.
|
||||
|
||||
These fail the whole batch, which is rolled back, with an error naming the step: two steps with the
|
||||
same `as` (before any step runs); a `$ref` to a name no step has (the error lists the names there
|
||||
are) or to the same or a later step; a `$step` to the same or a later step, or that is negative or
|
||||
not an integer; a `field` the result doesn't have (the error names the full path and, for a list,
|
||||
how many items it has); any key next to `$ref` or `$step` other than `field`. Only an object holding
|
||||
a `$ref` or `$step` key is a reference: text such as `"$ref card"` is left as written. Both keys are
|
||||
reserved in step params: an object holding either anywhere (in plugin data or a variable value too)
|
||||
is read as a reference. A step that is itself a `command.batch` is passed on as written, so the
|
||||
references in its steps are to its own steps and names, not the outer batch's; the outer batch can
|
||||
reach into its result list with a path (`{"$ref": "inner", "field": "1/id"}`).
|
||||
|
||||
The end-to-end tests in `crates/mcp/tests/it/agent_tasks.rs` are worked examples of realistic tasks
|
||||
(auto layout, a card built in one batch with step references, components and overrides, gradients
|
||||
|
||||
+2
-2
@@ -336,8 +336,8 @@ the address it came from and when it was last opened (for expiry).
|
||||
beside it, flushed, then renamed over the old one. The log is only ever appended to, and synced
|
||||
before the server tells anyone about the change.
|
||||
- **Compaction** writes the file's state as the next snapshot and starts an empty log, after
|
||||
`compact_after` entries or `compact_mb` of log, when the last person leaves, and when the server
|
||||
stops. A log left over from a compaction cut short is replayed over the snapshot, which already
|
||||
`compact_after` entries or `compact_mb` of log, when the last person leaves a file that was
|
||||
edited (the ids of people who only joined stay in the log), and when the server stops. A log left over from a compaction cut short is replayed over the snapshot, which already
|
||||
holds it all, so nothing changes. If a damaged generation field made a current log look like such
|
||||
a leftover, its edits the snapshot lacks are recovered the same way and the damage is reported.
|
||||
- **Damage is skipped and reported, never deleted.** At startup a folder whose description, links
|
||||
|
||||
Reference in New Issue
Block a user