mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-14 06:44:43 +00:00
Compare commits
467
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f979878d9e | ||
|
|
5f0fe486db | ||
|
|
64efe12e6f | ||
|
|
4bb30782ff | ||
|
|
8a46478e81 | ||
|
|
11ecf3e887 | ||
|
|
b82369201d | ||
|
|
e3b20b6540 | ||
|
|
b17bea9195 | ||
|
|
287b506bc1 | ||
|
|
05da6b2125 | ||
|
|
5617c94167 | ||
|
|
9ee0c7f572 | ||
|
|
0f95ce3d5b | ||
|
|
a7d0648958 | ||
|
|
309271851f | ||
|
|
f99fdea3b6 | ||
|
|
5d32c522d2 | ||
|
|
cc4799061e | ||
|
|
5b5a4855d4 | ||
|
|
ddde77db0a | ||
|
|
89529b3d00 | ||
|
|
ca0e689834 | ||
|
|
4c1546eed2 | ||
|
|
b19a81084e | ||
|
|
20a16dfd5c | ||
|
|
40fe1a623e | ||
|
|
4b030f027a | ||
|
|
6369b6b7a5 | ||
|
|
9d9cb9915a | ||
|
|
1abb9083c7 | ||
|
|
4788c6b819 | ||
|
|
e8a6033199 | ||
|
|
54e3587fd8 | ||
|
|
ba4a736d5b | ||
|
|
163e840fc3 | ||
|
|
b5904ad4c2 | ||
|
|
413ec40932 | ||
|
|
d32e971685 | ||
|
|
f8fc12b981 | ||
|
|
e4e872a489 | ||
|
|
d43375b69c | ||
|
|
227e182f68 | ||
|
|
f6090dde80 | ||
|
|
fbb4a22e01 | ||
|
|
7d0ecd2294 | ||
|
|
948c6fb8e6 | ||
|
|
da1f0a0484 | ||
|
|
baf93b7756 | ||
|
|
9c2988d408 | ||
|
|
c13c4301be | ||
|
|
df99f364a7 | ||
|
|
f22e2ed825 | ||
|
|
4e8f258300 | ||
|
|
8aaff7bc22 | ||
|
|
a8fa1f0fb4 | ||
|
|
e8b7917002 | ||
|
|
7b478052d1 | ||
|
|
bc35f02ecb | ||
|
|
66a8227bb4 | ||
|
|
7814c32a05 | ||
|
|
fbc660b8b7 | ||
|
|
08880f6e26 | ||
|
|
704eb1cc0a | ||
|
|
d54d107924 | ||
|
|
cbb95c6412 | ||
|
|
7b22438739 | ||
|
|
a1369310d4 | ||
|
|
85c036624d | ||
|
|
b9bfc51273 | ||
|
|
bf917a4c4f | ||
|
|
b51c9b68d7 | ||
|
|
b23d6b917b | ||
|
|
54ddb2a200 | ||
|
|
6382be2197 | ||
|
|
4c21d2df39 | ||
|
|
3335612c12 | ||
|
|
3afd896e2e | ||
|
|
0e0c3c7be4 | ||
|
|
3ed4578112 | ||
|
|
16f6b92519 | ||
|
|
f3c211eca5 | ||
|
|
088693cfd3 | ||
|
|
9ea757736f | ||
|
|
ab5a508538 | ||
|
|
8519af2539 | ||
|
|
4f1da1ebe6 | ||
|
|
ec9bb5f978 | ||
|
|
b29e17f5c0 | ||
|
|
458cd5b141 | ||
|
|
7ca8c80a8f | ||
|
|
20e4028afa | ||
|
|
a9975ccc22 | ||
|
|
8c7f40fc18 | ||
|
|
21d5090db2 | ||
|
|
9877b8e6cc | ||
|
|
22ef4fdd99 | ||
|
|
409de1a762 | ||
|
|
e358c695cc | ||
|
|
997444cdb6 | ||
|
|
3d0a93c1a1 | ||
|
|
ce615ada10 | ||
|
|
97f2c99d6b | ||
|
|
381ec85203 | ||
|
|
7543759150 | ||
|
|
7614567502 | ||
|
|
f2ede63dd9 | ||
|
|
d087645a00 | ||
|
|
75784f24d6 | ||
|
|
08c3f8f944 | ||
|
|
bb1fd20114 | ||
|
|
ba496eddca | ||
|
|
ec512a7767 | ||
|
|
742e911652 | ||
|
|
99a8ef0e26 | ||
|
|
1fdd95070e | ||
|
|
bb793470f1 | ||
|
|
109b90c0aa | ||
|
|
cfbb5a8f50 | ||
|
|
9fddc4b058 | ||
|
|
fb0e25a258 | ||
|
|
6325a323ee | ||
|
|
7126deaed0 | ||
|
|
203ec13e03 | ||
|
|
4cc2230013 | ||
|
|
b8aa1ef8f0 | ||
|
|
17da39a5ea | ||
|
|
95483273e3 | ||
|
|
163c293b8c | ||
|
|
cecc2865b0 | ||
|
|
44a504ddcf | ||
|
|
be38c68ac5 | ||
|
|
113d7ec106 | ||
|
|
afa431b47a | ||
|
|
8450d18c8e | ||
|
|
5a4b7c2795 | ||
|
|
e1da3f36bb | ||
|
|
94291f13e8 | ||
|
|
624795ba38 | ||
|
|
3d109bae1e | ||
|
|
80c8c7fa87 | ||
|
|
b744ff3e14 | ||
|
|
bafc814712 | ||
|
|
5738ce673c | ||
|
|
0008fc7901 | ||
|
|
479313bf67 | ||
|
|
07eb207d7c | ||
|
|
25899735a8 | ||
|
|
09427e72be | ||
|
|
2f14eeda60 | ||
|
|
46ce6102d8 | ||
|
|
1e1b59280d | ||
|
|
918a28a4f4 | ||
|
|
97cd06fd14 | ||
|
|
ee99a89e08 | ||
|
|
9c7b315301 | ||
|
|
b55f009198 | ||
|
|
ca3832c0d9 | ||
|
|
3e02bffbbc | ||
|
|
4b12a82839 | ||
|
|
258d6f61c0 | ||
|
|
df42e380f2 | ||
|
|
d2367d1eac | ||
|
|
ccddaa74cd | ||
|
|
82a50dad1b | ||
|
|
6a163721dc | ||
|
|
41c123657e | ||
|
|
0108a7c2f5 | ||
|
|
b7c9c712b1 | ||
|
|
710c95e527 | ||
|
|
6e7b63bc16 | ||
|
|
3e83ae44b7 | ||
|
|
40d7ce14ce | ||
|
|
b930ea5871 | ||
|
|
1b66fec3ec | ||
|
|
e17fccd7ab | ||
|
|
9f895a37d5 | ||
|
|
7af44512e7 | ||
|
|
9e4dcc9204 | ||
|
|
692f45123c | ||
|
|
855bbb328f | ||
|
|
fd02772d22 | ||
|
|
2af54a26fb | ||
|
|
d2dfe2ef20 | ||
|
|
139e0ba308 | ||
|
|
5d40b3f42f | ||
|
|
be212f00e6 | ||
|
|
be9e72f74d | ||
|
|
3b7b28c03c | ||
|
|
caf0f44d90 | ||
|
|
4e4bf54bb6 | ||
|
|
6f90717e71 | ||
|
|
147a374350 | ||
|
|
017517fbb9 | ||
|
|
f97789ade1 | ||
|
|
c530b4067d | ||
|
|
544b2455c9 | ||
|
|
4a54aa733f | ||
|
|
63dc418cc2 | ||
|
|
847e610550 | ||
|
|
79ac16a256 | ||
|
|
85c5286d39 | ||
|
|
3878f89bd6 | ||
|
|
a0d1162976 | ||
|
|
23d0d9e49e | ||
|
|
0a3f2341a8 | ||
|
|
98a1561183 | ||
|
|
35cc685e90 | ||
|
|
39f30d63c8 | ||
|
|
7fcb193f2a | ||
|
|
c3cb8c3e0e | ||
|
|
02e8f644fd | ||
|
|
54458cfb20 | ||
|
|
309bac6185 | ||
|
|
72990373c4 | ||
|
|
aec3a22fdf | ||
|
|
7a5bd57611 | ||
|
|
7558ac0444 | ||
|
|
bc10bb190d | ||
|
|
f03fc476c8 | ||
|
|
33c0f94cc8 | ||
|
|
ba4c502ada | ||
|
|
972e228f41 | ||
|
|
b778ba17cd | ||
|
|
bbf0d62863 | ||
|
|
4506f49ad4 | ||
|
|
a3e9af917d | ||
|
|
32bc822451 | ||
|
|
4d9d12ea13 | ||
|
|
a6659dcdfc | ||
|
|
2c3ea03c2e | ||
|
|
d8ff1441b1 | ||
|
|
9ae3223d22 | ||
|
|
d71f24741a | ||
|
|
c7261cb907 | ||
|
|
d3327d79f3 | ||
|
|
4707cb1579 | ||
|
|
018b8166b2 | ||
|
|
86e91d0b71 | ||
|
|
d1dad2e1c0 | ||
|
|
fec902b7d5 | ||
|
|
057bf9a289 | ||
|
|
f1d336249a | ||
|
|
55aa0165fd | ||
|
|
836d6f2fa6 | ||
|
|
1c3e175f66 | ||
|
|
aff3c4f18b | ||
|
|
adb23f0ff1 | ||
|
|
53c8308b66 | ||
|
|
a4c7e259ab | ||
|
|
de2915908c | ||
|
|
34b08ac8bf | ||
|
|
e9565813ac | ||
|
|
c87baa37cc | ||
|
|
911831fb89 | ||
|
|
be47f99534 | ||
|
|
0b08ea1127 | ||
|
|
61ccfe18a8 | ||
|
|
d1125130f6 | ||
|
|
ddd42b36bd | ||
|
|
6743c68203 | ||
|
|
caa3c5afce | ||
|
|
8d3a3e6171 | ||
|
|
d7a27c593e | ||
|
|
7d51d0515d | ||
|
|
13fdae3d44 | ||
|
|
ef535e49b0 | ||
|
|
e35f8eb1b7 | ||
|
|
55fbcd0d7e | ||
|
|
8629a6f586 | ||
|
|
99e5db399b | ||
|
|
247530b60e | ||
|
|
ae89635e00 | ||
|
|
633455c380 | ||
|
|
846a9f7214 | ||
|
|
1c158ed81b | ||
|
|
c3a21a6ab3 | ||
|
|
976bbb84ef | ||
|
|
78fd30ab5f | ||
|
|
4458eb8802 | ||
|
|
d1619f57bd | ||
|
|
eac260f160 | ||
|
|
a819e0a188 | ||
|
|
3a9610d480 | ||
|
|
ac606a2d43 | ||
|
|
104b80cd58 | ||
|
|
55a0712b11 | ||
|
|
bec13a4d07 | ||
|
|
f90becadab | ||
|
|
6a334fa354 | ||
|
|
2a3ea27605 | ||
|
|
d654a14720 | ||
|
|
d828e6379e | ||
|
|
e46dda1036 | ||
|
|
1768be2965 | ||
|
|
0f8e29bc9f | ||
|
|
47c2b228a5 | ||
|
|
d712f2ffd1 | ||
|
|
9b489a052e | ||
|
|
c8e93ddaaa | ||
|
|
52e3fe2967 | ||
|
|
78222e35d2 | ||
|
|
04e04f3275 | ||
|
|
67610bbba4 | ||
|
|
401af64bba | ||
|
|
27fedf0aa8 | ||
|
|
abd03d9f51 | ||
|
|
bcfaa3daef | ||
|
|
db43dc5a4a | ||
|
|
cf6bd4eeb2 | ||
|
|
e9be17427f | ||
|
|
47438dd4a2 | ||
|
|
84a86e1d84 | ||
|
|
c846f157dc | ||
|
|
1fe78aec66 | ||
|
|
1f0d9a9107 | ||
|
|
dd980c884f | ||
|
|
3f5f673ebc | ||
|
|
c7acd1a81d | ||
|
|
f7188de93d | ||
|
|
37419afe14 | ||
|
|
d574eaac1d | ||
|
|
154baa4467 | ||
|
|
1e7983a550 | ||
|
|
bc5a2b8fa7 | ||
|
|
e25c48cd56 | ||
|
|
42242e3c8b | ||
|
|
89c5d3dfe6 | ||
|
|
7fd936c8ba | ||
|
|
cbf6d2df41 | ||
|
|
cc12b8a3ec | ||
|
|
7ee2772da8 | ||
|
|
694c3114e8 | ||
|
|
a7352c1860 | ||
|
|
913634bec8 | ||
|
|
4a038a5f63 | ||
|
|
7b9ebaacbb | ||
|
|
76f0edce08 | ||
|
|
a7da760f9c | ||
|
|
f33ffb4367 | ||
|
|
e03c2a65f7 | ||
|
|
43bc7a3032 | ||
|
|
dc184083ef | ||
|
|
1c16aeae09 | ||
|
|
a672a6650f | ||
|
|
9265d554af | ||
|
|
c8b611a802 | ||
|
|
1d928a189c | ||
|
|
03bb164b5c | ||
|
|
91c80a19cc | ||
|
|
68b19422ad | ||
|
|
9a69f79861 | ||
|
|
0a01188c31 | ||
|
|
6afcfc2641 | ||
|
|
cae69a7ae8 | ||
|
|
6ce46b3532 | ||
|
|
a3c5025186 | ||
|
|
b84ee179d4 | ||
|
|
c6d6433473 | ||
|
|
825cb1e7af | ||
|
|
bb8b809d46 | ||
|
|
5962b630a1 | ||
|
|
df66ddd3a4 | ||
|
|
e39d24a32e | ||
|
|
c532053396 | ||
|
|
b8f97a5f4f | ||
|
|
2c350ca7fd | ||
|
|
8b3beac6bf | ||
|
|
ba433cc7b0 | ||
|
|
84ffdfc786 | ||
|
|
137425ab25 | ||
|
|
468bc5734d | ||
|
|
8e52c8cac2 | ||
|
|
8495309ea2 | ||
|
|
420ef78842 | ||
|
|
d254d5a6ca | ||
|
|
acfc0352c3 | ||
|
|
725e2c6913 | ||
|
|
e77a02993e | ||
|
|
88925c2f82 | ||
|
|
89174280d1 | ||
|
|
cc10c0de55 | ||
|
|
6b52bddc5b | ||
|
|
8aa5fd1a3b | ||
|
|
f84616702d | ||
|
|
af27ff7d2a | ||
|
|
17758458ef | ||
|
|
2efdeb593b | ||
|
|
96091494a5 | ||
|
|
ac4b98dbd7 | ||
|
|
ab1ae981f3 | ||
|
|
9417d13b26 | ||
|
|
c542af4efe | ||
|
|
23fbd8f935 | ||
|
|
adde034a7c | ||
|
|
64423c58bc | ||
|
|
6cdfe2ab8e | ||
|
|
2e3949f0ba | ||
|
|
fdd4eb5cad | ||
|
|
18b8bc11e9 | ||
|
|
b64ec66244 | ||
|
|
82ae9b1f59 | ||
|
|
1336d449d3 | ||
|
|
98e47487f4 | ||
|
|
28a23a9d1d | ||
|
|
2ebc496d07 | ||
|
|
6a76d4fa85 | ||
|
|
c448b85779 | ||
|
|
41abce53b7 | ||
|
|
05ecbe8d49 | ||
|
|
bacca0a0f0 | ||
|
|
e8fc6d410e | ||
|
|
05d4f1903b | ||
|
|
feeb9f71db | ||
|
|
7482410753 | ||
|
|
3365ae0370 | ||
|
|
0fe7e1e7db | ||
|
|
450faf7269 | ||
|
|
00b0b08680 | ||
|
|
e095ef1fe1 | ||
|
|
b377fab0a5 | ||
|
|
990e621ec4 | ||
|
|
5e2913db34 | ||
|
|
415722439a | ||
|
|
54bb194531 | ||
|
|
5b963dce08 | ||
|
|
da76c806cc | ||
|
|
ff418eaaac | ||
|
|
b044038857 | ||
|
|
32266dcd93 | ||
|
|
efe4911eb0 | ||
|
|
3065cd6da9 | ||
|
|
ac1a192a3a | ||
|
|
805a121e15 | ||
|
|
e1d9f24312 | ||
|
|
7d7a724764 | ||
|
|
52eb736241 | ||
|
|
be3912f986 | ||
|
|
4484c2d1b0 | ||
|
|
6d3e7aad20 | ||
|
|
afdb21bad6 | ||
|
|
a4156d2910 | ||
|
|
5be9a8caef | ||
|
|
1ac0e475c1 | ||
|
|
b525fde8d1 | ||
|
|
9c1d65d924 | ||
|
|
f05eecdaa0 | ||
|
|
8cccc48516 | ||
|
|
c6a3f25224 | ||
|
|
b83a0de7a8 | ||
|
|
c8ba7b4a4b | ||
|
|
ce6726aad3 | ||
|
|
692d52c514 | ||
|
|
fd24a6b883 | ||
|
|
35005a571d | ||
|
|
97d09369d1 | ||
|
|
69154a03f1 | ||
|
|
9809caaa30 | ||
|
|
7a5885cde9 | ||
|
|
d0c1c12969 | ||
|
|
1e0641a0bd | ||
|
|
48b62a8b95 | ||
|
|
3d90d8edfe | ||
|
|
93a75a4720 | ||
|
|
39d0f2d872 | ||
|
|
7a8e5373b1 | ||
|
|
1ddc2c338b |
No files matched your search
@@ -0,0 +1,32 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Maintain Rust / Cargo dependencies
|
||||
- package-ecosystem: "cargo"
|
||||
directories:
|
||||
- "/"
|
||||
- "/plugins"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
# Consolidate updates into a single PR rather than one PR per crate
|
||||
groups:
|
||||
cargo-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
# Maintain GitHub Actions workflows
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "ci(deps)"
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
name: test (ubuntu-latest)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install Linux system dependencies (egui/winit/rfd)
|
||||
run: |
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
name: wasm32 check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
@@ -32,4 +32,4 @@ jobs:
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
with:
|
||||
run-package-check: true
|
||||
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
|
||||
@@ -24,8 +24,14 @@ jobs:
|
||||
with:
|
||||
app-name: figcraft
|
||||
app-title: FigCraft
|
||||
# Needs fonts/latin-manifest.txt (Inter): crates/text/build.rs fails a release without it. Only
|
||||
# craft-fonts' pr/latin-presentation-fonts has it; its main has Noto Sans Georgian but no Inter.
|
||||
# Re-pin once one commit has both (storytold/craft-fonts#42).
|
||||
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
has-server: true
|
||||
# The browser has no system fonts: without craft-fonts the web build can't draw text (#885).
|
||||
# crates/text/build.rs keeps the web build to Inter.
|
||||
web-craft-fonts: true
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
|
||||
@@ -25,6 +25,7 @@ Release builds embed fonts from the optional craft-fonts build input
|
||||
| Family | Licence | Upstream |
|
||||
|---|---|---|
|
||||
| Inter | SIL Open Font License 1.1 | https://github.com/rsms/inter |
|
||||
| Archivo | SIL Open Font License 1.1 | [Google Fonts / Archivo](https://github.com/google/fonts/tree/6c70c829f09ea345d3590406693220ea35c6553f/ofl/archivo) |
|
||||
| Roboto, Montserrat, Lato, Open Sans, Poppins, Nunito Sans, Merriweather, Playfair Display, Source Sans 3, Source Serif 4, Carlito, Caladea | SIL Open Font License 1.1 | Google Fonts / upstream repositories (see craft-fonts) |
|
||||
| Liberation Sans, Serif, Mono | SIL Open Font License 1.1 | https://github.com/liberationfonts |
|
||||
| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic | SIL Open Font License 1.1 | Google Fonts (see craft-fonts) |
|
||||
|
||||
Generated
+72
-26
@@ -856,6 +856,11 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
@@ -1408,7 +1413,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
|
||||
|
||||
[[package]]
|
||||
name = "figcraft"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"eframe",
|
||||
@@ -1422,17 +1427,20 @@ dependencies = [
|
||||
"fmv-macos-events",
|
||||
"image",
|
||||
"log",
|
||||
"raw-window-handle",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"wat",
|
||||
"wayland-client",
|
||||
"webbrowser",
|
||||
"windows",
|
||||
"winit",
|
||||
"winresource",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-cli"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-engine",
|
||||
@@ -1446,7 +1454,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-collab"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-color",
|
||||
@@ -1461,7 +1469,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-collab-net"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-collab",
|
||||
@@ -1484,7 +1492,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-color"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1493,7 +1501,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-doc"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-geom",
|
||||
@@ -1505,9 +1513,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-engine"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"craft-docpath",
|
||||
"figcraft-collab",
|
||||
"figcraft-color",
|
||||
"figcraft-doc",
|
||||
@@ -1532,7 +1541,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-format"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-pathops",
|
||||
@@ -1547,7 +1556,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-geom"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1555,7 +1564,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-layout"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1563,7 +1572,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-mcp"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-collab-net",
|
||||
@@ -1579,7 +1588,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-pathops"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1588,7 +1597,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-pdf"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-doc",
|
||||
@@ -1600,12 +1609,12 @@ dependencies = [
|
||||
"krilla",
|
||||
"log",
|
||||
"thiserror 2.0.21",
|
||||
"tiny-skia",
|
||||
"tiny-skia 0.12.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-plugin"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"serde",
|
||||
@@ -1618,7 +1627,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-render"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"figcraft-color",
|
||||
@@ -1631,12 +1640,12 @@ dependencies = [
|
||||
"moxcms",
|
||||
"png",
|
||||
"rayon",
|
||||
"tiny-skia",
|
||||
"tiny-skia 0.12.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-server"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"ctrlc",
|
||||
@@ -1660,7 +1669,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-svg"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-color",
|
||||
@@ -1676,7 +1685,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-text"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1690,7 +1699,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-ui-egui"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"egui",
|
||||
@@ -1715,7 +1724,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-web"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -2570,7 +2579,7 @@ dependencies = [
|
||||
"skrifa 0.42.1",
|
||||
"smallvec",
|
||||
"subsetter",
|
||||
"tiny-skia-path",
|
||||
"tiny-skia-path 0.12.0",
|
||||
"xmp-writer",
|
||||
"yoke",
|
||||
"zune-jpeg",
|
||||
@@ -4018,6 +4027,17 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "sctk-adwaita"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
|
||||
dependencies = [
|
||||
"log",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"tiny-skia 0.11.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "self_cell"
|
||||
version = "1.3.0"
|
||||
@@ -4477,6 +4497,20 @@ dependencies = [
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"bytemuck",
|
||||
"cfg-if",
|
||||
"log",
|
||||
"tiny-skia-path 0.11.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia"
|
||||
version = "0.12.0"
|
||||
@@ -4489,7 +4523,18 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"log",
|
||||
"png",
|
||||
"tiny-skia-path",
|
||||
"tiny-skia-path 0.12.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia-path"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"bytemuck",
|
||||
"strict-num",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4778,7 +4823,7 @@ dependencies = [
|
||||
"skrifa 0.44.0",
|
||||
"strict-num",
|
||||
"svgtypes",
|
||||
"tiny-skia-path",
|
||||
"tiny-skia-path 0.12.0",
|
||||
"unicode-bidi",
|
||||
"unicode-script",
|
||||
"unicode-vo",
|
||||
@@ -5731,6 +5776,7 @@ dependencies = [
|
||||
"raw-window-handle",
|
||||
"redox_syscall 0.4.1",
|
||||
"rustix 0.38.44",
|
||||
"sctk-adwaita",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"smol_str",
|
||||
"tracing",
|
||||
@@ -5888,7 +5934,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"figcraft-engine",
|
||||
"serde_json",
|
||||
|
||||
+4
-1
@@ -6,7 +6,7 @@ exclude = ["plugins"]
|
||||
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -32,6 +32,9 @@ figcraft-mcp = { path = "crates/mcp" }
|
||||
figcraft-collab-net = { path = "crates/collab-net" }
|
||||
figcraft-plugin = { path = "crates/plugin" }
|
||||
|
||||
# Which paths stored in a document may be followed (craft-libs).
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
|
||||
|
||||
# foundation
|
||||
kurbo = { version = "0.13", features = ["serde"] }
|
||||
thiserror = "2"
|
||||
|
||||
@@ -20,3 +20,34 @@ licence texts.
|
||||
Rust crate dependencies keep their own licenses (MIT, Apache-2.0, BSD, ISC, Zlib, OFL for egui's
|
||||
default fonts, CDLA-Permissive-2.0 for the root certificate list webpki-roots compiles in, and
|
||||
similar permissive licenses); see each crate's metadata.
|
||||
|
||||
Linux Wayland window decorations
|
||||
-------------------------------
|
||||
sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita,
|
||||
commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux
|
||||
for vector window controls. Its title-font features are disabled: no Cantarell
|
||||
font is embedded by this dependency and no font file is added to this repo.
|
||||
|
||||
sctk-adwaita's MIT licence:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Bartłomiej Maryńczak
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -10,7 +10,7 @@
|
||||
<h1 align="center">FigCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
<b>Interface design; an open-source, clean-room reimplementation of Figma, rebuilt in pure Rust.</b><br>
|
||||
<b>Interface design and prototyping: open source, collaborative and built in pure Rust.</b><br>
|
||||
Frames, auto layout, components and variants, vector networks, styles, variables and prototypes,<br>
|
||||
in a native app that opens instantly and works offline. Open source, and yours.
|
||||
</p>
|
||||
@@ -62,7 +62,7 @@
|
||||
<tr>
|
||||
<td width="25%" valign="top">
|
||||
<h3>🎛️ Familiar by design</h3>
|
||||
The toolbar, layers panel, Design panel, menus and shortcuts sit where your hands expect them. If you know Figma, you already know FigCraft.
|
||||
The toolbar, layers panel, Design panel, menus and shortcuts sit right where your hands expect them.
|
||||
</td>
|
||||
<td width="25%" valign="top">
|
||||
<h3>⚡ Native and fast</h3>
|
||||
@@ -227,7 +227,7 @@ The desktop app also offers a loopback-only control channel (`figcraft --control
|
||||
- **Its own renderer:** tiny-skia on every core for the canvas and exports, with layer blending, effects and masks; booleans from linesweeper, geometry from kurbo, text from skrifa and HarfRust.
|
||||
- **Runs in the browser:** the whole engine and UI compile to WebAssembly.
|
||||
- **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes.
|
||||
- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech and Indonesian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)).
|
||||
- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech, Indonesian and Georgian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)).
|
||||
- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and three example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md).
|
||||
- **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md).
|
||||
- **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md).
|
||||
@@ -262,7 +262,7 @@ New contributors and AI agents: start with [`AGENTS.md`](AGENTS.md), then [`docs
|
||||
Installers for macOS (universal, signed and notarized), Windows (x64, x86 and arm64), Linux (AppImage, `.deb`, `.rpm`, Flatpak and tarball), FreeBSD and the web are attached to each [GitHub release](https://github.com/storytold/figcraft/releases). Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Status:** FigCraft is in **alpha**: about 49% of the way to replacing Figma for real work, by our own estimate. Nearly all of Figma's main menu is wired ([`docs/parity-checklist.md`](docs/parity-checklist.md)) and the core editor works end to end, but FigCraft opens `.fig` files only best effort, has only a first preview of multiplayer (live sessions hosted from the app or a self-hosted server, with per-person undo and share links to edit or to view; no accounts, teams or cloud yet), runs its own WebAssembly plugins rather than Figma's, and hasn't been checked against real Figma documents. The honest, area-by-area picture is in the [roadmap](ROADMAP.md) and [`docs/target-app-parity.md`](docs/target-app-parity.md). Expect rough edges and please file issues, or tell us what broke on [Discord](https://discord.gg/artcraft).
|
||||
> **Status:** FigCraft is in **alpha**: about 49% of the way to replacing proprietary tools for real work, by our own estimate. Nearly all tracked interface design actions in the menu checklist are wired ([`docs/parity-checklist.md`](docs/parity-checklist.md)) and the core editor works end to end, but FigCraft opens `.fig` files only best effort, has only a first preview of multiplayer (live sessions hosted from the app or a self-hosted server, with per-person undo and share links to edit or to view; no accounts, teams or cloud yet), runs its own WebAssembly plugins, and hasn't been checked against real design documents. The honest, area-by-area picture is in the [roadmap](ROADMAP.md) and [`docs/target-app-parity.md`](docs/target-app-parity.md). Expect rough edges and please file issues, or tell us what broke on [Discord](https://discord.gg/artcraft).
|
||||
|
||||
## Privacy and online sharing
|
||||
|
||||
@@ -348,8 +348,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
|
||||
part of this repository and FigCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
|
||||
Forks and modified versions must remove them.
|
||||
|
||||
<sub>Figma is a trademark of Figma, Inc. FigCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Figma, Inc.; the name is used only to describe the workflows FigCraft is compatible with.</sub>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="https://raw.githubusercontent.com/storytold/vectorcraft/main/docs/brand/artcraft-mark.png" width="28"></a><br>
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
|
||||
+22
-13
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
|
||||
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
|
||||
@@ -13,7 +13,7 @@ start with [`docs/gaps.md`](docs/gaps.md).
|
||||
|
||||
| | Value | Kind |
|
||||
|---|---|---|
|
||||
| Menu/command breadth | **100%** (211 / 211) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) |
|
||||
| Menu/command breadth | **100%** (212 / 212) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) |
|
||||
| Feature breadth | **~85%** (core editor ~94%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) |
|
||||
| Ready for real work | **~53%** | estimated, same doc (written dimension weights and scores) |
|
||||
| Essentials user | **~59%** | estimated: core features 77.8% × 0.92 launch/stability × 0.94 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) |
|
||||
@@ -99,25 +99,25 @@ native speakers' reviews of the six language drafts need people.
|
||||
| Language | Code | Strings | Status |
|
||||
|---|---|---|---|
|
||||
| English | en | 100% | full |
|
||||
| Simplified Chinese | zh-Hans | 100% | draft (needs native review) |
|
||||
| Spanish | es, es-419 | 100% (es-419: an overlay of 98 entries) | draft (needs native review) |
|
||||
| Simplified Chinese | zh-Hans | 95% | draft (needs native review) |
|
||||
| Spanish | es, es-419 | 100% (es-419: an overlay of 101 entries) | draft (needs native review) |
|
||||
| Hindi | hi | 0% | none |
|
||||
| Arabic | ar | 0% | none |
|
||||
| French | fr | 100% | draft (needs native review) |
|
||||
| Portuguese (Brazil) | pt-BR | 100% | draft (needs native review) |
|
||||
| Indonesian | id | 0% | none |
|
||||
| Japanese | ja | 100% | draft (needs native review) |
|
||||
| German | de | 100% | draft (needs native review) |
|
||||
| Korean | ko | 100% | draft (needs native review) |
|
||||
| French | fr | 95% | draft (needs native review) |
|
||||
| Portuguese (Brazil) | pt-BR | 95% | draft (needs native review) |
|
||||
| Indonesian | id | 99% | draft (needs native review) |
|
||||
| Japanese | ja | 99% | draft (needs native review) |
|
||||
| German | de | 96% | draft (needs native review) |
|
||||
| Korean | ko | 95% | draft (needs native review) |
|
||||
| Vietnamese | vi | 0% | none |
|
||||
|
||||
Also shipped: Swedish (`sv`: every UI string, 120 of 588 command labels and menu wordings; native review welcome). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
|
||||
Percentages measured 2026-10-17 over 2,272 entries (strings added since a draft show English). Also shipped: Russian (`ru`, 99%, reviewed by VanTan), Swedish (`sv`, 99.6%, command labels since 2026-10-17; native review welcome), Turkish (`tr`, 99%) and Czech (`cs`, 99%, draft). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
|
||||
(Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America),
|
||||
French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts (Preferences ▸
|
||||
French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese and Indonesian drafts (Preferences ▸
|
||||
Language; Traditional Chinese has no catalog; CJK UI
|
||||
text uses an installed CJK font, loaded only when needed; adding a language:
|
||||
[`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping,
|
||||
IME) already works. Detail: [`docs/localization-parity.md`](docs/localization-parity.md).
|
||||
IME) already works (macOS IME composition fixed for #747, not yet checked on a Mac). Detail: [`docs/localization-parity.md`](docs/localization-parity.md).
|
||||
|
||||
## Upcoming
|
||||
|
||||
@@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-17** · Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged.
|
||||
- **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged.
|
||||
- **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged.
|
||||
- **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged.
|
||||
@@ -315,6 +316,14 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-20 | minor | Duplicating a main component makes an instance (#892): Duplicate and Alt-drag copied a main component as a second main; within its file the copy is now an instance of it, as Copy then Paste already was (a variant duplicated in its set stays a variant). Numbers unchanged. |
|
||||
| 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged |
|
||||
| 2026-10-20 | minor | Layer data reads back (#879): `node.get` (the command, the control method and MCP `get_node`) and `document.inspect` report a layer's or page's `pluginData` as `{key: value}` when it has any, so a tool that keeps state in a file with `node.set {props: {pluginData}}` can read it on its next run (`plugin_data_tests`). Numbers unchanged |
|
||||
| 2026-10-20 | minor | Measuring while nudging (#894): with Alt held over another layer, the arrows nudge the selection (Shift: the big step) and the distances follow every nudge (Alt+arrows used to do nothing); while measuring, the selection's handles and size label hide and both layers keep their outlines (`measure_nudge_tests`). Numbers unchanged |
|
||||
| 2026-10-20 | minor | Release fonts (#885): the web release embeds craft-fonts again (Inter), so the browser version draws text and lists fonts; it had none since releases moved to the shared workflow. The craft-fonts pin goes back to the commit with the Latin manifest (the Georgian change had moved it to one without Inter, which would have failed the next release). Numbers unchanged. |
|
||||
| 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). `cargo xtask parity` re-run: 212 / 212 menu items (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Language table re-measured from the catalogs (2,272 entries): the drafts read 95–99% (strings added since 2026-10-12 show English), Indonesian has a draft (99%; it read none), and Russian, Swedish (its command labels from #599), Turkish and Czech are listed with their counts. Per-area parity rows for the day's landings in `docs/gaps.md`, `target-app-parity.md` and the area docs. Numbers unchanged |
|
||||
| 2026-10-17 | minor | Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. |
|
||||
| 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. |
|
||||
| 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. |
|
||||
| 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. |
|
||||
| 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged |
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Minimal std-only argument parsing: positionals, `--key value` / `--key=value` / `-o value`
|
||||
//! options, and boolean `--flag`s. Unknown options are usage errors.
|
||||
//! options (given again: the last counts, or all with [`Args::values`]), and boolean `--flag`s.
|
||||
//! Unknown options are usage errors.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -8,7 +9,7 @@ use crate::{CliError, CliResult};
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Args {
|
||||
pub positional: Vec<String>,
|
||||
values: BTreeMap<String, String>,
|
||||
values: BTreeMap<String, Vec<String>>,
|
||||
flags: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -34,7 +35,7 @@ impl Args {
|
||||
Some(v) => v,
|
||||
None => it.next().cloned().ok_or_else(|| CliError::Usage(format!("`{arg}` needs a value")))?,
|
||||
};
|
||||
a.values.insert(name.to_string(), v);
|
||||
a.values.entry(name.to_string()).or_default().push(v);
|
||||
} else if flags.contains(&name) && inline.is_none() {
|
||||
a.flags.push(name.to_string());
|
||||
} else {
|
||||
@@ -45,7 +46,12 @@ impl Args {
|
||||
}
|
||||
|
||||
pub fn value(&self, k: &str) -> Option<String> {
|
||||
self.values.get(k).cloned()
|
||||
self.values.get(k).and_then(|v| v.last()).cloned()
|
||||
}
|
||||
|
||||
/// Every value of an option that may be given more than once, in order.
|
||||
pub fn values(&self, k: &str) -> &[String] {
|
||||
self.values.get(k).map_or(&[], Vec::as_slice)
|
||||
}
|
||||
|
||||
pub fn flag(&self, k: &str) -> bool {
|
||||
@@ -62,9 +68,9 @@ impl Args {
|
||||
/// PDF print options: `--bleed LENGTH` (points, or "3mm", "0.125in"), `--crop-marks`,
|
||||
/// `--bleed-marks`, `--cmyk`.
|
||||
pub fn print(&self) -> CliResult<figcraft_doc::PrintSettings> {
|
||||
let bleed = match self.values.get("bleed") {
|
||||
let bleed = match self.value("bleed") {
|
||||
None => 0.0,
|
||||
Some(s) => figcraft_doc::PrintSettings::parse_length(s)
|
||||
Some(s) => figcraft_doc::PrintSettings::parse_length(&s)
|
||||
.filter(|b| *b <= figcraft_doc::PrintSettings::MAX_BLEED)
|
||||
.ok_or_else(|| CliError::Usage(format!("--bleed takes points or a length like 3mm or 0.125in (up to 2in), got `{s}`")))?,
|
||||
};
|
||||
@@ -72,7 +78,7 @@ impl Args {
|
||||
}
|
||||
|
||||
pub fn scale(&self) -> CliResult<f64> {
|
||||
match self.values.get("scale") {
|
||||
match self.value("scale") {
|
||||
None => Ok(1.0),
|
||||
Some(s) => s
|
||||
.trim_end_matches('x')
|
||||
@@ -85,7 +91,7 @@ impl Args {
|
||||
|
||||
/// A comma-separated list of whole numbers (`--layers 1000,10_000`).
|
||||
pub fn usizes(&self, k: &str, default: &[usize]) -> CliResult<Vec<usize>> {
|
||||
match self.values.get(k) {
|
||||
match self.value(k) {
|
||||
None => Ok(default.to_vec()),
|
||||
Some(s) => s
|
||||
.split(',')
|
||||
@@ -110,6 +116,10 @@ mod tests {
|
||||
assert_eq!(a.value("o").as_deref(), Some("out"));
|
||||
assert_eq!(a.scale().unwrap(), 2.0);
|
||||
assert!(a.flag("json"));
|
||||
let a = Args::parse(&v(&["--root", "a", "--root=b"]), &["root"], &[]).unwrap();
|
||||
assert_eq!(a.values("root"), ["a", "b"]);
|
||||
assert_eq!(a.value("root").as_deref(), Some("b"));
|
||||
assert!(a.values("none").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -29,9 +29,20 @@ fn image_format(e: &str) -> Option<ExportFormat> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes through [`figcraft_engine::file_access`] when automation roots are in force, so a
|
||||
/// confined run can only write inside a write root (#833).
|
||||
///
|
||||
/// Unconfined it writes with `std::fs::write`, as it always did. `file_access::write` stages the
|
||||
/// bytes and renames them into place, which **replaces** a symbolic link at `path` instead of
|
||||
/// writing through it to its target -- a change of behaviour for anyone whose output path is a
|
||||
/// link, and nothing to do with confinement. Inside a root the staged write is the right one:
|
||||
/// `Roots::check` has already refused a link that leads out of the root.
|
||||
fn write(path: &str, bytes: &[u8]) -> CliResult<()> {
|
||||
if let Some(dir) = Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(dir).map_err(|e| CliError::Failed(format!("{}: {e}", dir.display())))?;
|
||||
figcraft_engine::file_access::create_dir_all(dir).map_err(|e| CliError::Failed(e.to_string()))?;
|
||||
}
|
||||
if figcraft_engine::file_access::current().is_some() {
|
||||
return figcraft_engine::file_access::write(path, bytes).map_err(|e| CliError::Failed(e.to_string()));
|
||||
}
|
||||
std::fs::write(path, bytes).map_err(|e| CliError::Failed(format!("{path}: {e}")))
|
||||
}
|
||||
@@ -155,6 +166,7 @@ pub fn info(path: &str, as_json: bool) -> CliResult<ExitCode> {
|
||||
Some(json!({"id": p, "name": pn.name, "nodes": nodes, "topLevel": pn.children.len(), "frames": frames}))
|
||||
})
|
||||
.collect();
|
||||
let missing = figcraft_engine::fonts::missing(&doc);
|
||||
let v = json!({
|
||||
"file": path,
|
||||
"name": doc.name,
|
||||
@@ -164,6 +176,7 @@ pub fn info(path: &str, as_json: bool) -> CliResult<ExitCode> {
|
||||
"variables": doc.variables.variables.len(),
|
||||
"images": doc.images.len(),
|
||||
"notes": notes,
|
||||
"missingFonts": missing.iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect::<Vec<_>>(),
|
||||
});
|
||||
if as_json {
|
||||
println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
|
||||
@@ -187,6 +200,10 @@ pub fn info(path: &str, as_json: bool) -> CliResult<ExitCode> {
|
||||
println!(" {} {} \"{}\" {}×{}", f["id"], f["type"].as_str().unwrap_or(""), f["name"].as_str().unwrap_or(""), f["width"], f["height"]);
|
||||
}
|
||||
}
|
||||
for m in &missing {
|
||||
let layers = if m.layers == 1 { "text layer" } else { "text layers" };
|
||||
println!("missing font \"{}\": {} {layers} (drawn in a fallback font)", m.family, m.layers);
|
||||
}
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
|
||||
@@ -238,7 +255,7 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
|
||||
println!("{out}");
|
||||
return Ok(ExitCode::SUCCESS);
|
||||
}
|
||||
std::fs::create_dir_all(out).map_err(|e| CliError::Failed(format!("{out}: {e}")))?;
|
||||
figcraft_engine::file_access::create_dir_all(out).map_err(|e| CliError::Failed(e.to_string()))?;
|
||||
let ids = match target {
|
||||
Some(t) => vec![t],
|
||||
None => {
|
||||
@@ -323,7 +340,8 @@ pub fn parse_script(text: &str) -> Result<Vec<(String, Value)>, String> {
|
||||
}
|
||||
|
||||
pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliResult<ExitCode> {
|
||||
let text = std::fs::read_to_string(script).map_err(|e| CliError::Failed(format!("{script}: {e}")))?;
|
||||
let bytes = figcraft_engine::file_access::read(script).map_err(|e| CliError::Failed(e.to_string()))?;
|
||||
let text = String::from_utf8(bytes).map_err(|_| CliError::Failed(format!("{script}: not UTF-8")))?;
|
||||
let steps = parse_script(&text).map_err(|e| CliError::Failed(format!("{script}: {e}")))?;
|
||||
let mut s = match input {
|
||||
Some(path) => {
|
||||
|
||||
@@ -22,8 +22,9 @@ USAGE:
|
||||
figcraft-cli convert <in> <out>
|
||||
figcraft-cli render <file> -o <out.png> [--scale N] [--page NAME]
|
||||
figcraft-cli run <script.json|script.jsonl> [--in <file>] [--out <file>]
|
||||
(info, export, convert, render and run also take --automation-read-root DIR / --automation-write-root DIR)
|
||||
figcraft-cli commands [--json] [--filter TEXT]
|
||||
figcraft-cli mcp [--attach PORT]
|
||||
figcraft-cli mcp [--attach PORT] [--automation-read-root DIR]... [--automation-write-root DIR]...
|
||||
figcraft-cli plugin publish <folder> --server ws://host:port/[?key=KEY]
|
||||
figcraft-cli perf [--layers N[,N...]] [--instances N[,N...]]
|
||||
figcraft-cli help | --help | --version
|
||||
@@ -42,8 +43,33 @@ crop and bleed marks, CMYK colour). `run` executes a JSON array (or JSON lines)
|
||||
`plugin publish` sends a plugin folder (figcraft-plugin.json, its module, an optional assets/ folder)
|
||||
to a figcraft-server's plugin directory as a new version; the server's key comes from the address or
|
||||
the FIGCRAFT_SERVER_KEY environment variable (which keeps it out of the shell history).
|
||||
`--automation-read-root DIR` / `--automation-write-root DIR` (each may be repeated; the folders
|
||||
must exist) confine the files a subcommand touches: reads only inside a read or write root, writes
|
||||
only inside a write root, everything else refused -- so giving no write root denies writing
|
||||
altogether. They are taken by `mcp`, to confine the agent's commands, and by `info`, `export`,
|
||||
`convert`, `render` and `run`, to confine that one run; without them a subcommand reads and writes
|
||||
as the user who started it, as before. A running app confines itself: start it with
|
||||
`figcraft --control PORT` and the same flags (`--attach` refuses them).
|
||||
See docs/cli.md and docs/mcp.md.";
|
||||
|
||||
/// The automation roots a file subcommand was given, if any. Omitting both leaves the run
|
||||
/// unconfined, as before; giving either confines every file the subcommand touches (#833).
|
||||
const ROOT_FLAGS: [&str; 2] = ["automation-read-root", "automation-write-root"];
|
||||
|
||||
fn roots_of(a: &Args) -> CliResult<Option<figcraft_engine::file_access::Roots>> {
|
||||
let (read, write) = (a.values("automation-read-root"), a.values("automation-write-root"));
|
||||
if read.is_empty() && write.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(figcraft_engine::file_access::Roots::new(read, write).map_err(CliError::Failed)?))
|
||||
}
|
||||
|
||||
/// Runs `f` confined to the roots `a` carries.
|
||||
fn confined<R>(a: &Args, f: impl FnOnce() -> CliResult<R>) -> CliResult<R> {
|
||||
let roots = roots_of(a)?.map(std::sync::Arc::new);
|
||||
figcraft_engine::file_access::confined(roots.as_ref(), f)
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let argv: Vec<String> = std::env::args().skip(1).collect();
|
||||
match run(&argv) {
|
||||
@@ -94,35 +120,38 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
"info" => {
|
||||
let a = Args::parse(rest, &[], &["json"])?;
|
||||
let a = Args::parse(rest, &ROOT_FLAGS, &["json"])?;
|
||||
let file = a.one_positional("info <file>")?;
|
||||
files::info(&file, a.flag("json"))
|
||||
confined(&a, || files::info(&file, a.flag("json")))
|
||||
}
|
||||
"export" => {
|
||||
let a = Args::parse(rest, &["format", "scale", "page", "node", "o", "out", "bleed"], &["crop-marks", "bleed-marks", "cmyk"])?;
|
||||
let valued = [&["format", "scale", "page", "node", "o", "out", "bleed"][..], &ROOT_FLAGS[..]].concat();
|
||||
let a = Args::parse(rest, &valued, &["crop-marks", "bleed-marks", "cmyk"])?;
|
||||
let file = a.one_positional("export <file> -o <out>")?;
|
||||
let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("export needs -o <out>".into()))?;
|
||||
let target =
|
||||
files::Target { format: a.value("format"), scale: a.scale()?, page: a.value("page"), node: a.value("node"), print: a.print()? };
|
||||
files::export(&file, &out, &target)
|
||||
confined(&a, || files::export(&file, &out, &target))
|
||||
}
|
||||
"convert" => {
|
||||
let a = Args::parse(rest, &[], &[])?;
|
||||
let a = Args::parse(rest, &ROOT_FLAGS, &[])?;
|
||||
match a.positional.as_slice() {
|
||||
[i, o] => files::convert(i, o),
|
||||
[i, o] => confined(&a, || files::convert(i, o)),
|
||||
_ => Err(CliError::Usage("convert <in> <out>".into())),
|
||||
}
|
||||
}
|
||||
"render" => {
|
||||
let a = Args::parse(rest, &["o", "out", "scale", "page"], &[])?;
|
||||
let valued = [&["o", "out", "scale", "page"][..], &ROOT_FLAGS[..]].concat();
|
||||
let a = Args::parse(rest, &valued, &[])?;
|
||||
let file = a.one_positional("render <file> -o <out.png>")?;
|
||||
let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("render needs -o <out.png>".into()))?;
|
||||
files::render(&file, &out, a.scale()?, a.value("page").as_deref())
|
||||
confined(&a, || files::render(&file, &out, a.scale()?, a.value("page").as_deref()))
|
||||
}
|
||||
"run" => {
|
||||
let a = Args::parse(rest, &["in", "out"], &[])?;
|
||||
let valued = [&["in", "out"][..], &ROOT_FLAGS[..]].concat();
|
||||
let a = Args::parse(rest, &valued, &[])?;
|
||||
let script = a.one_positional("run <script.json|.jsonl>")?;
|
||||
files::run_script(&script, a.value("in").as_deref(), a.value("out").as_deref())
|
||||
confined(&a, || files::run_script(&script, a.value("in").as_deref(), a.value("out").as_deref()))
|
||||
}
|
||||
"commands" => {
|
||||
let a = Args::parse(rest, &["filter"], &["json"])?;
|
||||
@@ -133,11 +162,20 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
"mcp" => {
|
||||
let a = Args::parse(rest, &["attach"], &[])?;
|
||||
let valued = [&["attach"][..], &ROOT_FLAGS[..]].concat();
|
||||
let a = Args::parse(rest, &valued, &[])?;
|
||||
if !a.positional.is_empty() {
|
||||
return Err(CliError::Usage("mcp takes no positional arguments".into()));
|
||||
}
|
||||
mcp(a.value("attach").as_deref())
|
||||
let attach = a.value("attach");
|
||||
// The app runs the commands: only its own roots can confine them.
|
||||
if attach.is_some() && ROOT_FLAGS.iter().any(|f| !a.values(f).is_empty()) {
|
||||
return Err(CliError::Usage(
|
||||
"--attach can't take automation roots: start the app with them instead (`figcraft --control PORT --automation-read-root DIR ...`)"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
mcp(attach.as_deref(), roots_of(&a)?)
|
||||
}
|
||||
"plugin" => {
|
||||
let a = Args::parse(rest, &["server"], &[])?;
|
||||
@@ -205,7 +243,7 @@ fn publish_plugin(folder: &str, server: &str) -> CliResult<ExitCode> {
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
|
||||
fn mcp(attach: Option<&str>) -> CliResult<ExitCode> {
|
||||
fn mcp(attach: Option<&str>, roots: Option<figcraft_engine::file_access::Roots>) -> CliResult<ExitCode> {
|
||||
let backend: Box<dyn figcraft_mcp::Backend> = match attach {
|
||||
Some(p) => {
|
||||
let addr = if p.contains(':') {
|
||||
@@ -220,7 +258,10 @@ fn mcp(attach: Option<&str>) -> CliResult<ExitCode> {
|
||||
None => Box::new(figcraft_mcp::Headless::new()),
|
||||
};
|
||||
eprintln!("figcraft MCP server ({}) on stdio", backend.describe());
|
||||
let mut server = figcraft_mcp::Server::new(backend);
|
||||
if let Some(r) = &roots {
|
||||
eprintln!("file access confined to {}", r.to_json());
|
||||
}
|
||||
let mut server = figcraft_mcp::Server::new(backend).with_roots(roots);
|
||||
let stdin = std::io::stdin();
|
||||
let stdout = std::io::stdout();
|
||||
server.serve(stdin.lock(), stdout.lock()).map_err(|e| format!("stdio: {e}"))?;
|
||||
|
||||
@@ -25,6 +25,9 @@ fn bad_arguments_exit_2_without_panicking() {
|
||||
&["render", "a", "-o"],
|
||||
&["perf", "--shapes", "lots"],
|
||||
&["mcp", "--attach", "notaport"],
|
||||
// An attached app confines itself: roots go to `figcraft --control`.
|
||||
&["mcp", "--attach", "7979", "--automation-read-root", "."],
|
||||
&["mcp", "--automation-write-root"],
|
||||
&["convert", "a"],
|
||||
] {
|
||||
let out = bin().args(args).output().unwrap();
|
||||
@@ -120,9 +123,147 @@ fn mcp_over_stdio() {
|
||||
assert_eq!(lines[2]["result"]["isError"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_over_stdio_confined_to_automation_roots() {
|
||||
let d = tmp("roots");
|
||||
for sub in ["in", "out", "outside"] {
|
||||
std::fs::create_dir_all(d.join(sub)).unwrap();
|
||||
}
|
||||
let p = |rel: &str| d.join(rel).to_string_lossy().into_owned();
|
||||
// A root that isn't a folder stops the server before it serves.
|
||||
let out = bin().args(["mcp", "--automation-read-root", &p("missing")]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("--automation-read-root"));
|
||||
let mut child = bin()
|
||||
.args(["mcp", "--automation-read-root", &p("in"), "--automation-write-root", &p("out")])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
{
|
||||
let stdin = child.stdin.as_mut().unwrap();
|
||||
let call = |id: u32, tool: &str, args: serde_json::Value| {
|
||||
serde_json::json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": {"name": tool, "arguments": args}}).to_string()
|
||||
};
|
||||
writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap();
|
||||
writeln!(stdin, "{}", call(2, "create_node", serde_json::json!({"type": "ellipse"}))).unwrap();
|
||||
writeln!(stdin, "{}", call(3, "save_file", serde_json::json!({"path": p("outside/a.figcraft")}))).unwrap();
|
||||
writeln!(stdin, "{}", call(4, "save_file", serde_json::json!({"path": p("out/a.figcraft")}))).unwrap();
|
||||
writeln!(stdin, "{}", call(5, "open_file", serde_json::json!({"path": p("outside/a.figcraft")}))).unwrap();
|
||||
writeln!(stdin, "{}", call(6, "open_file", serde_json::json!({"path": p("out/a.figcraft")}))).unwrap();
|
||||
}
|
||||
drop(child.stdin.take());
|
||||
let out = child.wait_with_output().unwrap();
|
||||
assert!(out.status.success());
|
||||
let lines: Vec<serde_json::Value> = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
assert_eq!(lines.len(), 6);
|
||||
assert!(lines[0]["result"]["instructions"].as_str().unwrap().contains("only these folders may be read"));
|
||||
let error = |i: usize| lines[i]["result"]["isError"] == true;
|
||||
assert!(!error(1) && error(2) && !error(3) && error(4) && !error(5), "{lines:?}");
|
||||
assert!(lines[2]["result"]["content"][0]["text"].as_str().unwrap().contains("automation path rejected"));
|
||||
assert!(d.join("out/a.figcraft").is_file() && !d.join("outside/a.figcraft").exists());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_json_lists_everything() {
|
||||
let out = bin().args(["commands", "--json"]).output().unwrap();
|
||||
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
assert!(v.as_array().unwrap().len() > 200);
|
||||
}
|
||||
|
||||
/// The automation roots confine the file subcommands too, not just `mcp` (#833): a read outside a
|
||||
/// root, and a write outside a write root, are refused and leave no file behind. Without the flags
|
||||
/// the same commands behave as they always did.
|
||||
#[test]
|
||||
fn file_subcommands_are_confined_to_automation_roots() {
|
||||
let d = tmp("file-roots");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
for sub in ["in", "out"] {
|
||||
std::fs::create_dir_all(d.join(sub)).unwrap();
|
||||
}
|
||||
let p = |rel: &str| d.join(rel).to_string_lossy().into_owned();
|
||||
let out_of = |o: &std::process::Output| String::from_utf8_lossy(&o.stderr).into_owned();
|
||||
|
||||
// A document to work on, made unconfined.
|
||||
std::fs::write(d.join("in/make.json"), br#"[{"command":"shape.rectangle","params":{"x":0,"y":0,"width":40,"height":20}}]"#).unwrap();
|
||||
let made = bin().args(["run", &p("in/make.json"), "--out", &p("in/doc.figcraft")]).output().unwrap();
|
||||
assert!(made.status.success(), "{}", out_of(&made));
|
||||
|
||||
// Inside the roots: renders as usual.
|
||||
let ok = bin()
|
||||
.args(["render", &p("in/doc.figcraft"), "-o", &p("out/ok.png"), "--automation-read-root", &p("in"), "--automation-write-root", &p("out")])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(ok.status.success(), "{}", out_of(&ok));
|
||||
assert!(d.join("out/ok.png").is_file());
|
||||
|
||||
// A write outside the write root is refused, and nothing is written.
|
||||
let escaped = d.join("escaped.png");
|
||||
let refused = bin()
|
||||
.args([
|
||||
"render",
|
||||
&p("in/doc.figcraft"),
|
||||
"-o",
|
||||
&escaped.to_string_lossy(),
|
||||
"--automation-read-root",
|
||||
&p("in"),
|
||||
"--automation-write-root",
|
||||
&p("out"),
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!refused.status.success());
|
||||
assert!(out_of(&refused).contains("may be written"), "{}", out_of(&refused));
|
||||
assert!(!escaped.exists(), "the refused render wrote a file anyway");
|
||||
|
||||
// No write root at all: writing is refused outright, reading still works.
|
||||
let no_write = bin().args(["render", &p("in/doc.figcraft"), "-o", &p("out/no.png"), "--automation-read-root", &p("in")]).output().unwrap();
|
||||
assert!(!no_write.status.success());
|
||||
assert!(out_of(&no_write).contains("no folder may be written"), "{}", out_of(&no_write));
|
||||
|
||||
// A read outside every root is refused: `info` on the document with only `out` readable.
|
||||
let read_refused = bin().args(["info", &p("in/doc.figcraft"), "--automation-read-root", &p("out")]).output().unwrap();
|
||||
assert!(!read_refused.status.success());
|
||||
assert!(out_of(&read_refused).contains("may be read"), "{}", out_of(&read_refused));
|
||||
|
||||
// `convert` and `run` take them as well, and a root that is not a folder is refused.
|
||||
let missing = bin().args(["convert", &p("in/doc.figcraft"), &p("out/c.svg"), "--automation-read-root", &p("missing")]).output().unwrap();
|
||||
assert!(!missing.status.success());
|
||||
assert!(out_of(&missing).contains("automation-read-root"), "{}", out_of(&missing));
|
||||
|
||||
// Unconfined, the same render still writes wherever it is told.
|
||||
let plain = bin().args(["render", &p("in/doc.figcraft"), "-o", &escaped.to_string_lossy()]).output().unwrap();
|
||||
assert!(plain.status.success(), "{}", out_of(&plain));
|
||||
assert!(escaped.is_file());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// An output path that is a symbolic link is written through, to its target, as `std::fs::write`
|
||||
/// always did. Routing the CLI's writes through `file_access` for #833 would otherwise have
|
||||
/// replaced the link with a regular file, because that write stages the bytes and renames them
|
||||
/// into place.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn an_unconfined_write_follows_a_symbolic_link() {
|
||||
let d = tmp("symlink-out");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(d.join("real")).unwrap();
|
||||
let p = |rel: &str| d.join(rel).to_string_lossy().into_owned();
|
||||
std::fs::write(d.join("in.json"), br#"[{"command":"shape.rectangle","params":{"x":0,"y":0,"width":40,"height":20}}]"#).unwrap();
|
||||
let made = bin().args(["run", &p("in.json"), "--out", &p("doc.figcraft")]).output().unwrap();
|
||||
assert!(made.status.success(), "{}", String::from_utf8_lossy(&made.stderr));
|
||||
|
||||
let target = d.join("real/target.png");
|
||||
std::fs::write(&target, b"placeholder").unwrap();
|
||||
let link = d.join("link.png");
|
||||
std::os::unix::fs::symlink(&target, &link).unwrap();
|
||||
|
||||
let out = bin().args(["render", &p("doc.figcraft"), "-o", &link.to_string_lossy()]).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink(), "the link is still a link");
|
||||
let written = std::fs::read(&target).unwrap();
|
||||
assert!(written.starts_with(b"\x89PNG"), "the target holds the render, not the placeholder");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
@@ -45,3 +45,25 @@ fn headless_runs_resolve_installed_families_unless_opted_out() {
|
||||
assert_eq!(run_one("face", face.clone(), &[], false)["family"], json!(installed), "text uses the installed family");
|
||||
assert_ne!(run_one("face", face, &["--no-system-fonts"], false)["family"], json!(installed), "opted out, it falls back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_lists_the_fonts_a_file_uses_that_are_missing() {
|
||||
let d = std::env::temp_dir().join(format!("figcraft-cli-missing-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
let (script, file) = (d.join("make.json"), d.join("doc.figcraft"));
|
||||
let text =
|
||||
|family: &str| json!({"command": "text.create", "params": {"text": "Hamburgefonstiv", "x": 0, "y": 0, "props": {"fontFamily": family}}});
|
||||
std::fs::write(&script, json!([text("FigCraft Missing CLI"), text("FigCraft Missing CLI")]).to_string()).unwrap();
|
||||
let cli = || Command::new(env!("CARGO_BIN_EXE_figcraft-cli"));
|
||||
let made = cli().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap();
|
||||
assert!(made.status.success(), "{}", String::from_utf8_lossy(&made.stderr));
|
||||
|
||||
let out = cli().arg("info").arg(&file).arg("--json").output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let v: Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
assert_eq!(v["missingFonts"], json!([{"family": "FigCraft Missing CLI", "layers": 2}]));
|
||||
|
||||
let out = cli().arg("info").arg(&file).output().unwrap();
|
||||
let shown = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(shown.contains("missing font \"FigCraft Missing CLI\": 2 text layers"), "{shown}");
|
||||
}
|
||||
@@ -182,7 +182,7 @@ impl Config {
|
||||
return Err("`idle_days` must be from 0 to 3650".into());
|
||||
}
|
||||
let terms = self.terms_url.trim();
|
||||
if !terms.is_empty() && !(terms.starts_with("https://") || terms.starts_with("http://")) || terms.contains(char::is_whitespace) {
|
||||
if !(terms.is_empty() || terms.starts_with("https://") || terms.starts_with("http://")) || terms.contains(char::is_whitespace) {
|
||||
return Err("`terms_url` must be a web address (https://…)".into());
|
||||
}
|
||||
if self.operator_contact.chars().count() > 120 || self.operator_contact.chars().any(char::is_control) || terms.len() > 500 {
|
||||
|
||||
@@ -26,4 +26,4 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
|
||||
wasm-bindgen = "0.2"
|
||||
js-sys = "0.3"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] }
|
||||
@@ -1,7 +1,7 @@
|
||||
//! The browser shell: web `Services` and the eframe web runner.
|
||||
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_ui_egui::{FigApp, Services};
|
||||
use figcraft_ui_egui::{ContentsFuture, FigApp, Services};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
const CANVAS_ID: &str = "figcraft_canvas";
|
||||
@@ -75,9 +75,10 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
}
|
||||
|
||||
/// Browser services. Open dialogs return nothing for now (the trait's defaults); files are saved
|
||||
/// as downloads. Copies go to the system clipboard through egui's output; pastes arrive as paste
|
||||
/// events with their text (a page can't read the clipboard when it likes).
|
||||
/// Browser services. Files are opened and imported through the browser's file picker, which hands
|
||||
/// over their contents (pickers that need a path return nothing, the trait's defaults); files are
|
||||
/// saved as downloads. Copies go to the system clipboard through egui's output; pastes arrive as
|
||||
/// paste events with their text (a page can't read the clipboard when it likes).
|
||||
struct WebServices {
|
||||
/// `prefers-reduced-motion: reduce` (it follows the setting while the page is open).
|
||||
reduce_motion: Option<web_sys::MediaQueryList>,
|
||||
@@ -85,6 +86,16 @@ struct WebServices {
|
||||
}
|
||||
|
||||
impl Services for WebServices {
|
||||
/// An `<input type="file">` taking `exts`, clicked; its file is read once picked.
|
||||
fn pick_open_contents(&mut self, _title: &str, exts: &[&str]) -> Option<ContentsFuture> {
|
||||
let accept = exts.iter().map(|e| format!(".{e}")).collect::<Vec<_>>().join(",");
|
||||
Some(Box::pin(async move {
|
||||
pick_file(&accept).await.unwrap_or_else(|e| {
|
||||
log::error!("couldn't open a file: {e:?}");
|
||||
None
|
||||
})
|
||||
}))
|
||||
}
|
||||
fn downloads(&self) -> bool {
|
||||
true
|
||||
}
|
||||
@@ -136,6 +147,90 @@ impl Services for WebServices {
|
||||
}
|
||||
}
|
||||
|
||||
/// The file picked in the browser's file picker (`accept`: the extensions it offers), with its
|
||||
/// name; `None` when the picker is cancelled, `Some(Err)` with what to tell the user when the file
|
||||
/// can't be read.
|
||||
async fn pick_file(accept: &str) -> Result<Option<Result<(String, Vec<u8>), String>>, wasm_bindgen::JsValue> {
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let document = window.document().ok_or("no document")?;
|
||||
let body = document.body().ok_or("no body")?;
|
||||
let input: web_sys::HtmlInputElement = document.create_element("input")?.dyn_into()?;
|
||||
input.set_type("file");
|
||||
input.set_accept(accept);
|
||||
input.set_hidden(true);
|
||||
// Some browsers (older Safari) open the picker only for an input in the page.
|
||||
body.append_child(&input)?;
|
||||
let (closed, picker) = PickerClosed::new(&window, input.clone());
|
||||
input.click();
|
||||
let result = wasm_bindgen_futures::JsFuture::from(closed).await;
|
||||
let file = input.files().and_then(|files| files.get(0));
|
||||
drop(picker);
|
||||
result?;
|
||||
let Some(file) = file else { return Ok(None) };
|
||||
let name = file.name();
|
||||
if let Err(why) = figcraft_ui_egui::browser_can_read(&name, file.size()) {
|
||||
return Ok(Some(Err(why)));
|
||||
}
|
||||
Ok(Some(match wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await {
|
||||
Ok(buffer) => Ok((name, js_sys::Uint8Array::new(&buffer).to_vec())),
|
||||
Err(e) => {
|
||||
log::error!("couldn't read {name}: {e:?}");
|
||||
Err(figcraft_ui_egui::unreadable(&name))
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/// How long after the page gets focus back the file picker counts as closed (milliseconds): a
|
||||
/// picked file's `change` can come just after the focus, and must win. A `change` later than this
|
||||
/// (a slow cloud-backed file in a browser that never sends `cancel`) counts as a cancel.
|
||||
const PICKER_FOCUS_SETTLE_MS: i32 = 1_000;
|
||||
|
||||
/// Settles a file picker's promise once: on `change` (a file was picked), on `cancel` (closed
|
||||
/// without one) or shortly after the page gets focus back, for browsers that never send `cancel`
|
||||
/// (Safari before 16.4), where the pick would otherwise stay pending and block every later one.
|
||||
/// Dropping it (also when the pick is abandoned) removes the input, the focus listener and its timer.
|
||||
struct PickerClosed {
|
||||
window: web_sys::Window,
|
||||
input: web_sys::HtmlInputElement,
|
||||
_settle: wasm_bindgen::closure::Closure<dyn FnMut()>,
|
||||
on_focus: wasm_bindgen::closure::Closure<dyn FnMut()>,
|
||||
timer: std::rc::Rc<std::cell::Cell<Option<i32>>>,
|
||||
}
|
||||
|
||||
impl PickerClosed {
|
||||
fn new(window: &web_sys::Window, input: web_sys::HtmlInputElement) -> (js_sys::Promise, Self) {
|
||||
let resolve = std::rc::Rc::new(std::cell::RefCell::new(None::<js_sys::Function>));
|
||||
let slot = resolve.clone();
|
||||
let closed = js_sys::Promise::new(&mut |res, _rej| *slot.borrow_mut() = Some(res));
|
||||
let settle = wasm_bindgen::closure::Closure::<dyn FnMut()>::new(move || {
|
||||
if let Some(res) = resolve.borrow_mut().take() {
|
||||
let _ = res.call0(&wasm_bindgen::JsValue::NULL);
|
||||
}
|
||||
});
|
||||
let _ = input.add_event_listener_with_callback("change", settle.as_ref().unchecked_ref());
|
||||
let _ = input.add_event_listener_with_callback("cancel", settle.as_ref().unchecked_ref());
|
||||
let timer = std::rc::Rc::new(std::cell::Cell::new(None));
|
||||
let (w, t, f) = (window.clone(), timer.clone(), settle.as_ref().unchecked_ref::<js_sys::Function>().clone());
|
||||
let on_focus = wasm_bindgen::closure::Closure::<dyn FnMut()>::new(move || {
|
||||
if t.get().is_none() {
|
||||
t.set(w.set_timeout_with_callback_and_timeout_and_arguments_0(&f, PICKER_FOCUS_SETTLE_MS).ok());
|
||||
}
|
||||
});
|
||||
let _ = window.add_event_listener_with_callback("focus", on_focus.as_ref().unchecked_ref());
|
||||
(closed, Self { window: window.clone(), input, _settle: settle, on_focus, timer })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PickerClosed {
|
||||
fn drop(&mut self) {
|
||||
if let Some(t) = self.timer.take() {
|
||||
self.window.clear_timeout_with_handle(t);
|
||||
}
|
||||
let _ = self.window.remove_event_listener_with_callback("focus", self.on_focus.as_ref().unchecked_ref());
|
||||
self.input.remove();
|
||||
}
|
||||
}
|
||||
|
||||
fn download(name: &str, bytes: &[u8]) -> Result<(), wasm_bindgen::JsValue> {
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let document = window.document().ok_or("no document")?;
|
||||
|
||||
@@ -43,3 +43,9 @@ wat = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
winresource = "0.1"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Use vector window controls without a title-font renderer or embedded Cantarell.
|
||||
winit = { version = "0.30", default-features = false, features = ["wayland-csd-adwaita-notitle"] }
|
||||
raw-window-handle = "0.6"
|
||||
wayland-client = "0.31"
|
||||
@@ -1,16 +1,26 @@
|
||||
//! The command line: `figcraft [--control <port>] [files…] [figcraft://join?…]`. An invite link
|
||||
//! The command line: `figcraft [--control <port>] [--automation-read-root <dir>]…
|
||||
//! [--automation-write-root <dir>]… [files…] [figcraft://join?…]`. An invite link
|
||||
//! (what the system passes when a `figcraft://` link is opened) joins that session once the window
|
||||
//! is up; everything else that isn't a flag is a file to open, as a path or a `file://` URL (the
|
||||
//! Linux desktop entry passes URLs, `%U`, so that it can also hand over invite links).
|
||||
//! Linux desktop entry passes URLs, `%U`, so that it can also hand over invite links). Any other
|
||||
//! `--flag` is unknown and stops the app at startup (#836): a mistyped root flag must never leave
|
||||
//! the app running unconfined. After a bare `--`, every argument is a file.
|
||||
|
||||
/// What the app was started with.
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub struct Args {
|
||||
/// `--control <port>`: the JSON-lines control server.
|
||||
/// `--control <port>` (or `--control=<port>`): the JSON-lines control server.
|
||||
pub control: Option<u16>,
|
||||
pub files: Vec<String>,
|
||||
/// The last invite link given.
|
||||
pub join: Option<String>,
|
||||
/// `--automation-read-root <dir>` / `--automation-write-root <dir>` (each repeatable; `=` works
|
||||
/// too): the folders the app's file access is confined to. A flag without a value gives `""`,
|
||||
/// which is refused at startup.
|
||||
pub read_roots: Vec<String>,
|
||||
pub write_roots: Vec<String>,
|
||||
/// `--flags` the app doesn't know, as given.
|
||||
pub unknown: Vec<String>,
|
||||
}
|
||||
|
||||
/// An invite link rather than a file (`figcraft:` links, in any case).
|
||||
@@ -49,13 +59,27 @@ pub fn parse(args: &[String]) -> Args {
|
||||
let mut out = Args::default();
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
if a == "--control" {
|
||||
out.control = it.next().and_then(|p| p.parse().ok());
|
||||
if a == "--" {
|
||||
out.files.extend(it.by_ref().cloned());
|
||||
break;
|
||||
}
|
||||
let (flag, inline) = a.split_once('=').map_or((a.as_str(), None), |(f, v)| (f, Some(v.to_string())));
|
||||
let roots = match flag {
|
||||
"--automation-read-root" => Some(&mut out.read_roots),
|
||||
"--automation-write-root" => Some(&mut out.write_roots),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(roots) = roots {
|
||||
roots.push(inline.or_else(|| it.next().cloned()).unwrap_or_default());
|
||||
} else if flag == "--control" {
|
||||
out.control = inline.or_else(|| it.next().cloned()).and_then(|p| p.parse().ok());
|
||||
} else if is_invite(a) {
|
||||
out.join = Some(a.clone());
|
||||
} else if let Some(path) = file_url_path(a) {
|
||||
out.files.push(path);
|
||||
} else if !a.starts_with("--") {
|
||||
} else if a.starts_with("--") {
|
||||
out.unknown.push(a.clone());
|
||||
} else {
|
||||
out.files.push(a.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,13 +9,44 @@ fn args(a: &[&str]) -> Vec<String> {
|
||||
#[test]
|
||||
fn files_and_the_control_port() {
|
||||
assert_eq!(parse(&args(&[])), Args::default());
|
||||
let a = parse(&args(&["--control", "4242", "a.figcraft", "--verbose", "b.svg"]));
|
||||
assert_eq!(a, Args { control: Some(4242), files: args(&["a.figcraft", "b.svg"]), join: None });
|
||||
let a = parse(&args(&["--control", "4242", "a.figcraft", "b.svg"]));
|
||||
assert_eq!(a, Args { control: Some(4242), files: args(&["a.figcraft", "b.svg"]), ..Args::default() });
|
||||
// A missing or bad port is ignored, not taken as a file.
|
||||
assert_eq!(parse(&args(&["--control"])), Args::default());
|
||||
assert_eq!(parse(&args(&["--control", "nope"])).control, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_control_port_accepts_the_equals_form() {
|
||||
let a = parse(&args(&["--control=7980", "a.figcraft"]));
|
||||
assert_eq!(a, Args { control: Some(7980), files: args(&["a.figcraft"]), ..Args::default() });
|
||||
// A bad port is ignored, not taken as a file.
|
||||
assert_eq!(parse(&args(&["--control=nope"])), Args::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automation_roots_repeat_and_are_never_files() {
|
||||
let a = parse(&args(&["--automation-read-root", "in", "--automation-write-root=out", "--automation-read-root=more", "a.figcraft"]));
|
||||
assert_eq!(a.read_roots, args(&["in", "more"]));
|
||||
assert_eq!(a.write_roots, args(&["out"]));
|
||||
assert_eq!(a.files, args(&["a.figcraft"]));
|
||||
// A flag without its folder is kept as an empty one, which startup refuses.
|
||||
assert_eq!(parse(&args(&["--automation-write-root"])).write_roots, args(&[""]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_flags_are_refused_never_ignored() {
|
||||
// #836: a mistyped root flag (or one an older release lacks) must stop the app, not start it
|
||||
// unconfined. Both forms, with and without `=`.
|
||||
let a = parse(&args(&["--automation-write-roots", "out", "--automation-write-dir=out", "--verbose", "a.figcraft"]));
|
||||
assert_eq!(a.unknown, args(&["--automation-write-roots", "--automation-write-dir=out", "--verbose"]));
|
||||
assert!(a.write_roots.is_empty());
|
||||
// Single-dash arguments are left as they were (files); after `--` everything is a file.
|
||||
let a = parse(&args(&["--control", "1", "--", "--odd.figcraft", "--automation-write-root=x"]));
|
||||
assert_eq!(a.files, args(&["--odd.figcraft", "--automation-write-root=x"]));
|
||||
assert!(a.unknown.is_empty() && a.write_roots.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invite_links_join_instead_of_opening() {
|
||||
let link = "figcraft://join?host=192.168.1.20:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv";
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
//! Request winit's frame only on Wayland compositors without xdg-decoration.
|
||||
//! Desktop/session names cannot identify the backend of an individual window.
|
||||
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use raw_window_handle::{HasDisplayHandle, RawDisplayHandle};
|
||||
use wayland_client::globals::{GlobalListContents, registry_queue_init};
|
||||
use wayland_client::protocol::wl_registry;
|
||||
use wayland_client::{Connection, Dispatch, QueueHandle};
|
||||
|
||||
/// Capture the socket path before winit consumes an inherited WAYLAND_SOCKET.
|
||||
/// Never consume, duplicate or query that inherited connection: there may be no
|
||||
/// independently addressable display, or WAYLAND_DISPLAY may name another server.
|
||||
pub fn display_socket() -> Option<PathBuf> {
|
||||
if std::env::var_os("WAYLAND_SOCKET").is_some_and(|s| !s.is_empty()) {
|
||||
return None;
|
||||
}
|
||||
let display = std::env::var_os("WAYLAND_DISPLAY").filter(|s| !s.is_empty())?;
|
||||
let path = PathBuf::from(display);
|
||||
if path.is_absolute() {
|
||||
Some(path)
|
||||
} else {
|
||||
std::env::var_os("XDG_RUNTIME_DIR").filter(|s| !s.is_empty()).map(|dir| PathBuf::from(dir).join(path))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn needs_native_frame(cc: &eframe::CreationContext<'_>, socket: Option<PathBuf>) -> bool {
|
||||
let wayland = cc.display_handle().is_ok_and(|h| matches!(h.as_raw(), RawDisplayHandle::Wayland(_)));
|
||||
if !wayland {
|
||||
log::debug!("window controls: non-Wayland window, retaining custom controls");
|
||||
return false;
|
||||
}
|
||||
let server_decorations = socket.and_then(probe);
|
||||
log::debug!("window controls: Wayland xdg-decoration available: {server_decorations:?}");
|
||||
native_frame(wayland, server_decorations)
|
||||
}
|
||||
|
||||
fn native_frame(wayland: bool, server_decorations: Option<bool>) -> bool {
|
||||
wayland && server_decorations == Some(false)
|
||||
}
|
||||
|
||||
fn probe(socket: PathBuf) -> Option<bool> {
|
||||
// The compositor must not be able to block startup on a registry roundtrip.
|
||||
// On failure/timeout retain FigCraft's own working title bar and buttons.
|
||||
let (send, receive) = mpsc::sync_channel(1);
|
||||
std::thread::Builder::new()
|
||||
.name("wayland-decorations".into())
|
||||
.spawn(move || {
|
||||
let _ = send.send(registry_has_decorations(socket));
|
||||
})
|
||||
.ok()?;
|
||||
receive.recv_timeout(Duration::from_millis(500)).ok().flatten()
|
||||
}
|
||||
|
||||
fn registry_has_decorations(socket: PathBuf) -> Option<bool> {
|
||||
// A fresh connection leaves winit's event queue and inherited descriptors alone.
|
||||
let stream = UnixStream::connect(socket).ok()?;
|
||||
let connection = Connection::from_socket(stream).ok()?;
|
||||
let (globals, _queue) = registry_queue_init::<Registry>(&connection).ok()?;
|
||||
Some(globals.contents().with_list(|list| list.iter().any(|g| g.interface == "zxdg_decoration_manager_v1")))
|
||||
}
|
||||
|
||||
struct Registry;
|
||||
|
||||
impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for Registry {
|
||||
fn event(
|
||||
_state: &mut Self,
|
||||
_registry: &wl_registry::WlRegistry,
|
||||
_event: wl_registry::Event,
|
||||
_data: &GlobalListContents,
|
||||
_connection: &Connection,
|
||||
_queue: &QueueHandle<Self>,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn only_wayland_without_server_decorations_uses_the_frame() {
|
||||
for (wayland, server_decorations, expected) in [
|
||||
(false, None, false),
|
||||
(false, Some(false), false),
|
||||
(false, Some(true), false),
|
||||
(true, Some(true), false),
|
||||
(true, Some(false), true),
|
||||
(true, None, false),
|
||||
] {
|
||||
assert_eq!(super::native_frame(wayland, server_decorations), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unavailable_display_keeps_the_custom_controls() {
|
||||
assert_eq!(super::probe("/nonexistent/figcraft-wayland-test".into()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unresponsive_display_cannot_block_window_startup() -> std::io::Result<()> {
|
||||
let socket = std::env::temp_dir().join(format!("figcraft-decoration-timeout-{}", std::process::id()));
|
||||
let listener = std::os::unix::net::UnixListener::bind(&socket)?;
|
||||
let start = std::time::Instant::now();
|
||||
assert_eq!(super::probe(socket.clone()), None);
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(2));
|
||||
drop(listener);
|
||||
let _ = std::fs::remove_file(socket);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+94
-16
@@ -6,6 +6,12 @@
|
||||
//! `--control <port>` (or `FIGCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
|
||||
//! `{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":0,"y":0,"width":100,"height":100}}}`
|
||||
//! → `{"id":1,"ok":true,"result":{…}}`. Methods: `figcraft_ui_egui::control`.
|
||||
//!
|
||||
//! `--automation-read-root <dir>` / `--automation-write-root <dir>` (each repeatable) confine the
|
||||
//! files the window and the control channel read and write (`figcraft_engine::file_access`); the
|
||||
//! app's own folders (settings, recovery copies, installed plugins) and opening the files given on
|
||||
//! the command line are not. A root that isn't an existing folder stops the app at startup, and so
|
||||
//! does any flag the app doesn't know (a mistyped root flag must not start the app unconfined).
|
||||
#![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")]
|
||||
|
||||
@@ -15,6 +21,8 @@ mod args;
|
||||
mod control_server;
|
||||
mod gamepad;
|
||||
mod graphics;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux_window;
|
||||
mod recovery;
|
||||
mod services;
|
||||
mod startup;
|
||||
@@ -100,10 +108,13 @@ impl eframe::App for App {
|
||||
}
|
||||
|
||||
fn on_exit(&mut self) {
|
||||
with_app(|fig| services::save_state(fig));
|
||||
if let Some(r) = recovery::lock(&self.recovery).take() {
|
||||
r.finish();
|
||||
}
|
||||
// With the app out of reach (busy), its recovery copies stay for the next start.
|
||||
let copies = recovery::lock(&self.recovery).take();
|
||||
with_app(|fig| services::shutdown(fig, services::config_dir().as_deref(), copies));
|
||||
// Everything is kept: end before the windows' teardown, where AppKit's Touch Bar support
|
||||
// throws on the content view AccessKit restores (craft-libs#3, #781).
|
||||
#[cfg(target_os = "macos")]
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +130,23 @@ fn main() -> eframe::Result {
|
||||
return Ok(());
|
||||
}
|
||||
let args = args::parse(&args);
|
||||
if let Some(flag) = args.unknown.first() {
|
||||
eprintln!(
|
||||
"FigCraft: unknown option {flag}\nUsage: figcraft [--control PORT] [--automation-read-root DIR]... [--automation-write-root DIR]... [FILE | figcraft://join?...]..."
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
let roots = match (
|
||||
args.read_roots.is_empty() && args.write_roots.is_empty(),
|
||||
figcraft_engine::file_access::Roots::new(&args.read_roots, &args.write_roots),
|
||||
) {
|
||||
(true, _) => None,
|
||||
(false, Ok(r)) => Some(Arc::new(r)),
|
||||
(false, Err(e)) => {
|
||||
eprintln!("FigCraft: {e}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
let port = args.control.or_else(|| std::env::var("FIGCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()));
|
||||
// System fonts: index them in the background, then wake the UI (an idle window draws no frames)
|
||||
// to lay text out again with them.
|
||||
@@ -143,6 +171,7 @@ fn main() -> eframe::Result {
|
||||
log::error!("{f}: {e}");
|
||||
}
|
||||
}
|
||||
let opened: Vec<u64> = session.docs.iter().map(|d| d.uid).collect();
|
||||
// Documents a crashed or killed run left unsaved come back; this run keeps its own copies.
|
||||
let recovery_root = services::config_dir().map(|d| d.join("Recovery"));
|
||||
// The preferences first: they hold the online files a recovered document may be one of.
|
||||
@@ -164,17 +193,18 @@ fn main() -> eframe::Result {
|
||||
// default, never a build from source unless asked).
|
||||
session.live.links = figcraft_collab_net::links::system();
|
||||
session.invite_links_startup();
|
||||
// A first run (no settings kept yet, nothing to open) starts on the home screen, with its
|
||||
// tips and templates; later runs start in a new file.
|
||||
let first_run = svc.prefs.is_none() && args.join.is_none();
|
||||
if session.docs.is_empty() && !first_run {
|
||||
if session.docs.is_empty() && opens_new_file(first_run, session.prefs.start_on_home, port.is_some()) {
|
||||
let _ = session.execute("file.new", &json!({}));
|
||||
}
|
||||
// The interface language from the first frame on, and for the message when the window can't
|
||||
// open (no frame then).
|
||||
figcraft_ui_egui::i18n::set(&session.prefs.language, figcraft_ui_egui::Services::system_language(&svc).as_deref());
|
||||
let mut fig = FigApp::new(session, Box::new(svc));
|
||||
fig.automation_roots = roots;
|
||||
fig.ui.home = false;
|
||||
// Files named on the command line are the user's too: their missing fonts are listed.
|
||||
fig.ui.font_checks = opened;
|
||||
fig.note_recovered(recovered.copies);
|
||||
if let Some(link) = &args.join {
|
||||
figcraft_ui_egui::live::open_invite(&mut fig, link);
|
||||
@@ -192,21 +222,18 @@ fn main() -> eframe::Result {
|
||||
.with_inner_size([1440.0, 900.0])
|
||||
.with_min_inner_size([800.0, 500.0])
|
||||
.with_drag_and_drop(true);
|
||||
// The tab strip is the title bar: macOS keeps its window buttons inset into it; elsewhere the
|
||||
// window is frameless and FigCraft draws the buttons and resize edges.
|
||||
if cfg!(target_os = "macos") {
|
||||
viewport = viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false);
|
||||
fig.ui.title_bar = figcraft_ui_egui::TitleBar::Inset;
|
||||
} else {
|
||||
viewport = viewport.with_decorations(false);
|
||||
fig.ui.title_bar = figcraft_ui_egui::TitleBar::Custom;
|
||||
}
|
||||
// Start frameless on Linux; the app creator checks the actual display backend
|
||||
// before enabling a client-side frame. Other windows inherit that decision.
|
||||
fig.ui.title_bar = desktop_title_bar();
|
||||
viewport = window_chrome(viewport, fig.ui.title_bar);
|
||||
if let Some(icon) = icon {
|
||||
viewport = viewport.with_icon(icon);
|
||||
}
|
||||
let mut options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() };
|
||||
graphics::configure(&mut options.wgpu_options);
|
||||
figcraft_ui_egui::windows::install(fig);
|
||||
#[cfg(target_os = "linux")]
|
||||
let linux_display = linux_window::display_socket();
|
||||
// Registered before the event loop starts, so it catches the Finder event that launched the app
|
||||
// as well as later ones. Lives until the event loop returns; the app creator only borrows it.
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -217,6 +244,11 @@ fn main() -> eframe::Result {
|
||||
"FigCraft",
|
||||
options,
|
||||
Box::new(move |_cc| {
|
||||
#[cfg(target_os = "linux")]
|
||||
if linux_window::needs_native_frame(_cc, linux_display) {
|
||||
let _ = with_app(|fig| fig.ui.title_bar = figcraft_ui_egui::TitleBar::Native);
|
||||
_cc.egui_ctx.send_viewport_cmd(egui::ViewportCommand::Decorations(true));
|
||||
}
|
||||
Ok(Box::new(App {
|
||||
queue,
|
||||
ctx_slot,
|
||||
@@ -233,3 +265,49 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// With nothing to open, whether the window starts in a new file rather than on the home screen.
|
||||
/// A first run (no settings kept yet) shows the home screen's tips and templates, and later runs
|
||||
/// start in a new file unless the preference asks for Home, which `--control` overrides: its
|
||||
/// callers expect a document to act on.
|
||||
fn opens_new_file(first_run: bool, start_on_home: bool, control: bool) -> bool {
|
||||
!first_run && (!start_on_home || control)
|
||||
}
|
||||
|
||||
fn desktop_title_bar() -> figcraft_ui_egui::TitleBar {
|
||||
use figcraft_ui_egui::TitleBar;
|
||||
if cfg!(target_os = "macos") { TitleBar::Inset } else { TitleBar::Custom }
|
||||
}
|
||||
|
||||
fn window_chrome(viewport: egui::ViewportBuilder, title_bar: figcraft_ui_egui::TitleBar) -> egui::ViewportBuilder {
|
||||
use figcraft_ui_egui::TitleBar;
|
||||
match title_bar {
|
||||
TitleBar::Inset => viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false),
|
||||
TitleBar::Native => viewport.with_decorations(true),
|
||||
TitleBar::Custom => viewport.with_decorations(false),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod linux_window_tests {
|
||||
#[test]
|
||||
fn linux_starts_with_the_existing_frameless_title_bar() {
|
||||
let bar = super::desktop_title_bar();
|
||||
assert_eq!(bar, figcraft_ui_egui::TitleBar::Custom);
|
||||
let viewport = super::window_chrome(egui::ViewportBuilder::default(), bar);
|
||||
assert_eq!(viewport.decorations, Some(false));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod start_tests {
|
||||
use super::opens_new_file;
|
||||
|
||||
#[test]
|
||||
fn the_home_preference_starts_on_home_except_for_control_callers() {
|
||||
assert!(opens_new_file(false, false, false), "a new file by default");
|
||||
assert!(!opens_new_file(false, true, false), "Home when the preference asks");
|
||||
assert!(opens_new_file(false, true, true), "--control still starts with a document");
|
||||
assert!(!opens_new_file(true, false, false), "a first run shows the home screen");
|
||||
}
|
||||
}
|
||||
@@ -120,8 +120,7 @@ impl Recovery {
|
||||
(0..10).find_map(|n| {
|
||||
let dir = root.join(if n == 0 { pid.to_string() } else { format!("{pid}-{n}") });
|
||||
std::fs::create_dir_all(&dir).ok()?;
|
||||
let lock = File::options().write(true).create(true).truncate(false).open(dir.join(LOCK)).ok()?;
|
||||
lock.try_lock().ok()?;
|
||||
let lock = lock_folder(&dir).ok()?;
|
||||
Some(Self { dir, _lock: lock, written: HashMap::new(), next: 0.0, waiting: Vec::new() })
|
||||
})
|
||||
}
|
||||
@@ -250,6 +249,17 @@ impl Recovery {
|
||||
self.written.insert(uid, Copy { done: None, flight: Some((vec![revision], writer)) });
|
||||
}
|
||||
|
||||
/// Quitting: a normal quit ([`finish`](Self::finish)) when the unsaved-changes question was
|
||||
/// `asked` or nothing is unsaved. Otherwise (macOS's Quit menu ends the app without asking) the
|
||||
/// unsaved documents are copied and the copies stay for the next start, as after a crash.
|
||||
pub fn quit(mut self, s: &Session, asked: bool) {
|
||||
if asked || !s.docs.iter().any(|d| needs_copy(s, d)) {
|
||||
return self.finish();
|
||||
}
|
||||
self.write(s, true);
|
||||
self.settle(s);
|
||||
}
|
||||
|
||||
/// A normal quit: the documents were saved or their changes discarded on purpose. (Earlier
|
||||
/// runs' copies still waiting for their outcome stay for the next start.)
|
||||
pub fn finish(self) {
|
||||
@@ -282,6 +292,17 @@ fn remove_kept(w: Waiting) {
|
||||
/// The extension of the note beside the copy of a document that is one of your online files.
|
||||
const ONLINE: &str = "online";
|
||||
|
||||
/// How long the startup waits, once, for folder locks that may free themselves (#334).
|
||||
#[cfg(unix)]
|
||||
const LOCK_WAIT: std::time::Duration = std::time::Duration::from_millis(100);
|
||||
|
||||
/// Lock the folder `dir` (`WouldBlock` while another process holds it).
|
||||
fn lock_folder(dir: &Path) -> Result<File, std::fs::TryLockError> {
|
||||
let lock = File::options().write(true).create(true).truncate(false).open(dir.join(LOCK)).map_err(std::fs::TryLockError::Error)?;
|
||||
lock.try_lock()?;
|
||||
Ok(lock)
|
||||
}
|
||||
|
||||
/// The copy at `file` is of one of your online files: join that file again as its owner, keeping
|
||||
/// the copy to compare with what arrives. The session, or `None` when the copy isn't one (or its
|
||||
/// file isn't recorded any more, or is open already): then it opens as any other copy.
|
||||
@@ -351,6 +372,7 @@ pub fn recover_all(root: &Path, s: &mut Session) -> Recovered {
|
||||
}
|
||||
}
|
||||
};
|
||||
let (mut free, mut held) = (Vec::new(), Vec::new());
|
||||
for e in entries.flatten() {
|
||||
let path = e.path();
|
||||
if !path.is_dir() {
|
||||
@@ -358,10 +380,22 @@ pub fn recover_all(root: &Path, s: &mut Session) -> Recovered {
|
||||
continue;
|
||||
}
|
||||
// Still running (another window of the app), or not ours to take.
|
||||
let Ok(lock) = File::options().write(true).create(true).truncate(false).open(path.join(LOCK)) else { continue };
|
||||
if lock.try_lock().is_err() {
|
||||
continue;
|
||||
match lock_folder(&path) {
|
||||
Ok(lock) => free.push((path, lock)),
|
||||
Err(std::fs::TryLockError::WouldBlock) => held.push(path),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
// A child between its `fork` and its `exec` shares the run's open descriptors, and a `flock`
|
||||
// lasts while any of them is open, so an ended run's folder can look held for a moment. Wait
|
||||
// once, however many folders are held, and try those again (#334). Windows doesn't pass the
|
||||
// lock to child processes.
|
||||
#[cfg(unix)]
|
||||
if !held.is_empty() {
|
||||
std::thread::sleep(LOCK_WAIT);
|
||||
free.extend(held.into_iter().filter_map(|path| lock_folder(&path).ok().map(|lock| (path, lock))));
|
||||
}
|
||||
for (path, lock) in free {
|
||||
let lock = Arc::new(lock);
|
||||
let files: Vec<PathBuf> = std::fs::read_dir(&path).into_iter().flatten().flatten().map(|f| f.path()).collect();
|
||||
for f in &files {
|
||||
|
||||
@@ -167,6 +167,23 @@ fn a_normal_quit_removes_the_recovered_copies_it_took_over() {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quitting_without_the_unsaved_question_keeps_copies_for_the_next_start() {
|
||||
let root = root("quitunasked");
|
||||
// macOS's Quit menu: nothing asked, so the unsaved document comes back next time.
|
||||
let s = edited();
|
||||
Recovery::start(&root).unwrap().quit(&s, false);
|
||||
let mut next = Session::empty();
|
||||
assert_eq!(recover(&root, &mut next), 1);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
// Asked (saved or discarded on purpose), or nothing unsaved: nothing is left.
|
||||
for (s, asked) in [(edited(), true), (Session::new(), false)] {
|
||||
Recovery::start(&root).unwrap().quit(&s, asked);
|
||||
assert!(std::fs::read_dir(&root).unwrap().next().is_none(), "asked: {asked}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_whose_folder_name_is_still_held_takes_another() {
|
||||
let root = root("pidreuse");
|
||||
@@ -186,6 +203,29 @@ fn a_run_whose_folder_name_is_still_held_takes_another() {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// #334: a lock that frees itself is not a run that is still going. A child between its `fork` and
|
||||
/// its `exec` inherits the run's open descriptors and a `flock` lasts while any descriptor for the
|
||||
/// file is open, so a crashed run's copies must still come back once that child has exec'd.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_lock_freed_a_moment_later_is_not_a_run_that_is_still_going() {
|
||||
let root = root("fork-window");
|
||||
crashed(&edited(), &root);
|
||||
// Hold the crashed run's folder lock on a second descriptor, the way a child forked before its
|
||||
// exec holds it, and free it shortly after.
|
||||
let lock = std::fs::File::options().write(true).create(true).truncate(false).open(root.join("crashed").join("lock")).unwrap();
|
||||
lock.try_lock().unwrap();
|
||||
let freed = std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
drop(lock);
|
||||
});
|
||||
// The next start waits for that lock rather than reading the run as still going.
|
||||
let mut next = Session::empty();
|
||||
assert_eq!(recover(&root, &mut next), 1, "the copies come back once the lock frees itself");
|
||||
freed.join().unwrap();
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// The rectangles in `doc`.
|
||||
fn rectangles(doc: &figcraft_engine::doc::Document) -> usize {
|
||||
doc.nodes().filter(|n| matches!(n.kind, figcraft_engine::doc::NodeKind::Rectangle)).count()
|
||||
|
||||
@@ -167,10 +167,18 @@ fn reveal(path: &Path) -> std::io::Result<()> {
|
||||
/// The preferences' file in the config folder.
|
||||
const STATE: &str = "state.json";
|
||||
|
||||
pub fn save_state(app: &FigApp) {
|
||||
if let Some(dir) = config_dir() {
|
||||
write_prefs(&dir, &app.session.prefs);
|
||||
/// Everything quitting keeps, all done when it returns (macOS then ends the process): the
|
||||
/// preferences in `dir`, this run's recovery copies ([`Recovery::quit`](crate::recovery::Recovery::quit))
|
||||
/// and the live sessions' ends (one this app hosts tells everyone and stops; a joined one leaves).
|
||||
pub fn shutdown(app: &mut FigApp, dir: Option<&Path>, recovery: Option<crate::recovery::Recovery>) {
|
||||
if let Some(dir) = dir {
|
||||
write_prefs(dir, &app.session.prefs);
|
||||
}
|
||||
// Before the sessions end: a recovered online file's copy waits for its session's outcome.
|
||||
if let Some(r) = recovery {
|
||||
r.quit(&app.session, app.ui.quit);
|
||||
}
|
||||
drop(std::mem::take(&mut app.session.live));
|
||||
}
|
||||
|
||||
/// Write `prefs` to `dir` atomically (a temporary file renamed over the old one: a crash leaves
|
||||
|
||||
@@ -82,3 +82,38 @@ fn preferences_are_readable_by_their_owner_only() {
|
||||
assert_eq!(mode(&dir.join("state.json")), 0o600);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A live link that only records the sessions it was told to leave.
|
||||
struct Leaves(Arc<Mutex<Vec<u64>>>);
|
||||
|
||||
impl figcraft_engine::live::LiveLink for Leaves {
|
||||
fn host(&mut self, _: u64, _: figcraft_engine::live::HostOptions) -> Result<figcraft_engine::live::SessionInfo, String> {
|
||||
Err("not in this test".into())
|
||||
}
|
||||
fn join(&mut self, _: u64, _: &str, _: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
fn leave(&mut self, session: u64) {
|
||||
self.0.lock().unwrap().push(session);
|
||||
}
|
||||
fn presence(&mut self, _: u64, _: &figcraft_engine::live::Presence) {}
|
||||
fn send(&mut self, _: u64, _: figcraft_engine::figcraft_collab::message::ClientMsg) {}
|
||||
fn poll(&mut self) -> Vec<(u64, figcraft_engine::live::Event)> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quitting_keeps_the_preferences_and_ends_the_live_sessions() {
|
||||
let dir = root("shutdown");
|
||||
let mut fig = start(&dir);
|
||||
let left = Arc::new(Mutex::new(Vec::new()));
|
||||
fig.session.live.install(Box::new(Leaves(left.clone())));
|
||||
let joined = fig.run("collab.join", json!({"link": "ws://127.0.0.1:9/?file=f1&token=EditSecretEditSecret00"})).unwrap();
|
||||
fig.session.prefs.user_name = "Ada".into();
|
||||
crate::services::shutdown(&mut fig, Some(&dir), None);
|
||||
assert_eq!(*left.lock().unwrap(), [joined["session"].as_u64().unwrap()], "the session was left");
|
||||
assert!(!fig.session.live.available(), "nothing is left to send on");
|
||||
assert_eq!(start(&dir).session.prefs.user_name, "Ada", "the preferences were kept");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -37,6 +37,11 @@ real_name = "Justin John"
|
||||
|
||||
[people.Losminek]
|
||||
|
||||
[people.alrifatsabbir]
|
||||
real_name = "Al Rifat Sabbir"
|
||||
display_name = "Al Rifat Sabbir"
|
||||
sync_github_name = true
|
||||
|
||||
[people.RevDonz]
|
||||
|
||||
[people.Nael-Nathanael]
|
||||
@@ -49,3 +54,16 @@ real_name = "Andrii Volkov"
|
||||
real_name = "Marco Arnulfo"
|
||||
display_name = "Marco Arnulfo"
|
||||
sync_github_name = false
|
||||
|
||||
[people.krinry]
|
||||
display_name = "krinry"
|
||||
sync_github_name = true
|
||||
|
||||
[people.pechnikov2026-star]
|
||||
sync_github_name = true
|
||||
|
||||
[people.MortenHusted]
|
||||
sync_github_name = true
|
||||
|
||||
[people.AganzeFelicite]
|
||||
sync_github_name = true
|
||||
@@ -16,7 +16,7 @@ use figcraft_engine::live::{Event, Status};
|
||||
use web_time::Instant;
|
||||
|
||||
use crate::inbox::Inbox;
|
||||
use crate::join::{GuestConfig, Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, unresolved};
|
||||
use crate::join::{GuestConfig, Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, too_large, unresolved};
|
||||
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
|
||||
|
||||
/// A WebSocket the driver opens, writes and closes; what it receives comes back through the
|
||||
@@ -114,6 +114,9 @@ impl<S: Socket> Driver<S> {
|
||||
/// A binary message from the host.
|
||||
pub(crate) fn message(&mut self, bytes: &[u8], now: Instant) {
|
||||
let Some(c) = self.conn.as_mut() else { return };
|
||||
if bytes.len() > self.cfg.limits.max_server_message {
|
||||
return self.end(too_large(&self.cfg.limits), now);
|
||||
}
|
||||
c.last_heard = now;
|
||||
let taken = match message::decode::<ServerMsg>(bytes, self.cfg.limits.max_server_message) {
|
||||
// The join code's answer: join the file it opens, on a new connection.
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::time::{Duration, Instant};
|
||||
use figcraft_collab::message::{self, ClientMsg, Presence, ServerMsg};
|
||||
use figcraft_collab::{Client, Limits, Outgoing, Server};
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use figcraft_engine::live::{Event, Status};
|
||||
use figcraft_engine::live::{Event, Status, TOO_LARGE_TO_JOIN};
|
||||
use tungstenite::protocol::WebSocket;
|
||||
use tungstenite::{Bytes, Message};
|
||||
|
||||
@@ -262,6 +262,15 @@ fn refusals_and_the_end_of_the_session_are_final() {
|
||||
assert!(d.done());
|
||||
assert!(matches!(statuses(&inbox.drain()).last(), Some(Status::Failed { reason }) if reason.starts_with("The host refused")));
|
||||
}
|
||||
// A message over the size limit: the file is too large for this app, not refused by the host.
|
||||
let inbox = Inbox::new(Arc::new(|| {}));
|
||||
let mut cfg = GuestConfig::new(invite(), "Ada".into());
|
||||
cfg.limits.max_server_message = 2 << 20;
|
||||
let mut d = Driver::start(Script::default(), cfg, inbox.clone(), t0);
|
||||
d.opened(t0);
|
||||
d.message(&vec![0; (2 << 20) + 1], t0);
|
||||
assert!(d.done());
|
||||
assert!(matches!(statuses(&inbox.drain()).last(), Some(Status::Failed { reason }) if *reason == TOO_LARGE_TO_JOIN.replace("{mb}", "2")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -26,7 +26,7 @@ use tungstenite::{Bytes, Message};
|
||||
use crate::inbox::Inbox;
|
||||
use crate::invite::{Endpoint, Invite};
|
||||
pub use crate::join::GuestConfig;
|
||||
use crate::join::{Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, unresolved};
|
||||
use crate::join::{Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, too_large, unresolved};
|
||||
use crate::manage;
|
||||
use crate::net::{Duplex, NetLimits, config, open, shutdown};
|
||||
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
|
||||
@@ -367,7 +367,7 @@ fn read_loop(mut ws: WebSocket<Duplex>, ctx: &ReaderCtx) {
|
||||
Ok(Message::Close(f)) => break f.map_or_else(|| closed(0, ""), |f| closed(f.code.into(), &f.reason)),
|
||||
Ok(Message::Text(_)) => break Outcome::Refused("the host sent a text message".into()),
|
||||
Ok(_) => {}
|
||||
Err(tungstenite::Error::Capacity(_)) => break Outcome::Refused("the host sent a message over the size limit".into()),
|
||||
Err(tungstenite::Error::Capacity(_)) => break too_large(&ctx.cfg.limits),
|
||||
Err(e) => break Outcome::Retry(e.to_string()),
|
||||
}
|
||||
};
|
||||
|
||||
@@ -203,7 +203,7 @@ fn pairs(query: &str) -> Result<Vec<(String, String)>, String> {
|
||||
fn with_port(authority: &str, default: u16) -> String {
|
||||
let has_port = match authority.rsplit_once(':') {
|
||||
// `[v6]` alone has colons inside the brackets only.
|
||||
Some((h, p)) => !(h.starts_with('[') && !h.ends_with(']')) && !p.contains(']'),
|
||||
Some((h, p)) => (!h.starts_with('[') || h.ends_with(']')) && !p.contains(']'),
|
||||
None => false,
|
||||
};
|
||||
if has_port { authority.to_string() } else { format!("{authority}:{default}") }
|
||||
@@ -220,7 +220,8 @@ fn valid_host(s: &str) -> bool {
|
||||
return true;
|
||||
}
|
||||
let Some((name, port)) = s.rsplit_once(':') else { return false };
|
||||
port.parse::<u16>().is_ok_and(|p| p != 0)
|
||||
port.bytes().all(|b| b.is_ascii_digit())
|
||||
&& port.parse::<u16>().is_ok_and(|p| p != 0)
|
||||
&& (1..=253).contains(&name.len())
|
||||
&& name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.')
|
||||
&& !name.starts_with(['-', '.'])
|
||||
@@ -239,6 +240,9 @@ fn percent_decode(s: &str) -> Option<String> {
|
||||
while let Some(&b) = bytes.get(i) {
|
||||
if b == b'%' {
|
||||
let hex = bytes.get(i + 1..i + 3)?;
|
||||
if !hex.iter().all(u8::is_ascii_hexdigit) {
|
||||
return None;
|
||||
}
|
||||
out.push(u8::from_str_radix(std::str::from_utf8(hex).ok()?, 16).ok()?);
|
||||
i += 3;
|
||||
} else {
|
||||
|
||||
@@ -123,3 +123,19 @@ proptest! {
|
||||
prop_assert_eq!(Invite::parse(&i.url()), Ok(i));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plus_or_sign_inside_a_percent_escape_is_broken() {
|
||||
assert!(Endpoint::parse("ws://example.org/a/?key=%41").is_ok());
|
||||
for bad in ["%+1", "%-1", "%1", "%+a"] {
|
||||
assert!(Endpoint::parse(&format!("ws://example.org/a/?key={bad}")).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_must_be_all_digits() {
|
||||
assert!(Endpoint::parse("ws://localhost:80/").is_ok());
|
||||
for bad in ["ws://localhost:+80/", "ws://localhost:-80/", "ws://localhost:/"] {
|
||||
assert!(Endpoint::parse(bad).is_err(), "accepted {bad}");
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ use std::time::Duration;
|
||||
|
||||
use figcraft_collab::Client;
|
||||
use figcraft_collab::message::{ClientMsg, Peer, Resume, ServerMsg};
|
||||
use figcraft_engine::live::{Event, Role, SessionInfo, Status};
|
||||
use figcraft_engine::live::{Event, Role, SessionInfo, Status, TOO_LARGE_TO_JOIN};
|
||||
|
||||
use crate::inbox::Inbox;
|
||||
use crate::invite::Invite;
|
||||
@@ -95,6 +95,12 @@ pub(crate) fn unresolved(cfg: &GuestConfig, outcome: Outcome) -> Outcome {
|
||||
})
|
||||
}
|
||||
|
||||
/// The host sent a message over this app's size limit (the file's welcome, usually): final, and
|
||||
/// not the host's doing.
|
||||
pub(crate) fn too_large(limits: &NetLimits) -> Outcome {
|
||||
Outcome::Failed(TOO_LARGE_TO_JOIN.replace("{mb}", &(limits.max_server_message >> 20).to_string()))
|
||||
}
|
||||
|
||||
/// The server's answer to the join code: the invite is whole now.
|
||||
pub(crate) fn resolved(cfg: &mut GuestConfig, file: String, token: String) -> Result<(), Outcome> {
|
||||
if !crate::invite::valid_id(&file, 1, 64) || !crate::invite::valid_id(&token, 16, 128) {
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::time::{Duration, Instant};
|
||||
use figcraft_collab::message::{self, ClientMsg, Hello, Presence, ServerMsg, Signal};
|
||||
use figcraft_collab::{Client, Limits};
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use figcraft_engine::live::{Event, PEOPLE, Status};
|
||||
use figcraft_engine::live::{Event, PEOPLE, Status, TOO_LARGE_TO_JOIN};
|
||||
use tungstenite::protocol::WebSocket;
|
||||
use tungstenite::{Bytes, Message};
|
||||
|
||||
@@ -642,3 +642,31 @@ fn the_host_removes_someone_and_they_cant_come_back() {
|
||||
host.stop();
|
||||
assert_eq!(host.connections(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_welcome_over_the_size_limit_says_the_file_is_too_large() {
|
||||
let mut doc = Document::new("B".repeat(1 << 20));
|
||||
doc.repair();
|
||||
let probe = Probe::new();
|
||||
let cfg = HostConfig {
|
||||
addr: SocketAddr::from(([127, 0, 0, 1], 0)),
|
||||
any_port_if_taken: false,
|
||||
file: FILE.into(),
|
||||
token: TOKEN.into(),
|
||||
doc,
|
||||
name: "Hana".into(),
|
||||
limits: fast_limits(),
|
||||
core: Limits::default(),
|
||||
};
|
||||
let mut host = Host::start(cfg, probe.inbox.clone()).unwrap();
|
||||
let invite = Invite::new(host.addr().to_string(), FILE, TOKEN);
|
||||
|
||||
let mut guest_cfg = quick(GuestConfig::new(invite, "Ada".into()));
|
||||
guest_cfg.limits = NetLimits { max_server_message: 1 << 20, ..fast_limits() };
|
||||
let mut gp = Probe::new();
|
||||
let _g = Guest::start(guest_cfg, gp.inbox.clone()).unwrap();
|
||||
let too_large = TOO_LARGE_TO_JOIN.replace("{mb}", "1");
|
||||
gp.wait("the file too large to join", |e| e.iter().any(|ev| matches!(ev, Event::Status(Status::Failed { reason }) if *reason == too_large)));
|
||||
assert!(!welcomed(&gp.events));
|
||||
host.stop();
|
||||
}
|
||||
@@ -87,8 +87,9 @@ macro_rules! node_props {
|
||||
($($variant:ident => $field:ident),* $(,)?) => {
|
||||
/// A node property: the tree position (`parent`, `position`) or one saved field of
|
||||
/// [`Node`]. List-valued fields (fills, text runs, vector networks…) are one property each,
|
||||
/// replaced whole (three-way merges line fills, strokes and effects up item by item, see
|
||||
/// [`merge`](crate::merge)). New properties go at the end (the binary form numbers them).
|
||||
/// replaced whole (three-way merges line the lists whose items stand on their own, such as
|
||||
/// fills, up item by item, see [`merge`](crate::merge)). New properties go at the end (the
|
||||
/// binary form numbers them).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Prop {
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
//!
|
||||
//! A conflict is the same property (or document-level entry) changed on both sides to different
|
||||
//! values, or a layer deleted on one side while the other side edited it or something inside it
|
||||
//! (including adding layers to it or moving layers into or out of it). Fills, strokes and effects
|
||||
//! merge item by item: both sides changing different items of the list (or one adding items at its
|
||||
//! end) is no conflict, only the same item changed both ways or the list shortened on a side
|
||||
//! while the other changed it. Nothing that conflicts is
|
||||
//! merged until it is resolved: each conflict is resolved for one side, and the other side's
|
||||
//! changes to it are left out. Everything else from both sides is kept.
|
||||
//! (including adding layers to it or moving layers into or out of it). The layer lists whose items
|
||||
//! stand on their own (fills, strokes, effects, layout grids, export settings, guides, prototype
|
||||
//! interactions, accessibility annotations and Dev Mode resources) merge item by item: both sides
|
||||
//! changing different items of the list (or one adding items at its end) is no conflict, only the
|
||||
//! same item changed both ways or the list shortened on a side while the other changed it. Nothing
|
||||
//! that conflicts is merged until it is resolved: each conflict is resolved for one side, and the
|
||||
//! other side's changes to it are left out. Everything else from both sides is kept.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt;
|
||||
@@ -159,8 +160,23 @@ fn by_key(changes: &[Change]) -> BTreeMap<Key, Vec<usize>> {
|
||||
out
|
||||
}
|
||||
|
||||
/// List properties whose items merge one by one.
|
||||
const PER_ITEM: &[Prop] = &[Prop::Fills, Prop::Strokes, Prop::Effects];
|
||||
/// List properties whose items merge one by one: the items stand on their own, so both sides
|
||||
/// changing different ones (or one side adding at its end) is no conflict. Sets and keyed maps
|
||||
/// (`bound`, `varModes`, `overrides`, `propRefs`, `pluginData`) are not here: their items line up
|
||||
/// by key, not by position.
|
||||
const PER_ITEM: &[Prop] = &[
|
||||
Prop::Fills,
|
||||
Prop::Strokes,
|
||||
Prop::Effects,
|
||||
Prop::Grids,
|
||||
Prop::Exports,
|
||||
Prop::Guides,
|
||||
Prop::Interactions,
|
||||
Prop::Annotations,
|
||||
// Not `CodeLinks`: there is one per language, and lining them up by position could leave two
|
||||
// for one language (the second hidden), so they merge whole.
|
||||
Prop::DevResources,
|
||||
];
|
||||
|
||||
/// Both sides' writes `b` and `m` of list property `key` merged item by item against the base
|
||||
/// (see [`merge_items`]); `None` when `key` isn't such a list or the writes conflict.
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
//! Fills, strokes and effects merge item by item: different items changed on each side merge,
|
||||
//! the same item changed both ways conflicts.
|
||||
//! The layer lists whose items stand on their own (fills, strokes, effects, layout grids, export
|
||||
//! settings, guides, prototype interactions, accessibility annotations and Dev Mode resources)
|
||||
//! merge item by item: different items changed on each side merge, the same item changed both ways
|
||||
//! conflicts.
|
||||
|
||||
use figcraft_color::{Color, Paint};
|
||||
use figcraft_doc::{Document, Effect, NodeId, NodeKind};
|
||||
use figcraft_doc::{Document, Effect, LayoutGrid, NodeId, NodeKind, proto};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::merge::{Key, Side, merge_items, plan};
|
||||
@@ -100,19 +102,113 @@ fn the_same_item_changed_both_ways_conflicts() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_lists_still_conflict_as_a_whole() {
|
||||
// Docs silent: lists other than fills, strokes and effects (export settings here) still
|
||||
// conflict whole when both sides change them.
|
||||
fn the_other_item_lists_merge_one_by_one_too() {
|
||||
// Layout grids, export settings, guides, accessibility annotations and Dev Mode resources are
|
||||
// item lists like fills: both sides changing different items merges.
|
||||
let (mut base, _, _, r) = files();
|
||||
let export = |suffix: &str| figcraft_doc::ExportSetting { suffix: suffix.into(), ..Default::default() };
|
||||
base.get_mut(r).unwrap().exports = vec![export("@1"), export("@2")];
|
||||
let guide = |vertical: bool, offset: f64| figcraft_doc::Guide { vertical, offset };
|
||||
let annot = |label: &str| figcraft_doc::Annotation { label: label.into(), properties: Vec::new(), category: None };
|
||||
let grid = |size: f64| LayoutGrid { size, ..Default::default() };
|
||||
let res = |url: &str| figcraft_doc::DevResource { url: url.into(), name: String::new() };
|
||||
let n = base.get_mut(r).unwrap();
|
||||
n.exports = vec![export("@1"), export("@2")];
|
||||
n.guides = vec![guide(true, 10.0), guide(false, 20.0)];
|
||||
n.annotations = vec![annot("a1"), annot("a2")];
|
||||
n.grids = vec![grid(10.0), grid(20.0)];
|
||||
n.dev_resources = vec![res("https://a.test"), res("https://b.test")];
|
||||
let (mut branch, mut main) = (base.clone(), base.clone());
|
||||
branch.set_client_id(base.highest_id_space() + 1);
|
||||
branch.get_mut(r).unwrap().exports[0] = export("@b");
|
||||
main.get_mut(r).unwrap().exports[1] = export("@m");
|
||||
// The branch changes the first item of each list; main the second.
|
||||
let b = branch.get_mut(r).unwrap();
|
||||
(b.exports[0].suffix, b.guides[0].offset, b.annotations[0].label, b.grids[0].size, b.dev_resources[0].url) =
|
||||
("@b".into(), 11.0, "b1".into(), 12.0, "https://branch.test".into());
|
||||
let m = main.get_mut(r).unwrap();
|
||||
(m.exports[1].suffix, m.guides[1].offset, m.annotations[1].label, m.grids[1].size, m.dev_resources[1].url) =
|
||||
("@m".into(), 22.0, "m2".into(), 24.0, "https://main.test".into());
|
||||
both_ways(&base, &branch, &main, |doc| {
|
||||
let n = doc.node(r).unwrap();
|
||||
let suffixes: Vec<&str> = n.exports.iter().map(|e| e.suffix.as_str()).collect();
|
||||
assert_eq!(suffixes, ["@b", "@m"], "export settings");
|
||||
let offsets: Vec<f64> = n.guides.iter().map(|g| g.offset).collect();
|
||||
assert_eq!(offsets, [11.0, 22.0], "guides");
|
||||
let labels: Vec<&str> = n.annotations.iter().map(|a| a.label.as_str()).collect();
|
||||
assert_eq!(labels, ["b1", "m2"], "annotations");
|
||||
let sizes: Vec<f64> = n.grids.iter().map(|g| g.size).collect();
|
||||
assert_eq!(sizes, [12.0, 24.0], "layout grids");
|
||||
let urls: Vec<&str> = n.dev_resources.iter().map(|d| d.url.as_str()).collect();
|
||||
assert_eq!(urls, ["https://branch.test", "https://main.test"], "dev resources");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_item_of_another_list_changed_both_ways_conflicts() {
|
||||
let (mut base, _, _, r) = files();
|
||||
let link = |language: &str| figcraft_doc::CodeLink { language: language.into(), ..Default::default() };
|
||||
let n = base.get_mut(r).unwrap();
|
||||
n.exports = vec![
|
||||
figcraft_doc::ExportSetting { suffix: "@1".into(), ..Default::default() },
|
||||
figcraft_doc::ExportSetting { suffix: "@2".into(), ..Default::default() },
|
||||
];
|
||||
n.code_links = vec![link("react"), link("vue")];
|
||||
let (mut branch, mut main) = (base.clone(), base.clone());
|
||||
branch.set_client_id(base.highest_id_space() + 1);
|
||||
branch.get_mut(r).unwrap().exports[0].suffix = "@b".into();
|
||||
main.get_mut(r).unwrap().exports[0].suffix = "@m".into();
|
||||
// Code links are one per language, not an item list: different links changed still conflict.
|
||||
branch.get_mut(r).unwrap().code_links[0].name = "B".into();
|
||||
main.get_mut(r).unwrap().code_links[1].name = "M".into();
|
||||
let p = plan(&base, &branch, &main).unwrap();
|
||||
let keys: Vec<String> = p.conflicts.iter().map(|c| c.key.to_string()).collect();
|
||||
assert_eq!(keys, [format!("{r}/exports")]);
|
||||
let mut keys: Vec<String> = p.conflicts.iter().map(|c| c.key.to_string()).collect();
|
||||
keys.sort();
|
||||
let mut want = [format!("{r}/codeLinks"), format!("{r}/exports")];
|
||||
want.sort();
|
||||
assert_eq!(keys, want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interactions_merge_item_by_item() {
|
||||
let ia = |duration_ms: u32| proto::Interaction {
|
||||
trigger: Default::default(),
|
||||
actions: vec![proto::Action::Back],
|
||||
transition: Default::default(),
|
||||
duration_ms,
|
||||
easing: Default::default(),
|
||||
delay_ms: 0,
|
||||
};
|
||||
let durations = |doc: &Document, r: NodeId| doc.node(r).unwrap().interactions.iter().map(|i| i.duration_ms).collect::<Vec<_>>();
|
||||
let (mut base, _, _, r) = files();
|
||||
base.get_mut(r).unwrap().interactions = vec![ia(100), ia(200)];
|
||||
let fork = || {
|
||||
let (mut branch, main) = (base.clone(), base.clone());
|
||||
branch.set_client_id(base.highest_id_space() + 1);
|
||||
(branch, main)
|
||||
};
|
||||
// The branch changes the first interaction and adds one; main changes the second.
|
||||
let (mut branch, mut main) = fork();
|
||||
branch.get_mut(r).unwrap().interactions[0].duration_ms = 150;
|
||||
branch.get_mut(r).unwrap().interactions.push(ia(300));
|
||||
main.get_mut(r).unwrap().interactions[1].duration_ms = 250;
|
||||
both_ways(&base, &branch, &main, |doc| assert_eq!(durations(doc, r), [150, 250, 300]));
|
||||
// The branch deletes one while main leaves them alone.
|
||||
let (mut branch, mut main) = fork();
|
||||
branch.get_mut(r).unwrap().interactions.remove(0);
|
||||
main.get_mut(r).unwrap().fills[0] = red();
|
||||
both_ways(&base, &branch, &main, |doc| assert_eq!(durations(doc, r), [200]));
|
||||
// The same interaction changed both ways, or one deleted while the other side changed the
|
||||
// list, conflicts.
|
||||
for delete in [false, true] {
|
||||
let (mut branch, mut main) = fork();
|
||||
if delete {
|
||||
branch.get_mut(r).unwrap().interactions.remove(0);
|
||||
} else {
|
||||
branch.get_mut(r).unwrap().interactions[1].duration_ms = 220;
|
||||
}
|
||||
main.get_mut(r).unwrap().interactions[1].duration_ms = 250;
|
||||
let p = plan(&base, &branch, &main).unwrap();
|
||||
let keys: Vec<String> = p.conflicts.iter().map(|c| c.key.to_string()).collect();
|
||||
assert_eq!(keys, [format!("{r}/interactions")], "delete: {delete}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -61,7 +61,7 @@ impl<'de> Deserialize<'de> for Color {
|
||||
}
|
||||
|
||||
fn clamp01(v: f32) -> f32 {
|
||||
if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }
|
||||
if v.is_nan() { 0.0 } else { v.clamp(0.0, 1.0) }
|
||||
}
|
||||
|
||||
impl Default for Color {
|
||||
|
||||
@@ -80,7 +80,7 @@ fn blend_names() {
|
||||
#[test]
|
||||
fn nan_inputs_are_sanitized() {
|
||||
let c = Color::new(f32::NAN, 2.0, -1.0, f32::INFINITY);
|
||||
assert_eq!(c.to_rgba8(), [0, 255, 0, 0]);
|
||||
assert_eq!(c.to_rgba8(), [0, 255, 0, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -106,3 +106,11 @@ fn crop_defaults_to_fill_and_roundtrips() {
|
||||
assert!((a - b).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infinite_channels_clamp() {
|
||||
let c = Color::parse_css("rgb(1e40, 0, 0)").unwrap();
|
||||
assert_eq!(c.r, 1.0);
|
||||
let c = Color::new(f32::INFINITY, f32::NEG_INFINITY, f32::NAN, f32::INFINITY);
|
||||
assert_eq!((c.r, c.g, c.b, c.a), (1.0, 0.0, 0.0, 1.0));
|
||||
}
|
||||
+47
-16
@@ -77,17 +77,19 @@ pub enum DocError {
|
||||
|
||||
pub type DocResult<T> = Result<T, DocError>;
|
||||
|
||||
/// The set of touched node ids, and whether any layer is a mask (worked out when first asked
|
||||
/// after a change: hit testing skips its mask checks without one). Never part of equality or
|
||||
/// The set of touched node ids, whether any layer is a mask (hit testing skips its mask checks
|
||||
/// without one) and the largest background blur radius (renders keep that much overlap between
|
||||
/// their bands), both worked out when first asked after a change. Never part of equality or
|
||||
/// serialisation.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Dirty(pub std::collections::BTreeSet<NodeId>, std::sync::OnceLock<bool>);
|
||||
pub struct Dirty(pub std::collections::BTreeSet<NodeId>, std::sync::OnceLock<bool>, std::sync::OnceLock<f64>);
|
||||
|
||||
impl Dirty {
|
||||
/// Record node `id` as touched (edited, added or removed).
|
||||
fn touch(&mut self, id: NodeId) {
|
||||
self.0.insert(id);
|
||||
self.1 = std::sync::OnceLock::new();
|
||||
self.2 = std::sync::OnceLock::new();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,6 +256,11 @@ impl Document {
|
||||
*self.dirty.1.get_or_init(|| self.nodes.values().any(|n| n.is_mask))
|
||||
}
|
||||
|
||||
/// The largest background blur or glass radius of any layer's effects (0 without any).
|
||||
pub fn max_backdrop_radius(&self) -> f64 {
|
||||
*self.dirty.2.get_or_init(|| self.nodes.values().flat_map(|n| n.effects.iter()).map(Effect::backdrop_radius).fold(0.0, f64::max))
|
||||
}
|
||||
|
||||
/// Node ids touched so far (without clearing).
|
||||
pub fn dirty(&self) -> &std::collections::BTreeSet<NodeId> {
|
||||
&self.dirty.0
|
||||
@@ -942,21 +949,39 @@ impl Document {
|
||||
self.styles.iter().find(|s| s.id == id || s.name == id)
|
||||
}
|
||||
|
||||
/// The effective variable modes for a node (walking up its ancestors).
|
||||
/// The effective variable modes for a node: the ones it sets over [`Self::inherited_modes`].
|
||||
pub fn modes_for(&self, id: NodeId) -> BTreeMap<CollectionId, ModeId> {
|
||||
let mut out = BTreeMap::new();
|
||||
let mut chain = vec![id];
|
||||
chain.extend(self.ancestors(id));
|
||||
for c in chain.into_iter().rev() {
|
||||
if let Some(n) = self.node(c) {
|
||||
for (k, v) in &n.var_modes {
|
||||
out.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
let mut out = self.inherited_modes(id);
|
||||
if let Some(n) = self.node(id) {
|
||||
out.extend(n.var_modes.iter().map(|(k, v)| (k.clone(), v.clone())));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The modes a node is on where it sets none (Auto): its ancestors', and for an instance the
|
||||
/// ones its main component sets, which are closer than any container's.
|
||||
pub fn inherited_modes(&self, id: NodeId) -> BTreeMap<CollectionId, ModeId> {
|
||||
let mut out = BTreeMap::new();
|
||||
for c in self.ancestors(id).into_iter().rev() {
|
||||
if let Some(n) = self.node(c) {
|
||||
out.extend(self.main_modes(n).chain(&n.var_modes).map(|(k, v)| (k.clone(), v.clone())));
|
||||
}
|
||||
}
|
||||
if let Some(n) = self.node(id) {
|
||||
out.extend(self.main_modes(n).map(|(k, v)| (k.clone(), v.clone())));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The modes an instance's main component sets (none for other layers).
|
||||
fn main_modes<'a>(&'a self, n: &Node) -> impl Iterator<Item = (&'a CollectionId, &'a ModeId)> {
|
||||
let main = match n.kind {
|
||||
NodeKind::Instance { component, .. } => self.node(component),
|
||||
_ => None,
|
||||
};
|
||||
main.into_iter().flat_map(|m| &m.var_modes)
|
||||
}
|
||||
|
||||
/// Check structural invariants (used by tests and after loading untrusted files).
|
||||
pub fn validate(&self) -> Vec<String> {
|
||||
let mut issues = Vec::new();
|
||||
@@ -1117,8 +1142,14 @@ impl Document {
|
||||
/// mask with the layers above it up to the next mask, or a single layer. An auto layout frame
|
||||
/// whose first child paints on top paints the runs in reverse (see [`Node::paint_order`]).
|
||||
pub fn paint_runs(&self, n: &Node) -> Vec<std::ops::Range<usize>> {
|
||||
let kids = &n.children;
|
||||
let node = |i: usize| kids.get(i).and_then(|k| self.node(*k));
|
||||
let kids: Vec<Option<&Node>> = n.children.iter().map(|k| self.node(*k)).collect();
|
||||
Self::paint_runs_of(&kids, n.layout.as_ref().is_some_and(|l| l.reverse_z))
|
||||
}
|
||||
|
||||
/// [`Self::paint_runs`] of children already looked up (`None`: not in the document), last run
|
||||
/// first when `reverse_z`.
|
||||
pub fn paint_runs_of(kids: &[Option<&Node>], reverse_z: bool) -> Vec<std::ops::Range<usize>> {
|
||||
let node = |i: usize| kids.get(i).copied().flatten();
|
||||
let mut runs = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < kids.len() {
|
||||
@@ -1131,7 +1162,7 @@ impl Document {
|
||||
runs.push(i..j);
|
||||
i = j;
|
||||
}
|
||||
if n.layout.as_ref().is_some_and(|l| l.reverse_z) {
|
||||
if reverse_z {
|
||||
runs.reverse();
|
||||
}
|
||||
runs
|
||||
|
||||
+92
-1
@@ -860,7 +860,35 @@ pub struct Device {
|
||||
|
||||
impl PrototypeSettings {
|
||||
/// The devices to pick from ("None" presents the frame at its own size).
|
||||
pub const DEVICES: [Device; 6] = [
|
||||
pub const DEVICES: [Device; 19] = [
|
||||
Device {
|
||||
name: "iPhone 17",
|
||||
width: 402.0,
|
||||
height: 874.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x38, 0x39, 0x3D), c("White", 0xE9, 0xE9, 0xE5)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 17 Pro",
|
||||
width: 402.0,
|
||||
height: 874.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x38, 0x39, 0x3D), c("Silver", 0xD7, 0xD7, 0xD5)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 17 Pro Max",
|
||||
width: 440.0,
|
||||
height: 956.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x38, 0x39, 0x3D), c("Silver", 0xD7, 0xD7, 0xD5)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone Air",
|
||||
width: 420.0,
|
||||
height: 912.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x38, 0x39, 0x3D), c("Silver", 0xD7, 0xD7, 0xD5)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 16",
|
||||
width: 393.0,
|
||||
@@ -874,6 +902,13 @@ impl PrototypeSettings {
|
||||
c("Blue", 0x72, 0x84, 0xD4),
|
||||
],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 16 Pro",
|
||||
width: 402.0,
|
||||
height: 874.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x3B, 0x3A, 0x38), c("White", 0xEC, 0xEB, 0xE6)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 16 Pro Max",
|
||||
width: 440.0,
|
||||
@@ -881,6 +916,55 @@ impl PrototypeSettings {
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x3B, 0x3A, 0x38), c("White", 0xEC, 0xEB, 0xE6), c("Natural", 0xBF, 0xB9, 0xAF), c("Sand", 0xC6, 0xAC, 0x8F)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 16 Plus",
|
||||
width: 430.0,
|
||||
height: 932.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x3A, 0x3B, 0x3F), c("White", 0xEE, 0xED, 0xE8)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 15 Pro",
|
||||
width: 393.0,
|
||||
height: 852.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x3B, 0x3A, 0x38), c("Silver", 0xD7, 0xD7, 0xD5)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 15 Pro Max",
|
||||
width: 430.0,
|
||||
height: 932.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x3B, 0x3A, 0x38), c("Silver", 0xD7, 0xD7, 0xD5)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 15 Plus",
|
||||
width: 430.0,
|
||||
height: 932.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x3A, 0x3B, 0x3F), c("White", 0xEE, 0xED, 0xE8)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 14 Pro",
|
||||
width: 393.0,
|
||||
height: 852.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x3B, 0x3A, 0x38), c("Silver", 0xD7, 0xD7, 0xD5)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 14 Pro Max",
|
||||
width: 430.0,
|
||||
height: 932.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x3B, 0x3A, 0x38), c("Silver", 0xD7, 0xD7, 0xD5)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 14",
|
||||
width: 390.0,
|
||||
height: 844.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x3A, 0x3B, 0x3F), c("White", 0xEE, 0xED, 0xE8)],
|
||||
},
|
||||
Device {
|
||||
name: "Android Compact",
|
||||
width: 412.0,
|
||||
@@ -888,6 +972,13 @@ impl PrototypeSettings {
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x2E, 0x2F, 0x33), c("Silver", 0xD6, 0xD8, 0xDB), c("Green", 0xA6, 0xC0, 0xA3)],
|
||||
},
|
||||
Device {
|
||||
name: "Android Medium",
|
||||
width: 700.0,
|
||||
height: 840.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x2E, 0x2F, 0x33), c("Silver", 0xD6, 0xD8, 0xDB)],
|
||||
},
|
||||
Device {
|
||||
name: "iPad mini",
|
||||
width: 744.0,
|
||||
|
||||
@@ -20,6 +20,7 @@ figcraft-render = { workspace = true }
|
||||
figcraft-format = { workspace = true }
|
||||
figcraft-svg = { workspace = true }
|
||||
figcraft-pdf = { workspace = true }
|
||||
craft-docpath = { workspace = true }
|
||||
figcraft-plugin = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# The workspace settings (../../clippy.toml; clippy reads only the nearest file), plus the engine's
|
||||
# own rule: shipped engine code reaches the file system only through `file_access`, which enforces
|
||||
# the automation roots (#715), so every command is confined without a check of its own.
|
||||
allow-unwrap-in-tests = true
|
||||
allow-expect-in-tests = true
|
||||
allow-panic-in-tests = true
|
||||
too-many-arguments-threshold = 10
|
||||
disallowed-methods = [
|
||||
{ path = "std::fs::read", reason = "use file_access::read (automation roots)" },
|
||||
{ path = "std::fs::read_to_string", reason = "use file_access::read (automation roots)" },
|
||||
{ path = "std::fs::write", reason = "use file_access::write (automation roots)" },
|
||||
{ path = "std::fs::metadata", reason = "use file_access::metadata (automation roots)" },
|
||||
{ path = "std::fs::symlink_metadata", reason = "use file_access (automation roots)" },
|
||||
{ path = "std::fs::read_dir", reason = "use file_access::read_dir (automation roots)" },
|
||||
{ path = "std::fs::canonicalize", reason = "use file_access (automation roots)" },
|
||||
{ path = "std::fs::copy", reason = "use file_access (automation roots)" },
|
||||
{ path = "std::fs::rename", reason = "use file_access (automation roots)" },
|
||||
{ path = "std::fs::create_dir", reason = "use file_access::create_dir_all (automation roots)" },
|
||||
{ path = "std::fs::create_dir_all", reason = "use file_access::create_dir_all (automation roots)" },
|
||||
{ path = "std::fs::remove_file", reason = "use file_access (automation roots)" },
|
||||
{ path = "std::fs::remove_dir", reason = "use file_access (automation roots)" },
|
||||
{ path = "std::fs::remove_dir_all", reason = "use file_access (automation roots)" },
|
||||
{ path = "std::fs::hard_link", reason = "use file_access (automation roots)" },
|
||||
{ path = "std::fs::File::open", reason = "use file_access::open (automation roots)" },
|
||||
{ path = "std::fs::File::create", reason = "use file_access::write (automation roots)" },
|
||||
{ path = "std::fs::File::create_new", reason = "use file_access::write (automation roots)" },
|
||||
{ path = "std::fs::OpenOptions::open", reason = "use file_access (automation roots)" },
|
||||
{ path = "std::path::Path::exists", reason = "use file_access::exists (automation roots)" },
|
||||
{ path = "std::path::Path::try_exists", reason = "use file_access::exists (automation roots)" },
|
||||
{ path = "std::path::Path::is_file", reason = "use file_access::metadata (automation roots)" },
|
||||
{ path = "std::path::Path::is_dir", reason = "use file_access::is_dir (automation roots)" },
|
||||
{ path = "std::path::Path::metadata", reason = "use file_access::metadata (automation roots)" },
|
||||
{ path = "std::path::Path::symlink_metadata", reason = "use file_access (automation roots)" },
|
||||
{ path = "std::path::Path::read_dir", reason = "use file_access::read_dir (automation roots)" },
|
||||
{ path = "std::path::Path::canonicalize", reason = "use file_access (automation roots)" },
|
||||
{ path = "figcraft_format::write_file_atomic", reason = "use file_access::write (automation roots)" },
|
||||
{ path = "figcraft_format::save_file", reason = "use file_access::write (automation roots)" },
|
||||
{ path = "figcraft_format::load_file", reason = "use file_access::read (automation roots)" },
|
||||
]
|
||||
@@ -55,3 +55,51 @@ fn reduce_motion_is_a_preference_that_persists() {
|
||||
let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap();
|
||||
assert!(!old.reduce_motion);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interface_size_is_relative_to_the_system_and_persists() {
|
||||
let mut s = Session::new();
|
||||
assert_eq!(s.prefs.ui_scale, 1.0, "the system's own size by default");
|
||||
assert_eq!(s.execute("pref.uiScale", &json!({})).unwrap()["scale"], 1.0);
|
||||
assert_eq!(s.execute("pref.uiScale", &json!({"scale": 1.25})).unwrap()["scale"], 1.25);
|
||||
assert_eq!(s.prefs.ui_scale, 1.25);
|
||||
// Reading changes nothing; "auto" (older scripts) is the system's own size.
|
||||
assert_eq!(s.execute("pref.uiScale", &json!({})).unwrap()["scale"], 1.25);
|
||||
assert_eq!(s.execute("pref.uiScale", &json!({"scale": "Auto"})).unwrap()["scale"], 1.0);
|
||||
// Nothing to undo: the document didn't change.
|
||||
assert!(!s.ds().map(|d| d.is_dirty()).unwrap_or(false));
|
||||
// Saved with the other preferences and read back.
|
||||
s.prefs.ui_scale = 0.9;
|
||||
let v = serde_json::to_value(&s.prefs).unwrap();
|
||||
assert_eq!(v["ui_scale"], 0.9);
|
||||
let back: crate::Prefs = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(back.ui_scale, 0.9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interface_size_stops_at_the_ends_and_junk_never_changes_it() {
|
||||
let mut s = Session::new();
|
||||
assert_eq!(s.execute("pref.uiScale", &json!({"scale": 0.01})).unwrap()["scale"], crate::UI_SCALE_MIN);
|
||||
assert_eq!(s.execute("pref.uiScale", &json!({"scale": -4})).unwrap()["scale"], crate::UI_SCALE_MIN);
|
||||
assert_eq!(s.execute("pref.uiScale", &json!({"scale": 1e308})).unwrap()["scale"], crate::UI_SCALE_MAX);
|
||||
// The size is a factor (1.25), not a percentage: 125 is far past the end and stops there.
|
||||
assert_eq!(s.execute("pref.uiScale", &json!({"scale": 125})).unwrap()["scale"], crate::UI_SCALE_MAX);
|
||||
s.prefs.ui_scale = 1.5;
|
||||
for junk in [json!({"scale": "NaN"}), json!({"scale": "big"}), json!({"scale": [1]}), json!({"scale": {"x": 1}})] {
|
||||
assert!(s.execute("pref.uiScale", &junk).is_err(), "{junk}");
|
||||
assert_eq!(s.prefs.ui_scale, 1.5, "{junk}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_settings_file_without_or_with_a_wrong_interface_size_reads_as_the_systems_size_and_keeps_the_rest() {
|
||||
let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap();
|
||||
assert_eq!(old.ui_scale, 1.0);
|
||||
for bad in [json!("big"), json!(null), json!([2]), json!(true)] {
|
||||
let p: crate::Prefs = serde_json::from_value(json!({"theme": "dark", "ui_scale": bad})).unwrap();
|
||||
assert_eq!((p.ui_scale, p.theme.as_str()), (1.0, "dark"), "{bad}");
|
||||
}
|
||||
// A number out of range is clamped rather than refused.
|
||||
let p: crate::Prefs = serde_json::from_value(json!({"ui_scale": 99, "theme": "dark"})).unwrap();
|
||||
assert_eq!((p.ui_scale, p.theme.as_str()), (crate::UI_SCALE_MAX, "dark"));
|
||||
}
|
||||
@@ -180,6 +180,23 @@ fn vertical_wrap_columns_take_arrows_and_drops() {
|
||||
assert_eq!(line, Some([30.0, 48.0, 50.0, 48.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adding_auto_layout_to_a_frame_keeps_its_size() {
|
||||
// An existing frame with content hugs it (padding inferred, so the size is preserved);
|
||||
// an empty one keeps its size instead of collapsing to its padding.
|
||||
let mut s = Session::new();
|
||||
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 200})).unwrap());
|
||||
rect(&mut s, f, 40.0, 20.0, "#FF0000");
|
||||
s.execute("layout.add", &json!({"ids": [f]})).unwrap();
|
||||
assert_eq!(sizing(&s, f), (Sizing::Hug, Sizing::Hug));
|
||||
assert_eq!(size(&s, f), (300.0, 200.0));
|
||||
// An empty frame keeps its size too.
|
||||
let e = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 200})).unwrap());
|
||||
s.execute("layout.add", &json!({"ids": [e]})).unwrap();
|
||||
assert_eq!(sizing(&s, e), (Sizing::Fixed, Sizing::Fixed));
|
||||
assert_eq!(size(&s, e), (300.0, 200.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canvas_stacking_paints_and_exports_the_first_child_on_top() {
|
||||
// Documented: the last child is on top by default; "first on top" flips which one is drawn
|
||||
|
||||
@@ -46,7 +46,7 @@ fn a_bound_fill_colour_keeps_the_other_fills() {
|
||||
let fills = s.doc().unwrap().node(r).unwrap().fills.clone();
|
||||
assert_eq!(fills.len(), 2, "the second fill stays");
|
||||
assert_eq!(fills[0].kind, PaintKind::Solid { color: Color::from_rgb8_const(0xFF, 0, 0) });
|
||||
assert_eq!(fills[0].opacity, 0.5, "the bound paint keeps its opacity");
|
||||
assert_eq!(fills[0].opacity, 1.0, "the variable brings the whole colour, alpha included");
|
||||
assert_eq!(fills[1].kind, PaintKind::Solid { color: Color::from_rgb8_const(0, 0xFF, 0) });
|
||||
// Removing the bound (first) fill takes the binding with it.
|
||||
ex(&mut s, "fill.remove", json!({"ids": [r], "index": 0}));
|
||||
@@ -97,3 +97,77 @@ fn effect_moves_and_removals_reject_junk() {
|
||||
ex(&mut s, "effect.remove", json!({"ids": [r], "index": 7}));
|
||||
assert_eq!(s.doc().unwrap().node(r).unwrap().effects.len(), 1);
|
||||
}
|
||||
|
||||
fn fill_hex(s: &Session, r: NodeId) -> Vec<String> {
|
||||
s.doc().unwrap().node(r).unwrap().fills.iter().filter_map(|p| p.solid_color()).map(|c| c.to_hex()).collect()
|
||||
}
|
||||
|
||||
fn brand_id(s: &Session) -> String {
|
||||
s.doc().unwrap().variables.get("brand").unwrap().id.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_paint_edits_detach_the_colour_binding() {
|
||||
// An edit that reported success keeps its colour: the binding it overwrote lets go (as
|
||||
// selection colours already do), instead of putting the variable's colour back.
|
||||
let edits = [
|
||||
("fill.set", json!({"color": "#00FF00"})),
|
||||
("node.set", json!({"props": {"fill": "#00FF00"}})),
|
||||
("node.set", json!({"props": {"fills": ["#00FF00"]}})),
|
||||
];
|
||||
for (cmd, mut p) in edits {
|
||||
let (mut s, r) = setup();
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"}));
|
||||
p["ids"] = json!([r]);
|
||||
ex(&mut s, cmd, p.clone());
|
||||
assert_eq!(fill_hex(&s, r), ["00FF00"], "{cmd} {p}");
|
||||
assert!(bound(&s, r).is_empty(), "{cmd} {p}");
|
||||
ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#123456"}));
|
||||
assert_eq!(fill_hex(&s, r), ["00FF00"], "{cmd} {p}: no longer follows the variable");
|
||||
}
|
||||
for (cmd, mut p) in [("stroke.set", json!({"color": "#00FF00"})), ("node.set", json!({"props": {"stroke": "#00FF00"}}))] {
|
||||
let (mut s, r) = setup();
|
||||
ex(&mut s, "node.set", json!({"ids": [r], "props": {"strokes": ["#000000"]}}));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "stroke", "variable": "brand"}));
|
||||
p["ids"] = json!([r]);
|
||||
ex(&mut s, cmd, p);
|
||||
let n = s.doc().unwrap().node(r).unwrap();
|
||||
assert_eq!(n.strokes[0].solid_color().map(|c| c.to_hex()).as_deref(), Some("00FF00"), "{cmd}");
|
||||
assert!(n.bound.is_empty(), "{cmd}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_bound_fills_adds_no_paint_back() {
|
||||
let (mut s, r) = setup();
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"}));
|
||||
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": []}}));
|
||||
assert!(s.doc().unwrap().node(r).unwrap().fills.is_empty());
|
||||
assert!(bound(&s, r).is_empty());
|
||||
ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#123456"}));
|
||||
assert!(s.doc().unwrap().node(r).unwrap().fills.is_empty(), "the variable doesn't bring the fill back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paint_opacity_edits_keep_the_colour_binding() {
|
||||
let (mut s, r) = setup();
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"}));
|
||||
ex(&mut s, "fill.set", json!({"ids": [r], "opacity": 0.5}));
|
||||
ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#0000FF"}));
|
||||
assert_eq!(fill_hex(&s, r), ["0000FF"], "the colour still follows the variable");
|
||||
assert_eq!(s.doc().unwrap().node(r).unwrap().fills[0].opacity, 0.5);
|
||||
// The same colour with another opacity, through node.set, keeps it too.
|
||||
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": [{"type": "solid", "color": "#0000FF", "opacity": 0.25}]}}));
|
||||
assert_eq!(bound(&s, r).len(), 1);
|
||||
assert_eq!(s.doc().unwrap().node(r).unwrap().fills[0].opacity, 0.25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paints_set_with_their_binding_in_the_same_call_keep_it() {
|
||||
let (mut s, r) = setup();
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"}));
|
||||
let brand = brand_id(&s);
|
||||
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": ["#00FF00"], "boundVariables": {"fill": brand}}}));
|
||||
assert_eq!(bound(&s, r), [("fill".to_string(), brand)]);
|
||||
assert_eq!(fill_hex(&s, r), ["FF0000"], "the binding given with the paints applies");
|
||||
}
|
||||
@@ -124,7 +124,7 @@ pub(crate) fn apply(s: &mut Session, cmd: &str, p: &Value, op: BoolOp) -> Result
|
||||
}
|
||||
return Ok(json!({"ids": ids, "operation": op}));
|
||||
}
|
||||
let out = crate::cmd::structure::wrap_boolean(s, p, op)?;
|
||||
let out = crate::cmd::structure::wrap_boolean(s, p, cmd, op)?;
|
||||
// Operands of a new group follow its operation.
|
||||
if let Some(g) = crate::params::id_of(&out["id"]) {
|
||||
let doc = s.doc_mut()?;
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
//! A colour variable's alpha applies once to the paint it is bound to (#802): a `#RRGGBBAA` fill keeps
|
||||
//! its alpha in the paint opacity, and binding a variable with alpha hands the variable the whole
|
||||
//! colour, so the two alphas don't multiply (canvas, SVG, `.fig` round trips, mode switches).
|
||||
|
||||
use figcraft_doc::NodeId;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// 0x80 / 255 and 0x40 / 255.
|
||||
const HALF: f32 = 128.0 / 255.0;
|
||||
const QUARTER: f32 = 64.0 / 255.0;
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: serde_json::Value) {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"));
|
||||
}
|
||||
|
||||
/// A rectangle filled `#0D6EFD80` and a colour variable `tint` (Light `light`, Dark `dark`).
|
||||
fn setup(light: &str, dark: &str) -> (Session, NodeId) {
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10, "props": {"fill": "#0D6EFD80"}})).unwrap();
|
||||
let r = NodeId(r["id"].as_u64().unwrap());
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "Tokens", "modes": ["Light", "Dark"]}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "Tokens", "name": "tint", "type": "color", "value": light}));
|
||||
ex(&mut s, "variable.set", json!({"variable": "tint", "mode": "Dark", "value": dark}));
|
||||
(s, r)
|
||||
}
|
||||
|
||||
/// The fill's effective alpha (colour alpha times paint opacity).
|
||||
fn alpha(s: &Session, r: NodeId) -> f32 {
|
||||
s.doc().unwrap().node(r).unwrap().fills[0].solid_color().unwrap().a
|
||||
}
|
||||
|
||||
/// The alpha byte of the rectangle rendered on its own (transparent backdrop).
|
||||
fn pixel_alpha(s: &Session, r: NodeId) -> u8 {
|
||||
let pix = figcraft_render::render_node(s.doc().unwrap(), r, 1.0).unwrap();
|
||||
let i = ((5 * pix.width() + 5) * 4 + 3) as usize;
|
||||
pix.data()[i]
|
||||
}
|
||||
|
||||
fn near(a: f32, b: f32) -> bool {
|
||||
(a - b).abs() < 2e-3
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hex_with_alpha_keeps_its_alpha_in_the_opacity() {
|
||||
let (s, r) = setup("#0D6EFD80", "#0D6EFD40");
|
||||
let p = &s.doc().unwrap().node(r).unwrap().fills[0];
|
||||
assert_eq!(p.kind, figcraft_color::PaintKind::Solid { color: figcraft_color::Color::from_rgb8_const(0x0D, 0x6E, 0xFD) });
|
||||
assert!(near(p.opacity, HALF));
|
||||
assert_eq!(pixel_alpha(&s, r), 128);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bound_variable_alpha_applies_once() {
|
||||
// The fill and the variable are both #0D6EFD80: the result is 50%, not 25%.
|
||||
let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40");
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
|
||||
assert!(near(alpha(&s, r), HALF), "{}", alpha(&s, r));
|
||||
assert_eq!(pixel_alpha(&s, r), 128);
|
||||
let svg = figcraft_svg::export_svg(s.doc().unwrap(), &[r], &figcraft_svg::SvgOptions::default()).unwrap();
|
||||
assert!(svg.contains("fill=\"#0D6EFD\" fill-opacity=\"0.502\""), "{svg}");
|
||||
// Binding the same variable again changes nothing.
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
|
||||
assert!(near(alpha(&s, r), HALF));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbinding_keeps_the_colour_with_its_alpha_in_the_opacity() {
|
||||
let (mut s, r) = setup("#0D6EFD40", "#0D6EFD");
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
|
||||
assert_eq!(pixel_alpha(&s, r), 64);
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": null}));
|
||||
let p = &s.doc().unwrap().node(r).unwrap().fills[0];
|
||||
assert_eq!(p.kind, figcraft_color::PaintKind::Solid { color: figcraft_color::Color::from_rgb8_const(0x0D, 0x6E, 0xFD) });
|
||||
assert!(near(p.opacity, QUARTER), "{}", p.opacity);
|
||||
assert_eq!(pixel_alpha(&s, r), 64);
|
||||
// Undo restores the binding and its paint.
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert!(!s.doc().unwrap().node(r).unwrap().bound.is_empty());
|
||||
assert_eq!(pixel_alpha(&s, r), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modes_switch_the_alpha_without_accumulating() {
|
||||
let (mut s, r) = setup("#0D6EFD40", "#0D6EFD80");
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
|
||||
for (mode, want) in [("Dark", HALF), ("Light", QUARTER), ("Dark", HALF), ("Light", QUARTER)] {
|
||||
ex(&mut s, "variable.setMode", json!({"collection": "Tokens", "mode": mode}));
|
||||
assert!(near(alpha(&s, r), want), "{mode}: {}", alpha(&s, r));
|
||||
}
|
||||
// An opacity set while bound multiplies on top and survives mode switches.
|
||||
ex(&mut s, "fill.set", json!({"ids": [r.0], "index": 0, "opacity": 0.5}));
|
||||
ex(&mut s, "variable.setMode", json!({"collection": "Tokens", "mode": "Dark"}));
|
||||
assert!(near(alpha(&s, r), HALF * 0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_set_and_style_bindings_apply_the_alpha_once() {
|
||||
let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40");
|
||||
let var = s.doc().unwrap().variables.get("tint").unwrap().id.clone();
|
||||
ex(&mut s, "node.set", json!({"id": r.0, "props": {"boundVariables": {"fill": var}}}));
|
||||
assert!(near(alpha(&s, r), HALF));
|
||||
ex(&mut s, "node.set", json!({"id": r.0, "props": {"boundVariables": {"fill": null}}}));
|
||||
assert!(near(s.doc().unwrap().node(r).unwrap().fills[0].opacity, HALF));
|
||||
// A paint style made from a `#RRGGBBAA` colour, bound to the variable.
|
||||
let st = s.execute("style.create", &json!({"type": "paint", "name": "Tint", "value": "#0D6EFD80"})).unwrap()["id"].clone();
|
||||
ex(&mut s, "style.bind", json!({"style": st, "field": "fills/0/color", "variable": "tint"}));
|
||||
ex(&mut s, "style.apply", json!({"ids": [r.0], "style": st}));
|
||||
assert!(near(alpha(&s, r), HALF), "{}", alpha(&s, r));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fig_round_trip_keeps_the_alpha_once() {
|
||||
let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40");
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
|
||||
ex(&mut s, "fill.set", json!({"ids": [r.0], "index": 0, "opacity": 0.5}));
|
||||
let (bytes, _) = figcraft_format::fig::export(s.doc().unwrap(), None).unwrap();
|
||||
let back = crate::cmd::file::load_fig(&bytes, "x").unwrap().unwrap().doc;
|
||||
let n = back.nodes().find(|n| !n.bound.is_empty()).unwrap();
|
||||
let a = n.fills[0].solid_color().unwrap().a;
|
||||
assert!(near(a, HALF * 0.5), "{a}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_gives_a_translucent_stroke_its_alpha_once() {
|
||||
let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40");
|
||||
ex(&mut s, "stroke.add", json!({"ids": [r.0], "color": "#FF000080"}));
|
||||
let code = s.execute("inspect.code", &json!({"id": r.0})).unwrap()["code"].as_str().unwrap_or_default().to_string();
|
||||
assert!(code.contains("solid rgba(255, 0, 0, 0.50)"), "{code}");
|
||||
}
|
||||
@@ -213,6 +213,8 @@ pub static MENU: &[MenuItem] = &[
|
||||
m!("Text/Case", "Uppercase", "text.caseUpper"),
|
||||
m!("Text/Case", "Lowercase", "text.caseLower"),
|
||||
m!("Text/Case", "Title case", "text.caseTitle"),
|
||||
m!("Text", SEP, ""),
|
||||
m!("Text", "Missing fonts…", "text.missingFonts"),
|
||||
// Arrange
|
||||
m!("Arrange", "Align left", "align.left"),
|
||||
m!("Arrange", "Align horizontal centers", "align.hcenter"),
|
||||
@@ -287,7 +289,7 @@ pub fn items() -> impl Iterator<Item = &'static MenuItem> {
|
||||
}
|
||||
|
||||
/// Items of FigCraft's own that the parity count leaves out (as it does the `collab.` ones).
|
||||
const OWN: [&str; 2] = ["file.newFromTemplate", "help.gettingStarted"];
|
||||
const OWN: [&str; 3] = ["file.newFromTemplate", "help.gettingStarted", "text.missingFonts"];
|
||||
|
||||
/// Coverage: (implemented, total, missing ids). `extra` lists ids implemented by the UI layer.
|
||||
pub fn parity(extra: &[&str]) -> (usize, usize, Vec<&'static str>) {
|
||||
|
||||
@@ -19,7 +19,21 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("align.bottom", "Align bottom", "{ids?}", true, |s, p| align(s, p, Al::Bottom)),
|
||||
spec!("distribute.horizontal", "Distribute horizontal spacing", "{ids?, spacing?}", true, |s, p| distribute(s, p, true)),
|
||||
spec!("distribute.vertical", "Distribute vertical spacing", "{ids?, spacing?}", true, |s, p| distribute(s, p, false)),
|
||||
spec!(
|
||||
"arrange.spacing",
|
||||
"Spacing",
|
||||
"{ids?, horizontal?, vertical?, fromGesture?} (page units; fromGesture uses the interaction snapshot)",
|
||||
true,
|
||||
spacing
|
||||
),
|
||||
spec!("arrange.tidy", "Tidy up", "{ids?, spacing?}", true, tidy),
|
||||
spec!(
|
||||
"arrange.reorder",
|
||||
"Reorder selection",
|
||||
"{ids?, source, target, swap?, fromGesture?} (insert at target's visual slot; no hierarchy changes)",
|
||||
true,
|
||||
reorder
|
||||
),
|
||||
spec!("arrange.packH", "Pack horizontal", "{ids?}", true, |s, p| pack(s, p, true)),
|
||||
spec!("arrange.packV", "Pack vertical", "{ids?}", true, |s, p| pack(s, p, false)),
|
||||
spec!("arrange.roundToPixels", "Round to pixels", "{ids?}", true, round_px),
|
||||
@@ -521,6 +535,7 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (col, row) = string(p, "anchor").and_then(|a| crate::tools::anchor_of(&a)).unwrap_or((0, 0));
|
||||
let (ax, ay) = (f64::from(col) / 2.0, f64::from(row) / 2.0);
|
||||
let doc = s.doc_mut()?;
|
||||
let before = super::styles::bound_before(doc, &ids);
|
||||
for i in ids {
|
||||
let n = doc.get(i)?;
|
||||
let (ow, oh) = (n.width, n.height);
|
||||
@@ -544,5 +559,98 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
super::props::restore_worlds(doc, &kept)?;
|
||||
}
|
||||
super::styles::detach_edited(doc, &before);
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Reorder visual slots without rebuilding or reparenting layers.
|
||||
fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "arrange.reorder";
|
||||
let ids = req_targets(cmd, s, p)?;
|
||||
let source = req_id(cmd, p, "source")?;
|
||||
let target = req_id(cmd, p, "target")?;
|
||||
let ds = s.ds()?;
|
||||
let base = ds.interaction.as_ref().filter(|_| boolean(p, "fromGesture") == Some(true)).map(|i| i.doc.clone()).unwrap_or_else(|| ds.doc.clone());
|
||||
let layout =
|
||||
crate::selection_reorder::ReorderLayout::new(&base, &ids).ok_or_else(|| bad(cmd, "select a regular row, column or complete aligned grid"))?;
|
||||
let positions = layout
|
||||
.positions(source, target, boolean(p, "swap") == Some(true))
|
||||
.ok_or_else(|| bad(cmd, "source and target must belong to the selection"))?;
|
||||
// Validate every delta before editing; absolute targets always come from the gesture's
|
||||
// snapshot so crossing several slots and returning to the start does not accumulate edits.
|
||||
let doc = s.doc()?;
|
||||
let mut moves = Vec::new();
|
||||
for (id, at) in positions {
|
||||
let now = doc.world_bounds(id).ok_or_else(|| bad(cmd, "layer is no longer available"))?;
|
||||
let (dx, dy) = (at.x - now.x0, at.y - now.y0);
|
||||
if !dx.is_finite() || !dy.is_finite() {
|
||||
return Err(bad(cmd, "non-finite reorder result"));
|
||||
}
|
||||
if dx.abs() > 1e-9 || dy.abs() > 1e-9 {
|
||||
let node = doc.get(id)?;
|
||||
let parent = node.parent.map(|p| doc.world_transform(p)).unwrap_or(Affine::IDENTITY);
|
||||
let transform = parent.inverse() * Affine::translate((dx, dy)) * parent * node.transform;
|
||||
if !transform.as_coeffs().iter().all(|v| v.is_finite()) {
|
||||
return Err(bad(cmd, "non-finite local transform"));
|
||||
}
|
||||
moves.push((id, dx, dy));
|
||||
}
|
||||
}
|
||||
if !moves.is_empty() {
|
||||
let doc = s.doc_mut()?;
|
||||
for (id, dx, dy) in moves {
|
||||
move_world(doc, id, dx, dy)?;
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Set the distances between smart-selection tracks without rebuilding/reparenting the layers.
|
||||
fn spacing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("arrange.spacing", s, p)?;
|
||||
// A scrub is absolute from the interaction snapshot, even if tracks overlap or cross.
|
||||
let ds = s.ds()?;
|
||||
let base = ds.interaction.as_ref().filter(|_| boolean(p, "fromGesture") == Some(true)).map(|i| i.doc.clone()).unwrap_or_else(|| ds.doc.clone());
|
||||
let layout = crate::selection_spacing::selection_spacing(&base, &ids);
|
||||
let mut offsets = std::collections::BTreeMap::<NodeId, (Option<f64>, Option<f64>)>::new();
|
||||
let mut any = false;
|
||||
for (key, axis, vertical) in [("horizontal", &layout.horizontal, false), ("vertical", &layout.vertical, true)] {
|
||||
if get(p, key).is_none() {
|
||||
continue;
|
||||
}
|
||||
let value = req_num("arrange.spacing", p, key)?;
|
||||
if value.abs() > 1_000_000.0 {
|
||||
return Err(bad("arrange.spacing", "spacing out of range"));
|
||||
}
|
||||
let axis = axis.as_ref().ok_or_else(|| bad("arrange.spacing", "selection has no spacing on this axis"))?;
|
||||
any = true;
|
||||
for (id, delta) in axis.offsets(value) {
|
||||
if !delta.is_finite() {
|
||||
return Err(bad("arrange.spacing", "non-finite spacing result"));
|
||||
}
|
||||
let pair = offsets.entry(id).or_default();
|
||||
if vertical {
|
||||
pair.1 = Some(delta);
|
||||
} else {
|
||||
pair.0 = Some(delta);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !any {
|
||||
return Err(bad("arrange.spacing", "pass horizontal or vertical spacing"));
|
||||
}
|
||||
let doc = s.doc_mut()?;
|
||||
let mut moves = Vec::new();
|
||||
for (id, (dx, dy)) in offsets {
|
||||
let (Some(before), Some(now)) = (base.world_bounds(id), doc.world_bounds(id)) else { continue };
|
||||
let (dx, dy) = (dx.map_or(0.0, |d| before.x0 + d - now.x0), dy.map_or(0.0, |d| before.y0 + d - now.y0));
|
||||
if !dx.is_finite() || !dy.is_finite() {
|
||||
return Err(bad("arrange.spacing", "non-finite spacing result"));
|
||||
}
|
||||
moves.push((id, dx, dy));
|
||||
}
|
||||
for (id, dx, dy) in moves {
|
||||
move_world(doc, id, dx, dy)?;
|
||||
}
|
||||
ok()
|
||||
}
|
||||
@@ -472,6 +472,7 @@ fn detach(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Own layers lose their link (slot content stays as it is); nested instances stay
|
||||
// instances of their own mains.
|
||||
let nested = unlink_layers(doc, i, Some(i));
|
||||
keep_main_modes(doc, i)?;
|
||||
let n = doc.get_mut(i)?;
|
||||
n.kind = NodeKind::Frame;
|
||||
n.source = None;
|
||||
@@ -491,6 +492,19 @@ fn detach(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"ids": out}))
|
||||
}
|
||||
|
||||
/// Make the variable modes instance `i` takes from its main its own (it's being detached).
|
||||
fn keep_main_modes(doc: &mut Document, i: NodeId) -> Result<()> {
|
||||
let modes = match doc.get(i)?.kind {
|
||||
NodeKind::Instance { component, .. } => doc.node(component).map(|m| m.var_modes.clone()).unwrap_or_default(),
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let own = &mut doc.get_mut(i)?.var_modes;
|
||||
for (c, m) in modes {
|
||||
own.entry(c).or_insert(m);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reset_instance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.resetInstance";
|
||||
let ids = req_targets(cmd, s, p)?;
|
||||
@@ -608,7 +622,7 @@ fn make_slot(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let owner = Owner::resolve(doc, id).ok_or_else(|| bad(cmd, "the frame isn't inside a component"))?;
|
||||
let name = string(p, "name").unwrap_or_else(|| fresh_prop_name(doc, &owner, &n.name));
|
||||
let preferred = get(p, "preferred").cloned().unwrap_or(Value::Null);
|
||||
add_prop(s, &json!({"id": id, "layer": id, "name": name, "type": "slot", "preferred": preferred}))
|
||||
add_prop_for(cmd, s, &json!({"id": id, "layer": id, "name": name, "type": "slot", "preferred": preferred}))
|
||||
}
|
||||
|
||||
fn remove_slot(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -678,8 +692,8 @@ fn push_changes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !doc.contains(component) {
|
||||
return Err(EngineError::Disabled(cmd.into(), "the main component was deleted (use component.restoreMain)".into()));
|
||||
}
|
||||
// Copy every overridden layer's values to its source in the main (geometry, swaps, property
|
||||
// values and slot content stay with the instance).
|
||||
// Copy every overridden layer's values to its source in the main (geometry, resizing, swaps,
|
||||
// property values and slot content stay with the instance).
|
||||
let mut pushed = 0;
|
||||
for d in doc.subtree(inst) {
|
||||
let Some(n) = doc.node(d).cloned() else { continue };
|
||||
@@ -691,7 +705,7 @@ fn push_changes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let groups: BTreeSet<String> = n
|
||||
.overrides
|
||||
.iter()
|
||||
.filter(|g| !matches!(g.as_str(), "size" | "position" | "component" | "props" | SLOT) && !(d == inst && g.as_str() == "name"))
|
||||
.filter(|g| !(matches!(g.as_str(), "size" | "resizing" | "position" | "component" | "props" | SLOT) || d == inst && g.as_str() == "name"))
|
||||
.cloned()
|
||||
.collect();
|
||||
for g in &groups {
|
||||
@@ -1039,7 +1053,12 @@ fn valid_prop_name(cmd: &str, name: &str) -> Result<String> {
|
||||
}
|
||||
|
||||
fn add_prop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.addProperty";
|
||||
add_prop_for("component.addProperty", s, p)
|
||||
}
|
||||
|
||||
/// Add a component property for command `cmd`, which errors name (`component.makeSlot` adds its
|
||||
/// slot property here).
|
||||
fn add_prop_for(cmd: &str, s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component"))?;
|
||||
let name = valid_prop_name(cmd, &req_string(cmd, p, "name")?)?;
|
||||
let t = req_string(cmd, p, "type")?;
|
||||
|
||||
@@ -7,7 +7,7 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
use crate::{Result, Session, bad, spec};
|
||||
|
||||
/// Frame presets shown by the Frame tool (category, name, width, height).
|
||||
pub const FRAME_PRESETS: &[(&str, &str, f64, f64)] = &[
|
||||
@@ -73,9 +73,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("text.create", "Text", "{text, x, y, parent?, width? (fixed width wraps), props? (fontSize, fontFamily, …)}", true, |s, p| {
|
||||
create_kind(s, p, "text")
|
||||
}),
|
||||
spec!("vector.create", "Vector", "{path (SVG path data) | points [[x,y],..], closed?, x?, y?, parent?, props?}", true, |s, p| create_kind(
|
||||
s, p, "vector"
|
||||
)),
|
||||
spec!(
|
||||
"vector.create",
|
||||
"Vector",
|
||||
"{path (SVG path data) | points [[x,y],..], closed?, x?, y?, width?, height? (scale the path; one keeps its proportion), parent?, props?}",
|
||||
true,
|
||||
|s, p| create_kind(s, p, "vector")
|
||||
),
|
||||
spec!(
|
||||
"image.place",
|
||||
"Place image",
|
||||
@@ -162,6 +166,37 @@ pub fn parse_path_data(d: &str) -> Option<figcraft_geom::BezPath> {
|
||||
figcraft_geom::BezPath::from_svg(d).ok()
|
||||
}
|
||||
|
||||
/// How much a vector's path is scaled to take the size asked for: with both sides, each scales
|
||||
/// independently (as resizing a vector does); with one, the other keeps its proportion. A size on
|
||||
/// an axis the geometry has none of is an error unless it is 0 (a line's height), and a size of 0
|
||||
/// or less on an axis it has is refused rather than flattening it.
|
||||
fn vector_scale(width: Option<f64>, height: Option<f64>, bw: f64, bh: f64, what: &str) -> std::result::Result<(f64, f64), String> {
|
||||
let fit = |asked: Option<f64>, have: f64, axis: &str| match asked {
|
||||
None => Ok(1.0),
|
||||
Some(a) if have <= 1e-9 => {
|
||||
if a == 0.0 {
|
||||
Ok(1.0)
|
||||
} else {
|
||||
Err(format!("`{axis}` can't size {what} with no {} extent", if axis == "width" { "horizontal" } else { "vertical" }))
|
||||
}
|
||||
}
|
||||
Some(a) if a > 0.0 && (a / have).is_finite() => Ok(a / have),
|
||||
Some(_) => Err(format!("`{axis}` must be a positive, finite size: 0 or less would flatten {what}")),
|
||||
};
|
||||
Ok(match (width, height) {
|
||||
(Some(_), Some(_)) => (fit(width, bw, "width")?, fit(height, bh, "height")?),
|
||||
(Some(_), None) => {
|
||||
let s = fit(width, bw, "width")?;
|
||||
(s, s)
|
||||
}
|
||||
(None, Some(_)) => {
|
||||
let s = fit(height, bh, "height")?;
|
||||
(s, s)
|
||||
}
|
||||
(None, None) => (1.0, 1.0),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "node.create";
|
||||
let t = string(p, "type").ok_or_else(|| bad(cmd, "`type` is required"))?;
|
||||
@@ -187,20 +222,32 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"Section" => (num_or(p, "width", 400.0), num_or(p, "height", 300.0)),
|
||||
_ => (num_or(p, "width", 100.0), num_or(p, "height", 100.0)),
|
||||
};
|
||||
// Vectors: path data or points define the shape and its box.
|
||||
// Vectors: path data or points define the shape and its box. `width`/`height`, when asked
|
||||
// for, scale the geometry: both sides independently, one side proportionally.
|
||||
let mut offset = (0.0, 0.0);
|
||||
let asked = (num(p, "width"), num(p, "height"));
|
||||
if let NodeKind::Vector { network } = &mut kind {
|
||||
if let Some(d) = string(p, "path") {
|
||||
let mut path = parse_path_data(&d).ok_or_else(|| bad(cmd, "`path` is not valid SVG path data"))?;
|
||||
use figcraft_geom::Shape;
|
||||
let bb = path.bounding_box();
|
||||
if bb.width() <= 1e-9 && bb.height() <= 1e-9 {
|
||||
return Err(bad(cmd, "`path` draws nothing: a vector needs a visible line or area"));
|
||||
}
|
||||
path.apply_affine(figcraft_geom::Affine::translate((-bb.x0, -bb.y0)));
|
||||
let (sx, sy) = vector_scale(asked.0, asked.1, bb.width(), bb.height(), "a path").map_err(|e| bad(cmd, e))?;
|
||||
if sx != 1.0 || sy != 1.0 {
|
||||
path.apply_affine(figcraft_geom::Affine::scale_non_uniform(sx, sy));
|
||||
}
|
||||
*network = VectorNetwork::from_path(&path);
|
||||
if network.segments.is_empty() {
|
||||
return Err(bad(cmd, "`path` draws nothing: a vector needs a visible line or area"));
|
||||
}
|
||||
if boolean(p, "closed") == Some(false) {
|
||||
network.regions.clear();
|
||||
}
|
||||
w = bb.width();
|
||||
h = bb.height();
|
||||
w = bb.width() * sx;
|
||||
h = bb.height() * sy;
|
||||
offset = (bb.x0, bb.y0);
|
||||
} else if let Some(Value::Array(pts)) = get(p, "points") {
|
||||
let pts: Vec<Point> = pts.iter().filter_map(|v| Some(Point::new(f64_of(v.get(0)?)?, f64_of(v.get(1)?)?))).collect();
|
||||
@@ -208,10 +255,14 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, "`points` needs at least two [x, y] pairs"));
|
||||
}
|
||||
let bb = figcraft_geom::points_bounds(pts.iter().copied()).unwrap_or_default();
|
||||
let local: Vec<Point> = pts.iter().map(|q| Point::new(q.x - bb.x0, q.y - bb.y0)).collect();
|
||||
if bb.width() <= 1e-9 && bb.height() <= 1e-9 {
|
||||
return Err(bad(cmd, "`points` draws nothing: a vector needs a visible line or area"));
|
||||
}
|
||||
let (sx, sy) = vector_scale(asked.0, asked.1, bb.width(), bb.height(), "a point set").map_err(|e| bad(cmd, e))?;
|
||||
let local: Vec<Point> = pts.iter().map(|q| Point::new((q.x - bb.x0) * sx, (q.y - bb.y0) * sy)).collect();
|
||||
*network = VectorNetwork::from_points(&local, boolean(p, "closed").unwrap_or(false));
|
||||
w = bb.width();
|
||||
h = bb.height();
|
||||
w = bb.width() * sx;
|
||||
h = bb.height() * sy;
|
||||
offset = (bb.x0, bb.y0);
|
||||
}
|
||||
}
|
||||
@@ -313,12 +364,12 @@ fn free_spot(s: &Session, w: f64, h: f64) -> Result<(f64, f64)> {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_path(path: &str) -> Result<Vec<u8>> {
|
||||
std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
crate::file_access::read(path)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn read_path(path: &str) -> Result<Vec<u8>> {
|
||||
Err(EngineError::Other(format!("{path}: files can't be read in the browser")))
|
||||
Err(crate::EngineError::Other(format!("{path}: files can't be read in the browser")))
|
||||
}
|
||||
|
||||
/// Place an image as a rectangle with an image fill (Figma's image layer).
|
||||
|
||||
+111
-16
@@ -18,13 +18,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"edit.duplicate",
|
||||
"Duplicate",
|
||||
"{ids?, dx?, dy? (page offset; default: the offset of the last duplicate when ids are its copies, still selected; else top-level frames to the right with a 100 gap, everything else in place)}",
|
||||
"{ids?, dx?, dy? (page offset; default: the offset of the last duplicate when ids are its copies, still selected; else top-level frames to the right with a 100 gap, everything else in place)} a main component's copy is an instance of it (a variant stays a new variant in its set)",
|
||||
true,
|
||||
duplicate
|
||||
),
|
||||
spec!("edit.copy", "Copy", "{ids?}", false, copy),
|
||||
spec!("edit.cut", "Cut", "{ids?}", true, cut),
|
||||
spec!("edit.paste", "Paste", "{parent?, x?, y? (page coordinates of the pasted top-left)}", true, paste),
|
||||
spec!(
|
||||
"edit.paste",
|
||||
"Paste",
|
||||
"{parent?, x?, y? (page coordinates of the pasted top-left)} a copied main component pastes as an instance of it within its file (cut ones, and pastes into other files, keep the component)",
|
||||
true,
|
||||
paste
|
||||
),
|
||||
spec!(
|
||||
"edit.pasteDesignLayers",
|
||||
"Paste design layers",
|
||||
@@ -261,7 +267,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
spec!("effect.set", "Set effect", "{ids?, index, effect {..}}", true, |s, p| {
|
||||
let i = req_index("effect.set", p, "index")?;
|
||||
let e: Effect = parse("effect.set", p, "effect")?.ok_or_else(|| bad("effect.set", "`effect` required"))?;
|
||||
let e = super::props::effect_from("effect.set", p, "effect")?.ok_or_else(|| bad("effect.set", "`effect` required"))?;
|
||||
for id in req_targets("effect.set", s, p)? {
|
||||
let n = s.doc_mut()?.get_mut(id)?;
|
||||
if let Some(slot) = n.effects.get_mut(i) {
|
||||
@@ -466,7 +472,7 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let top_frame = doc.node(parent).is_some_and(Node::is_page) && n.kind.is_frame_like();
|
||||
let idx = doc.index_in_parent(i).map(|x| x + 1);
|
||||
let w = n.width;
|
||||
let nid = doc.duplicate_into(i, parent, idx)?;
|
||||
let nid = crate::components::copy_in_file(doc, i, parent, idx)?;
|
||||
let (dx, dy) = match (num(p, "dx"), num(p, "dy")) {
|
||||
(Some(dx), dy) => (dx, dy.unwrap_or(0.0)),
|
||||
(None, Some(dy)) => (0.0, dy),
|
||||
@@ -481,7 +487,8 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
nn.transform = Affine::translate((dx, dy)) * nn.transform;
|
||||
}
|
||||
}
|
||||
if rename {
|
||||
// An instance keeps the name instances get by default.
|
||||
if rename && !matches!(doc.get(nid)?.kind, NodeKind::Instance { .. }) {
|
||||
let nn = doc.get_mut(nid)?;
|
||||
nn.name = bump_name(&nn.name);
|
||||
}
|
||||
@@ -515,6 +522,13 @@ pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<(
|
||||
s.clipboard.source = Some(source);
|
||||
s.clipboard.source_path = source_path;
|
||||
s.clipboard.source_doc = uid;
|
||||
s.clipboard.copied_components = nodes
|
||||
.iter()
|
||||
.filter(|(_, sub)| {
|
||||
cmd == "edit.copy" && sub.first().is_some_and(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. }))
|
||||
})
|
||||
.map(|(root, _)| *root)
|
||||
.collect();
|
||||
s.clipboard.nodes = nodes;
|
||||
s.clipboard.images = images;
|
||||
s.clipboard.bounds = bounds;
|
||||
@@ -593,7 +607,8 @@ fn paste_design_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (nodes, images, bounds) = copied(&doc, &tops);
|
||||
let props = s.clipboard.props.take();
|
||||
// No open file has id 0. So paste treats this copy as a copy from a different file.
|
||||
s.clipboard = crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None };
|
||||
s.clipboard =
|
||||
crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None, copied_components: BTreeSet::new() };
|
||||
let mut out = paste(s, p)?;
|
||||
if !other.issues.is_empty() {
|
||||
out["warnings"] = json!(other.issues);
|
||||
@@ -623,6 +638,7 @@ pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Re
|
||||
}
|
||||
// From another file: its instances follow the mains this file can have.
|
||||
let foreign = clip.source.as_ref().filter(|_| s.ds().is_ok_and(|d| d.uid != clip.source_doc));
|
||||
let copy_local = s.ds()?.uid == clip.source_doc;
|
||||
let doc = s.doc_mut()?;
|
||||
for (k, v) in &clip.images {
|
||||
doc.images.entry(k.clone()).or_insert_with(|| v.clone());
|
||||
@@ -636,6 +652,16 @@ pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Re
|
||||
let mut out = Vec::new();
|
||||
let mut all = BTreeMap::new();
|
||||
for (root, nodes) in &clip.nodes {
|
||||
if copy_local
|
||||
&& clip.copied_components.contains(root)
|
||||
&& let Some(n) = nodes.first().filter(|n| matches!(n.kind, NodeKind::Component { .. }))
|
||||
&& doc.node(*root).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))
|
||||
{
|
||||
let instance = crate::components::create_instance(doc, *root, parent, 0.0, 0.0)?;
|
||||
doc.get_mut(instance)?.transform = doc.world_transform(parent).inverse() * shift * n.transform;
|
||||
out.push(instance);
|
||||
continue;
|
||||
}
|
||||
let mut map = BTreeMap::new();
|
||||
for n in nodes {
|
||||
map.insert(n.id, doc.alloc_id());
|
||||
@@ -661,10 +687,12 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// else in the frame's centre; otherwise in place on the page (or the viewport centre).
|
||||
let sel = s.selection();
|
||||
let doc = s.doc()?;
|
||||
let copy_local = s.ds()?.uid == s.clipboard.source_doc;
|
||||
let target = id(p, "parent").or_else(|| {
|
||||
sel.first()
|
||||
.copied()
|
||||
.filter(|i| sel.len() == 1 && doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && !matches!(n.kind, NodeKind::Instance { .. })))
|
||||
.filter(|i| !copy_local || !s.clipboard.copied_components.iter().any(|root| doc.is_ancestor(*root, *i)))
|
||||
});
|
||||
let parent = target.unwrap_or(page);
|
||||
let at = match (num(p, "x"), num(p, "y")) {
|
||||
@@ -931,9 +959,13 @@ pub fn opacity_from_digits(digits: &str) -> Option<f32> {
|
||||
fn opacity_keys(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let digits = req_string("object.opacityKeys", p, "digits")?;
|
||||
let o = opacity_from_digits(&digits).ok_or_else(|| bad("object.opacityKeys", "`digits` must be one or two digits"))?;
|
||||
for i in req_targets("object.opacityKeys", s, p)? {
|
||||
s.doc_mut()?.get_mut(i)?.opacity = o;
|
||||
let ids = req_targets("object.opacityKeys", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let before = super::styles::bound_before(doc, &ids);
|
||||
for i in ids {
|
||||
doc.get_mut(i)?.opacity = o;
|
||||
}
|
||||
super::styles::detach_edited(doc, &before);
|
||||
Ok(json!({"opacity": o}))
|
||||
}
|
||||
|
||||
@@ -1156,10 +1188,37 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.collect(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
let doc = s.doc_mut()?;
|
||||
for i in ids {
|
||||
super::props::apply("node.set", doc, i, &props)?;
|
||||
let mut warnings = font_style_warnings(&s.ds()?.doc, &ids, &props);
|
||||
// Auto layout keys go to the layers that can have auto layout; with none among the targets,
|
||||
// the first refuses them (`props::layout_mut`).
|
||||
let layout_keys: Vec<&str> = props.keys().map(String::as_str).filter(|k| super::props::LAYOUT_KEYS.contains(k)).collect();
|
||||
let mut without_layout = props.clone();
|
||||
let mut skipped = BTreeSet::new();
|
||||
if !layout_keys.is_empty() {
|
||||
let doc = &s.ds()?.doc;
|
||||
let frame_like = |i: &NodeId| doc.node(*i).is_some_and(|n| n.kind.is_frame_like());
|
||||
if ids.iter().any(frame_like) {
|
||||
skipped = ids.iter().copied().filter(|i| !frame_like(i)).collect();
|
||||
without_layout.retain(|k, _| !layout_keys.contains(&k.as_str()));
|
||||
}
|
||||
if !skipped.is_empty() {
|
||||
let names: Vec<String> =
|
||||
skipped.iter().take(10).filter_map(|i| doc.node(*i)).map(|n| format!("{} “{}” ({})", n.id, n.name, n.kind.type_name())).collect();
|
||||
let more = if skipped.len() > names.len() { format!(" and {} more", skipped.len() - names.len()) } else { String::new() };
|
||||
warnings.push(format!(
|
||||
"{} skipped on {}{more}: only frames, components and instances have auto layout",
|
||||
layout_keys.iter().map(|k| format!("`{k}`")).collect::<Vec<_>>().join(", "),
|
||||
names.join(", ")
|
||||
));
|
||||
}
|
||||
}
|
||||
let doc = s.doc_mut()?;
|
||||
let before = super::styles::bound_before(doc, &ids);
|
||||
for i in ids {
|
||||
let props = if skipped.contains(&i) { &without_layout } else { &props };
|
||||
super::props::apply("node.set", doc, i, props)?;
|
||||
}
|
||||
super::styles::detach_edited(doc, &before);
|
||||
if let Some(o) = pivot {
|
||||
for (i, q) in pinned {
|
||||
let d = o - doc.world_transform(i) * q;
|
||||
@@ -1168,7 +1227,43 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
ok()
|
||||
if warnings.is_empty() { ok() } else { Ok(json!({"ok": true, "warnings": warnings})) }
|
||||
}
|
||||
|
||||
/// A `fontStyle` draws in the family's face nearest to it, as a text layer resolves it. When that
|
||||
/// face has another weight or slant than the name asks for ("Black" on a family whose heaviest is
|
||||
/// Bold), say which face it is. Families that aren't installed, and names that match a style or a
|
||||
/// named instance (spelled any way: "SemiBold", "Semi Bold"), say nothing.
|
||||
fn font_style_warnings(doc: &figcraft_doc::Document, ids: &[NodeId], props: &serde_json::Map<String, Value>) -> Vec<String> {
|
||||
let Some(style) = props.get("fontStyle").and_then(Value::as_str) else { return Vec::new() };
|
||||
let mut families: Vec<String> = match props.get("fontFamily").and_then(Value::as_str) {
|
||||
Some(f) => vec![f.to_string()],
|
||||
None => ids
|
||||
.iter()
|
||||
.filter_map(|i| doc.node(*i))
|
||||
.filter_map(|n| n.kind.text())
|
||||
.flat_map(|t| t.spans().into_iter().map(|(_, _, st)| st.font_family.clone()).collect::<Vec<_>>())
|
||||
.collect(),
|
||||
};
|
||||
families.sort();
|
||||
families.dedup();
|
||||
let norm = |s: &str| s.to_lowercase().replace([' ', '-', '_'], "");
|
||||
let (want_w, want_i) = (i32::from(figcraft_doc::style_weight(style)), style.to_lowercase().contains("italic"));
|
||||
families
|
||||
.into_iter()
|
||||
.filter_map(|family| {
|
||||
let styles = figcraft_text::family_styles(&family);
|
||||
if styles.is_empty() || styles.iter().any(|x| norm(x) == norm(style)) {
|
||||
return None;
|
||||
}
|
||||
let face = figcraft_text::find_face(&family, style).filter(|f| f.family.eq_ignore_ascii_case(&family))?;
|
||||
let w = face.axis("wght").map_or(i32::from(face.weight), |a| a.clamp(f64::from(want_w)).round() as i32);
|
||||
if face.instance(style).is_some() || (w == want_w && face.italic == want_i) {
|
||||
return None;
|
||||
}
|
||||
Some(format!("{family} has no `{style}` style: it draws in {}, the nearest (it has: {})", face.style, styles.join(", ")))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `stroke.widthProfile`: a preset or points (through `node.set`'s `widthProfile`, so instances
|
||||
@@ -1281,7 +1376,7 @@ fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
|
||||
if let Some(b) = blend(p, "blend") {
|
||||
slot.blend = b;
|
||||
}
|
||||
super::lists::remap_stop_bindings(n, fills, at, &old);
|
||||
super::lists::rebind_edited_paint(n, fills, at, &old);
|
||||
if fills {
|
||||
n.styles.fill = None;
|
||||
} else {
|
||||
@@ -1295,7 +1390,7 @@ fn pattern_fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "fill.pattern";
|
||||
let src = req_id(CMD, p, "source")?;
|
||||
let doc = s.doc()?;
|
||||
if !doc.node(src).is_some_and(|n| !n.is_page()) {
|
||||
if doc.node(src).is_none_or(|n| n.is_page()) {
|
||||
return Err(bad(CMD, "`source` must be a layer"));
|
||||
}
|
||||
if req_targets(CMD, s, p)?.iter().any(|t| doc.is_ancestor(*t, src)) {
|
||||
@@ -1315,8 +1410,8 @@ fn pattern_fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn effect_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let e: Effect = match get(p, "effect") {
|
||||
Some(v) => serde_json::from_value(v.clone()).map_err(|e| bad("effect.add", e.to_string()))?,
|
||||
let e: Effect = match super::props::effect_from("effect.add", p, "effect")? {
|
||||
Some(e) => e,
|
||||
None => {
|
||||
let ty = string(p, "type").unwrap_or_else(|| "dropShadow".into());
|
||||
Effect::of_type(&ty).ok_or_else(|| bad("effect.add", format!("unknown effect type `{ty}`")))?
|
||||
|
||||
@@ -92,6 +92,20 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
spec!("file.placeImage", "Place image", "{path | data, x?, y?}", true, super::create::place_image),
|
||||
spec!("file.close", "Close tab", "{index?, force? (discard unsaved changes)}", false, close),
|
||||
spec!("file.moveTab", "Move tab", "{from, to} (indices among open documents)", false, |s, p| {
|
||||
let from = req_index("file.moveTab", p, "from")?;
|
||||
let to = req_index("file.moveTab", p, "to")?;
|
||||
if from >= s.docs.len() || to >= s.docs.len() {
|
||||
return Err(bad("file.moveTab", "no such document"));
|
||||
}
|
||||
if from != to {
|
||||
let active_uid = s.docs.get(s.active).map(|d| d.uid);
|
||||
let doc = s.docs.remove(from);
|
||||
s.docs.insert(to, doc);
|
||||
s.active = active_uid.and_then(|uid| s.docs.iter().position(|d| d.uid == uid)).unwrap_or(0);
|
||||
}
|
||||
Ok(json!({"index": to}))
|
||||
}),
|
||||
spec!(
|
||||
"file.recover",
|
||||
"Recover file",
|
||||
@@ -435,7 +449,7 @@ pub(crate) fn recovered(path: &str) -> Result<DocState> {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read(path: &str) -> Result<Vec<u8>> {
|
||||
std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
crate::file_access::read(path)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -445,11 +459,7 @@ fn read(path: &str) -> Result<Vec<u8>> {
|
||||
|
||||
/// The bytes of file `path`, refused when it's larger than `max`.
|
||||
pub(crate) fn read_capped(path: &str, max: u64) -> Result<Vec<u8>> {
|
||||
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
if !meta.is_file() || meta.len() > max {
|
||||
return Err(EngineError::Other(format!("{path}: not a file of at most {max} bytes")));
|
||||
}
|
||||
read(path)
|
||||
crate::file_access::read_capped(path, max)
|
||||
}
|
||||
|
||||
/// Write `bytes` to `path` through a uniquely named, exclusively created staging file and one
|
||||
@@ -457,7 +467,7 @@ pub(crate) fn read_capped(path: &str, max: u64) -> Result<Vec<u8>> {
|
||||
/// truncates `path`, and no file or symlink at a staging name is ever written through.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn write(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
figcraft_format::write_file_atomic(std::path::Path::new(path), bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
crate::file_access::write(path, bytes)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -552,6 +562,7 @@ pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option<Result<figcraft_forma
|
||||
/// Link imported instances to their mains (layer sources, overrides found by comparing values),
|
||||
/// innermost first so outer instances settle last.
|
||||
fn link_instances(doc: &mut Document) {
|
||||
super::styles::unfold_imported_bound_alpha(doc);
|
||||
crate::style_vars::after_load(doc);
|
||||
super::styles::apply_paint_bindings_on_load(doc);
|
||||
let mut insts: Vec<(usize, NodeId)> =
|
||||
@@ -577,11 +588,9 @@ fn rest_images(path: &str) -> impl Fn(&str) -> Option<Vec<u8>> {
|
||||
return None;
|
||||
}
|
||||
let dir = dir.as_ref()?;
|
||||
["", ".png", ".jpg", ".jpeg", ".gif", ".webp"].iter().find_map(|e| {
|
||||
let p = dir.join(format!("{r}{e}"));
|
||||
let meta = std::fs::metadata(&p).ok()?;
|
||||
if meta.is_file() && meta.len() <= MAX_IMAGE_FILE { std::fs::read(&p).ok() } else { None }
|
||||
})
|
||||
["", ".png", ".jpg", ".jpeg", ".gif", ".webp"]
|
||||
.iter()
|
||||
.find_map(|e| crate::file_access::read_capped(dir.join(format!("{r}{e}")), MAX_IMAGE_FILE).ok())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -604,11 +613,7 @@ fn sibling_variables(path: &str) -> Option<Value> {
|
||||
let file = std::path::Path::new(path);
|
||||
let dir = file.parent()?;
|
||||
[format!("{}.variables.json", file_stem(path)), "variables.json".to_string()].iter().map(|n| dir.join(n)).filter(|c| c != file).find_map(|c| {
|
||||
let meta = std::fs::metadata(&c).ok()?;
|
||||
if !meta.is_file() || meta.len() > MAX_VARIABLES_FILE {
|
||||
return None;
|
||||
}
|
||||
let v: Value = serde_json::from_slice(&std::fs::read(&c).ok()?).ok()?;
|
||||
let v: Value = serde_json::from_slice(&crate::file_access::read_capped(c, MAX_VARIABLES_FILE).ok()?).ok()?;
|
||||
figcraft_format::rest::is_variables_json(&v).then_some(v)
|
||||
})
|
||||
}
|
||||
@@ -646,20 +651,29 @@ fn remember(s: &mut Session, path: &str) {
|
||||
}
|
||||
|
||||
fn open_recent(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use crate::file_access::{Access, allow, allowed};
|
||||
const CMD: &str = "file.openRecent";
|
||||
// Confined to automation roots, the recent files outside them are never listed, opened, removed
|
||||
// or named: `index` counts the files listed.
|
||||
let listed = |r: &String| allowed(r, Access::Read);
|
||||
if p.get("clear").and_then(Value::as_bool) == Some(true) {
|
||||
s.prefs.recent_files.clear();
|
||||
s.prefs.recent_files.retain(|r| !listed(r));
|
||||
}
|
||||
if let Some(gone) = string(p, "remove") {
|
||||
s.prefs.recent_files.retain(|r| *r != gone);
|
||||
s.prefs.recent_files.retain(|r| *r != gone || !listed(r));
|
||||
}
|
||||
let path = match (string(p, "path"), num(p, "index")) {
|
||||
(Some(path), _) => Some(path),
|
||||
(None, Some(i)) => Some(s.prefs.recent_files.get(i.max(0.0) as usize).cloned().ok_or_else(|| bad(CMD, "no such recent file"))?),
|
||||
(Some(path), _) => {
|
||||
allow(&path, Access::Read)?;
|
||||
Some(path)
|
||||
}
|
||||
(None, Some(i)) => {
|
||||
Some(s.prefs.recent_files.iter().filter(|r| listed(r)).nth(i.max(0.0) as usize).cloned().ok_or_else(|| bad(CMD, "no such recent file"))?)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(path) = path {
|
||||
if !std::path::Path::new(&path).exists() {
|
||||
if !crate::file_access::exists(&path) {
|
||||
s.prefs.recent_files.retain(|r| *r != path);
|
||||
return Err(bad(CMD, format!("{path} no longer exists")));
|
||||
}
|
||||
@@ -669,9 +683,10 @@ fn open_recent(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.prefs
|
||||
.recent_files
|
||||
.iter()
|
||||
.filter(|r| listed(r))
|
||||
.map(|r| {
|
||||
let name = std::path::Path::new(r).file_name().map_or_else(|| r.clone(), |f| f.to_string_lossy().into_owned());
|
||||
json!({"path": r, "name": name, "exists": std::path::Path::new(r).exists()})
|
||||
json!({"path": r, "name": name, "exists": crate::file_access::exists(r)})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({"files": files}))
|
||||
@@ -1183,7 +1198,7 @@ fn export_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = std::path::Path::new(d).join(&job.file);
|
||||
// A layer name with slashes exports into folders.
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
let _ = crate::file_access::create_dir_all(parent);
|
||||
}
|
||||
let path = path.to_string_lossy().to_string();
|
||||
write(&path, &bytes)?;
|
||||
@@ -1373,14 +1388,36 @@ fn close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(EngineError::Disabled("file.close".into(), "the document has unsaved changes (pass force: true to discard)".into()));
|
||||
}
|
||||
let uid = ds.uid;
|
||||
let active_uid = s.docs.get(s.active).map(|d| d.uid);
|
||||
s.docs.remove(i);
|
||||
if s.active >= s.docs.len() {
|
||||
s.active = s.docs.len().saturating_sub(1);
|
||||
}
|
||||
// Removing a tab before the active one shifts its index; keep the same document selected.
|
||||
s.active = active_uid.and_then(|a| s.docs.iter().position(|d| d.uid == a)).unwrap_or(i.min(s.docs.len().saturating_sub(1)));
|
||||
crate::branches::close_tabs(s, uid, None);
|
||||
ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tab_order_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn moving_and_closing_inactive_tabs_preserves_the_active_document() {
|
||||
let mut session = Session::new();
|
||||
for name in ["Two", "Three", "Four"] {
|
||||
session.execute("file.new", &json!({"name": name})).unwrap();
|
||||
}
|
||||
session.execute("doc.switch", &json!({"index": 2})).unwrap();
|
||||
let active_uid = session.docs[session.active].uid;
|
||||
let moved_uid = session.docs[0].uid;
|
||||
session.execute("file.moveTab", &json!({"from": 0, "to": 2})).unwrap();
|
||||
assert_eq!(session.docs[2].uid, moved_uid);
|
||||
assert_eq!(session.docs[session.active].uid, active_uid);
|
||||
session.execute("file.close", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(session.docs[session.active].uid, active_uid);
|
||||
assert!(session.execute("file.moveTab", &json!({"from": 0, "to": 99})).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
/// Ctrl+Tab / Ctrl+Shift+Tab: the next or previous open document, wrapping around.
|
||||
fn cycle_doc(s: &mut Session, dir: i64) -> Result<Value> {
|
||||
let n = s.docs.len() as i64;
|
||||
|
||||
@@ -149,6 +149,35 @@ mod tests {
|
||||
assert!(s.execute("node.set", &json!({"id": b, "props": {"absolute": "maybe"}})).is_err());
|
||||
}
|
||||
|
||||
/// Each side key writes its own padding slot and leaves the other three alone.
|
||||
#[test]
|
||||
fn each_side_padding_sets_its_side_alone() {
|
||||
let mut s = Session::new();
|
||||
let (f, _, _) = al_frame(&mut s);
|
||||
// One side at a time: the other three stay 0 ([top, right, bottom, left]).
|
||||
for (key, v, want) in [
|
||||
("paddingTop", 8.0, [8.0, 0.0, 0.0, 0.0]),
|
||||
("paddingRight", 12.0, [0.0, 12.0, 0.0, 0.0]),
|
||||
("paddingBottom", 16.0, [0.0, 0.0, 16.0, 0.0]),
|
||||
("paddingLeft", 20.0, [0.0, 0.0, 0.0, 20.0]),
|
||||
] {
|
||||
ex(&mut s, "node.set", json!({"id": f, "props": {"padding": 0}}));
|
||||
ex(&mut s, "node.set", json!({"id": f, "props": {key: v}}));
|
||||
assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, want, "{key}");
|
||||
}
|
||||
// The [t, r, b, l] array and the axis pairs agree with the four sides.
|
||||
ex(&mut s, "node.set", json!({"id": f, "props": {"padding": [8, 12, 16, 20]}}));
|
||||
assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, [8.0, 12.0, 16.0, 20.0]);
|
||||
ex(&mut s, "node.set", json!({"id": f, "props": {"paddingHorizontal": 4}}));
|
||||
assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, [8.0, 4.0, 16.0, 4.0]);
|
||||
ex(&mut s, "node.set", json!({"id": f, "props": {"paddingVertical": 2}}));
|
||||
assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, [2.0, 4.0, 2.0, 4.0]);
|
||||
// Negatives clamp to 0; junk is refused.
|
||||
ex(&mut s, "node.set", json!({"id": f, "props": {"paddingTop": -5}}));
|
||||
assert_eq!(node(&s, f).layout.as_ref().unwrap().padding[0], 0.0);
|
||||
assert!(s.execute("node.set", &json!({"id": f, "props": {"paddingLeft": "wide"}})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baseline_alignment_lines_up_text() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -112,7 +112,8 @@ pub fn stop_binding(field: &str) -> Option<usize> {
|
||||
/// Keep the gradient stop bindings of paint `index` of `n`'s fills (or strokes) on their stops after
|
||||
/// the paint changed from `old`: one stop removed shifts the later ones down (the removed one's
|
||||
/// binding goes); a paint that is no longer a gradient drops them. Other edits keep stop indices.
|
||||
pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old: &figcraft_color::Paint) {
|
||||
/// Returns the removed stop's index.
|
||||
pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old: &figcraft_color::Paint) -> Option<usize> {
|
||||
use figcraft_color::PaintKind;
|
||||
let list = if fills { BoundList::Fills } else { BoundList::Strokes };
|
||||
let new = if fills { n.fills.get(index) } else { n.strokes.get(index) };
|
||||
@@ -122,11 +123,11 @@ pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old:
|
||||
// The first stop that differs is the removed one when the rest moved down unchanged.
|
||||
let k = a.stops.iter().zip(&b.stops).position(|(x, y)| x != y).unwrap_or(b.stops.len());
|
||||
if a.stops.get(k + 1..) != b.stops.get(k..) {
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
Some(k)
|
||||
}
|
||||
(_, Some(PaintKind::Gradient(_))) => return,
|
||||
(_, Some(PaintKind::Gradient(_))) => return None,
|
||||
_ => None,
|
||||
};
|
||||
let keys: Vec<(String, Option<String>)> = n
|
||||
@@ -150,6 +151,42 @@ pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old:
|
||||
}
|
||||
}
|
||||
n.bound.extend(kept);
|
||||
removed
|
||||
}
|
||||
|
||||
/// After an explicit edit changed paint `index` of `n`'s fills (or strokes) from `old`: its stop
|
||||
/// bindings follow a removed stop, then each binding whose value the edit changed (the colour, the
|
||||
/// opacity, a stop's colour) lets go, so the edit stays instead of being put back from the
|
||||
/// variable. Bindings whose value the edit kept stay; a paint that is gone takes its bindings.
|
||||
pub(crate) fn rebind_edited_paint(n: &mut Node, fills: bool, index: usize, old: &figcraft_color::Paint) {
|
||||
let removed = remap_stop_bindings(n, fills, index, old);
|
||||
let mut old = old.clone();
|
||||
if let (Some(k), figcraft_color::PaintKind::Gradient(g)) = (removed, &mut old.kind)
|
||||
&& k < g.stops.len()
|
||||
{
|
||||
g.stops.remove(k);
|
||||
}
|
||||
let list = if fills { BoundList::Fills } else { BoundList::Strokes };
|
||||
let new = if fills { n.fills.get(index) } else { n.strokes.get(index) }.cloned();
|
||||
n.bound.retain(|key, _| match list_binding(key) {
|
||||
Some((l, i, field)) if l == list && i == index => new.as_ref().is_some_and(|new| paint_field_kept(&old, new, field)),
|
||||
_ => true,
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether a paint's bound `field` (`color`, `opacity`, `stops/<j>/color`, …) has the same value in
|
||||
/// `new` as in `old`.
|
||||
fn paint_field_kept(old: &figcraft_color::Paint, new: &figcraft_color::Paint, field: &str) -> bool {
|
||||
use figcraft_color::PaintKind;
|
||||
match field {
|
||||
"color" => matches!((&old.kind, &new.kind), (PaintKind::Solid { color: a }, PaintKind::Solid { color: b }) if a == b),
|
||||
"opacity" => old.opacity == new.opacity,
|
||||
_ => match (stop_binding(field), &old.kind, &new.kind) {
|
||||
(Some(j), PaintKind::Gradient(a), PaintKind::Gradient(b)) => a.stops.get(j).map(|s| s.color) == b.stops.get(j).map(|s| s.color),
|
||||
(Some(_), _, _) => false,
|
||||
_ => old == new,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Where item `k` lands when the item at `from` moves to `to`.
|
||||
|
||||
@@ -71,6 +71,7 @@ fn registry() -> &'static Registry {
|
||||
list.extend(create::specs());
|
||||
list.extend(edit::specs());
|
||||
list.extend(lists::specs());
|
||||
list.extend(crate::selection_colors::specs());
|
||||
list.extend(arrange::specs());
|
||||
list.extend(structure::specs());
|
||||
list.extend(section::specs());
|
||||
@@ -79,6 +80,7 @@ fn registry() -> &'static Registry {
|
||||
list.extend(page::specs());
|
||||
list.extend(view::specs());
|
||||
list.extend(text::specs());
|
||||
list.extend(crate::fonts::specs());
|
||||
list.extend(styles::specs());
|
||||
list.extend(image::specs());
|
||||
list.extend(variables::specs());
|
||||
|
||||
+159
-33
@@ -61,6 +61,48 @@ pub fn snake(v: &Value) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
/// `v` as an effect list, refusing keys an effect doesn't read: serde dropped them without a word,
|
||||
/// so a shadow given `offsetY` kept the default offset.
|
||||
pub fn effects_from(cmd: &str, v: &Value) -> Result<Vec<Effect>> {
|
||||
let effects: Vec<Effect> = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("effects: {e}")))?;
|
||||
for (i, given) in v.as_array().into_iter().flatten().enumerate() {
|
||||
refuse_ignored_effect_keys(cmd, &format!("effects[{i}]"), given, |v| serde_json::from_value(snake(v)).ok())?;
|
||||
}
|
||||
Ok(effects)
|
||||
}
|
||||
|
||||
/// `p[key]` as one effect, read as serde reads it (its aliases too), refusing keys it doesn't read.
|
||||
pub fn effect_from(cmd: &str, p: &Value, key: &str) -> Result<Option<Effect>> {
|
||||
let Some(v) = get(p, key) else { return Ok(None) };
|
||||
let effect: Effect = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("`{key}`: {e}")))?;
|
||||
refuse_ignored_effect_keys(cmd, &format!("`{key}`"), v, |v| serde_json::from_value(v.clone()).ok())?;
|
||||
Ok(Some(effect))
|
||||
}
|
||||
|
||||
/// Refuse the keys of effect `given` that `parse` ignores, naming the keys the effect takes. Set to
|
||||
/// null, a key it reads fails to parse or changes the effect; a key it ignores changes nothing.
|
||||
fn refuse_ignored_effect_keys(cmd: &str, at: &str, given: &Value, parse: impl Fn(&Value) -> Option<Effect>) -> Result<()> {
|
||||
let Some(fields) = given.as_object() else { return Ok(()) };
|
||||
let parsed = parse(given);
|
||||
let ignored: Vec<&str> = fields
|
||||
.iter()
|
||||
.filter(|(_, v)| !v.is_null())
|
||||
.filter(|(k, _)| {
|
||||
let mut probe = fields.clone();
|
||||
probe.insert((*k).clone(), Value::Null);
|
||||
parse(&Value::Object(probe)).is_some_and(|e| Some(e) == parsed)
|
||||
})
|
||||
.map(|(k, _)| k.as_str())
|
||||
.collect();
|
||||
if ignored.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let takes = parsed.and_then(|e| serde_json::to_value(e).ok()).map(crate::inspect::camel).unwrap_or_default();
|
||||
let kind = takes.get("type").and_then(Value::as_str).unwrap_or("effect").to_string();
|
||||
let names: Vec<&str> = takes.as_object().into_iter().flatten().map(|(k, _)| k.as_str()).collect();
|
||||
Err(bad(cmd, format!("{at}: unknown properties: {} (a {kind} takes {})", ignored.join(", "), names.join(", "))))
|
||||
}
|
||||
|
||||
pub(crate) fn enum_of<T: serde::de::DeserializeOwned>(v: &Value) -> Option<T> {
|
||||
let s = v.as_str()?;
|
||||
// Accept camelCase, snake/upper-case and spaced variants.
|
||||
@@ -265,18 +307,88 @@ fn ensure_layout(n: &mut Node) -> &mut AutoLayout {
|
||||
n.layout.get_or_insert_with(|| AutoLayout { padding: [0.0; 4], gap: 0.0, ..Default::default() })
|
||||
}
|
||||
|
||||
/// Apply a property map to one node. `x`/`y` are relative to the parent.
|
||||
pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Value>) -> Result<()> {
|
||||
let mut unknown = Vec::new();
|
||||
// Geometry keys first so rotation applies to the final size.
|
||||
let order = |k: &str| match k {
|
||||
/// The keys that set a frame's auto layout (through [`layout_mut`]).
|
||||
pub const LAYOUT_KEYS: &[&str] = &[
|
||||
"gap",
|
||||
"itemSpacing",
|
||||
"columnGap",
|
||||
"crossGap",
|
||||
"counterAxisSpacing",
|
||||
"rowGap",
|
||||
"padding",
|
||||
"paddingTop",
|
||||
"paddingRight",
|
||||
"paddingBottom",
|
||||
"paddingLeft",
|
||||
"paddingHorizontal",
|
||||
"paddingVertical",
|
||||
"mainAlign",
|
||||
"primaryAxisAlignItems",
|
||||
"crossAlign",
|
||||
"counterAxisAlignItems",
|
||||
"contentAlign",
|
||||
"counterAxisAlignContent",
|
||||
"wrap",
|
||||
"gridColumns",
|
||||
"gridRows",
|
||||
"strokesIncluded",
|
||||
"reverseZ",
|
||||
"columnTracks",
|
||||
"gridColumnTracks",
|
||||
"rowTracks",
|
||||
"gridRowTracks",
|
||||
];
|
||||
|
||||
/// Drop the bindings of auto layout fields (gap, padding…): they go with the auto layout, or
|
||||
/// re-applying them would bring it back.
|
||||
pub fn drop_layout_bindings(n: &mut Node) {
|
||||
n.bound.retain(|k, _| !LAYOUT_KEYS.contains(&k.as_str()));
|
||||
}
|
||||
|
||||
/// The auto layout of a frame-like node, created when it has none yet. Other nodes can't hold an
|
||||
/// auto layout: an auto layout key on them is refused, not stored where nothing reads it
|
||||
/// (`node.set` on several layers skips them instead, see `edit::set`).
|
||||
fn layout_mut<'a>(cmd: &str, k: &str, n: &'a mut Node) -> Result<&'a mut AutoLayout> {
|
||||
if !n.kind.is_frame_like() {
|
||||
return Err(bad(
|
||||
cmd,
|
||||
format!(
|
||||
"`{k}` is an auto layout property, and layer {} “{}” is a {} (only frames, components and instances have auto layout)",
|
||||
n.id,
|
||||
n.name,
|
||||
n.kind.type_name()
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(ensure_layout(n))
|
||||
}
|
||||
|
||||
/// Replace a node's fills (or strokes). Bindings on the paints whose bound values the new list
|
||||
/// changed let go, as an explicit edit does (see `lists::rebind_edited_paint`).
|
||||
fn set_paints(n: &mut Node, fills: bool, paints: Vec<Paint>) {
|
||||
let old = std::mem::replace(if fills { &mut n.fills } else { &mut n.strokes }, paints);
|
||||
for (i, p) in old.iter().enumerate() {
|
||||
super::lists::rebind_edited_paint(n, fills, i, p);
|
||||
}
|
||||
}
|
||||
|
||||
/// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, variable
|
||||
/// bindings last so paints set in the same call don't detach them.
|
||||
fn apply_order(k: &str) -> u8 {
|
||||
match k {
|
||||
"width" | "height" | "w" | "h" => 0,
|
||||
"rotation" => 2,
|
||||
"x" | "y" => 3,
|
||||
"boundVariables" => 4,
|
||||
_ => 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a property map to one node. `x`/`y` are relative to the parent.
|
||||
pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Value>) -> Result<()> {
|
||||
let mut unknown = Vec::new();
|
||||
let mut keys: Vec<(&String, &Value)> = props.iter().collect();
|
||||
keys.sort_by_key(|(k, _)| order(k));
|
||||
keys.sort_by_key(|(k, _)| apply_order(k));
|
||||
for (k, v) in keys {
|
||||
let k = k.as_str();
|
||||
match k {
|
||||
@@ -348,15 +460,15 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
let b = v.as_str().and_then(figcraft_color::BlendMode::from_name).ok_or_else(|| bad(cmd, "unknown blend mode"))?;
|
||||
doc.get_mut(id)?.blend = b;
|
||||
}
|
||||
"fills" => doc.get_mut(id)?.fills = paints_of(v).ok_or_else(|| bad(cmd, "`fills` must be paints or colours"))?,
|
||||
"fills" => set_paints(doc.get_mut(id)?, true, paints_of(v).ok_or_else(|| bad(cmd, "`fills` must be paints or colours"))?),
|
||||
"fill" => {
|
||||
let c = color_of(v).ok_or_else(|| bad(cmd, "`fill` must be a colour"))?;
|
||||
doc.get_mut(id)?.fills = vec![Paint::solid(c)];
|
||||
set_paints(doc.get_mut(id)?, true, vec![Paint::solid(c)]);
|
||||
}
|
||||
"strokes" => doc.get_mut(id)?.strokes = paints_of(v).ok_or_else(|| bad(cmd, "`strokes` must be paints or colours"))?,
|
||||
"strokes" => set_paints(doc.get_mut(id)?, false, paints_of(v).ok_or_else(|| bad(cmd, "`strokes` must be paints or colours"))?),
|
||||
"stroke" => {
|
||||
let c = color_of(v).ok_or_else(|| bad(cmd, "`stroke` must be a colour"))?;
|
||||
doc.get_mut(id)?.strokes = vec![Paint::solid(c)];
|
||||
set_paints(doc.get_mut(id)?, false, vec![Paint::solid(c)]);
|
||||
}
|
||||
"strokeWeight" => doc.get_mut(id)?.stroke.weight = f64_of(v).ok_or_else(|| bad(cmd, "`strokeWeight` must be a number"))?.max(0.0),
|
||||
"strokeAlign" => doc.get_mut(id)?.stroke.align = enum_of(v).ok_or_else(|| bad(cmd, "strokeAlign: inside|center|outside"))?,
|
||||
@@ -412,7 +524,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
}
|
||||
}
|
||||
"cornerSmoothing" => doc.get_mut(id)?.corners.smoothing = f64_of(v).unwrap_or(0.0).clamp(0.0, 1.0),
|
||||
"effects" => doc.get_mut(id)?.effects = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("effects: {e}")))?,
|
||||
"effects" => doc.get_mut(id)?.effects = effects_from(cmd, v)?,
|
||||
"clip" | "clipsContent" => doc.get_mut(id)?.clip = v.as_bool().ok_or_else(|| bad(cmd, "`clip` must be a boolean"))?,
|
||||
"isMask" => doc.get_mut(id)?.is_mask = v.as_bool().unwrap_or(false),
|
||||
"devStatus" => {
|
||||
@@ -436,8 +548,14 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
return Err(bad(cmd, "auto layout needs a frame"));
|
||||
}
|
||||
match v {
|
||||
Value::Null => n.layout = None,
|
||||
Value::String(s) if s.eq_ignore_ascii_case("none") => n.layout = None,
|
||||
Value::Null => {
|
||||
n.layout = None;
|
||||
drop_layout_bindings(n);
|
||||
}
|
||||
Value::String(s) if s.eq_ignore_ascii_case("none") => {
|
||||
n.layout = None;
|
||||
drop_layout_bindings(n);
|
||||
}
|
||||
Value::String(s) => {
|
||||
let mode = match s.to_ascii_lowercase().as_str() {
|
||||
"horizontal" | "row" => LayoutMode::Horizontal,
|
||||
@@ -452,56 +570,56 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
}
|
||||
}
|
||||
"gap" | "itemSpacing" | "columnGap" => {
|
||||
ensure_layout(doc.get_mut(id)?).gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?
|
||||
layout_mut(cmd, k, doc.get_mut(id)?)?.gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?
|
||||
}
|
||||
"crossGap" | "counterAxisSpacing" | "rowGap" => {
|
||||
ensure_layout(doc.get_mut(id)?).cross_gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?
|
||||
layout_mut(cmd, k, doc.get_mut(id)?)?.cross_gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?
|
||||
}
|
||||
"padding" => {
|
||||
let p = four(v).ok_or_else(|| bad(cmd, "padding: n or [t,r,b,l]"))?;
|
||||
ensure_layout(doc.get_mut(id)?).padding = p.map(|x| x.max(0.0));
|
||||
layout_mut(cmd, k, doc.get_mut(id)?)?.padding = p.map(|x| x.max(0.0));
|
||||
}
|
||||
"paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft" => {
|
||||
let i = ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"].iter().position(|x| *x == k).unwrap_or(0);
|
||||
let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0);
|
||||
if let Some(slot) = ensure_layout(doc.get_mut(id)?).padding.get_mut(i) {
|
||||
if let Some(slot) = layout_mut(cmd, k, doc.get_mut(id)?)?.padding.get_mut(i) {
|
||||
*slot = val;
|
||||
}
|
||||
}
|
||||
"paddingHorizontal" => {
|
||||
let val = f64_of(v).unwrap_or(0.0).max(0.0);
|
||||
let l = ensure_layout(doc.get_mut(id)?);
|
||||
let l = layout_mut(cmd, k, doc.get_mut(id)?)?;
|
||||
l.padding[1] = val;
|
||||
l.padding[3] = val;
|
||||
}
|
||||
"paddingVertical" => {
|
||||
let val = f64_of(v).unwrap_or(0.0).max(0.0);
|
||||
let l = ensure_layout(doc.get_mut(id)?);
|
||||
let l = layout_mut(cmd, k, doc.get_mut(id)?)?;
|
||||
l.padding[0] = val;
|
||||
l.padding[2] = val;
|
||||
}
|
||||
"mainAlign" | "primaryAxisAlignItems" => {
|
||||
ensure_layout(doc.get_mut(id)?).main_align =
|
||||
layout_mut(cmd, k, doc.get_mut(id)?)?.main_align =
|
||||
enum_of(v).ok_or_else(|| bad(cmd, "mainAlign: min|center|max|spaceBetween|spaceAround|spaceEvenly"))?
|
||||
}
|
||||
"crossAlign" | "counterAxisAlignItems" => {
|
||||
ensure_layout(doc.get_mut(id)?).cross_align = enum_of(v).ok_or_else(|| bad(cmd, "crossAlign: min|center|max|baseline"))?
|
||||
layout_mut(cmd, k, doc.get_mut(id)?)?.cross_align = enum_of(v).ok_or_else(|| bad(cmd, "crossAlign: min|center|max|baseline"))?
|
||||
}
|
||||
"contentAlign" | "counterAxisAlignContent" => {
|
||||
ensure_layout(doc.get_mut(id)?).content_align = enum_of(v).ok_or_else(|| bad(cmd, "contentAlign: auto|spaceBetween"))?
|
||||
layout_mut(cmd, k, doc.get_mut(id)?)?.content_align = enum_of(v).ok_or_else(|| bad(cmd, "contentAlign: auto|spaceBetween"))?
|
||||
}
|
||||
"wrap" => ensure_layout(doc.get_mut(id)?).wrap = v.as_bool().unwrap_or(false),
|
||||
"wrap" => layout_mut(cmd, k, doc.get_mut(id)?)?.wrap = v.as_bool().unwrap_or(false),
|
||||
"gridColumns" | "gridRows" => {
|
||||
let count = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.clamp(1.0, 1000.0) as u32;
|
||||
let l = ensure_layout(doc.get_mut(id)?);
|
||||
let l = layout_mut(cmd, k, doc.get_mut(id)?)?;
|
||||
// Tracks past the new count go away (new tracks are 1fr).
|
||||
let (n, tracks) =
|
||||
if k == "gridColumns" { (&mut l.grid_columns, &mut l.column_tracks) } else { (&mut l.grid_rows, &mut l.row_tracks) };
|
||||
*n = count;
|
||||
tracks.truncate(count as usize);
|
||||
}
|
||||
"strokesIncluded" => ensure_layout(doc.get_mut(id)?).strokes_included = v.as_bool().unwrap_or(false),
|
||||
"reverseZ" => ensure_layout(doc.get_mut(id)?).reverse_z = v.as_bool().unwrap_or(false),
|
||||
"strokesIncluded" => layout_mut(cmd, k, doc.get_mut(id)?)?.strokes_included = v.as_bool().unwrap_or(false),
|
||||
"reverseZ" => layout_mut(cmd, k, doc.get_mut(id)?)?.reverse_z = v.as_bool().unwrap_or(false),
|
||||
"sizingH" | "layoutSizingHorizontal" => {
|
||||
let s = sizing(v).ok_or_else(|| bad(cmd, "sizing: fixed|hug|fill"))?;
|
||||
set_sizing(cmd, doc, id, s, true)?;
|
||||
@@ -533,7 +651,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
"columnTracks" | "gridColumnTracks" | "rowTracks" | "gridRowTracks" => {
|
||||
let a = v.as_array().ok_or_else(|| bad(cmd, format!("`{k}` must be an array of tracks")))?;
|
||||
let tracks = a.iter().map(track_of).collect::<Option<Vec<_>>>().ok_or_else(|| bad(cmd, TRACK_HELP))?;
|
||||
let l = ensure_layout(doc.get_mut(id)?);
|
||||
let l = layout_mut(cmd, k, doc.get_mut(id)?)?;
|
||||
if k.contains("olumn") {
|
||||
l.grid_columns = (tracks.len() as u32).clamp(1, 1000);
|
||||
l.column_tracks = tracks;
|
||||
@@ -725,10 +843,15 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
for (field, var) in o {
|
||||
match var.as_str() {
|
||||
Some(vid) => {
|
||||
if n.bound.get(field).map(String::as_str) != Some(vid) {
|
||||
crate::cmd::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, true);
|
||||
}
|
||||
n.bound.insert(field.clone(), vid.to_string());
|
||||
}
|
||||
None => {
|
||||
n.bound.remove(field);
|
||||
if n.bound.remove(field).is_some() {
|
||||
crate::cmd::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -783,12 +906,14 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
if !unknown.is_empty() {
|
||||
return Err(bad(cmd, format!("unknown properties: {} (known: {KEYS})", unknown.join(", "))));
|
||||
}
|
||||
// Inside an instance, record which property groups were overridden (an instance's own export
|
||||
// settings and interactions are its own, not overrides).
|
||||
// Inside an instance, record which property groups were overridden (an instance's own
|
||||
// resizing, export settings and interactions are its own, not overrides).
|
||||
let own_root = matches!(doc.get(id)?.kind, NodeKind::Instance { .. }) && !crate::components::inside_instance(doc, id);
|
||||
let n = doc.get_mut(id)?;
|
||||
if n.source.is_some() {
|
||||
for g in props.keys().map(|k| override_group(k)).filter(|g| *g != "other" && !(own_root && matches!(*g, "exports" | "interactions"))) {
|
||||
for g in
|
||||
props.keys().map(|k| override_group(k)).filter(|g| *g != "other" && !(own_root && matches!(*g, "resizing" | "exports" | "interactions")))
|
||||
{
|
||||
n.overrides.insert(g.to_string());
|
||||
}
|
||||
}
|
||||
@@ -810,7 +935,8 @@ pub fn override_group(k: &str) -> &'static str {
|
||||
"features" | "fontFeatures" | "openTypeFeatures" | "listStyle" | "indentation" | "listSpacing" | "maxLines" | "variationSettings"
|
||||
| "hangingPunctuation" | "hangingList" => "textStyle",
|
||||
"cornerRadius" | "cornerSmoothing" | "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => "corners",
|
||||
"width" | "height" | "w" | "h" | "sizingH" | "sizingV" => "size",
|
||||
"width" | "height" | "w" | "h" => "size",
|
||||
"sizingH" | "sizingV" | "layoutSizingHorizontal" | "layoutSizingVertical" | "minWidth" | "maxWidth" | "minHeight" | "maxHeight" => "resizing",
|
||||
"gap" | "itemSpacing" | "columnGap" | "crossGap" | "counterAxisSpacing" | "rowGap" => "gap",
|
||||
"padding" | "paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft" | "paddingHorizontal" | "paddingVertical" => "padding",
|
||||
"mainAlign" | "primaryAxisAlignItems" | "crossAlign" | "counterAxisAlignItems" | "contentAlign" | "counterAxisAlignContent" => "alignment",
|
||||
|
||||
@@ -10,11 +10,13 @@ use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
|
||||
/// The layer structure commands: group, frame, mask, boolean operations, flatten, auto layout
|
||||
/// add/remove, move to page and vector path edits.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!("object.group", "Group selection", "{ids?, name?}", true, |s, p| wrap(s, p, Wrap::Group)),
|
||||
spec!("object.group", "Group selection", "{ids?, name?}", true, |s, p| wrap(s, p, "object.group", Wrap::Group)),
|
||||
spec!("object.ungroup", "Ungroup selection", "{ids?}", true, ungroup),
|
||||
spec!("object.frameSelection", "Frame selection", "{ids?, name?}", true, |s, p| wrap(s, p, Wrap::Frame)),
|
||||
spec!("object.frameSelection", "Frame selection", "{ids?, name?}", true, |s, p| wrap(s, p, "object.frameSelection", Wrap::Frame)),
|
||||
spec!("object.removeFrame", "Remove frame", "{ids?}", true, ungroup),
|
||||
spec!(
|
||||
"object.convertToGroup",
|
||||
@@ -42,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Wrap in new section",
|
||||
"{ids?, name?} (layers on the page or in a section; the section wraps them with some room)",
|
||||
true,
|
||||
|s, p| wrap(s, p, Wrap::Section)
|
||||
|s, p| wrap(s, p, "object.wrapInSection", Wrap::Section)
|
||||
),
|
||||
spec!(
|
||||
"object.resizeToFit",
|
||||
@@ -71,7 +73,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
// Hugging needs auto layout and the children that filled have nothing to fill: both
|
||||
// become fixed (the frame keeps filling its own auto layout parent).
|
||||
let doc = s.doc_mut()?;
|
||||
doc.get_mut(i)?.layout = None;
|
||||
let n = doc.get_mut(i)?;
|
||||
n.layout = None;
|
||||
super::props::drop_layout_bindings(n);
|
||||
figcraft_layout::coherent_sizing(doc, i);
|
||||
}
|
||||
ok()
|
||||
@@ -128,26 +132,27 @@ const SECTION_PAD: f64 = 40.0;
|
||||
const BOOL_PARAMS: &str = "{ids?} — layers inside boolean groups: sets their own operation (how each combines with the operands below it); one boolean group: sets its operation (every operand follows it); otherwise wraps the layers in a new boolean group";
|
||||
|
||||
/// Wrap the selection in a new boolean group doing `op`.
|
||||
pub(crate) fn wrap_boolean(s: &mut Session, p: &Value, op: BoolOp) -> Result<Value> {
|
||||
wrap(s, p, Wrap::Bool(op))
|
||||
pub(crate) fn wrap_boolean(s: &mut Session, p: &Value, cmd: &str, op: BoolOp) -> Result<Value> {
|
||||
wrap(s, p, cmd, Wrap::Bool(op))
|
||||
}
|
||||
|
||||
/// Wrap the selection in a new container at the topmost selected layer's place.
|
||||
fn wrap(s: &mut Session, p: &Value, w: Wrap) -> Result<Value> {
|
||||
let ids = req_targets("object.group", s, p)?;
|
||||
/// Wrap the selection in a new container at the topmost selected layer's place. Errors name `cmd`,
|
||||
/// the command that asked.
|
||||
fn wrap(s: &mut Session, p: &Value, cmd: &str, w: Wrap) -> Result<Value> {
|
||||
let ids = req_targets(cmd, s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let ids = z_sorted(doc, &roots(doc, &ids));
|
||||
let top = *ids.last().ok_or_else(|| bad("object.group", "nothing selected"))?;
|
||||
let parent = doc.get(top)?.parent.ok_or_else(|| bad("object.group", "can't group a page"))?;
|
||||
let top = *ids.last().ok_or_else(|| bad(cmd, "nothing selected"))?;
|
||||
let parent = doc.get(top)?.parent.ok_or_else(|| bad(cmd, "can't group a page"))?;
|
||||
// Sections sit on the page or in sections, never in frames or groups.
|
||||
let board = |doc: &Document, i: NodeId| {
|
||||
doc.node(i).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.is_page() || matches!(p.kind, NodeKind::Section { .. }))
|
||||
};
|
||||
if matches!(w, Wrap::Section) && !ids.iter().all(|i| board(doc, *i)) {
|
||||
return Err(bad("object.wrapInSection", "only layers on the page or in a section can be wrapped in a section"));
|
||||
return Err(bad(cmd, "only layers on the page or in a section can be wrapped in a section"));
|
||||
}
|
||||
if !matches!(w, Wrap::Section) && ids.iter().any(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. }))) {
|
||||
return Err(bad("object.group", "sections can't go in groups or frames"));
|
||||
return Err(bad(cmd, "sections can't go in groups or frames"));
|
||||
}
|
||||
let index = doc.index_in_parent(top).map(|i| i + 1);
|
||||
let mut bounds = doc.bounds_of(&ids).unwrap_or_default();
|
||||
@@ -285,7 +290,7 @@ fn convert_to_section(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
n.grids.clear();
|
||||
n.guides.clear();
|
||||
n.styles.grid = None;
|
||||
n.bound.retain(|k, _| !k.starts_with("padding") && !matches!(k.as_str(), "gap" | "counterAxisSpacing"));
|
||||
super::props::drop_layout_bindings(n);
|
||||
let kids = n.children.clone();
|
||||
for k in kids {
|
||||
// Out of the flow there is nothing to fill or to be absolute against.
|
||||
@@ -338,7 +343,7 @@ fn mask(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return ok();
|
||||
}
|
||||
// Group, then the bottom-most child is the mask.
|
||||
let r = wrap(s, &json!({"ids": ids, "name": "Mask group"}), Wrap::Group)?;
|
||||
let r = wrap(s, &json!({"ids": ids, "name": "Mask group"}), "object.mask", Wrap::Group)?;
|
||||
let gid = id_of(&r["id"]).ok_or_else(|| EngineError::Other("group failed".into()))?;
|
||||
let doc = s.doc_mut()?;
|
||||
if let Some(first) = doc.get(gid)?.children.first().copied() {
|
||||
@@ -472,7 +477,7 @@ fn outline_stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let f = doc.get_mut(fill_id)?;
|
||||
f.strokes.clear();
|
||||
}
|
||||
let r = wrap(s, &json!({"ids": [fill_id, vid], "name": n.name}), Wrap::Group)?;
|
||||
let r = wrap(s, &json!({"ids": [fill_id, vid], "name": n.name}), "vector.outlineStroke", Wrap::Group)?;
|
||||
if let Some(g) = id_of(&r["id"]) {
|
||||
out.push(g);
|
||||
}
|
||||
@@ -519,7 +524,7 @@ fn layout_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ids
|
||||
} else {
|
||||
// Wrap non-frames in a new frame without a fill, then add auto layout to it.
|
||||
let r = wrap(s, &json!({"ids": ids}), Wrap::Frame)?;
|
||||
let r = wrap(s, &json!({"ids": ids}), "layout.add", Wrap::Frame)?;
|
||||
let fid = id_of(&r["id"]).ok_or_else(|| EngineError::Other("wrap failed".into()))?;
|
||||
let f = s.doc_mut()?.get_mut(fid)?;
|
||||
f.fills.clear();
|
||||
@@ -544,11 +549,16 @@ fn layout_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pb = doc.node(*b).map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect())).unwrap_or_default();
|
||||
if vertical { pa.y0.total_cmp(&pb.y0) } else { pa.x0.total_cmp(&pb.x0) }
|
||||
});
|
||||
let empty = doc.node(*f).is_some_and(|n| n.children.iter().filter_map(|c| doc.node(*c)).all(|c| !c.visible || c.child_layout.absolute));
|
||||
let n = doc.get_mut(*f)?;
|
||||
n.children = kids;
|
||||
n.layout = Some(al);
|
||||
n.child_layout.sizing_h = Sizing::Hug;
|
||||
n.child_layout.sizing_v = Sizing::Hug;
|
||||
if !all_frames || !empty {
|
||||
// A frame wrapped around the selection hugs it; an existing frame with no flow
|
||||
// children keeps its size instead of collapsing to its padding.
|
||||
n.child_layout.sizing_h = Sizing::Hug;
|
||||
n.child_layout.sizing_v = Sizing::Hug;
|
||||
}
|
||||
}
|
||||
s.set_selection(targets.clone());
|
||||
Ok(json!({"ids": targets}))
|
||||
|
||||
@@ -110,16 +110,26 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
))
|
||||
}),
|
||||
spec!("variable.createCollection", "Create variable collection", "{name, modes? [names]}", true, |s, p| {
|
||||
let name = req_string("variable.createCollection", p, "name")?;
|
||||
let doc = s.doc_mut()?;
|
||||
if doc.variables.collections.iter().any(|c| c.name == name) {
|
||||
return Err(bad("variable.createCollection", format!("a collection named `{name}` already exists")));
|
||||
let cmd = "variable.createCollection";
|
||||
let name = req_string(cmd, p, "name")?.trim().to_string();
|
||||
if name.is_empty() {
|
||||
return Err(bad(cmd, "name can't be empty"));
|
||||
}
|
||||
let id = doc.new_collection_id();
|
||||
let modes: Vec<String> = match get(p, "modes") {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(|m| m.as_str().map(str::to_string)).collect(),
|
||||
Some(Value::Array(a)) => a.iter().filter_map(|m| m.as_str().map(|m| m.trim().to_string())).collect(),
|
||||
_ => vec!["Mode 1".into()],
|
||||
};
|
||||
if modes.is_empty() {
|
||||
return Err(bad(cmd, "a collection needs at least one mode"));
|
||||
}
|
||||
if modes.iter().any(String::is_empty) {
|
||||
return Err(bad(cmd, "mode names can't be empty"));
|
||||
}
|
||||
let doc = s.doc_mut()?;
|
||||
if doc.variables.collections.iter().any(|c| c.name == name) {
|
||||
return Err(bad(cmd, format!("a collection named `{name}` already exists")));
|
||||
}
|
||||
let id = doc.new_collection_id();
|
||||
let modes = modes.into_iter().enumerate().map(|(i, m)| Mode { id: format!("{id}/mode{}", i + 1), name: m }).collect();
|
||||
doc.variables.collections.push(Collection { id: id.clone(), name, modes, extends: None });
|
||||
Ok(json!({"id": id}))
|
||||
@@ -458,10 +468,19 @@ fn bind_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match var {
|
||||
Some((_, kind)) if kind != want => return Err(bad(cmd, format!("`{field}` takes a {} variable, not a {} one", want.id(), kind.id()))),
|
||||
Some((id, _)) => {
|
||||
if style.bound.get(&key) != Some(&id)
|
||||
&& let StyleKind::Paint { paints } = &mut style.kind
|
||||
{
|
||||
rebind_paint_alpha(paints, &mut [], &key, true);
|
||||
}
|
||||
style.bound.insert(key, id);
|
||||
}
|
||||
None => {
|
||||
style.bound.remove(&key);
|
||||
if style.bound.remove(&key).is_some()
|
||||
&& let StyleKind::Paint { paints } = &mut style.kind
|
||||
{
|
||||
rebind_paint_alpha(paints, &mut [], &key, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The style takes its default-mode value, and each layer using it its own.
|
||||
@@ -770,16 +789,41 @@ pub fn bind(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let n = doc.get_mut(i)?;
|
||||
match &vid {
|
||||
Some(v) => {
|
||||
if n.bound.get(&field) != Some(v) {
|
||||
rebind_paint_alpha(&mut n.fills, &mut n.strokes, &field, true);
|
||||
}
|
||||
n.bound.insert(field.clone(), v.clone());
|
||||
}
|
||||
None => {
|
||||
n.bound.remove(&field);
|
||||
if n.bound.remove(&field).is_some() {
|
||||
rebind_paint_alpha(&mut n.fills, &mut n.strokes, &field, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
/// A colour variable bound to a solid paint owns its whole colour, alpha included. Binding restarts
|
||||
/// the opacity (which holds the alpha a typed colour had, see `Paint::solid`) at 1, so the
|
||||
/// variable's alpha applies once; unbinding moves the colour's alpha back into the opacity, where a
|
||||
/// typed colour keeps it. An opacity set while bound multiplies on top and stays.
|
||||
pub(crate) fn rebind_paint_alpha(fills: &mut [Paint], strokes: &mut [Paint], field: &str, bind: bool) {
|
||||
use super::lists::{BoundList, list_binding};
|
||||
let Some((list, i, "color")) = list_binding(field) else { return };
|
||||
let paints = match list {
|
||||
BoundList::Fills => fills,
|
||||
BoundList::Strokes => strokes,
|
||||
_ => return,
|
||||
};
|
||||
if let Some(p) = paints.get_mut(i)
|
||||
&& let PaintKind::Solid { color } = &mut p.kind
|
||||
{
|
||||
p.opacity = if bind { 1.0 } else { p.opacity * color.a };
|
||||
color.a = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// `variable.setMode`: choose a collection's mode on frames (the page when none are given).
|
||||
pub fn set_mode(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "variable.setMode";
|
||||
@@ -916,6 +960,147 @@ pub fn apply_bindings_to(doc: &mut Document, only: Option<&std::collections::BTr
|
||||
apply_bindings_matching(doc, only, |_| true);
|
||||
}
|
||||
|
||||
/// The layers `ids` before an explicit edit, for [`detach_edited`] (only those with bindings).
|
||||
pub(crate) fn bound_before(doc: &Document, ids: &[NodeId]) -> Vec<std::sync::Arc<Node>> {
|
||||
ids.iter().filter_map(|i| doc.node_arc(*i)).filter(|n| !n.bound.is_empty()).cloned().collect()
|
||||
}
|
||||
|
||||
/// An explicit edit sticks: each layer's own bound field (not a list item's) whose value the edit
|
||||
/// changed drops its binding, which would otherwise write the variable's value back (#809).
|
||||
/// Compared by value, so keys written unchanged keep their bindings and fields moved along with
|
||||
/// another (proportions, min/max) lose theirs.
|
||||
pub(crate) fn detach_edited(doc: &mut Document, before: &[std::sync::Arc<Node>]) {
|
||||
for old in before {
|
||||
let Some(n) = doc.node(old.id) else { continue };
|
||||
let edited: Vec<String> = n
|
||||
.bound
|
||||
.keys()
|
||||
// A binding the edit itself made (`boundVariables`) stays.
|
||||
.filter(|f| old.bound.get(*f) == n.bound.get(*f))
|
||||
.filter(|f| super::lists::list_binding(f).is_none() && !f.starts_with(crate::components::PROP_BINDING))
|
||||
.filter(|f| bound_field(old, f) != bound_field(n, f))
|
||||
.cloned()
|
||||
.collect();
|
||||
if edited.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(n) = doc.get_mut(old.id) {
|
||||
for f in edited {
|
||||
n.bound.remove(&f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The stored value a layer-level binding writes, comparable (`None`: the layer has no such field).
|
||||
fn bound_field(n: &Node, field: &str) -> Option<String> {
|
||||
let text = |f: &dyn Fn(&TextStyle) -> String| match &n.kind {
|
||||
NodeKind::Text(t) => Some(std::iter::once(&t.style).chain(t.runs.iter().map(|r| &r.style)).map(f).collect::<Vec<_>>().join(",")),
|
||||
_ => None,
|
||||
};
|
||||
let layout = n.layout.as_ref();
|
||||
let cl = &n.child_layout;
|
||||
let pad = |i: usize| layout.and_then(|l| l.padding.get(i).copied());
|
||||
Some(match field {
|
||||
"width" => format!("{}", n.width),
|
||||
"height" => format!("{}", n.height),
|
||||
"minWidth" => format!("{:?}", cl.min_w),
|
||||
"maxWidth" => format!("{:?}", cl.max_w),
|
||||
"minHeight" => format!("{:?}", cl.min_h),
|
||||
"maxHeight" => format!("{:?}", cl.max_h),
|
||||
"opacity" => format!("{}", n.opacity),
|
||||
"visible" => format!("{}", n.visible),
|
||||
"gap" | "columnGap" => format!("{:?}", layout.map(|l| l.gap)),
|
||||
"crossGap" | "rowGap" => format!("{:?}", layout.map(|l| l.cross_gap)),
|
||||
"padding" => format!("{:?}", layout.map(|l| l.padding)),
|
||||
"paddingTop" => format!("{:?}", pad(0)),
|
||||
"paddingRight" => format!("{:?}", pad(1)),
|
||||
"paddingBottom" => format!("{:?}", pad(2)),
|
||||
"paddingLeft" => format!("{:?}", pad(3)),
|
||||
"paddingHorizontal" => format!("{:?}", (pad(1), pad(3))),
|
||||
"paddingVertical" => format!("{:?}", (pad(0), pad(2))),
|
||||
"cornerRadius" => format!("{:?}", n.corners.radii),
|
||||
"topLeftRadius" => format!("{:?}", n.corners.radii.first()),
|
||||
"topRightRadius" => format!("{:?}", n.corners.radii.get(1)),
|
||||
"bottomRightRadius" => format!("{:?}", n.corners.radii.get(2)),
|
||||
"bottomLeftRadius" => format!("{:?}", n.corners.radii.get(3)),
|
||||
"strokeWeight" => format!("{:?}", (n.stroke.weight, n.stroke.sides)),
|
||||
"strokeTopWeight" | "strokeRightWeight" | "strokeBottomWeight" | "strokeLeftWeight" => format!("{:?}", n.stroke.sides),
|
||||
"arcStart" | "arcSweep" | "arcInner" => match &n.kind {
|
||||
NodeKind::Ellipse { start, sweep, inner } => format!(
|
||||
"{}",
|
||||
if field == "arcStart" {
|
||||
start
|
||||
} else if field == "arcSweep" {
|
||||
sweep
|
||||
} else {
|
||||
inner
|
||||
}
|
||||
),
|
||||
_ => return None,
|
||||
},
|
||||
"characters" => match &n.kind {
|
||||
NodeKind::Text(t) => t.characters.clone(),
|
||||
_ => return None,
|
||||
},
|
||||
"paragraphSpacing" | "paragraphIndent" => match &n.kind {
|
||||
NodeKind::Text(t) => format!("{}", if field == "paragraphSpacing" { t.paragraph_spacing } else { t.paragraph_indent }),
|
||||
_ => return None,
|
||||
},
|
||||
"fontFamily" => text(&|s| s.font_family.clone())?,
|
||||
"fontStyle" => text(&|s| s.font_style.clone())?,
|
||||
"fontWeight" => text(&|s| s.weight().to_string())?,
|
||||
"fontSize" => text(&|s| s.font_size.to_string())?,
|
||||
"lineHeight" => text(&|s| format!("{:?}", s.line_height))?,
|
||||
"letterSpacing" => text(&|s| format!("{:?}", s.letter_spacing))?,
|
||||
// shortcut: other layer-level fields keep their binding on edits; add a reader when one is bound in practice.
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Imported files (`.fig`, REST) keep a solid paint's alpha in its opacity, a bound paint's too,
|
||||
/// while the bound colour brings its own alpha: take the variable's alpha back out of the opacity
|
||||
/// (styles' paints and layers') before the bindings are first resolved, so it applies once.
|
||||
pub(crate) fn unfold_imported_bound_alpha(doc: &mut Document) {
|
||||
use super::lists::{BoundList, list_binding};
|
||||
fn alphas(vars: &Variables, bound: &BTreeMap<String, VariableId>, modes: &BTreeMap<CollectionId, ModeId>) -> Vec<(BoundList, usize, f32)> {
|
||||
let found = bound.iter().filter_map(|(field, var)| match (list_binding(field)?, vars.resolve(var, modes)?) {
|
||||
((list @ (BoundList::Fills | BoundList::Strokes), i, "color"), VariableValue::Color(c)) if c.a > 0.0 && c.a < 1.0 => Some((list, i, c.a)),
|
||||
_ => None,
|
||||
});
|
||||
found.collect()
|
||||
}
|
||||
fn unfold(p: Option<&mut Paint>, a: f32) {
|
||||
if let Some(p) = p
|
||||
&& matches!(p.kind, PaintKind::Solid { .. })
|
||||
{
|
||||
p.opacity = (p.opacity / a).min(1.0);
|
||||
}
|
||||
}
|
||||
let pending = doc.take_dirty();
|
||||
for i in 0..doc.styles.len() {
|
||||
let found = doc.styles.get(i).map(|s| alphas(&doc.variables, &s.bound, &BTreeMap::new())).unwrap_or_default();
|
||||
if let Some(StyleKind::Paint { paints }) = doc.styles.get_mut(i).filter(|_| !found.is_empty()).map(|s| &mut s.kind) {
|
||||
for (_, j, a) in found {
|
||||
unfold(paints.get_mut(j), a);
|
||||
}
|
||||
}
|
||||
}
|
||||
let ids: Vec<NodeId> = doc.nodes().filter(|n| !n.bound.is_empty()).map(|n| n.id).collect();
|
||||
for id in ids {
|
||||
let found = doc.node(id).map(|n| alphas(&doc.variables, &n.bound, &doc.modes_for(id))).unwrap_or_default();
|
||||
if found.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Ok(n) = doc.get_mut(id) else { continue };
|
||||
for (list, j, a) in found {
|
||||
unfold(if list == BoundList::Fills { n.fills.get_mut(j) } else { n.strokes.get_mut(j) }, a);
|
||||
}
|
||||
}
|
||||
doc.take_dirty();
|
||||
doc.mark_dirty(pending);
|
||||
}
|
||||
|
||||
/// Settle imported paint colours before the document is first shown. Other bindings can affect
|
||||
/// layout or component structure, which must be resolved through the normal edit pipeline.
|
||||
pub(crate) fn apply_paint_bindings_on_load(doc: &mut Document) {
|
||||
@@ -930,6 +1115,8 @@ pub(crate) fn apply_paint_bindings_on_load(doc: &mut Document) {
|
||||
doc.mark_dirty(pending);
|
||||
}
|
||||
|
||||
/// Re-apply the variable bindings whose field passes `include`, on the `only` nodes (every bound
|
||||
/// node when `None`). Auto layout fields wait while their node has no auto layout.
|
||||
fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::BTreeSet<NodeId>>, include: impl Fn(&str) -> bool) {
|
||||
use super::lists::{BoundList, list_binding};
|
||||
if doc.variables.variables.is_empty() {
|
||||
@@ -944,6 +1131,7 @@ fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::B
|
||||
let modes = doc.modes_for(id);
|
||||
let Some(n) = doc.node(id) else { continue };
|
||||
let binds = n.bound.clone();
|
||||
let has_layout = n.layout.is_some();
|
||||
let mut sets = serde_json::Map::new();
|
||||
let mut effect_sets: Vec<(usize, String, VariableValue)> = Vec::new();
|
||||
let mut paint_sets: Vec<(bool, usize, Option<usize>, Color)> = Vec::new();
|
||||
@@ -953,6 +1141,10 @@ fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::B
|
||||
if !include(&field) {
|
||||
continue;
|
||||
}
|
||||
// A binding never creates an auto layout: without one it waits for the next.
|
||||
if !has_layout && super::props::LAYOUT_KEYS.contains(&field.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let Some(val) = doc.variables.resolve(&var, &modes) else { continue };
|
||||
// One list item's value: a paint's colour, an effect's or a layout grid's field.
|
||||
if let Some((list, i, prop)) = list_binding(&field) {
|
||||
@@ -1104,7 +1296,6 @@ pub fn document_colors(doc: &Document, page: NodeId) -> Vec<(Color, usize)> {
|
||||
stack.extend(n.children.iter().copied());
|
||||
for paint in n.fills.iter().chain(n.strokes.iter()).filter(|p| p.visible) {
|
||||
if let Some(c) = paint.solid_color() {
|
||||
let c = c.with_alpha(c.a * paint.opacity);
|
||||
counts.entry(c.to_rgba8()).or_insert((c, 0)).1 += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ fn style(s: &mut Session, p: &Value, f: impl Fn(&mut TextStyle)) -> Result<Value
|
||||
let ds = s.ds_mut()?;
|
||||
let before = ds.doc.clone();
|
||||
let doc = ds.doc_mut();
|
||||
let bound = super::styles::bound_before(doc, &[id]);
|
||||
if let NodeKind::Text(t) = &mut doc.get_mut(id)?.kind {
|
||||
if a == b {
|
||||
t.restyle(0, t.char_count(), &f);
|
||||
@@ -25,19 +26,21 @@ fn style(s: &mut Session, p: &Value, f: impl Fn(&mut TextStyle)) -> Result<Value
|
||||
t.restyle(a, b, &f);
|
||||
}
|
||||
}
|
||||
super::styles::detach_edited(doc, &bound);
|
||||
crate::normalize(doc, &before);
|
||||
return ok();
|
||||
}
|
||||
let ids = req_targets("text", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
for i in ids {
|
||||
for d in doc.subtree(i) {
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(d).map(|n| &mut n.kind) {
|
||||
let n = t.char_count();
|
||||
t.restyle(0, n, &f);
|
||||
}
|
||||
let ids: Vec<NodeId> = ids.into_iter().flat_map(|i| doc.subtree(i)).collect();
|
||||
let before = super::styles::bound_before(doc, &ids);
|
||||
for d in ids {
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(d).map(|n| &mut n.kind) {
|
||||
let n = t.char_count();
|
||||
t.restyle(0, n, &f);
|
||||
}
|
||||
}
|
||||
super::styles::detach_edited(doc, &before);
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -348,11 +351,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let c = string(p, "characters").or_else(|| string(p, "text")).ok_or_else(|| bad("text.set", "`characters` required"))?;
|
||||
let ids = req_targets("text.set", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let before = super::styles::bound_before(doc, &ids);
|
||||
for i in ids {
|
||||
let mut m = Map::new();
|
||||
m.insert("characters".into(), json!(c));
|
||||
super::props::apply("text.set", doc, i, &m)?;
|
||||
}
|
||||
super::styles::detach_edited(doc, &before);
|
||||
ok()
|
||||
}),
|
||||
spec!(
|
||||
@@ -364,6 +369,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let ids = req_targets("text.style", s, p)?;
|
||||
let m = p.as_object().cloned().unwrap_or_default();
|
||||
let doc = s.doc_mut()?;
|
||||
let before = super::styles::bound_before(doc, &ids);
|
||||
for i in ids {
|
||||
let n = doc.get_mut(i)?;
|
||||
let NodeKind::Text(t) = &mut n.kind else { return Err(bad("text.style", format!("{i} is not text"))) };
|
||||
@@ -377,6 +383,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
super::props::apply_text_style("text.style", t, a, b, k, v)?;
|
||||
}
|
||||
}
|
||||
super::styles::detach_edited(doc, &before);
|
||||
ok()
|
||||
}
|
||||
),
|
||||
@@ -474,5 +481,40 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
}
|
||||
),
|
||||
spec!(
|
||||
"text.rescanFonts",
|
||||
"Rescan installed fonts",
|
||||
"{} walks the system font folders again, for fonts installed while the app runs; the open document's auto-sizing text in the families that gained faces (new ones, or styles installed since) fits them (text.refit) → {added: [families that were unknown], refitted: [ids]}",
|
||||
true,
|
||||
|s, _| {
|
||||
let rescan = figcraft_text::rescan_system_fonts();
|
||||
let refitted = crate::fonts::refit_open(s, None, Some(&rescan.changed));
|
||||
Ok(json!({"added": rescan.added, "refitted": refitted}))
|
||||
}
|
||||
),
|
||||
spec!(
|
||||
"text.loadFont",
|
||||
"Load font file",
|
||||
"{path | data (base64)} adds a font file (.ttf, .otf, .ttc, .otc; every face of a collection) to this session's fonts without installing it; the open document's auto-sizing text in its families fits it (text.refit) → {families, refitted: [ids]}",
|
||||
true,
|
||||
|s, p| {
|
||||
let cmd = "text.loadFont";
|
||||
let bytes = if let Some(b64) = string(p, "data") {
|
||||
use base64::Engine as _;
|
||||
let b64 = b64.split_once(',').map(|(_, d)| d.to_string()).unwrap_or(b64);
|
||||
base64::engine::general_purpose::STANDARD.decode(b64.trim()).map_err(|e| bad(cmd, format!("`data`: {e}")))?
|
||||
} else if let Some(path) = string(p, "path") {
|
||||
super::file::read_capped(&path, figcraft_text::fontdb::MAX_FONT_FILE)?
|
||||
} else {
|
||||
return Err(bad(cmd, "pass `path` or `data` (base64)"));
|
||||
};
|
||||
let families = figcraft_text::load_font_bytes(bytes);
|
||||
if families.is_empty() {
|
||||
return Err(bad(cmd, "not a font file"));
|
||||
}
|
||||
let refitted = crate::fonts::refit_open(s, None, Some(&families));
|
||||
Ok(json!({"families": families, "refitted": refitted}))
|
||||
}
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -288,7 +288,11 @@ pub(crate) fn forget_variables(doc: &mut Document, gone: &BTreeSet<VariableId>)
|
||||
doc.variables.variables.retain(|v| !gone.contains(&v.id));
|
||||
let users: Vec<NodeId> = doc.nodes().filter(|n| n.bound.values().any(|v| gone.contains(v))).map(|n| n.id).collect();
|
||||
for id in users {
|
||||
doc.get_mut(id)?.bound.retain(|_, v| !gone.contains(v));
|
||||
let n = doc.get_mut(id)?;
|
||||
for (field, _) in n.bound.iter().filter(|(_, v)| gone.contains(*v)) {
|
||||
super::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, false);
|
||||
}
|
||||
n.bound.retain(|_, v| !gone.contains(v));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"variable.import",
|
||||
"Import variables",
|
||||
"{variables (the local variables JSON, as variable.export writes it: an object, JSON text or a path)} collections join this file's collection of the same name (values by mode name; new modes and variables are added, a variable of another type is skipped), others are added → {collections (added), variables (added), updated, warnings}",
|
||||
"{variables (the local variables JSON, as variable.export writes it: an object, JSON text or a path; `variableCollections` and `variables` under `meta` or at the top level)} collections join this file's collection of the same name (values by mode name; new modes and variables are added, a variable of another type is skipped), others are added → {collections (added), variables (added), updated, warnings (also when it holds no variables, or fewer than its collections list)}",
|
||||
true,
|
||||
import
|
||||
),
|
||||
|
||||
+105
-38
@@ -19,30 +19,58 @@ fn flag(s: &mut Session, p: &Value, f: fn(&mut ViewState) -> &mut bool) -> Resul
|
||||
Ok(json!({"on": *slot}))
|
||||
}
|
||||
|
||||
/// One of a view's on/off settings.
|
||||
type Slot = fn(&mut ViewState) -> &mut bool;
|
||||
|
||||
/// The View menu's on/off toggles (Pixel preview, with its scale, has its own command), and
|
||||
/// whether the preferences keep each: files opened or made later start with the kept ones. Outline
|
||||
/// modes and the sRGB soft-proof stay with their file, so a new file never opens in one.
|
||||
const TOGGLES: &[(&str, Slot, bool)] = &[
|
||||
("view.pixelGrid", |v| &mut v.pixel_grid, true),
|
||||
("view.layoutGrids", |v| &mut v.layout_grids, true),
|
||||
("view.rulers", |v| &mut v.rulers, true),
|
||||
("view.slices", |v| &mut v.show_slices, true),
|
||||
("view.comments", |v| &mut v.comments, true),
|
||||
("view.multiplayerCursors", |v| &mut v.multiplayer_cursors, true),
|
||||
("view.outlines", |v| &mut v.outlines, false),
|
||||
("view.outlinesSelection", |v| &mut v.outlines_selection, false),
|
||||
("view.outlinesHiddenLayers", |v| &mut v.outline_hidden, true),
|
||||
("view.outlinesObjectBounds", |v| &mut v.outline_bounds, true),
|
||||
("view.maskOutlines", |v| &mut v.mask_outlines, true),
|
||||
("view.frameOutlines", |v| &mut v.frame_outlines, true),
|
||||
("view.proofSrgb", |v| &mut v.proof_srgb, false),
|
||||
("view.snapGeometry", |v| &mut v.snap_geometry, true),
|
||||
("view.snapObjects", |v| &mut v.snap_objects, true),
|
||||
("view.snapPixel", |v| &mut v.snap_pixel, true),
|
||||
];
|
||||
|
||||
/// View toggle `id`: the file's view changes, and the preferences remember a kept one.
|
||||
fn toggle(s: &mut Session, p: &Value, id: &str) -> Result<Value> {
|
||||
let Some(&(_, f, keep)) = TOGGLES.iter().find(|(k, ..)| *k == id) else { return Err(bad(id, "not a View toggle")) };
|
||||
let r = flag(s, p, f)?;
|
||||
if keep && let Some(on) = r.get("on").and_then(Value::as_bool) {
|
||||
s.prefs.view.insert(id.to_string(), on);
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Set `v`'s View toggles to the ones the preferences keep (the others stay as they are).
|
||||
pub(crate) fn apply_kept(prefs: &crate::Prefs, v: &mut ViewState) {
|
||||
for (id, f, _) in TOGGLES.iter().filter(|t| t.2) {
|
||||
if let Some(on) = prefs.view.get(*id) {
|
||||
*f(v) = *on;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the View menu's toggle `id` is on (the menus show a check mark); `None` for a command
|
||||
/// that isn't one.
|
||||
pub fn checked(s: &Session, id: &str) -> Option<bool> {
|
||||
let v = &s.ds().ok()?.view;
|
||||
Some(match id {
|
||||
"view.pixelGrid" => v.pixel_grid,
|
||||
"view.layoutGrids" => v.layout_grids,
|
||||
"view.rulers" => v.rulers,
|
||||
"view.slices" => v.show_slices,
|
||||
"view.comments" => v.comments,
|
||||
"view.multiplayerCursors" => v.multiplayer_cursors,
|
||||
"view.outlines" => v.outlines,
|
||||
"view.outlinesSelection" => v.outlines_selection,
|
||||
"view.outlinesHiddenLayers" => v.outline_hidden,
|
||||
"view.outlinesObjectBounds" => v.outline_bounds,
|
||||
"view.maskOutlines" => v.mask_outlines,
|
||||
"view.pixelPreview" => v.pixel_preview,
|
||||
"view.frameOutlines" => v.frame_outlines,
|
||||
"view.proofSrgb" => v.proof_srgb,
|
||||
"view.snapGeometry" => v.snap_geometry,
|
||||
"view.snapObjects" => v.snap_objects,
|
||||
"view.snapPixel" => v.snap_pixel,
|
||||
_ => return None,
|
||||
})
|
||||
let mut v = s.ds().ok()?.view.clone();
|
||||
if id == "view.pixelPreview" {
|
||||
return Some(v.pixel_preview);
|
||||
}
|
||||
TOGGLES.iter().find(|(k, ..)| *k == id).map(|(_, f, _)| *f(&mut v))
|
||||
}
|
||||
|
||||
/// Pixel preview: toggles, or turns on at a resolution of 1x or 2x.
|
||||
@@ -235,46 +263,46 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("view.nextFrame", "Zoom to next frame", "{}", false, |s, _| zoom_frame(s, 1)),
|
||||
spec!("view.prevPage", "Previous page", "{}", false, |s, _| page_step(s, -1)),
|
||||
spec!("view.nextPage", "Next page", "{}", false, |s, _| page_step(s, 1)),
|
||||
spec!("view.pixelGrid", "Pixel grid", "{on?}", false, |s, p| flag(s, p, |v| &mut v.pixel_grid)),
|
||||
spec!("view.layoutGrids", "Layout grids", "{on?}", false, |s, p| flag(s, p, |v| &mut v.layout_grids)),
|
||||
spec!("view.rulers", "Rulers", "{on?}", false, |s, p| flag(s, p, |v| &mut v.rulers)),
|
||||
spec!("view.slices", "Slices", "{on?}", false, |s, p| flag(s, p, |v| &mut v.show_slices)),
|
||||
spec!("view.comments", "Comments", "{on?}", false, |s, p| flag(s, p, |v| &mut v.comments)),
|
||||
spec!("view.pixelGrid", "Pixel grid", "{on?}", false, |s, p| toggle(s, p, "view.pixelGrid")),
|
||||
spec!("view.layoutGrids", "Layout grids", "{on?}", false, |s, p| toggle(s, p, "view.layoutGrids")),
|
||||
spec!("view.rulers", "Rulers", "{on?}", false, |s, p| toggle(s, p, "view.rulers")),
|
||||
spec!("view.slices", "Slices", "{on?}", false, |s, p| toggle(s, p, "view.slices")),
|
||||
spec!("view.comments", "Comments", "{on?}", false, |s, p| toggle(s, p, "view.comments")),
|
||||
spec!("view.multiplayerCursors", "Multiplayer cursors", "{on?} others' cursors in a live session", false, |s, p| {
|
||||
flag(s, p, |v| &mut v.multiplayer_cursors)
|
||||
toggle(s, p, "view.multiplayerCursors")
|
||||
}),
|
||||
spec!("view.pan", "Pan the view", "{dx, dy (arrow-key steps, e.g. 1 or -1), big? (ten times as far, like Shift)}", false, pan),
|
||||
spec!("view.outlines", "Outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.outlines)),
|
||||
spec!("view.outlines", "Outlines", "{on?}", false, |s, p| toggle(s, p, "view.outlines")),
|
||||
spec!("view.outlinesSelection", "Outlines for selection", "{on?} outline mode for the selected layers only", false, |s, p| {
|
||||
flag(s, p, |v| &mut v.outlines_selection)
|
||||
toggle(s, p, "view.outlinesSelection")
|
||||
}),
|
||||
spec!(
|
||||
"view.outlinesHiddenLayers",
|
||||
"Include hidden layers in outlines",
|
||||
"{on?} outline mode (and outlines for the selection) outlines hidden layers too; they can be clicked there",
|
||||
false,
|
||||
|s, p| { flag(s, p, |v| &mut v.outline_hidden) }
|
||||
|s, p| { toggle(s, p, "view.outlinesHiddenLayers") }
|
||||
),
|
||||
spec!(
|
||||
"view.outlinesObjectBounds",
|
||||
"Include object bounds in outlines",
|
||||
"{on?} outline mode also draws each layer's box (groups, text, vectors); a click on a box's edge picks its layer",
|
||||
false,
|
||||
|s, p| { flag(s, p, |v| &mut v.outline_bounds) }
|
||||
|s, p| { toggle(s, p, "view.outlinesObjectBounds") }
|
||||
),
|
||||
spec!("view.maskOutlines", "Mask outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.mask_outlines)),
|
||||
spec!("view.maskOutlines", "Mask outlines", "{on?}", false, |s, p| toggle(s, p, "view.maskOutlines")),
|
||||
spec!("view.pixelPreview", "Pixel preview", "{on?, scale? (1|2: rendered pixels per page pixel; turns it on)}", false, pixel_preview),
|
||||
spec!("view.frameOutlines", "Frame outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.frame_outlines)),
|
||||
spec!("view.frameOutlines", "Frame outlines", "{on?}", false, |s, p| toggle(s, p, "view.frameOutlines")),
|
||||
spec!(
|
||||
"view.proofSrgb",
|
||||
"Soft-proof sRGB",
|
||||
"{on?} mark the colours of a Display P3 file that sRGB screens and files can't show",
|
||||
false,
|
||||
|s, p| { flag(s, p, |v| &mut v.proof_srgb) }
|
||||
|s, p| { toggle(s, p, "view.proofSrgb") }
|
||||
),
|
||||
spec!("view.snapGeometry", "Snap to geometry", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_geometry)),
|
||||
spec!("view.snapObjects", "Snap to objects", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_objects)),
|
||||
spec!("view.snapPixel", "Snap to pixel grid", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_pixel)),
|
||||
spec!("view.snapGeometry", "Snap to geometry", "{on?}", false, |s, p| toggle(s, p, "view.snapGeometry")),
|
||||
spec!("view.snapObjects", "Snap to objects", "{on?}", false, |s, p| toggle(s, p, "view.snapObjects")),
|
||||
spec!("view.snapPixel", "Snap to pixel grid", "{on?}", false, |s, p| toggle(s, p, "view.snapPixel")),
|
||||
spec!("view.toggleUi", "Show/Hide UI", "{on?}", false, |s, p| flag(s, p, |v| &mut v.ui_hidden)),
|
||||
spec!("view.fullscreen", "Full screen", "{on?} the window fills the screen (the desktop app; a no-op headless)", false, |s, p| {
|
||||
s.request_ui("window.fullscreen", json!({"on": boolean(p, "on")}));
|
||||
@@ -342,7 +370,25 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("pref.showDimensions", "Show dimensions on objects", "{on?}", false, |s, p| pref(s, p, |x| &mut x.show_dimensions)),
|
||||
spec!("pref.numberKeysOpacity", "Use number keys for opacity", "{on?}", false, |s, p| pref(s, p, |x| &mut x.number_keys_opacity)),
|
||||
spec!("pref.reduceMotion", "Reduce motion", "{on?}", false, |s, p| pref(s, p, |x| &mut x.reduce_motion)),
|
||||
spec!(
|
||||
"pref.uiScale",
|
||||
"Interface size",
|
||||
"{scale?: a size from 0.75 to 3 relative to the system's display scale, 1 being the system's own size, so 1.25 is 125 %, not 125 (outside the range it stops at the nearest end; \"auto\" is 1; omit: just read it)} → {scale}: how large the whole interface is drawn; the canvas zoom is separate",
|
||||
false,
|
||||
|s, p| {
|
||||
match get(p, "scale") {
|
||||
None => {}
|
||||
Some(Value::String(v)) if v.trim().eq_ignore_ascii_case("auto") => s.prefs.ui_scale = 1.0,
|
||||
Some(_) => {
|
||||
let scale = req_num("pref.uiScale", p, "scale")?;
|
||||
s.prefs.ui_scale = crate::clamp_ui_scale(scale).ok_or_else(|| bad("pref.uiScale", "`scale` must be a finite number"))?;
|
||||
}
|
||||
}
|
||||
Ok(json!({"scale": s.prefs.ui_scale}))
|
||||
}
|
||||
),
|
||||
spec!("pref.autoSave", "Save files automatically", "{on?}", false, |s, p| pref(s, p, |x| &mut x.auto_save)),
|
||||
spec!("pref.startOnHome", "Start on the Home screen", "{on?}", false, |s, p| pref(s, p, |x| &mut x.start_on_home)),
|
||||
spec!("pref.nudge", "Nudge amount", "{small?, big?}", false, |s, p| {
|
||||
s.prefs.nudge_small = num(p, "small").unwrap_or(s.prefs.nudge_small).clamp(0.01, 1000.0);
|
||||
s.prefs.nudge_big = num(p, "big").unwrap_or(s.prefs.nudge_big).clamp(0.01, 10000.0);
|
||||
@@ -381,7 +427,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"pref.language",
|
||||
"Interface language",
|
||||
"{language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…; else English}",
|
||||
"{language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|zh-Hans|…; else English}",
|
||||
false,
|
||||
|s, p| {
|
||||
let lang = req_string("pref.language", p, "language")?;
|
||||
@@ -494,3 +540,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn unused(_: NodeId) {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod start_on_home_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn start_on_home_is_a_preference_that_persists() {
|
||||
let mut s = Session::new();
|
||||
assert!(!s.prefs.start_on_home);
|
||||
assert_eq!(s.execute("pref.startOnHome", &json!({})).unwrap()["on"], true);
|
||||
assert!(s.prefs.start_on_home);
|
||||
// Saved with the other preferences and read back; older settings files without it read as off.
|
||||
let saved: crate::Prefs = serde_json::from_value(serde_json::to_value(&s.prefs).unwrap()).unwrap();
|
||||
assert!(saved.start_on_home);
|
||||
assert_eq!(s.execute("pref.startOnHome", &json!({"on": false})).unwrap()["on"], false);
|
||||
let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap();
|
||||
assert!(!old.start_on_home);
|
||||
}
|
||||
}
|
||||
@@ -256,7 +256,26 @@ fn stroke_color(n: &Node) -> Option<Color> {
|
||||
if !n.has_visible_stroke() {
|
||||
return None;
|
||||
}
|
||||
n.strokes.iter().filter(|p| p.visible).find_map(|p| p.solid_color().map(|c| c.with_alpha(c.a * p.opacity)))
|
||||
n.strokes.iter().filter(|p| p.visible).find_map(|p| p.solid_color())
|
||||
}
|
||||
|
||||
/// The CSS borders a solid stroke makes: one `border`, or with individual stroke sides a border
|
||||
/// on each side that has a weight.
|
||||
fn css_borders(doc: &Document, n: &Node, t: &mut Tokens) -> Vec<(String, String)> {
|
||||
let Some(c) = stroke_color(n) else { return Vec::new() };
|
||||
let Some(sides) = n.stroke.sides else {
|
||||
let weight = tok(doc, n, &["strokeWeight"], format!("{}px", num(n.stroke.weight)), t);
|
||||
return vec![("border".into(), format!("{weight} solid {}", tok(doc, n, &["stroke"], c.to_css(), t)))];
|
||||
};
|
||||
let color = tok(doc, n, &["stroke"], c.to_css(), t);
|
||||
let named = ["top", "right", "bottom", "left"].into_iter().zip(crate::cmd::props::STROKE_SIDES);
|
||||
named
|
||||
.zip(sides)
|
||||
.filter(|(_, weight)| *weight > 0.0)
|
||||
.map(|((side, field), weight)| {
|
||||
(format!("border-{side}"), format!("{} solid {color}", tok(doc, n, &[field], format!("{}px", num(weight)), t)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn drop_shadow(n: &Node) -> Option<&Shadow> {
|
||||
@@ -452,9 +471,8 @@ pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bo
|
||||
PaintKind::Pattern(_) => put("background", "url(pattern.png) repeat".into()),
|
||||
}
|
||||
}
|
||||
if let Some(c) = stroke_color(n) {
|
||||
let weight = tok(doc, n, &["strokeWeight"], px(n.stroke.weight), t);
|
||||
put("border", format!("{weight} solid {}", tok(doc, n, &["stroke"], c.to_css(), t)));
|
||||
for (key, value) in css_borders(doc, n, t) {
|
||||
put(&key, value);
|
||||
}
|
||||
let shadows: Vec<String> = n
|
||||
.effects
|
||||
@@ -858,11 +876,16 @@ pub(crate) fn swift(ctx: &Ctx, id: NodeId, depth: usize, out: &mut String, extra
|
||||
swift_layer(ctx, n, depth, out, extra);
|
||||
}
|
||||
|
||||
/// A layer name safe for a single-line `//` comment: line breaks become spaces.
|
||||
fn one_line(name: &str) -> String {
|
||||
name.replace(['\n', '\r', '\u{85}', '\u{2028}', '\u{2029}'], " ")
|
||||
}
|
||||
|
||||
fn swift_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[String]) {
|
||||
let doc = ctx.doc;
|
||||
let pad = indent(depth);
|
||||
if ctx.too_deep(depth) {
|
||||
out.push_str(&format!("{pad}// {} …\n", n.name));
|
||||
out.push_str(&format!("{pad}// {} …\n", one_line(&n.name)));
|
||||
return;
|
||||
}
|
||||
// Nested instances are calls; an instance swap parameter is a view.
|
||||
@@ -1035,7 +1058,7 @@ fn compose_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String) {
|
||||
let doc = ctx.doc;
|
||||
let pad = indent(depth);
|
||||
if ctx.too_deep(depth) {
|
||||
out.push_str(&format!("{pad}// {} …\n", n.name));
|
||||
out.push_str(&format!("{pad}// {} …\n", one_line(&n.name)));
|
||||
return;
|
||||
}
|
||||
// Nested instances are calls; an instance swap parameter is a composable.
|
||||
@@ -1168,4 +1191,26 @@ mod tests {
|
||||
assert!(kt.contains("Modifier.offset(7.dp, 9.dp)"));
|
||||
assert!(code_for(doc, NodeId(999_999), CodeLang::Css).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_deep_placeholder_keeps_a_multiline_layer_name_in_the_comment() {
|
||||
let mut s = Session::new();
|
||||
let name = "deep\nevil()\r\u{2028}more()";
|
||||
let mut parent = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 50, "name": name})).unwrap()["id"]).unwrap();
|
||||
let root = parent;
|
||||
for _ in 0..16 {
|
||||
parent =
|
||||
id_of(&s.execute("frame.create", &json!({"x": 1, "y": 1, "width": 40, "height": 40, "parent": parent, "name": name})).unwrap()["id"])
|
||||
.unwrap();
|
||||
}
|
||||
let doc = s.doc().unwrap();
|
||||
for lang in [CodeLang::SwiftUi, CodeLang::Compose] {
|
||||
let code = code_for(doc, root, lang);
|
||||
assert!(code.contains("// deep evil() more() …\n"), "{code}");
|
||||
for line in code.lines() {
|
||||
let t = line.trim_start();
|
||||
assert!(!t.starts_with("evil()") && !t.starts_with("more()"), "layer name leaked as code:\n{code}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,12 @@
|
||||
//! Override groups: `fills`, `strokes`, `effects`, `opacity`, `visible`, `name`, `corners`,
|
||||
//! `characters`, `textStyle` (font, size, spacing, resizing, …), `padding`, `gap` and `alignment`
|
||||
//! (of auto layout: its direction and other settings are the main's), `grids`, `exports`,
|
||||
//! `interactions`, `component` (a swapped nested instance), `props` (property values of a nested
|
||||
//! instance) and `slot` (a slot holding the instance's own content). An instance's own export
|
||||
//! settings and interactions are its own, like its place. `size` and `position` overrides of
|
||||
//! layers inside an instance are still read and kept, but new ones are refused
|
||||
//! ([`check_override_edit`]): those layers take their geometry, constraints and resizing from the
|
||||
//! main; the instance itself resizes.
|
||||
//! `interactions`, `size` and `resizing` (hug, fill or fixed, with min and max sizes) of layers
|
||||
//! inside an instance, `component` (a swapped nested instance), `props` (property values of a
|
||||
//! nested instance) and `slot` (a slot holding the instance's own content). An instance's own
|
||||
//! export settings, interactions and resizing are its own, like its place. `position`
|
||||
//! overrides of layers inside an instance are still read and kept, but new ones are refused
|
||||
//! ([`check_override_edit`]): those layers take their place and constraints from the main.
|
||||
//!
|
||||
//! Switching variants keeps an override where the layer's name path matches and the two variants
|
||||
//! agreed on that property (text layers keep all of theirs); swapping from the Assets panel keeps
|
||||
@@ -58,6 +58,7 @@ pub const OVERRIDE_GROUPS: &[&str] = &[
|
||||
"exports",
|
||||
"interactions",
|
||||
"size",
|
||||
"resizing",
|
||||
"position",
|
||||
"component",
|
||||
"props",
|
||||
@@ -284,11 +285,28 @@ pub fn create_instance_at(doc: &mut Document, component: NodeId, parent: NodeId,
|
||||
root.dev_resources.clear();
|
||||
// The main's interactions play on its instances in prototypes; the instance's own start empty.
|
||||
root.interactions.clear();
|
||||
// So do the variable modes the main sets (see `Document::inherited_modes`).
|
||||
root.var_modes.clear();
|
||||
doc.insert(root, parent, index)?;
|
||||
sync_instance(doc, id);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Copy `id` within its own file under `parent` (at `index`), keeping its local transform. A file
|
||||
/// has one main per component, so a main component's copy is an instance of it; a variant copied
|
||||
/// into its set stays a new variant, and every other layer is deep-copied.
|
||||
pub fn copy_in_file(doc: &mut Document, id: NodeId, parent: NodeId, index: Option<usize>) -> Result<NodeId, DocError> {
|
||||
let n = doc.get(id)?;
|
||||
let in_set = doc.node(parent).is_some_and(|p| matches!(p.kind, NodeKind::ComponentSet { .. }));
|
||||
if !matches!(n.kind, NodeKind::Component { .. }) || in_set {
|
||||
return doc.duplicate_into(id, parent, index);
|
||||
}
|
||||
let transform = n.transform;
|
||||
let instance = create_instance_at(doc, id, parent, 0.0, 0.0, index)?;
|
||||
doc.get_mut(instance)?.transform = transform;
|
||||
Ok(instance)
|
||||
}
|
||||
|
||||
/// Which part of the carried overrides to apply.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Carry {
|
||||
@@ -388,11 +406,14 @@ pub(crate) fn copy_group(from: &Node, to: &mut Node, g: &str) {
|
||||
"size" => {
|
||||
to.width = from.width;
|
||||
to.height = from.height;
|
||||
to.child_layout = from.child_layout.clone();
|
||||
if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) {
|
||||
b.auto_resize = a.auto_resize;
|
||||
}
|
||||
}
|
||||
"resizing" => {
|
||||
let (a, b) = (&from.child_layout, &mut to.child_layout);
|
||||
(b.sizing_h, b.sizing_v, b.min_w, b.max_w, b.min_h, b.max_h) = (a.sizing_h, a.sizing_v, a.min_w, a.max_w, a.min_h, a.max_h);
|
||||
}
|
||||
"position" => to.transform = from.transform,
|
||||
"component" => {
|
||||
if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&from.kind, &mut to.kind) {
|
||||
@@ -773,6 +794,15 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT
|
||||
for s in &src {
|
||||
let Some(nid) = map.get(s).copied() else { continue };
|
||||
let Some(o) = old.get(&nid) else { continue };
|
||||
// A frame that kept the size its own resizing gave it: its layers (copied at their main
|
||||
// layers' places) follow their constraints from its main layer's size.
|
||||
if o.overrides.contains("resizing")
|
||||
&& !o.overrides.contains("size")
|
||||
&& doc.node(nid).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none())
|
||||
&& let Some((mw, mh)) = doc.node(*s).map(|m| (m.width, m.height))
|
||||
{
|
||||
figcraft_layout::apply_constraints(doc, nid, mw, mh);
|
||||
}
|
||||
if !o.overrides.contains("size") && !o.overrides.contains("position") {
|
||||
continue;
|
||||
}
|
||||
@@ -985,6 +1015,9 @@ pub fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> {
|
||||
if (a.width - b.width).abs() > 1e-6 || (a.height - b.height).abs() > 1e-6 {
|
||||
out.push("size");
|
||||
}
|
||||
if resizing(a) != resizing(b) {
|
||||
out.push("resizing");
|
||||
}
|
||||
if a.transform != b.transform {
|
||||
out.push("position");
|
||||
}
|
||||
@@ -1027,6 +1060,12 @@ pub fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The `resizing` group of a layer: how it sizes on each axis, and its min and max sizes.
|
||||
fn resizing(n: &Node) -> (Sizing, Sizing, [Option<f64>; 4]) {
|
||||
let c = &n.child_layout;
|
||||
(c.sizing_h, c.sizing_v, [c.min_w, c.max_w, c.min_h, c.max_h])
|
||||
}
|
||||
|
||||
/// Whether two texts differ in anything but their characters (the `textStyle` group).
|
||||
fn text_style_differs(x: &TextData, y: &TextData) -> bool {
|
||||
x.style != y.style
|
||||
@@ -1107,9 +1146,10 @@ pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId]
|
||||
}
|
||||
let raw = diff_groups(b, n);
|
||||
let mut groups = raw.clone();
|
||||
// An instance's own place, export settings and interactions are its own, not overrides.
|
||||
// An instance's own place, resizing, export settings and interactions are its own, not
|
||||
// overrides.
|
||||
if top_root {
|
||||
groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "exports" | "interactions"));
|
||||
groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "resizing" | "exports" | "interactions"));
|
||||
}
|
||||
if groups.iter().any(|g| matches!(*g, "size" | "position")) {
|
||||
groups.retain(|g| !matches!(*g, "size" | "position") || !geometry_derived(doc, before, id, g));
|
||||
@@ -1414,7 +1454,7 @@ fn patch_structure(doc: &mut Document, before: &Document, seeds: &[NodeId]) -> b
|
||||
fn child_follows_main(doc: &Document, parent: NodeId, absolute: bool) -> bool {
|
||||
let Some(p) = doc.node(parent) else { return false };
|
||||
let constrains = p.layout.is_none() || absolute;
|
||||
let resized = p.overrides.contains("size")
|
||||
let resized = sized_own(p)
|
||||
|| match &p.kind {
|
||||
NodeKind::Instance { component, .. } => {
|
||||
doc.node(*component).is_none_or(|m| (m.width - p.width).abs() > 1e-9 || (m.height - p.height).abs() > 1e-9)
|
||||
@@ -1475,7 +1515,7 @@ fn patch_copies(doc: &mut Document, seeds: &[(NodeId, Vec<&'static str>)]) -> bo
|
||||
let gs: Vec<&'static str> = groups
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|g| !cn.overrides.contains(*g) && !(root && matches!(*g, "visible" | "name" | "position" | "exports" | "interactions")))
|
||||
.filter(|g| !(cn.overrides.contains(*g) || root && matches!(*g, "visible" | "name" | "position" | "exports" | "interactions")))
|
||||
.collect();
|
||||
if gs.is_empty() {
|
||||
continue;
|
||||
@@ -1518,7 +1558,7 @@ fn follows_main_geometry(doc: &Document, id: NodeId) -> bool {
|
||||
chain.windows(2).all(|w| {
|
||||
let (Some(below), Some(a)) = (w.first().and_then(|i| doc.node(*i)), w.get(1).and_then(|i| doc.node(*i))) else { return false };
|
||||
let constrains = a.layout.is_none() || below.child_layout.absolute;
|
||||
if a.overrides.contains("size") && constrains {
|
||||
if sized_own(a) && constrains {
|
||||
return false;
|
||||
}
|
||||
match &a.kind {
|
||||
@@ -1530,6 +1570,12 @@ fn follows_main_geometry(doc: &Document, id: NodeId) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a layer inside an instance overrides its size or resizing: it may not have its main
|
||||
/// layer's size, so the layers in it can't take their main layers' places as they are.
|
||||
fn sized_own(n: &Node) -> bool {
|
||||
n.overrides.contains("size") || n.overrides.contains("resizing")
|
||||
}
|
||||
|
||||
/// Rebuild every instance of `comps` (and of the components that hold those instances, and so on
|
||||
/// outwards), each outermost instance once, inner components first.
|
||||
fn rebuild_instances(doc: &mut Document, comps: &BTreeSet<NodeId>) {
|
||||
@@ -1665,10 +1711,9 @@ pub fn check_override_edit(before: &Document, after: &Document) -> Result<(), St
|
||||
|
||||
/// What about layer `id` an edit from `before` to `after` changed that only its main can: the
|
||||
/// auto layout of an instance or a layer in one but its padding, gap and alignment, or the
|
||||
/// position (rotation too), size, constraints or resizing of a layer inside an instance (the
|
||||
/// instance itself resizes, and its layers follow; text whose box follows its content still
|
||||
/// does). `None` when the edit is fine, also when the main changed the same way in this edit (a
|
||||
/// library update).
|
||||
/// position (rotation too), constraints or place in auto layout (absolute, grid cell) of a layer
|
||||
/// inside an instance (its size and resizing are overrides). `None` when the edit is fine, also
|
||||
/// when the main changed the same way in this edit (a library update).
|
||||
pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Option<&'static str> {
|
||||
let (n, b) = (after.node(id)?, before.node(id)?);
|
||||
if after.is_remote(id) || n.source != b.source || owning_instance(before, id).is_none() || owning_instance(after, id).is_none() {
|
||||
@@ -1684,8 +1729,12 @@ pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Opti
|
||||
l
|
||||
})
|
||||
};
|
||||
let size = |x: &Node| (x.width, x.height);
|
||||
let resized = |x: &Node, y: &Node| (x.width - y.width).abs() > 1e-6 || (x.height - y.height).abs() > 1e-6;
|
||||
// Child layout settings other than the resizing ones.
|
||||
let placing = |x: &Node| {
|
||||
let mut c = x.child_layout.clone();
|
||||
(c.sizing_h, c.sizing_v, c.min_w, c.max_w, c.min_h, c.max_h) = (Sizing::Fixed, Sizing::Fixed, None, None, None, None);
|
||||
c
|
||||
};
|
||||
if mode(n) != mode(b) && mode(ma) == mode(mb) {
|
||||
return Some("The auto layout direction");
|
||||
}
|
||||
@@ -1698,14 +1747,11 @@ pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Opti
|
||||
if n.transform != b.transform && ma.transform == mb.transform && !geometry_derived(after, before, id, "position") {
|
||||
return Some("A layer's position");
|
||||
}
|
||||
if resized(n, b) && size(ma) == size(mb) && !geometry_derived(after, before, id, "size") {
|
||||
return Some("A layer's size");
|
||||
}
|
||||
if n.constraints != b.constraints && ma.constraints == mb.constraints {
|
||||
return Some("A layer's constraints");
|
||||
}
|
||||
if n.child_layout != b.child_layout && ma.child_layout == mb.child_layout {
|
||||
return Some("A layer's resizing");
|
||||
if placing(n) != placing(b) && placing(ma) == placing(mb) {
|
||||
return Some("A layer's place in auto layout");
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -1923,6 +1969,7 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) {
|
||||
let mut groups: Vec<String> = value_groups(m, n)
|
||||
.into_iter()
|
||||
.filter(|g| !matches!(g.as_str(), "name" | "exports" | "interactions") && !from_variables.contains(&g.as_str()))
|
||||
.filter(|g| placed_source.is_some() || g != "resizing")
|
||||
.collect();
|
||||
let named = if placed_source.is_some() { n.name != m.name } else { n.name != default_instance_name(doc, component) };
|
||||
if named {
|
||||
@@ -1946,7 +1993,8 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) {
|
||||
}
|
||||
if let Some(n) = doc.node_mut(inst) {
|
||||
n.source = root_base;
|
||||
n.overrides.retain(|g| !matches!(g.as_str(), "component" | "props" | "position"));
|
||||
// A top-level instance's resizing is its own (a nested one's overrides its main layer's).
|
||||
n.overrides.retain(|g| !matches!(g.as_str(), "component" | "props" | "position") && (placed_source.is_some() || g != "resizing"));
|
||||
n.overrides.extend(root_groups);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,179 @@ fn text(n: &Node) -> &TextData {
|
||||
n.kind.text().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copying_a_main_component_pastes_a_linked_instance() {
|
||||
for command in ["edit.paste", "edit.pasteOver", "edit.pasteReplace"] {
|
||||
let mut s = Session::new();
|
||||
let k = kit(&mut s);
|
||||
ex(&mut s, "select.set", json!({"ids": [k.card]}));
|
||||
ex(&mut s, "edit.copy", json!({}));
|
||||
if command != "edit.paste" {
|
||||
ex(&mut s, "select.set", json!({"ids": [k.inst]}));
|
||||
}
|
||||
let before = undo_len(&s);
|
||||
let pasted = crate::params::id_of(&ex(&mut s, command, json!({}))["ids"][0]).unwrap();
|
||||
assert_eq!(component_of(&s, pasted), k.card, "{command}");
|
||||
assert_eq!(node(&s, pasted).parent, Some(s.page().unwrap()));
|
||||
assert_eq!(node(&s, pasted).source, Some(k.card));
|
||||
assert_eq!(undo_len(&s), before + 1);
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc().unwrap().node(pasted).is_none());
|
||||
ex(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(component_of(&s, pasted), k.card);
|
||||
ex(&mut s, "node.set", json!({"id": k.title, "props": {"characters": "Updated"}}));
|
||||
assert_eq!(chars(&s, copy_of(&s, pasted, k.title)), "Updated");
|
||||
healthy(&s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copying_a_variant_pastes_an_instance_but_copying_its_set_keeps_the_components() {
|
||||
let mut s = Session::new();
|
||||
let (set, _, hover) = button_set(&mut s);
|
||||
ex(&mut s, "select.set", json!({"ids": [hover]}));
|
||||
ex(&mut s, "edit.copy", json!({}));
|
||||
let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({}))["ids"][0]).unwrap();
|
||||
assert_eq!(component_of(&s, pasted), hover);
|
||||
assert_eq!(node(&s, pasted).name, node(&s, set).name);
|
||||
let NodeKind::Instance { props, .. } = &node(&s, pasted).kind else { panic!("pasted variant is not an instance") };
|
||||
assert_eq!(chars(&s, layer(&s, pasted, "Label").unwrap()), "Hover");
|
||||
assert_eq!(props["Show dot"], true);
|
||||
assert_eq!(node(&s, pasted).parent, Some(s.page().unwrap()));
|
||||
|
||||
ex(&mut s, "select.set", json!({"ids": [set]}));
|
||||
ex(&mut s, "edit.copy", json!({}));
|
||||
let copied_set = crate::params::id_of(&ex(&mut s, "edit.paste", json!({}))["ids"][0]).unwrap();
|
||||
assert!(matches!(node(&s, copied_set).kind, NodeKind::ComponentSet { .. }));
|
||||
assert_eq!(node(&s, copied_set).parent, Some(s.page().unwrap()));
|
||||
assert_eq!(kids(&s, copied_set).len(), 2);
|
||||
for variant in kids(&s, copied_set) {
|
||||
assert!(matches!(node(&s, variant).kind, NodeKind::Component { .. }));
|
||||
assert!(!kids(&s, set).contains(&variant));
|
||||
}
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_components_and_components_copied_to_another_file_remain_components() {
|
||||
for (copy, undo_cut, new_file) in [("edit.cut", false, false), ("edit.cut", true, false), ("edit.copy", false, true)] {
|
||||
let mut s = Session::new();
|
||||
let k = kit(&mut s);
|
||||
ex(&mut s, copy, json!({"ids": [k.card]}));
|
||||
if undo_cut {
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
}
|
||||
if new_file {
|
||||
ex(&mut s, "file.new", json!({}));
|
||||
}
|
||||
let page = s.page().unwrap();
|
||||
let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({"parent": page}))["ids"][0]).unwrap();
|
||||
assert!(matches!(node(&s, pasted).kind, NodeKind::Component { .. }), "{copy}, undo={undo_cut}, new_file={new_file}");
|
||||
assert_eq!(kids(&s, pasted).len(), 3);
|
||||
healthy(&s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copying_a_component_does_not_link_to_an_id_reused_after_undo() {
|
||||
let mut s = Session::new();
|
||||
let frame = idv(&ex(&mut s, "frame.create", json!({"width": 120, "height": 60, "name": "Copied main"})));
|
||||
let main = idv(&ex(&mut s, "component.create", json!({"ids": [frame]})));
|
||||
ex(&mut s, "edit.copy", json!({"ids": [main]}));
|
||||
s.undo().unwrap();
|
||||
s.undo().unwrap();
|
||||
let other = idv(&ex(&mut s, "frame.create", json!({"width": 300, "height": 200, "name": "Unrelated main"})));
|
||||
ex(&mut s, "component.create", json!({"ids": [other]}));
|
||||
assert_eq!(other, main, "undo makes the original id available again");
|
||||
let page = s.page().unwrap();
|
||||
let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({"parent": page}))["ids"][0]).unwrap();
|
||||
assert_eq!(node(&s, pasted).name, "Copied main");
|
||||
assert_eq!((node(&s, pasted).width, node(&s, pasted).height), (120.0, 60.0));
|
||||
assert!(matches!(node(&s, pasted).kind, NodeKind::Component { .. }));
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_component_instances_keep_the_copied_transform_in_the_target_frame() {
|
||||
let mut s = Session::new();
|
||||
let k = kit(&mut s);
|
||||
ex(&mut s, "node.set", json!({"id": k.card, "props": {"rotation": 30}}));
|
||||
let original = s.doc().unwrap().world_transform(k.card);
|
||||
let frame = idv(&ex(&mut s, "frame.create", json!({"x": -100, "y": -100, "width": 1000, "height": 1000})));
|
||||
ex(&mut s, "edit.copy", json!({"ids": [k.card]}));
|
||||
let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({"parent": frame}))["ids"][0]).unwrap();
|
||||
assert_eq!(component_of(&s, pasted), k.card);
|
||||
for (actual, expected) in s.doc().unwrap().world_transform(pasted).as_coeffs().into_iter().zip(original.as_coeffs()) {
|
||||
assert!((actual - expected).abs() < 1e-9);
|
||||
}
|
||||
assert_eq!(node(&s, pasted).parent, Some(frame));
|
||||
let before = s.doc().unwrap().clone();
|
||||
assert!(s.execute("edit.paste", &json!({"parent": k.card})).is_err());
|
||||
assert_eq!(s.doc().unwrap(), &before, "pasting into the main is refused atomically");
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicating_a_main_component_makes_a_linked_instance() {
|
||||
for rename in [false, true] {
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "pref.renameDuplicates", json!({"on": rename}));
|
||||
let k = kit(&mut s);
|
||||
let rect = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 300, "width": 50, "height": 50, "props": {"name": "Box"}})));
|
||||
let before = s.doc().unwrap().clone();
|
||||
let out = ex(&mut s, "edit.duplicate", json!({"ids": [k.card, rect]}));
|
||||
let ids: Vec<NodeId> = out["ids"].as_array().unwrap().iter().filter_map(crate::params::id_of).collect();
|
||||
assert_eq!(s.selection(), ids);
|
||||
let copy = *ids.iter().find(|i| matches!(node(&s, **i).kind, NodeKind::Instance { .. })).expect("the main's copy is an instance");
|
||||
assert_eq!(component_of(&s, copy), k.card);
|
||||
assert_eq!(node(&s, copy).name, "Card", "instances keep their default name (rename={rename})");
|
||||
assert_eq!(node(&s, copy).parent, Some(s.page().unwrap()));
|
||||
let (b, c) = (s.doc().unwrap().world_bounds(k.card).unwrap(), s.doc().unwrap().world_bounds(copy).unwrap());
|
||||
assert_eq!((c.x0, c.y0, c.width(), c.height()), (b.x1 + 100.0, b.y0, b.width(), b.height()), "a top-level frame's offset");
|
||||
let other = *ids.iter().find(|i| **i != copy).unwrap();
|
||||
assert!(matches!(node(&s, other).kind, NodeKind::Rectangle), "other layers are still copied");
|
||||
ex(&mut s, "node.set", json!({"id": k.title, "props": {"characters": "Updated"}}));
|
||||
assert_eq!(chars(&s, copy_of(&s, copy, k.title)), "Updated", "the instance follows its main");
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(*s.doc().unwrap(), before, "undo restores exactly");
|
||||
healthy(&s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_dragging_a_main_component_copies_an_instance() {
|
||||
use crate::tools::{PointerEvent, PointerKind};
|
||||
let mut s = Session::new();
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100, "name": "Tile"})));
|
||||
let main = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
|
||||
let ev = |kind, x| PointerEvent { alt: true, ..PointerEvent::at(kind, x, 50.0, 1.0) };
|
||||
s.pointer(&ev(PointerKind::Down, 50.0)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, 150.0)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, 250.0)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Up, 250.0)).unwrap();
|
||||
let [copy] = s.selection()[..] else { panic!("one copy selected") };
|
||||
assert_eq!(component_of(&s, copy), main);
|
||||
assert_eq!(s.doc().unwrap().world_bounds(copy).unwrap().x0, 200.0);
|
||||
assert_eq!(s.doc().unwrap().world_bounds(main).unwrap().x0, 0.0, "the main stays");
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicating_a_variant_adds_a_variant_to_its_set() {
|
||||
let mut s = Session::new();
|
||||
let (set, _, hover) = button_set(&mut s);
|
||||
let copy = crate::params::id_of(&ex(&mut s, "edit.duplicate", json!({"ids": [hover]}))["ids"][0]).unwrap();
|
||||
assert!(matches!(node(&s, copy).kind, NodeKind::Component { .. }));
|
||||
assert_eq!(node(&s, copy).parent, Some(set));
|
||||
assert_eq!(kids(&s, set).len(), 3);
|
||||
// A whole set duplicates as a new set of variants.
|
||||
let set2 = crate::params::id_of(&ex(&mut s, "edit.duplicate", json!({"ids": [set]}))["ids"][0]).unwrap();
|
||||
assert!(matches!(node(&s, set2).kind, NodeKind::ComponentSet { .. }));
|
||||
assert!(kids(&s, set2).iter().all(|v| matches!(node(&s, *v).kind, NodeKind::Component { .. })));
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overridable_properties_stay_through_main_edits_and_reset() {
|
||||
// (what, layer, group, override on the instance, the same property edited on the main, read,
|
||||
@@ -316,14 +489,6 @@ fn what_instances_cannot_override_is_refused() {
|
||||
("nudge a layer", "transform.nudge", json!({"ids": ["$"], "dx": 1, "dy": 0}), Layer::Bg, "position"),
|
||||
("rotate a layer", "node.set", json!({"id": "$", "props": {"rotation": 15}}), Layer::Bg, "position and rotation come with the layer's place"),
|
||||
("flip a layer", "object.flipH", json!({"ids": ["$"]}), Layer::Bg, "position"),
|
||||
(
|
||||
"resize a layer",
|
||||
"node.set",
|
||||
json!({"id": "$", "props": {"width": 50}}),
|
||||
Layer::Bg,
|
||||
"not among the overridable properties; the instance resizes",
|
||||
),
|
||||
("resize a text box", "node.set", json!({"id": "$", "props": {"width": 150}}), Layer::Title, "\"the bounds of any text layers\""),
|
||||
(
|
||||
"change constraints",
|
||||
"node.set",
|
||||
@@ -331,13 +496,6 @@ fn what_instances_cannot_override_is_refused() {
|
||||
Layer::Bg,
|
||||
"\"any constraints applied to the layers\"",
|
||||
),
|
||||
(
|
||||
"make a layer fill its auto layout",
|
||||
"node.set",
|
||||
json!({"id": "$", "props": {"sizingH": "fill"}}),
|
||||
Layer::Chip,
|
||||
"resizing comes with the layer's size",
|
||||
),
|
||||
(
|
||||
"take a layer out of auto layout",
|
||||
"node.set",
|
||||
|
||||
@@ -747,3 +747,29 @@ fn alt_dropping_a_library_component_swaps_to_it() {
|
||||
assert!(doc.node(glyph).unwrap().overrides.is_empty());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fill_set_on_a_bound_instance_layer_detaches_it_as_an_override() {
|
||||
// Without unbinding first: the explicit colour detaches the variable on the instance layer only,
|
||||
// and stays through main edits until the override is reset.
|
||||
let mut s = Session::new();
|
||||
let (chip, _) = bound_chip(&mut s);
|
||||
let i = crate::params::id_of(&ex(&mut s, "instance.create", json!({"component": chip, "x": 200, "y": 0}))["id"]).unwrap();
|
||||
let bg = find_path(s.doc().unwrap(), i, &["Bg"]).unwrap();
|
||||
ex(&mut s, "node.set", json!({"ids": [bg], "props": {"fill": "#ff00ff"}}));
|
||||
let n = s.doc().unwrap().node(bg).unwrap().clone();
|
||||
assert_eq!(n.fills[0].solid_color().unwrap().to_hex(), "FF00FF");
|
||||
assert!(!n.bound.contains_key("fill"));
|
||||
assert!(n.overrides.contains("fills"));
|
||||
let main_bg = find_path(s.doc().unwrap(), chip, &["Bg"]).unwrap();
|
||||
ex(&mut s, "node.set", json!({"ids": [main_bg], "props": {"height": 28}}));
|
||||
ex(&mut s, "variable.set", json!({"variable": "Chip", "value": "#445566"}));
|
||||
let doc = s.doc().unwrap();
|
||||
healthy(doc);
|
||||
assert_eq!(doc.node(bg).unwrap().fills[0].solid_color().unwrap().to_hex(), "FF00FF");
|
||||
assert!(doc.node(main_bg).unwrap().bound.contains_key("fill"), "the main keeps its binding");
|
||||
ex(&mut s, "component.resetOverride", json!({"id": bg, "group": "fills"}));
|
||||
let n = s.doc().unwrap().node(bg).unwrap();
|
||||
assert!(n.bound.contains_key("fill"));
|
||||
assert_eq!(n.fills[0].solid_color().unwrap().to_hex(), "445566");
|
||||
}
|
||||
@@ -67,8 +67,6 @@ fn overrides_survive_main_edits() {
|
||||
ex(&mut s, "node.set", json!({"id": ibg, "props": {"effects": [{"type": "dropShadow"}]}}));
|
||||
ex(&mut s, "text.set", json!({"id": ititle, "characters": "Overridden"}));
|
||||
ex(&mut s, "node.set", json!({"id": idot, "props": {"visible": false}}));
|
||||
// A layer inside an instance keeps its main's size (the instance itself resizes).
|
||||
assert!(s.execute("node.set", &json!({"id": idot, "props": {"width": 30}})).is_err());
|
||||
let ov = node(&s, ibg).overrides;
|
||||
for g in ["fills", "strokes", "effects", "name"] {
|
||||
assert!(ov.contains(g), "{g} in {ov:?}");
|
||||
|
||||
@@ -194,3 +194,26 @@ fn only_frames_on_the_page_or_in_a_section_become_sections() {
|
||||
assert!(err.contains("holds sections"), "{err}");
|
||||
assert!(matches!(kind(&s, sec), NodeKind::Section { .. }), "nothing changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapping_errors_name_the_command_that_was_run() {
|
||||
// Grouping, framing, auto layout, masks and booleans share one wrapping step; a refusal names
|
||||
// the command that asked, not `object.group`.
|
||||
let mut s = Session::new();
|
||||
let page = s.page().unwrap();
|
||||
for cmd in ["object.group", "object.frameSelection", "layout.add", "bool.union"] {
|
||||
let err = s.execute(cmd, &json!({"ids": [page]})).unwrap_err().to_string();
|
||||
assert!(err.contains(&format!("`{cmd}`")), "{cmd}: {err}");
|
||||
}
|
||||
let r = rect(&mut s, page, 0.0, 0.0);
|
||||
let err = s.execute("object.mask", &json!({"ids": [page, r]})).unwrap_err().to_string();
|
||||
assert!(err.contains("`object.mask`"), "{err}");
|
||||
let section = id(ex(&mut s, "node.create", json!({"type": "section", "x": 300, "y": 0, "width": 100, "height": 100})));
|
||||
ex(&mut s, "node.set", json!({"ids": [section], "props": {"fills": ["#FFFFFF"], "strokes": ["#000000"], "strokeWeight": 1}}));
|
||||
let err = s.execute("vector.outlineStroke", &json!({"ids": [section]})).unwrap_err().to_string();
|
||||
assert!(err.contains("`vector.outlineStroke`"), "{err}");
|
||||
let f = id(ex(&mut s, "frame.create", json!({"x": 100, "y": 0, "width": 100, "height": 100})));
|
||||
let inner = rect(&mut s, f, 10.0, 10.0);
|
||||
let err = s.execute("object.wrapInSection", &json!({"ids": [inner, r]})).unwrap_err().to_string();
|
||||
assert!(err.contains("`object.wrapInSection`"), "{err}");
|
||||
}
|
||||
@@ -202,15 +202,16 @@ fn an_edit_an_instance_refuses_shows_not_allowed_and_is_undone() {
|
||||
let before = s.doc().unwrap().world_bounds(inner).unwrap();
|
||||
assert_eq!((before.x0, before.y0, before.x1, before.y1), (450.0, 50.0, 550.0, 150.0));
|
||||
ex(&mut s, "select.set", json!({"ids": [inner]}));
|
||||
// Resizing it by its right edge: only the main component can.
|
||||
assert!(close(resize(hover(&mut s, (550.0, 100.0), NONE)), 0.0), "the handle still shows its arrow");
|
||||
// Resizing it by its right edge: a size of its own (an override).
|
||||
assert!(close(resize(hover(&mut s, (550.0, 100.0), NONE)), 0.0), "the handle shows its arrow");
|
||||
s.pointer(&ev(PointerKind::Down, (550.0, 100.0), NONE)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, (570.0, 100.0), NONE)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, (600.0, 100.0), NONE)).unwrap();
|
||||
assert_eq!(s.tool.overlay.cursor, Cursor::NotAllowed);
|
||||
assert_ne!(s.tool.overlay.cursor, Cursor::NotAllowed);
|
||||
s.pointer(&ev(PointerKind::Up, (600.0, 100.0), NONE)).unwrap();
|
||||
assert_eq!(s.doc().unwrap().world_bounds(inner).unwrap(), before, "the resize was undone");
|
||||
// Moving it: refused too.
|
||||
let before = s.doc().unwrap().world_bounds(inner).unwrap();
|
||||
assert_eq!((before.x0, before.x1), (450.0, 600.0), "the resize stays");
|
||||
// Moving it: only the main component can.
|
||||
s.pointer(&ev(PointerKind::Down, (500.0, 100.0), NONE)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, (510.0, 110.0), NONE)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, (520.0, 120.0), NONE)).unwrap();
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
//! Which URLs stored in a document may be handed to the browser or the system opener.
|
||||
//!
|
||||
//! A document can carry URLs that the app opens for whoever views it: a prototype's Open link
|
||||
//! action ([`crate::proto_run`]) and Dev Mode's dev resources ([`crate::dev_resources`]). Both end
|
||||
//! up at the platform's opener, so a document that someone else made chooses what that opener is
|
||||
//! asked to do. Only the schemes a browser would follow on a plain link are allowed:
|
||||
//!
|
||||
//! - `javascript:` runs script. In the web build the opener is `window.open`, which executes it in
|
||||
//! the app's own origin, so a saved document becomes stored XSS.
|
||||
//! - `file:` and `smb:` reach the file system; on Windows a UNC host also sends credentials.
|
||||
//! - Windows hands `ms-…:` schemes to application handlers, and macOS and Linux hand unknown
|
||||
//! schemes to whichever application registered them.
|
||||
//!
|
||||
//! The check is lexical and does no I/O. It is deliberately an allow-list: a deny-list of known
|
||||
//! dangerous schemes is outgrown by every new handler a platform adds.
|
||||
|
||||
/// The schemes a URL stored in a document may use.
|
||||
const WEB_SCHEMES: [&str; 3] = ["http", "https", "mailto"];
|
||||
|
||||
/// Whether `url`, stored in a document, may be opened.
|
||||
///
|
||||
/// Requires a scheme from [`WEB_SCHEMES`], compared ignoring ASCII case. A URL with no scheme is
|
||||
/// refused: the opener would resolve it against the app's own page on the web. Control characters
|
||||
/// are refused anywhere, because browsers ignore some of them inside a scheme (`java\tscript:`
|
||||
/// reaches a `javascript:` handler), and `http`/`https` must name a host.
|
||||
pub fn openable(url: &str) -> bool {
|
||||
let url = url.trim();
|
||||
if url.is_empty() || url.chars().any(char::is_control) {
|
||||
return false;
|
||||
}
|
||||
let Some((scheme, rest)) = url.split_once(':') else { return false };
|
||||
// An RFC 3986 scheme, so a stray colon in text cannot look like one.
|
||||
let mut chars = scheme.chars();
|
||||
if !chars.next().is_some_and(|c| c.is_ascii_alphabetic()) || !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) {
|
||||
return false;
|
||||
}
|
||||
if !WEB_SCHEMES.iter().any(|s| scheme.eq_ignore_ascii_case(s)) {
|
||||
return false;
|
||||
}
|
||||
if scheme.eq_ignore_ascii_case("mailto") {
|
||||
// `mailto:` names an address.
|
||||
return !rest.is_empty();
|
||||
}
|
||||
// `http` and `https` name a host, so the authority is required: `http:x` is not a web link,
|
||||
// and `http:///p` has no host.
|
||||
let Some(after) = rest.strip_prefix("//") else { return false };
|
||||
!after.is_empty() && !after.starts_with('/')
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! A URL stored in a document reaches the browser or the system opener, so only the schemes a
|
||||
//! browser follows on a plain link are allowed ([`crate::doc_urls`]).
|
||||
|
||||
use crate::doc_urls::openable;
|
||||
|
||||
#[test]
|
||||
fn web_links_are_opened() {
|
||||
for url in [
|
||||
"http://example.com",
|
||||
"https://example.com",
|
||||
"https://example.com/a/b?c=d#e",
|
||||
"HTTPS://EXAMPLE.COM",
|
||||
"HtTp://example.com",
|
||||
"mailto:[email protected]",
|
||||
"mailto:[email protected]?subject=hi",
|
||||
" https://example.com ",
|
||||
"https://user:[email protected]:8443/p",
|
||||
] {
|
||||
assert!(openable(url), "{url:?} should open");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_and_handler_schemes_are_refused() {
|
||||
for url in [
|
||||
// Runs in the app's own origin on the web build: a saved document would be stored XSS.
|
||||
"javascript:alert(1)",
|
||||
"JavaScript:alert(1)",
|
||||
"jAvAsCrIpT:alert(document.cookie)",
|
||||
"vbscript:msgbox(1)",
|
||||
"data:text/html,<script>alert(1)</script>",
|
||||
"blob:https://example.com/x",
|
||||
// Reach the file system; a UNC host also sends Windows credentials.
|
||||
"file:///etc/passwd",
|
||||
"file://attacker.example/share/x",
|
||||
"smb://attacker.example/share/x",
|
||||
"webdav://attacker.example/x",
|
||||
// Windows application handlers.
|
||||
"ms-msdt:/id PCWDiagnostic",
|
||||
"ms-officecmd:x",
|
||||
"search-ms:query=x",
|
||||
"shell:startup",
|
||||
// Anything else a platform may have registered.
|
||||
"tel:+1234",
|
||||
"sms:+1234",
|
||||
"ssh://host",
|
||||
"ftp://host/x",
|
||||
"figcraft://join?host=127.0.0.1",
|
||||
"zoommtg://zoom.us/join?x",
|
||||
"view-source:https://example.com",
|
||||
"jar:https://example.com/x!/y",
|
||||
] {
|
||||
assert!(!openable(url), "{url:?} must be refused");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scheme_is_required_and_control_characters_are_refused() {
|
||||
for url in [
|
||||
// No scheme: the web opener would resolve these against the app's own page.
|
||||
"",
|
||||
" ",
|
||||
"example.com",
|
||||
"/etc/passwd",
|
||||
"//example.com",
|
||||
"\\\\host\\share\\x",
|
||||
"about",
|
||||
// Browsers ignore some control characters inside a scheme, so a smuggled one must not
|
||||
// leave `javascript` looking like an unknown scheme that we then refuse by luck.
|
||||
"java\tscript:alert(1)",
|
||||
"java\nscript:alert(1)",
|
||||
"java\rscript:alert(1)",
|
||||
"javascript\u{0}:alert(1)",
|
||||
"https://example.com/\u{0}",
|
||||
// A colon in ordinary text is not a scheme.
|
||||
"see page 2: the diagram",
|
||||
"1https://example.com",
|
||||
// The allowed schemes still need their own part.
|
||||
"mailto:",
|
||||
"http:",
|
||||
"http:example.com",
|
||||
"http:///p",
|
||||
"https://",
|
||||
] {
|
||||
assert!(!openable(url), "{url:?} must be refused");
|
||||
}
|
||||
}
|
||||
@@ -157,3 +157,49 @@ fn an_alt_drag_into_auto_layout_sets_no_step() {
|
||||
cmd_d(&mut s);
|
||||
assert_eq!(s.doc().unwrap().node(f).unwrap().children.len(), n + 1, "Duplicate still adds one to the flow");
|
||||
}
|
||||
|
||||
/// Drag `from` by `by` with no modifier: moving the selection, not copying it.
|
||||
fn drag_move(s: &mut Session, from: (f64, f64), by: (f64, f64)) {
|
||||
let ev = |kind, (x, y): (f64, f64)| PointerEvent::at(kind, x, y, 1.0);
|
||||
s.pointer(&ev(PointerKind::Down, from)).unwrap();
|
||||
for k in 1..=4 {
|
||||
let t = f64::from(k) / 4.0;
|
||||
s.pointer(&ev(PointerKind::Move, (from.0 + by.0 * t, from.1 + by.1 * t))).unwrap();
|
||||
}
|
||||
s.pointer(&ev(PointerKind::Up, (from.0 + by.0, from.1 + by.1))).unwrap();
|
||||
}
|
||||
|
||||
/// The reported path (#567): Cmd+D, drag the copy with the mouse, Cmd+D again. The other tests
|
||||
/// here set the copy's position with `node.set` or Alt-drag it; dragging is how it is usually done,
|
||||
/// and the step is measured from where the copy ended up, whichever way it got there.
|
||||
#[test]
|
||||
fn duplicate_repeats_the_step_set_by_dragging_the_copy() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0.0, 0.0);
|
||||
ex(&mut s, "select.set", json!({"ids": [a]}));
|
||||
let c1 = cmd_d(&mut s);
|
||||
assert_eq!(at(&s, c1), (0.0, 0.0), "the first copy lands in place");
|
||||
|
||||
drag_move(&mut s, (50.0, 50.0), (150.0, 20.0));
|
||||
assert_eq!(at(&s, c1), (150.0, 20.0), "the drag moved the copy, it did not make another one");
|
||||
assert_eq!(s.selection(), vec![c1], "and left it selected");
|
||||
assert_eq!(at(&s, a), (0.0, 0.0), "the original stays");
|
||||
|
||||
let c2 = cmd_d(&mut s);
|
||||
assert_eq!(at(&s, c2), (300.0, 40.0), "the next one as far again");
|
||||
let c3 = cmd_d(&mut s);
|
||||
assert_eq!(at(&s, c3), (450.0, 60.0), "and again");
|
||||
}
|
||||
|
||||
/// And a step set with the arrow keys, which is how a small offset is usually made exact.
|
||||
#[test]
|
||||
fn duplicate_repeats_a_nudged_step() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0.0, 0.0);
|
||||
ex(&mut s, "select.set", json!({"ids": [a]}));
|
||||
let c1 = cmd_d(&mut s);
|
||||
ex(&mut s, "transform.nudge", json!({"dx": 40, "dy": 0}));
|
||||
assert_eq!(at(&s, c1), (40.0, 0.0));
|
||||
let c2 = cmd_d(&mut s);
|
||||
assert_eq!(at(&s, c2), (80.0, 0.0), "the nudge is the step");
|
||||
}
|
||||
@@ -51,6 +51,32 @@ fn effect_set_edits_noise_texture_and_glass() {
|
||||
assert!(s.execute("effect.set", &json!({"ids": [r], "index": 0, "effect": {"type": "glass", "depth": "deep"}})).is_err());
|
||||
}
|
||||
|
||||
/// A key an effect doesn't have is refused, naming what it takes: serde dropped it without a word,
|
||||
/// so a shadow given `offsetX`/`offsetY` (the names variable bindings use) kept the default offset.
|
||||
#[test]
|
||||
fn effects_refuse_keys_they_dont_have() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s);
|
||||
let shadow = json!({"type": "dropShadow", "color": "#00000040", "offsetX": 0, "offsetY": 1, "radius": 3});
|
||||
let err = s.execute("node.set", &json!({"ids": [r], "props": {"effects": [shadow]}})).unwrap_err().to_string();
|
||||
assert!(err.contains("effects[0]") && err.contains("offsetX, offsetY") && err.contains("offset"), "{err}");
|
||||
assert!(s.doc().unwrap().node(r).unwrap().effects.is_empty(), "nothing stored");
|
||||
let err = s.execute("effect.add", &json!({"ids": [r], "effect": {"type": "layerBlur", "radious": 8}})).unwrap_err().to_string();
|
||||
assert!(err.contains("radious"), "{err}");
|
||||
s.execute("effect.add", &json!({"ids": [r], "type": "dropShadow"})).unwrap();
|
||||
let err = s.execute("effect.set", &json!({"ids": [r], "index": 0, "effect": {"type": "dropShadow", "spred": 2}})).unwrap_err().to_string();
|
||||
assert!(err.contains("spred"), "{err}");
|
||||
// Every kind's own keys still go in, camelCase or snake_case, and so does what inspect reports.
|
||||
for ty in ["innerShadow", "layerBlur", "backgroundBlur", "noise", "texture", "glass"] {
|
||||
s.execute("effect.add", &json!({"ids": [r], "type": ty})).unwrap();
|
||||
}
|
||||
let reported = crate::inspect::node_json(s.doc().unwrap(), r, 0)["effects"].clone();
|
||||
s.execute("node.set", &json!({"ids": [r], "props": {"effects": reported}})).unwrap();
|
||||
let camel = json!({"type": "dropShadow", "offset": [0, 1], "showBehind": true, "visible": true});
|
||||
let snake = json!({"type": "innerShadow", "offset": [0, 1], "show_behind": false});
|
||||
s.execute("node.set", &json!({"ids": [r], "props": {"effects": [camel, snake]}})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaling_scales_grain_and_glass() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
//! Automation roots (#715): the folders the files an agent's commands read and write must lie in.
|
||||
//!
|
||||
//! `figcraft-cli mcp --automation-read-root DIR --automation-write-root DIR` and the desktop app
|
||||
//! started with the same flags run their work [`confined`] to [`Roots`]: while a scope is active on
|
||||
//! the thread, every file the engine reads must lie inside a read or a write root and every file it
|
||||
//! writes inside a write root; anything else is an ordinary error. The engine reaches the file
|
||||
//! system only through this module (`crates/engine/clippy.toml` denies `std::fs` and friends
|
||||
//! anywhere else), so every command, present or future, is covered. Without a scope nothing changes.
|
||||
//!
|
||||
//! A path is made absolute against the working directory and resolved before it is compared: its
|
||||
//! links are followed (`std::fs::canonicalize`), and for a file that doesn't exist yet its nearest
|
||||
//! existing folder is resolved and the rest must be plain names. The comparison is by whole
|
||||
//! components (`/work2` is not inside `/work`), and the I/O uses the resolved path, so what was
|
||||
//! checked is what is opened.
|
||||
#![allow(clippy::disallowed_methods)]
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{EngineError, Result};
|
||||
|
||||
/// What a command does with a file.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Access {
|
||||
Read,
|
||||
Write,
|
||||
}
|
||||
|
||||
/// The folders automation may read (read and write roots) and write (write roots), resolved.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Roots {
|
||||
read: Vec<PathBuf>,
|
||||
write: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl Roots {
|
||||
/// Roots from the folders given (relative ones against the working directory). Each must be an
|
||||
/// existing folder.
|
||||
pub fn new(read: &[String], write: &[String]) -> std::result::Result<Self, String> {
|
||||
let resolve = |flag: &str, dirs: &[String]| {
|
||||
dirs.iter()
|
||||
.map(|d| match std::fs::canonicalize(d) {
|
||||
Ok(p) if p.is_dir() => Ok(p),
|
||||
Ok(_) => Err(format!("{flag} {d}: not a folder")),
|
||||
Err(e) => Err(format!("{flag} {d}: {e}")),
|
||||
})
|
||||
.collect::<std::result::Result<Vec<_>, String>>()
|
||||
};
|
||||
Ok(Self { read: resolve("--automation-read-root", read)?, write: resolve("--automation-write-root", write)? })
|
||||
}
|
||||
|
||||
/// The resolved path to use for `access` to `path`, or why it is refused.
|
||||
pub fn check(&self, path: &Path, access: Access) -> std::result::Result<PathBuf, String> {
|
||||
let asked = path.display();
|
||||
let (roots, what) = match access {
|
||||
Access::Read => ([self.read.as_slice(), self.write.as_slice()].concat(), "read"),
|
||||
Access::Write => (self.write.clone(), "written"),
|
||||
};
|
||||
match resolve(path) {
|
||||
Ok(resolved) if roots.iter().any(|r| resolved.starts_with(r)) => return Ok(resolved),
|
||||
Ok(_) => {}
|
||||
// Why a path can't be resolved says what exists there: only told for paths inside a root.
|
||||
Err(why) if inside_as_far_as_it_exists(path, &roots) => return Err(format!("automation path rejected: {asked}: {why}")),
|
||||
Err(_) => {}
|
||||
}
|
||||
if roots.is_empty() {
|
||||
return Err(format!("automation path rejected: no folder may be {what} (no root for it was given): {asked}"));
|
||||
}
|
||||
let shown: Vec<String> = roots.iter().map(|r| shown(r)).collect();
|
||||
Err(format!("automation path rejected: {asked} is outside the folders that may be {what}: {}", shown.join(", ")))
|
||||
}
|
||||
|
||||
/// The folders as JSON, resolved: `{read: [every folder that may be read], write: [the write roots]}`.
|
||||
pub fn to_json(&self) -> serde_json::Value {
|
||||
let list = |v: &[PathBuf]| v.iter().map(|p| shown(p)).collect::<Vec<_>>();
|
||||
serde_json::json!({"read": list(&[self.read.as_slice(), self.write.as_slice()].concat()), "write": list(&self.write)})
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `path` lies in one of `roots` once made absolute with its `.` and `..` taken by name and
|
||||
/// its longest leading part that exists resolved, so links on the way are followed as the roots'
|
||||
/// were (`/tmp/work/x` is inside root `/private/tmp/work`, a link out of a root leads out of it).
|
||||
fn inside_as_far_as_it_exists(path: &Path, roots: &[PathBuf]) -> bool {
|
||||
let Ok(abs) = std::path::absolute(path) else { return false };
|
||||
let mut lexical = PathBuf::new();
|
||||
for c in abs.components() {
|
||||
match c {
|
||||
Component::ParentDir => {
|
||||
lexical.pop();
|
||||
}
|
||||
Component::CurDir => {}
|
||||
c => lexical.push(c),
|
||||
}
|
||||
}
|
||||
let mut rest: Vec<&OsStr> = Vec::new();
|
||||
let mut at = lexical.as_path();
|
||||
let resolved = loop {
|
||||
if let Ok(mut base) = std::fs::canonicalize(at) {
|
||||
base.extend(rest.iter().rev());
|
||||
break base;
|
||||
}
|
||||
let (Some(name), Some(parent)) = (at.file_name(), at.parent()) else { return false };
|
||||
rest.push(name);
|
||||
at = parent;
|
||||
};
|
||||
roots.iter().any(|r| resolved.starts_with(r))
|
||||
}
|
||||
|
||||
/// Whether `path` lies in the app's own folder `folder`, both resolved (links and `..` followed);
|
||||
/// false when either can't be resolved.
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
pub(crate) fn within_app_folder(path: &Path, folder: &Path) -> bool {
|
||||
matches!((std::fs::canonicalize(path), std::fs::canonicalize(folder)), (Ok(p), Ok(f)) if p.starts_with(&f))
|
||||
}
|
||||
|
||||
/// `p` without Windows' `\\?\` prefix, for messages.
|
||||
fn shown(p: &Path) -> String {
|
||||
let s = p.to_string_lossy();
|
||||
match s.strip_prefix(r"\\?\UNC\") {
|
||||
Some(unc) => format!(r"\\{unc}"),
|
||||
None => s.strip_prefix(r"\\?\").unwrap_or(&s).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `path` made absolute and resolved: links followed, and for a path that doesn't exist yet its
|
||||
/// nearest existing folder resolved with the rest appended (plain names only).
|
||||
fn resolve(path: &Path) -> std::result::Result<PathBuf, String> {
|
||||
if path.as_os_str().is_empty() {
|
||||
return Err("not a file path".into());
|
||||
}
|
||||
let abs = std::path::absolute(path).map_err(|e| e.to_string())?;
|
||||
let mut rest: Vec<OsString> = Vec::new();
|
||||
let mut at = abs.as_path();
|
||||
loop {
|
||||
match std::fs::canonicalize(at) {
|
||||
Ok(mut base) => {
|
||||
base.extend(rest.iter().rev());
|
||||
return Ok(base);
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
// Writing through a link that leads nowhere would create its target, wherever it is.
|
||||
if std::fs::symlink_metadata(at).is_ok() {
|
||||
return Err("a link that leads nowhere".into());
|
||||
}
|
||||
match at.components().next_back() {
|
||||
Some(Component::Normal(name)) => {
|
||||
plain_name(name)?;
|
||||
rest.push(name.to_os_string());
|
||||
}
|
||||
Some(Component::CurDir) => {}
|
||||
_ => return Err("`..` (or a drive) below a folder that doesn't exist".into()),
|
||||
}
|
||||
at = at.parent().ok_or("no existing folder")?;
|
||||
}
|
||||
Err(e) => return Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A name for a file or folder that doesn't exist yet: on Windows no device (`CON`, `NUL`, `COM1`…)
|
||||
/// and no alternate data stream (`a:b`).
|
||||
fn plain_name(name: &OsStr) -> std::result::Result<(), String> {
|
||||
if cfg!(windows) {
|
||||
let n = name.to_string_lossy();
|
||||
let stem = n.split('.').next().unwrap_or_default().trim_end().to_ascii_uppercase();
|
||||
let device = matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$")
|
||||
|| ((stem.starts_with("COM") || stem.starts_with("LPT"))
|
||||
&& stem.chars().count() == 4
|
||||
&& stem.chars().last().is_some_and(|c| c.is_ascii_digit() || matches!(c, '¹' | '²' | '³')));
|
||||
if device || n.contains(':') {
|
||||
return Err(format!("`{n}` is a device or stream name, not a file"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static ROOTS: RefCell<Option<Arc<Roots>>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Run `f` with this thread's file access confined to `roots` (unchanged when `None`).
|
||||
pub fn confined<R>(roots: Option<&Arc<Roots>>, f: impl FnOnce() -> R) -> R {
|
||||
match roots {
|
||||
Some(roots) => scoped(Some(roots.clone()), f),
|
||||
None => f(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f` unconfined: only for the app's own files, never for a path a command was given.
|
||||
// Only plugins read from disk use it, and the browser has no disk.
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
pub(crate) fn app_owned<R>(f: impl FnOnce() -> R) -> R {
|
||||
scoped(None, f)
|
||||
}
|
||||
|
||||
fn scoped<R>(roots: Option<Arc<Roots>>, f: impl FnOnce() -> R) -> R {
|
||||
/// Puts the outer scope's roots back, also when `f` panics.
|
||||
struct Restore(Option<Arc<Roots>>);
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
let outer = self.0.take();
|
||||
ROOTS.with(|r| *r.borrow_mut() = outer);
|
||||
}
|
||||
}
|
||||
let _restore = Restore(ROOTS.with(|r| std::mem::replace(&mut *r.borrow_mut(), roots)));
|
||||
f()
|
||||
}
|
||||
|
||||
/// The roots this thread's file access is confined to now, if any.
|
||||
pub fn current() -> Option<Arc<Roots>> {
|
||||
ROOTS.with(|r| r.borrow().clone())
|
||||
}
|
||||
|
||||
/// The path to use for `access` to `path`: resolved when confined, else as given.
|
||||
pub fn allow(path: impl AsRef<Path>, access: Access) -> Result<PathBuf> {
|
||||
let path = path.as_ref();
|
||||
match current() {
|
||||
Some(roots) => roots.check(path, access).map_err(EngineError::Other),
|
||||
None => Ok(path.to_path_buf()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `path` may be accessed so now.
|
||||
pub fn allowed(path: impl AsRef<Path>, access: Access) -> bool {
|
||||
allow(path, access).is_ok()
|
||||
}
|
||||
|
||||
fn io(path: &Path) -> impl Fn(std::io::Error) -> EngineError + '_ {
|
||||
move |e| EngineError::Other(format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// The bytes of file `path`.
|
||||
pub fn read(path: impl AsRef<Path>) -> Result<Vec<u8>> {
|
||||
let path = path.as_ref();
|
||||
std::fs::read(allow(path, Access::Read)?).map_err(io(path))
|
||||
}
|
||||
|
||||
/// The bytes of file `path`, refused when it isn't a file or is larger than `max`.
|
||||
pub fn read_capped(path: impl AsRef<Path>, max: u64) -> Result<Vec<u8>> {
|
||||
let path = path.as_ref();
|
||||
let p = allow(path, Access::Read)?;
|
||||
let meta = std::fs::metadata(&p).map_err(io(path))?;
|
||||
if !meta.is_file() || meta.len() > max {
|
||||
return Err(EngineError::Other(format!("{}: not a file of at most {max} bytes", path.display())));
|
||||
}
|
||||
std::fs::read(p).map_err(io(path))
|
||||
}
|
||||
|
||||
/// File `path` opened for reading.
|
||||
pub fn open(path: impl AsRef<Path>) -> Result<std::fs::File> {
|
||||
let path = path.as_ref();
|
||||
std::fs::File::open(allow(path, Access::Read)?).map_err(io(path))
|
||||
}
|
||||
|
||||
/// What the file system says about `path` (links followed).
|
||||
pub fn metadata(path: impl AsRef<Path>) -> Result<std::fs::Metadata> {
|
||||
let path = path.as_ref();
|
||||
std::fs::metadata(allow(path, Access::Read)?).map_err(io(path))
|
||||
}
|
||||
|
||||
/// Whether `path` is a folder (false when it may not be read).
|
||||
pub fn is_dir(path: impl AsRef<Path>) -> bool {
|
||||
metadata(path).is_ok_and(|m| m.is_dir())
|
||||
}
|
||||
|
||||
/// Whether `path` exists (false when it may not be read).
|
||||
pub fn exists(path: impl AsRef<Path>) -> bool {
|
||||
metadata(path).is_ok()
|
||||
}
|
||||
|
||||
/// The paths of the entries of folder `path`.
|
||||
pub fn read_dir(path: impl AsRef<Path>) -> Result<Vec<PathBuf>> {
|
||||
let path = path.as_ref();
|
||||
let dir = allow(path, Access::Read)?;
|
||||
Ok(std::fs::read_dir(&dir).map_err(io(path))?.flatten().map(|e| e.path()).collect())
|
||||
}
|
||||
|
||||
/// Write `bytes` to `path` through a uniquely named staging file and one atomic rename (see
|
||||
/// [`figcraft_format::write_file_atomic`]).
|
||||
pub fn write(path: impl AsRef<Path>, bytes: &[u8]) -> Result<()> {
|
||||
let path = path.as_ref();
|
||||
write_atomic(&allow(path, Access::Write)?, bytes).map_err(io(path))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
figcraft_format::write_file_atomic(path, bytes)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn write_atomic(_: &Path, _: &[u8]) -> std::io::Result<()> {
|
||||
Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "no file system in the browser"))
|
||||
}
|
||||
|
||||
/// Create folder `path` and the folders above it that are missing.
|
||||
pub fn create_dir_all(path: impl AsRef<Path>) -> Result<()> {
|
||||
let path = path.as_ref();
|
||||
std::fs::create_dir_all(allow(path, Access::Write)?).map_err(io(path))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "file_access_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,388 @@
|
||||
//! Automation roots: how paths are resolved and compared, and that commands run confined can't
|
||||
//! reach past them.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::{Access, Roots, allow, confined, current};
|
||||
use crate::Session;
|
||||
|
||||
/// A fresh folder for one test: `<tmp>/figcraft-roots-<name>-<pid>`, holding `root/`, `root2/`
|
||||
/// (a sibling whose name starts like the root's) and `outside/`.
|
||||
struct Fixture(PathBuf);
|
||||
|
||||
impl Fixture {
|
||||
fn new(name: &str) -> Self {
|
||||
let d = std::env::temp_dir().join(format!("figcraft-roots-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
for sub in ["root/sub", "root2", "outside"] {
|
||||
std::fs::create_dir_all(d.join(sub)).unwrap();
|
||||
}
|
||||
std::fs::write(d.join("root/in.txt"), b"in").unwrap();
|
||||
std::fs::write(d.join("outside/secret.txt"), b"secret").unwrap();
|
||||
Self(d)
|
||||
}
|
||||
|
||||
fn p(&self, rel: &str) -> String {
|
||||
self.0.join(rel).to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// The root as both read and write root.
|
||||
fn rw(&self) -> Roots {
|
||||
Roots::new(&[], &[self.p("root")]).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn symlink_dir(target: &Path, link: &Path) -> bool {
|
||||
std::os::unix::fs::symlink(target, link).is_ok()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn symlink_dir(target: &Path, link: &Path) -> bool {
|
||||
std::os::windows::fs::symlink_dir(target, link).is_ok()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn symlink_file(target: &Path, link: &Path) -> bool {
|
||||
std::os::unix::fs::symlink(target, link).is_ok()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn symlink_file(target: &Path, link: &Path) -> bool {
|
||||
std::os::windows::fs::symlink_file(target, link).is_ok()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inside_is_allowed_and_resolved_outside_is_refused() {
|
||||
let f = Fixture::new("inside");
|
||||
let r = f.rw();
|
||||
let got = r.check(Path::new(&f.p("root/in.txt")), Access::Read).unwrap();
|
||||
assert_eq!(got, std::fs::canonicalize(f.p("root/in.txt")).unwrap(), "the I/O uses the resolved path");
|
||||
assert!(r.check(Path::new(&f.p("root/new.png")), Access::Write).is_ok());
|
||||
let e = r.check(Path::new(&f.p("outside/secret.txt")), Access::Read).unwrap_err();
|
||||
assert!(e.starts_with("automation path rejected:") && e.contains("outside the folders that may be read"), "{e}");
|
||||
assert!(r.check(Path::new(&f.p("outside/new.png")), Access::Write).is_err());
|
||||
// The root itself is inside.
|
||||
assert!(r.check(Path::new(&f.p("root")), Access::Read).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sibling_whose_name_starts_like_the_root_is_outside() {
|
||||
let f = Fixture::new("prefix");
|
||||
let r = f.rw();
|
||||
for p in ["root2/x.png", "root2", "root.png"] {
|
||||
assert!(r.check(Path::new(&f.p(p)), Access::Write).is_err(), "{p}");
|
||||
assert!(r.check(Path::new(&f.p(p)), Access::Read).is_err(), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn traversal_is_resolved_before_comparing() {
|
||||
let f = Fixture::new("dots");
|
||||
let r = f.rw();
|
||||
assert!(r.check(Path::new(&f.p("root/sub/../../outside/secret.txt")), Access::Read).is_err());
|
||||
assert!(r.check(Path::new(&f.p("root/sub/../../outside/new.txt")), Access::Write).is_err());
|
||||
assert!(r.check(Path::new(&f.p("root/sub/../in.txt")), Access::Read).is_ok());
|
||||
assert!(r.check(Path::new(&f.p("root/./sub/../new.txt")), Access::Write).is_ok());
|
||||
// `..` below a folder that doesn't exist can't be resolved (on Windows it is taken lexically,
|
||||
// as the system does, and stays inside).
|
||||
let deep = f.p("root/missing/../../outside/x.txt");
|
||||
assert!(r.check(Path::new(&deep), Access::Write).is_err(), "{deep}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_that_dont_exist_yet_resolve_through_their_nearest_folder() {
|
||||
let f = Fixture::new("missing");
|
||||
let r = f.rw();
|
||||
let got = r.check(Path::new(&f.p("root/a/b/c.png")), Access::Write).unwrap();
|
||||
assert_eq!(got, std::fs::canonicalize(f.p("root")).unwrap().join("a").join("b").join("c.png"));
|
||||
assert!(r.check(Path::new(&f.p("outside/a/b/c.png")), Access::Write).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_paths_are_resolved_against_the_working_directory() {
|
||||
// `cargo test` runs in the crate's folder, which holds Cargo.toml.
|
||||
let cwd = std::env::current_dir().unwrap().to_string_lossy().into_owned();
|
||||
let here = Roots::new(std::slice::from_ref(&cwd), &[]).unwrap();
|
||||
assert_eq!(here.check(Path::new("Cargo.toml"), Access::Read).unwrap(), std::fs::canonicalize("Cargo.toml").unwrap());
|
||||
assert!(here.check(Path::new("src/../Cargo.toml"), Access::Read).is_ok());
|
||||
let f = Fixture::new("relative");
|
||||
assert!(f.rw().check(Path::new("Cargo.toml"), Access::Read).is_err());
|
||||
// Relative roots too.
|
||||
assert_eq!(Roots::new(&[".".into()], &[]).unwrap(), here);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_and_write_authority_are_separate() {
|
||||
let f = Fixture::new("modes");
|
||||
let read_only = Roots::new(&[f.p("root")], &[]).unwrap();
|
||||
assert!(read_only.check(Path::new(&f.p("root/in.txt")), Access::Read).is_ok());
|
||||
let e = read_only.check(Path::new(&f.p("root/out.png")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("no folder may be written"), "{e}");
|
||||
// A write root may be read too; a read root elsewhere doesn't make it writable.
|
||||
let both = Roots::new(&[f.p("outside")], &[f.p("root")]).unwrap();
|
||||
assert!(both.check(Path::new(&f.p("root/in.txt")), Access::Read).is_ok());
|
||||
assert!(both.check(Path::new(&f.p("outside/secret.txt")), Access::Read).is_ok());
|
||||
assert!(both.check(Path::new(&f.p("outside/new.txt")), Access::Write).is_err());
|
||||
// Several roots of each kind.
|
||||
let many = Roots::new(&[f.p("root"), f.p("root2")], &[f.p("root2"), f.p("outside")]).unwrap();
|
||||
assert!(many.check(Path::new(&f.p("outside/new.txt")), Access::Write).is_ok());
|
||||
assert!(many.check(Path::new(&f.p("root/new.txt")), Access::Write).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roots_must_be_existing_folders() {
|
||||
let f = Fixture::new("bad-roots");
|
||||
assert!(Roots::new(&[f.p("nope")], &[]).unwrap_err().contains("--automation-read-root"));
|
||||
assert!(Roots::new(&[], &[f.p("root/in.txt")]).unwrap_err().contains("not a folder"));
|
||||
assert!(Roots::new(&[], &[String::new()]).is_err());
|
||||
assert!(f.rw().check(Path::new(""), Access::Read).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_that_lead_out_of_a_root_are_outside() {
|
||||
let f = Fixture::new("links");
|
||||
let r = f.rw();
|
||||
if !symlink_dir(&f.0.join("outside"), &f.0.join("root/escape")) || !symlink_file(&f.0.join("outside/secret.txt"), &f.0.join("root/secret.txt")) {
|
||||
eprintln!("skipped: this system doesn't let tests make symbolic links");
|
||||
return;
|
||||
}
|
||||
assert!(r.check(Path::new(&f.p("root/escape/secret.txt")), Access::Read).is_err());
|
||||
assert!(r.check(Path::new(&f.p("root/escape/new.txt")), Access::Write).is_err());
|
||||
assert!(r.check(Path::new(&f.p("root/secret.txt")), Access::Read).is_err());
|
||||
assert!(r.check(Path::new(&f.p("root/secret.txt")), Access::Write).is_err());
|
||||
// A link that leads nowhere: writing through it would create its target.
|
||||
assert!(symlink_file(&f.0.join("outside/not-yet.txt"), &f.0.join("root/dangling.txt")));
|
||||
let e = r.check(Path::new(&f.p("root/dangling.txt")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("leads nowhere"), "{e}");
|
||||
// A link inside the root to the root is fine.
|
||||
assert!(symlink_dir(&f.0.join("root/sub"), &f.0.join("root/alias")));
|
||||
assert!(r.check(Path::new(&f.p("root/alias/x.png")), Access::Write).is_ok());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_case_devices_and_streams() {
|
||||
let f = Fixture::new("windows");
|
||||
let r = f.rw();
|
||||
// Case is taken from the disk: another spelling of the root is the root.
|
||||
let upper = f.0.join("ROOT").join("IN.TXT");
|
||||
assert!(r.check(&upper, Access::Read).is_ok());
|
||||
for name in ["CON", "nul.txt", "Com1.png", "LPT9", "a.txt:stream", "conout$"] {
|
||||
assert!(r.check(&f.0.join("root").join(name), Access::Write).is_err(), "{name}");
|
||||
}
|
||||
assert!(r.check(&f.0.join("root").join("console.png"), Access::Write).is_ok());
|
||||
// The `\\?\` form of a path inside is inside; of a path outside, outside.
|
||||
let verbatim = std::fs::canonicalize(f.p("root")).unwrap().join("v.png");
|
||||
assert!(verbatim.to_string_lossy().starts_with(r"\\?\"));
|
||||
assert!(r.check(&verbatim, Access::Write).is_ok());
|
||||
assert!(r.check(&std::fs::canonicalize(f.p("outside")).unwrap().join("v.png"), Access::Write).is_err());
|
||||
// Messages don't show the prefix.
|
||||
let e = r.check(Path::new(&f.p("outside/x.png")), Access::Write).unwrap_err();
|
||||
assert!(!e.contains(r"\\?\"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scope_confines_the_thread_and_ends_with_it() {
|
||||
let f = Fixture::new("scope");
|
||||
let roots = Arc::new(f.rw());
|
||||
assert!(current().is_none());
|
||||
confined(Some(&roots), || {
|
||||
assert!(allow(f.p("outside/secret.txt"), Access::Read).is_err());
|
||||
assert!(super::read(f.p("outside/secret.txt")).is_err());
|
||||
assert_eq!(super::read(f.p("root/in.txt")).unwrap(), b"in");
|
||||
// Not confined: the app's own files.
|
||||
assert_eq!(super::app_owned(|| super::read(f.p("outside/secret.txt")).unwrap()), b"secret");
|
||||
assert!(current().is_some(), "back in the scope after app_owned");
|
||||
// Another thread isn't confined by this one's scope.
|
||||
let p = f.p("outside/secret.txt");
|
||||
assert!(std::thread::spawn(move || current().is_none() && allow(p, Access::Read).is_ok()).join().unwrap());
|
||||
});
|
||||
assert!(current().is_none());
|
||||
assert!(allow(f.p("outside/secret.txt"), Access::Read).is_ok());
|
||||
// A panic inside the scope still ends it.
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| confined(Some(&roots), || panic!("boom"))));
|
||||
assert!(r.is_err());
|
||||
assert!(current().is_none());
|
||||
// `None` changes nothing.
|
||||
assert!(confined(None, || current().is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_run_confined_read_and_write_only_inside() {
|
||||
let f = Fixture::new("commands");
|
||||
std::fs::create_dir_all(f.0.join("outside/readable")).unwrap();
|
||||
let roots = Arc::new(Roots::new(&[f.p("outside/readable")], &[f.p("root")]).unwrap());
|
||||
// Unconfined, the outside is writable: the bait files a confined command must not reach.
|
||||
let bait = f.p("outside/bait.figcraft");
|
||||
let mut s = Session::new();
|
||||
s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 20, "height": 10})).unwrap();
|
||||
s.execute("file.saveAs", &json!({"path": bait})).unwrap();
|
||||
std::fs::copy(&bait, f.0.join("outside/readable/ok.figcraft")).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 20, "height": 10})).unwrap();
|
||||
s.prefs.recent_files = vec![bait.clone(), f.p("root/in.txt")];
|
||||
confined(Some(&roots), || {
|
||||
let e = s.execute("file.export", &json!({"format": "png", "path": f.p("outside/x.png")})).unwrap_err().to_string();
|
||||
assert!(e.contains("automation path rejected"), "{e}");
|
||||
assert!(s.execute("file.export", &json!({"format": "png", "path": f.p("root/x.png")})).is_ok());
|
||||
assert!(s.execute("file.exportLayers", &json!({"dir": f.p("outside/layers")})).is_err());
|
||||
assert!(s.execute("file.open", &json!({"path": bait})).is_err());
|
||||
assert!(s.execute("file.import", &json!({"path": bait})).is_err());
|
||||
assert!(s.execute("file.placeImage", &json!({"path": f.p("outside/secret.txt")})).is_err());
|
||||
assert!(s.execute("file.open", &json!({"path": f.p("outside/readable/ok.figcraft")})).is_ok(), "read roots may be read");
|
||||
assert!(s.execute("file.save", &json!({})).is_err(), "but not written");
|
||||
assert!(s.execute("file.saveAs", &json!({"path": f.p("root/ok.figcraft")})).is_ok());
|
||||
// Recent files outside the roots aren't listed.
|
||||
let recent = s.execute("file.openRecent", &json!({})).unwrap().to_string();
|
||||
assert!(!recent.contains("bait") && recent.contains("in.txt"), "{recent}");
|
||||
assert!(s.execute("file.openRecent", &json!({"path": bait})).is_err());
|
||||
});
|
||||
assert!(f.0.join("root/x.png").is_file() && !f.0.join("outside/x.png").exists() && !f.0.join("outside/layers").exists());
|
||||
}
|
||||
|
||||
/// Every command, given paths outside the roots in its usual path parameters, writes nothing
|
||||
/// there and opens nothing from there: they all reach files through `file_access`.
|
||||
#[test]
|
||||
fn no_command_reaches_outside_the_roots() {
|
||||
let f = Fixture::new("sweep");
|
||||
let roots = Arc::new(f.rw());
|
||||
let outside = f.0.join("outside");
|
||||
// Bait the sweep could pick up: a document, an SVG, a PNG and variables JSON, each holding a
|
||||
// name that must never show up in a document or a result.
|
||||
let mut s = Session::new();
|
||||
s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 20, "height": 10, "name": "BAIT-LAYER"})).unwrap();
|
||||
s.execute("file.saveAs", &json!({"path": f.p("outside/bait.figcraft")})).unwrap();
|
||||
s.execute("file.export", &json!({"format": "png", "path": f.p("outside/bait.png")})).unwrap();
|
||||
std::fs::write(
|
||||
outside.join("bait.svg"),
|
||||
r#"<svg xmlns="http://www.w3.org/2000/svg" width="9" height="9"><rect id="BAIT-LAYER" width="9" height="9"/></svg>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let before: Vec<_> = std::fs::read_dir(&outside).unwrap().flatten().map(|e| e.file_name()).collect();
|
||||
// The bait is what an unconfined command finds.
|
||||
for (cmd, bait) in [("file.open", "bait.figcraft"), ("file.import", "bait.svg")] {
|
||||
let mut s = Session::new();
|
||||
s.execute(cmd, &json!({"path": f.p(&format!("outside/{bait}"))})).unwrap();
|
||||
assert!(crate::inspect::document(&s, 64, true).to_string().contains("BAIT-LAYER"), "{cmd} {bait}");
|
||||
}
|
||||
let mut s = Session::new();
|
||||
s.execute("file.placeImage", &json!({"path": f.p("outside/bait.png")})).unwrap();
|
||||
assert!(!s.doc().unwrap().images.is_empty());
|
||||
for bait in ["bait.figcraft", "bait.svg", "bait.png", "new.figcraft", "new.png"] {
|
||||
let path = f.p(&format!("outside/{bait}"));
|
||||
let params = json!({"path": path, "dir": f.p("outside"), "file": path, "variables": path, "folder": f.p("outside"), "confirm": true});
|
||||
for c in crate::all_commands() {
|
||||
let mut s = Session::new();
|
||||
let r = confined(Some(&roots), || s.execute(c.id, ¶ms));
|
||||
let shown = format!("{r:?}");
|
||||
assert!(!shown.contains("BAIT-LAYER"), "{} read {bait}: {shown}", c.id);
|
||||
let docs = crate::inspect::document(&s, 64, true).to_string();
|
||||
assert!(!docs.contains("BAIT-LAYER"), "{} opened {bait}", c.id);
|
||||
assert!(s.docs.iter().all(|ds| ds.doc.images.is_empty()), "{} placed {bait}", c.id);
|
||||
let now: Vec<_> = std::fs::read_dir(&outside).unwrap().flatten().map(|e| e.file_name()).collect();
|
||||
assert_eq!(now, before, "{} wrote outside the roots with {bait}", c.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_files_outside_the_roots_are_never_opened_removed_or_named() {
|
||||
let f = Fixture::new("recents");
|
||||
let roots = Arc::new(f.rw());
|
||||
let (bait, gone, ok) = (f.p("outside/bait.figcraft"), f.p("outside/gone.figcraft"), f.p("root/ok.figcraft"));
|
||||
let mut s = Session::new();
|
||||
s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 20, "height": 10})).unwrap();
|
||||
s.execute("file.saveAs", &json!({"path": bait})).unwrap();
|
||||
s.execute("file.saveAs", &json!({"path": ok})).unwrap();
|
||||
let mut s = Session::new();
|
||||
let recents = vec![bait.clone(), gone.clone(), ok.clone()];
|
||||
s.prefs.recent_files = recents.clone();
|
||||
let quiet = |r: &crate::Result<serde_json::Value>| {
|
||||
let shown = format!("{r:?}");
|
||||
assert!(!shown.contains("bait") && !shown.contains("gone"), "an outside recent file was named: {shown}");
|
||||
};
|
||||
confined(Some(&roots), || {
|
||||
let listing = s.execute("file.openRecent", &json!({}));
|
||||
quiet(&listing);
|
||||
assert_eq!(listing.unwrap()["files"].as_array().unwrap().len(), 1);
|
||||
// `index` counts the files listed: 0 is the first one inside the roots.
|
||||
let opened = s.execute("file.openRecent", &json!({"index": 0}));
|
||||
quiet(&opened);
|
||||
assert!(opened.is_ok(), "{opened:?}");
|
||||
assert_eq!(s.ds().unwrap().path.as_deref(), Some(ok.as_str()));
|
||||
for i in [1, 2] {
|
||||
let r = s.execute("file.openRecent", &json!({"index": i}));
|
||||
quiet(&r);
|
||||
assert!(r.is_err());
|
||||
}
|
||||
// A path outside is refused like any outside path, and never dropped from the list.
|
||||
let e = s.execute("file.openRecent", &json!({"path": gone})).unwrap_err().to_string();
|
||||
assert!(e.contains("is outside the folders that may be read") && !e.contains("no longer exists"), "{e}");
|
||||
quiet(&s.execute("file.openRecent", &json!({"remove": bait})));
|
||||
assert!(s.prefs.recent_files.contains(&bait) && s.prefs.recent_files.contains(&gone));
|
||||
// Clear clears what is listed.
|
||||
quiet(&s.execute("file.openRecent", &json!({"clear": true})));
|
||||
});
|
||||
assert_eq!(s.prefs.recent_files, vec![bait, gone]);
|
||||
// Unconfined, a recent file that is gone is still dropped.
|
||||
s.execute("file.openRecent", &json!({"index": 1})).unwrap_err();
|
||||
assert_eq!(s.prefs.recent_files.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outside_paths_are_refused_alike_whatever_exists_there() {
|
||||
let f = Fixture::new("oracle");
|
||||
let r = f.rw();
|
||||
let refusal = |p: &str| r.check(Path::new(p), Access::Write).unwrap_err();
|
||||
let standard = |p: &str| format!("automation path rejected: {} is outside the folders that may be written:", Path::new(p).display());
|
||||
let mut outside = vec![f.p("outside/secret.txt"), f.p("outside/none.txt"), f.p("outside/missing/../x.txt"), f.p("nowhere/deeper/x.txt")];
|
||||
if symlink_file(&f.0.join("outside/not-yet.txt"), &f.0.join("outside/dangling.txt")) {
|
||||
outside.push(f.p("outside/dangling.txt"));
|
||||
// Reached through a link in the root, it is outside all the same.
|
||||
if symlink_dir(&f.0.join("outside"), &f.0.join("root/escape")) {
|
||||
outside.push(f.p("root/escape/dangling.txt"));
|
||||
}
|
||||
}
|
||||
for p in &outside {
|
||||
let e = refusal(p);
|
||||
assert!(e.starts_with(&standard(p)), "{p}: {e}");
|
||||
}
|
||||
// Inside a root the reason is given.
|
||||
if symlink_file(&f.0.join("outside/not-yet.txt"), &f.0.join("root/dangling.txt")) {
|
||||
assert!(refusal(&f.p("root/dangling.txt")).contains("leads nowhere"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_root_given_through_a_link_tells_why_paths_in_it_are_refused() {
|
||||
let f = Fixture::new("linked-root");
|
||||
if !symlink_dir(&f.0.join("root"), &f.0.join("via")) || !symlink_file(&f.0.join("outside/not-yet.txt"), &f.0.join("root/dangling.txt")) {
|
||||
eprintln!("skipped: this system doesn't let tests make symbolic links");
|
||||
return;
|
||||
}
|
||||
// Like `/tmp/work` on macOS, where `/tmp` leads to `/private/tmp`.
|
||||
let r = Roots::new(&[], &[f.p("via")]).unwrap();
|
||||
let e = r.check(Path::new(&f.p("via/dangling.txt")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("leads nowhere"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_resolved_paths_count_as_the_apps_own_folder() {
|
||||
let f = Fixture::new("own");
|
||||
let install = f.0.join("root");
|
||||
assert!(super::within_app_folder(&install.join("in.txt"), &install));
|
||||
assert!(!super::within_app_folder(&install.join("..").join("outside").join("secret.txt"), &install), "`..` leaves it");
|
||||
assert!(!super::within_app_folder(&f.0.join("root2"), &install), "a folder named like it is another");
|
||||
assert!(!super::within_app_folder(&install.join("missing.wasm"), &install));
|
||||
assert!(!super::within_app_folder(&install.join("in.txt"), &f.0.join("no-such-folder")));
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//! Fonts a document names that this machine doesn't have (#752): text layers, their styled
|
||||
//! ranges, text styles and the string variables bound to a font family. Text in such a family
|
||||
//! is drawn in a fallback font, so the user is told which ones are missing. Once such a font is
|
||||
//! there, [`refit`] lets auto-sizing text measure its box in it again.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use figcraft_doc::{Document, NodeId, NodeKind, StyleKind, VariableValue, Variables};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cmd::CommandSpec;
|
||||
use crate::params::{get, req_targets};
|
||||
use crate::{Result, Session, spec};
|
||||
|
||||
/// A family the document names that no installed or built-in font provides.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Missing {
|
||||
/// As the document spells it.
|
||||
pub family: String,
|
||||
/// Text layers that use it (0: only a text style or a variable names it).
|
||||
pub layers: usize,
|
||||
}
|
||||
|
||||
/// The families `doc` names that the font database doesn't have, by name. On the desktop the
|
||||
/// installed fonts are indexed in the background: ask once that's done
|
||||
/// (`figcraft_text::installed_fonts_known`), or every installed family counts as missing.
|
||||
pub fn missing(doc: &Document) -> Vec<Missing> {
|
||||
// Lower-case family → (spelling, layers).
|
||||
let mut used: BTreeMap<String, (String, usize)> = BTreeMap::new();
|
||||
let mut add = |family: &str, layer: bool| {
|
||||
let family = family.trim();
|
||||
if family.is_empty() {
|
||||
return;
|
||||
}
|
||||
let e = used.entry(family.to_lowercase()).or_insert_with(|| (family.to_string(), 0));
|
||||
e.1 += usize::from(layer);
|
||||
};
|
||||
for n in doc.nodes() {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
let mut families: Vec<&str> = t.spans().into_iter().map(|(_, _, st)| st.font_family.as_str()).collect();
|
||||
families.push(&t.style.font_family);
|
||||
families.sort_by_key(|f| f.trim().to_lowercase());
|
||||
families.dedup_by_key(|f| f.trim().to_lowercase());
|
||||
for f in families {
|
||||
add(f, true);
|
||||
}
|
||||
}
|
||||
if let Some(var) = n.bound.get("fontFamily") {
|
||||
for f in string_values(&doc.variables, var) {
|
||||
add(&f, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
for st in &doc.styles {
|
||||
if let StyleKind::Text { style } = &st.kind {
|
||||
add(&style.font_family, false);
|
||||
}
|
||||
if let Some(var) = st.bound.get("fontFamily") {
|
||||
for f in string_values(&doc.variables, var) {
|
||||
add(&f, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
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).
|
||||
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 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)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Auto-sizing text layers measure their boxes again: a box measured while its family was missing
|
||||
/// (in a fallback font) follows the real face once that's installed or loaded. Only layers under
|
||||
/// `under` (every layer: `None`) that use one of `families` (any family: `None`). Returns the
|
||||
/// layers whose size changed.
|
||||
pub fn refit(doc: &mut Document, under: Option<&[NodeId]>, families: Option<&[String]>) -> Vec<NodeId> {
|
||||
let want: Option<BTreeSet<String>> = families.map(|f| f.iter().map(|x| x.trim().to_lowercase()).collect());
|
||||
let scope: Option<BTreeSet<NodeId>> = under.map(|ids| ids.iter().flat_map(|i| doc.subtree(*i)).collect());
|
||||
let ids: Vec<NodeId> = doc
|
||||
.nodes()
|
||||
.filter(|n| scope.as_ref().is_none_or(|s| s.contains(&n.id)))
|
||||
.filter(|n| {
|
||||
let NodeKind::Text(t) = &n.kind else { return false };
|
||||
let uses = |w: &BTreeSet<String>| {
|
||||
w.contains(&t.style.font_family.trim().to_lowercase())
|
||||
|| t.spans().iter().any(|(_, _, st)| w.contains(&st.font_family.trim().to_lowercase()))
|
||||
};
|
||||
want.as_ref().is_none_or(uses)
|
||||
&& figcraft_text::auto_size(n).is_some_and(|(w, h)| (w - n.width).abs() > 1e-6 || (h - n.height).abs() > 1e-6)
|
||||
})
|
||||
.map(|n| n.id)
|
||||
.collect();
|
||||
if !ids.is_empty() {
|
||||
let before = doc.clone();
|
||||
doc.mark_dirty(ids.iter().copied());
|
||||
crate::normalize(doc, &before);
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
/// [`refit`] in the open document, if there is one.
|
||||
pub(crate) fn refit_open(s: &mut Session, under: Option<&[NodeId]>, families: Option<&[String]>) -> Vec<NodeId> {
|
||||
match s.doc_mut() {
|
||||
Ok(doc) => refit(doc, under, families),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `text.refit`: the layers `ids` names (an `ids` that isn't a list of layers is refused), else the
|
||||
/// whole document.
|
||||
fn refit_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let under = match get(p, "ids") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(_) => Some(req_targets("text.refit", s, p)?),
|
||||
};
|
||||
s.doc()?;
|
||||
Ok(json!({"refitted": refit_open(s, under.as_deref(), None)}))
|
||||
}
|
||||
|
||||
fn missing_fonts(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let families: Vec<Value> = missing(s.doc()?).into_iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect();
|
||||
s.request_ui("dialog.missingFonts", json!({}));
|
||||
Ok(json!({"families": families}))
|
||||
}
|
||||
|
||||
/// The font commands: `text.missingFonts` and `text.refit`.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!(
|
||||
"text.missingFonts",
|
||||
"Missing fonts",
|
||||
"{} fonts the open document names that aren't installed (text layers, styled ranges, text styles, font family variables); the app shows them → {families: [{family, layers}]}",
|
||||
false,
|
||||
missing_fonts
|
||||
),
|
||||
spec!(
|
||||
"text.refit",
|
||||
"Fit text to fonts",
|
||||
"{ids? (these layers and the text in them; default: the whole document)} auto-sizing text measures its box again, e.g. a box measured in a fallback font while its family was missing → {refitted: [ids whose size changed]}",
|
||||
true,
|
||||
refit_text
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "fonts_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,215 @@
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::params::id_of;
|
||||
use figcraft_doc::{Style, StyleRun, TextStyle};
|
||||
|
||||
fn text(s: &mut Session, family: &str) -> figcraft_doc::NodeId {
|
||||
let id = id_of(&s.execute("text.create", &json!({"text": "Hamburgefonstiv", "x": 0, "y": 0})).unwrap()["id"]).unwrap();
|
||||
if let NodeKind::Text(t) = &mut s.doc_mut().unwrap().get_mut(id).unwrap().kind {
|
||||
t.style.font_family = family.into();
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
fn families(s: &mut Session) -> Vec<(String, u64)> {
|
||||
let r = s.execute("text.missingFonts", &json!({})).unwrap();
|
||||
r["families"].as_array().unwrap().iter().map(|f| (f["family"].as_str().unwrap().to_string(), f["layers"].as_u64().unwrap())).collect()
|
||||
}
|
||||
|
||||
/// An installed family (built in, or found by the system scan), if this machine has any.
|
||||
fn installed() -> Option<String> {
|
||||
figcraft_text::scan_system_fonts();
|
||||
figcraft_text::family_names().into_iter().next()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_families_of_layers_ranges_styles_and_variables_once_each() {
|
||||
let mut s = Session::new();
|
||||
text(&mut s, "FigCraft Missing A");
|
||||
let b = text(&mut s, "figcraft missing a");
|
||||
if let NodeKind::Text(t) = &mut s.doc_mut().unwrap().get_mut(b).unwrap().kind {
|
||||
let style = TextStyle { font_family: "FigCraft Missing B".into(), ..t.style.clone() };
|
||||
t.runs.push(StyleRun { start: 0, end: 3, style });
|
||||
}
|
||||
let doc = s.doc_mut().unwrap();
|
||||
doc.styles.push(Style {
|
||||
id: "S:1".into(),
|
||||
name: "Heading".into(),
|
||||
description: String::new(),
|
||||
kind: StyleKind::Text { style: TextStyle { font_family: "FigCraft Missing C".into(), ..TextStyle::default() } },
|
||||
bound: Default::default(),
|
||||
});
|
||||
s.execute("variable.createCollection", &json!({"name": "Type", "modes": ["Light", "Dark"]})).unwrap();
|
||||
s.execute("variable.create", &json!({"collection": "Type", "name": "Family", "type": "string", "value": "FigCraft Missing D"})).unwrap();
|
||||
s.execute("variable.set", &json!({"variable": "Family", "mode": "Dark", "value": "FigCraft Missing H"})).unwrap();
|
||||
let var = s.doc().unwrap().variables.get("Family").unwrap().id.clone();
|
||||
s.doc_mut().unwrap().get_mut(b).unwrap().bound.insert("fontFamily".into(), var);
|
||||
|
||||
assert_eq!(
|
||||
families(&mut s),
|
||||
vec![
|
||||
("FigCraft Missing A".to_string(), 2),
|
||||
("FigCraft Missing B".to_string(), 1),
|
||||
("FigCraft Missing C".to_string(), 0),
|
||||
("FigCraft Missing D".to_string(), 0),
|
||||
("FigCraft Missing H".to_string(), 0),
|
||||
]
|
||||
);
|
||||
assert!(s.ui_requests.iter().any(|r| r.kind == "dialog.missingFonts"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_families_are_not_listed() {
|
||||
let Some(family) = installed() else {
|
||||
eprintln!("skipped: no fonts on this machine");
|
||||
return;
|
||||
};
|
||||
let mut s = Session::new();
|
||||
text(&mut s, &family);
|
||||
text(&mut s, &family.to_uppercase());
|
||||
text(&mut s, "FigCraft Missing E");
|
||||
assert_eq!(families(&mut s), vec![("FigCraft Missing E".to_string(), 1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_family_found_only_by_the_system_scan_is_not_missing() {
|
||||
figcraft_text::scan_system_fonts();
|
||||
let embedded: Vec<String> = figcraft_text::CRAFT_FONTS.iter().map(|f| f.family.to_lowercase()).collect();
|
||||
let Some(family) = figcraft_text::family_names().into_iter().find(|f| !embedded.contains(&f.to_lowercase())) else {
|
||||
eprintln!("skipped: no system fonts on this machine");
|
||||
return;
|
||||
};
|
||||
let mut s = Session::new();
|
||||
text(&mut s, &family);
|
||||
assert!(families(&mut s).is_empty(), "{family} is installed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_families_and_aliases_are_handled() {
|
||||
let mut s = Session::new();
|
||||
text(&mut s, " ");
|
||||
s.execute("variable.createCollection", &json!({"name": "Type"})).unwrap();
|
||||
s.execute("variable.create", &json!({"collection": "Type", "name": "Base", "type": "string", "value": "FigCraft Missing F"})).unwrap();
|
||||
let base = s.doc().unwrap().variables.get("Base").unwrap().id.clone();
|
||||
let t = text(&mut s, "FigCraft Missing G");
|
||||
let doc = s.doc_mut().unwrap();
|
||||
// A variable that only aliases another, and one aliasing itself.
|
||||
let mut alias = doc.variables.get(&base).unwrap().clone();
|
||||
alias.id = "V:alias".into();
|
||||
alias.name = "Alias".into();
|
||||
for v in alias.values.values_mut() {
|
||||
*v = VariableValue::Alias(base.clone());
|
||||
}
|
||||
let mut cycle = alias.clone();
|
||||
cycle.id = "V:cycle".into();
|
||||
cycle.name = "Cycle".into();
|
||||
for v in cycle.values.values_mut() {
|
||||
*v = VariableValue::Alias("V:cycle".into());
|
||||
}
|
||||
doc.variables.variables.push(alias);
|
||||
doc.variables.variables.push(cycle);
|
||||
doc.get_mut(t).unwrap().bound.insert("fontFamily".into(), "V:alias".into());
|
||||
let other = text(&mut s, "FigCraft Missing G");
|
||||
s.doc_mut().unwrap().get_mut(other).unwrap().bound.insert("fontFamily".into(), "V:cycle".into());
|
||||
// The bound layer takes the value its alias resolves to (bindings are applied after the next
|
||||
// command); the cycle resolves to nothing.
|
||||
let NodeKind::Text(bound) = &s.doc().unwrap().node(t).unwrap().kind else { panic!() };
|
||||
assert_eq!(bound.style.font_family, "FigCraft Missing F");
|
||||
assert_eq!(families(&mut s), vec![("FigCraft Missing F".to_string(), 1), ("FigCraft Missing G".to_string(), 1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_an_open_document() {
|
||||
let mut s = Session::empty();
|
||||
assert!(s.execute("text.missingFonts", &json!({})).is_err());
|
||||
}
|
||||
|
||||
/// Make `id`'s box stale, as if it was measured in another font: `dw` wider than its content.
|
||||
fn stale(s: &mut Session, id: figcraft_doc::NodeId, dw: f64) -> f64 {
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let n = doc.get_mut(id).unwrap();
|
||||
let want = figcraft_text::auto_size(n).unwrap().0;
|
||||
n.width = want + dw;
|
||||
doc.take_dirty();
|
||||
want
|
||||
}
|
||||
|
||||
/// The width of layer `id`.
|
||||
fn width(s: &Session, id: figcraft_doc::NodeId) -> f64 {
|
||||
s.doc().unwrap().node(id).unwrap().width
|
||||
}
|
||||
|
||||
/// The ids a refit reports.
|
||||
fn refitted(r: &Value) -> Vec<u64> {
|
||||
r["refitted"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect()
|
||||
}
|
||||
|
||||
/// Stale boxes fit their text again, by family, by layer or everywhere; fixed boxes keep theirs.
|
||||
#[test]
|
||||
fn refit_measures_stale_text_boxes_again() {
|
||||
let mut s = Session::new();
|
||||
let a = text(&mut s, "FigCraft Refit A");
|
||||
let b = text(&mut s, "FigCraft Refit B");
|
||||
let fixed = text(&mut s, "FigCraft Refit A");
|
||||
if let NodeKind::Text(t) = &mut s.doc_mut().unwrap().get_mut(fixed).unwrap().kind {
|
||||
t.auto_resize = figcraft_doc::TextAutoResize::None;
|
||||
}
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let want_a = stale(&mut s, a, -30.0);
|
||||
let want_b = stale(&mut s, b, 25.0);
|
||||
s.doc_mut().unwrap().get_mut(fixed).unwrap().width = 7.0;
|
||||
s.doc_mut().unwrap().take_dirty();
|
||||
// Only the families asked for.
|
||||
let only_a = super::refit(s.doc_mut().unwrap(), None, Some(&["figcraft refit a".to_string()]));
|
||||
assert_eq!(only_a, vec![a]);
|
||||
assert_eq!(width(&s, a), want_a);
|
||||
assert_eq!(width(&s, b), want_b + 25.0);
|
||||
// Only the layers asked for, then everything; a fixed box keeps its size.
|
||||
stale(&mut s, a, 12.0);
|
||||
assert!(refitted(&s.execute("text.refit", &json!({"ids": [b]})).unwrap()) == vec![b.0]);
|
||||
assert_eq!((width(&s, a), width(&s, b)), (want_a + 12.0, want_b));
|
||||
assert!(refitted(&s.execute("text.refit", &json!({})).unwrap()) == vec![a.0]);
|
||||
assert_eq!(width(&s, a), want_a);
|
||||
assert_eq!(width(&s, fixed), 7.0);
|
||||
// Nothing stale: nothing changes.
|
||||
assert!(refitted(&s.execute("text.refit", &json!({})).unwrap()).is_empty());
|
||||
assert!(s.execute("text.refit", &json!({"ids": ["nope"]})).is_err());
|
||||
// An `ids` that isn't a list is refused, not taken as the whole document.
|
||||
stale(&mut s, a, 9.0);
|
||||
assert!(s.execute("text.refit", &json!({"ids": a.0.to_string()})).is_err());
|
||||
assert_eq!(width(&s, a), want_a + 9.0);
|
||||
assert!(Session::empty().execute("text.refit", &json!({})).is_err());
|
||||
}
|
||||
|
||||
/// Text inside an instance is refitted too.
|
||||
#[test]
|
||||
fn refit_reaches_text_in_instances() {
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, "FigCraft Refit C");
|
||||
let comp = id_of(&s.execute("component.create", &json!({"ids": [t]})).unwrap()["id"]).unwrap();
|
||||
let inst = id_of(&s.execute("instance.create", &json!({"component": comp, "x": 300, "y": 0})).unwrap()["id"]).unwrap();
|
||||
let copy = s.doc().unwrap().node(inst).unwrap().children[0];
|
||||
let want = stale(&mut s, copy, -20.0);
|
||||
let r = s.execute("text.refit", &json!({"ids": [inst]})).unwrap();
|
||||
assert_eq!(refitted(&r), vec![copy.0]);
|
||||
assert_eq!(width(&s, copy), want);
|
||||
}
|
||||
|
||||
/// `text.loadFont` refuses what isn't a font file; `text.rescanFonts` reports what it added.
|
||||
#[test]
|
||||
fn fonts_load_from_files_and_rescans_report_new_families() {
|
||||
let mut s = Session::new();
|
||||
assert!(s.execute("text.loadFont", &json!({})).is_err());
|
||||
assert!(s.execute("text.loadFont", &json!({"data": "bm90IGEgZm9udA=="})).is_err(), "not a font file");
|
||||
assert!(s.execute("text.loadFont", &json!({"data": "%%%"})).is_err());
|
||||
assert!(s.execute("text.loadFont", &json!({"path": "no-such-dir/x.ttf"})).is_err());
|
||||
if let Some(f) = figcraft_text::CRAFT_FONTS.first() {
|
||||
use base64::Engine as _;
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(f.bytes);
|
||||
let r = s.execute("text.loadFont", &json!({"data": data})).unwrap();
|
||||
assert_eq!(r["families"].as_array().map(Vec::len), Some(1));
|
||||
}
|
||||
let r = s.execute("text.rescanFonts", &json!({})).unwrap();
|
||||
assert!(r["added"].is_array());
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Frame name labels as pointer targets: a press on a label (its area as the canvas drew it,
|
||||
//! turned with a rotated frame) selects the frame and drags it in one undo step, hovering it
|
||||
//! highlights the frame, and labels of locked, hidden, deleted or other pages' frames do nothing.
|
||||
//! highlights the frame, a double-click asks the UI to rename it there, and labels of locked,
|
||||
//! hidden, deleted or other pages' frames do nothing.
|
||||
|
||||
use figcraft_doc::NodeId;
|
||||
use figcraft_geom::Point;
|
||||
@@ -90,3 +91,24 @@ fn labels_of_frames_that_cant_be_picked_do_nothing() {
|
||||
let flat = [Point::new(1.0, 1.0); 4];
|
||||
assert_eq!(label_at(s.doc().unwrap(), page, &[(f, nan), (f, flat)], Point::new(1.0, 1.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_clicking_a_label_asks_to_rename_its_frame() {
|
||||
let (mut s, f, q) = setup();
|
||||
let c = center(&q);
|
||||
let before = s.doc().unwrap().world_bounds(f).unwrap();
|
||||
let undo = s.ds().unwrap().history.undo.len();
|
||||
for kind in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::DoubleClick] {
|
||||
s.pointer(&ev(kind, c)).unwrap();
|
||||
}
|
||||
assert_eq!(s.selection(), vec![f]);
|
||||
let asked = s.ui_requests.iter().find(|r| r.kind == "canvas.renameLabel").map(|r| id_of(&r.data["id"]));
|
||||
assert_eq!(asked, Some(Some(f)), "the UI renames the frame at its label");
|
||||
assert_eq!(s.doc().unwrap().world_bounds(f), Some(before), "not moved");
|
||||
assert_eq!(s.ds().unwrap().history.undo.len(), undo, "nothing to undo yet");
|
||||
// Other tools ignore the label.
|
||||
s.ui_requests.clear();
|
||||
s.set_tool(crate::tools::ToolId::Hand);
|
||||
s.pointer(&ev(PointerKind::DoubleClick, c)).unwrap();
|
||||
assert!(s.ui_requests.iter().all(|r| r.kind != "canvas.renameLabel"));
|
||||
}
|
||||
@@ -165,6 +165,10 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
|
||||
o.insert("operandOperation".into(), json!(op));
|
||||
}
|
||||
}
|
||||
// Layer data written with `node.set {props: {pluginData}}` (pages too).
|
||||
if !n.plugin_data.is_empty() {
|
||||
o.insert("pluginData".into(), json!(n.plugin_data));
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Text(t) => {
|
||||
o.insert("characters".into(), json!(t.characters));
|
||||
|
||||
@@ -214,6 +214,26 @@ fn css_for_common_layers() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Individual stroke sides are borders on those sides only: a header with a divider under it isn't
|
||||
/// boxed in, and a side bound to a variable names it.
|
||||
#[test]
|
||||
fn css_borders_follow_individual_stroke_sides() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s, 0.0, 0.0, 100.0, 50.0, None);
|
||||
set(&mut s, &[r], json!({"strokes": ["#000000"], "strokeWeight": 1, "strokeSides": [0, 0, 2, 0]}));
|
||||
let c = css(&mut s, r);
|
||||
assert!(c.contains("border-bottom: 2px solid #000000;"), "{c}");
|
||||
assert!(!c.contains("border:") && !c.contains("border-top") && !c.contains("border-right") && !c.contains("border-left"), "{c}");
|
||||
|
||||
let col = ex(&mut s, "variable.createCollection", json!({"name": "Borders"}));
|
||||
let col = col["id"].as_str().unwrap().to_string();
|
||||
ex(&mut s, "variable.create", json!({"collection": col, "name": "border/top", "type": "number", "value": 3}));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "strokeTopWeight", "variable": "border/top"}));
|
||||
let c = css(&mut s, r);
|
||||
assert!(c.contains("border-top: var(--border-top, 3px) solid #000000;"), "{c}");
|
||||
assert!(c.contains("border-bottom: 2px solid #000000;"), "{c}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_code_for_a_simple_layer() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
//! Variable modes on instances: an instance is on the modes its main component sets, under the
|
||||
//! ones set on the instance itself, so switching variants or changing the main's mode reaches it.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
use figcraft_doc::NodeId;
|
||||
|
||||
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
|
||||
}
|
||||
|
||||
fn id(v: Value) -> NodeId {
|
||||
id_of(&v["id"]).unwrap()
|
||||
}
|
||||
|
||||
fn fill(s: &Session, n: NodeId) -> String {
|
||||
s.doc().unwrap().node(n).unwrap().fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn set_mode(s: &mut Session, n: NodeId, mode: Value) {
|
||||
run(s, "variable.setMode", json!({"ids": [n], "collection": "Tone", "mode": mode}));
|
||||
}
|
||||
|
||||
/// A "Tone" collection whose variable `c` is red in mode A and blue in mode B.
|
||||
fn tone(s: &mut Session) {
|
||||
run(s, "variable.createCollection", json!({"name": "Tone", "modes": ["A", "B"]}));
|
||||
run(s, "variable.create", json!({"collection": "Tone", "name": "c", "type": "color", "values": {"A": "#FF0000", "B": "#0000FF"}}));
|
||||
}
|
||||
|
||||
/// A component filled from `c`, on `mode` when one is given.
|
||||
fn swatch(s: &mut Session, name: &str, mode: Option<&str>) -> NodeId {
|
||||
let f = id(run(s, "frame.create", json!({"x": 0, "y": 0, "width": 40, "height": 40, "name": name})));
|
||||
run(s, "variable.bind", json!({"ids": [f], "field": "fill", "variable": "c"}));
|
||||
if let Some(m) = mode {
|
||||
set_mode(s, f, json!(m));
|
||||
}
|
||||
id(run(s, "component.create", json!({"ids": [f]})))
|
||||
}
|
||||
|
||||
/// A set "Kind" whose variants A and B each set their own Tone mode.
|
||||
fn kinds(s: &mut Session) -> (NodeId, NodeId) {
|
||||
tone(s);
|
||||
let a = swatch(s, "Kind=A", Some("A"));
|
||||
let b = swatch(s, "Kind=B", Some("B"));
|
||||
let set = id(run(s, "component.combineVariants", json!({"ids": [a, b]})));
|
||||
(set, b)
|
||||
}
|
||||
|
||||
fn instance(s: &mut Session, component: NodeId) -> NodeId {
|
||||
id(run(s, "instance.create", json!({"component": component, "x": 200, "y": 0})))
|
||||
}
|
||||
|
||||
fn switch(s: &mut Session, i: NodeId, kind: &str) {
|
||||
run(s, "instance.setProperties", json!({"ids": [i], "properties": {"Kind": kind}}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_variants_takes_the_new_variants_mode() {
|
||||
let mut s = Session::new();
|
||||
let (set, _) = kinds(&mut s);
|
||||
let i = instance(&mut s, set);
|
||||
assert_eq!(fill(&s, i), "FF0000");
|
||||
switch(&mut s, i, "B");
|
||||
assert_eq!(fill(&s, i), "0000FF", "variant B sets mode B");
|
||||
switch(&mut s, i, "A");
|
||||
assert_eq!(fill(&s, i), "FF0000");
|
||||
assert!(s.doc().unwrap().node(i).unwrap().var_modes.is_empty(), "the instance sets no mode of its own");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instances_follow_a_mode_their_main_sets_later() {
|
||||
let mut s = Session::new();
|
||||
tone(&mut s);
|
||||
let main = swatch(&mut s, "Chip", None);
|
||||
let i = instance(&mut s, main);
|
||||
assert_eq!(fill(&s, i), "FF0000", "no mode set: the collection's default");
|
||||
set_mode(&mut s, main, json!("B"));
|
||||
assert_eq!(fill(&s, i), "0000FF");
|
||||
set_mode(&mut s, main, Value::Null);
|
||||
assert_eq!(fill(&s, i), "FF0000", "the main back on Auto");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mode_set_on_the_instance_wins_and_stays_through_variant_switches() {
|
||||
let mut s = Session::new();
|
||||
let (set, _) = kinds(&mut s);
|
||||
let i = instance(&mut s, set);
|
||||
set_mode(&mut s, i, json!("B"));
|
||||
assert_eq!(fill(&s, i), "0000FF");
|
||||
for kind in ["B", "A"] {
|
||||
switch(&mut s, i, kind);
|
||||
assert_eq!(fill(&s, i), "0000FF", "own mode B on variant {kind}");
|
||||
}
|
||||
set_mode(&mut s, i, Value::Null);
|
||||
assert_eq!(fill(&s, i), "FF0000", "back on Auto: variant A's mode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_main_mode_is_closer_than_the_frame_around_the_instance() {
|
||||
let mut s = Session::new();
|
||||
let (_, b) = kinds(&mut s);
|
||||
let frame = id(run(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 200, "height": 100})));
|
||||
set_mode(&mut s, frame, json!("A"));
|
||||
let i = id(run(&mut s, "instance.create", json!({"component": b, "x": 10, "y": 10, "parent": frame})));
|
||||
assert_eq!(fill(&s, i), "0000FF", "the main's mode B, not the frame's A");
|
||||
// Nested: an outer component on mode A holding an instance of B.
|
||||
let outer_frame = id(run(&mut s, "frame.create", json!({"x": 0, "y": 500, "width": 100, "height": 100, "name": "Outer"})));
|
||||
run(&mut s, "instance.create", json!({"component": b, "x": 0, "y": 0, "parent": outer_frame}));
|
||||
set_mode(&mut s, outer_frame, json!("A"));
|
||||
let outer = id(run(&mut s, "component.create", json!({"ids": [outer_frame]})));
|
||||
let copy = instance(&mut s, outer);
|
||||
let nested = s.doc().unwrap().node(copy).unwrap().children[0];
|
||||
assert_eq!(fill(&s, nested), "0000FF");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detaching_keeps_the_mode_it_took_from_its_main() {
|
||||
let mut s = Session::new();
|
||||
let (set, b) = kinds(&mut s);
|
||||
let i = instance(&mut s, set);
|
||||
switch(&mut s, i, "B");
|
||||
run(&mut s, "component.detach", json!({"ids": [i]}));
|
||||
set_mode(&mut s, b, json!("A"));
|
||||
assert_eq!(fill(&s, i), "0000FF", "the detached frame keeps mode B");
|
||||
assert_eq!(s.doc().unwrap().node(i).unwrap().var_modes.len(), 1);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
//! Size and resizing overrides of layers inside instances: a layer in an instance can hug, fill or
|
||||
//! keep a fixed size of its own, and that override is kept through relayout, edits of the main
|
||||
//! (only the overridden property stays), undo, save and reopen (native and .fig), REST import,
|
||||
//! detach and reset.
|
||||
|
||||
use base64::Engine;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::components::tests::{copy_of, ex, healthy, idv, node};
|
||||
use figcraft_doc::*;
|
||||
|
||||
/// A "Field" component: a 240×40 fixed horizontal auto layout frame (no padding or gap) holding
|
||||
/// "Atom", an auto layout frame hugging its 20×20 "Dot" with a min width of 100; and an instance of
|
||||
/// it. Returns (component, atom, dot, instance).
|
||||
fn field(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId) {
|
||||
let f = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 240, "height": 40, "name": "Field"})));
|
||||
ex(s, "node.set", json!({"id": f, "props": {"layout": "horizontal", "padding": 0, "gap": 0, "sizingH": "fixed", "sizingV": "fixed"}}));
|
||||
let atom = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 50, "height": 20, "parent": f, "name": "Atom"})));
|
||||
ex(
|
||||
s,
|
||||
"node.set",
|
||||
json!({"id": atom, "props": {"layout": "horizontal", "padding": 0, "gap": 0, "sizingH": "hug", "sizingV": "hug", "minWidth": 100}}),
|
||||
);
|
||||
let dot = idv(&ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20, "parent": atom, "props": {"name": "Dot"}})));
|
||||
let c = idv(&ex(s, "component.create", json!({"ids": [f]})));
|
||||
let i = idv(&ex(s, "instance.create", json!({"component": c, "x": 0, "y": 100})));
|
||||
(c, atom, dot, i)
|
||||
}
|
||||
|
||||
fn sizing_h(s: &Session, id: NodeId) -> Sizing {
|
||||
node(s, id).child_layout.sizing_h
|
||||
}
|
||||
|
||||
/// The layer named `name` below instance `inst`.
|
||||
fn named(s: &Session, inst: NodeId, name: &str) -> NodeId {
|
||||
let doc = s.doc().unwrap();
|
||||
doc.subtree(inst).into_iter().find(|d| *d != inst && doc.node(*d).is_some_and(|n| n.name == name)).unwrap()
|
||||
}
|
||||
|
||||
/// The first instance in the document.
|
||||
fn instance(s: &Session) -> NodeId {
|
||||
s.doc().unwrap().nodes().find(|n| matches!(n.kind, NodeKind::Instance { .. })).unwrap().id
|
||||
}
|
||||
|
||||
fn open(s: &mut Session, bytes: &[u8], name: &str) {
|
||||
ex(s, "file.open", json!({"data": base64::engine::general_purpose::STANDARD.encode(bytes), "name": name}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_in_an_instance_fills_and_keeps_it_through_main_edits() {
|
||||
let mut s = Session::new();
|
||||
let (c, atom, dot, i) = field(&mut s);
|
||||
let (ia, idot) = (copy_of(&s, i, atom), copy_of(&s, i, dot));
|
||||
assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Hug, 100.0), "hugs its dot, at least 100 wide");
|
||||
ex(&mut s, "node.set", json!({"id": ia, "props": {"sizingH": "fill"}}));
|
||||
assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fill, 240.0), "fills the instance");
|
||||
assert!(node(&s, ia).overrides.contains("resizing"), "{:?}", node(&s, ia).overrides);
|
||||
// Edits of the main relay the instance out: the override stays, other properties follow.
|
||||
ex(&mut s, "node.set", json!({"id": dot, "props": {"width": 30, "fill": "#ff0000"}}));
|
||||
assert_eq!((node(&s, ia).width, node(&s, idot).width), (240.0, 30.0));
|
||||
ex(&mut s, "node.set", json!({"id": atom, "props": {"minWidth": 120, "fill": "#00ff00"}}));
|
||||
assert_eq!(node(&s, atom).width, 120.0);
|
||||
assert_eq!((sizing_h(&s, ia), node(&s, ia).width, node(&s, ia).child_layout.min_w), (Sizing::Fill, 240.0, Some(100.0)));
|
||||
assert_eq!(node(&s, ia).fills, node(&s, atom).fills, "the fill isn't overridden: it follows the main");
|
||||
// The main fixing its own width is the same property: the instance keeps filling.
|
||||
ex(&mut s, "node.set", json!({"id": atom, "props": {"width": 80}}));
|
||||
assert_eq!((sizing_h(&s, atom), sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fixed, Sizing::Fill, 240.0));
|
||||
// A structure edit of the main rebuilds the instance: the atom fills what the new layer leaves.
|
||||
ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5, "parent": c}));
|
||||
assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fill, 235.0));
|
||||
healthy(&s);
|
||||
// Back to the main's resizing: no override left.
|
||||
ex(&mut s, "component.resetOverride", json!({"id": ia, "group": "resizing"}));
|
||||
assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fixed, node(&s, atom).width));
|
||||
assert!(!node(&s, ia).overrides.contains("resizing"));
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_and_resizing_overrides_undo_reset_and_detach() {
|
||||
let mut s = Session::new();
|
||||
let (_, atom, dot, i) = field(&mut s);
|
||||
let (ia, idot) = (copy_of(&s, i, atom), copy_of(&s, i, dot));
|
||||
ex(&mut s, "node.set", json!({"id": ia, "props": {"sizingH": "fill"}}));
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Hug, 100.0));
|
||||
assert!(node(&s, ia).overrides.is_empty());
|
||||
// A fixed size of its own: kept when the main resizes the layer.
|
||||
ex(&mut s, "node.set", json!({"id": idot, "props": {"width": 50, "height": 10}}));
|
||||
assert!(node(&s, idot).overrides.contains("size"));
|
||||
ex(&mut s, "node.set", json!({"id": dot, "props": {"width": 30}}));
|
||||
assert_eq!((node(&s, idot).width, node(&s, idot).height, node(&s, dot).width), (50.0, 10.0, 30.0));
|
||||
// A min width of its own.
|
||||
ex(&mut s, "node.set", json!({"id": ia, "props": {"minWidth": 150}}));
|
||||
assert_eq!(node(&s, ia).width, 150.0);
|
||||
ex(&mut s, "node.set", json!({"id": atom, "props": {"minWidth": 90}}));
|
||||
assert_eq!(node(&s, ia).width, 150.0);
|
||||
healthy(&s);
|
||||
// Reset all changes: the main's values again.
|
||||
ex(&mut s, "component.resetInstance", json!({"ids": [i]}));
|
||||
assert_eq!((node(&s, ia).width, node(&s, idot).width), (90.0, 30.0));
|
||||
assert!(node(&s, ia).overrides.is_empty() && node(&s, idot).overrides.is_empty());
|
||||
// Detaching keeps what the instance showed.
|
||||
ex(&mut s, "node.set", json!({"id": ia, "props": {"sizingH": "fill"}}));
|
||||
ex(&mut s, "component.detach", json!({"ids": [i]}));
|
||||
assert_eq!((sizing_h(&s, ia), node(&s, ia).width, node(&s, ia).source), (Sizing::Fill, 240.0, None));
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_in_instances_resize_but_keep_their_place() {
|
||||
let mut s = Session::new();
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})));
|
||||
let title = idv(&ex(&mut s, "text.create", json!({"text": "Hello", "x": 10, "y": 10, "parent": f, "props": {"name": "Title"}})));
|
||||
let c = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0})));
|
||||
let it = copy_of(&s, i, title);
|
||||
ex(&mut s, "node.set", json!({"id": it, "props": {"width": 150}}));
|
||||
assert_eq!(node(&s, it).width, 150.0, "a text box resizes");
|
||||
assert!(node(&s, it).overrides.contains("size"));
|
||||
// Its place, rotation and constraints are still the main's.
|
||||
for props in [json!({"x": 50}), json!({"rotation": 15}), json!({"constraints": {"horizontal": "right"}})] {
|
||||
assert!(s.execute("node.set", &json!({"id": it, "props": props})).is_err(), "{props} is refused");
|
||||
}
|
||||
// The instance's own resizing is its own, not an override.
|
||||
ex(&mut s, "node.set", json!({"id": i, "props": {"minWidth": 100}}));
|
||||
assert!(!node(&s, i).overrides.contains("resizing"));
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resizing_overrides_survive_save_and_reopen() {
|
||||
let mut s = Session::new();
|
||||
let (_, atom, dot, i) = field(&mut s);
|
||||
let (ia, idot) = (copy_of(&s, i, atom), copy_of(&s, i, dot));
|
||||
ex(&mut s, "node.set", json!({"id": ia, "props": {"sizingH": "fill"}}));
|
||||
ex(&mut s, "node.set", json!({"id": idot, "props": {"width": 40}}));
|
||||
let native = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
|
||||
let (fig, _) = figcraft_format::fig::export(s.doc().unwrap(), None).unwrap();
|
||||
for (bytes, name) in [(native, "field.figcraft"), (fig, "field.fig")] {
|
||||
let mut s = Session::new();
|
||||
open(&mut s, &bytes, name);
|
||||
let i = instance(&s);
|
||||
let (ia, idot) = (named(&s, i, "Atom"), named(&s, i, "Dot"));
|
||||
assert!(node(&s, ia).overrides.contains("resizing"), "{name}: {:?}", node(&s, ia).overrides);
|
||||
assert!(node(&s, idot).overrides.contains("size"), "{name}: {:?}", node(&s, idot).overrides);
|
||||
// An edit of the main relays the instance out.
|
||||
let main = |s: &Session, id: NodeId| node(s, id).source.unwrap();
|
||||
let (atom, dot) = (main(&s, ia), main(&s, idot));
|
||||
ex(&mut s, "node.set", json!({"id": atom, "props": {"minWidth": 120}}));
|
||||
ex(&mut s, "node.set", json!({"id": dot, "props": {"width": 30}}));
|
||||
assert_eq!((sizing_h(&s, ia), node(&s, ia).width, node(&s, idot).width), (Sizing::Fill, 240.0, 40.0), "{name}");
|
||||
healthy(&s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_resizing_overrides_survive_relayout() {
|
||||
let at = |x: f64, y: f64| json!([[1, 0, x], [0, 1, y]]);
|
||||
let dot = |id: &str| json!({"id": id, "name": "Dot", "type": "RECTANGLE", "relativeTransform": at(0.0, 0.0), "size": {"x": 20, "y": 20}});
|
||||
let atom = |id: &str, dot_id: &str, w: f64, more: Value| {
|
||||
let mut a = json!({"id": id, "name": "Atom", "type": "FRAME", "relativeTransform": at(0.0, 0.0), "size": {"x": w, "y": 20},
|
||||
"layoutMode": "HORIZONTAL", "primaryAxisSizingMode": "AUTO", "counterAxisSizingMode": "AUTO", "minWidth": 100, "children": [dot(dot_id)]});
|
||||
a.as_object_mut().unwrap().extend(more.as_object().unwrap().clone());
|
||||
a
|
||||
};
|
||||
let field = |id: &str, ty: &str, y: f64, kids: Value| {
|
||||
json!({"id": id, "name": "Field", "type": ty, "relativeTransform": at(0.0, y), "size": {"x": 240, "y": 40}, "layoutMode": "HORIZONTAL",
|
||||
"primaryAxisSizingMode": "FIXED", "counterAxisSizingMode": "FIXED", "children": kids})
|
||||
};
|
||||
let mut inst =
|
||||
field("2:1", "INSTANCE", 100.0, json!([atom("I2:1;1:2", "I2:1;1:3", 240.0, json!({"layoutSizingHorizontal": "FILL", "layoutGrow": 1}))]));
|
||||
inst["componentId"] = json!("1:1");
|
||||
inst["overrides"] = json!([{"id": "I2:1;1:2", "overriddenFields": ["layoutSizingHorizontal", "layoutGrow"]}]);
|
||||
let file = json!({"name": "Field", "document": {"id": "0:0", "type": "DOCUMENT", "children": [
|
||||
{"id": "0:1", "name": "Page", "type": "CANVAS", "children": [field("1:1", "COMPONENT", 0.0, json!([atom("1:2", "1:3", 100.0, json!({}))])), inst]}
|
||||
]}, "components": {"1:1": {"key": "k", "name": "Field", "description": ""}}, "styles": {}});
|
||||
let mut s = Session::new();
|
||||
open(&mut s, file.to_string().as_bytes(), "field.json");
|
||||
let i = instance(&s);
|
||||
let ia = named(&s, i, "Atom");
|
||||
assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fill, 240.0));
|
||||
assert!(node(&s, ia).overrides.contains("resizing"), "{:?}", node(&s, ia).overrides);
|
||||
// The main's first edit relays every instance out from it.
|
||||
let main_atom = node(&s, ia).source.unwrap();
|
||||
ex(&mut s, "node.set", json!({"id": main_atom, "props": {"minWidth": 120}}));
|
||||
assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fill, 240.0));
|
||||
healthy(&s);
|
||||
}
|
||||
+111
-4
@@ -6,6 +6,8 @@
|
||||
//! change is one undo step. Pointer tools ([`tools`]) and text editing reduce to the same document
|
||||
//! edits and are journaled the same way.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
// Tests make and inspect files directly; shipped code goes through `file_access` (clippy.toml).
|
||||
#![cfg_attr(test, allow(clippy::disallowed_methods))]
|
||||
|
||||
pub mod al_keys;
|
||||
pub mod annotations;
|
||||
@@ -24,7 +26,10 @@ pub mod dev_resources;
|
||||
pub mod devcompare;
|
||||
pub mod devinspect;
|
||||
pub mod distance;
|
||||
pub mod doc_urls;
|
||||
pub mod drop;
|
||||
pub mod file_access;
|
||||
pub mod fonts;
|
||||
pub mod guard;
|
||||
pub mod guides;
|
||||
pub mod inspect;
|
||||
@@ -43,6 +48,9 @@ pub mod plugins;
|
||||
pub mod proto_run;
|
||||
pub mod rename;
|
||||
pub mod rotation_origin;
|
||||
pub mod selection_colors;
|
||||
pub mod selection_reorder;
|
||||
pub mod selection_spacing;
|
||||
pub mod shape_handles;
|
||||
pub mod shapebuilder;
|
||||
pub mod snap;
|
||||
@@ -369,6 +377,8 @@ pub struct DocState {
|
||||
pub guide: Option<(NodeId, usize)>,
|
||||
/// The selection's rotation origin, when moved or shown (see [`rotation_origin`]; not saved).
|
||||
pub(crate) rotation_origin: Option<rotation_origin::RotationOrigin>,
|
||||
/// The View toggles the preferences keep were applied ([`Session::apply_view_prefs`]).
|
||||
view_from_prefs: bool,
|
||||
/// The last duplicate while its copies are the selection: Duplicate repeats its offset (see
|
||||
/// [`cmd::edit::Duplicated`]; not saved).
|
||||
pub(crate) last_duplicate: Option<cmd::edit::Duplicated>,
|
||||
@@ -430,6 +440,7 @@ impl DocState {
|
||||
focus: None,
|
||||
guide: None,
|
||||
rotation_origin: None,
|
||||
view_from_prefs: false,
|
||||
last_duplicate: None,
|
||||
};
|
||||
st.zoom_to_fit();
|
||||
@@ -593,6 +604,23 @@ impl DocState {
|
||||
}
|
||||
}
|
||||
|
||||
/// The smallest and largest interface size, relative to the system's display scale (1.0 is the
|
||||
/// system's own size).
|
||||
pub const UI_SCALE_MIN: f64 = 0.75;
|
||||
pub const UI_SCALE_MAX: f64 = 3.0;
|
||||
|
||||
/// `scale` kept between [`UI_SCALE_MIN`] and [`UI_SCALE_MAX`]; not a finite number: `None`.
|
||||
pub fn clamp_ui_scale(scale: f64) -> Option<f64> {
|
||||
scale.is_finite().then(|| scale.clamp(UI_SCALE_MIN, UI_SCALE_MAX))
|
||||
}
|
||||
|
||||
/// Reads the saved interface size. Anything but a number reads as 1 (the system's size) and a
|
||||
/// number out of range is clamped, so a settings file edited by hand never costs the other
|
||||
/// preferences.
|
||||
fn read_ui_scale<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<f64, D::Error> {
|
||||
Ok(Value::deserialize(d)?.as_f64().and_then(clamp_ui_scale).unwrap_or(1.0))
|
||||
}
|
||||
|
||||
/// App-wide preferences that commands read (nudge amounts, behaviours).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
@@ -609,6 +637,9 @@ pub struct Prefs {
|
||||
/// The widths of the main window's left and right panels (the interface keeps them within its
|
||||
/// limits; other windows have their own while they are open).
|
||||
pub panel_widths: (f32, f32),
|
||||
/// The height asked for the main window's pages list, dragged at its lower edge (`None`: a
|
||||
/// share of the panel; the interface keeps it within its limits).
|
||||
pub pages_height: Option<f32>,
|
||||
/// The interface language: a language code ("en", "es", "pt-BR") or "system" (the OS or browser
|
||||
/// language). Languages without a catalog show English.
|
||||
pub language: String,
|
||||
@@ -617,9 +648,16 @@ pub struct Prefs {
|
||||
/// Reduce motion: the interface's own animations are off (the system's setting turns them off
|
||||
/// too). Prototype transitions still play.
|
||||
pub reduce_motion: bool,
|
||||
/// How large the whole interface is drawn, relative to the system's display scale: 1.0 is the
|
||||
/// system's own size, 1.25 a quarter larger (kept between [`UI_SCALE_MIN`] and
|
||||
/// [`UI_SCALE_MAX`]). The canvas zoom is separate.
|
||||
#[serde(deserialize_with = "read_ui_scale")]
|
||||
pub ui_scale: f64,
|
||||
/// Files that have a path are saved by themselves a few seconds after they change (the app's
|
||||
/// timer; off: only File ▸ Save writes them).
|
||||
pub auto_save: bool,
|
||||
/// The desktop app starts on the Home screen instead of in a new file.
|
||||
pub start_on_home: bool,
|
||||
/// `figcraft://` invite links open this app (`collab.registerLinks`); `None`: on for installed
|
||||
/// builds, off for development builds.
|
||||
pub invite_links: Option<bool>,
|
||||
@@ -655,6 +693,10 @@ pub struct Prefs {
|
||||
pub home_sort: String,
|
||||
/// Which way: `"desc"` (newest first, Z to A) or `"asc"`.
|
||||
pub home_order: String,
|
||||
/// The View menu's toggles turned on or off (rulers, pixel grid, snapping…), by command id:
|
||||
/// files opened or made later start with them. Missing: the toggle's default.
|
||||
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub view: BTreeMap<String, bool>,
|
||||
/// Recently run actions (the Actions palette's Recents), newest first, at most
|
||||
/// [`MAX_RECENT_ACTIONS`]. Entries are `command:<id>`, `plugin:<plugin>/<command>` or
|
||||
/// `widget:<plugin>`.
|
||||
@@ -677,10 +719,13 @@ impl Default for Prefs {
|
||||
show_dimensions: true,
|
||||
theme: "system".into(),
|
||||
panel_widths: (240.0, 240.0),
|
||||
pages_height: None,
|
||||
language: "system".into(),
|
||||
user_name: cmd::comment::DEFAULT_AUTHOR.into(),
|
||||
reduce_motion: false,
|
||||
ui_scale: 1.0,
|
||||
auto_save: false,
|
||||
start_on_home: false,
|
||||
invite_links: None,
|
||||
plugins: Vec::new(),
|
||||
plugin_settings: BTreeMap::new(),
|
||||
@@ -692,6 +737,7 @@ impl Default for Prefs {
|
||||
home_views: BTreeMap::new(),
|
||||
home_sort: "modified".into(),
|
||||
home_order: "desc".into(),
|
||||
view: BTreeMap::new(),
|
||||
recent_actions: Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -716,6 +762,8 @@ pub struct Clipboard {
|
||||
/// The page-space bounds of the copied selection.
|
||||
pub bounds: Option<figcraft_geom::Rect>,
|
||||
pub source_doc: u64,
|
||||
/// Copied component/set roots, excluding cuts and originals discarded by undo (ids are reused).
|
||||
pub copied_components: std::collections::BTreeSet<NodeId>,
|
||||
/// The document copied from (pastes into other files follow its mains) and its file path.
|
||||
pub source: Option<Arc<Document>>,
|
||||
pub source_path: Option<String>,
|
||||
@@ -844,6 +892,14 @@ impl Session {
|
||||
s
|
||||
}
|
||||
|
||||
/// Files new to the session start with the View toggles the preferences keep.
|
||||
pub fn apply_view_prefs(&mut self) {
|
||||
for ds in self.docs.iter_mut().filter(|d| !d.view_from_prefs) {
|
||||
cmd::view::apply_kept(&self.prefs, &mut ds.view);
|
||||
ds.view_from_prefs = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ds(&self) -> Result<&DocState> {
|
||||
self.docs.get(self.active).ok_or(EngineError::NoDocument)
|
||||
}
|
||||
@@ -885,6 +941,7 @@ impl Session {
|
||||
return Err(EngineError::Disabled(id.into(), "leave the live session before reverting to the saved file".into()));
|
||||
}
|
||||
branches::sync(self);
|
||||
self.apply_view_prefs();
|
||||
if textpreview::COMMANDS.contains(&id) {
|
||||
// A live preview is no edit: no undo step, normalisation or rollback here.
|
||||
let r = guard::run(id, || (spec.run)(self, params));
|
||||
@@ -910,6 +967,7 @@ impl Session {
|
||||
match &result {
|
||||
Ok(_) => {
|
||||
self.after_command(spec, before);
|
||||
self.apply_view_prefs();
|
||||
if spec.history && id != "edit.repeat" {
|
||||
self.last_command = Some((id.to_string(), params.clone()));
|
||||
}
|
||||
@@ -1069,8 +1127,11 @@ impl Session {
|
||||
let Some(it) = ds.interaction.clone() else { return Err(EngineError::Other("no gesture in progress".into())) };
|
||||
let mut doc = (*it.doc).clone();
|
||||
let mut sel = it.selection.clone();
|
||||
// What the gesture changes on the selected layers is an explicit edit.
|
||||
let before = cmd::styles::bound_before(&doc, &it.selection);
|
||||
let res = f(&mut doc, &mut sel);
|
||||
if res.is_ok() {
|
||||
cmd::styles::detach_edited(&mut doc, &before);
|
||||
normalize(&mut doc, &it.doc);
|
||||
// The same preview again (a handle held still) keeps the document: a new one would
|
||||
// re-render the canvas for nothing.
|
||||
@@ -1137,6 +1198,13 @@ impl Session {
|
||||
}
|
||||
ds.clean_selection();
|
||||
ds.revision += 1;
|
||||
let (uid, doc) = (ds.uid, ds.doc.clone());
|
||||
if self.clipboard.source_doc == uid {
|
||||
self.clipboard.copied_components.retain(|id| {
|
||||
doc.node(*id)
|
||||
.is_some_and(|n| matches!(n.kind, figcraft_doc::NodeKind::Component { .. } | figcraft_doc::NodeKind::ComponentSet { .. }))
|
||||
});
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
@@ -1431,21 +1499,35 @@ fn affects_layout(before: &Document, doc: &Document, id: NodeId) -> bool {
|
||||
}
|
||||
|
||||
/// The layers whose variable bindings an edit may have changed: every bound layer when a variable
|
||||
/// changed, else the touched layers and everything below those that moved, are new or chose
|
||||
/// another variable mode (modes are inherited).
|
||||
/// changed, else the touched layers and everything below those that moved, are new, chose
|
||||
/// another variable mode or became an instance of another main (modes are inherited, an
|
||||
/// instance's from its main too), and the instances of a main that chose another mode.
|
||||
fn binding_scope(doc: &Document, before: &Document, touched: &BTreeSet<NodeId>) -> Option<BTreeSet<NodeId>> {
|
||||
if doc.variables != before.variables {
|
||||
return None;
|
||||
}
|
||||
let main = |n: &Node| match n.kind {
|
||||
figcraft_doc::NodeKind::Instance { component, .. } => Some(component),
|
||||
_ => None,
|
||||
};
|
||||
let mut out = BTreeSet::new();
|
||||
for &id in touched {
|
||||
let Some(n) = doc.node(id) else { continue };
|
||||
let inherited = before.node(id).is_none_or(|b| b.var_modes != n.var_modes || b.parent != n.parent);
|
||||
if inherited {
|
||||
let Some(b) = before.node(id) else {
|
||||
out.extend(doc.subtree(id));
|
||||
continue;
|
||||
};
|
||||
let modes = b.var_modes != n.var_modes;
|
||||
if modes || b.parent != n.parent || main(b) != main(n) {
|
||||
out.extend(doc.subtree(id));
|
||||
} else {
|
||||
out.insert(id);
|
||||
}
|
||||
if modes && matches!(n.kind, figcraft_doc::NodeKind::Component { .. }) {
|
||||
for i in components::instances_of(doc, id) {
|
||||
out.extend(doc.subtree(i));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
@@ -1525,6 +1607,8 @@ mod binding_lists_tests;
|
||||
#[cfg(test)]
|
||||
mod booleans_tests;
|
||||
#[cfg(test)]
|
||||
mod bound_alpha_tests;
|
||||
#[cfg(test)]
|
||||
mod branch_tests;
|
||||
#[cfg(test)]
|
||||
mod branch_versions_tests;
|
||||
@@ -1567,6 +1651,8 @@ mod devfocus_tests;
|
||||
#[cfg(test)]
|
||||
mod devinspect_tests;
|
||||
#[cfg(test)]
|
||||
mod doc_urls_tests;
|
||||
#[cfg(test)]
|
||||
mod double_click_tests;
|
||||
#[cfg(test)]
|
||||
mod draw_tools_tests;
|
||||
@@ -1619,12 +1705,18 @@ mod import_journal_tests;
|
||||
#[cfg(test)]
|
||||
mod inspect_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod instance_modes_tests;
|
||||
#[cfg(test)]
|
||||
mod instance_resizing_tests;
|
||||
#[cfg(test)]
|
||||
mod interaction_rollback_tests;
|
||||
#[cfg(test)]
|
||||
mod invite_links_tests;
|
||||
#[cfg(test)]
|
||||
mod keymap_tests;
|
||||
#[cfg(test)]
|
||||
mod library_path_tests;
|
||||
#[cfg(test)]
|
||||
mod library_range_styles_tests;
|
||||
#[cfg(test)]
|
||||
mod library_variables_tests;
|
||||
@@ -1651,6 +1743,8 @@ mod menu_gaps_tests;
|
||||
#[cfg(test)]
|
||||
mod nested_override_tests;
|
||||
#[cfg(test)]
|
||||
mod node_set_refusals_tests;
|
||||
#[cfg(test)]
|
||||
mod online_tests;
|
||||
#[cfg(test)]
|
||||
mod open_issues_tests;
|
||||
@@ -1673,12 +1767,16 @@ mod panels_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod pdf_print_tests;
|
||||
#[cfg(test)]
|
||||
mod pen_end_tests;
|
||||
#[cfg(test)]
|
||||
mod pen_overlay_tests;
|
||||
#[cfg(test)]
|
||||
mod pixel_preview_tests;
|
||||
#[cfg(test)]
|
||||
mod playground_tests;
|
||||
#[cfg(test)]
|
||||
mod plugin_data_tests;
|
||||
#[cfg(test)]
|
||||
mod plugin_directory_tests;
|
||||
#[cfg(test)]
|
||||
mod plugin_panels_tests;
|
||||
@@ -1705,6 +1803,8 @@ mod proto_run_tests;
|
||||
#[cfg(test)]
|
||||
mod proto_tail_tests;
|
||||
#[cfg(test)]
|
||||
mod rest_slot_tests;
|
||||
#[cfg(test)]
|
||||
mod retained_walk_tests;
|
||||
#[cfg(test)]
|
||||
mod rotated_guides_tests;
|
||||
@@ -1777,6 +1877,8 @@ mod variables_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod variables_editor_tests;
|
||||
#[cfg(test)]
|
||||
mod variables_import_shapes_tests;
|
||||
#[cfg(test)]
|
||||
mod variables_io_tests;
|
||||
#[cfg(test)]
|
||||
mod vector_behaviour_tests;
|
||||
@@ -1792,3 +1894,8 @@ mod version_tests;
|
||||
mod view_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod widgets_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod selection_reorder_tests;
|
||||
#[cfg(test)]
|
||||
mod selection_spacing_tests;
|
||||
@@ -449,9 +449,27 @@ struct Stamp {
|
||||
#[derive(Clone, Debug)]
|
||||
struct Entry {
|
||||
stamp: Option<Stamp>,
|
||||
/// When the file was read: a stamp only proves the file unchanged once its modification time
|
||||
/// is safely older than that, since file systems keep the time in coarse ticks.
|
||||
read_at: Option<std::time::SystemTime>,
|
||||
result: std::result::Result<Arc<PublishedLibrary>, String>,
|
||||
}
|
||||
|
||||
/// How far a file's modification time must lie before its read for the stamp to be trusted.
|
||||
const RACY_STAMP: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
|
||||
impl Entry {
|
||||
/// A file stamped in the future (clock skew, unpacked archives) never settles: it is re-read on
|
||||
/// every load until the clock passes it.
|
||||
fn still_valid(&self, now: &Stamp) -> bool {
|
||||
let settled = match (now.modified, self.read_at) {
|
||||
(Some(modified), Some(read_at)) => modified.checked_add(RACY_STAMP).is_some_and(|t| t < read_at),
|
||||
_ => false,
|
||||
};
|
||||
settled && self.stamp.as_ref() == Some(now)
|
||||
}
|
||||
}
|
||||
|
||||
/// Library files read by a session, by path (re-read when they change on disk).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Cache {
|
||||
@@ -481,10 +499,11 @@ impl Cache {
|
||||
return Err(EngineError::Other(format!("{path} is too large to use as a library (the limit is {} MB)", max_bytes / 1024 / 1024)));
|
||||
}
|
||||
if let (Some(e), Ok(st)) = (self.entries.get(path), &stamp)
|
||||
&& e.stamp.as_ref() == Some(st)
|
||||
&& e.still_valid(st)
|
||||
{
|
||||
return e.result.clone().map_err(EngineError::Other);
|
||||
}
|
||||
let read_at = now();
|
||||
let result = match &stamp {
|
||||
Ok(_) => read_file(path).map(Arc::new),
|
||||
Err(e) => Err(e.clone()),
|
||||
@@ -492,7 +511,7 @@ impl Cache {
|
||||
if self.entries.len() >= MAX_CACHED && !self.entries.contains_key(path) {
|
||||
self.entries.clear();
|
||||
}
|
||||
self.entries.insert(path.to_string(), Entry { stamp: stamp.ok(), result: result.clone() });
|
||||
self.entries.insert(path.to_string(), Entry { stamp: stamp.ok(), read_at, result: result.clone() });
|
||||
result.map_err(EngineError::Other)
|
||||
}
|
||||
|
||||
@@ -506,13 +525,23 @@ impl Cache {
|
||||
if self.entries.len() >= MAX_CACHED && !self.entries.contains_key(path) {
|
||||
self.entries.clear();
|
||||
}
|
||||
self.entries.insert(path.to_string(), Entry { stamp: None, result });
|
||||
self.entries.insert(path.to_string(), Entry { stamp: None, read_at: None, result });
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn now() -> Option<std::time::SystemTime> {
|
||||
Some(std::time::SystemTime::now())
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn now() -> Option<std::time::SystemTime> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn stat(path: &str) -> std::result::Result<Stamp, String> {
|
||||
let m = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
let m = crate::file_access::metadata(path).map_err(|e| e.to_string())?;
|
||||
if !m.is_file() {
|
||||
return Err(format!("{path} is not a file"));
|
||||
}
|
||||
@@ -526,7 +555,7 @@ fn stat(_path: &str) -> std::result::Result<Stamp, String> {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_file(path: &str) -> std::result::Result<PublishedLibrary, String> {
|
||||
let f = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
let f = crate::file_access::open(path).map_err(|e| e.to_string())?;
|
||||
match figcraft_format::read_library(std::io::BufReader::new(f)) {
|
||||
Ok(Some(lib)) => Ok(lib),
|
||||
Ok(None) => Err(format!("{path} has no published library: open it and publish it first (Libraries ▸ Publish library…)")),
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
//! A library link is stored in the document, so it is followed only on this computer
|
||||
//! ([`crate::library_server`]): a path on another server would make opening someone else's file
|
||||
//! contact that server, and on Windows send the credentials of whoever opened it.
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn a_library_is_opened_only_from_this_computer() {
|
||||
let mut s = Session::new();
|
||||
for path in [
|
||||
r"\\attacker.example\share\lib.figcraft",
|
||||
"//attacker.example/share/lib.figcraft",
|
||||
"file://attacker.example/share/lib.figcraft",
|
||||
r"\\?\UNC\attacker.example\share\lib.figcraft",
|
||||
r"\\.\pipe\lib",
|
||||
"https://attacker.example/lib.figcraft",
|
||||
] {
|
||||
let e = s.fetch_library(path, false).expect_err(&format!("{path} must be refused"));
|
||||
assert!(e.to_string().contains("only from this computer"), "{path}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_local_library_path_is_still_followed() {
|
||||
let mut s = Session::new();
|
||||
// Refused for being absent, not for where it is: the gate let it through.
|
||||
let missing = std::env::temp_dir().join("figcraft-no-such-library.figcraft");
|
||||
let e = s.fetch_library(&missing.to_string_lossy(), false).expect_err("the file does not exist");
|
||||
assert!(!e.to_string().contains("only from this computer"), "a local path must not be refused as remote: {e}");
|
||||
}
|
||||
@@ -129,6 +129,13 @@ impl Session {
|
||||
/// document uses the library.
|
||||
pub fn fetch_library(&mut self, path: &str, fresh: bool) -> Result<Arc<PublishedLibrary>> {
|
||||
if !is_server_link(path) {
|
||||
// A library link is stored in the document, so opening someone else's file would
|
||||
// otherwise reach whatever path it names. Only a path on this computer is followed: a
|
||||
// network share would make opening a document contact that server, and on Windows
|
||||
// send the credentials of whoever opened it.
|
||||
if !craft_docpath::is_local(path) {
|
||||
return Err(EngineError::Other(format!("{path}: a library is opened only from this computer")));
|
||||
}
|
||||
return self.libraries.load(path);
|
||||
}
|
||||
let have = match self.libraries.cached(path) {
|
||||
|
||||
@@ -445,3 +445,21 @@ fn library_survives_saving_with_versions() {
|
||||
healthy(&user);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_stamp_from_the_moment_it_was_read_does_not_prove_it_unchanged() {
|
||||
use std::time::{Duration, SystemTime};
|
||||
let read_at = SystemTime::now();
|
||||
let entry = |modified: SystemTime| Entry {
|
||||
stamp: Some(Stamp { len: 10, modified: Some(modified) }),
|
||||
read_at: Some(read_at),
|
||||
result: Err("unused".into()),
|
||||
};
|
||||
// Written in the same coarse tick as the read: a same-length rewrite would keep this stamp.
|
||||
let fresh = entry(read_at);
|
||||
assert!(!fresh.still_valid(&Stamp { len: 10, modified: Some(read_at) }));
|
||||
// Written well before the read: an unchanged stamp means an unchanged file.
|
||||
let old = read_at - Duration::from_secs(10);
|
||||
assert!(entry(old).still_valid(&Stamp { len: 10, modified: Some(old) }));
|
||||
assert!(!entry(old).still_valid(&Stamp { len: 11, modified: Some(old) }));
|
||||
}
|
||||
@@ -81,11 +81,15 @@ pub const PEOPLE: [&str; 8] = ["#0D99FF", "#9747FF", "#14AE5C", "#F24822", "#D97
|
||||
|
||||
/// `#RRGGBB` → (r, g, b); `None` for anything else.
|
||||
pub fn parse_color(s: &str) -> Option<(u8, u8, u8)> {
|
||||
let hex = s.strip_prefix('#').filter(|h| h.len() == 6 && h.is_ascii())?;
|
||||
let hex = s.strip_prefix('#').filter(|h| h.len() == 6 && h.bytes().all(|b| b.is_ascii_hexdigit()))?;
|
||||
let byte = |i: usize| hex.get(i..i + 2).and_then(|b| u8::from_str_radix(b, 16).ok());
|
||||
Some((byte(0)?, byte(2)?, byte(4)?))
|
||||
}
|
||||
|
||||
/// Why a join failed when a message from the host is over this app's size limit (`{mb}`: the
|
||||
/// limit in MB). The UI translates it.
|
||||
pub const TOO_LARGE_TO_JOIN: &str = "This file is too large to join here (up to {mb} MB)";
|
||||
|
||||
/// The connection to a live session.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "state")]
|
||||
|
||||
@@ -507,3 +507,11 @@ fn hosting_commands_check_what_they_are_given() {
|
||||
assert!(e.contains("join a file"), "{e}");
|
||||
assert!(s.execute("collab.report", &json!({"reason": " \n "})).unwrap_err().to_string().contains("few words"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_color_rejects_signs_and_non_hex() {
|
||||
use crate::live::parse_color;
|
||||
assert_eq!(parse_color("#+1+2+3"), None);
|
||||
assert_eq!(parse_color("#-1-2-3"), None);
|
||||
assert_eq!(parse_color("#0D99FF"), Some((13, 153, 255)));
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//! `node.set` doesn't store what can't apply: auto layout keys go only to layers that can have auto
|
||||
//! layout (the others are named in `warnings`, and with none of them the call is refused), and a
|
||||
//! `fontStyle` the family lacks says which face draws instead.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
fn id(v: &Value) -> crate::NodeId {
|
||||
crate::params::id_of(v).unwrap()
|
||||
}
|
||||
|
||||
fn bad_msg(r: crate::Result<Value>) -> String {
|
||||
match r {
|
||||
Err(EngineError::BadParams { msg, .. }) => msg,
|
||||
other => panic!("expected BadParams, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_layout_keys_on_layers_without_auto_layout_are_refused() {
|
||||
let mut s = Session::new();
|
||||
let t = id(&ex(&mut s, "node.create", json!({"type": "text", "text": "Hi"}))["id"]);
|
||||
let r = id(&ex(&mut s, "node.create", json!({"type": "rectangle"}))["id"]);
|
||||
for (target, key, value) in [(t, "gap", json!(8)), (r, "padding", json!(4)), (t, "mainAlign", json!("center")), (r, "wrap", json!(true))] {
|
||||
let msg = bad_msg(s.execute("node.set", &json!({"ids": [target], "props": {key: value}})));
|
||||
assert!(msg.contains(key) && msg.contains("auto layout"), "{msg}");
|
||||
assert!(s.doc().unwrap().node(target).unwrap().layout.is_none(), "no hidden auto layout on {target}");
|
||||
}
|
||||
// Frames still take them, with or without a layout mode first.
|
||||
let f = id(&ex(&mut s, "node.create", json!({"type": "frame"}))["id"]);
|
||||
let out = ex(&mut s, "node.set", json!({"ids": [f], "props": {"gap": 8, "padding": 4}}));
|
||||
assert_eq!(s.doc().unwrap().node(f).unwrap().layout.as_ref().map(|l| l.gap), Some(8.0));
|
||||
assert!(out.get("warnings").is_none(), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_several_layers_the_frames_take_auto_layout_keys_and_the_others_are_named() {
|
||||
let mut s = Session::new();
|
||||
let f = id(&ex(&mut s, "node.create", json!({"type": "frame", "name": "Card"}))["id"]);
|
||||
let t = id(&ex(&mut s, "node.create", json!({"type": "text", "text": "Hi", "name": "Title"}))["id"]);
|
||||
let out = ex(&mut s, "node.set", json!({"ids": [f, t], "props": {"gap": 8, "opacity": 0.5}}));
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(doc.node(f).unwrap().layout.as_ref().map(|l| l.gap), Some(8.0));
|
||||
assert!(doc.node(t).unwrap().layout.is_none(), "no hidden auto layout on the text");
|
||||
assert_eq!(doc.node(t).unwrap().opacity, 0.5, "the text still gets the other keys");
|
||||
let w = out["warnings"][0].as_str().unwrap();
|
||||
assert!(w.contains("`gap`") && w.contains(&format!("{t} “Title”")) && w.contains("TEXT"), "{w}");
|
||||
// One undo step for all of it.
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc().unwrap().node(f).unwrap().layout.as_ref().is_none_or(|l| l.gap != 8.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_font_style_draws_in_the_nearest_face_and_says_so_when_it_differs() {
|
||||
figcraft_text::scan_system_fonts();
|
||||
// Any installed family with a regular and a bold face and nothing heavier (CI machines differ).
|
||||
let pick = figcraft_text::family_names().into_iter().find_map(|f| {
|
||||
let st = figcraft_text::family_styles(&f);
|
||||
let has = |w: u16| st.iter().any(|x| figcraft_doc::style_weight(x) == w && !x.to_lowercase().contains("italic"));
|
||||
(has(400) && has(700) && !st.iter().any(|x| figcraft_doc::style_weight(x) > 700)).then_some((f, st))
|
||||
});
|
||||
let Some((family, styles)) = pick else { return };
|
||||
let mut s = Session::new();
|
||||
let t = id(&ex(&mut s, "node.create", json!({"type": "text", "text": "Hi", "props": {"fontFamily": family}}))["id"]);
|
||||
let set = |s: &mut Session, style: &str| ex(s, "node.set", json!({"ids": [t], "props": {"fontStyle": style}}));
|
||||
// Any spelling of a style it has, and names that resolve to the weight they ask for, say nothing.
|
||||
let bold = styles.iter().find(|x| figcraft_doc::style_weight(x) == 700).unwrap().clone();
|
||||
for style in [bold.clone(), bold.to_uppercase(), bold.replace(' ', ""), "Roman".into(), "Normal".into()] {
|
||||
let out = set(&mut s, &style);
|
||||
assert!(out.get("warnings").is_none(), "{style}: {out}");
|
||||
}
|
||||
// A heavier weight than the family has applies (as a text layer resolves it) and says which face draws.
|
||||
let out = set(&mut s, "Black");
|
||||
let w = out["warnings"][0].as_str().unwrap();
|
||||
assert!(w.contains(&family) && w.contains("`Black`") && w.contains("nearest"), "{w}");
|
||||
assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().spans()[0].2.font_style, "Black");
|
||||
// A family that isn't installed keeps any style name without a word (files from elsewhere name theirs).
|
||||
let out = ex(&mut s, "node.set", json!({"ids": [t], "props": {"fontFamily": "No Such Family 7f3a", "fontStyle": "Hairline"}}));
|
||||
assert!(out.get("warnings").is_none(), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_call_changes_nothing() {
|
||||
let mut s = Session::new();
|
||||
let t = id(&ex(&mut s, "node.create", json!({"type": "text", "text": "Hi"}))["id"]);
|
||||
let before = s.doc().unwrap().node(t).unwrap().fills.clone();
|
||||
// `fill` comes before `gap` (keys apply in order), so this checks the rollback.
|
||||
assert!(s.execute("node.set", &json!({"ids": [t], "props": {"fill": "#FF0000", "gap": 8}})).is_err());
|
||||
assert_eq!(s.doc().unwrap().node(t).unwrap().fills, before);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//! Ending a pen path with Escape or Enter (#760): the path's vector stays in edit mode with the pen,
|
||||
//! so the next path is drawn into the same layer; another Escape leaves edit mode.
|
||||
|
||||
use crate::Session;
|
||||
use crate::tools::{PointerEvent, PointerKind, ToolId};
|
||||
use crate::vectoredit::VTool;
|
||||
use figcraft_doc::NodeKind;
|
||||
|
||||
fn click(s: &mut Session, x: f64, y: f64) {
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::at(kind, x, y, 1.0)).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn layers(s: &Session) -> usize {
|
||||
s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len()
|
||||
}
|
||||
|
||||
/// (points, segments) of vector `id`.
|
||||
fn shape(s: &Session, id: figcraft_doc::NodeId) -> (usize, usize) {
|
||||
match &s.doc().unwrap().node(id).unwrap().kind {
|
||||
NodeKind::Vector { network } => (network.vertices.len(), network.segments.len()),
|
||||
k => panic!("not a vector: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A two-point pen path, ended with `end`.
|
||||
fn drawn(end: fn(&mut Session)) -> (Session, figcraft_doc::NodeId) {
|
||||
let mut s = Session::new();
|
||||
s.set_tool(ToolId::Pen);
|
||||
click(&mut s, 100.0, 100.0);
|
||||
click(&mut s, 200.0, 100.0);
|
||||
let id = s.tool.pen.as_ref().and_then(|p| p.id).unwrap();
|
||||
let undo = s.undo_depth().0;
|
||||
end(&mut s);
|
||||
assert!(s.tool.pen.is_none(), "the path ended");
|
||||
assert_eq!(s.undo_depth().0, undo + 1, "the path is one undo step");
|
||||
let ve = s.ds().unwrap().vector_edit.clone().expect("still editing the vector");
|
||||
assert_eq!((ve.id, ve.tool, ve.anchor), (id, VTool::Pen, None), "the pen, with no open end");
|
||||
(s, id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_and_enter_keep_the_vector_in_edit_mode_with_the_pen() {
|
||||
// Escape, and Enter (its key runs `pen_end`).
|
||||
let ends: [fn(&mut Session); 2] = [|s| s.escape().unwrap(), |s| s.pen_end().unwrap()];
|
||||
for end in ends {
|
||||
let (mut s, id) = drawn(end);
|
||||
// The next path goes into the same layer, unconnected to the first.
|
||||
click(&mut s, 100.0, 300.0);
|
||||
click(&mut s, 200.0, 300.0);
|
||||
assert_eq!(layers(&s), 1, "no new layer");
|
||||
assert_eq!(shape(&s, id), (4, 2), "two separate paths in one vector network");
|
||||
// Escape ends that path, the next one leaves edit mode.
|
||||
s.escape().unwrap();
|
||||
assert!(s.ds().unwrap().vector_edit.is_some());
|
||||
s.escape().unwrap();
|
||||
assert!(s.ds().unwrap().vector_edit.is_none(), "the second Escape leaves edit mode");
|
||||
assert_eq!(s.ds().unwrap().selection, vec![id]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_before_a_segment_leaves_nothing_to_edit() {
|
||||
let mut s = Session::new();
|
||||
s.set_tool(ToolId::Pen);
|
||||
click(&mut s, 100.0, 100.0);
|
||||
s.escape().unwrap();
|
||||
assert!(s.tool.pen.is_none());
|
||||
assert!(s.ds().unwrap().vector_edit.is_none());
|
||||
assert_eq!(layers(&s), 0);
|
||||
}
|
||||
@@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
if let Some(path) = string(p, "path") {
|
||||
let scale = num(p, "scale").filter(|k| k.is_finite()).map_or(2.0, |k| k.clamp(0.01, 16.0));
|
||||
let png = crate::cmd::file::export_bytes(©, &[inst], ExportFormat::Png, scale)?;
|
||||
std::fs::write(&path, png).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
crate::file_access::write(&path, &png)?;
|
||||
out["path"] = json!(path);
|
||||
}
|
||||
Ok(out)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
//! Layer data (`pluginData`) reads back through `node.get` and `document.inspect` (#879): what
|
||||
//! `node.set` writes on a layer or a page comes back as `{key: value}`, a removed key goes, and a
|
||||
//! layer without any reports none.
|
||||
|
||||
use crate::Session;
|
||||
use crate::components::tests::{ex, idv};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn node_get_reports_the_layer_data_node_set_wrote() {
|
||||
let mut s = Session::new();
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
|
||||
let r = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})));
|
||||
let page = s.ds().unwrap().page;
|
||||
assert!(ex(&mut s, "node.get", json!({"id": f})).get("pluginData").is_none(), "no data, no field");
|
||||
ex(&mut s, "node.set", json!({"ids": [f], "props": {"pluginData": {"com.example.sync": "{\"v\":1}", "n": 2}}}));
|
||||
ex(&mut s, "node.set", json!({"ids": [page], "props": {"pluginData": {"com.example.sync": "page"}}}));
|
||||
let got = ex(&mut s, "node.get", json!({"id": f}))["pluginData"].clone();
|
||||
assert_eq!(got, json!({"com.example.sync": "{\"v\":1}", "n": "2"}), "values read back as the strings stored");
|
||||
assert_eq!(ex(&mut s, "node.get", json!({"id": page}))["pluginData"], json!({"com.example.sync": "page"}), "pages keep data too");
|
||||
assert!(ex(&mut s, "node.get", json!({"id": r})).get("pluginData").is_none(), "other layers report none");
|
||||
// The document tree reports it as well (the page and its children).
|
||||
let tree = crate::inspect::document(&s, 2, false);
|
||||
assert_eq!(tree["pages"][0]["pluginData"], json!({"com.example.sync": "page"}));
|
||||
// A null removes a key; undo brings it back.
|
||||
ex(&mut s, "node.set", json!({"ids": [f], "props": {"pluginData": {"n": null}}}));
|
||||
assert_eq!(ex(&mut s, "node.get", json!({"id": f}))["pluginData"], json!({"com.example.sync": "{\"v\":1}"}));
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(ex(&mut s, "node.get", json!({"id": f}))["pluginData"]["n"], "2");
|
||||
}
|
||||
@@ -147,6 +147,9 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// Keep an installed plugin's files in its own folder under the install folder (named by the hash
|
||||
/// of its id, so no id reaches the file system), its module renamed; the manifest's path. Without
|
||||
/// an install folder (the browser, the CLI and tests) the plugin lasts for the session.
|
||||
// The install folder is the app's own (set by the desktop app, never by a command), so automation
|
||||
// roots don't apply to it.
|
||||
#[allow(clippy::disallowed_methods)]
|
||||
fn save(s: &Session, m: &mut Manifest, b: &Bundle) -> std::result::Result<Option<String>, String> {
|
||||
let Some(root) = s.plugins.install_dir.clone() else { return Ok(None) };
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -189,6 +192,7 @@ fn save(s: &Session, m: &mut Manifest, b: &Bundle) -> std::result::Result<Option
|
||||
}
|
||||
|
||||
/// Delete the folder of a plugin installed from a directory (only one inside the install folder).
|
||||
#[allow(clippy::disallowed_methods)]
|
||||
pub(crate) fn delete_installed(s: &Session, p: &InstalledPlugin) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let (Some(_), Some(path), Some(root)) = (&p.source, &p.path, &s.plugins.install_dir)
|
||||
@@ -208,7 +212,7 @@ pub(crate) fn delete_installed(s: &Session, p: &InstalledPlugin) {
|
||||
fn bundle_from_disk(cmd: &str, path: &str) -> Result<Bundle> {
|
||||
use crate::plugins::{module_file, read_file};
|
||||
let mut file = std::path::PathBuf::from(crate::library::absolute_path(path.trim()));
|
||||
if file.is_dir() {
|
||||
if crate::file_access::is_dir(&file) {
|
||||
file.push(MANIFEST_FILE);
|
||||
}
|
||||
let text =
|
||||
@@ -217,9 +221,12 @@ fn bundle_from_disk(cmd: &str, path: &str) -> Result<Bundle> {
|
||||
let wasm = module_file(&file, &m)?;
|
||||
let mut assets = Vec::new();
|
||||
let dir = file.parent().map(|d| d.join(ASSETS));
|
||||
if let Some(entries) = dir.as_ref().and_then(|d| std::fs::read_dir(d).ok()) {
|
||||
let mut names: Vec<(String, std::path::PathBuf)> =
|
||||
entries.flatten().filter(|e| e.path().is_file()).map(|e| (e.file_name().to_string_lossy().into_owned(), e.path())).collect();
|
||||
if let Some(entries) = dir.as_ref().and_then(|d| crate::file_access::read_dir(d).ok()) {
|
||||
let mut names: Vec<(String, std::path::PathBuf)> = entries
|
||||
.into_iter()
|
||||
.filter(|p| crate::file_access::metadata(p).is_ok_and(|m| m.is_file()))
|
||||
.map(|p| (p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), p))
|
||||
.collect();
|
||||
names.sort();
|
||||
if names.len() > MAX_ASSETS {
|
||||
return Err(bad(cmd, format!("a plugin's assets folder holds at most {MAX_ASSETS} files")));
|
||||
|
||||
@@ -251,12 +251,11 @@ pub(crate) fn installed<'a>(s: &'a Session, cmd: &str, id: &str) -> Result<&'a I
|
||||
/// Read a file of at most `max` bytes.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn read_file(path: &std::path::Path, max: usize) -> Result<Vec<u8>> {
|
||||
let shown = path.display();
|
||||
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{shown}: {e}")))?;
|
||||
let meta = crate::file_access::metadata(path)?;
|
||||
if !meta.is_file() || meta.len() > max as u64 {
|
||||
return Err(EngineError::Other(format!("{shown}: not a file of at most {} KB", max / 1024)));
|
||||
return Err(EngineError::Other(format!("{}: not a file of at most {} KB", path.display(), max / 1024)));
|
||||
}
|
||||
std::fs::read(path).map_err(|e| EngineError::Other(format!("{shown}: {e}")))
|
||||
crate::file_access::read(path)
|
||||
}
|
||||
|
||||
/// The manifest at `path` (the file, or the folder holding `figcraft-plugin.json`), its absolute
|
||||
@@ -264,7 +263,7 @@ pub(crate) fn read_file(path: &std::path::Path, max: usize) -> Result<Vec<u8>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn load_from_disk(cmd: &str, path: &str) -> Result<(Manifest, String, Vec<u8>)> {
|
||||
let mut file = std::path::PathBuf::from(crate::library::absolute_path(path.trim()));
|
||||
if file.is_dir() {
|
||||
if crate::file_access::is_dir(&file) {
|
||||
file.push(MANIFEST_FILE);
|
||||
}
|
||||
let text = read_file(&file, figcraft_plugin::manifest::MAX_MANIFEST_BYTES)?;
|
||||
@@ -295,10 +294,22 @@ pub(crate) fn module_of(s: &Session, p: &InstalledPlugin) -> Result<Arc<Vec<u8>>
|
||||
let Some(path) = &p.path else {
|
||||
return Err(EngineError::Other(format!("{} was imported without a manifest file: import it again", p.manifest.name)));
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
return module_file(std::path::Path::new(path), &p.manifest).map(Arc::new);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
return Err(EngineError::Other(format!("{path}: there are no files in the browser")));
|
||||
module_from_disk(s, p, path)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn module_from_disk(s: &Session, p: &InstalledPlugin, path: &str) -> Result<Arc<Vec<u8>>> {
|
||||
let file = std::path::Path::new(path);
|
||||
let read = || module_file(file, &p.manifest).map(Arc::new);
|
||||
// A plugin installed from a directory lives in the app's own install folder, not among the
|
||||
// files automation roots confine.
|
||||
let own = p.source.is_some() && s.plugins.install_dir.as_deref().is_some_and(|d| crate::file_access::within_app_folder(file, d));
|
||||
if own { crate::file_access::app_owned(read) } else { read() }
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn module_from_disk(_: &Session, _: &InstalledPlugin, path: &str) -> Result<Arc<Vec<u8>>> {
|
||||
Err(EngineError::Other(format!("{path}: there are no files in the browser")))
|
||||
}
|
||||
|
||||
/// The sandbox limits for a run, with `fuel` lowering the instruction budget.
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
//! Slots opened from REST file JSON: instances keep their own slot content through edits and
|
||||
//! moves of the mains, and through saving.
|
||||
|
||||
use crate::Session;
|
||||
use crate::components::tests::{ex, healthy, idv};
|
||||
use crate::components::*;
|
||||
use base64::Engine as _;
|
||||
use figcraft_doc::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn at(x: f64, y: f64) -> Value {
|
||||
json!([[1, 0, x], [0, 1, y]])
|
||||
}
|
||||
|
||||
fn text(id: &str, s: &str) -> Value {
|
||||
json!({"id": id, "name": s, "type": "TEXT", "characters": s, "relativeTransform": at(0.0, 0.0), "size": {"x": 100, "y": 20}})
|
||||
}
|
||||
|
||||
/// A Chip main, a Container main with an empty "Slot", and an instance of Container whose slot
|
||||
/// holds a text and a chip.
|
||||
fn file() -> Value {
|
||||
let slot = |id: &str, kids: Value| json!({"id": id, "name": "Slot", "type": "SLOT", "relativeTransform": at(10.0, 40.0), "size": {"x": 280, "y": 100}, "children": kids});
|
||||
let guid = |l: u32| json!({"guid": {"sessionID": 5, "localID": l}});
|
||||
let chip = json!({"id": "2:3", "name": "Chip", "type": "INSTANCE", "componentId": "1:9", "relativeTransform": at(0.0, 30.0), "size": {"x": 40, "y": 20},
|
||||
"children": [{"id": "I2:3;1:10", "name": "Dot", "type": "ELLIPSE", "relativeTransform": at(2.0, 2.0), "size": {"x": 16, "y": 16}}]});
|
||||
json!({"name": "Slots", "document": {"id": "0:0", "type": "DOCUMENT", "children": [{"id": "0:1", "name": "Page", "type": "CANVAS", "children": [
|
||||
{"id": "1:9", "name": "Chip", "type": "COMPONENT", "relativeTransform": at(0.0, 400.0), "size": {"x": 40, "y": 20}, "children": [
|
||||
{"id": "1:10", "name": "Dot", "type": "ELLIPSE", "relativeTransform": at(2.0, 2.0), "size": {"x": 16, "y": 16}}
|
||||
]},
|
||||
{"id": "1:2", "name": "Container", "type": "COMPONENT", "relativeTransform": at(0.0, 0.0), "size": {"x": 300, "y": 160},
|
||||
"componentPropertyDefinitions": {"Slot#1:3": {"type": "SLOT", "defaultValue": guid(1)}},
|
||||
"children": [text("1:4", "Title"), slot("1:5", json!([]))]},
|
||||
{"id": "2:1", "name": "Container", "type": "INSTANCE", "componentId": "1:2", "relativeTransform": at(400.0, 0.0), "size": {"x": 300, "y": 160},
|
||||
"componentProperties": {"Slot#1:3": {"type": "SLOT", "value": guid(7)}},
|
||||
"children": [text("I2:1;1:4", "Title"), slot("I2:1;1:5", json!([text("2:2", "Reports in progress"), chip]))]}
|
||||
]}]}})
|
||||
}
|
||||
|
||||
fn named(doc: &Document, kind: fn(&NodeKind) -> bool, name: &str) -> NodeId {
|
||||
doc.nodes().find(|n| n.name == name && kind(&n.kind)).map(|n| n.id).unwrap_or_else(|| panic!("no {name}"))
|
||||
}
|
||||
|
||||
/// The instance's slot and the names of what it holds.
|
||||
fn slot_content(s: &Session) -> (NodeId, Vec<String>) {
|
||||
let doc = s.doc().unwrap();
|
||||
let inst = named(doc, |k| matches!(k, NodeKind::Instance { .. }), "Container");
|
||||
let (slot, _) = slots_of(doc, inst).into_iter().next().expect("the instance has its slot");
|
||||
let names = doc.node(slot).unwrap().children.iter().map(|c| doc.node(*c).unwrap().name.clone()).collect();
|
||||
(slot, names)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rest_slot_content_survives_main_edits_moves_and_saving() {
|
||||
let mut s = Session::new();
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&file()).unwrap());
|
||||
ex(&mut s, "file.open", json!({"data": data, "name": "slots.json"}));
|
||||
healthy(&s);
|
||||
let want = vec!["Reports in progress".to_string(), "Chip".to_string()];
|
||||
let (slot, names) = slot_content(&s);
|
||||
assert_eq!(names, want);
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(slot_name(doc, slot).as_deref(), Some("Slot"));
|
||||
assert!(slot_edited(doc, slot), "the instance's own content");
|
||||
let chip = named(doc, |k| matches!(k, NodeKind::Instance { .. }), "Chip");
|
||||
assert!(!inside_instance(doc, chip), "content stands alone");
|
||||
|
||||
// Edit and move both mains (the container's and the one used in the content).
|
||||
let main = named(doc, |k| matches!(k, NodeKind::Component { .. }), "Container");
|
||||
let chip_main = named(doc, |k| matches!(k, NodeKind::Component { .. }), "Chip");
|
||||
let title = doc.node(main).unwrap().children[0];
|
||||
ex(&mut s, "node.set", json!({"id": title, "props": {"fill": "#ff0000"}}));
|
||||
ex(&mut s, "node.set", json!({"id": main, "props": {"width": 320}}));
|
||||
let holder = idv(&ex(&mut s, "frame.create", json!({"x": -1000, "y": 0, "width": 900, "height": 900, "name": "Design system"})));
|
||||
ex(&mut s, "node.reparent", json!({"ids": [main, chip_main], "parent": holder}));
|
||||
healthy(&s);
|
||||
assert_eq!(slot_content(&s).1, want, "kept across syncs");
|
||||
|
||||
// Saving and opening again keeps it.
|
||||
let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||
let mut again = Session::new();
|
||||
ex(&mut again, "file.open", json!({"data": data, "name": "slots.figcraft"}));
|
||||
healthy(&again);
|
||||
let (slot, names) = slot_content(&again);
|
||||
assert_eq!(names, want);
|
||||
assert!(slot_edited(again.doc().unwrap(), slot));
|
||||
|
||||
// Resetting the slot shows the main's (empty) default content.
|
||||
ex(&mut again, "instance.resetSlot", json!({"id": slot}));
|
||||
assert!(slot_content(&again).1.is_empty());
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Paints used by the selection, including descendants and effective text-range fills.
|
||||
//! Targets name paint slots rather than colors: a live edit must not capture other slots when
|
||||
//! its color happens to become equal to another selection color.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use figcraft_color::{Paint, PaintKind};
|
||||
use figcraft_doc::{Document, NodeId, NodeKind, StyleKind};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cmd::lists::{BoundList, list_binding, rebind_edited_paint};
|
||||
use crate::params::*;
|
||||
use crate::{CommandSpec, Result, Session, bad, spec};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct PaintTarget {
|
||||
pub id: NodeId,
|
||||
pub fills: bool,
|
||||
pub index: usize,
|
||||
/// Character offsets, not byte offsets. None means the node's fill or stroke list.
|
||||
pub range: Option<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl PaintTarget {
|
||||
pub fn paints(&self, doc: &Document) -> Option<Vec<Paint>> {
|
||||
let n = doc.node(self.id)?;
|
||||
if let Some((start, end)) = self.range {
|
||||
let t = n.kind.text()?;
|
||||
if !self.fills || start > end || end > t.char_count() || (start == end && end != 0) {
|
||||
return None;
|
||||
}
|
||||
Some(t.style_at(start).fills.clone().unwrap_or_else(|| n.fills.clone()))
|
||||
} else {
|
||||
Some(if self.fills { n.fills.clone() } else { n.strokes.clone() })
|
||||
}
|
||||
}
|
||||
|
||||
pub fn paint(&self, doc: &Document) -> Option<Paint> {
|
||||
self.paints(doc)?.get(self.index).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct SelectionColor {
|
||||
pub paint: Paint,
|
||||
pub style: Option<String>,
|
||||
pub variable: Option<String>,
|
||||
pub targets: Vec<PaintTarget>,
|
||||
}
|
||||
|
||||
/// Deterministic order, one target per slot even when a parent and child are both selected.
|
||||
/// Images and patterns are assets rather than selection colors. Hidden paints are omitted.
|
||||
pub fn collect(doc: &Document, ids: &[NodeId]) -> Vec<SelectionColor> {
|
||||
let mut groups: Vec<SelectionColor> = Vec::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut by_paint: BTreeMap<(Option<String>, Option<String>, String), usize> = BTreeMap::new();
|
||||
let mut pending: Vec<_> = ids.iter().rev().copied().collect();
|
||||
while let Some(id) = pending.pop() {
|
||||
if !seen.insert(id) {
|
||||
continue;
|
||||
}
|
||||
let Some(n) = doc.node(id) else { continue };
|
||||
if n.is_mask {
|
||||
continue;
|
||||
}
|
||||
if !matches!(n.kind, NodeKind::BooleanOp { .. }) {
|
||||
pending.extend(n.children.iter().rev().copied());
|
||||
}
|
||||
let mut add = |paints: &[Paint], fills: bool, range: Option<(usize, usize)>| {
|
||||
for (index, paint) in paints.iter().enumerate() {
|
||||
if !paint.visible || !matches!(paint.kind, PaintKind::Solid { .. } | PaintKind::Gradient(_)) {
|
||||
continue;
|
||||
}
|
||||
let target = PaintTarget { id, fills, index, range };
|
||||
let inherited = range.is_none_or(|(start, _)| n.kind.text().is_some_and(|t| t.style_at(start).fills.is_none()));
|
||||
let style = inherited.then(|| if fills { n.styles.fill.clone() } else { n.styles.stroke.clone() }).flatten();
|
||||
let list = if fills { BoundList::Fills } else { BoundList::Strokes };
|
||||
let variable = inherited
|
||||
.then(|| {
|
||||
n.bound.iter().find_map(|(key, var)| {
|
||||
list_binding(key).filter(|(l, i, field)| *l == list && *i == index && *field == "color").map(|_| var.clone())
|
||||
})
|
||||
})
|
||||
.flatten();
|
||||
let Ok(value) = serde_json::to_string(paint) else { continue };
|
||||
let key = (style.clone(), variable.clone(), value);
|
||||
if let Some(index) = by_paint.get(&key).copied() {
|
||||
if let Some(g) = groups.get_mut(index) {
|
||||
g.targets.push(target);
|
||||
}
|
||||
} else {
|
||||
by_paint.insert(key, groups.len());
|
||||
groups.push(SelectionColor { paint: paint.clone(), style, variable, targets: vec![target] });
|
||||
}
|
||||
}
|
||||
};
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
let spans = t.spans();
|
||||
if spans.iter().all(|(_, _, style)| style.fills.is_none()) {
|
||||
add(&n.fills, true, None);
|
||||
} else {
|
||||
for (start, end, style) in spans {
|
||||
add(style.fills.as_deref().unwrap_or(&n.fills), true, Some((start, end)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
add(&n.fills, true, None);
|
||||
}
|
||||
add(&n.strokes, false, None);
|
||||
}
|
||||
groups
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!(
|
||||
"selection.colors",
|
||||
"Selection colors",
|
||||
"{ids?} → {colors: [{paint, targets}]}; includes descendants and text ranges",
|
||||
false,
|
||||
|s, p| { Ok(json!({"colors": collect(s.doc()?, &targets(s, p))})) }
|
||||
),
|
||||
spec!("selection.setPaint", "Change selection color", "{targets: [{id, fills, index, range: [start,end] | null}], paint}", true, set_paint),
|
||||
spec!(
|
||||
"selection.applyColorStyle",
|
||||
"Apply selection color style",
|
||||
"{targets, style}; applies the color style to matching fill/stroke lists (text ranges keep typography)",
|
||||
true,
|
||||
apply_style
|
||||
),
|
||||
spec!(
|
||||
"selection.selectColor",
|
||||
"Select layers with color",
|
||||
"{targets} → {ids}; selects the layers owning these paint slots",
|
||||
false,
|
||||
select_color
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn checked_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<PaintTarget>> {
|
||||
let ts: Vec<PaintTarget> = parse(cmd, p, "targets")?.ok_or_else(|| bad(cmd, "`targets` is required"))?;
|
||||
if ts.is_empty() {
|
||||
return Err(bad(cmd, "`targets` must not be empty"));
|
||||
}
|
||||
for t in &ts {
|
||||
if t.paint(s.doc()?).is_none() {
|
||||
return Err(bad(cmd, "a selection color target no longer exists"));
|
||||
}
|
||||
}
|
||||
Ok(ts)
|
||||
}
|
||||
|
||||
fn set_paint(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "selection.setPaint";
|
||||
let targets = checked_targets(s, p, CMD)?;
|
||||
let paint = get(p, "paint").and_then(paint_of).ok_or_else(|| bad(CMD, "`paint` must be a paint"))?;
|
||||
for target in &targets {
|
||||
let mut paints = target.paints(s.doc()?).ok_or_else(|| bad(CMD, "invalid target"))?;
|
||||
let Some(slot) = paints.get_mut(target.index) else { return Err(bad(CMD, "invalid paint index")) };
|
||||
if *slot == paint {
|
||||
continue;
|
||||
}
|
||||
let old = slot.clone();
|
||||
*slot = paint.clone();
|
||||
let n = s.doc_mut()?.get_mut(target.id)?;
|
||||
// Variables detach only where the edit changed their value; range fills override the node's
|
||||
// paints, whose bindings still belong to the other characters.
|
||||
if let Some((start, end)) = target.range {
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
t.restyle(start, end, |style| style.fills = Some(paints.clone()));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if target.fills {
|
||||
n.fills = paints;
|
||||
n.styles.fill = None;
|
||||
} else {
|
||||
n.strokes = paints;
|
||||
n.styles.stroke = None;
|
||||
}
|
||||
rebind_edited_paint(n, target.fills, target.index, &old);
|
||||
}
|
||||
Ok(json!({"ok": true}))
|
||||
}
|
||||
|
||||
fn apply_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "selection.applyColorStyle";
|
||||
let targets = checked_targets(s, p, CMD)?;
|
||||
let name = req_string(CMD, p, "style")?;
|
||||
let style = s.doc()?.style(&name).cloned().ok_or_else(|| bad(CMD, "no such color style"))?;
|
||||
if !matches!(style.kind, StyleKind::Paint { .. }) {
|
||||
return Err(bad(CMD, "a color style is required"));
|
||||
}
|
||||
for target in &targets {
|
||||
if let Some((start, end)) = target.range {
|
||||
// Text ranges have typography-style links, not paint-style links. Copy resolved paint
|
||||
// values just as an explicit range fill does; never replace its font or size.
|
||||
let StyleKind::Paint { paints } = crate::style_vars::kind_for(s.doc()?, &style, target.id).into_owned() else { continue };
|
||||
let n = s.doc_mut()?.get_mut(target.id)?;
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
t.restyle(start, end, |st| st.fills = Some(paints.clone()));
|
||||
}
|
||||
} else {
|
||||
crate::cmd::styles::apply_style_ref(s.doc_mut()?, target.id, if target.fills { "fillStyle" } else { "strokeStyle" }, Some(&style.id))?;
|
||||
}
|
||||
}
|
||||
Ok(json!({"ok": true}))
|
||||
}
|
||||
|
||||
fn select_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ts = checked_targets(s, p, "selection.selectColor")?;
|
||||
let ids: Vec<_> = ts.iter().map(|t| t.id).collect::<BTreeSet<_>>().into_iter().collect();
|
||||
crate::cmd::edit::select_and_page(s, ids.clone())?;
|
||||
Ok(json!({"ids": ids}))
|
||||
}
|
||||
Loaded 100 of 267 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user