mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-14 06:44:43 +00:00
Compare commits
279
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd807693e9 | ||
|
|
9dcfe4ecbc | ||
|
|
2fe7a9449a | ||
|
|
4fbe8d2976 | ||
|
|
8c731ddedc | ||
|
|
745e599118 | ||
|
|
ffc01cbac9 | ||
|
|
d2e0ac40aa | ||
|
|
4ed5190d7f | ||
|
|
3ce80b25ab | ||
|
|
ff3981de73 | ||
|
|
f32e8c5d2d | ||
|
|
4812b3ebfa | ||
|
|
2c51dbe4e3 | ||
|
|
261225340f | ||
|
|
0444dc39a0 | ||
|
|
01463fb78e | ||
|
|
3acffcd362 | ||
|
|
2ea8c3f86f | ||
|
|
9ba207e900 | ||
|
|
6551f870b6 | ||
|
|
7a9e9de38c | ||
|
|
1d5c9ae010 | ||
|
|
51549f9a60 | ||
|
|
3d7191f4d3 | ||
|
|
d25e302a57 | ||
|
|
4004aecaea | ||
|
|
5a10623414 | ||
|
|
be875da985 | ||
|
|
2a3309902a | ||
|
|
f3e6b00e1a | ||
|
|
58e33f8fbb | ||
|
|
ef375c623e | ||
|
|
e4d0b92e55 | ||
|
|
650049b469 | ||
|
|
731bb0247c | ||
|
|
b13fbb52b3 | ||
|
|
48f75c92da | ||
|
|
f39b9a11fd | ||
|
|
120aa8a7a0 | ||
|
|
09ad21489b | ||
|
|
0a2ed2fb65 | ||
|
|
4b29532261 | ||
|
|
22d5f17828 | ||
|
|
4dc7c1b59c | ||
|
|
a89031eab2 | ||
|
|
7a819dd190 | ||
|
|
6291e43605 | ||
|
|
cc80bd2a5d | ||
|
|
9894ee0e65 | ||
|
|
62a10ea460 | ||
|
|
8f58c39bd7 | ||
|
|
b2d5dfecc1 | ||
|
|
62ed3452d6 | ||
|
|
d96d03f452 | ||
|
|
cf06f2ca30 | ||
|
|
42c8e762a0 | ||
|
|
dd84097b6d | ||
|
|
8de536960e | ||
|
|
466bf6a813 | ||
|
|
e100c165fb | ||
|
|
370d9f7adc | ||
|
|
a51b8d2271 | ||
|
|
68e130aae6 | ||
|
|
50f1237617 | ||
|
|
a95f95ae37 | ||
|
|
6215cae877 | ||
|
|
c230a97cca | ||
|
|
f2eb75da0b | ||
|
|
18381ff701 | ||
|
|
3d112c4b8c | ||
|
|
18d4715cd9 | ||
|
|
25beadea65 | ||
|
|
de1d2f77df | ||
|
|
aeeb1a7cde | ||
|
|
fa3d0873ed | ||
|
|
4aa6ac6fcf | ||
|
|
b53967b423 | ||
|
|
8bcb0f78dd | ||
|
|
e50bafbb75 | ||
|
|
54f9212a2f | ||
|
|
477f019737 | ||
|
|
41d2ef688e | ||
|
|
4378308c30 | ||
|
|
e11a182afd | ||
|
|
4aadfa0439 | ||
|
|
394b609ec4 | ||
|
|
29954e6067 | ||
|
|
272973e6f9 | ||
|
|
e15242f506 | ||
|
|
6d9024e0d0 | ||
|
|
121ba72957 | ||
|
|
adf96ca2e7 | ||
|
|
0757800b3d | ||
|
|
102404e8c3 | ||
|
|
9b1c79277a | ||
|
|
379e4b5c55 | ||
|
|
b14a332405 | ||
|
|
65c9352c5c | ||
|
|
69de1ee36f | ||
|
|
b75e5bea3b | ||
|
|
8616f3d1c5 | ||
|
|
3ddb19edee | ||
|
|
8b1613c7ee | ||
|
|
4f12bf1e00 | ||
|
|
471694dc48 | ||
|
|
fc7556ae7f | ||
|
|
bebe509a25 | ||
|
|
55eaeb8709 | ||
|
|
3c52dad774 | ||
|
|
5a57388b8d | ||
|
|
7a3d2870c5 | ||
|
|
cc0c3fb10a | ||
|
|
b71084f366 | ||
|
|
5dc213820b | ||
|
|
f518d87b71 | ||
|
|
1dae6a439d | ||
|
|
022821211f | ||
|
|
03716da731 | ||
|
|
2420791015 | ||
|
|
8ae567c06b | ||
|
|
a25952dc60 | ||
|
|
4d93274eb1 | ||
|
|
a663e97a5d | ||
|
|
097110b359 | ||
|
|
1778b2317c | ||
|
|
e5405f4eb6 | ||
|
|
23bb8040ad | ||
|
|
d337b34380 | ||
|
|
731b7e356a | ||
|
|
a8604adbca | ||
|
|
8959030255 | ||
|
|
1d71fb4044 | ||
|
|
8c8c00fcba | ||
|
|
990cb4993f | ||
|
|
b321ff48d0 | ||
|
|
ec83040535 | ||
|
|
55a476053a | ||
|
|
eededda4fd | ||
|
|
92d00ca04a | ||
|
|
b93ace5cbf | ||
|
|
f4a7b39731 | ||
|
|
f9b7dcf9d1 | ||
|
|
663c203914 | ||
|
|
36c6cf6bd2 | ||
|
|
23b82e1f00 | ||
|
|
4ac1d80fe8 | ||
|
|
2f0369a7ad | ||
|
|
457c688872 | ||
|
|
0b364ae1fe | ||
|
|
627933ae06 | ||
|
|
8c74c72b50 | ||
|
|
17a3e2b6d6 | ||
|
|
e4126949bf | ||
|
|
15ca8d4881 | ||
|
|
704831830e | ||
|
|
de30e34538 | ||
|
|
4e5ff2bd46 | ||
|
|
6fbf3ca9aa | ||
|
|
e41e12324d | ||
|
|
7f2f8ee09a | ||
|
|
611fb324b9 | ||
|
|
1b67ac1e6d | ||
|
|
49a2c643c6 | ||
|
|
996852d15c | ||
|
|
1199f690ad | ||
|
|
099465212e | ||
|
|
8482c3e85b | ||
|
|
3a1aeb7a4b | ||
|
|
13691c1cc0 | ||
|
|
d1430e3054 | ||
|
|
fc6f52e852 | ||
|
|
3c00ea6440 | ||
|
|
87b9a3a9ce | ||
|
|
12d4dbfbc9 | ||
|
|
3edb15c417 | ||
|
|
01a0f4f332 | ||
|
|
b87d2e476f | ||
|
|
2aa3ca3c45 | ||
|
|
c02e864873 | ||
|
|
3c03f370f9 | ||
|
|
8efb66ba19 | ||
|
|
65e52aa7b9 | ||
|
|
212438a6e3 | ||
|
|
f387d3f464 | ||
|
|
90a4606c65 | ||
|
|
245837ff27 | ||
|
|
ca2f5dbcee | ||
|
|
5e14632207 | ||
|
|
fa0365470e | ||
|
|
b8043cc2ad | ||
|
|
30478894fd | ||
|
|
6bd87bccc6 | ||
|
|
ac8a0a112c | ||
|
|
64eae8c8c5 | ||
|
|
b821b6f8f4 | ||
|
|
a3048770b6 | ||
|
|
1c1b699598 | ||
|
|
3e777d2485 | ||
|
|
ebf6045184 | ||
|
|
15ae2e9115 | ||
|
|
639c4d7db0 | ||
|
|
f1e0a758d1 | ||
|
|
9404226235 | ||
|
|
d5fee489f5 | ||
|
|
82d84d86b1 | ||
|
|
72390d7b51 | ||
|
|
698e249fbf | ||
|
|
1aeb5abb74 | ||
|
|
f9b7aa03ca | ||
|
|
3405a07d33 | ||
|
|
4f321fb68d | ||
|
|
d72e5e0516 | ||
|
|
ed0e7a9b2d | ||
|
|
4c23cb71c0 | ||
|
|
f1f7da1f40 | ||
|
|
a3a273cfd3 | ||
|
|
3917c26802 | ||
|
|
00ac44f6ca | ||
|
|
de5ba60bc4 | ||
|
|
8b8825ce68 | ||
|
|
54ba84eddc | ||
|
|
a8a689d682 | ||
|
|
6eeac4fa04 | ||
|
|
8b961b980a | ||
|
|
4193a82dfb | ||
|
|
833f0d2c27 | ||
|
|
3709e7320c | ||
|
|
e92792e3e8 | ||
|
|
310cb40af9 | ||
|
|
d1ac66a803 | ||
|
|
661144021e | ||
|
|
73b2845ba4 | ||
|
|
10dc38495f | ||
|
|
efdd5fc645 | ||
|
|
61def74383 | ||
|
|
ce366ea325 | ||
|
|
d8556e8f17 | ||
|
|
01eb35df25 | ||
|
|
b8e5b8e133 | ||
|
|
9c0b441119 | ||
|
|
3f57a4b76a | ||
|
|
bd482ea6dd | ||
|
|
64a54ca408 | ||
|
|
5f3e1e1e6c | ||
|
|
dde193cbf4 | ||
|
|
4964910cc9 | ||
|
|
06850b8900 | ||
|
|
7a0bd980c6 | ||
|
|
f3cd0509cb | ||
|
|
94ce2a3611 | ||
|
|
bcedfeba10 | ||
|
|
b34c5de2ab | ||
|
|
52d013e1db | ||
|
|
6267ae5638 | ||
|
|
5e90592e80 | ||
|
|
ef2c6cb30e | ||
|
|
24c5617230 | ||
|
|
fb8ace5bf4 | ||
|
|
ce84d1f27f | ||
|
|
8e12e5eeda | ||
|
|
118ca7c60b | ||
|
|
d61ccc633a | ||
|
|
badcb6ec21 | ||
|
|
7a9a67a8c3 | ||
|
|
dd3d4d326c | ||
|
|
6d735653a2 | ||
|
|
ac65168417 | ||
|
|
f7e726714a | ||
|
|
7d589c4456 | ||
|
|
79a0e58a3c | ||
|
|
db55720c93 | ||
|
|
0efbd55564 | ||
|
|
5b52eeeb36 | ||
|
|
8b7435890e | ||
|
|
33dcb1aef6 | ||
|
|
e8d7dc24f2 | ||
|
|
d30255bf62 | ||
|
|
01904ce2fd |
No files matched your search
@@ -29,6 +29,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 35
|
||||
name: test (ubuntu-latest)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -69,6 +70,7 @@ jobs:
|
||||
run: cargo run --locked -q -p xtask -- assets
|
||||
|
||||
wasm:
|
||||
timeout-minutes: 30
|
||||
name: wasm32 check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
# uploaded and no secrets are used.
|
||||
name: FreeBSD
|
||||
|
||||
# The VM build takes one to several hours, so it runs nightly on main rather than on every push
|
||||
# or dependency change: a drifted dependency still shows up within a day. PRs that change the
|
||||
# FreeBSD packaging itself still run it.
|
||||
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every push
|
||||
# or dependency change: a drifted dependency still shows up within a day, and always before a
|
||||
# release ships. PRs that change the FreeBSD packaging itself still run it.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 2 * * *"
|
||||
push:
|
||||
branches: [release]
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/freebsd.yml
|
||||
@@ -41,8 +43,9 @@ jobs:
|
||||
mem: 12288
|
||||
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
|
||||
copyback: false
|
||||
# dejavu: the text tests need one real font, as the Ubuntu runner has.
|
||||
prepare: |
|
||||
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2
|
||||
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 dejavu
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
run: |
|
||||
set -e
|
||||
|
||||
@@ -19,6 +19,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
|
||||
@@ -41,6 +41,7 @@ env:
|
||||
|
||||
jobs:
|
||||
version:
|
||||
timeout-minutes: 10
|
||||
name: version
|
||||
runs-on: ubuntu-latest
|
||||
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
|
||||
@@ -69,6 +70,7 @@ jobs:
|
||||
echo "### FigCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
|
||||
macos:
|
||||
timeout-minutes: 90
|
||||
name: macOS universal
|
||||
needs: version
|
||||
runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend)
|
||||
@@ -127,6 +129,7 @@ jobs:
|
||||
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
|
||||
|
||||
windows:
|
||||
timeout-minutes: 90
|
||||
name: Windows ${{ matrix.arch }}
|
||||
needs: version
|
||||
runs-on: windows-latest
|
||||
@@ -187,6 +190,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
linux:
|
||||
timeout-minutes: 90
|
||||
name: Linux ${{ matrix.arch }}
|
||||
needs: version
|
||||
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
|
||||
@@ -252,6 +256,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
server:
|
||||
timeout-minutes: 90
|
||||
name: Server Linux ${{ matrix.arch }}
|
||||
needs: version
|
||||
# The headless collaboration server (docs/server.md): figcraft-server-<v>-linux-<arch>.tar.gz
|
||||
@@ -295,6 +300,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
flatpak:
|
||||
timeout-minutes: 90
|
||||
name: Flatpak ${{ matrix.arch }}
|
||||
needs: [version, linux]
|
||||
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
|
||||
@@ -388,6 +394,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
web:
|
||||
timeout-minutes: 90
|
||||
name: Web (wasm)
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
@@ -433,6 +440,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
linux-riscv64:
|
||||
timeout-minutes: 90
|
||||
name: Linux riscv64
|
||||
needs: version
|
||||
# Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release
|
||||
@@ -502,6 +510,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
timeout-minutes: 10
|
||||
name: Draft GitHub Release
|
||||
needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web, server]
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -4,15 +4,17 @@
|
||||
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
|
||||
name: Windows ARM64
|
||||
|
||||
# Nightly on main (dependency and runner-image drift shows up within a day), and on PRs that
|
||||
# change the Windows packaging itself.
|
||||
# Nightly on main and on pushes to `release` (dependency and runner-image drift shows up within a
|
||||
# day, and always before a release ships), and on PRs that change the Windows packaging itself.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "41 4 * * *"
|
||||
push:
|
||||
branches: [release]
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/windows-arm64.yml
|
||||
- packaging/windows/**
|
||||
schedule:
|
||||
- cron: "41 4 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
||||
Generated
+41
-23
@@ -1407,7 +1407,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
|
||||
|
||||
[[package]]
|
||||
name = "figcraft"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"eframe",
|
||||
@@ -1418,6 +1418,7 @@ dependencies = [
|
||||
"figcraft-format",
|
||||
"figcraft-text",
|
||||
"figcraft-ui-egui",
|
||||
"fmv-macos-events",
|
||||
"image",
|
||||
"log",
|
||||
"rfd",
|
||||
@@ -1430,7 +1431,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-cli"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-engine",
|
||||
@@ -1444,7 +1445,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-collab"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-color",
|
||||
@@ -1459,7 +1460,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-collab-net"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-collab",
|
||||
@@ -1482,7 +1483,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-color"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1491,7 +1492,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-doc"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-geom",
|
||||
@@ -1503,7 +1504,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-engine"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-collab",
|
||||
@@ -1530,7 +1531,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-format"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-pathops",
|
||||
@@ -1545,7 +1546,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-geom"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1553,7 +1554,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-layout"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1561,7 +1562,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-mcp"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-collab-net",
|
||||
@@ -1577,7 +1578,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-pathops"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1586,7 +1587,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-pdf"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-doc",
|
||||
@@ -1603,7 +1604,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-plugin"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"serde",
|
||||
@@ -1616,7 +1617,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-render"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"figcraft-color",
|
||||
@@ -1634,7 +1635,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-server"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"ctrlc",
|
||||
@@ -1658,7 +1659,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-svg"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-color",
|
||||
@@ -1667,17 +1668,20 @@ dependencies = [
|
||||
"figcraft-pathops",
|
||||
"figcraft-render",
|
||||
"figcraft-text",
|
||||
"flate2",
|
||||
"thiserror 2.0.21",
|
||||
"usvg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-text"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
"harfrust 0.13.3",
|
||||
"log",
|
||||
"png",
|
||||
"skrifa 0.47.0",
|
||||
"unicode-bidi",
|
||||
"unicode-segmentation",
|
||||
@@ -1685,7 +1689,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-ui-egui"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"egui",
|
||||
@@ -1693,6 +1697,7 @@ dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-doc",
|
||||
"figcraft-engine",
|
||||
"figcraft-format",
|
||||
"figcraft-geom",
|
||||
"figcraft-layout",
|
||||
"figcraft-pathops",
|
||||
@@ -1704,17 +1709,19 @@ dependencies = [
|
||||
"serde_json",
|
||||
"toml",
|
||||
"wat",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-web"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
"figcraft-collab-net",
|
||||
"figcraft-engine",
|
||||
"figcraft-ui-egui",
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
@@ -1747,6 +1754,17 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fmv-macos-events"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55"
|
||||
dependencies = [
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
@@ -3923,9 +3941,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ruzstd"
|
||||
version = "0.9.0"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a252f5e20f038fe7b4ea53e073e65398d652c864cc162fc77c56c2f13717b888"
|
||||
checksum = "69e4ce4a039f42bf25920b95667e4d789e98749ab11bbb8d2c2936f788421bb5"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
@@ -5804,7 +5822,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"figcraft-engine",
|
||||
"serde_json",
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@ exclude = ["plugins"]
|
||||
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.5.1"
|
||||
version = "0.6.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -59,7 +59,7 @@ linesweeper = "0.4"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
# `.fig` import: raw deflate chunks (already built for zip) and zstd chunks (pure Rust, MIT, wasm-safe).
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
ruzstd = { version = "0.9", default-features = false, features = ["std"] }
|
||||
ruzstd = { version = "0.9.1", default-features = false, features = ["std"] }
|
||||
sha2 = "0.10"
|
||||
base64 = "0.22"
|
||||
crc32fast = "1"
|
||||
|
||||
@@ -191,7 +191,7 @@ exposes to agents, and exported at 2x by FigCraft's own renderer. The source fil
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<h3>Prototype and hand off</h3>
|
||||
Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like <code>count + 1</code>), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, SwiftUI, Compose) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
|
||||
Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like <code>count + 1</code>), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, Leptos, SwiftUI, Compose) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -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 and Spanish (a draft waiting for a native speaker's review), 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 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)).
|
||||
- **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 two 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).
|
||||
@@ -251,6 +251,8 @@ Fonts for the UI and the Text tool come from [craft-fonts](https://github.com/st
|
||||
git clone https://github.com/storytold/craft-fonts ../craft-fonts
|
||||
```
|
||||
|
||||
If you clone it after a first build, run `cargo clean -p figcraft-text` once so the next build embeds the fonts.
|
||||
|
||||
**Start from a template.** The home screen's Templates tab has eight starter files (a mobile app, a landing page, a dashboard, a design system starter, a wireframe kit, social post sizes, presentation slides and an icon set starter), also under File ▸ New from template…. They are built in code from original, synthetic content, so scripts and agents can open them too: `file.newFromTemplate {template}` ([`docs/mcp.md`](docs/mcp.md#starter-templates)).
|
||||
|
||||
For the web build, install [trunk](https://trunkrs.dev/) and run `cargo xtask wasm`.
|
||||
|
||||
+31
-1
@@ -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-13 · **Change:** minor (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-14 · **Change:** minor (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
|
||||
@@ -131,6 +131,14 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
## Progress log
|
||||
|
||||
- **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.
|
||||
- **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged.
|
||||
- **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged.
|
||||
- **2026-10-14** · SVG/PDF export depth (M0.9, #208): visible descendants deeper than 256 levels below an exported root are refused before bounds or drawing, including masks and raster fallbacks. SVG has a checked API; the engine and CLI report the limit and suggest selecting an inner layer. Existing destination files survive refusal. Boundary, inner-root, hidden-subtree, multi-root/page and command/CLI regressions cover the change. Numbers unchanged.
|
||||
- **2026-10-13** · MCP/control transport hardening (#201): 32 MiB incoming frames are bounded while reading; oversized frames return an error and close without draining. The opt-in loopback control listener caps active workers at 8, applies 60 s socket read/write inactivity timeouts and caps both request queues at 8, discarding timed-out requests including those deferred to hidden windows. Reader, queue, idle-client and blocked-writer regressions cover the limits. Numbers unchanged.
|
||||
- **2026-10-13** · Attached MCP (M0.9, #194): a transport failure no longer replays a request that may already have edited the document. The caller gets an outcome-unknown error and must inspect before retrying; the next explicit call reconnects. Loopback tests cover a lost mutation reply, a single applied edit, reconnect and a connection failure before sending. Numbers unchanged.
|
||||
- **2026-10-13** · Constrain proportions in the Layout panel (M1, #220): with the W/H link on, typing a width or height scales the other side by the layer's own ratio; the property edit dropped the ratio, so the link only worked while dragging resize handles. Both sides in one edit stay as typed, the `w`/`h` aliases work the same, each layer of a multi-selection keeps its own ratio, and a zero side has no ratio to keep. Regression test in the transform behaviour suite. Numbers unchanged.
|
||||
- **2026-10-13** · Repeat last command (M1, #181): repeated edits keep the underlying command and parameters, so repeated nudges accumulate with one undo step each. A stored self-reference is refused instead of recursing. Regression tests cover three repeats, their undo steps, and missing or self-referencing commands. Numbers unchanged.
|
||||
- **2026-10-13** · Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged.
|
||||
- **2026-10-13** · Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged.
|
||||
- **2026-10-13** · Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged.
|
||||
@@ -305,6 +313,28 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | minor | System theme on first run (M7): preferences without a theme default to System; the first frame reads the OS appearance directly, including when returning from a manual theme. Saved Light/Dark choices stay explicit; unavailable OS appearance falls back to Light. Numbers unchanged. |
|
||||
| 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. |
|
||||
| 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged |
|
||||
| 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `<image>` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged |
|
||||
| 2026-10-14 | minor | Leptos code (#223): `inspect.code`, `inspect.componentCode` and `codeConnect.set` take `language: leptos`: a `#[component]` with `#[prop]` defaults, variant enums and `view!` markup, instances as calls, Code Connect `use` lines; the Dev Mode language list and rem units include it. Numbers unchanged |
|
||||
| 2026-10-14 | minor | Full screen (#227): View ▸ Full screen (`view.fullscreen {on?}`, F11 on Windows and Linux, Ctrl+Cmd+F on macOS) makes the window fill the screen and leaves it again; FigCraft's own item. Numbers unchanged |
|
||||
| 2026-10-14 | minor | Colour picker: wrapped rows of page and recent swatches stay inset on both sides instead of running past the picker's right edge. Numbers unchanged |
|
||||
| 2026-10-14 | minor | Server snapshots (#188): figcraft-server never replaces a readable snapshot with one too large to be read back, and reads snapshots up to twice the file cap (recent batches kept for reconnecting clients may take a file over it). Numbers unchanged |
|
||||
| 2026-10-14 | minor | Preferences owner-only on Unix (#200): `state.json`, which holds online owner tokens, is written with mode 0600 from the first byte, and an older world-readable file is narrowed. Numbers unchanged |
|
||||
| 2026-10-14 | minor | ruzstd 0.9.1 (#182): `.fig` import requires the release that fixes RUSTSEC-2026-0344 (a zstd decompression bomb). Numbers unchanged |
|
||||
| 2026-10-14 | minor | Layer cycles (#205): `validate` reports children listed twice, pages with a parent and layers not reachable from a page (cycles, detached subtrees); `repair` drops them in one linear pass. Numbers unchanged |
|
||||
| 2026-10-14 | minor | AppImage CLI: `figcraft.AppImage cli <args>` runs the bundled `figcraft-cli` (MCP server included) on Linux without a second download. Numbers unchanged |
|
||||
| 2026-10-14 | minor | Boolean outline depth (#193): nested boolean groups share one outline depth budget instead of each level getting its own. Numbers unchanged |
|
||||
| 2026-10-14 | minor | Command batches roll back as one: a failed `command.batch` (MCP `batch`) restores every open document and its state, and puts the tab in front back. Numbers unchanged |
|
||||
| 2026-10-14 | minor | Desktop builds no longer rebuild the text crate on every build when the craft-fonts manifest is missing. Numbers unchanged |
|
||||
| 2026-10-14 | minor | Direct3D 12 on Windows: the desktop app asks wgpu for Direct3D 12 there instead of Vulkan, so it starts without Vulkan loader errors on machines with stray overlay layers. Numbers unchanged |
|
||||
| 2026-10-14 | minor | Plugin metering (#192): every host call and every byte copied between a plugin and the host costs fuel, so a plugin can't stall the app through cheap-looking host calls. Numbers unchanged |
|
||||
| 2026-10-14 | minor | SVG/PDF export depth (#208): explicit preflight refusal beyond 256 descendant levels, propagated through public APIs, commands and CLI, with boundary/content and destination-preservation regressions. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | MCP/control transport hardening (#201): frame limits during reads, bounded connections and both UI request queues, socket inactivity timeouts; percentages and estimates unchanged |
|
||||
| 2026-10-13 | minor | Plugin log limits (#191): cap each UTF-8 line at 8 KiB, total log bytes at 1 MiB and lines at 1,000 per run/callback before copying guest memory or forwarding to the host. Over-budget runs fail and roll back document edits; callbacks get fresh budgets. Added sandbox boundary/forwarding tests and an engine rollback regression. Percentages and hours unchanged. |
|
||||
| 2026-10-13 | minor | Live-session revert guard (M9, #198): `file.revert` is refused while its document belongs to a live session, including publishing, reconnecting and hosting before the welcome. The document, previews, branch tabs and session stay intact; leave the session before reverting a local save. Offline revert is unchanged. Scripted access and sync regressions in `live_access_tests`. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | Repeat last command (M1, #181): keep the underlying command and parameters across repeats, reject self-reference, and retain one undo step per repeated edit. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged. |
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
//! Windows: give the main thread the stack Linux and macOS give it (see `main_thread_stack`).
|
||||
fn main() {
|
||||
main_thread_stack();
|
||||
}
|
||||
|
||||
/// Windows starts the main thread with 1 MB of stack, Linux and macOS with 8 MB. Layers nest up
|
||||
/// to 256 deep in an export, and that recursion needs the same room everywhere.
|
||||
fn main_thread_stack() {
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
|
||||
return;
|
||||
}
|
||||
let msvc = std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc");
|
||||
println!("cargo::rustc-link-arg-bins={}", if msvc { "/STACK:8388608" } else { "-Wl,--stack,8388608" });
|
||||
}
|
||||
@@ -102,8 +102,8 @@ pub fn write_doc(doc: &Document, page: NodeId, out: &str) -> CliResult<()> {
|
||||
let v = figcraft_format::to_json(doc).map_err(|e| CliError::Failed(e.to_string()))?;
|
||||
serde_json::to_vec_pretty(&v).map_err(|e| CliError::Failed(e.to_string()))?
|
||||
}
|
||||
"svg" => figcraft_svg::export_svg(&doc.in_srgb(), &children(doc, page), &Default::default())
|
||||
.ok_or_else(|| CliError::Failed("the page is empty: nothing to export".into()))?
|
||||
"svg" => figcraft_svg::try_export_svg(&doc.in_srgb(), &children(doc, page), &Default::default())
|
||||
.map_err(|e| CliError::Failed(e.to_string()))?
|
||||
.into_bytes(),
|
||||
"png" | "jpg" | "jpeg" | "webp" => encode(doc, &render_page(doc, page, 1.0)?, &e)?,
|
||||
"pdf" => page_pdf(doc, page, &PrintSettings::default())?,
|
||||
@@ -379,4 +379,31 @@ mod tests {
|
||||
assert_eq!(sanitize("../.."), "_");
|
||||
assert_eq!(sanitize(""), "layer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_depth_errors_preserve_conversion_destinations() {
|
||||
let mut doc = Document::new("export depth");
|
||||
let page = doc.first_page().unwrap();
|
||||
let root = doc.create(page, figcraft_doc::NodeKind::Frame, "root", 0.0, 0.0, 32.0, 32.0).unwrap();
|
||||
let mut parent = root;
|
||||
for _ in 1..257 {
|
||||
parent = doc.create(parent, figcraft_doc::NodeKind::Group, "group", 0.0, 0.0, 32.0, 32.0).unwrap();
|
||||
}
|
||||
doc.create(parent, figcraft_doc::NodeKind::Rectangle, "leaf", 0.0, 0.0, 8.0, 8.0).unwrap();
|
||||
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-cli-export-depth-{}-{stamp}", std::process::id()));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
for ext in ["svg", "pdf"] {
|
||||
let path = dir.join(format!("previous.{ext}"));
|
||||
std::fs::write(&path, b"previous export").unwrap();
|
||||
let CliError::Failed(error) = write_doc(&doc, page, path.to_str().unwrap()).unwrap_err() else {
|
||||
panic!("depth overflow must be an operation failure");
|
||||
};
|
||||
assert!(error.contains("maximum export depth of 256"), "{ext}: {error}");
|
||||
assert!(error.contains("select an inner layer"), "{ext}: {error}");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"previous export");
|
||||
}
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,27 @@ fn run_export_info_convert() {
|
||||
std::fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_takes_the_documented_gradient_shorthand() {
|
||||
// #252: only MCP used to accept it; `run` said "`fills` must be paints or colours".
|
||||
let d = tmp("gradient");
|
||||
let script = d.join("s.json");
|
||||
std::fs::write(
|
||||
&script,
|
||||
r##"[{"command":"shape.rectangle","params":{"x":0,"y":0,"width":200,"height":100}},
|
||||
{"command":"node.set","params":{"ids":[2],"fills":[{"type":"linearGradient","stops":["#ff0000","#0000ff"],"from":[0,0.5],"to":[1,0.5]}]}}]"##,
|
||||
)
|
||||
.unwrap();
|
||||
let file = d.join("g.figcraft");
|
||||
let out = bin().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap();
|
||||
assert!(out.status.success(), "{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
|
||||
let json = d.join("g.json");
|
||||
assert!(bin().arg("convert").arg(&file).arg(&json).status().unwrap().success());
|
||||
let doc = std::fs::read_to_string(&json).unwrap();
|
||||
assert!(doc.contains("\"kind\":\"linear\"") || doc.contains("\"kind\": \"linear\""), "{doc}");
|
||||
std::fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_over_stdio() {
|
||||
let mut child = bin().arg("mcp").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap();
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Depth refusal through the actual CLI, including its exit status and destination writes.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use figcraft_doc::color::{Color, Paint};
|
||||
use figcraft_doc::{Document, NodeKind};
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn export_depth_cli_refuses_partial_files_and_accepts_an_inner_root() {
|
||||
let mut doc = Document::new("export depth");
|
||||
let root = doc.create(doc.first_page().unwrap(), NodeKind::Frame, "root", 0.0, 0.0, 32.0, 32.0).unwrap();
|
||||
let mut parent = root;
|
||||
for depth in 1..257 {
|
||||
let name = if depth == 1 { "inner-root" } else { "group" };
|
||||
parent = doc.create(parent, NodeKind::Group, name, 0.0, 0.0, 32.0, 32.0).unwrap();
|
||||
}
|
||||
let leaf = doc.create(parent, NodeKind::Rectangle, "leaf", 3.0, 5.0, 13.0, 17.0).unwrap();
|
||||
doc.get_mut(leaf).unwrap().fills = vec![Paint::solid(Color::from_hex("FF0000").unwrap())];
|
||||
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-cli-depth-process-{}-{stamp}", std::process::id()));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
let input = dir.join("deep.figcraft");
|
||||
std::fs::write(&input, figcraft_format::save_bytes(&doc, None).unwrap()).unwrap();
|
||||
for ext in ["svg", "pdf"] {
|
||||
let path = dir.join(format!("previous.{ext}"));
|
||||
std::fs::write(&path, b"previous export").unwrap();
|
||||
for verb in ["convert", "export"] {
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_figcraft-cli"));
|
||||
command.arg(verb).arg(&input);
|
||||
if verb == "export" {
|
||||
command.arg("-o");
|
||||
}
|
||||
let output = command.arg(&path).arg("--no-system-fonts").output().unwrap();
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
assert_eq!(output.status.code(), Some(1), "{verb} {ext}: {error}");
|
||||
assert!(error.contains("maximum export depth of 256"), "{verb} {ext}: {error}");
|
||||
assert!(error.contains("select an inner layer"), "{verb} {ext}: {error}");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"previous export");
|
||||
}
|
||||
let inner = dir.join(format!("inner.{ext}"));
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_figcraft-cli"))
|
||||
.arg("export")
|
||||
.arg(&input)
|
||||
.arg("-o")
|
||||
.arg(&inner)
|
||||
.args(["--node", "inner-root", "--no-system-fonts"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
let bytes = std::fs::read(inner).unwrap();
|
||||
if ext == "svg" {
|
||||
assert!(String::from_utf8_lossy(&bytes).contains("#FF0000"));
|
||||
} else {
|
||||
assert!(bytes.starts_with(b"%PDF"));
|
||||
}
|
||||
}
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 5, "no staging files after refusal");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
@@ -235,6 +235,21 @@ fn a_file_cant_grow_past_its_cap_through_batches() {
|
||||
// Its snapshot holds the document and the recent batches kept for reconnecting clients (at
|
||||
// most a quarter of the cap), and the last batch let in may finish what the cap allows.
|
||||
assert!(on_disk <= max + max / 4 + 210_000, "{on_disk} bytes on disk");
|
||||
drop(raw);
|
||||
server.stop();
|
||||
// Everything confirmed opens again with the same settings, after compacting on the way out.
|
||||
let kept = client.document().nodes().count();
|
||||
let mut server = crate::start(&cfg).unwrap();
|
||||
let mut raw = Raw::connect(server.addr());
|
||||
raw.send(&Client::hello(&file.id, &edit.token, "Grower", ""));
|
||||
let welcome = loop {
|
||||
match raw.next() {
|
||||
w @ ServerMsg::Welcome { .. } => break w,
|
||||
ServerMsg::Refused { reason } => panic!("the file doesn't open again: {reason}"),
|
||||
_ => {}
|
||||
}
|
||||
};
|
||||
assert_eq!(Client::join(welcome).unwrap().document().nodes().count(), kept);
|
||||
server.stop();
|
||||
}
|
||||
|
||||
@@ -785,3 +800,41 @@ fn the_server_forgets_addresses_whose_connections_are_gone() {
|
||||
assert!(server.blocks_remembered() <= 2, "{} blocks remembered", server.blocks_remembered());
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_library_publish_that_fails_keeps_serving_the_old_version() {
|
||||
let dir = TempDir::new("libfail");
|
||||
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
|
||||
let (file, ..) = store.publish("Library", &snapshot("Library")).unwrap();
|
||||
// Version 1, then a changed library as version 2.
|
||||
let mut s = Session::new();
|
||||
let f = s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 20, "name": "Button"})).unwrap()["id"].as_u64().unwrap();
|
||||
let c = s.execute("component.create", &json!({"ids": [f]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("library.publish", &json!({})).unwrap();
|
||||
let v1 = figcraft_engine::library_server::archive(&s.doc().unwrap().published.clone().unwrap()).unwrap();
|
||||
s.execute("node.set", &json!({"id": c, "props": {"fill": "#00ff00"}})).unwrap();
|
||||
s.execute("library.publish", &json!({})).unwrap();
|
||||
let v2 = figcraft_engine::library_server::archive(&s.doc().unwrap().published.clone().unwrap()).unwrap();
|
||||
assert_eq!(store.publish_library(&file.id, &v1).unwrap(), 1);
|
||||
let files = dir.path().join(crate::store::FILES).join(&file.id);
|
||||
// The description can't be written: a directory sits where its temporary file goes.
|
||||
let tmp = crate::disk::temp_of(&files.join(crate::disk::META));
|
||||
std::fs::create_dir(&tmp).unwrap();
|
||||
assert!(store.publish_library(&file.id, &v2).is_err());
|
||||
let fetch = |s: &mut Store| s.library(ip("198.51.100.1"), &file.id, Access::View, LibraryReq::Fetch { have: None, usage: None });
|
||||
let served = |reply: LibraryReply| match reply {
|
||||
LibraryReply::Library { version, archive } => (version, archive),
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
assert_eq!(served(fetch(&mut store)), (1, v1.clone()), "version 1, with its own bytes");
|
||||
// A crash right after the archive was written leaves it on disk, the description unchanged.
|
||||
std::fs::write(files.join(crate::store::library_name(2)), &v2).unwrap();
|
||||
drop(store);
|
||||
std::fs::remove_dir(&tmp).unwrap();
|
||||
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
|
||||
assert_eq!(served(fetch(&mut store)), (1, v1), "still version 1 after a restart");
|
||||
// Publishing again works, and serves the new version.
|
||||
assert_eq!(store.publish_library(&file.id, &v2).unwrap(), 2);
|
||||
assert_eq!(served(fetch(&mut store)), (2, v2));
|
||||
assert!(!files.join(crate::store::library_name(1)).exists(), "the old version is deleted");
|
||||
}
|
||||
@@ -10,7 +10,8 @@
|
||||
//! and a join code's hash and wrapped token (see `codes`)
|
||||
//! <data>/files/<id>/snapshot.bin the session state at the last compaction
|
||||
//! <data>/files/<id>/log.bin what changed since (see `disk`)
|
||||
//! <data>/files/<id>/library.bin its team library, when one was published (a `.figcraft` archive)
|
||||
//! <data>/files/<id>/library-<version>.bin its team library, when one was published (a `.figcraft`
|
||||
//! archive; `library.bin` from servers before versioned names)
|
||||
//! <data>/files/<id>/usage.json how other files use that library, as their apps report it
|
||||
//! <data>/plugins/ the plugin directory (see `directory`)
|
||||
//! <data>/blocked.json addresses the operator blocked
|
||||
@@ -179,7 +180,7 @@ impl Meta {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a file's team library is (`library.bin` holds it).
|
||||
/// What a file's team library is ([`library_file`] holds it).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
struct LibraryMeta {
|
||||
id: String,
|
||||
@@ -265,6 +266,9 @@ pub struct Store {
|
||||
next_sweep: u64,
|
||||
/// Compactions so far (tests count them).
|
||||
compactions: u64,
|
||||
/// Open files closed by a compaction cut short (their snapshot replaced, their log not):
|
||||
/// reported at the next flush, so nothing appended after it is confirmed.
|
||||
cut_short: Vec<String>,
|
||||
/// The free space last seen on the data directory's disk, and when.
|
||||
free: Option<(Instant, Option<u64>)>,
|
||||
_lock: File,
|
||||
@@ -360,6 +364,7 @@ impl Store {
|
||||
gone_codes: VecDeque::new(),
|
||||
next_sweep: 0,
|
||||
compactions: 0,
|
||||
cut_short: Vec::new(),
|
||||
free: None,
|
||||
_lock: lock,
|
||||
};
|
||||
@@ -426,7 +431,7 @@ impl Store {
|
||||
let meta: Meta = serde_json::from_slice(&disk::read_capped(&path.join(META), 1 << 20)?).map_err(|e| format!("{META} is damaged ({e})"))?;
|
||||
let links: Vec<Link> =
|
||||
serde_json::from_slice(&disk::read_capped(&path.join(LINKS), 16 << 20)?).map_err(|e| format!("{LINKS} is damaged ({e})"))?;
|
||||
let generation = disk::check_snapshot(&disk::read_capped(&path.join(SNAPSHOT), self.cfg.max_file_bytes)?)?;
|
||||
let generation = disk::check_snapshot(&disk::read_capped(&path.join(SNAPSHOT), self.snapshot_cap())?)?;
|
||||
// A damaged log only loses its end; it is reported when the file opens.
|
||||
if let Ok(bytes) = disk::read_capped(&path.join(LOG), self.cfg.max_file_bytes)
|
||||
&& let Ok(log) = disk::read_log(&bytes)
|
||||
@@ -919,11 +924,12 @@ impl Store {
|
||||
/// A file's team library for `ip`, unless its version is `have`; within what one address and
|
||||
/// everyone may download (the size is known before anything is read).
|
||||
fn fetch_library(&mut self, ip: IpAddr, file: &str, have: Option<u64>) -> Result<LibraryReply, String> {
|
||||
let (path, max) = (self.dir(file).join(LIBRARY), self.cfg.max_file_bytes);
|
||||
let (dir, max) = (self.dir(file), self.cfg.max_file_bytes);
|
||||
let Some(lib) = self.entry(file)?.meta.library.clone() else { return Ok(LibraryReply::Empty) };
|
||||
if have == Some(lib.version) {
|
||||
return Ok(LibraryReply::Unchanged { version: lib.version });
|
||||
}
|
||||
let path = library_file(&dir, lib.version);
|
||||
let size = fs::metadata(&path).map_or(0, |m| m.len());
|
||||
let now = Instant::now();
|
||||
if !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), now) || !self.served.take_n(now, size as f64) {
|
||||
@@ -965,14 +971,24 @@ impl Store {
|
||||
return Err(format!("version {} is on the server already: publish again to follow it", old.version));
|
||||
}
|
||||
}
|
||||
// The archive first: a description never names a version whose library isn't there (a
|
||||
// failed description write leaves the older number, which the next publish follows).
|
||||
disk::write_atomic(&dir.join(LIBRARY), archive).map_err(|e| format!("couldn't keep the library: {e}"))?;
|
||||
// Each version has a file of its own, written before the description names it: a failed
|
||||
// description write or a crash in between leaves the previous version served whole, never
|
||||
// the new bytes under the old number.
|
||||
let path = dir.join(library_name(m.version));
|
||||
disk::write_atomic(&path, archive).map_err(|e| format!("couldn't keep the library: {e}"))?;
|
||||
let before = entry.meta.library.replace(LibraryMeta { id: m.id.clone(), name: m.name.clone(), version: m.version });
|
||||
if let Err(e) = write_json(&dir.join(META), &entry.meta) {
|
||||
// Never served (the description doesn't name it); the next publish of it replaces it.
|
||||
if let Err(r) = fs::remove_file(&path) {
|
||||
log::warn!("file {file}: an unpublished library version couldn't be deleted: {r}");
|
||||
}
|
||||
entry.meta.library = before;
|
||||
return Err(format!("couldn't keep the library: {e}"));
|
||||
}
|
||||
// The previous version isn't served any more.
|
||||
if let Some(Err(e)) = before.map(|old| fs::remove_file(library_file(&dir, old.version))) {
|
||||
log::warn!("file {file}: the previous library version couldn't be deleted: {e}");
|
||||
}
|
||||
log::info!("file {file}: published library version {} ({} bytes)", m.version, archive.len());
|
||||
Ok(m.version)
|
||||
}
|
||||
@@ -1086,7 +1102,7 @@ impl Store {
|
||||
fn load(&mut self, file: &str) -> Result<Server, String> {
|
||||
self.touch(file);
|
||||
let dir = self.dir(file);
|
||||
let (generation, state) = disk::decode_snapshot(&disk::read_capped(&dir.join(SNAPSHOT), self.cfg.max_file_bytes)?)?;
|
||||
let (generation, state) = disk::decode_snapshot(&disk::read_capped(&dir.join(SNAPSHOT), self.snapshot_cap())?)?;
|
||||
let mut server = Server::restore(state, "", self.cfg.core).map_err(|e| format!("the snapshot can't be used: {e}"))?;
|
||||
let mut issues = Vec::new();
|
||||
let mut fresh_log = true;
|
||||
@@ -1124,23 +1140,37 @@ impl Store {
|
||||
Log { generation, file: f, pending: Vec::new(), entries: kept, bytes, dirty: kept > 0 }
|
||||
}
|
||||
// A new snapshot and log of the next generation, so appends start clean.
|
||||
_ => self.rewrite(file, generation + 1, &server)?,
|
||||
_ => self.rewrite(file, generation + 1, &server).map_err(|e| e.why)?,
|
||||
};
|
||||
self.entry(file)?.log = Some(log);
|
||||
Ok(server)
|
||||
}
|
||||
|
||||
/// The largest snapshot read back: a file's cap plus room for what its state keeps beside the
|
||||
/// document (recent batches for reconnecting clients, deletions), which may take it over.
|
||||
fn snapshot_cap(&self) -> u64 {
|
||||
self.cfg.max_file_bytes.saturating_mul(2)
|
||||
}
|
||||
|
||||
/// Write `server`'s state as snapshot `generation` and start an empty log of it.
|
||||
fn rewrite(&mut self, file: &str, generation: u64, server: &Server) -> Result<Log, String> {
|
||||
fn rewrite(&mut self, file: &str, generation: u64, server: &Server) -> Result<Log, RewriteError> {
|
||||
let dir = self.dir(file);
|
||||
let state = server.state().map_err(|e| e.to_string())?;
|
||||
let bytes = disk::encode_snapshot(generation, &state)?;
|
||||
let kept = |why: String| RewriteError { why, snapshot_replaced: false };
|
||||
let state = server.state().map_err(|e| kept(e.to_string()))?;
|
||||
let bytes = disk::encode_snapshot(generation, &state).map_err(kept)?;
|
||||
// Never replace a readable snapshot with one that can't be opened again: the old snapshot
|
||||
// and log still hold everything.
|
||||
if bytes.len() as u64 > self.snapshot_cap() {
|
||||
return Err(kept(format!("the snapshot would be {} MiB, more than can be opened again", bytes.len() >> 20)));
|
||||
}
|
||||
if bytes.len() as u64 > self.cfg.max_file_bytes {
|
||||
log::warn!("file {file} is {} MiB, over the {} MiB cap (kept anyway)", bytes.len() >> 20, self.cfg.max_file_bytes >> 20);
|
||||
}
|
||||
disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| format!("couldn't write the snapshot: {e}"))?;
|
||||
disk::write_atomic(&dir.join(LOG), &disk::log_header(generation)).map_err(|e| format!("couldn't start the log: {e}"))?;
|
||||
let f = OpenOptions::new().append(true).open(dir.join(LOG)).map_err(|e| format!("couldn't open the log: {e}"))?;
|
||||
disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| kept(format!("couldn't write the snapshot: {e}")))?;
|
||||
// From here the old log no longer goes with the snapshot on disk.
|
||||
let replaced = |why: String| RewriteError { why, snapshot_replaced: true };
|
||||
disk::write_atomic(&dir.join(LOG), &disk::log_header(generation)).map_err(|e| replaced(format!("couldn't start the log: {e}")))?;
|
||||
let f = OpenOptions::new().append(true).open(dir.join(LOG)).map_err(|e| replaced(format!("couldn't open the log: {e}")))?;
|
||||
if let Ok(e) = self.entry(file) {
|
||||
e.bytes = (bytes.len() + LOG_HEADER) as u64;
|
||||
e.meta.seq = server.seq();
|
||||
@@ -1193,8 +1223,19 @@ impl Store {
|
||||
}
|
||||
log::debug!("compacted file {file}");
|
||||
}
|
||||
// The new snapshot holds everything, but a restart would drop whatever is appended to
|
||||
// the old log now (its generation no longer matches): the file closes, the hub hears
|
||||
// it at the next flush, and it opens again from the new snapshot.
|
||||
Err(RewriteError { why, snapshot_replaced: true }) => {
|
||||
self.note(format!("file {file}: compaction was cut short ({why}); it was closed"));
|
||||
log::error!("file {file}: compaction was cut short: {why}");
|
||||
if let Some(e) = self.files.get_mut(file) {
|
||||
e.log = None;
|
||||
}
|
||||
self.cut_short.push(file.to_string());
|
||||
}
|
||||
// The old snapshot and log still hold everything: carry on appending.
|
||||
Err(e) => log::error!("file {file}: compaction failed: {e}"),
|
||||
Err(RewriteError { why, .. }) => log::error!("file {file}: compaction failed: {why}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1202,7 +1243,7 @@ impl Store {
|
||||
/// Those are closed here (their pending records dropped: nobody was told of them) and open
|
||||
/// again from what is on disk.
|
||||
fn flush_all(&mut self) -> Vec<String> {
|
||||
let mut failed = Vec::new();
|
||||
let mut failed = std::mem::take(&mut self.cut_short);
|
||||
for (id, e) in &mut self.files {
|
||||
if let Some(log) = e.log.as_mut() {
|
||||
match flush_log(id, log) {
|
||||
@@ -1218,6 +1259,13 @@ impl Store {
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a snapshot and log couldn't be rewritten, and whether the snapshot on disk was replaced
|
||||
/// before it failed (then the old log no longer goes with it).
|
||||
struct RewriteError {
|
||||
why: String,
|
||||
snapshot_replaced: bool,
|
||||
}
|
||||
|
||||
/// Write and sync a log's pending records: the bytes written, or `Err` when that failed (the log
|
||||
/// can't be trusted to hold them).
|
||||
fn flush_log(id: &str, log: &mut Log) -> Result<u64, ()> {
|
||||
@@ -1238,6 +1286,18 @@ fn flush_log(id: &str, log: &mut Log) -> Result<u64, ()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The name of the file holding version `version` of a file's team library.
|
||||
pub fn library_name(version: u64) -> String {
|
||||
format!("library-{version}.bin")
|
||||
}
|
||||
|
||||
/// Where version `version` of the team library in `dir` is: its own file, or `library.bin` for a
|
||||
/// version published before each version had one.
|
||||
fn library_file(dir: &Path, version: u64) -> PathBuf {
|
||||
let path = dir.join(library_name(version));
|
||||
if path.exists() { path } else { dir.join(LIBRARY) }
|
||||
}
|
||||
|
||||
fn write_json<T: Serialize + ?Sized>(path: &Path, value: &T) -> Result<(), String> {
|
||||
let text = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?;
|
||||
disk::write_atomic(path, &text).map_err(|e| e.to_string())
|
||||
@@ -1305,6 +1365,8 @@ impl Backend for Store {
|
||||
if dirty {
|
||||
self.compact(file, server);
|
||||
}
|
||||
// It closes anyway, everything in its snapshot: a compaction cut short needs no report.
|
||||
self.cut_short.retain(|f| f != file);
|
||||
if let Some(e) = self.files.get_mut(file) {
|
||||
e.log = None;
|
||||
}
|
||||
@@ -1427,6 +1489,8 @@ impl Backend for Store {
|
||||
Admin::Blocked => AdminReply::Blocked(self.blocked()).into(),
|
||||
Admin::Code { file, token } => self.set_code(&file, &token).map_or_else(failed, |code| AdminReply::Code { code }.into()),
|
||||
Admin::Owned { files } => self.owned(&files).into(),
|
||||
// The hub removes people itself (it knows who is connected); it never asks the store.
|
||||
Admin::RemovePeer { .. } => failed("removing someone from a file is done by the server's hub".into()),
|
||||
Admin::Remove { file } => {
|
||||
if !self.files.contains_key(&file) {
|
||||
return failed("this file isn't on the server (it may have been removed)".into());
|
||||
|
||||
@@ -269,3 +269,47 @@ fn random_bytes_never_panic_the_readers() {
|
||||
let _ = disk::read_log(&bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// The first page's name in `server`'s document.
|
||||
fn page_name(server: &Server) -> String {
|
||||
let d = server.replica().document();
|
||||
d.node(d.first_page().unwrap()).unwrap().name.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_confirmed_after_a_compaction_cut_short_survive_a_restart() {
|
||||
let dir = TempDir::new("cutshort");
|
||||
let c = StoreConfig { compact_after: 3, ..cfg(dir.path()) };
|
||||
let folder = dir.path().join(FILES);
|
||||
let mut store = Store::open(dir.path(), c.clone()).unwrap();
|
||||
let (id, _, _) = publish(&mut store, "Busy");
|
||||
let mut server = store.open(&id).unwrap();
|
||||
let mut a = join(&mut server, Access::Edit);
|
||||
store.changed(&id, &mut server);
|
||||
// The next compaction writes its snapshot, then can't start its log.
|
||||
let blocker = disk::temp_of(&folder.join(&id).join(LOG));
|
||||
fs::create_dir(&blocker).unwrap();
|
||||
// What the hub would confirm: every change whose flush didn't report the file.
|
||||
let mut confirmed = String::new();
|
||||
let mut failed = false;
|
||||
let names = ["P0", "P1", "P2", "After 1", "After 2"];
|
||||
for name in names {
|
||||
rename(&mut server, &mut a, name);
|
||||
store.changed(&id, &mut server);
|
||||
if store.flush().contains(&id) {
|
||||
failed = true;
|
||||
break;
|
||||
}
|
||||
confirmed = name.to_string();
|
||||
}
|
||||
assert!(failed, "the hub hears the file can't be kept; confirmed {confirmed:?}");
|
||||
// A crash, then the disk recovers.
|
||||
drop(store);
|
||||
fs::remove_dir(&blocker).unwrap();
|
||||
let mut store = Store::open(dir.path(), c).unwrap();
|
||||
let restored = store.open(&id).unwrap();
|
||||
// Every confirmed change is there (the snapshot may also hold the one that was cut short).
|
||||
let at = |n: &str| names.iter().position(|x| *x == n);
|
||||
let name = page_name(&restored);
|
||||
assert!(at(&name) >= at(&confirmed), "restored {name:?}, confirmed {confirmed:?}");
|
||||
}
|
||||
@@ -24,5 +24,6 @@ log = { workspace = true }
|
||||
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
|
||||
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", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] }
|
||||
@@ -40,7 +40,7 @@ pub fn start() {
|
||||
// on the socket wakes the page for a frame.
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
session.live.install(Box::new(figcraft_collab_net::NetLink::new(std::sync::Arc::new(move || ctx.request_repaint()))));
|
||||
let fig = FigApp::new(session, Box::new(WebServices { reduce_motion }));
|
||||
let fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() }));
|
||||
Ok(Box::new(WebShell { fig }))
|
||||
}),
|
||||
)
|
||||
@@ -69,14 +69,35 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
}
|
||||
|
||||
/// Browser services. File dialogs return nothing for now (the trait's defaults); the clipboard
|
||||
/// goes through egui's own web clipboard handling.
|
||||
/// 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).
|
||||
struct WebServices {
|
||||
/// `prefers-reduced-motion: reduce` (it follows the setting while the page is open).
|
||||
reduce_motion: Option<web_sys::MediaQueryList>,
|
||||
ctx: egui::Context,
|
||||
}
|
||||
|
||||
impl Services for WebServices {
|
||||
fn downloads(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// A Blob behind a temporary `<a download>` link, clicked and removed.
|
||||
fn download(&mut self, name: &str, bytes: &[u8]) -> bool {
|
||||
match download(name, bytes) {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
log::error!("couldn't download {name}: {e:?}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
fn clipboard_set_text(&mut self, s: &str) {
|
||||
figcraft_ui_egui::egui_copy_text(&self.ctx, s);
|
||||
}
|
||||
fn clipboard_set_png(&mut self, w: u32, h: u32, rgba: &[u8]) {
|
||||
figcraft_ui_egui::egui_copy_png(&self.ctx, w, h, rgba);
|
||||
}
|
||||
fn open_url(&mut self, url: &str) {
|
||||
let Some(window) = web_sys::window() else { return };
|
||||
if let Err(e) = window.open_with_url_and_target(url, "_blank") {
|
||||
@@ -108,3 +129,32 @@ impl Services for WebServices {
|
||||
held
|
||||
}
|
||||
}
|
||||
|
||||
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")?;
|
||||
let body = document.body().ok_or("no body")?;
|
||||
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
|
||||
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &{
|
||||
let o = web_sys::BlobPropertyBag::new();
|
||||
o.set_type("application/octet-stream");
|
||||
o
|
||||
})?;
|
||||
let url = web_sys::Url::create_object_url_with_blob(&blob)?;
|
||||
let a: web_sys::HtmlAnchorElement = document.create_element("a")?.dyn_into()?;
|
||||
a.set_href(&url);
|
||||
a.set_download(name);
|
||||
body.append_child(&a)?;
|
||||
a.click();
|
||||
a.remove();
|
||||
// Revoked later, not at once: some browsers cancel a download whose URL goes before it starts.
|
||||
let revoke = wasm_bindgen::closure::Closure::once_into_js(move || {
|
||||
let _ = web_sys::Url::revoke_object_url(&url);
|
||||
});
|
||||
// Without the timer the URL just stays until the page closes.
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), REVOKE_DOWNLOAD_MS);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// How long a download's object URL is kept (milliseconds).
|
||||
const REVOKE_DOWNLOAD_MS: i32 = 40_000;
|
||||
@@ -31,6 +31,13 @@ env_logger = { version = "0.11", default-features = false, features = ["auto-col
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.62", default-features = false, features = ["UI_ViewManagement", "System_UserProfile", "Foundation_Collections", "Gaming_Input"] }
|
||||
|
||||
# Finder double-clicks, Open With, Dock drops and `open -a FigCraft file.figcraft` arrive as a
|
||||
# kAEOpenDocuments Apple event, which winit 0.30 doesn't handle. This crate (also used by GridCraft,
|
||||
# EffectCraft and PhotoCraft) registers the handler behind a safe API (Objective-C interop needs
|
||||
# unsafe, which the workspace forbids). Pinned exactly: review the diff before bumping.
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
fmv-macos-events = "=0.1.0"
|
||||
|
||||
[dev-dependencies]
|
||||
wat = { workspace = true }
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Windows: embed the icon and version info in figcraft.exe (no-op elsewhere).
|
||||
//! Windows: embed the icon and version info in figcraft.exe and size its main thread's stack
|
||||
//! (no-op elsewhere).
|
||||
fn main() {
|
||||
println!("cargo::rerun-if-changed=../../assets/app-icon/figcraft.ico");
|
||||
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||
@@ -14,6 +15,10 @@ fn main() {
|
||||
if let Err(e) = res.compile() {
|
||||
println!("cargo::warning=winresource: {e}");
|
||||
}
|
||||
// Windows starts the main thread with 1 MB of stack, Linux and macOS with 8 MB. Layers
|
||||
// nest up to 256 deep in an export, and that recursion needs the same room everywhere.
|
||||
let msvc = std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc");
|
||||
println!("cargo::rustc-link-arg-bins={}", if msvc { "/STACK:8388608" } else { "-Wl,--stack,8388608" });
|
||||
}
|
||||
let sha = std::env::var("FIGCRAFT_BUILD_SHA").unwrap_or_default();
|
||||
println!("cargo::rustc-env=FIGCRAFT_BUILD_SHA={sha}");
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
//! macOS open-documents and quit Apple events (#289).
|
||||
//!
|
||||
//! Finder double-clicks, Open With, drops on the Dock icon and `open -a FigCraft file.figcraft`
|
||||
//! don't pass paths on the command line: LaunchServices sends the running (or just-launched) app a
|
||||
//! `kAEOpenDocuments` ('odoc') Apple event. winit 0.30 doesn't handle it and owns the
|
||||
//! `NSApplicationDelegate`, so AppKit answered "FigCraft cannot open files in the FigCraft Document
|
||||
//! format". Handling it ourselves needs Objective-C class declarations, i.e. `unsafe`, which this
|
||||
//! workspace forbids. The `fmv-macos-events` crate (also used by GridCraft, EffectCraft and
|
||||
//! PhotoCraft) wraps exactly that, an `NSAppleEventManager` handler registered before Finder's
|
||||
//! launch event that leaves winit's delegate alone, behind a safe main-thread API.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use figcraft_ui_egui::FigApp;
|
||||
use fmv_macos_events::{Event, Inbox, Registration};
|
||||
use serde_json::json;
|
||||
|
||||
/// Keeps the Apple-event handlers registered; hold it until the event loop returns.
|
||||
pub struct AppleEvents {
|
||||
_registration: Registration,
|
||||
inbox: Inbox,
|
||||
}
|
||||
|
||||
impl AppleEvents {
|
||||
/// Register the handlers. Call on the main thread before the event loop starts, so the event
|
||||
/// that launched the app (a Finder double-click) is caught too.
|
||||
pub fn install() -> Self {
|
||||
let (registration, inbox) = Registration::install();
|
||||
Self { _registration: registration, inbox }
|
||||
}
|
||||
|
||||
/// The queue the app drains every frame ([`poll`]); events arriving later wake `ctx`.
|
||||
pub fn connect(&self, ctx: &egui::Context) -> Inbox {
|
||||
let ctx = ctx.clone();
|
||||
self.inbox.set_wake(move || ctx.request_repaint());
|
||||
self.inbox.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the documents and act on the quit requests that arrived since the last frame.
|
||||
pub fn poll(inbox: &Inbox, fig: &mut FigApp, ctx: &egui::Context) {
|
||||
for e in inbox.drain() {
|
||||
match e {
|
||||
Event::Open(paths) => open_documents(fig, paths),
|
||||
Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open `paths` as File › Open does; a file that fails shows its error and the others still open.
|
||||
pub fn open_documents(fig: &mut FigApp, paths: Vec<PathBuf>) {
|
||||
for p in paths {
|
||||
if fig.run("file.open", json!({"path": p.to_string_lossy()})).is_some() {
|
||||
fig.ui.home = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//! Documents macOS hands over in an open-documents Apple event (a Finder double-click, Open With,
|
||||
//! a drop on the Dock icon) open as File › Open opens them (#289).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_ui_egui::{FigApp, NoServices};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::apple_events::open_documents;
|
||||
|
||||
#[test]
|
||||
fn documents_from_finder_open_and_leave_the_home_screen() {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-apple-events-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("BLA-001.figcraft");
|
||||
let mut s = Session::new();
|
||||
s.execute("file.saveAs", &json!({"path": file.to_string_lossy()})).unwrap();
|
||||
|
||||
let mut app = FigApp::new(Session::empty(), Box::new(NoServices));
|
||||
app.ui.home = true;
|
||||
// A file that can't be read is reported and doesn't keep the others from opening.
|
||||
open_documents(&mut app, vec![dir.join("Missing.figcraft"), file.clone()]);
|
||||
let paths: Vec<_> = app.session.docs.iter().map(|d| d.path.clone().map(PathBuf::from)).collect();
|
||||
assert_eq!(paths, [Some(file)]);
|
||||
assert!(!app.ui.home, "the opened document shows");
|
||||
assert!(app.ui.last_error.as_deref().is_some_and(|e| e.contains("Missing.figcraft")), "{:?}", app.ui.last_error);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Localhost JSON-lines control server: each line `{"id", "method", "params"}` is queued for the UI
|
||||
//! thread; the reply line `{"id", "ok", "result" | "error"}` is written when the frame handled it.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
@@ -12,6 +12,20 @@ use serde_json::{Value, json};
|
||||
type Queue = Arc<Mutex<Vec<ControlRequest>>>;
|
||||
type CtxSlot = Arc<Mutex<Option<egui::Context>>>;
|
||||
|
||||
const MAX_LINE: usize = 32 * 1024 * 1024;
|
||||
const MAX_CONNECTIONS: usize = 8;
|
||||
const MAX_QUEUED_REQUESTS: usize = 8;
|
||||
const TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Deferred requests for a hidden window must not make the UI's queue grow without a bound.
|
||||
pub fn drain_queue(queue: &Queue, target: &mut Vec<ControlRequest>) {
|
||||
target.retain(|request| !request.is_cancelled());
|
||||
let mut queue = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
queue.retain(|request| !request.is_cancelled());
|
||||
let count = queue.len().min(MAX_QUEUED_REQUESTS.saturating_sub(target.len()));
|
||||
target.extend(queue.drain(..count));
|
||||
}
|
||||
|
||||
pub fn start(port: u16, queue: Queue, ctx: CtxSlot) {
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => l,
|
||||
@@ -23,9 +37,9 @@ pub fn start(port: u16, queue: Queue, ctx: CtxSlot) {
|
||||
};
|
||||
eprintln!("FigCraft control server listening on 127.0.0.1:{port}");
|
||||
let spawned = std::thread::Builder::new().name("figcraft-control".into()).spawn(move || {
|
||||
let mut workers = Vec::new();
|
||||
for stream in listener.incoming().flatten() {
|
||||
let (q, c) = (queue.clone(), ctx.clone());
|
||||
let _ = std::thread::Builder::new().name("figcraft-control-conn".into()).spawn(move || serve(stream, q, c));
|
||||
spawn_connection(stream, queue.clone(), ctx.clone(), &mut workers, TIMEOUT);
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
@@ -33,37 +47,83 @@ pub fn start(port: u16, queue: Queue, ctx: CtxSlot) {
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, queue: Queue, ctx: CtxSlot) {
|
||||
let Ok(read_half) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
let reader = BufReader::new(read_half);
|
||||
for line in reader.lines() {
|
||||
let Ok(line) = line else { break };
|
||||
fn spawn_connection(stream: TcpStream, queue: Queue, ctx: CtxSlot, workers: &mut Vec<std::thread::JoinHandle<()>>, timeout: Duration) {
|
||||
workers.retain(|worker| !worker.is_finished());
|
||||
if workers.len() >= MAX_CONNECTIONS {
|
||||
return;
|
||||
}
|
||||
match std::thread::Builder::new().name("figcraft-control-conn".into()).spawn(move || {
|
||||
let _ = serve(stream, queue, ctx, timeout);
|
||||
}) {
|
||||
Ok(worker) => workers.push(worker),
|
||||
Err(e) => log::error!("control connection thread: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> std::io::Result<()> {
|
||||
out.set_read_timeout(Some(timeout))?;
|
||||
out.set_write_timeout(Some(timeout))?;
|
||||
let mut reader = BufReader::new(out.try_clone()?);
|
||||
let mut line = Vec::new();
|
||||
loop {
|
||||
line.clear();
|
||||
if (&mut reader).take((MAX_LINE + 1) as u64).read_until(b'\n', &mut line)? == 0 {
|
||||
break;
|
||||
}
|
||||
if line.last() == Some(&b'\n') {
|
||||
line.pop();
|
||||
}
|
||||
if line.len() > MAX_LINE {
|
||||
writeln!(out, "{}", json!({"id": null, "ok": false, "error": "message too large"}))?;
|
||||
break;
|
||||
}
|
||||
let Ok(line) = std::str::from_utf8(&line) else { break };
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(&line) {
|
||||
let reply = match serde_json::from_str::<Value>(line) {
|
||||
Ok(v) => {
|
||||
let id = v.get("id").cloned().unwrap_or(Value::Null);
|
||||
let method = v.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let params = v.get("params").cloned().unwrap_or(json!({}));
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(ControlRequest { id: id.clone(), method, params, reply: tx });
|
||||
let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let mut pending = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
pending.retain(|request| !request.is_cancelled());
|
||||
if pending.len() >= MAX_QUEUED_REQUESTS {
|
||||
drop(pending);
|
||||
writeln!(out, "{}", json!({"id": id, "ok": false, "error": "control request queue is full"}))?;
|
||||
continue;
|
||||
}
|
||||
pending.push(ControlRequest { id: id.clone(), method, params, reply: tx, cancelled: cancelled.clone() });
|
||||
drop(pending);
|
||||
if let Some(c) = ctx.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
|
||||
c.request_repaint();
|
||||
}
|
||||
match rx.recv_timeout(Duration::from_secs(60)) {
|
||||
match rx.recv_timeout(timeout) {
|
||||
Ok(r) => match r.result {
|
||||
Ok(res) => json!({"id": r.id, "ok": true, "result": res}),
|
||||
Err(e) => json!({"id": r.id, "ok": false, "error": e}),
|
||||
},
|
||||
Err(_) => json!({"id": id, "ok": false, "error": "timed out waiting for the UI (is the window visible?)"}),
|
||||
Err(_) => {
|
||||
cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
json!({"id": id, "ok": false, "error": "timed out waiting for the UI: screenshots and input need the FigCraft window on screen (is it minimised or covered?)"})
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => json!({"id": null, "ok": false, "error": format!("bad JSON: {e}")}),
|
||||
Err(e) => {
|
||||
// A line that isn't JSON ends the connection: a web page can make the browser send
|
||||
// an HTTP request here, and its header lines must not let the JSON lines of its body
|
||||
// through as commands.
|
||||
writeln!(out, "{}", json!({"id": null, "ok": false, "error": format!("bad JSON: {e}")}))?;
|
||||
break;
|
||||
}
|
||||
};
|
||||
if writeln!(out, "{reply}").is_err() {
|
||||
break;
|
||||
}
|
||||
writeln!(out, "{reply}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "control_server_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,278 @@
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
use std::io::Read;
|
||||
use std::net::Shutdown;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Instant;
|
||||
|
||||
fn pair() -> (TcpStream, TcpStream) {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let client = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
|
||||
client.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
client.set_write_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
(client, listener.accept().unwrap().0)
|
||||
}
|
||||
|
||||
fn queue() -> Queue {
|
||||
Arc::new(Mutex::new(Vec::new()))
|
||||
}
|
||||
|
||||
fn context() -> CtxSlot {
|
||||
Arc::new(Mutex::new(None))
|
||||
}
|
||||
|
||||
fn reply(reader: &mut BufReader<TcpStream>) -> Value {
|
||||
let mut line = String::new();
|
||||
assert!(reader.read_line(&mut line).unwrap() > 0);
|
||||
serde_json::from_str(&line).unwrap()
|
||||
}
|
||||
|
||||
fn request() -> ControlRequest {
|
||||
ControlRequest { id: json!(0), method: "ui.inspect".into(), params: json!({}), reply: mpsc::channel().0, cancelled: Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_an_oversized_unterminated_frame_without_dispatch() {
|
||||
let (mut client, server) = pair();
|
||||
let pending = queue();
|
||||
let q = pending.clone();
|
||||
let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));
|
||||
// Generate bytes as they are written; no giant input string and no terminating newline.
|
||||
std::io::copy(&mut std::io::repeat(b' ').take((MAX_LINE + 1) as u64), &mut client).unwrap();
|
||||
let mut reader = BufReader::new(client);
|
||||
assert_eq!(reply(&mut reader), json!({"id": null, "ok": false, "error": "message too large"}));
|
||||
assert_eq!(reader.read(&mut [0]).unwrap(), 0);
|
||||
worker.join().unwrap().unwrap();
|
||||
assert!(pending.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_frames_keep_their_order_ids_and_parameters() {
|
||||
let (mut client, server) = pair();
|
||||
let pending = queue();
|
||||
let q = pending.clone();
|
||||
let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));
|
||||
client.write_all(b"\r\n{\"id\":\"test\",\"method\":\"engine.execute\",\"params\":{\"command\":\"shape.rectangle\"}}\r\n").unwrap();
|
||||
let mut reader = BufReader::new(client);
|
||||
let start = Instant::now();
|
||||
let r = loop {
|
||||
if let Some(r) = pending.lock().unwrap().pop() {
|
||||
break r;
|
||||
}
|
||||
assert!(start.elapsed() < Duration::from_secs(5));
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
};
|
||||
assert_eq!(r.id, "test");
|
||||
assert_eq!(r.method, "engine.execute");
|
||||
assert_eq!(r.params, json!({"command": "shape.rectangle"}));
|
||||
r.reply.send(figcraft_ui_egui::ControlReply { id: r.id, result: Ok(json!({"id": 42})) }).unwrap();
|
||||
assert_eq!(reply(&mut reader), json!({"id": "test", "ok": true, "result": {"id": 42}}));
|
||||
reader.get_ref().shutdown(Shutdown::Write).unwrap();
|
||||
worker.join().unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_that_is_not_json_ends_the_connection_before_anything_after_it_runs() {
|
||||
// What a browser sends when a web page posts to the port: the JSON line in its body never runs.
|
||||
let (mut client, server) = pair();
|
||||
let pending = queue();
|
||||
let q = pending.clone();
|
||||
let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));
|
||||
let body = "{\"id\":1,\"method\":\"engine.execute\",\"params\":{\"command\":\"file.save\"}}\n";
|
||||
let http = format!("POST / HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: text/plain\r\nContent-Length: {}\r\n\r\n{body}", body.len());
|
||||
client.write_all(http.as_bytes()).unwrap();
|
||||
let mut reader = BufReader::new(client);
|
||||
assert_eq!(reply(&mut reader)["ok"], false);
|
||||
assert_eq!(reader.read(&mut [0]).unwrap(), 0, "the connection is closed");
|
||||
worker.join().unwrap().unwrap();
|
||||
assert!(pending.lock().unwrap().is_empty(), "nothing was dispatched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_queue_rejects_before_dispatch_and_ui_queue_stays_bounded() {
|
||||
let (mut client, server) = pair();
|
||||
let pending = queue();
|
||||
pending.lock().unwrap().extend((0..MAX_QUEUED_REQUESTS).map(|_| request()));
|
||||
let mut ui_queue = (0..MAX_QUEUED_REQUESTS - 1).map(|_| request()).collect();
|
||||
drain_queue(&pending, &mut ui_queue);
|
||||
assert_eq!(ui_queue.len(), MAX_QUEUED_REQUESTS);
|
||||
assert_eq!(pending.lock().unwrap().len(), MAX_QUEUED_REQUESTS - 1);
|
||||
pending.lock().unwrap().push(request());
|
||||
drain_queue(&pending, &mut ui_queue);
|
||||
assert_eq!(pending.lock().unwrap().len(), MAX_QUEUED_REQUESTS);
|
||||
let q = pending.clone();
|
||||
let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));
|
||||
client.write_all(b"{\"id\":1,\"method\":\"engine.execute\"}\n").unwrap();
|
||||
let mut reader = BufReader::new(client);
|
||||
assert_eq!(reply(&mut reader), json!({"id": 1, "ok": false, "error": "control request queue is full"}));
|
||||
assert_eq!(pending.lock().unwrap().len(), MAX_QUEUED_REQUESTS);
|
||||
ui_queue.clear();
|
||||
drain_queue(&pending, &mut ui_queue);
|
||||
assert_eq!(ui_queue.len(), MAX_QUEUED_REQUESTS);
|
||||
assert!(pending.lock().unwrap().is_empty());
|
||||
reader.get_ref().shutdown(Shutdown::Write).unwrap();
|
||||
worker.join().unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_and_partial_frame_connections_expire() {
|
||||
for bytes in [b"".as_slice(), b"{\"id\":"] {
|
||||
let (mut client, server) = pair();
|
||||
let (done, finished) = mpsc::channel();
|
||||
let pending = queue();
|
||||
let q = pending.clone();
|
||||
let worker = std::thread::spawn(move || done.send(serve(server, q, context(), Duration::from_millis(50))));
|
||||
client.write_all(bytes).unwrap();
|
||||
let err = finished.recv_timeout(Duration::from_secs(5)).unwrap().unwrap_err();
|
||||
assert!(matches!(err.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut));
|
||||
assert!(pending.lock().unwrap().is_empty());
|
||||
worker.join().unwrap().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_limit_rejects_excess_clients_and_reuses_finished_slots() {
|
||||
let pending = queue();
|
||||
let ctx = context();
|
||||
let mut workers = Vec::new();
|
||||
let mut clients = Vec::new();
|
||||
for _ in 0..MAX_CONNECTIONS {
|
||||
let (client, server) = pair();
|
||||
spawn_connection(server, pending.clone(), ctx.clone(), &mut workers, Duration::from_secs(5));
|
||||
clients.push(client);
|
||||
}
|
||||
assert_eq!(workers.len(), MAX_CONNECTIONS);
|
||||
let (mut extra, server) = pair();
|
||||
spawn_connection(server, pending.clone(), ctx.clone(), &mut workers, Duration::from_secs(5));
|
||||
assert_eq!(extra.read(&mut [0]).unwrap(), 0);
|
||||
assert_eq!(workers.len(), MAX_CONNECTIONS);
|
||||
for client in clients {
|
||||
client.shutdown(Shutdown::Both).unwrap();
|
||||
}
|
||||
let start = Instant::now();
|
||||
while workers.iter().any(|worker| !worker.is_finished()) {
|
||||
assert!(start.elapsed() < Duration::from_secs(5));
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
let (mut client, server) = pair();
|
||||
spawn_connection(server, pending, ctx, &mut workers, Duration::from_secs(5));
|
||||
assert_eq!(workers.len(), 1);
|
||||
client.write_all(b"invalid\n").unwrap();
|
||||
assert_eq!(reply(&mut BufReader::new(client.try_clone().unwrap()))["ok"], false);
|
||||
client.shutdown(Shutdown::Both).unwrap();
|
||||
workers.pop().unwrap().join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_client_that_stops_reading_cannot_hold_a_worker() {
|
||||
let (mut client, server) = pair();
|
||||
let pending = queue();
|
||||
let q = pending.clone();
|
||||
let (done, finished) = mpsc::channel();
|
||||
let worker = std::thread::spawn(move || done.send(serve(server, q, context(), Duration::from_millis(100))));
|
||||
client.write_all(b"{\"id\":1,\"method\":\"document.inspect\"}\n").unwrap();
|
||||
let start = Instant::now();
|
||||
let r = loop {
|
||||
if let Some(r) = pending.lock().unwrap().pop() {
|
||||
break r;
|
||||
}
|
||||
assert!(start.elapsed() < Duration::from_secs(5));
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
};
|
||||
r.reply.send(figcraft_ui_egui::ControlReply { id: r.id, result: Ok(json!("x".repeat(8 * 1024 * 1024))) }).unwrap();
|
||||
let err = finished.recv_timeout(Duration::from_secs(5)).unwrap().unwrap_err();
|
||||
assert!(matches!(err.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut));
|
||||
worker.join().unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_limit_sized_frame_is_accepted_at_eof() {
|
||||
let (mut client, server) = pair();
|
||||
let pending = queue();
|
||||
let q = pending.clone();
|
||||
let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));
|
||||
let message = b"{\"id\":1,\"method\":\"ui.inspect\"}";
|
||||
client.write_all(message).unwrap();
|
||||
std::io::copy(&mut std::io::repeat(b' ').take((MAX_LINE - message.len()) as u64), &mut client).unwrap();
|
||||
client.shutdown(Shutdown::Write).unwrap();
|
||||
let start = Instant::now();
|
||||
let r = loop {
|
||||
if let Some(r) = pending.lock().unwrap().pop() {
|
||||
break r;
|
||||
}
|
||||
assert!(start.elapsed() < Duration::from_secs(5));
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
};
|
||||
assert_eq!(r.id, 1);
|
||||
assert_eq!(r.method, "ui.inspect");
|
||||
r.reply.send(figcraft_ui_egui::ControlReply { id: r.id, result: Ok(json!({})) }).unwrap();
|
||||
assert_eq!(reply(&mut BufReader::new(client)), json!({"id": 1, "ok": true, "result": {}}));
|
||||
worker.join().unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timed_out_requests_release_a_full_deferred_ui_queue() {
|
||||
use figcraft_ui_egui::{FigApp, NoServices, control, windows};
|
||||
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_embed_viewports(false);
|
||||
let mut app = FigApp::new(figcraft_engine::Session::new(), Box::new(NoServices));
|
||||
let window = windows::open(&mut app, &ctx, Some(0), None, None).unwrap();
|
||||
let pending = queue();
|
||||
let mut clients = Vec::new();
|
||||
let mut workers = Vec::new();
|
||||
for id in 0..MAX_QUEUED_REQUESTS {
|
||||
let (mut client, server) = pair();
|
||||
let q = pending.clone();
|
||||
workers.push(std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(1))));
|
||||
writeln!(client, "{}", json!({"id": id, "method": "engine.execute", "params": {"window": window, "command": "file.new"}})).unwrap();
|
||||
clients.push(BufReader::new(client));
|
||||
}
|
||||
let start = Instant::now();
|
||||
while pending.lock().unwrap().len() < MAX_QUEUED_REQUESTS {
|
||||
assert!(start.elapsed() < Duration::from_secs(5));
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
drain_queue(&pending, &mut app.control_queue);
|
||||
control::drain(&mut app, &ctx);
|
||||
assert_eq!(app.control_queue.len(), MAX_QUEUED_REQUESTS, "the other window has not drawn");
|
||||
for client in &mut clients {
|
||||
assert!(reply(client)["error"].as_str().unwrap().contains("timed out"));
|
||||
client.get_ref().shutdown(Shutdown::Write).unwrap();
|
||||
}
|
||||
assert!(app.control_queue.iter().all(ControlRequest::is_cancelled));
|
||||
let expired = app.control_queue.pop().unwrap();
|
||||
// Include expired requests in the socket queue, too: admission must reclaim their slots.
|
||||
pending.lock().unwrap().extend((0..MAX_QUEUED_REQUESTS).map(|_| {
|
||||
let r = request();
|
||||
r.cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
r
|
||||
}));
|
||||
let (mut client, server) = pair();
|
||||
let q = pending.clone();
|
||||
workers.push(std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5))));
|
||||
client.write_all(b"{\"id\":100,\"method\":\"window.list\",\"params\":{\"window\":0}}\n").unwrap();
|
||||
let start = Instant::now();
|
||||
while !pending.lock().unwrap().iter().any(|r| r.id == 100) {
|
||||
assert!(start.elapsed() < Duration::from_secs(5));
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
drain_queue(&pending, &mut app.control_queue);
|
||||
assert_eq!(app.control_queue.len(), 1, "expired UI requests no longer occupy the cap");
|
||||
control::drain(&mut app, &ctx);
|
||||
let mut client = BufReader::new(client);
|
||||
assert_eq!(reply(&mut client)["ok"], true);
|
||||
client.get_ref().shutdown(Shutdown::Write).unwrap();
|
||||
assert!(app.control_queue.is_empty());
|
||||
// A window that finally draws must discard its expired command before dispatch, too.
|
||||
let documents = app.session.docs.len();
|
||||
app.windows.current = window;
|
||||
app.control_queue.push(expired);
|
||||
control::drain(&mut app, &ctx);
|
||||
assert!(app.control_queue.is_empty());
|
||||
assert_eq!(app.session.docs.len(), documents);
|
||||
for worker in workers {
|
||||
worker.join().unwrap().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//! Which graphics API draws the window.
|
||||
//!
|
||||
//! eframe offers wgpu every backend it was built with, and between equal GPUs wgpu takes Vulkan
|
||||
//! first, on Windows too. A Vulkan driver that faults there takes the app down as the window opens:
|
||||
//! an access violation inside the vendor's driver, which no guard of ours can catch (seen on a laptop
|
||||
//! with two AMD GPUs whose drivers were years apart). Direct3D 12 is part of Windows, so on Windows
|
||||
//! wgpu is offered Direct3D 12, and OpenGL for hardware without a Direct3D 12 driver.
|
||||
//!
|
||||
//! `WGPU_BACKEND` (`dx12`, `vulkan`, `gl`, `metal`) still names the backend on every system, and
|
||||
//! systems other than Windows keep eframe's defaults.
|
||||
|
||||
use eframe::egui_wgpu::{WgpuConfiguration, WgpuSetup};
|
||||
use eframe::wgpu::Backends;
|
||||
|
||||
/// Direct3D 12, then OpenGL: wgpu ranks a hardware GPU on either ahead of Direct3D's software one.
|
||||
const WINDOWS: Backends = Backends::DX12.union(Backends::GL);
|
||||
|
||||
/// The backends wgpu chooses from: the ones `WGPU_BACKEND` asks for (`requested`), else [`WINDOWS`]
|
||||
/// on Windows, else eframe's `defaults`.
|
||||
pub fn backends(requested: Option<Backends>, windows: bool, defaults: Backends) -> Backends {
|
||||
match requested {
|
||||
Some(requested) => requested,
|
||||
None if windows => WINDOWS,
|
||||
None => defaults,
|
||||
}
|
||||
}
|
||||
|
||||
/// Offer wgpu [`backends`] instead of eframe's defaults.
|
||||
pub fn configure(options: &mut WgpuConfiguration) {
|
||||
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_setup {
|
||||
let offered = &mut create.instance_descriptor.backends;
|
||||
*offered = backends(Backends::from_env(), cfg!(windows), *offered);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//! The backends offered to wgpu: no Vulkan on Windows unless it is asked for (a faulting Vulkan
|
||||
//! driver crashed the app as its window opened), and nothing changed anywhere else.
|
||||
|
||||
use eframe::wgpu::Backends;
|
||||
|
||||
use crate::graphics::backends;
|
||||
|
||||
/// What eframe offers when `WGPU_BACKEND` isn't set.
|
||||
const DEFAULTS: Backends = Backends::PRIMARY.union(Backends::GL);
|
||||
|
||||
#[test]
|
||||
fn windows_is_offered_direct3d_and_not_vulkan() {
|
||||
let offered = backends(None, true, DEFAULTS);
|
||||
assert!(offered.contains(Backends::DX12), "Direct3D 12 draws the window");
|
||||
assert!(offered.contains(Backends::GL), "OpenGL is there for hardware without a Direct3D 12 driver");
|
||||
assert!(!offered.contains(Backends::VULKAN), "a Vulkan driver that faults can't be picked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wgpu_backend_names_the_backend_on_every_system() {
|
||||
for windows in [true, false] {
|
||||
for asked in [Backends::VULKAN, Backends::DX12, Backends::GL, Backends::VULKAN | Backends::GL] {
|
||||
assert_eq!(backends(Some(asked), windows, DEFAULTS), asked, "windows: {windows}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_systems_keep_eframes_defaults() {
|
||||
assert_eq!(backends(None, false, DEFAULTS), DEFAULTS);
|
||||
assert_eq!(backends(None, false, Backends::METAL), Backends::METAL);
|
||||
}
|
||||
@@ -9,20 +9,30 @@
|
||||
#![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")]
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod apple_events;
|
||||
mod args;
|
||||
mod control_server;
|
||||
mod gamepad;
|
||||
mod graphics;
|
||||
mod recovery;
|
||||
mod services;
|
||||
mod startup;
|
||||
|
||||
#[cfg(all(test, target_os = "macos"))]
|
||||
mod apple_events_tests;
|
||||
#[cfg(test)]
|
||||
mod args_tests;
|
||||
#[cfg(test)]
|
||||
mod graphics_tests;
|
||||
#[cfg(test)]
|
||||
mod recovery_online_tests;
|
||||
#[cfg(test)]
|
||||
mod recovery_tests;
|
||||
#[cfg(test)]
|
||||
mod services_tests;
|
||||
#[cfg(test)]
|
||||
mod startup_tests;
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -40,6 +50,9 @@ struct App {
|
||||
title: String,
|
||||
/// Shared with the services, which write the copies after a panic.
|
||||
recovery: recovery::Shared,
|
||||
/// Documents and quit requests from Finder, the Dock and Open With.
|
||||
#[cfg(target_os = "macos")]
|
||||
apple_events: fmv_macos_events::Inbox,
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
@@ -51,9 +64,10 @@ impl eframe::App for App {
|
||||
}
|
||||
}
|
||||
let title = with_app(|fig| {
|
||||
let mut q = self.queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
fig.control_queue.extend(q.drain(..));
|
||||
drop(q);
|
||||
control_server::drain_queue(&self.queue, &mut fig.control_queue);
|
||||
figcraft_ui_egui::control::drain_hidden(fig, ctx);
|
||||
#[cfg(target_os = "macos")]
|
||||
apple_events::poll(&self.apple_events, fig, ctx);
|
||||
// Closing the window asks about unsaved changes (and keeps the app running while other
|
||||
// windows are open).
|
||||
figcraft_ui_egui::windows::on_close_request(fig, ctx);
|
||||
@@ -80,9 +94,9 @@ impl eframe::App for App {
|
||||
}
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||||
let ctx = ui.ctx().clone();
|
||||
with_app(|fig| figcraft_ui_egui::windows::guard(fig, &ctx, |fig| fig.ui(ui)));
|
||||
services::parented(frame, || with_app(|fig| figcraft_ui_egui::windows::guard(fig, &ctx, |fig| fig.ui(ui))));
|
||||
}
|
||||
|
||||
fn on_exit(&mut self) {
|
||||
@@ -156,6 +170,9 @@ fn main() -> eframe::Result {
|
||||
if session.docs.is_empty() && !first_run {
|
||||
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.ui.home = false;
|
||||
fig.note_recovered(recovered.copies);
|
||||
@@ -167,8 +184,11 @@ fn main() -> eframe::Result {
|
||||
control_server::start(p, queue.clone(), ctx_slot.clone());
|
||||
}
|
||||
let icon = services::window_icon();
|
||||
// The app id is the Wayland app_id and the X11 window class on Linux: the desktop entry's name
|
||||
// and StartupWMClass (FreeBSD's package matches the binary's name instead).
|
||||
let mut viewport = egui::ViewportBuilder::default()
|
||||
.with_title("FigCraft")
|
||||
.with_app_id("ai.storyteller.figcraft")
|
||||
.with_inner_size([1440.0, 900.0])
|
||||
.with_min_inner_size([800.0, 500.0])
|
||||
.with_drag_and_drop(true);
|
||||
@@ -184,7 +204,32 @@ fn main() -> eframe::Result {
|
||||
if let Some(icon) = icon {
|
||||
viewport = viewport.with_icon(icon);
|
||||
}
|
||||
let options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() };
|
||||
let mut options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() };
|
||||
graphics::configure(&mut options.wgpu_options);
|
||||
figcraft_ui_egui::windows::install(fig);
|
||||
eframe::run_native("FigCraft", options, Box::new(move |_cc| Ok(Box::new(App { queue, ctx_slot, title: String::new(), recovery }))))
|
||||
// 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")]
|
||||
let apple_events = apple_events::AppleEvents::install();
|
||||
#[cfg(target_os = "macos")]
|
||||
let apple_events = &apple_events;
|
||||
let result = eframe::run_native(
|
||||
"FigCraft",
|
||||
options,
|
||||
Box::new(move |_cc| {
|
||||
Ok(Box::new(App {
|
||||
queue,
|
||||
ctx_slot,
|
||||
title: String::new(),
|
||||
recovery,
|
||||
#[cfg(target_os = "macos")]
|
||||
apple_events: apple_events.connect(&_cc.egui_ctx),
|
||||
}))
|
||||
}),
|
||||
);
|
||||
// Say why instead of quitting silently: release builds on Windows have no console.
|
||||
if let Err(e) = &result {
|
||||
startup::failed(e, port.is_some());
|
||||
}
|
||||
result
|
||||
}
|
||||
+177
-66
@@ -4,7 +4,10 @@
|
||||
//! in the config folder, in the background, at most every [`INTERVAL`] seconds, and the copy goes
|
||||
//! once the document is saved or closed. The app holds a lock on its folder. At startup, the folders
|
||||
//! whose lock is free belong to runs that ended without closing: their copies open again as
|
||||
//! unsaved documents (`file.recover`) and the folder goes. Closing the app normally removes its own.
|
||||
//! unsaved documents (`file.recover`). Each such copy **stays until this run has its own copy of
|
||||
//! the document** (or it is saved or closed): until then it is the only copy on disk, and a run
|
||||
//! that can't keep copies of its own leaves it for the next start. Then it goes, and its folder
|
||||
//! with its last copy. Closing the app normally removes its own.
|
||||
//!
|
||||
//! A document that is one of your online files has a note beside its copy (`<uid>.online`, written
|
||||
//! after the copy it describes): the server, the file's id, and who the app was in the file with
|
||||
@@ -22,7 +25,8 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_engine::{DocState, Session};
|
||||
use figcraft_format::{Branch, Version};
|
||||
use serde_json::json;
|
||||
|
||||
/// Seconds between copies of a document that keeps changing.
|
||||
@@ -41,20 +45,61 @@ pub struct Recovery {
|
||||
dir: PathBuf,
|
||||
/// Held while the app runs: a free lock means the run that wrote the folder is gone.
|
||||
_lock: File,
|
||||
/// The revision each document was last copied at, and the write in flight.
|
||||
written: HashMap<u64, (u64, Option<JoinHandle<()>>)>,
|
||||
/// What was copied of each document, and the write in flight.
|
||||
written: HashMap<u64, Copy>,
|
||||
/// When (UI clock) the next copies are due.
|
||||
next: f64,
|
||||
/// Copies of online files from earlier runs, kept until their outcome is known.
|
||||
waiting: Vec<Waiting>,
|
||||
}
|
||||
|
||||
/// A copy of one of your online files whose file is being joined again as session `session`.
|
||||
/// The state of the copy of one document: only a write that succeeded counts as a copy.
|
||||
#[derive(Default)]
|
||||
struct Copy {
|
||||
/// The [`stamp`] whose copy was written.
|
||||
done: Option<Vec<u64>>,
|
||||
/// The write in flight: the stamp it copies, and whether it succeeded once it ends.
|
||||
flight: Option<(Vec<u64>, JoinHandle<bool>)>,
|
||||
}
|
||||
|
||||
impl Copy {
|
||||
/// Take in the result of the write in flight once it ended; a failed one leaves its revision uncopied.
|
||||
fn reap(&mut self) {
|
||||
if self.flight.as_ref().is_some_and(|f| f.1.is_finished()) {
|
||||
self.join();
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for the write in flight and take in its result.
|
||||
fn join(&mut self) {
|
||||
if let Some((stamp, h)) = self.flight.take()
|
||||
&& h.join().unwrap_or(false)
|
||||
{
|
||||
self.done = Some(stamp);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `stamp` has neither been copied nor is being copied.
|
||||
fn pending(&self, stamp: &[u64]) -> bool {
|
||||
self.done.as_deref() != Some(stamp) && self.flight.as_ref().is_none_or(|f| f.0 != stamp)
|
||||
}
|
||||
}
|
||||
|
||||
/// A copy from an earlier run, kept until what it became is safe.
|
||||
pub struct Waiting {
|
||||
session: u64,
|
||||
until: Until,
|
||||
copy: PathBuf,
|
||||
/// The lock of the folder it is in: no other start takes the copy meanwhile.
|
||||
_lock: Arc<File>,
|
||||
/// The lock of the folder it is in: no other start takes the copy meanwhile (none for a copy
|
||||
/// left directly in the root by older versions).
|
||||
_lock: Option<Arc<File>>,
|
||||
}
|
||||
|
||||
/// What a kept copy waits for.
|
||||
enum Until {
|
||||
/// One of your online files, joined again as this session: its outcome.
|
||||
Session(u64),
|
||||
/// Opened as document `uid`: this run's own copy of it (or its save or close).
|
||||
Copied(u64),
|
||||
}
|
||||
|
||||
/// What [`recover_all`] brought back.
|
||||
@@ -62,21 +107,26 @@ pub struct Waiting {
|
||||
pub struct Recovered {
|
||||
/// Documents opened from their copies.
|
||||
pub copies: usize,
|
||||
/// Copies of online files waiting for their file ([`Recovery::adopt`]).
|
||||
/// The copies kept until what they became is safe ([`Recovery::adopt`]).
|
||||
pub waiting: Vec<Waiting>,
|
||||
}
|
||||
|
||||
impl Recovery {
|
||||
/// Take this run's folder under `root` (`None` when it can't be created or locked: no copies).
|
||||
/// A crashed run with the same process id may still hold its folder (its copies kept): then
|
||||
/// another name is taken.
|
||||
pub fn start(root: &Path) -> Option<Self> {
|
||||
let dir = root.join(std::process::id().to_string());
|
||||
std::fs::create_dir_all(&dir).ok()?;
|
||||
let lock = File::create(dir.join(LOCK)).ok()?;
|
||||
lock.try_lock().ok()?;
|
||||
Some(Self { dir, _lock: lock, written: HashMap::new(), next: 0.0, waiting: Vec::new() })
|
||||
let pid = std::process::id();
|
||||
(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()?;
|
||||
Some(Self { dir, _lock: lock, written: HashMap::new(), next: 0.0, waiting: Vec::new() })
|
||||
})
|
||||
}
|
||||
|
||||
/// Keep earlier runs' copies of online files until their outcome is known.
|
||||
/// Keep earlier runs' copies until what they became is safe.
|
||||
pub fn adopt(&mut self, waiting: Vec<Waiting>) {
|
||||
self.waiting.extend(waiting);
|
||||
}
|
||||
@@ -85,38 +135,47 @@ impl Recovery {
|
||||
/// were saved or closed. Returns how long until it needs to run again (`None`: nothing pending,
|
||||
/// an idle window can sleep).
|
||||
pub fn tick(&mut self, s: &Session, now: f64) -> Option<f64> {
|
||||
self.written.values_mut().for_each(Copy::reap);
|
||||
self.settle(s);
|
||||
// A join on its way settles within its timeouts: look again soon.
|
||||
let settling = (!self.waiting.is_empty()).then_some(0.5);
|
||||
let pending = s.docs.iter().any(|d| needs_copy(d) && self.written.get(&d.uid).is_none_or(|w| w.0 != d.revision));
|
||||
// A join on its way settles within its timeouts: look again soon. A copy waiting for this
|
||||
// run's own is looked at again with the next copies.
|
||||
let joining = self.waiting.iter().any(|w| matches!(w.until, Until::Session(_)));
|
||||
let settling = if joining { Some(0.5) } else { (!self.waiting.is_empty()).then_some(INTERVAL) };
|
||||
if now < self.next {
|
||||
return pending.then_some(self.next - now).or(settling);
|
||||
return self.pending(s).then_some(self.next - now).or(settling);
|
||||
}
|
||||
self.write(s, false);
|
||||
self.next = now + INTERVAL;
|
||||
settling
|
||||
// A copy that failed is tried again at the next turn, without another edit.
|
||||
settling.or(self.pending(s).then_some(INTERVAL))
|
||||
}
|
||||
|
||||
/// Remove earlier runs' copies whose outcome is known, once what they became is copied here.
|
||||
fn settle(&mut self, s: &Session) {
|
||||
if self.waiting.iter().all(|w| s.live.recovering(w.session)) {
|
||||
return;
|
||||
/// Whether a document with unsaved changes has a revision that is neither copied nor being copied.
|
||||
fn pending(&self, s: &Session) -> bool {
|
||||
s.docs.iter().any(|d| needs_copy(s, d) && self.written.get(&d.uid).is_none_or(|w| w.pending(&stamp(s, d))))
|
||||
}
|
||||
|
||||
/// Remove earlier runs' copies once what they became is safe: copied here (the write
|
||||
/// succeeded), saved or closed. An online file's copy goes once its outcome is known and every
|
||||
/// document is safe, since which of them it became isn't known.
|
||||
pub fn settle(&mut self, s: &Session) {
|
||||
let joined = |w: &Waiting| matches!(w.until, Until::Session(id) if !s.live.recovering(id));
|
||||
if self.waiting.iter().any(joined) {
|
||||
self.write(s, true);
|
||||
}
|
||||
self.write(s, true);
|
||||
let (done, waiting): (Vec<Waiting>, Vec<Waiting>) =
|
||||
std::mem::take(&mut self.waiting).into_iter().partition(|w| !s.live.recovering(w.session));
|
||||
let all_safe = s.docs.iter().all(|d| self.safe(s, d));
|
||||
let (done, waiting): (Vec<Waiting>, Vec<Waiting>) = std::mem::take(&mut self.waiting).into_iter().partition(|w| match w.until {
|
||||
Until::Session(_) => joined(w) && all_safe,
|
||||
Until::Copied(uid) => s.docs.iter().find(|d| d.uid == uid).is_none_or(|d| self.safe(s, d)),
|
||||
});
|
||||
self.waiting = waiting;
|
||||
for w in done {
|
||||
let _ = std::fs::remove_file(&w.copy);
|
||||
let _ = std::fs::remove_file(w.copy.with_extension(ONLINE));
|
||||
// The folder goes with its last copy (while another waits there its lock is held).
|
||||
let dir = w.copy.parent().map(Path::to_path_buf).filter(|_| Arc::strong_count(&w._lock) == 1);
|
||||
drop(w);
|
||||
if let Some(dir) = dir {
|
||||
let _ = std::fs::remove_file(dir.join(LOCK));
|
||||
let _ = std::fs::remove_dir(dir);
|
||||
}
|
||||
}
|
||||
done.into_iter().for_each(remove_kept);
|
||||
}
|
||||
|
||||
/// Whether document `d`'s work is on disk: saved (or nothing to keep), or copied here (a write
|
||||
/// that succeeded) with no write in flight.
|
||||
fn safe(&self, s: &Session, d: &DocState) -> bool {
|
||||
!needs_copy(s, d) || self.written.get(&d.uid).is_some_and(|w| w.done.is_some() && w.flight.is_none())
|
||||
}
|
||||
|
||||
/// Copy every document with unsaved changes; `wait`: before returning (after a panic).
|
||||
@@ -124,47 +183,60 @@ impl Recovery {
|
||||
for d in &s.docs {
|
||||
let path = self.dir.join(format!("{}.figcraft", d.uid));
|
||||
let note = path.with_extension(ONLINE);
|
||||
if !needs_copy(d) {
|
||||
if self.written.remove(&d.uid).is_some() {
|
||||
if !needs_copy(s, d) {
|
||||
if let Some(mut w) = self.written.remove(&d.uid) {
|
||||
// A write still running would bring the copy back after it was removed.
|
||||
w.join();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(¬e);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let entry = self.written.entry(d.uid).or_insert((0, None));
|
||||
let busy = entry.1.as_ref().is_some_and(|h| !h.is_finished());
|
||||
if entry.0 == d.revision || busy {
|
||||
let entry = self.written.entry(d.uid).or_default();
|
||||
// An emergency save must copy the newest revision: let the write in flight finish first.
|
||||
if wait {
|
||||
entry.join();
|
||||
} else {
|
||||
entry.reap();
|
||||
}
|
||||
// A write still in flight (for any revision) is waited for; a failed one is tried again.
|
||||
let stamp = stamp(s, d);
|
||||
if entry.done.as_ref() == Some(&stamp) || entry.flight.is_some() {
|
||||
continue;
|
||||
}
|
||||
let doc = d.doc.clone();
|
||||
let (versions, branches) = (d.versions.clone(), figcraft_engine::branches::current(s, d.uid));
|
||||
// Which online file it is and what of the server's this very copy has (a line each:
|
||||
// the server, the file's id, then the session, client id and last change), while it
|
||||
// is one. Written after the copy, never before: a note must not promise more of the
|
||||
// server's changes than its copy holds.
|
||||
let online = s.online_of(d.uid).map(|(server, file, at)| format!("{server}\n{file}\n{} {} {}", at.session, at.client_id, at.seq));
|
||||
let job = move || {
|
||||
if !write_atomic(&path, &doc) {
|
||||
return;
|
||||
if !write_atomic(&path, &doc, &versions, &branches) {
|
||||
return false;
|
||||
}
|
||||
let _ = match online {
|
||||
Some(text) => std::fs::write(¬e, text),
|
||||
None => std::fs::remove_file(¬e),
|
||||
};
|
||||
true
|
||||
};
|
||||
// The stamp counts as copied only once its write succeeded.
|
||||
if wait {
|
||||
job();
|
||||
entry.1 = None;
|
||||
if job() {
|
||||
entry.done = Some(stamp);
|
||||
}
|
||||
} else {
|
||||
entry.1 = std::thread::Builder::new().name("recovery".into()).spawn(job).ok();
|
||||
entry.flight = std::thread::Builder::new().name("recovery".into()).spawn(job).ok().map(|h| (stamp, h));
|
||||
}
|
||||
entry.0 = d.revision;
|
||||
}
|
||||
// Closed documents.
|
||||
let open: Vec<u64> = s.docs.iter().map(|d| d.uid).collect();
|
||||
let dir = &self.dir;
|
||||
self.written.retain(|uid, _| {
|
||||
self.written.retain(|uid, w| {
|
||||
let keep = open.contains(uid);
|
||||
if !keep {
|
||||
w.join();
|
||||
let _ = std::fs::remove_file(dir.join(format!("{uid}.figcraft")));
|
||||
let _ = std::fs::remove_file(dir.join(format!("{uid}.{ONLINE}")));
|
||||
}
|
||||
@@ -172,12 +244,20 @@ impl Recovery {
|
||||
});
|
||||
}
|
||||
|
||||
/// Test hook: pretend a write of `uid` at `revision` is in flight in `writer`.
|
||||
#[cfg(test)]
|
||||
pub fn hold(&mut self, uid: u64, revision: u64, writer: JoinHandle<bool>) {
|
||||
self.written.insert(uid, Copy { done: None, flight: Some((vec![revision], writer)) });
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
let Self { dir, _lock, written, .. } = self;
|
||||
let Self { dir, _lock, written, waiting, .. } = self;
|
||||
// The documents earlier runs' copies became were saved or discarded too.
|
||||
waiting.into_iter().filter(|w| matches!(w.until, Until::Copied(_))).for_each(remove_kept);
|
||||
// Windows removes a folder only once nothing in it is open: copies being written first.
|
||||
for h in written.into_values().filter_map(|w| w.1) {
|
||||
for (_, h) in written.into_values().filter_map(|w| w.flight) {
|
||||
let _ = h.join();
|
||||
}
|
||||
drop(_lock);
|
||||
@@ -185,6 +265,20 @@ impl Recovery {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a kept copy and its note, and its folder with its last copy (while another is kept
|
||||
/// there, its lock is held).
|
||||
fn remove_kept(w: Waiting) {
|
||||
let _ = std::fs::remove_file(&w.copy);
|
||||
let _ = std::fs::remove_file(w.copy.with_extension(ONLINE));
|
||||
let last = w._lock.as_ref().is_some_and(|l| Arc::strong_count(l) == 1);
|
||||
let dir = w.copy.parent().map(Path::to_path_buf).filter(|_| last);
|
||||
drop(w);
|
||||
if let Some(dir) = dir {
|
||||
let _ = std::fs::remove_file(dir.join(LOCK));
|
||||
let _ = std::fs::remove_dir(dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// The extension of the note beside the copy of a document that is one of your online files.
|
||||
const ONLINE: &str = "online";
|
||||
|
||||
@@ -201,15 +295,27 @@ fn rejoin_online(file: &Path, s: &mut Session) -> Option<u64> {
|
||||
s.execute("collab.recoverFile", ¶ms).ok()?.get("session")?.as_u64()
|
||||
}
|
||||
|
||||
/// A document whose changes would be lost (a branch tab's edits are in its main file).
|
||||
fn needs_copy(d: &figcraft_engine::DocState) -> bool {
|
||||
d.is_dirty() && d.branch_of.is_none()
|
||||
/// A file whose changes would be lost: in its document, its history or one of its branch tabs
|
||||
/// (a branch tab's edits are saved with its main file, so the tab has no copy of its own).
|
||||
fn needs_copy(s: &Session, d: &DocState) -> bool {
|
||||
d.branch_of.is_none() && (d.is_dirty() || branch_tabs(s, d.uid).any(DocState::is_dirty))
|
||||
}
|
||||
|
||||
/// Write `doc` to `path` through a temporary file; false when it couldn't be written (the copy
|
||||
/// before it is still there).
|
||||
fn write_atomic(path: &Path, doc: &figcraft_engine::doc::Document) -> bool {
|
||||
let Ok(bytes) = figcraft_format::save_bytes(doc, None) else { return false };
|
||||
/// The tabs showing branches of the file in tab `uid`.
|
||||
fn branch_tabs(s: &Session, uid: u64) -> impl Iterator<Item = &DocState> {
|
||||
s.docs.iter().filter(move |t| t.branch_of.as_ref().is_some_and(|(main, _)| *main == uid))
|
||||
}
|
||||
|
||||
/// The revisions a copy of `d` is of: its own and its branch tabs' (a branch tab's edits change
|
||||
/// only its own revision). A new copy is due when they change.
|
||||
fn stamp(s: &Session, d: &DocState) -> Vec<u64> {
|
||||
std::iter::once(d.revision).chain(branch_tabs(s, d.uid).map(|t| t.revision)).collect()
|
||||
}
|
||||
|
||||
/// Write `doc` with its version history and branches to `path` through a temporary file; false
|
||||
/// when it couldn't be written (the copy before it is still there).
|
||||
fn write_atomic(path: &Path, doc: &figcraft_engine::doc::Document, versions: &[Version], branches: &[Branch]) -> bool {
|
||||
let Ok(bytes) = figcraft_format::save_bytes_with_history(doc, None, versions, branches) else { return false };
|
||||
let tmp = path.with_extension("tmp");
|
||||
std::fs::write(&tmp, bytes).is_ok() && std::fs::rename(&tmp, path).is_ok()
|
||||
}
|
||||
@@ -222,9 +328,9 @@ pub fn recover(root: &Path, s: &mut Session) -> usize {
|
||||
}
|
||||
|
||||
/// Open the copies left by runs that ended without closing (folders under `root` whose lock is
|
||||
/// free, and copies directly in `root` from older versions) as unsaved documents, then remove them.
|
||||
/// A copy that can't be read stays where it is. A copy of one of your online files is kept
|
||||
/// instead while its file is joined again ([`Recovered::waiting`]).
|
||||
/// free, and copies directly in `root` from older versions) as unsaved documents. Each is kept
|
||||
/// until this run has its own copy of the document ([`Recovered::waiting`]); a copy of one of your
|
||||
/// online files, while its file is joined again. A copy that can't be read stays where it is.
|
||||
pub fn recover_all(root: &Path, s: &mut Session) -> Recovered {
|
||||
let mut out = Recovered::default();
|
||||
let Ok(entries) = std::fs::read_dir(root) else { return out };
|
||||
@@ -234,10 +340,15 @@ pub fn recover_all(root: &Path, s: &mut Session) -> Recovered {
|
||||
}
|
||||
// One of your online files comes back from its server, its copy kept until then.
|
||||
if let Some((session, lock)) = lock.and_then(|l| Some((rejoin_online(file, s)?, l))) {
|
||||
out.waiting.push(Waiting { session, copy: file.to_path_buf(), _lock: lock.clone() });
|
||||
} else if s.execute("file.recover", &json!({"path": file.to_string_lossy()})).is_ok() {
|
||||
out.waiting.push(Waiting { until: Until::Session(session), copy: file.to_path_buf(), _lock: Some(lock.clone()) });
|
||||
return;
|
||||
}
|
||||
let before: Vec<u64> = s.docs.iter().map(|d| d.uid).collect();
|
||||
if s.execute("file.recover", &json!({"path": file.to_string_lossy()})).is_ok() {
|
||||
out.copies += 1;
|
||||
let _ = std::fs::remove_file(file);
|
||||
if let Some(uid) = s.docs.iter().map(|d| d.uid).find(|u| !before.contains(u)) {
|
||||
out.waiting.push(Waiting { until: Until::Copied(uid), copy: file.to_path_buf(), _lock: lock.cloned() });
|
||||
}
|
||||
}
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
@@ -265,7 +376,7 @@ pub fn recover_all(root: &Path, s: &mut Session) -> Recovered {
|
||||
for f in files.iter().filter(|f| f.extension().is_some_and(|e| e == ONLINE) && !f.with_extension("figcraft").exists()) {
|
||||
let _ = std::fs::remove_file(f);
|
||||
}
|
||||
// Empty now unless a copy couldn't be read, or waits for its file (kept, its lock held).
|
||||
// Empty now unless a copy couldn't be read, or is kept (its lock held).
|
||||
if Arc::strong_count(&lock) == 1 {
|
||||
drop(lock);
|
||||
let _ = std::fs::remove_file(path.join(LOCK));
|
||||
|
||||
@@ -223,6 +223,30 @@ fn a_copy_ahead_of_the_server_has_its_edits_applied_and_sent() {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_online_copy_stays_while_what_it_became_cant_be_copied() {
|
||||
let root = root("ahead-blocked");
|
||||
let (mut s, script, mut server, _) = shared();
|
||||
s.execute("shape.ellipse", &json!({"x": 30, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
script.lock().unwrap().sent.clear();
|
||||
let left = crash(&s, &root);
|
||||
let (mut next, script, mut r, _) = restart(&s, &root);
|
||||
arrive(&mut next, &script, &mut server);
|
||||
assert!(next.docs[1].is_dirty(), "the copy's edit is on top, not saved anywhere yet");
|
||||
// Something is in the way of this run's copy: writing it fails.
|
||||
let mine = root.join(std::process::id().to_string()).join(format!("{}.figcraft", next.docs[1].uid));
|
||||
std::fs::create_dir_all(&mine).unwrap();
|
||||
r.tick(&next, 1.0);
|
||||
assert_eq!((copies(&left, "figcraft"), copies(&left, "online")), (1, 1), "kept: the work is nowhere else on disk");
|
||||
// Once it can be written, the old copy goes.
|
||||
std::fs::remove_dir(&mine).unwrap();
|
||||
next.execute("shape.rectangle", &json!({"x": 50, "y": 0, "width": 4, "height": 4})).unwrap();
|
||||
r.tick(&next, 2.0);
|
||||
assert!(!left.exists());
|
||||
drop(r);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_the_server_already_has_is_discarded() {
|
||||
let root = root("equal");
|
||||
@@ -332,7 +356,7 @@ fn without_its_record_the_copy_opens_as_any_other() {
|
||||
assert_eq!(recover(&root, &mut next), 1);
|
||||
assert!(script.lock().unwrap().joined.is_empty());
|
||||
assert!(next.docs.first().is_some_and(|d| d.is_dirty() && d.path.is_none()));
|
||||
assert!(std::fs::read_dir(&root).unwrap().next().is_none(), "the folder is gone");
|
||||
assert!(std::fs::read_dir(&root).unwrap().next().is_some(), "the copy stays until a run has its own");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::recovery::{Recovery, recover};
|
||||
use crate::recovery::{Recovery, recover, recover_all};
|
||||
|
||||
fn root(name: &str) -> std::path::PathBuf {
|
||||
let r = std::env::temp_dir().join(format!("figcraft-recovery-{name}-{}", std::process::id()));
|
||||
@@ -34,7 +34,7 @@ fn a_crashed_runs_unsaved_documents_come_back() {
|
||||
let ds = next.docs.first().unwrap();
|
||||
assert!(ds.is_dirty() && ds.path.is_none());
|
||||
assert_eq!(next.doc().unwrap().node(next.doc().unwrap().pages[0]).map(|p| p.children.len()), Some(1));
|
||||
assert!(std::fs::read_dir(&root).unwrap().next().is_none(), "the folder is gone");
|
||||
assert!(std::fs::read_dir(&root).unwrap().next().is_some(), "the copy stays until a run has its own");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
@@ -62,3 +62,289 @@ fn copies_follow_saves_and_a_normal_quit_leaves_nothing() {
|
||||
assert!(!dir.exists(), "a normal quit removes the copies");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// A run that ended without closing, leaving a copy of `s`'s unsaved document: its folder (as
|
||||
/// another process's).
|
||||
fn crashed(s: &Session, root: &std::path::Path) -> std::path::PathBuf {
|
||||
let mut r = Recovery::start(root).unwrap();
|
||||
r.write(s, true);
|
||||
drop(r);
|
||||
let left = root.join("crashed");
|
||||
std::fs::rename(root.join(std::process::id().to_string()), &left).unwrap();
|
||||
left
|
||||
}
|
||||
|
||||
fn copies_in(d: &std::path::Path) -> usize {
|
||||
std::fs::read_dir(d).map_or(0, |d| d.flatten().filter(|f| f.path().extension().is_some_and(|e| e == "figcraft")).count())
|
||||
}
|
||||
|
||||
/// The next start: the copies come back, and this run's recovery keeps them until it covers them.
|
||||
fn restart(root: &std::path::Path) -> (Session, Recovery) {
|
||||
let mut next = Session::empty();
|
||||
let recovered = recover_all(root, &mut next);
|
||||
assert_eq!(recovered.copies, 1);
|
||||
let mut r = Recovery::start(root).unwrap();
|
||||
r.adopt(recovered.waiting);
|
||||
(next, r)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recovered_copy_stays_until_this_run_has_its_own_copy() {
|
||||
let root = root("handoff");
|
||||
let left = crashed(&edited(), &root);
|
||||
let (next, mut r) = restart(&root);
|
||||
assert_eq!(copies_in(&left), 1, "the only copy on disk until this run writes one");
|
||||
r.write(&next, true);
|
||||
r.tick(&next, 100.0);
|
||||
assert!(!left.exists(), "covered by this run's copy, the old one goes");
|
||||
assert_eq!(copies_in(&root.join(std::process::id().to_string())), 1);
|
||||
r.finish();
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recovered_copy_stays_while_its_replacement_cant_be_written() {
|
||||
let root = root("blocked");
|
||||
let left = crashed(&edited(), &root);
|
||||
let (next, mut r) = restart(&root);
|
||||
// Something is in the way of this run's copy: writing it fails.
|
||||
let mine = root.join(std::process::id().to_string()).join(format!("{}.figcraft", next.docs[0].uid));
|
||||
std::fs::create_dir_all(&mine).unwrap();
|
||||
r.write(&next, true);
|
||||
r.tick(&next, 100.0);
|
||||
assert_eq!(copies_in(&left), 1, "kept: the work is nowhere else on disk");
|
||||
// Once it can be written, the old copy goes.
|
||||
std::fs::remove_dir(&mine).unwrap();
|
||||
let mut next = next;
|
||||
next.execute("shape.rectangle", &json!({"x": 1, "y": 1, "width": 2, "height": 2})).unwrap();
|
||||
r.write(&next, true);
|
||||
r.tick(&next, 200.0);
|
||||
assert!(!left.exists());
|
||||
r.finish();
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_recovery_of_its_own_the_copy_stays_for_the_next_start() {
|
||||
let root = root("nocopies");
|
||||
crashed(&edited(), &root);
|
||||
let mut next = Session::empty();
|
||||
let recovered = recover_all(&root, &mut next);
|
||||
assert_eq!(recovered.copies, 1);
|
||||
// This run couldn't take a folder of its own: it ends (or crashes) with nothing copied.
|
||||
drop(recovered);
|
||||
let mut after = Session::empty();
|
||||
assert_eq!(recover(&root, &mut after), 1, "the work comes back again");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recovered_copy_goes_once_its_document_is_saved_or_closed() {
|
||||
for close in [false, true] {
|
||||
let root = root(if close { "closed" } else { "saved" });
|
||||
let left = crashed(&edited(), &root);
|
||||
let (mut next, mut r) = restart(&root);
|
||||
if close {
|
||||
next.docs.clear();
|
||||
} else {
|
||||
next.ds_mut().unwrap().mark_saved();
|
||||
}
|
||||
r.tick(&next, 0.0);
|
||||
assert!(!left.exists(), "close {close}");
|
||||
r.finish();
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_normal_quit_removes_the_recovered_copies_it_took_over() {
|
||||
let root = root("quitafter");
|
||||
let left = crashed(&edited(), &root);
|
||||
let (_, r) = restart(&root);
|
||||
// Quitting asked to save or discard every document.
|
||||
r.finish();
|
||||
assert!(!left.exists());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_whose_folder_name_is_still_held_takes_another() {
|
||||
let root = root("pidreuse");
|
||||
// A crashed run with this process's id whose folder the start still holds (its copy waits).
|
||||
let held = root.join(std::process::id().to_string());
|
||||
std::fs::create_dir_all(&held).unwrap();
|
||||
let lock = std::fs::File::create(held.join("lock")).unwrap();
|
||||
lock.try_lock().unwrap();
|
||||
let mut r = Recovery::start(&root).expect("a folder of its own");
|
||||
let s = edited();
|
||||
r.write(&s, true);
|
||||
assert_eq!(copies_in(&held), 0);
|
||||
let dirs = std::fs::read_dir(&root).unwrap().flatten().filter(|e| e.path() != held).count();
|
||||
assert_eq!(dirs, 1);
|
||||
r.finish();
|
||||
drop(lock);
|
||||
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()
|
||||
}
|
||||
|
||||
/// The documents a crashed run's copies under `root` open as.
|
||||
fn crash_and_recover(r: Recovery, root: &std::path::Path) -> Session {
|
||||
drop(r);
|
||||
let mut next = Session::empty();
|
||||
assert_eq!(recover(root, &mut next), 1);
|
||||
next
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copies_keep_the_version_history_and_branches() {
|
||||
// #185: a copy has what a save would write, the latest edits on a branch tab included.
|
||||
let root = root("history");
|
||||
let mut s = edited();
|
||||
s.execute("file.saveVersion", &json!({"name": "v1"})).unwrap();
|
||||
s.execute("branch.create", &json!({"name": "Explore"})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
let mut r = Recovery::start(&root).unwrap();
|
||||
r.write(&s, true);
|
||||
let next = crash_and_recover(r, &root);
|
||||
let ds = next.docs.first().unwrap();
|
||||
assert_eq!(ds.versions.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(), ["v1"]);
|
||||
assert_eq!(ds.branches.iter().map(|b| (b.name.as_str(), rectangles(&b.head))).collect::<Vec<_>>(), [("Explore", 2)]);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// The one copy under `root` (the run's folder), read back.
|
||||
fn copy(root: &std::path::Path) -> figcraft_format::Loaded {
|
||||
let dir = std::fs::read_dir(root).unwrap().flatten().next().unwrap().path();
|
||||
let file = std::fs::read_dir(dir).unwrap().flatten().map(|f| f.path()).find(|f| f.extension().is_some_and(|e| e == "figcraft")).unwrap();
|
||||
figcraft_format::load_bytes_with_versions(&std::fs::read(file).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_change_only_on_a_branch_tab_or_to_the_history_is_copied() {
|
||||
let root = root("history-only");
|
||||
let mut s = edited();
|
||||
s.execute("file.saveVersion", &json!({"name": "v1"})).unwrap();
|
||||
s.execute("branch.create", &json!({"name": "Explore"})).unwrap();
|
||||
for d in &mut s.docs {
|
||||
d.mark_saved();
|
||||
}
|
||||
let mut r = Recovery::start(&root).unwrap();
|
||||
// An edit on the branch tab only: the main file has no changes of its own yet.
|
||||
s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
r.write(&s, true);
|
||||
assert_eq!(copy(&root).branches.iter().map(|b| rectangles(&b.head)).collect::<Vec<_>>(), [2]);
|
||||
s.execute("shape.rectangle", &json!({"x": 40, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
r.write(&s, true);
|
||||
assert_eq!(copy(&root).branches.iter().map(|b| rectangles(&b.head)).collect::<Vec<_>>(), [3]);
|
||||
// A version renamed on the main file leaves its document as it was.
|
||||
s.execute("doc.switch", &json!({"index": 0})).unwrap();
|
||||
r.write(&s, true);
|
||||
let id = s.docs[0].versions[0].id.clone();
|
||||
s.execute("version.rename", &json!({"id": id, "name": "v1 (renamed)"})).unwrap();
|
||||
r.write(&s, true);
|
||||
assert_eq!(copy(&root).versions.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(), ["v1 (renamed)"]);
|
||||
r.finish();
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
fn slow_write(path: std::path::PathBuf) -> std::thread::JoinHandle<bool> {
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(150));
|
||||
std::fs::write(path, b"stale").is_ok()
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_or_closing_waits_for_a_slow_write_so_the_copy_does_not_come_back() {
|
||||
let root = root("slow-cleanup");
|
||||
let mut s = edited();
|
||||
let mut r = Recovery::start(&root).unwrap();
|
||||
let dir = std::fs::read_dir(&root).unwrap().flatten().next().unwrap().path();
|
||||
let uid = s.docs[0].uid;
|
||||
let copy = dir.join(format!("{uid}.figcraft"));
|
||||
r.hold(uid, 1, slow_write(copy.clone()));
|
||||
s.ds_mut().unwrap().mark_saved();
|
||||
r.write(&s, false);
|
||||
std::thread::sleep(std::time::Duration::from_millis(400));
|
||||
assert!(!copy.exists(), "a saved document's copy stays removed");
|
||||
// Closed: the same.
|
||||
let s = edited();
|
||||
let uid = s.docs[0].uid;
|
||||
let copy = dir.join(format!("{uid}.figcraft"));
|
||||
r.hold(uid, 1, slow_write(copy.clone()));
|
||||
r.write(&Session::empty(), false);
|
||||
std::thread::sleep(std::time::Duration::from_millis(400));
|
||||
assert!(!copy.exists(), "a closed document's copy stays removed");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_emergency_save_waits_for_the_write_in_flight_and_copies_the_newest_revision() {
|
||||
let root = root("slow-emergency");
|
||||
let s = edited();
|
||||
let mut r = Recovery::start(&root).unwrap();
|
||||
let dir = std::fs::read_dir(&root).unwrap().flatten().next().unwrap().path();
|
||||
let uid = s.docs[0].uid;
|
||||
let copy = dir.join(format!("{uid}.figcraft"));
|
||||
r.hold(uid, 0, slow_write(copy.clone()));
|
||||
r.write(&s, true);
|
||||
assert!(std::fs::read(©).is_ok_and(|b| b != b"stale"), "the newest revision replaced the slow write");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// The files named `*.figcraft` in the run folder under `root`.
|
||||
fn copy_count(root: &std::path::Path) -> usize {
|
||||
let Some(dir) = std::fs::read_dir(root).unwrap().flatten().next().map(|e| e.path()) else { return 0 };
|
||||
std::fs::read_dir(dir).unwrap().flatten().filter(|f| f.path().extension().is_some_and(|e| e == "figcraft")).count()
|
||||
}
|
||||
|
||||
/// A folder at the path of `s`'s first document's copy in the run folder under `root`.
|
||||
fn blocked_copy(root: &std::path::Path, s: &Session) -> std::path::PathBuf {
|
||||
let dir = std::fs::read_dir(root).unwrap().flatten().next().unwrap().path();
|
||||
let blocker = dir.join(format!("{}.figcraft", s.docs[0].uid));
|
||||
std::fs::create_dir(&blocker).unwrap();
|
||||
blocker
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_that_failed_is_tried_again_without_another_edit() {
|
||||
let root = root("retry");
|
||||
let s = edited();
|
||||
let mut r = Recovery::start(&root).unwrap();
|
||||
// A folder where the copy goes makes its write fail on every platform (renaming the run folder
|
||||
// away fails on Windows, where the run's lock file keeps it open).
|
||||
let blocker = blocked_copy(&root, &s);
|
||||
r.write(&s, true);
|
||||
std::fs::remove_dir(&blocker).unwrap();
|
||||
// Nothing was edited since: the same revision still has to be copied.
|
||||
r.write(&s, true);
|
||||
assert_eq!(copy_count(&root), 1, "the failed revision was copied again");
|
||||
r.finish();
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_background_copy_that_failed_is_tried_again_without_another_edit() {
|
||||
let root = root("retry-bg");
|
||||
let s = edited();
|
||||
let mut r = Recovery::start(&root).unwrap();
|
||||
let blocker = blocked_copy(&root, &s);
|
||||
r.write(&s, false);
|
||||
// Let the failing write end before the way is clear again.
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
std::fs::remove_dir(&blocker).unwrap();
|
||||
for _ in 0..50 {
|
||||
r.write(&s, false);
|
||||
if copy_count(&root) == 1 {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
}
|
||||
assert_eq!(copy_count(&root), 1, "the failed revision was copied again");
|
||||
r.finish();
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
//! Desktop services: native file dialogs, clipboard, URLs, persisted preferences.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use figcraft_engine::{Prefs, Session};
|
||||
use figcraft_ui_egui::{FigApp, Services};
|
||||
use figcraft_ui_egui::{DialogFuture, FigApp, Services};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub struct Desktop {
|
||||
@@ -59,12 +60,16 @@ impl Desktop {
|
||||
|
||||
/// The system's preferred interface languages, most preferred first, comma-separated
|
||||
/// ("nl-NL,de-DE,en-US"): Windows' language list, macOS's AppleLanguages, `LANGUAGE` and the
|
||||
/// locale variables elsewhere. The UI shows the first one it has a catalog for.
|
||||
/// locale variables elsewhere. The UI shows the first language in the list that has a catalog.
|
||||
/// Windows keeps only its first preferred language (and English when it has no catalog): that
|
||||
/// list is also the input-language list, so a language added for a keyboard (Deutsch on a Polish
|
||||
/// system) must not decide the interface language.
|
||||
fn system_language() -> Option<String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
windows::System::UserProfile::GlobalizationPreferences::Languages()
|
||||
.map(|l| l.into_iter().map(|s| s.to_string()).collect::<Vec<_>>().join(","))
|
||||
.and_then(|l| l.GetAt(0))
|
||||
.map(|s| s.to_string())
|
||||
.inspect_err(|e| log::info!("language: can't read the system languages: {e}"))
|
||||
.ok()
|
||||
}
|
||||
@@ -131,6 +136,34 @@ fn command_says(program: &str, args: &[&str], want: &str) -> bool {
|
||||
.is_some_and(|o| String::from_utf8_lossy(&o.stdout).trim() == want)
|
||||
}
|
||||
|
||||
/// Show `path` in the file manager: selected in Finder and Explorer, its folder elsewhere.
|
||||
fn reveal(path: &Path) -> std::io::Result<()> {
|
||||
#[cfg(target_os = "macos")]
|
||||
let mut command = {
|
||||
let mut c = std::process::Command::new("open");
|
||||
c.arg("-R").arg(path);
|
||||
c
|
||||
};
|
||||
#[cfg(windows)]
|
||||
let mut command = {
|
||||
let mut arg = std::ffi::OsString::from("/select,");
|
||||
arg.push(path);
|
||||
let mut c = std::process::Command::new("explorer");
|
||||
c.arg(arg);
|
||||
c
|
||||
};
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
let mut command = {
|
||||
let mut c = std::process::Command::new("xdg-open");
|
||||
c.arg(path.parent().unwrap_or(path));
|
||||
c
|
||||
};
|
||||
// The file manager keeps running; reap it off this thread so it leaves no zombie.
|
||||
let mut child = command.spawn()?;
|
||||
let _ = std::thread::Builder::new().name("figcraft-reveal".into()).spawn(move || child.wait());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The preferences' file in the config folder.
|
||||
const STATE: &str = "state.json";
|
||||
|
||||
@@ -141,26 +174,64 @@ pub fn save_state(app: &FigApp) {
|
||||
}
|
||||
|
||||
/// Write `prefs` to `dir` atomically (a temporary file renamed over the old one: a crash leaves
|
||||
/// either version whole).
|
||||
fn write_prefs(dir: &Path, prefs: &Prefs) {
|
||||
/// either version whole). They hold online owner tokens, so on Unix only the owner can read them.
|
||||
pub(crate) fn write_prefs(dir: &Path, prefs: &Prefs) {
|
||||
let tmp = dir.join("state.json.tmp");
|
||||
let written = std::fs::create_dir_all(dir)
|
||||
.and_then(|()| std::fs::write(&tmp, json!({"prefs": prefs}).to_string()))
|
||||
.and_then(|()| write_private(&tmp, json!({"prefs": prefs}).to_string().as_bytes()))
|
||||
.and_then(|()| std::fs::rename(&tmp, dir.join(STATE)));
|
||||
if let Err(e) = written {
|
||||
log::warn!("preferences: couldn't save them in {}: {e}", dir.display());
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `bytes` to `path`, owner read/write only on Unix from before the first byte (a leftover
|
||||
/// file from a crash is narrowed too: the mode given at open only applies to new files).
|
||||
fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
let mut o = std::fs::OpenOptions::new();
|
||||
o.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::OpenOptionsExt::mode(&mut o, 0o600);
|
||||
let mut f = o.open(path)?;
|
||||
#[cfg(unix)]
|
||||
f.set_permissions(std::os::unix::fs::PermissionsExt::from_mode(0o600))?;
|
||||
f.write_all(bytes)
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// While the main window draws: a dialog parented to it, so a dialog opened from it stays in
|
||||
/// front of it (a sheet on macOS). eframe gives other windows no handle: theirs have no parent.
|
||||
static PARENT: RefCell<Option<rfd::AsyncFileDialog>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Run the main window's frame `f` with the dialogs it opens parented to `frame`'s window.
|
||||
pub fn parented<R>(frame: &eframe::Frame, f: impl FnOnce() -> R) -> R {
|
||||
PARENT.with(|p| p.replace(Some(rfd::AsyncFileDialog::new().set_parent(frame))));
|
||||
let r = f();
|
||||
PARENT.with(|p| p.replace(None));
|
||||
r
|
||||
}
|
||||
|
||||
/// A dialog with `title`, parented to the main window while it draws.
|
||||
fn dialog(title: &str) -> rfd::AsyncFileDialog {
|
||||
PARENT.with(|p| p.borrow().clone()).unwrap_or_default().set_title(title)
|
||||
}
|
||||
|
||||
/// The dialog opens now; its future gives the path picked.
|
||||
fn picked(pick: impl Future<Output = Option<rfd::FileHandle>> + 'static) -> DialogFuture {
|
||||
Box::pin(async move { pick.await.map(|f| f.path().to_path_buf()) })
|
||||
}
|
||||
|
||||
impl Services for Desktop {
|
||||
fn pick_open(&mut self, title: &str, exts: &[&str]) -> Option<PathBuf> {
|
||||
rfd::FileDialog::new().set_title(title).add_filter("Supported files", exts).pick_file()
|
||||
fn pick_open(&mut self, title: &str, exts: &[&str]) -> DialogFuture {
|
||||
picked(dialog(title).add_filter("Supported files", exts).pick_file())
|
||||
}
|
||||
fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> Option<PathBuf> {
|
||||
rfd::FileDialog::new().set_title(title).set_file_name(name).add_filter("File", exts).save_file()
|
||||
fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> DialogFuture {
|
||||
picked(dialog(title).set_file_name(name).add_filter("File", exts).save_file())
|
||||
}
|
||||
fn pick_folder(&mut self, title: &str) -> Option<PathBuf> {
|
||||
rfd::FileDialog::new().set_title(title).pick_folder()
|
||||
fn pick_folder(&mut self, title: &str) -> DialogFuture {
|
||||
picked(dialog(title).pick_folder())
|
||||
}
|
||||
fn clipboard_set_text(&mut self, s: &str) {
|
||||
if let Some(c) = self.clipboard.as_mut() {
|
||||
@@ -187,6 +258,11 @@ impl Services for Desktop {
|
||||
fn open_url(&mut self, url: &str) {
|
||||
let _ = webbrowser::open(url);
|
||||
}
|
||||
fn reveal_path(&mut self, path: &Path) {
|
||||
if let Err(e) = reveal(path) {
|
||||
log::warn!("show in folder: {}: {e}", path.display());
|
||||
}
|
||||
}
|
||||
fn save_prefs(&mut self, prefs: &Prefs) {
|
||||
if let Some(dir) = &self.dir {
|
||||
write_prefs(dir, prefs);
|
||||
@@ -195,6 +271,8 @@ impl Services for Desktop {
|
||||
fn emergency_save(&mut self, session: &Session) {
|
||||
if let Some(r) = crate::recovery::lock(&self.recovery).as_mut() {
|
||||
r.write(session, true);
|
||||
// Earlier runs' copies now covered go, so the next start doesn't open them twice.
|
||||
r.settle(session);
|
||||
}
|
||||
}
|
||||
fn reduce_motion(&self) -> bool {
|
||||
|
||||
@@ -66,3 +66,19 @@ fn plugins_are_kept_when_the_app_is_killed() {
|
||||
assert!(!dir.join("state.json.tmp").exists(), "no temporary file is left behind");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn preferences_are_readable_by_their_owner_only() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = root("private");
|
||||
let mode = |p: &std::path::Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
|
||||
// An older world-readable file and a leftover temporary one from a crash.
|
||||
for name in ["state.json", "state.json.tmp"] {
|
||||
std::fs::write(dir.join(name), "{}").unwrap();
|
||||
std::fs::set_permissions(dir.join(name), std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
}
|
||||
crate::services::write_prefs(&dir, &figcraft_engine::Prefs::default());
|
||||
assert_eq!(mode(&dir.join("state.json")), 0o600);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//! When the window can't open.
|
||||
//!
|
||||
//! Release builds on Windows have no console, so an error from `eframe::run_native` (no usable
|
||||
//! graphics adapter, a surface or device the driver refuses) went to a standard error nobody sees
|
||||
//! and the app quit without a word (#261). Now it is logged and shown in a message box.
|
||||
|
||||
/// Log the failure and, unless `quiet` (started for an agent with `--control`: a modal box would
|
||||
/// keep the caller waiting, and it reads standard error), show it in a message box.
|
||||
pub fn failed(error: &eframe::Error, quiet: bool) {
|
||||
// In the interface language: the text and its catalog entries are the UI crate's.
|
||||
let text = figcraft_ui_egui::dialogs::window_failed(&error.to_string(), std::env::var_os("WGPU_BACKEND").is_some());
|
||||
// Standard error even when RUST_LOG turned errors off.
|
||||
if log::log_enabled!(log::Level::Error) {
|
||||
log::error!("{text}");
|
||||
} else {
|
||||
eprintln!("figcraft: {text}");
|
||||
}
|
||||
if quiet {
|
||||
return;
|
||||
}
|
||||
let _ = rfd::MessageDialog::new()
|
||||
.set_level(rfd::MessageLevel::Error)
|
||||
.set_title("FigCraft")
|
||||
.set_description(text)
|
||||
.set_buttons(rfd::MessageButtons::Ok)
|
||||
.show();
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//! The message shown when the window can't open: it carries the error, a next step and where to
|
||||
//! report it, instead of the app quitting silently (#261).
|
||||
|
||||
use figcraft_ui_egui::dialogs::window_failed as message;
|
||||
|
||||
const ERROR: &str = "WGPU error: Failed to create surface for any enabled backend: {}";
|
||||
|
||||
#[test]
|
||||
fn the_message_carries_the_error_and_where_to_report_it() {
|
||||
let text = message(ERROR, false);
|
||||
assert!(text.contains(ERROR), "the error itself: {text}");
|
||||
assert!(text.contains("https://github.com/storytold/figcraft/issues"), "where to report it: {text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_wgpu_backend_it_suggests_the_driver_and_another_graphics_api() {
|
||||
let text = message(ERROR, false);
|
||||
assert!(text.contains("graphics driver"), "{text}");
|
||||
assert!(text.contains("WGPU_BACKEND"), "another graphics API to try: {text}");
|
||||
assert!(!text.contains("unsetting"), "{text}");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn on_windows_it_shows_how_to_start_with_opengl() {
|
||||
let text = message(ERROR, false);
|
||||
assert!(text.contains("set WGPU_BACKEND=gl"), "a release build has no console to look this up from: {text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_wgpu_backend_set_it_suggests_unsetting_it() {
|
||||
let text = message(ERROR, true);
|
||||
assert!(text.contains("unsetting"), "{text}");
|
||||
assert!(!text.contains("graphics driver"), "the chosen backend is the likelier cause: {text}");
|
||||
}
|
||||
@@ -16,3 +16,26 @@
|
||||
#
|
||||
# To remove your names, delete your entry (or set fields empty) in a PR. The next
|
||||
# contributors.py run, and the next build, drop them.
|
||||
|
||||
[people.VanTan666]
|
||||
display_name = "VanTan"
|
||||
|
||||
[people.tylerjenningsw]
|
||||
real_name = "Tyler Jennings"
|
||||
display_name = "tylerjenningsw"
|
||||
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
sync_github_name = false
|
||||
|
||||
[people.yyamandevrim]
|
||||
real_name = "Yaman Devrim"
|
||||
sync_github_name = true
|
||||
|
||||
[people.justinjohn0306]
|
||||
real_name = "Justin John"
|
||||
|
||||
[people.Losminek]
|
||||
|
||||
[people.RevDonz]
|
||||
sync_github_name = true
|
||||
@@ -0,0 +1,53 @@
|
||||
//! Addresses removed from a file are kept only while its session lasts, so a long-running server
|
||||
//! doesn't collect them for every file it ever had open.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
|
||||
use figcraft_collab::{Limits, Server};
|
||||
use figcraft_doc::Document;
|
||||
|
||||
use super::{Admission, Backend, Closing, Hub, InFlight, Room};
|
||||
|
||||
/// A backend that closes a file as soon as everyone left it.
|
||||
struct Closes;
|
||||
|
||||
impl Backend for Closes {
|
||||
fn admit(&mut self, _file: &str, _token: &str) -> Result<Admission, String> {
|
||||
Err("no".into())
|
||||
}
|
||||
|
||||
fn open(&mut self, _file: &str) -> Result<Server, String> {
|
||||
Err("no".into())
|
||||
}
|
||||
|
||||
fn idle(&mut self, _file: &str, _server: &mut Server) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn closing(&self) -> Closing {
|
||||
Closing::Restarting
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closed_file_forgets_whom_it_barred() {
|
||||
let mut doc = Document::new("File");
|
||||
doc.repair();
|
||||
let mut hub = Hub {
|
||||
in_flight: InFlight(Default::default()),
|
||||
backend: Closes,
|
||||
rooms: HashMap::from([("f".to_string(), Room::new(Server::new(doc, "", Limits::default())))]),
|
||||
conns: HashMap::new(),
|
||||
local: None,
|
||||
outbox: Vec::new(),
|
||||
barred: HashMap::new(),
|
||||
};
|
||||
let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 7));
|
||||
hub.barred.entry("f".into()).or_default().insert(ip);
|
||||
hub.barred.entry("other".into()).or_default().insert(ip);
|
||||
hub.close_if_empty("f");
|
||||
assert!(!hub.rooms.contains_key("f"), "everyone left: the file closed");
|
||||
assert!(!hub.barred.contains_key("f"), "and its barred addresses went with it");
|
||||
assert!(hub.barred.contains_key("other"), "another file's stay");
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use figcraft_collab::{Outgoing, Server, same_secret};
|
||||
use figcraft_doc::Document;
|
||||
use figcraft_engine::live::{Event, PEOPLE};
|
||||
|
||||
pub use crate::hub::REMOVED;
|
||||
use crate::hub::{Admission, Backend, Closing, Hosting, Local, clean_name, to_local};
|
||||
use crate::inbox::Inbox;
|
||||
pub use crate::join::ENDED;
|
||||
@@ -129,6 +130,12 @@ impl Host {
|
||||
self.hosting.send(msg);
|
||||
}
|
||||
|
||||
/// Remove client `id` from the session: they are disconnected and can't join again from
|
||||
/// their address while it runs. The host can't remove themselves.
|
||||
pub fn remove(&self, id: u32) {
|
||||
self.hosting.remove(id);
|
||||
}
|
||||
|
||||
/// Cut every connection as a network failure would (no close frame).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn cut_connections(&self) {
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
//! backend may refuse addresses or limit what they do. A plain `GET /healthz` on the same port is
|
||||
//! answered `200 ok` for uptime checks.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::io::{self, Read, Write};
|
||||
use std::net::{IpAddr, Ipv6Addr, SocketAddr, TcpListener, TcpStream};
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
@@ -413,6 +413,8 @@ enum HubIn {
|
||||
},
|
||||
Local(Presence),
|
||||
LocalMsg(ClientMsg),
|
||||
/// The person hosting removes client `id` from their file (app sessions).
|
||||
Remove(u32),
|
||||
/// Cut every connection without a word (tests: a network failure).
|
||||
#[cfg(test)]
|
||||
Cut,
|
||||
@@ -461,7 +463,15 @@ impl Hosting {
|
||||
pub(crate) fn start<B: Backend>(listener: TcpListener, backend: B, limits: NetLimits, local: Option<Local>) -> io::Result<Hosting> {
|
||||
let addr = listener.local_addr()?;
|
||||
let in_flight = InFlight(Arc::default());
|
||||
let mut hub = Hub { backend, rooms: HashMap::new(), conns: HashMap::new(), local: None, outbox: Vec::new(), in_flight: in_flight.clone() };
|
||||
let mut hub = Hub {
|
||||
backend,
|
||||
rooms: HashMap::new(),
|
||||
conns: HashMap::new(),
|
||||
local: None,
|
||||
outbox: Vec::new(),
|
||||
in_flight: in_flight.clone(),
|
||||
barred: HashMap::new(),
|
||||
};
|
||||
if let Some(l) = local {
|
||||
let mut room = Room::new(l.server);
|
||||
room.local = Some((l.id, l.inbox));
|
||||
@@ -551,6 +561,12 @@ impl Hosting {
|
||||
let _ = self.hub.send(HubIn::LocalMsg(msg));
|
||||
}
|
||||
|
||||
/// The hosting person removes client `id` from their file (app sessions; see
|
||||
/// [`HubIn::Remove`]).
|
||||
pub(crate) fn remove(&self, id: u32) {
|
||||
let _ = self.hub.send(HubIn::Remove(id));
|
||||
}
|
||||
|
||||
/// Cut every connection as a network failure would (no close frame).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn cut_connections(&self) {
|
||||
@@ -1108,6 +1124,16 @@ fn unsupported(protocol: u32) -> String {
|
||||
/// sharing): the session's end for them.
|
||||
pub const STOPPED: &str = "Sharing was stopped: the link you joined with no longer works. Your copy stays open";
|
||||
|
||||
/// What someone removed from a file is told: the session's end for them (they keep their copy;
|
||||
/// nothing retries).
|
||||
pub const REMOVED: &str = "You were removed from this session. Your copy stays open";
|
||||
|
||||
/// Why a removed person's address can't join the file again.
|
||||
const BARRED: &str = "you were removed from this session";
|
||||
|
||||
/// The most addresses one file keeps barred.
|
||||
const MAX_BARRED: usize = 256;
|
||||
|
||||
/// What everyone in a file is told when its changes couldn't be saved (they reconnect).
|
||||
const UNSAVED: &str = "The server couldn't save the latest changes; reconnecting";
|
||||
|
||||
@@ -1121,6 +1147,9 @@ struct Hub<B> {
|
||||
local: Option<String>,
|
||||
/// What the servers said, sent once the backend made what changed durable.
|
||||
outbox: Vec<(String, Outgoing)>,
|
||||
/// Addresses ([`ip_key`]) removed from each open file: they can't join it again until its
|
||||
/// session closes (the file is closed or removed; the entry goes with it).
|
||||
barred: HashMap<String, HashSet<IpAddr>>,
|
||||
}
|
||||
|
||||
impl<B: Backend> Hub<B> {
|
||||
@@ -1208,6 +1237,13 @@ impl<B: Backend> Hub<B> {
|
||||
self.client_message(&file, id, msg, None);
|
||||
}
|
||||
}
|
||||
HubIn::Remove(id) => {
|
||||
if let Some(file) = self.local.clone()
|
||||
&& let Err(why) = self.remove_peer(&file, id)
|
||||
{
|
||||
log::info!("live session: couldn't remove client {id}: {why}");
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
HubIn::Cut => {
|
||||
for c in self.conns.values() {
|
||||
@@ -1232,6 +1268,14 @@ impl<B: Backend> Hub<B> {
|
||||
self.client_message(&file, id, msg, Some(bytes));
|
||||
}
|
||||
Some(Kind::Hosting { owns }) => match msg {
|
||||
ClientMsg::Admin(Admin::RemovePeer { file, client }) => {
|
||||
let reply = if owns.as_deref() == Some(file.as_str()) {
|
||||
self.removed_reply(&file, client)
|
||||
} else {
|
||||
AdminReply::Failed { reason: "an owner token manages only its own file".into() }
|
||||
};
|
||||
self.answer(conn, &ServerMsg::Admin(Box::new(reply)));
|
||||
}
|
||||
ClientMsg::Admin(req) => {
|
||||
let owns = owns.clone();
|
||||
let rooms = &self.rooms;
|
||||
@@ -1243,6 +1287,10 @@ impl<B: Backend> Hub<B> {
|
||||
_ => self.kick(&[conn], "only publishing and managing your own files are spoken on this connection"),
|
||||
},
|
||||
Some(Kind::Managing) => match msg {
|
||||
ClientMsg::Admin(Admin::RemovePeer { file, client }) => {
|
||||
let reply = self.removed_reply(&file, client);
|
||||
self.answer(conn, &ServerMsg::Admin(Box::new(reply)));
|
||||
}
|
||||
ClientMsg::Admin(req) => self.admin(conn, req),
|
||||
ClientMsg::Ping => self.answer(conn, &ServerMsg::Pong),
|
||||
_ => self.kick(&[conn], "only requests to manage files are spoken on this connection"),
|
||||
@@ -1305,6 +1353,10 @@ impl<B: Backend> Hub<B> {
|
||||
Ok(a) => a,
|
||||
Err(why) => return refuse(&c.out, &why),
|
||||
};
|
||||
if self.barred.get(&hello.file).is_some_and(|b| b.contains(&c.ip)) {
|
||||
log::info!("event=refused ip={} reason=removed", c.ip);
|
||||
return refuse(&c.out, BARRED);
|
||||
}
|
||||
let hosting = reads_hosting(&hello.client_version);
|
||||
// The backend checked the secret: the sessions don't keep it.
|
||||
hello.token.clear();
|
||||
@@ -1362,6 +1414,34 @@ impl<B: Backend> Hub<B> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove client `id` from `file`: they are told the session is over for them and
|
||||
/// disconnected, and their address can't join the file again while its session lasts. The
|
||||
/// person hosting can't be removed.
|
||||
fn remove_peer(&mut self, file: &str, id: u32) -> Result<(), String> {
|
||||
let room = self.rooms.get(file).ok_or("nobody is in this file")?;
|
||||
if room.local.as_ref().is_some_and(|(local, _)| *local == id) {
|
||||
return Err("the person hosting can't be removed".into());
|
||||
}
|
||||
let conn = *room.ids.get(&id).ok_or_else(|| format!("nobody with client id {id} is in this file"))?;
|
||||
let c = self.conns.get(&conn).ok_or_else(|| format!("nobody with client id {id} is in this file"))?;
|
||||
let barred = self.barred.entry(file.to_string()).or_default();
|
||||
if barred.len() < MAX_BARRED {
|
||||
barred.insert(c.ip);
|
||||
}
|
||||
log::info!("event=removed file={file} client={id} ip={}", c.ip);
|
||||
let _ = c.out.tx.try_send(Out::Close(CloseCode::Away, REMOVED.into()));
|
||||
self.gone(conn);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// [`remove_peer`](Self::remove_peer) as a managing connection's answer.
|
||||
fn removed_reply(&mut self, file: &str, id: u32) -> AdminReply {
|
||||
match self.remove_peer(file, id) {
|
||||
Ok(()) => AdminReply::Done,
|
||||
Err(reason) => AdminReply::Failed { reason },
|
||||
}
|
||||
}
|
||||
|
||||
/// Take in connection `c` as `kind` and send it `first`.
|
||||
fn take_in(&mut self, conn: u64, c: Pending, kind: Kind, first: &ServerMsg) {
|
||||
self.conns.insert(conn, Conn { out: c.out, sock: c.sock, kind, ip: c.ip });
|
||||
@@ -1479,6 +1559,7 @@ impl<B: Backend> Hub<B> {
|
||||
.collect();
|
||||
// Closed without saving: the file is gone.
|
||||
self.rooms.remove(&file);
|
||||
self.barred.remove(&file);
|
||||
self.kick(&users, "this file was removed from the server");
|
||||
}
|
||||
if let Some(ip) = handled.blocked {
|
||||
@@ -1556,6 +1637,7 @@ impl<B: Backend> Hub<B> {
|
||||
let Some(room) = self.rooms.get_mut(file).filter(|r| r.empty()) else { return };
|
||||
if self.backend.idle(file, &mut room.server) {
|
||||
self.rooms.remove(file);
|
||||
self.barred.remove(file);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1661,3 +1743,7 @@ pub(crate) fn to_local(inbox: &Inbox, local: u32, msg: &ServerMsg) {
|
||||
#[cfg(test)]
|
||||
#[path = "queue_tests.rs"]
|
||||
mod queue_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "barred_tests.rs"]
|
||||
mod barred_tests;
|
||||
@@ -243,6 +243,18 @@ impl LiveLink for NetLink {
|
||||
}
|
||||
}
|
||||
|
||||
fn remove(&mut self, session: SessionId, client: u32) -> Result<(), String> {
|
||||
match self.sessions.get(session) {
|
||||
Some(Active::Host(h)) if h.me().client_id == client => Err("you can't remove yourself: stop the session instead".into()),
|
||||
Some(Active::Host(h)) => {
|
||||
h.remove(client);
|
||||
Ok(())
|
||||
}
|
||||
Some(Active::Guest(_)) => Err("only the person hosting can remove people from this session".into()),
|
||||
None => Err("this session is over".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn leave(&mut self, session: SessionId) {
|
||||
if let Some(active) = self.sessions.remove(session) {
|
||||
active.end();
|
||||
|
||||
@@ -15,7 +15,7 @@ use tungstenite::protocol::WebSocket;
|
||||
use tungstenite::{Bytes, Message};
|
||||
|
||||
use crate::guest::{Guest, GuestConfig};
|
||||
use crate::host::{ENDED, Host, HostConfig};
|
||||
use crate::host::{ENDED, Host, HostConfig, REMOVED};
|
||||
use crate::inbox::Inbox;
|
||||
use crate::invite::Invite;
|
||||
use crate::net::{Bucket, NetLimits};
|
||||
@@ -611,3 +611,34 @@ fn until_presence_or_pong(r: &mut Raw) -> Option<ServerMsg> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_host_removes_someone_and_they_cant_come_back() {
|
||||
let (mut host, mut hp, invite) = start();
|
||||
let (_a, mut ap) = join(&invite, "Ada");
|
||||
let (b, mut bp) = join(&invite, "Bo");
|
||||
ap.wait("welcome", welcomed);
|
||||
bp.wait("welcome", welcomed);
|
||||
let (ada, bo) = (ap.me(), bp.me());
|
||||
connections_settle(&host, 2);
|
||||
// The host can't remove themselves, nor someone who isn't there: nothing happens.
|
||||
host.remove(host.me().client_id);
|
||||
host.remove(9999);
|
||||
host.remove(ada);
|
||||
// Ada is told the session is over for her (no reconnecting); the others see her leave.
|
||||
ap.wait("Ada removed", |e| e.iter().any(|e| matches!(e, Event::Ended { reason } if reason == REMOVED)));
|
||||
hp.wait("the host sees Ada leave", |e| e.iter().any(|e| matches!(e, Event::PeerLeft(id) if *id == ada)));
|
||||
bp.wait("Bo sees Ada leave", |e| e.iter().any(|e| matches!(e, Event::PeerLeft(id) if *id == ada)));
|
||||
connections_settle(&host, 1);
|
||||
assert!(!ap.events.iter().any(|e| matches!(e, Event::Status(Status::Reconnecting { .. }))), "a removed guest doesn't reconnect");
|
||||
assert!(!hp.left().contains(&bo), "only Ada was removed");
|
||||
// Her address can't join again with the same invite (every guest here comes from 127.0.0.1).
|
||||
let (_again, mut againp) = join(&invite, "Ada");
|
||||
againp.wait("a refusal", |e| e.iter().any(|e| matches!(e, Event::Status(Status::Failed { reason }) if reason.contains("removed"))));
|
||||
assert!(!welcomed(&againp.events));
|
||||
// Bo, already in, stays and can still talk to the host.
|
||||
b.presence(presence(3.0, 4.0));
|
||||
hp.wait("Bo's cursor", |e| e.iter().any(|e| matches!(e, Event::Presence(id, _) if *id == bo)));
|
||||
host.stop();
|
||||
assert_eq!(host.connections(), 0);
|
||||
}
|
||||
@@ -307,7 +307,8 @@ impl Client {
|
||||
/// Queue `changes` as a new batch (already shown in the view).
|
||||
fn queue(&mut self, changes: Vec<Change>, kind: BatchKind, step: Option<u64>) {
|
||||
let batch_id = self.next_batch;
|
||||
self.next_batch += 1;
|
||||
// `next_batch` starts from the welcome's `last_batch`, the host's word: saturate.
|
||||
self.next_batch = self.next_batch.saturating_add(1);
|
||||
self.pending.push_back(Pending { batch_id, base_seq: self.seq, changes, kind, step, rebased: false, sent: false });
|
||||
}
|
||||
|
||||
@@ -484,7 +485,11 @@ impl Client {
|
||||
|
||||
/// A batch the server accepted (in order). Follow-ups it causes are queued.
|
||||
fn accepted(&mut self, acc: Accepted, recompute: &dyn Recompute) -> Result<(), Error> {
|
||||
if acc.seq != self.seq + 1 {
|
||||
// The host's sequence number is untrusted: at `u64::MAX` it can't step past it.
|
||||
let Some(next) = self.seq.checked_add(1) else {
|
||||
return Err(Error::Protocol(format!("sequence {} after {}", acc.seq, self.seq)));
|
||||
};
|
||||
if acc.seq != next {
|
||||
return Err(Error::Protocol(format!("sequence {} after {}", acc.seq, self.seq)));
|
||||
}
|
||||
if acc.author != self.id {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
//! A client's handling of the host's messages: sequence numbers and batch ids come from the host,
|
||||
//! so they are treated as hostile input (they must never overflow).
|
||||
|
||||
use figcraft_doc::NodeKind;
|
||||
|
||||
use crate::message::{Accepted, ClientMsg, Hello, ServerMsg};
|
||||
use crate::sim_tests::base;
|
||||
use crate::{Client, Limits, NoRecompute, Outgoing, Server};
|
||||
|
||||
fn hello() -> Hello {
|
||||
let ClientMsg::Hello(h) = Client::hello("f", "tok", "Ada", "#FF8800") else { panic!("not a hello") };
|
||||
h
|
||||
}
|
||||
|
||||
/// A welcome whose sequence numbers are what a lying host would send.
|
||||
fn hostile_welcome(seq: u64, last_batch: u64) -> ServerMsg {
|
||||
let mut s = Server::new(base(), "tok", Limits::default());
|
||||
let (_, out) = s.join(hello()).unwrap();
|
||||
let mut welcome = out
|
||||
.into_iter()
|
||||
.find_map(|o| match o {
|
||||
Outgoing::To(_, m) => Some(m),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
let ServerMsg::Welcome { seq: seq0, processed, .. } = &mut welcome else { panic!("no welcome") };
|
||||
*seq0 = seq;
|
||||
processed.last_batch = last_batch;
|
||||
welcome
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_sequence_numbers_do_not_overflow_the_client() {
|
||||
let mut c = Client::join(hostile_welcome(u64::MAX, u64::MAX)).unwrap();
|
||||
// This client's own first batch must not overflow its batch counter.
|
||||
let mut d = c.document().clone();
|
||||
let page = d.first_page().unwrap();
|
||||
d.create(page, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
assert!(c.edit(&d).unwrap().is_some(), "the edit is batched");
|
||||
// A batch the host claims follows `u64::MAX` can't exist: refuse it, don't overflow.
|
||||
let acc = Accepted { seq: 0, author: c.id().wrapping_add(1), batch_id: 0, changes: Vec::new() };
|
||||
assert!(c.receive(ServerMsg::Changes(acc), &NoRecompute).is_err());
|
||||
}
|
||||
@@ -75,6 +75,7 @@ fn samples() -> (Vec<ClientMsg>, Vec<ServerMsg>) {
|
||||
ClientMsg::Admin(Admin::Block { ip: "203.0.113.9".into() }),
|
||||
ClientMsg::Admin(Admin::Unblock { ip: "2001:db8::".into() }),
|
||||
ClientMsg::Admin(Admin::Blocked),
|
||||
ClientMsg::Admin(Admin::RemovePeer { file: "f".into(), client: 7 }),
|
||||
ClientMsg::Report { reason: "Spam ☕".into() },
|
||||
];
|
||||
let snapshot = Replica::new(Document::new("Snap")).snapshot().unwrap();
|
||||
|
||||
@@ -123,6 +123,8 @@ impl Recompute for NoRecompute {
|
||||
fn recompute(&self, _: &mut Document, _: &Document, _: &BTreeSet<NodeId>) {}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod client_tests;
|
||||
#[cfg(test)]
|
||||
mod codec_tests;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -274,6 +274,10 @@ pub enum Admin {
|
||||
/// these `(file id, owner token)` pairs own, at most [`MAX_OWNED`] of them:
|
||||
/// [`AdminReply::Owned`]. Servers older than this request can't read it.
|
||||
Owned { files: Vec<(String, String)> },
|
||||
/// Remove client `client` from `file`'s session: they are disconnected and their address
|
||||
/// can't join the file again while its session lasts. With the server key, or on an open
|
||||
/// hosting connection with `file`'s owner token. Servers older than this request can't read it.
|
||||
RemovePeer { file: String, client: u32 },
|
||||
}
|
||||
|
||||
/// The most files one [`Admin::Owned`] asks about.
|
||||
|
||||
+50
-18
@@ -21,6 +21,7 @@ pub mod styles;
|
||||
pub mod text;
|
||||
pub mod vector;
|
||||
pub mod vector_faces;
|
||||
pub mod vector_regions;
|
||||
|
||||
pub use colors::*;
|
||||
pub use comments::*;
|
||||
@@ -902,15 +903,22 @@ impl Document {
|
||||
/// A unique name like "Frame 3" for a new node of a kind.
|
||||
pub fn next_name(&self, base: &str) -> String {
|
||||
let mut max = 0u64;
|
||||
let mut taken = std::collections::BTreeSet::new();
|
||||
for n in self.nodes() {
|
||||
if let Some(rest) = n.name.strip_prefix(base)
|
||||
&& let Some(num) = rest.strip_prefix(' ')
|
||||
&& let Ok(k) = num.parse::<u64>()
|
||||
{
|
||||
max = max.max(k);
|
||||
taken.insert(k);
|
||||
}
|
||||
}
|
||||
format!("{base} {}", max + 1)
|
||||
let next = match max.checked_add(1) {
|
||||
Some(next) => next,
|
||||
// The counter is spent: the lowest free number instead of wrapping onto a name.
|
||||
None => (1u64..).find(|k| !taken.contains(k)).unwrap_or(1),
|
||||
};
|
||||
format!("{base} {next}")
|
||||
}
|
||||
|
||||
pub fn style(&self, id: &str) -> Option<&Style> {
|
||||
@@ -939,7 +947,11 @@ impl Document {
|
||||
if n.id != *id {
|
||||
issues.push(format!("node key {id} has id {}", n.id));
|
||||
}
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for c in &n.children {
|
||||
if !seen.insert(*c) {
|
||||
issues.push(format!("child {c} listed twice in {id}"));
|
||||
}
|
||||
match self.node(*c) {
|
||||
Some(cn) if cn.parent == Some(*id) => {}
|
||||
Some(_) => issues.push(format!("child {c} of {id} has another parent")),
|
||||
@@ -956,10 +968,32 @@ impl Document {
|
||||
if n.is_page() != listed {
|
||||
issues.push(format!("page list mismatch for {id}"));
|
||||
}
|
||||
if n.is_page() && n.parent.is_some() {
|
||||
issues.push(format!("page {id} has a parent"));
|
||||
}
|
||||
}
|
||||
let reachable = self.reachable();
|
||||
for id in self.nodes.keys().filter(|k| !reachable.contains(*k)) {
|
||||
issues.push(format!("{id} is not under a page (cycle or detached)"));
|
||||
}
|
||||
issues
|
||||
}
|
||||
|
||||
/// Every node reachable from a page through reciprocal parent/child links, each once (cycles
|
||||
/// and repeated children are walked once, so this is linear in the node count).
|
||||
fn reachable(&self) -> std::collections::BTreeSet<NodeId> {
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let mut stack: Vec<NodeId> = self.nodes.iter().filter(|(_, n)| n.is_page() && n.parent.is_none()).map(|(k, _)| *k).collect();
|
||||
while let Some(id) = stack.pop() {
|
||||
if !seen.insert(id) {
|
||||
continue;
|
||||
}
|
||||
let Some(n) = self.nodes.get(&id) else { continue };
|
||||
stack.extend(n.children.iter().filter(|c| !seen.contains(*c) && self.nodes.get(*c).is_some_and(|cn| cn.parent == Some(id))));
|
||||
}
|
||||
seen
|
||||
}
|
||||
|
||||
/// Repair what [`validate`](Self::validate) reports (lenient loading of untrusted files).
|
||||
pub fn repair(&mut self) {
|
||||
let ids: Vec<NodeId> = self.nodes.keys().copied().collect();
|
||||
@@ -967,34 +1001,30 @@ impl Document {
|
||||
if let Some(n) = self.nodes.get_mut(id) {
|
||||
let n = Arc::make_mut(n);
|
||||
n.id = *id;
|
||||
// Pages are roots.
|
||||
if n.is_page() {
|
||||
n.parent = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Drop dangling children and children claimed by another parent.
|
||||
// Drop dangling children, children claimed by another parent and repeats.
|
||||
let parents: BTreeMap<NodeId, Option<NodeId>> = self.nodes.iter().map(|(k, n)| (*k, n.parent)).collect();
|
||||
for id in &ids {
|
||||
let Some(n) = self.nodes.get(id) else { continue };
|
||||
let keep: Vec<NodeId> = n.children.iter().copied().filter(|c| parents.get(c).copied().flatten() == Some(*id)).collect();
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let keep: Vec<NodeId> =
|
||||
n.children.iter().copied().filter(|c| parents.get(c).copied().flatten() == Some(*id) && seen.insert(*c)).collect();
|
||||
if keep != n.children
|
||||
&& let Some(n) = self.nodes.get_mut(id)
|
||||
{
|
||||
Arc::make_mut(n).children = keep;
|
||||
}
|
||||
}
|
||||
// Orphans (parent missing or not listing them) are removed.
|
||||
loop {
|
||||
let orphan = self.nodes.iter().find_map(|(k, n)| {
|
||||
if n.is_page() {
|
||||
return None;
|
||||
}
|
||||
match n.parent {
|
||||
Some(p) if self.nodes.get(&p).is_some_and(|pn| pn.children.contains(k)) => None,
|
||||
_ => Some(*k),
|
||||
}
|
||||
});
|
||||
let Some(o) = orphan else { break };
|
||||
for d in self.subtree(o) {
|
||||
self.nodes.remove(&d);
|
||||
}
|
||||
// Nodes not under a page (orphans, detached subtrees, cycles) are removed.
|
||||
let reachable = self.reachable();
|
||||
let gone: Vec<NodeId> = self.nodes.keys().filter(|k| !reachable.contains(*k)).copied().collect();
|
||||
for k in gone {
|
||||
self.nodes.remove(&k);
|
||||
}
|
||||
// Snapshots of layers that are gone or no longer changed since marked ready for dev, or that
|
||||
// don't start with their layer, are dropped.
|
||||
@@ -1130,6 +1160,8 @@ mod proto_expr_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod vector_regions_tests;
|
||||
#[cfg(test)]
|
||||
mod width_sides_tests;
|
||||
#[cfg(test)]
|
||||
mod width_tests;
|
||||
@@ -281,6 +281,30 @@ fn serde_roundtrip_and_repair() {
|
||||
assert!(bad.validate().is_empty(), "{:?}", bad.validate());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_drops_cycles_and_repeated_children() {
|
||||
let mut d = Document::new("x");
|
||||
let p = page(&d);
|
||||
let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
let g = d.create(f, NodeKind::Frame, "G", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
let r = d.create(p, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
let mut v = serde_json::to_value(&d).unwrap();
|
||||
// F and G each other's parent and child (G listing F twice), and R listed twice by the page.
|
||||
v["nodes"][f.to_string()]["parent"] = serde_json::json!(g);
|
||||
v["nodes"][g.to_string()]["children"] = serde_json::json!([f, f]);
|
||||
v["nodes"][p.to_string()]["children"].as_array_mut().unwrap().push(serde_json::json!(r));
|
||||
let mut bad: Document = serde_json::from_value(v).unwrap();
|
||||
let issues = bad.validate();
|
||||
for want in ["listed twice", "not under a page"] {
|
||||
assert!(issues.iter().any(|i| i.contains(want)), "{want}: {issues:?}");
|
||||
}
|
||||
bad.repair();
|
||||
assert!(bad.validate().is_empty(), "{:?}", bad.validate());
|
||||
assert!(bad.node(f).is_none() && bad.node(g).is_none());
|
||||
assert_eq!(bad.node(p).unwrap().children, vec![r]);
|
||||
assert_eq!(bad.subtree(p), vec![p, r]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_layout_loads_files_without_grid_tracks() {
|
||||
// Saved before grid tracks, cells and spans existed.
|
||||
@@ -311,6 +335,16 @@ fn next_name_increments() {
|
||||
assert_eq!(d.next_name("Ellipse"), "Ellipse 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_name_never_overflows() {
|
||||
let mut d = Document::new("x");
|
||||
let p = page(&d);
|
||||
d.create(p, NodeKind::Frame, "Frame 18446744073709551615", 0.0, 0.0, 1.0, 1.0).unwrap();
|
||||
assert_eq!(d.next_name("Frame"), "Frame 1", "the counter can't step past u64::MAX");
|
||||
d.create(p, NodeKind::Frame, "Frame 1", 0.0, 0.0, 1.0, 1.0).unwrap();
|
||||
assert_eq!(d.next_name("Frame"), "Frame 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_easings() {
|
||||
use crate::proto::Easing;
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
//! A vector network's regions as areas: walking a region's loops into contours, whether a point is
|
||||
//! inside a region by that region's own fill rule, which region is under a point (the last one
|
||||
//! listed wins) and a region's points. Vector edit mode hovers, hatches and selects regions with
|
||||
//! these.
|
||||
//!
|
||||
//! A loop lists its segments in ring order, but each segment may be stored either way round: the
|
||||
//! walk follows the point reached so far and runs a segment backwards when it starts at its `b` end.
|
||||
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
use figcraft_geom::{BezPath, Point, Shape};
|
||||
|
||||
use crate::vector::{VectorNetwork, WindingRule};
|
||||
|
||||
impl VectorNetwork {
|
||||
/// Loop `lp` walked as a ring: each existing segment with whether it is walked backwards (from
|
||||
/// its `b` end to its `a` end). The first segment runs toward the point it shares with the
|
||||
/// second; a segment that doesn't touch the point reached so far (a malformed loop) is taken
|
||||
/// as stored. Missing segments are skipped.
|
||||
pub fn loop_walk(&self, lp: &[usize]) -> Vec<(usize, bool)> {
|
||||
let Some(first) = lp.first().and_then(|i| self.segments.get(*i)) else { return Vec::new() };
|
||||
let forward_first = match lp.get(1).and_then(|i| self.segments.get(*i)) {
|
||||
Some(second) => first.b == second.a || first.b == second.b,
|
||||
None => true,
|
||||
};
|
||||
let mut cur = if forward_first { first.a } else { first.b };
|
||||
let mut out = Vec::with_capacity(lp.len());
|
||||
for &i in lp {
|
||||
let Some(s) = self.segments.get(i) else { continue };
|
||||
if s.a == cur {
|
||||
out.push((i, false));
|
||||
cur = s.b;
|
||||
} else if s.b == cur {
|
||||
out.push((i, true));
|
||||
cur = s.a;
|
||||
} else {
|
||||
out.push((i, false));
|
||||
cur = s.b;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Region `r`'s outline: each loop one closed contour, curves kept as curves (segments or
|
||||
/// points that don't exist are left out). `None` when there is no such region or nothing of it
|
||||
/// remains.
|
||||
pub fn region_path(&self, r: usize) -> Option<BezPath> {
|
||||
let region = self.regions.get(r)?;
|
||||
let mut path = BezPath::new();
|
||||
for lp in ®ion.loops {
|
||||
let mut open = false;
|
||||
let mut at = Point::ZERO;
|
||||
for (i, back) in self.loop_walk(lp) {
|
||||
let Some(s) = self.segments.get(i) else { continue };
|
||||
let (Some(a), Some(b)) = (self.vertices.get(s.a), self.vertices.get(s.b)) else { continue };
|
||||
let (a, b) = (a.point(), b.point());
|
||||
let (c1, c2) = (Point::new(a.x + s.ta.0, a.y + s.ta.1), Point::new(b.x + s.tb.0, b.y + s.tb.1));
|
||||
let (from, c1, c2, to) = if back { (b, c2, c1, a) } else { (a, c1, c2, b) };
|
||||
if !open {
|
||||
path.move_to(from);
|
||||
open = true;
|
||||
} else if from != at {
|
||||
// A gap in a malformed loop: bridged with a straight line.
|
||||
path.line_to(from);
|
||||
}
|
||||
if s.is_straight() {
|
||||
path.line_to(to);
|
||||
} else {
|
||||
path.curve_to(c1, c2, to);
|
||||
}
|
||||
at = to;
|
||||
}
|
||||
if open {
|
||||
path.close_path();
|
||||
}
|
||||
}
|
||||
(!path.elements().is_empty()).then_some(path)
|
||||
}
|
||||
|
||||
/// The winding count of `p` about all of region `r`'s loops together (curves counted where
|
||||
/// they run). `None` when there is no such region.
|
||||
pub fn region_winding(&self, r: usize, p: Point) -> Option<i32> {
|
||||
self.regions.get(r)?;
|
||||
Some(self.region_path(r).map_or(0, |path| path.winding(p)))
|
||||
}
|
||||
|
||||
/// Whether `p` is inside region `r` by the region's own fill rule (non-zero: any winding;
|
||||
/// even-odd: an odd winding).
|
||||
pub fn region_contains(&self, r: usize, p: Point) -> bool {
|
||||
let (Some(region), Some(w)) = (self.regions.get(r), self.region_winding(r, p)) else { return false };
|
||||
match region.winding {
|
||||
WindingRule::NonZero => w != 0,
|
||||
WindingRule::EvenOdd => w % 2 != 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The region under `p`: the last one listed that contains it.
|
||||
pub fn region_at(&self, p: Point) -> Option<usize> {
|
||||
if !(p.x.is_finite() && p.y.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
(0..self.regions.len()).rev().find(|r| self.region_contains(*r, p))
|
||||
}
|
||||
|
||||
/// Every point on region `r`'s loops (holes' too), each once, sorted.
|
||||
pub fn region_points(&self, r: usize) -> Vec<usize> {
|
||||
let Some(region) = self.regions.get(r) else { return Vec::new() };
|
||||
let mut out: Vec<usize> = region.loops.iter().flatten().filter_map(|i| self.segments.get(*i)).flat_map(|s| [s.a, s.b]).collect();
|
||||
out.sort_unstable();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
/// A fingerprint of the network's topology: which points each segment joins and which
|
||||
/// segments each region's loops list. Moving points or handles keeps it; adding, removing or
|
||||
/// reordering segments, regions or loops changes it (region indexes are only good while it
|
||||
/// stays the same).
|
||||
pub fn topology_key(&self) -> u64 {
|
||||
let mut h = std::hash::DefaultHasher::new();
|
||||
self.segments.len().hash(&mut h);
|
||||
for s in &self.segments {
|
||||
(s.a, s.b).hash(&mut h);
|
||||
}
|
||||
self.regions.len().hash(&mut h);
|
||||
for r in &self.regions {
|
||||
r.loops.hash(&mut h);
|
||||
}
|
||||
h.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Regions as areas (`vector_regions.rs`): the inside test by each region's own fill rule, the last
|
||||
//! region winning where regions overlap, curved edges, loops with segments stored backwards, a
|
||||
//! region's points and empty or damaged networks.
|
||||
|
||||
use figcraft_geom::Point;
|
||||
|
||||
use crate::vector::{Region, Segment, VectorNetwork, Vertex, WindingRule};
|
||||
|
||||
/// Four points and four straight segments of a square at (`x0`, `y0`), clockwise on screen (y
|
||||
/// down), appended to `n`. Returns the segment indexes in ring order.
|
||||
fn square(n: &mut VectorNetwork, x0: f64, y0: f64, size: f64) -> Vec<usize> {
|
||||
let v0 = n.vertices.len();
|
||||
for (x, y) in [(x0, y0), (x0 + size, y0), (x0 + size, y0 + size), (x0, y0 + size)] {
|
||||
n.vertices.push(Vertex::new(x, y));
|
||||
}
|
||||
let s0 = n.segments.len();
|
||||
for k in 0..4 {
|
||||
n.segments.push(Segment::line(v0 + k, v0 + (k + 1) % 4));
|
||||
}
|
||||
(s0..s0 + 4).collect()
|
||||
}
|
||||
|
||||
/// The 7.1 network: a 200 square (points 0..3, segments 0..3) with a 80 square inside it (points
|
||||
/// 4..7, segments 4..7), both wound the same way, and no regions yet.
|
||||
fn nested() -> VectorNetwork {
|
||||
let mut n = VectorNetwork::default();
|
||||
square(&mut n, 0.0, 0.0, 200.0);
|
||||
square(&mut n, 60.0, 60.0, 80.0);
|
||||
n
|
||||
}
|
||||
|
||||
fn region(loops: Vec<Vec<usize>>, winding: WindingRule) -> Region {
|
||||
Region { loops, winding }
|
||||
}
|
||||
|
||||
fn p(x: f64, y: f64) -> Point {
|
||||
Point::new(x, y)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_inside_test_follows_each_regions_own_fill_rule() {
|
||||
let mut n = nested();
|
||||
n.regions = vec![region(vec![vec![0, 1, 2, 3], vec![4, 5, 6, 7]], WindingRule::NonZero)];
|
||||
// 1. Non-zero: the centre counts (both rings wound the same way).
|
||||
assert_eq!(n.region_at(p(100.0, 100.0)), Some(0));
|
||||
// 4. Outside.
|
||||
assert_eq!(n.region_at(p(300.0, 100.0)), None);
|
||||
n.regions[0].winding = WindingRule::EvenOdd;
|
||||
// 2. Even-odd: the centre is a hole; 3. the ring is inside.
|
||||
assert_eq!(n.region_at(p(100.0, 100.0)), None);
|
||||
assert_eq!(n.region_at(p(20.0, 100.0)), Some(0));
|
||||
assert_eq!(n.region_at(p(300.0, 100.0)), None);
|
||||
// 5. The inner ring wound the other way, non-zero: a hole.
|
||||
let mut r = nested();
|
||||
for s in &mut r.segments[4..8] {
|
||||
std::mem::swap(&mut s.a, &mut s.b);
|
||||
}
|
||||
r.regions = vec![region(vec![vec![0, 1, 2, 3], vec![7, 6, 5, 4]], WindingRule::NonZero)];
|
||||
assert_eq!(r.region_at(p(100.0, 100.0)), None);
|
||||
assert_eq!(r.region_at(p(20.0, 100.0)), Some(0));
|
||||
// Regions of one path may use different rules.
|
||||
let mut m = nested();
|
||||
m.regions = vec![
|
||||
region(vec![vec![0, 1, 2, 3], vec![4, 5, 6, 7]], WindingRule::NonZero),
|
||||
region(vec![vec![0, 1, 2, 3], vec![4, 5, 6, 7]], WindingRule::EvenOdd),
|
||||
];
|
||||
assert_eq!(m.region_at(p(100.0, 100.0)), Some(0), "the even-odd region has a hole there, the non-zero one doesn't");
|
||||
assert_eq!(m.region_at(p(20.0, 100.0)), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_last_overlapping_region_wins() {
|
||||
let mut n = nested();
|
||||
n.regions = vec![region(vec![vec![0, 1, 2, 3]], WindingRule::NonZero), region(vec![vec![4, 5, 6, 7]], WindingRule::NonZero)];
|
||||
// 6, 7.
|
||||
assert_eq!(n.region_at(p(100.0, 100.0)), Some(1));
|
||||
assert_eq!(n.region_at(p(20.0, 100.0)), Some(0));
|
||||
// 8. Swapped: the outer square (region 0 before, listed last now) wins in the middle.
|
||||
n.regions.swap(0, 1);
|
||||
assert_eq!(n.region_at(p(100.0, 100.0)), Some(1));
|
||||
assert_eq!(n.region_points(1), vec![0, 1, 2, 3], "index 1 is the outer square now");
|
||||
assert_eq!(n.region_at(p(20.0, 100.0)), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curved_edges_count_where_the_curve_runs() {
|
||||
let mut n = VectorNetwork {
|
||||
vertices: vec![Vertex::new(0.0, 0.0), Vertex::new(120.0, 0.0)],
|
||||
segments: vec![Segment { a: 0, b: 1, ta: (0.0, -80.0), tb: (0.0, -80.0) }, Segment::line(1, 0)],
|
||||
regions: vec![region(vec![vec![0, 1]], WindingRule::NonZero)],
|
||||
};
|
||||
// 9. Inside the bulge, above the chord; 10. below the chord; 11. above the curve's top (-60).
|
||||
assert_eq!(n.region_at(p(60.0, -40.0)), Some(0));
|
||||
assert_eq!(n.region_at(p(60.0, 20.0)), None);
|
||||
assert_eq!(n.region_at(p(60.0, -70.0)), None);
|
||||
assert_eq!(n.region_at(p(60.0, -58.0)), Some(0), "just under the top of the curve");
|
||||
// The lens walks toward the point it shares with the second segment, whichever way that is.
|
||||
n.segments.swap(0, 1);
|
||||
n.regions = vec![region(vec![vec![0, 1]], WindingRule::NonZero)];
|
||||
assert_eq!(n.region_at(p(60.0, -40.0)), Some(0));
|
||||
assert_eq!(n.region_at(p(60.0, -70.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_curved_segment_loop_encloses_its_area() {
|
||||
let n = VectorNetwork {
|
||||
vertices: vec![Vertex::new(0.0, 0.0)],
|
||||
segments: vec![Segment { a: 0, b: 0, ta: (100.0, -100.0), tb: (-100.0, -100.0) }],
|
||||
regions: vec![region(vec![vec![0]], WindingRule::NonZero)],
|
||||
};
|
||||
assert_eq!(n.region_at(p(0.0, -40.0)), Some(0));
|
||||
assert_eq!(n.region_at(p(0.0, 40.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segments_stored_backwards_are_walked_the_right_way() {
|
||||
let mut n = VectorNetwork::default();
|
||||
square(&mut n, 0.0, 0.0, 200.0);
|
||||
// Segment 1 stored as 2 → 1 and segment 3 as 0 → 3.
|
||||
n.segments[1] = Segment::line(2, 1);
|
||||
n.segments[3] = Segment::line(0, 3);
|
||||
n.regions = vec![region(vec![vec![0, 1, 2, 3]], WindingRule::NonZero)];
|
||||
// 12, 13.
|
||||
assert_eq!(n.region_at(p(100.0, 100.0)), Some(0));
|
||||
assert_eq!(n.region_at(p(300.0, 100.0)), None);
|
||||
let walked = n.loop_walk(&[0, 1, 2, 3]);
|
||||
assert_eq!(walked, vec![(0, false), (1, true), (2, false), (3, true)]);
|
||||
// 14. The first segment reversed too: unchanged.
|
||||
n.segments[0] = Segment::line(1, 0);
|
||||
assert_eq!(n.region_at(p(100.0, 100.0)), Some(0));
|
||||
assert_eq!(n.region_at(p(300.0, 100.0)), None);
|
||||
// The walk equals the same loop stored consistently: one closed contour of the same area.
|
||||
use figcraft_geom::Shape;
|
||||
let mut consistent = VectorNetwork::default();
|
||||
square(&mut consistent, 0.0, 0.0, 200.0);
|
||||
consistent.regions = n.regions.clone();
|
||||
let (a, b) = (n.region_path(0).unwrap().area().abs(), consistent.region_path(0).unwrap().area().abs());
|
||||
assert!((a - 40_000.0).abs() < 1e-6 && (b - 40_000.0).abs() < 1e-6, "{a} {b}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_loop_that_does_not_close_is_closed_implicitly() {
|
||||
let mut n = VectorNetwork::default();
|
||||
square(&mut n, 0.0, 0.0, 200.0);
|
||||
n.regions = vec![region(vec![vec![0, 1, 2]], WindingRule::NonZero)];
|
||||
// The walk ends at point 3 and closes straight back to point 0.
|
||||
assert_eq!(n.region_at(p(100.0, 100.0)), Some(0));
|
||||
assert_eq!(n.region_at(p(300.0, 100.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_regions_points_are_listed_once() {
|
||||
let mut n = nested();
|
||||
n.regions = vec![region(vec![vec![0, 1, 2, 3], vec![4, 5, 6, 7]], WindingRule::NonZero)];
|
||||
// 15.
|
||||
assert_eq!(n.region_points(0), vec![0, 1, 2, 3, 4, 5, 6, 7]);
|
||||
// 16.
|
||||
n.regions = vec![region(vec![vec![0, 1, 2, 3]], WindingRule::NonZero), region(vec![vec![4, 5, 6, 7]], WindingRule::NonZero)];
|
||||
assert_eq!(n.region_points(1), vec![4, 5, 6, 7]);
|
||||
assert!(n.region_points(9).is_empty());
|
||||
// 17. A missing segment is ignored.
|
||||
n.regions = vec![region(vec![vec![0, 1, 99, 2, 3]], WindingRule::NonZero)];
|
||||
assert_eq!(n.region_points(0), vec![0, 1, 2, 3]);
|
||||
assert_eq!(n.region_at(p(100.0, 100.0)), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_damaged_networks_have_no_regions_and_never_panic() {
|
||||
// 18.
|
||||
let mut n = nested();
|
||||
assert_eq!(n.region_at(p(100.0, 100.0)), None);
|
||||
let empty = VectorNetwork::default();
|
||||
assert_eq!(empty.region_at(p(0.0, 0.0)), None);
|
||||
assert!(empty.region_path(0).is_none());
|
||||
assert!(empty.region_points(0).is_empty());
|
||||
assert_eq!(empty.region_winding(0, p(0.0, 0.0)), None);
|
||||
// Loops naming missing segments, segments naming missing points, empty loops, junk points.
|
||||
n.regions = vec![region(vec![vec![], vec![42, 43]], WindingRule::EvenOdd), region(vec![vec![0, 1, 2, 3]], WindingRule::NonZero)];
|
||||
n.segments.push(Segment::line(1, 500));
|
||||
n.regions.push(region(vec![vec![8, 8, 8]], WindingRule::NonZero));
|
||||
assert_eq!(n.region_at(p(100.0, 100.0)), Some(1));
|
||||
assert!(n.region_path(0).is_none());
|
||||
assert!(n.region_path(2).is_none());
|
||||
for q in [p(f64::NAN, 0.0), p(f64::INFINITY, 1.0), p(1e300, -1e300)] {
|
||||
let _ = n.region_at(q);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_topology_key_ignores_moved_points_but_not_new_segments() {
|
||||
let mut n = nested();
|
||||
n.regions = vec![region(vec![vec![0, 1, 2, 3]], WindingRule::NonZero)];
|
||||
let k = n.topology_key();
|
||||
n.translate(15.0, 25.0);
|
||||
n.segments[0].ta = (5.0, 5.0);
|
||||
assert_eq!(n.topology_key(), k, "moving points and handles keeps it");
|
||||
n.regions.push(region(vec![vec![4, 5, 6, 7]], WindingRule::NonZero));
|
||||
assert_ne!(n.topology_key(), k);
|
||||
let k = n.topology_key();
|
||||
n.regions.swap(0, 1);
|
||||
assert_ne!(n.topology_key(), k, "reordered regions");
|
||||
let k = n.topology_key();
|
||||
n.segments.push(Segment::line(0, 2));
|
||||
assert_ne!(n.topology_key(), k);
|
||||
}
|
||||
@@ -73,3 +73,52 @@ fn autosaves_keep_their_flag_in_the_file() {
|
||||
assert!(!autosave(&mut t));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_that_save_themselves_still_get_checkpoints() {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-autosave-on-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("auto.figcraft").to_string_lossy().to_string();
|
||||
let mut s = Session::new();
|
||||
s.execute("pref.autoSave", &json!({"on": true})).unwrap();
|
||||
rect(&mut s, 0.0);
|
||||
s.execute("file.save", &json!({"path": path})).unwrap();
|
||||
assert!(autosave(&mut s), "saved, but changed since the last version");
|
||||
assert!(!autosave(&mut s), "no change since the checkpoint");
|
||||
// Just opened: nothing to checkpoint.
|
||||
let mut t = Session::empty();
|
||||
t.execute("pref.autoSave", &json!({"on": true})).unwrap();
|
||||
t.execute("file.open", &json!({"path": path})).unwrap();
|
||||
assert!(!autosave(&mut t));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_encoded_elsewhere_is_saved_as_of_its_snapshot() {
|
||||
use crate::cmd::file::FileSnapshot;
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-snapshot-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("snap.figcraft").to_string_lossy().to_string();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.save", &json!({"path": path})).unwrap();
|
||||
rect(&mut s, 0.0);
|
||||
let file = FileSnapshot::of(s.ds().unwrap());
|
||||
let bytes = file.encode().unwrap();
|
||||
rect(&mut s, 50.0);
|
||||
assert!(file.save(&mut s, &bytes).unwrap());
|
||||
assert!(s.ds().unwrap().is_dirty(), "the edit since the snapshot is still unsaved");
|
||||
let mut t = Session::empty();
|
||||
t.execute("file.open", &json!({"path": path})).unwrap();
|
||||
assert_eq!(t.ds().unwrap().doc.page_content_bounds(t.ds().unwrap().page).unwrap().x1, 10.0, "the file is the snapshot");
|
||||
// Saved meanwhile: the older bytes never overwrite the newer file.
|
||||
let file = FileSnapshot::of(s.ds().unwrap());
|
||||
let bytes = file.encode().unwrap();
|
||||
rect(&mut s, 100.0);
|
||||
s.execute("file.save", &json!({})).unwrap();
|
||||
assert!(!file.save(&mut s, &bytes).unwrap());
|
||||
assert!(!s.ds().unwrap().is_dirty());
|
||||
let mut t = Session::empty();
|
||||
t.execute("file.open", &json!({"path": path})).unwrap();
|
||||
assert_eq!(t.ds().unwrap().doc.page_content_bounds(t.ds().unwrap().page).unwrap().x1, 110.0);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
use serde_json::json;
|
||||
|
||||
use crate::plugins_tests::{OnRefusal, install, manifest, script};
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
fn id(value: &serde_json::Value) -> crate::NodeId {
|
||||
crate::params::id_of(&value["id"]).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_group_closes_documents_opened_inside_it() {
|
||||
let mut s = Session::new();
|
||||
let original = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
|
||||
let before_journal = s.journal.clone();
|
||||
let before_history = s.ds().unwrap().history.undo.len();
|
||||
|
||||
let result = s.execute(
|
||||
"command.batch",
|
||||
&json!({
|
||||
"label": "Open then fail",
|
||||
"commands": [
|
||||
{"command": "file.new", "params": {"name": "Temporary"}},
|
||||
{"command": "shape.rectangle", "params": {"x": 20, "y": 20, "width": 10, "height": 10}},
|
||||
{"command": "node.set", "params": {"id": 999999, "props": {"name": "must fail"}}}
|
||||
]
|
||||
}),
|
||||
);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(s.docs.len(), 1);
|
||||
assert_eq!(s.active, 0);
|
||||
assert!(s.doc().unwrap().contains(original));
|
||||
assert_eq!(s.ds().unwrap().history.undo.len(), before_history);
|
||||
assert_eq!(s.journal, before_journal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_plugin_run_restores_each_document_without_restoring_unrelated_state() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"name": "Second"})).unwrap();
|
||||
let before_history: Vec<_> = s.docs.iter().map(|ds| ds.history.undo.len()).collect();
|
||||
let calls = [
|
||||
("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})),
|
||||
("doc.nextTab", json!({})),
|
||||
("shape.rectangle", json!({"x": 20, "y": 0, "width": 10, "height": 10})),
|
||||
("node.set", json!({"id": 999999, "props": {"name": "must fail"}})),
|
||||
];
|
||||
install(&mut s, manifest("org.test.rollback", "write", &[]), &script(&calls, OnRefusal::Fail, ""));
|
||||
s.message = Some("keep this".into());
|
||||
let before_journal = s.journal.clone();
|
||||
let before_message = s.message.clone();
|
||||
// The new file is the one in front when the plugin starts.
|
||||
let before_active = s.active;
|
||||
|
||||
let result = s.execute("plugin.run", &json!({"id": "org.test.rollback"}));
|
||||
|
||||
assert!(matches!(result, Err(EngineError::Other(_))));
|
||||
assert_eq!(s.docs.iter().map(|ds| ds.doc.node(ds.page).map_or(0, |page| page.children.len())).collect::<Vec<_>>(), vec![0, 0]);
|
||||
assert_eq!(s.active, before_active, "the tab in front is put back");
|
||||
assert_eq!(s.docs.iter().map(|ds| ds.history.undo.len()).collect::<Vec<_>>(), before_history);
|
||||
assert_eq!(s.journal, before_journal);
|
||||
assert_eq!(s.message, before_message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_group_keeps_the_redo_steps() {
|
||||
let mut s = Session::new();
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
assert!(s.undo().unwrap());
|
||||
assert_eq!(s.undo_depth().1, 1);
|
||||
|
||||
let result = s.execute(
|
||||
"command.batch",
|
||||
&json!({
|
||||
"commands": [
|
||||
{"command": "shape.rectangle", "params": {"x": 20, "y": 20, "width": 10, "height": 10}},
|
||||
{"command": "node.set", "params": {"id": 999999, "props": {"name": "must fail"}}}
|
||||
]
|
||||
}),
|
||||
);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(s.undo_depth().1, 1, "the redo step survives the failed batch");
|
||||
assert!(s.redo().unwrap());
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
fn id(value: &serde_json::Value) -> crate::NodeId {
|
||||
crate::params::id_of(&value["id"]).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn later_steps_target_layers_created_by_earlier_steps() {
|
||||
let mut s = Session::new();
|
||||
let results = s
|
||||
.execute(
|
||||
"command.batch",
|
||||
&json!({
|
||||
"label": "Card",
|
||||
"commands": [
|
||||
{"command": "node.create", "params": {"type": "frame", "x": 0, "y": 0, "width": 200, "height": 100}},
|
||||
{"command": "node.create", "params": {"type": "rectangle", "parent": {"$step": 0}, "x": 10, "y": 10, "width": 20, "height": 20}},
|
||||
{"command": "node.set", "params": {"ids": [{"$step": 1}], "props": {"name": "Swatch"}}}
|
||||
]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let frame = id(&results[0]);
|
||||
let rect = id(&results[1]);
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(doc.node(rect).unwrap().parent, Some(frame));
|
||||
assert_eq!(doc.node(rect).unwrap().name, "Swatch");
|
||||
assert_eq!(s.ds().unwrap().history.undo.len(), 1, "still one undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_picks_another_part_of_the_result() {
|
||||
let mut s = Session::new();
|
||||
let results = s
|
||||
.execute(
|
||||
"command.batch",
|
||||
&json!({"commands": [
|
||||
{"command": "node.create", "params": {"type": "frame", "width": 50, "height": 50}},
|
||||
{"command": "node.create", "params": {"type": "text", "text": "hi", "parent": {"$step": 0, "field": "id"}}}
|
||||
]}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.doc().unwrap().node(id(&results[1])).unwrap().parent, Some(id(&results[0])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_references_fail_and_roll_back() {
|
||||
let mut s = Session::new();
|
||||
let before = s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len();
|
||||
let frame = json!({"command": "node.create", "params": {"type": "frame", "width": 10, "height": 10}});
|
||||
for (bad, needle) in [
|
||||
(json!({"$step": 1}), "earlier step"),
|
||||
(json!({"$step": 5}), "earlier step"),
|
||||
(json!({"$step": -1}), "step index"),
|
||||
(json!({"$step": "0"}), "step index"),
|
||||
(json!({"$step": 0, "field": "nope"}), "no `nope`"),
|
||||
(json!({"$step": 0, "field": 3}), "`field` must be a string"),
|
||||
(json!({"$step": 0, "extra": true}), "not `extra`"),
|
||||
] {
|
||||
let r = s.execute("command.batch", &json!({"commands": [frame, {"command": "node.set", "params": {"ids": [bad], "props": {"name": "x"}}}]}));
|
||||
match r {
|
||||
Err(EngineError::BadParams { msg, .. }) => assert!(msg.contains(needle), "{msg} should mention {needle}"),
|
||||
other => panic!("expected BadParams for {bad}, got {other:?}"),
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len(), before, "rolled back");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_params_are_refused_not_overflowed() {
|
||||
let mut s = Session::new();
|
||||
let mut deep = json!(1);
|
||||
for _ in 0..200 {
|
||||
deep = json!([deep]);
|
||||
}
|
||||
let r = s.execute("command.batch", &json!({"commands": [{"command": "select.none", "params": {"x": deep}}]}));
|
||||
assert!(matches!(r, Err(EngineError::BadParams { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_that_looks_like_a_reference_is_left_alone() {
|
||||
let mut s = Session::new();
|
||||
let results = s
|
||||
.execute("command.batch", &json!({"commands": [{"command": "node.create", "params": {"type": "text", "text": "$step 0 {\"$step\": 0}"}}]}))
|
||||
.unwrap();
|
||||
let n = s.doc().unwrap().node(id(&results[0])).unwrap().clone();
|
||||
assert!(matches!(n.kind, figcraft_doc::NodeKind::Text(t) if t.characters == "$step 0 {\"$step\": 0}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nested_batch_refers_to_its_own_steps() {
|
||||
let mut s = Session::new();
|
||||
let frame = |name: &str| json!({"command": "node.create", "params": {"type": "frame", "name": name, "width": 100, "height": 100}});
|
||||
let results = s
|
||||
.execute(
|
||||
"command.batch",
|
||||
&json!({"commands": [
|
||||
frame("Outer"),
|
||||
{"command": "command.batch", "params": {"commands": [
|
||||
frame("Inner"),
|
||||
{"command": "node.create", "params": {"type": "rectangle", "parent": {"$step": 0}, "width": 10, "height": 10}}
|
||||
]}}
|
||||
]}),
|
||||
)
|
||||
.unwrap();
|
||||
let inner = &results[1];
|
||||
let rect = s.doc().unwrap().node(id(&inner[1])).unwrap().clone();
|
||||
assert_eq!(rect.parent, Some(id(&inner[0])), "in the nested batch's own first frame, not the outer one");
|
||||
}
|
||||
@@ -156,6 +156,22 @@ pub(crate) fn sync(s: &mut Session) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The branches of the file in tab `uid` as saving it now would write them: [`sync`] only puts a
|
||||
/// branch tab's latest edits in before the next command.
|
||||
pub fn current(s: &Session, uid: u64) -> Vec<Branch> {
|
||||
let Some(ds) = s.docs.iter().find(|d| d.uid == uid) else { return Vec::new() };
|
||||
let mut branches = ds.branches.clone();
|
||||
for d in &s.docs {
|
||||
let Some((_, id)) = d.branch_of.as_ref().filter(|(main, _)| *main == uid) else { continue };
|
||||
let Some(b) = branches.iter_mut().find(|b| b.id == *id) else { continue };
|
||||
b.head = d.document_itself().clone();
|
||||
if d.versions_changed {
|
||||
b.versions = d.versions.clone();
|
||||
}
|
||||
}
|
||||
branches
|
||||
}
|
||||
|
||||
/// The tab a save writes: the main file's for a branch tab, else the active tab.
|
||||
pub fn file_tab(s: &Session) -> usize {
|
||||
s.ds().ok().and_then(|d| d.branch_of.as_ref()).and_then(|(m, _)| s.docs.iter().position(|d| d.uid == *m)).unwrap_or(s.active)
|
||||
|
||||
@@ -104,6 +104,8 @@ pub static MENU: &[MenuItem] = &[
|
||||
m!("View", "Soft-proof sRGB", "view.proofSrgb"),
|
||||
m!("View", SEP, ""),
|
||||
m!("View/Panels", "Show/Hide UI", "view.toggleUi"),
|
||||
// FigCraft's own: the window fills the screen (F11), for setups that hide the taskbar then.
|
||||
m!("View", "Full screen", "view.fullscreen"),
|
||||
m!("View/Panels", "Reset panel widths", "view.panelWidths"),
|
||||
m!("View/Panels", "Layers", "view.layers"),
|
||||
m!("View/Panels", "Assets", "view.assets"),
|
||||
|
||||
@@ -145,21 +145,28 @@ pub fn rotate_world(doc: &mut Document, id: NodeId, pivot: Point, deg: f64) -> R
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether aligning `ids` would move anything: a layer alone on the page has nothing to align to.
|
||||
pub fn can_align(doc: &Document, ids: &[NodeId]) -> bool {
|
||||
align_target(doc, &roots(doc, ids)).is_some()
|
||||
}
|
||||
|
||||
/// What `roots` align to: one layer to its parent frame, several to their combined bounds.
|
||||
fn align_target(doc: &Document, roots: &[NodeId]) -> Option<Rect> {
|
||||
match roots {
|
||||
[] => None,
|
||||
[id] => {
|
||||
let parent = doc.node(*id)?.parent.filter(|pp| !doc.node(*pp).is_some_and(Node::is_page))?;
|
||||
Some(doc.world_bounds(parent).unwrap_or_default())
|
||||
}
|
||||
_ => Some(doc.bounds_of(roots).unwrap_or_default()),
|
||||
}
|
||||
}
|
||||
|
||||
fn align(s: &mut Session, p: &Value, a: Al) -> Result<Value> {
|
||||
let ids = req_targets("align", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let ids = roots(doc, &ids);
|
||||
// One layer aligns within its parent frame; several align to their combined bounds.
|
||||
let target: Rect = if ids.len() == 1 {
|
||||
let Some(id) = ids.first() else { return ok() };
|
||||
let parent = doc.get(*id)?.parent;
|
||||
match parent.filter(|pp| !doc.node(*pp).is_some_and(Node::is_page)) {
|
||||
Some(pp) => doc.world_bounds(pp).unwrap_or_default(),
|
||||
None => return ok(),
|
||||
}
|
||||
} else {
|
||||
doc.bounds_of(&ids).unwrap_or_default()
|
||||
};
|
||||
let Some(target) = align_target(doc, &ids) else { return ok() };
|
||||
for i in ids {
|
||||
let Some(b) = doc.world_bounds(i) else { continue };
|
||||
let (dx, dy) = match a {
|
||||
@@ -325,8 +332,12 @@ fn zorder(s: &mut Session, p: &Value, z: Z) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
if matches!(z, Z::Back) {
|
||||
for (k, i) in ids.iter().enumerate() {
|
||||
doc.reorder(*i, k)?;
|
||||
let mut next: std::collections::HashMap<NodeId, usize> = std::collections::HashMap::new();
|
||||
for i in &ids {
|
||||
let Some(pp) = doc.get(*i)?.parent else { continue };
|
||||
let k = next.entry(pp).or_insert(0);
|
||||
doc.reorder(*i, *k)?;
|
||||
*k += 1;
|
||||
}
|
||||
}
|
||||
ok()
|
||||
|
||||
@@ -242,7 +242,7 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let doc = ds.doc_mut();
|
||||
let nid = doc.alloc_id();
|
||||
let mut node = if base == "Section" { Node::new_section(nid, name, w, h) } else { Node::new(nid, kind, name, w, h) };
|
||||
if app_text && let Some(fills) = crate::tools::readable_text_fills(doc, doc.page_of(parent).unwrap_or(page)) {
|
||||
if app_text && let Some(fills) = crate::tools::readable_text_fills(doc, parent) {
|
||||
node.fills = fills;
|
||||
}
|
||||
node.transform = space.inverse() * figcraft_geom::Affine::translate((x + offset.0, y + offset.1));
|
||||
|
||||
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"edit.duplicate",
|
||||
"Duplicate",
|
||||
"{ids?, dx?, dy? (offset; default 0 for frames-in-place... Figma offsets top-level frames to the right)}",
|
||||
"{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)}",
|
||||
true,
|
||||
duplicate
|
||||
),
|
||||
@@ -253,7 +253,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
ok()
|
||||
}),
|
||||
spec!("effect.set", "Set effect", "{ids?, index, effect {..}}", true, |s, p| {
|
||||
let i = req_num("effect.set", p, "index")? as usize;
|
||||
let i = req_index("effect.set", p, "index")?;
|
||||
let e: Effect = parse("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)?;
|
||||
@@ -302,7 +302,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
true,
|
||||
|s, p| {
|
||||
let cmd = "export.update";
|
||||
let i = req_num(cmd, p, "index")? as usize;
|
||||
let i = req_index(cmd, p, "index")?;
|
||||
super::file::export_setting(cmd, ExportSetting::default(), p)?;
|
||||
for id in req_targets(cmd, s, p)? {
|
||||
let n = s.doc_mut()?.get_mut(id)?;
|
||||
@@ -314,7 +314,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
),
|
||||
spec!("export.remove", "Remove export setting", "{ids?, index}", true, |s, p| {
|
||||
let i = req_num("export.remove", p, "index")? as usize;
|
||||
let i = req_index("export.remove", p, "index")?;
|
||||
for id in req_targets("export.remove", s, p)? {
|
||||
let n = s.doc_mut()?.get_mut(id)?;
|
||||
if i < n.exports.len() {
|
||||
@@ -340,7 +340,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(Value::Array(s.batch(&label, &cmds)?))
|
||||
}),
|
||||
spec!("edit.repeat", "Repeat last command", "{}", true, |s, _| {
|
||||
let Some((id, p)) = s.last_command.clone() else { return Err(EngineError::Disabled("edit.repeat".into(), "nothing to repeat".into())) };
|
||||
let Some((id, p)) = s.last_command.clone().filter(|(id, _)| id != "edit.repeat") else {
|
||||
return Err(EngineError::Disabled("edit.repeat".into(), "nothing to repeat".into()));
|
||||
};
|
||||
let spec = crate::find_command(&id).ok_or_else(|| EngineError::UnknownCommand(id.clone()))?;
|
||||
(spec.run)(s, &p)
|
||||
}),
|
||||
@@ -410,13 +412,48 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"deleted": ids}))
|
||||
}
|
||||
|
||||
/// Layers duplicated last (Duplicate or Alt-drag) and their copies, in the same order. While the
|
||||
/// copies are the selection, Duplicate places the next copies as far from them as they are from
|
||||
/// their sources, measured then: so moving the copies first sets the step.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Duplicated {
|
||||
pub sources: Vec<NodeId>,
|
||||
pub copies: Vec<NodeId>,
|
||||
}
|
||||
|
||||
impl Duplicated {
|
||||
pub fn is_selection(&self, sel: &[NodeId]) -> bool {
|
||||
let set = |v: &[NodeId]| v.iter().copied().collect::<BTreeSet<_>>();
|
||||
!self.copies.is_empty() && set(&self.copies) == set(sel)
|
||||
}
|
||||
|
||||
/// The page offset from the sources to the copies, if both are still there and the copies move
|
||||
/// freely (an auto layout places its own).
|
||||
fn offset(&self, doc: &Document) -> Option<(f64, f64)> {
|
||||
let free = self
|
||||
.copies
|
||||
.iter()
|
||||
.all(|c| doc.node(*c).is_some_and(|n| n.child_layout.absolute || n.parent.and_then(|p| doc.node(p)).is_none_or(|p| p.layout.is_none())));
|
||||
if !free || self.sources.iter().chain(&self.copies).any(|i| !doc.contains(*i)) {
|
||||
return None;
|
||||
}
|
||||
let (a, b) = (doc.bounds_of(&self.sources)?, doc.bounds_of(&self.copies)?);
|
||||
let d = (b.x0 - a.x0, b.y0 - a.y0);
|
||||
(d.0.is_finite() && d.1.is_finite() && (d.0 != 0.0 || d.1 != 0.0)).then_some(d)
|
||||
}
|
||||
}
|
||||
|
||||
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("edit.duplicate", s, p)?;
|
||||
let rename = s.prefs.rename_duplicates;
|
||||
let explicit = num(p, "dx").is_some() || num(p, "dy").is_some();
|
||||
let ds = s.ds()?;
|
||||
let roots_now = z_sorted(&ds.doc, &roots(&ds.doc, &ids));
|
||||
let repeat = ds.last_duplicate.as_ref().filter(|d| !explicit && d.is_selection(&roots_now)).and_then(|d| d.offset(&ds.doc));
|
||||
let doc = s.doc_mut()?;
|
||||
let ids = z_sorted(doc, &roots(doc, &ids));
|
||||
let ids = roots_now;
|
||||
let mut out = Vec::new();
|
||||
for i in ids {
|
||||
for i in ids.iter().copied() {
|
||||
let n = doc.get(i)?;
|
||||
let parent = n.parent.ok_or_else(|| bad("edit.duplicate", "pages are duplicated with page.duplicate"))?;
|
||||
let top_frame = doc.node(parent).is_some_and(Node::is_page) && n.kind.is_frame_like();
|
||||
@@ -430,22 +467,30 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ if top_frame => (w + 100.0, 0.0),
|
||||
_ => (0.0, 0.0),
|
||||
};
|
||||
let nn = doc.get_mut(nid)?;
|
||||
nn.transform = Affine::translate((dx, dy)) * nn.transform;
|
||||
match repeat {
|
||||
Some((rx, ry)) => super::arrange::move_world(doc, nid, rx, ry)?,
|
||||
None => {
|
||||
let nn = doc.get_mut(nid)?;
|
||||
nn.transform = Affine::translate((dx, dy)) * nn.transform;
|
||||
}
|
||||
}
|
||||
if rename {
|
||||
let nn = doc.get_mut(nid)?;
|
||||
nn.name = bump_name(&nn.name);
|
||||
}
|
||||
out.push(nid);
|
||||
}
|
||||
s.set_selection(out.clone());
|
||||
s.ds_mut()?.last_duplicate = Some(Duplicated { sources: ids, copies: out.clone() });
|
||||
Ok(json!({"ids": out}))
|
||||
}
|
||||
|
||||
fn bump_name(n: &str) -> String {
|
||||
if let Some((base, num)) = n.rsplit_once(' ')
|
||||
&& let Ok(k) = num.parse::<u64>()
|
||||
&& let Some(next) = k.checked_add(1)
|
||||
{
|
||||
return format!("{base} {}", k + 1);
|
||||
return format!("{base} {next}");
|
||||
}
|
||||
format!("{n} 2")
|
||||
}
|
||||
@@ -801,7 +846,8 @@ pub fn opacity_from_digits(digits: &str) -> Option<f32> {
|
||||
let mut n = 0u32;
|
||||
let mut count = 0;
|
||||
for c in digits.chars() {
|
||||
n = n * 10 + c.to_digit(10)?;
|
||||
// Input-derived: a number that outgrows `u32` stops the scan (None); it never wraps.
|
||||
n = n.checked_mul(10)?.checked_add(c.to_digit(10)?)?;
|
||||
count += 1;
|
||||
}
|
||||
match count {
|
||||
@@ -942,6 +988,16 @@ fn batch_rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"names": names.iter().map(|(i, n)| json!({"id": i, "name": n})).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// Char index of the first case-insensitive occurrence of `needle` in `hay` at or after `from`.
|
||||
/// Works on char indices (what `replace_range` takes), so lowercasing never shifts offsets.
|
||||
fn find_ci(hay: &[char], needle: &[char], from: usize) -> Option<usize> {
|
||||
if needle.is_empty() {
|
||||
return None;
|
||||
}
|
||||
(from..hay.len())
|
||||
.find(|&i| hay.get(i..i + needle.len()).is_some_and(|w| w.iter().zip(needle).all(|(a, b)| a == b || a.to_lowercase().eq(b.to_lowercase()))))
|
||||
}
|
||||
|
||||
fn find_replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let find = req_string("edit.findReplace", p, "find")?;
|
||||
if find.is_empty() {
|
||||
@@ -952,11 +1008,16 @@ fn find_replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let page = s.page()?;
|
||||
let doc = s.doc_mut()?;
|
||||
let mut hits = Vec::new();
|
||||
let lower = find.to_lowercase();
|
||||
let needle: Vec<char> = find.chars().collect();
|
||||
let r_chars: Vec<char> = replace.as_deref().map(|r| r.chars().collect()).unwrap_or_default();
|
||||
for i in doc.subtree(page) {
|
||||
let Some(n) = doc.node(i) else { continue };
|
||||
let text_hit = matches!(&n.kind, NodeKind::Text(t) if t.characters.to_lowercase().contains(&lower));
|
||||
let name_hit = names && n.name.to_lowercase().contains(&lower);
|
||||
let text: Option<Vec<char>> = match &n.kind {
|
||||
NodeKind::Text(t) => Some(t.characters.chars().collect()),
|
||||
_ => None,
|
||||
};
|
||||
let text_hit = text.as_deref().is_some_and(|c| find_ci(c, &needle, 0).is_some());
|
||||
let name_hit = names && find_ci(&n.name.chars().collect::<Vec<_>>(), &needle, 0).is_some();
|
||||
if !(text_hit || name_hit) || n.is_page() {
|
||||
continue;
|
||||
}
|
||||
@@ -964,19 +1025,27 @@ fn find_replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(r) = &replace {
|
||||
let n = doc.get_mut(i)?;
|
||||
if names && name_hit {
|
||||
n.name = n.name.replace(find.as_str(), r);
|
||||
let chars: Vec<char> = n.name.chars().collect();
|
||||
let mut out = String::new();
|
||||
let mut at = 0;
|
||||
while let Some(pos) = find_ci(&chars, &needle, at) {
|
||||
out.extend(chars.get(at..pos).unwrap_or_default());
|
||||
out.push_str(r);
|
||||
at = pos + needle.len();
|
||||
}
|
||||
out.extend(chars.get(at..).unwrap_or_default());
|
||||
n.name = out;
|
||||
}
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
// Replace occurrence by occurrence so styles shift correctly.
|
||||
let mut guard = 0;
|
||||
while let Some(pos) = t.characters.find(find.as_str()) {
|
||||
let start = t.characters.get(..pos).map(|x| x.chars().count()).unwrap_or(0);
|
||||
let len = find.chars().count();
|
||||
t.replace_range(start, start + len, r);
|
||||
guard += 1;
|
||||
if guard > 10_000 || r.contains(find.as_str()) {
|
||||
break;
|
||||
}
|
||||
if let (NodeKind::Text(t), Some(mut chars)) = (&mut n.kind, text) {
|
||||
// Replace occurrence by occurrence so styles shift correctly. The search resumes
|
||||
// after each inserted replacement, so a replacement that matches `find` (in any
|
||||
// case) neither loops forever nor stops the other occurrences.
|
||||
let mut cursor = 0;
|
||||
for _ in 0..10_000 {
|
||||
let Some(start) = find_ci(&chars, &needle, cursor) else { break };
|
||||
t.replace_range(start, start + needle.len(), r);
|
||||
chars.splice(start..start + needle.len(), r_chars.iter().copied());
|
||||
cursor = start + r_chars.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+224
-21
@@ -37,7 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"file.openRecent",
|
||||
"Open recent",
|
||||
"{path? | index? (into the list), clear? (forget them all)} opens a recent file; a file that no longer exists is dropped from the list. Returns the list, newest first: {files: [{path, name, exists}]} (opening returns what `file.open` does)",
|
||||
"{path? | index? (into the list), clear? (forget them all), remove? (a path: forget that one)} opens a recent file; a file that no longer exists is dropped from the list. Returns the list, newest first: {files: [{path, name, exists}]} (opening returns what `file.open` does)",
|
||||
false,
|
||||
open_recent
|
||||
),
|
||||
@@ -112,7 +112,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s.active = s.docs.len() - 1;
|
||||
Ok(json!({"index": s.active}))
|
||||
}),
|
||||
spec!("file.revert", "Revert to saved", "{}", false, |s, _| {
|
||||
spec!("file.revert", "Revert to saved", "{} leave the live session first, if any", false, |s, _| {
|
||||
let path = s.ds()?.path.clone().ok_or_else(|| EngineError::Disabled("file.revert".into(), "the file was never saved".into()))?;
|
||||
let (loaded, _) = load_path(&path, None)?;
|
||||
let i = s.active;
|
||||
@@ -195,7 +195,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"version.autosave",
|
||||
"Autosave version",
|
||||
"{} save an automatic checkpoint when the document has unsaved changes since the last version (the app does this every 30 minutes); keeps the newest 20 autosaves → {saved, version?}",
|
||||
"{} save an automatic checkpoint when the document has unsaved changes since the last version (with `pref.autoSave` on: changes made since it was opened); the app does this every 30 minutes; keeps the newest 20 autosaves → {saved, version?}",
|
||||
false,
|
||||
autosave
|
||||
),
|
||||
@@ -219,7 +219,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
),
|
||||
spec!("doc.switch", "Switch document", "{index}", false, |s, p| {
|
||||
let i = req_num("doc.switch", p, "index")? as usize;
|
||||
let i = req_index("doc.switch", p, "index")?;
|
||||
if i >= s.docs.len() {
|
||||
return Err(bad("doc.switch", "no such document"));
|
||||
}
|
||||
@@ -257,8 +257,11 @@ pub const MAX_AUTOSAVES: usize = 20;
|
||||
|
||||
/// `version.autosave`: a checkpoint of a document with unsaved changes since the last version.
|
||||
fn autosave(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let auto_save = s.prefs.auto_save;
|
||||
let ds = s.ds_mut()?;
|
||||
if ds.preview.is_some() || !ds.is_dirty() || !ds.changed_since_checkpoint() {
|
||||
// A file that saves itself is rarely unsaved: edits since it was opened count then.
|
||||
let edited = ds.is_dirty() || (auto_save && !ds.history.undo.is_empty());
|
||||
if ds.preview.is_some() || !edited || !ds.changed_since_checkpoint() {
|
||||
return Ok(json!({"saved": false}));
|
||||
}
|
||||
Ok(match add_version(ds, AUTOSAVE, "", true) {
|
||||
@@ -437,15 +440,12 @@ pub(crate) fn read_capped(path: &str, max: u64) -> Result<Vec<u8>> {
|
||||
read(path)
|
||||
}
|
||||
|
||||
/// Write `bytes` to `path` through a uniquely named, exclusively created staging file and one
|
||||
/// atomic rename (see [`figcraft_format::write_file_atomic`]): a failed or interrupted write never
|
||||
/// 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<()> {
|
||||
let tmp = format!("{path}.tmp-figcraft");
|
||||
std::fs::write(&tmp, bytes).map_err(|e| EngineError::Other(format!("{tmp}: {e}")))?;
|
||||
std::fs::rename(&tmp, path).map_err(|e| {
|
||||
// Leave nothing behind when the file can't take its place.
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
EngineError::Other(format!("{path}: {e}"))
|
||||
})
|
||||
figcraft_format::write_file_atomic(std::path::Path::new(path), bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -625,6 +625,9 @@ fn open_recent(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if p.get("clear").and_then(Value::as_bool) == Some(true) {
|
||||
s.prefs.recent_files.clear();
|
||||
}
|
||||
if let Some(gone) = string(p, "remove") {
|
||||
s.prefs.recent_files.retain(|r| *r != gone);
|
||||
}
|
||||
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"))?),
|
||||
@@ -662,6 +665,13 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
remember(s, &path);
|
||||
let native = !rest && matches!(ext(&path).as_str(), "figcraft" | "json");
|
||||
let warnings = rest || ext(&path) == "svg";
|
||||
// A native file that lost something (a damaged image, say): the app says so, not only the result.
|
||||
if native && !loaded.issues.is_empty() {
|
||||
s.message = Some(match loaded.issues.as_slice() {
|
||||
[one] => format!("{}: {one}", file_stem(&path)),
|
||||
more => format!("{}: {} problems were repaired or left out while opening it, e.g. {}", file_stem(&path), more.len(), more[0]),
|
||||
});
|
||||
}
|
||||
s.docs.push(DocState::new(loaded.doc, native.then_some(path)).with_versions(loaded.versions).with_branches(loaded.branches));
|
||||
s.active = s.docs.len() - 1;
|
||||
let mut out = json!({"index": s.active});
|
||||
@@ -686,10 +696,71 @@ pub fn thumbnail(doc: &Document) -> Option<Vec<u8>> {
|
||||
|
||||
/// The `.figcraft` file for a document: content, thumbnail and version history.
|
||||
pub fn file_bytes(ds: &DocState) -> Result<Vec<u8>> {
|
||||
let mut doc = (*ds.doc).clone();
|
||||
doc.current_page = Some(ds.page);
|
||||
let thumb = thumbnail(&doc);
|
||||
figcraft_format::save_bytes_with_history(&doc, thumb.as_deref(), &ds.versions, &ds.branches).map_err(|e| EngineError::Other(e.to_string()))
|
||||
FileSnapshot::of(ds).encode()
|
||||
}
|
||||
|
||||
/// A document's file as it is at one moment, so the slow part of saving it (the thumbnail and the
|
||||
/// encoding) can run on another thread while editing goes on: [`encode`](Self::encode) there, then
|
||||
/// [`save`](Self::save) the bytes back on the session's thread.
|
||||
pub struct FileSnapshot {
|
||||
uid: u64,
|
||||
path: Option<String>,
|
||||
revision: u64,
|
||||
doc: Arc<Document>,
|
||||
/// The document as last saved when the snapshot was taken: another save since makes it stale.
|
||||
saved: Arc<Document>,
|
||||
page: NodeId,
|
||||
versions: Vec<Version>,
|
||||
branches: Vec<figcraft_format::Branch>,
|
||||
}
|
||||
|
||||
impl FileSnapshot {
|
||||
pub fn of(ds: &DocState) -> Self {
|
||||
Self {
|
||||
uid: ds.uid,
|
||||
path: ds.path.clone(),
|
||||
revision: ds.revision,
|
||||
doc: ds.doc.clone(),
|
||||
saved: ds.saved_doc.clone(),
|
||||
page: ds.page,
|
||||
versions: ds.versions.clone(),
|
||||
branches: ds.branches.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The file's bytes: content, thumbnail and version history.
|
||||
pub fn encode(&self) -> Result<Vec<u8>> {
|
||||
let mut doc = (*self.doc).clone();
|
||||
doc.current_page = Some(self.page);
|
||||
let thumb = thumbnail(&doc);
|
||||
figcraft_format::save_bytes_with_history(&doc, thumb.as_deref(), &self.versions, &self.branches)
|
||||
.map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
/// Write `bytes` (this snapshot's [`encode`](Self::encode)) to the document's file: it is saved
|
||||
/// as of the snapshot, and edits since stay unsaved. False (nothing written) when the document
|
||||
/// was closed, saved, reverted or given another path since: the bytes are out of date.
|
||||
pub fn save(self, s: &mut Session, bytes: &[u8]) -> Result<bool> {
|
||||
let Some(ds) = s.docs.iter_mut().find(|d| d.uid == self.uid && d.path == self.path && Arc::ptr_eq(&d.saved_doc, &self.saved)) else {
|
||||
return Ok(false);
|
||||
};
|
||||
write(self.path.as_deref().ok_or(EngineError::NoDocument)?, bytes)?;
|
||||
// The history or branches may have changed since: then they still count as unsaved.
|
||||
if ds.revision == self.revision {
|
||||
ds.versions_changed = false;
|
||||
}
|
||||
ds.saved_doc = self.doc;
|
||||
ds.revision += 1;
|
||||
branch_tabs_saved(s, self.uid);
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// The branch tabs of the file in tab `uid` were saved with it.
|
||||
fn branch_tabs_saved(s: &mut Session, uid: u64) {
|
||||
for tab in s.docs.iter_mut().filter(|d| d.branch_of.as_ref().is_some_and(|(m, _)| *m == uid)) {
|
||||
tab.mark_saved();
|
||||
}
|
||||
}
|
||||
|
||||
fn color_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -714,9 +785,7 @@ fn save_to(s: &mut Session, path: String) -> Result<Value> {
|
||||
ds.mark_saved();
|
||||
ds.revision += 1;
|
||||
let uid = ds.uid;
|
||||
for tab in s.docs.iter_mut().filter(|d| d.branch_of.as_ref().is_some_and(|(m, _)| *m == uid)) {
|
||||
tab.mark_saved();
|
||||
}
|
||||
branch_tabs_saved(s, uid);
|
||||
remember(s, &path);
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
}
|
||||
@@ -782,8 +851,11 @@ pub fn export_bytes_with(doc: &Document, ids: &[NodeId], setting: &ExportSetting
|
||||
if ids.is_empty() {
|
||||
return Err(nothing());
|
||||
}
|
||||
let area = export_area(doc, ids, setting).ok_or_else(nothing)?;
|
||||
let drawn = drawn_layers(doc, ids, setting);
|
||||
if matches!(setting.format, ExportFormat::Svg | ExportFormat::Pdf) {
|
||||
figcraft_render::check_export_depth(doc, &drawn).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
}
|
||||
let area = export_area(doc, ids, setting).ok_or_else(nothing)?;
|
||||
// The layers' own area unless an option or a slice says otherwise.
|
||||
let own_area = setting.contents_only
|
||||
&& !setting.include_bounding_box
|
||||
@@ -797,7 +869,10 @@ pub fn export_bytes_with(doc: &Document, ids: &[NodeId], setting: &ExportSetting
|
||||
simplify_stroke: setting.svg_simplify_stroke,
|
||||
bounds: Some(area),
|
||||
};
|
||||
figcraft_svg::export_svg(&doc.in_srgb(), &drawn, &opts).map(String::into_bytes).ok_or_else(nothing)
|
||||
figcraft_svg::try_export_svg(&doc.in_srgb(), &drawn, &opts).map(String::into_bytes).map_err(|e| match e {
|
||||
figcraft_svg::SvgError::Empty => nothing(),
|
||||
other => EngineError::Other(other.to_string()),
|
||||
})
|
||||
}
|
||||
ExportFormat::Pdf => {
|
||||
let doc = &*doc.in_srgb();
|
||||
@@ -877,6 +952,11 @@ pub fn fig_bytes(doc: &Document) -> Result<(Vec<u8>, Vec<String>)> {
|
||||
|
||||
/// The scale that gives `ids` (their export area together) `setting`'s export size.
|
||||
pub fn export_scale(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> f64 {
|
||||
// Size-based exports ask for bounds before encoding. Let encoding report the depth error
|
||||
// without entering a recursive bounds walk here; SVG/PDF do not use this raster scale.
|
||||
if matches!(setting.format, ExportFormat::Svg | ExportFormat::Pdf) && figcraft_render::check_export_depth(doc, ids).is_err() {
|
||||
return 1.0;
|
||||
}
|
||||
export_area(doc, ids, setting).map_or(1.0, |b| setting.size.scale_for(b.width(), b.height()))
|
||||
}
|
||||
|
||||
@@ -1306,3 +1386,126 @@ mod rest_import_tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod staging_write_tests {
|
||||
use super::*;
|
||||
|
||||
fn dir(name: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("figcraft-engine-staging-{name}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// A symlink `from` → `to`; false when the platform won't make one (Windows needs privileges).
|
||||
#[cfg(unix)]
|
||||
fn symlink(from: &std::path::Path, to: &std::path::Path) -> bool {
|
||||
std::os::unix::fs::symlink(to, from).is_ok()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn symlink(from: &std::path::Path, to: &std::path::Path) -> bool {
|
||||
std::os::windows::fs::symlink_file(to, from).is_ok()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_existing_staging_file_is_never_written_through() {
|
||||
let d = dir("existing");
|
||||
let path = d.join("shot.svg");
|
||||
// The old deterministic staging name, held by an unrelated file: the write must pick its
|
||||
// own staging name instead of truncating this one.
|
||||
let old = d.join("shot.svg.tmp-figcraft");
|
||||
std::fs::write(&old, b"not yours").unwrap();
|
||||
write(&path.to_string_lossy(), b"<svg/>").unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"<svg/>");
|
||||
assert_eq!(std::fs::read(&old).unwrap(), b"not yours", "an unrelated file at a staging name is untouched");
|
||||
// Just the file and the untouched staging file: no new staging leftovers.
|
||||
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 2);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_staging_symlink_is_never_followed() {
|
||||
let d = dir("symlink");
|
||||
let path = d.join("shot.svg");
|
||||
let victim = d.join("victim.txt");
|
||||
std::fs::write(&victim, b"innocent").unwrap();
|
||||
let old = d.join("shot.svg.tmp-figcraft");
|
||||
if !symlink(&old, &victim) {
|
||||
return; // Making a symlink needs privileges on some Windows setups.
|
||||
}
|
||||
write(&path.to_string_lossy(), b"<svg/>").unwrap();
|
||||
assert_eq!(std::fs::read(&victim).unwrap(), b"innocent", "the symlink was never written through");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"<svg/>");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_writers_of_one_file_never_interleave() {
|
||||
let d = dir("concurrent");
|
||||
let path = d.join("shot.svg");
|
||||
for round in 0..5u8 {
|
||||
let mut handles = Vec::new();
|
||||
for w in 0u8..4 {
|
||||
let (path, bytes) = (path.clone(), vec![b'0' + w; 8192]);
|
||||
handles.push(std::thread::spawn(move || write(&path.to_string_lossy(), &bytes)));
|
||||
}
|
||||
let mut wins = 0;
|
||||
for h in handles {
|
||||
// On Windows a rename over a file another writer just replaced can lose the race.
|
||||
if h.join().unwrap().is_ok() {
|
||||
wins += 1;
|
||||
}
|
||||
}
|
||||
assert!(wins > 0, "round {round}: every writer lost");
|
||||
// Whatever landed is one writer's complete payload, never a mix.
|
||||
let got = std::fs::read(&path).unwrap();
|
||||
assert_eq!(got.len(), 8192, "round {round}: a complete payload landed");
|
||||
assert!(got.windows(2).all(|w| w[0] == w[1]), "round {round}: one writer's payload, not a mix");
|
||||
}
|
||||
// No staging file was left stuck (a failed writer removes its own).
|
||||
let left = std::fs::read_dir(&d).unwrap().count();
|
||||
assert!(left <= if cfg!(windows) { 5 } else { 1 }, "{left} files left in the staging directory");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_write_leaves_the_destination_alone_and_cleans_up() {
|
||||
let d = dir("failure");
|
||||
// The destination is a directory: the rename fails, our staging file is removed and the
|
||||
// directory is untouched.
|
||||
let taken = d.join("over-a-directory");
|
||||
std::fs::create_dir_all(&taken).unwrap();
|
||||
assert!(write(&taken.to_string_lossy(), b"payload").is_err());
|
||||
assert!(taken.is_dir());
|
||||
assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1, "no staging file left behind");
|
||||
// A missing parent directory fails without creating anything anywhere.
|
||||
assert!(write(&d.join("no").join("such.svg").to_string_lossy(), b"payload").is_err());
|
||||
assert!(!d.join("no").exists());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
// A read-only directory refuses the staging file, and the old destination stays.
|
||||
let ro = d.join("read-only");
|
||||
std::fs::create_dir_all(&ro).unwrap();
|
||||
let dest = ro.join("keep.svg");
|
||||
std::fs::write(&dest, b"keep").unwrap();
|
||||
let mut perms = std::fs::metadata(&ro).unwrap().permissions();
|
||||
perms.set_mode(0o500);
|
||||
std::fs::set_permissions(&ro, perms).unwrap();
|
||||
// Running as root would defeat the read-only directory: skip instead of failing.
|
||||
let probe = ro.join(".probe");
|
||||
if std::fs::write(&probe, b"").is_ok() {
|
||||
let _ = std::fs::remove_file(&probe);
|
||||
} else {
|
||||
assert!(write(&dest.to_string_lossy(), b"payload").is_err());
|
||||
assert_eq!(std::fs::read(&dest).unwrap(), b"keep");
|
||||
assert_eq!(std::fs::read_dir(&ro).unwrap().count(), 1, "no staging file left behind");
|
||||
}
|
||||
let mut perms = std::fs::metadata(&ro).unwrap().permissions();
|
||||
perms.set_mode(0o700);
|
||||
std::fs::set_permissions(&ro, perms).unwrap();
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,12 @@ fn settle(s: &mut Session, cmd: &str) -> crate::Result<()> {
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!("page.add", "Add page", "{name?} (a name starting with a hyphen adds a divider)", true, |s, p| {
|
||||
let background = s.new_page_background();
|
||||
// An added page takes the current page's colour; the theme only decides new files.
|
||||
let current = s.page().ok().and_then(|p| match s.doc().ok()?.node(p)?.kind {
|
||||
NodeKind::Page { background, .. } => Some(background),
|
||||
_ => None,
|
||||
});
|
||||
let background = current.unwrap_or_else(|| s.new_page_background());
|
||||
let doc = s.doc_mut()?;
|
||||
let name = string(p, "name").unwrap_or_else(|| format!("Page {}", doc.pages.len() + 1));
|
||||
let id = doc.add_page(&name);
|
||||
@@ -47,7 +52,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let id = match id(p, "id") {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
let k = req_num("page.select", p, "index")? as usize;
|
||||
let k = req_index("page.select", p, "index")?;
|
||||
*doc.pages.get(k).ok_or_else(|| bad("page.select", "no such page"))?
|
||||
}
|
||||
};
|
||||
@@ -102,6 +107,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let id = id(p, "id").unwrap_or(s.page()?);
|
||||
let k = req_num("page.move", p, "index")?.max(0.0) as usize;
|
||||
let doc = s.doc_mut()?;
|
||||
if !doc.pages.contains(&id) {
|
||||
return Err(bad("page.move", format!("{id} is not a page")));
|
||||
}
|
||||
doc.pages.retain(|x| *x != id);
|
||||
let k = k.min(doc.pages.len());
|
||||
doc.pages.insert(k, id);
|
||||
|
||||
@@ -13,7 +13,7 @@ pub const STROKE_SIDES: [&str; 4] = ["strokeTopWeight", "strokeRightWeight", "st
|
||||
|
||||
/// Keys `node.set` understands (documented for agents).
|
||||
pub const KEYS: &str = "name, visible, locked, opacity (0-1), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), \
|
||||
fills (paints or colours), fill (colour), strokes, stroke (colour), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \
|
||||
fills (paints or colours; gradients as {type: linearGradient|radialGradient|angularGradient|diamondGradient, stops: [\"#hex\", ...], from?, to?} too), fill (colour), strokes, stroke (colour), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \
|
||||
strokeCap / startCap / endCap, strokeJoin, miterAngle (degrees: sharper joins bevel; or miterLimit as a ratio), dashes [..], widthProfile (preset uniform|wedge|taper|eye|rounded|pinch, [[t, scale], ...] or null), brush (preset id, {preset, spacing, sizeJitter, rotationJitter, scatter, seed} or null), cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \
|
||||
clip, isMask, layout (null|horizontal|vertical|grid|{...}), gap, crossGap, padding (n or [t,r,b,l]), paddingTop/Right/Bottom/Left, \
|
||||
mainAlign (min|center|max, or the auto gaps spaceBetween|spaceAround|spaceEvenly), crossAlign (min|center|max|baseline: first text baselines, horizontal layouts), contentAlign (wrapped lines: auto = packed where crossAlign says | spaceBetween), wrap (rows of a horizontal layout, columns of a vertical one), strokesIncluded (the frame's inside stroke takes up space), reverseZ (canvas stacking: true = first on top), \
|
||||
@@ -293,16 +293,26 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
}
|
||||
"width" | "height" | "w" | "h" => {
|
||||
let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0);
|
||||
let (ow, oh, keep) = {
|
||||
let is_w = k.starts_with('w');
|
||||
// With the proportions locked (the Layout panel's link) and only one side set, the
|
||||
// other side follows the node's ratio; both sides in one `node.set` stay as typed.
|
||||
let (nw, nh) = {
|
||||
let n = doc.get(id)?;
|
||||
(n.width, n.height, n.constrain_proportions && !props.contains_key("width") || !props.contains_key("height"))
|
||||
let (ow, oh) = (n.width, n.height);
|
||||
let (other, other_short) = if is_w { ("height", "h") } else { ("width", "w") };
|
||||
let linked = n.constrain_proportions && !props.contains_key(other) && !props.contains_key(other_short) && ow > 1e-9 && oh > 1e-9;
|
||||
let derived = if is_w { oh * val / ow } else { ow * val / oh };
|
||||
match (is_w, linked && derived.is_finite()) {
|
||||
(true, true) => (val, derived),
|
||||
(false, true) => (derived, val),
|
||||
(true, false) => (val, oh),
|
||||
(false, false) => (ow, val),
|
||||
}
|
||||
};
|
||||
let _ = keep;
|
||||
let (nw, nh) = if k.starts_with('w') { (val, oh) } else { (ow, val) };
|
||||
resize_node(doc, id, nw, nh)?;
|
||||
// Explicit size makes text fixed in that dimension.
|
||||
if let Some(t) = doc.get_mut(id)?.kind.text_mut() {
|
||||
t.auto_resize = if k.starts_with('w') {
|
||||
t.auto_resize = if is_w {
|
||||
if t.auto_resize == TextAutoResize::WidthAndHeight { TextAutoResize::Height } else { t.auto_resize }
|
||||
} else {
|
||||
TextAutoResize::None
|
||||
@@ -310,7 +320,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
}
|
||||
// Explicit width/height makes hugging/filling fixed (like typing into W/H).
|
||||
let n = doc.get_mut(id)?;
|
||||
if k.starts_with('w') {
|
||||
if is_w {
|
||||
n.child_layout.sizing_h = Sizing::Fixed;
|
||||
} else {
|
||||
n.child_layout.sizing_v = Sizing::Fixed;
|
||||
|
||||
@@ -362,9 +362,9 @@ fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn outline_stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("vector.outlineStroke", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let mut out = Vec::new();
|
||||
for i in ids {
|
||||
let doc = s.doc_mut()?;
|
||||
let n = doc.get(i)?.clone();
|
||||
if !n.has_visible_stroke() {
|
||||
continue;
|
||||
@@ -409,7 +409,7 @@ fn outline_stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(g) = id_of(&r["id"]) {
|
||||
out.push(g);
|
||||
}
|
||||
return Ok(json!({"ids": out}));
|
||||
continue;
|
||||
}
|
||||
// Standing in for the layer, the outline keeps how it is drawn and placed.
|
||||
(v.opacity, v.blend, v.effects, v.styles.effect) = (n.opacity, n.blend, n.effects.clone(), n.styles.effect);
|
||||
|
||||
@@ -268,6 +268,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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.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")}));
|
||||
ok()
|
||||
}),
|
||||
spec!("view.layers", "Layers panel", "{}", false, |s, _| ui(s, "panel.layers")),
|
||||
spec!("view.assets", "Assets panel", "{}", false, |s, _| ui(s, "panel.assets")),
|
||||
spec!("view.design", "Design panel", "{}", false, |s, _| ui(s, "panel.design")),
|
||||
@@ -330,6 +334,7 @@ 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.autoSave", "Save files automatically", "{on?}", false, |s, p| pref(s, p, |x| &mut x.auto_save)),
|
||||
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);
|
||||
@@ -365,18 +370,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"url": s.prefs.publish_server, "default": crate::live::DEFAULT_SERVER}))
|
||||
}
|
||||
),
|
||||
spec!("pref.language", "Interface language", "{language: system|en|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…; else English}", false, |s, p| {
|
||||
let lang = req_string("pref.language", p, "language")?;
|
||||
let lang = lang.trim();
|
||||
// A BCP 47-style tag ("es", "pt-BR", "zh_Hans") or "system".
|
||||
let tag = lang.len() <= 35
|
||||
&& lang.split(['-', '_']).all(|part| !part.is_empty() && part.len() <= 8 && part.chars().all(|c| c.is_ascii_alphanumeric()));
|
||||
if !tag {
|
||||
return Err(bad("pref.language", format!("`{lang}` is not a language code")));
|
||||
spec!(
|
||||
"pref.language",
|
||||
"Interface language",
|
||||
"{language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…; else English}",
|
||||
false,
|
||||
|s, p| {
|
||||
let lang = req_string("pref.language", p, "language")?;
|
||||
let lang = lang.trim();
|
||||
// A BCP 47-style tag ("es", "pt-BR", "zh_Hans") or "system".
|
||||
let tag = lang.len() <= 35
|
||||
&& lang.split(['-', '_']).all(|part| !part.is_empty() && part.len() <= 8 && part.chars().all(|c| c.is_ascii_alphanumeric()));
|
||||
if !tag {
|
||||
return Err(bad("pref.language", format!("`{lang}` is not a language code")));
|
||||
}
|
||||
s.prefs.language = lang.to_string();
|
||||
Ok(json!({"language": s.prefs.language}))
|
||||
}
|
||||
s.prefs.language = lang.to_string();
|
||||
Ok(json!({"language": s.prefs.language}))
|
||||
}),
|
||||
),
|
||||
spec!("app.preferences", "Preferences", "{}", false, |s, _| ui(s, "dialog.preferences")),
|
||||
spec!("app.actions", "Actions", "{}", false, |s, _| ui(s, "palette.actions")),
|
||||
spec!("help.shortcuts", "Keyboard shortcuts", "{}", false, |s, _| ui(s, "panel.shortcuts")),
|
||||
|
||||
@@ -37,7 +37,7 @@ const MAX_WARNINGS: usize = 50;
|
||||
|
||||
/// The languages a component can be connected in.
|
||||
pub fn language(s: &str) -> Option<CodeLang> {
|
||||
CodeLang::from_id(s).filter(|l| matches!(l, CodeLang::React | CodeLang::SwiftUi | CodeLang::Compose))
|
||||
CodeLang::from_id(s).filter(|l| matches!(l, CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose))
|
||||
}
|
||||
|
||||
/// The layer `id`'s links are stored on: its component set, else its component (an instance's
|
||||
@@ -65,6 +65,7 @@ pub fn import_line(link: &CodeLink, lang: CodeLang) -> String {
|
||||
match lang {
|
||||
_ if path.is_empty() => String::new(),
|
||||
CodeLang::React => format!("import {{ {} }} from {};", link.name.split('.').next().unwrap_or_default(), js_string(path)),
|
||||
CodeLang::Leptos => format!("use {path}::{};", link.name.split('.').next().unwrap_or_default()),
|
||||
_ => format!("import {path}"),
|
||||
}
|
||||
}
|
||||
@@ -172,6 +173,7 @@ fn expr(doc: &Document, c: &Component, p: &Param, v: &Value, lang: CodeLang) ->
|
||||
let swapped = (p.kind == ParamKind::InstanceSwap).then(|| id_of(v)).flatten().and_then(|i| link(doc, i, lang));
|
||||
match (swapped, lang) {
|
||||
(Some(l), CodeLang::React) => format!("<{} />", l.name),
|
||||
(Some(l), CodeLang::Leptos) => format!("|| view! {{ <{} /> }}", l.name),
|
||||
(Some(l), CodeLang::SwiftUi) => format!("{}()", l.name),
|
||||
(Some(l), _) => format!("{{ {}() }}", l.name),
|
||||
(None, _) => value_expr(doc, c, p, v, lang),
|
||||
@@ -191,9 +193,10 @@ pub(crate) fn render(doc: &Document, c: &Component, values: &[(String, Value)],
|
||||
Piece::Value { name, inner } => match find(c, values, name) {
|
||||
Some((p, v)) => {
|
||||
let e = expr(doc, c, p, v, lang);
|
||||
// JSX: a plain string reads best as an attribute value, anything else is an
|
||||
// expression in braces (JSX attribute strings have no escapes).
|
||||
if lang != CodeLang::React || (out.ends_with('=') && e.starts_with('"') && !e.contains('\\')) {
|
||||
// JSX and `view!`: a plain string reads best as an attribute value, anything
|
||||
// else is an expression in braces (JSX attribute strings have no escapes).
|
||||
let markup = matches!(lang, CodeLang::React | CodeLang::Leptos);
|
||||
if !markup || (out.ends_with('=') && e.starts_with('"') && !e.contains('\\')) {
|
||||
out.push_str(&e);
|
||||
} else {
|
||||
out.push_str(&format!("{{{e}}}"));
|
||||
@@ -215,6 +218,9 @@ fn default_template(c: &Component, lang: CodeLang, name: &str) -> String {
|
||||
let params: Vec<&str> = c.params.iter().filter(|p| p.kind != ParamKind::Slot).map(|p| p.ident.as_str()).collect();
|
||||
match lang {
|
||||
CodeLang::React => format!("<{name}{} />", params.iter().map(|p| format!(" {p}={{{p}}}")).collect::<String>()),
|
||||
CodeLang::Leptos => {
|
||||
format!("<{name}{} />", params.iter().map(|p| format!(" {}={{{p}}}", crate::component_codegen::rust_ident(p))).collect::<String>())
|
||||
}
|
||||
CodeLang::SwiftUi => format!("{name}({})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::<Vec<_>>().join(", ")),
|
||||
_ => format!("{name}({})", params.iter().map(|p| format!("{p} = {{{p}}}")).collect::<Vec<_>>().join(", ")),
|
||||
}
|
||||
@@ -234,11 +240,13 @@ pub fn check(c: &Component, link: &CodeLink, lang: CodeLang) -> std::result::Res
|
||||
let path = &link.import_path;
|
||||
let path_ok = match lang {
|
||||
CodeLang::React => !path.chars().any(|ch| ch.is_control() || ch == '"' || ch == '\\'),
|
||||
CodeLang::Leptos => path.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == ':'),
|
||||
_ => path.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '.'),
|
||||
};
|
||||
if path.chars().count() > MAX_IMPORT || !path_ok {
|
||||
return Err(match lang {
|
||||
CodeLang::React => format!("importPath: a module path without quotes, up to {MAX_IMPORT} characters"),
|
||||
CodeLang::Leptos => format!("importPath: a Rust module path (letters, digits, _ and ::), up to {MAX_IMPORT} characters"),
|
||||
_ => format!("importPath: a module or package name (letters, digits, _ and .), up to {MAX_IMPORT} characters"),
|
||||
});
|
||||
}
|
||||
@@ -274,7 +282,7 @@ fn text(cmd: &str, p: &Value, key: &str) -> Result<Option<String>> {
|
||||
/// The `language` parameter (default React).
|
||||
fn lang_param(cmd: &str, p: &Value) -> Result<CodeLang> {
|
||||
match text(cmd, p, "language")? {
|
||||
Some(l) => language(&l).ok_or_else(|| bad(cmd, "language: react|swiftui|compose")),
|
||||
Some(l) => language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|swiftui|compose")),
|
||||
None => Ok(CodeLang::React),
|
||||
}
|
||||
}
|
||||
@@ -304,7 +312,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"codeConnect.set",
|
||||
"Connect to code",
|
||||
"{id? (component, set or instance; default the selection), language?: react|swiftui|compose (default react), importPath?, name? (default the generated name), template? (default a call passing every property)} links the component (its set, for a variant) to its code component; fields left out keep their current value. Placeholders: {property} (its name or parameter name) and {property ? \"a\" : \"b\"}; other {…} stay as written → {component, language, code (the snippet for the layer), unknown (placeholder names that aren't properties)}",
|
||||
"{id? (component, set or instance; default the selection), language?: react|leptos|swiftui|compose (default react), importPath?, name? (default the generated name), template? (default a call passing every property)} links the component (its set, for a variant) to its code component; fields left out keep their current value. Placeholders: {property} (its name or parameter name) and {property ? \"a\" : \"b\"}; other {…} stay as written → {component, language, code (the snippet for the layer), unknown (placeholder names that aren't properties)}",
|
||||
true,
|
||||
|s, p| {
|
||||
let cmd = "codeConnect.set";
|
||||
@@ -419,7 +427,7 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (Some(component), Some(lang), Some(name), Some(template)) =
|
||||
(field("component"), field("language").and_then(language), field("name"), e.get("template").and_then(Value::as_str))
|
||||
else {
|
||||
warnings.push(format!("link {}: needs component, language (react|swiftui|compose), name and template strings", i + 1));
|
||||
warnings.push(format!("link {}: needs component, language (react|leptos|swiftui|compose), name and template strings", i + 1));
|
||||
continue;
|
||||
};
|
||||
let link = CodeLink {
|
||||
|
||||
@@ -65,6 +65,15 @@ fn connected_instances_show_the_real_snippet() {
|
||||
);
|
||||
assert_eq!(code(&mut s, i, "swiftui"), "import AcmeUI\n\nCard(title: \"Say \\\"hi\\\"\", style: .plain)\n");
|
||||
assert!(code(&mut s, i, "compose").contains("@Composable"), "other languages stay generated");
|
||||
// Leptos: a `use` line and `view!` markup (strings with escapes in braces).
|
||||
ex(
|
||||
&mut s,
|
||||
"codeConnect.set",
|
||||
json!({"id": c, "language": "leptos", "importPath": "crate::ui", "template": "<Card title={title} dotted={Show dot} />"}),
|
||||
);
|
||||
assert_eq!(code(&mut s, i, "leptos"), "use crate::ui::Card;\n\n<Card title={\"Say \\\"hi\\\"\"} dotted={false} />\n");
|
||||
assert!(s.execute("codeConnect.set", &json!({"id": c, "language": "leptos", "importPath": "crate/ui"})).is_err(), "a Rust path");
|
||||
ex(&mut s, "codeConnect.remove", json!({"id": c, "language": "leptos"}));
|
||||
// inspect.componentCode: the snippet as the usage, and the link.
|
||||
let v = ex(&mut s, "inspect.componentCode", json!({"id": i, "language": "swiftui"}));
|
||||
assert_eq!(v["usage"], "Card(title: \"Say \\\"hi\\\"\", style: .plain)\n");
|
||||
|
||||
@@ -18,13 +18,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![spec!(
|
||||
"inspect.code",
|
||||
"Code for layer",
|
||||
"{id?, language?: css|html|react|swiftui|compose (default css; html and react are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead), unit?: px|rem (CSS, HTML and React; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}",
|
||||
"{id?, language?: css|html|react|leptos|swiftui|compose (default css; html, react and leptos are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead), unit?: px|rem (CSS, HTML, React and Leptos; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}",
|
||||
false,
|
||||
|s, p| {
|
||||
let cmd = "inspect.code";
|
||||
let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer"))?;
|
||||
let lang = match string(p, "language") {
|
||||
Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|swiftui|compose"))?,
|
||||
Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|swiftui|compose"))?,
|
||||
None => CodeLang::Css,
|
||||
};
|
||||
let rem = match string(p, "unit").map(|u| u.to_ascii_lowercase()).as_deref() {
|
||||
@@ -42,7 +42,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let connected = crate::code_connect::link(doc, id, lang).is_some();
|
||||
let code = code_for(doc, id, lang);
|
||||
let code = match rem {
|
||||
Some(root) if matches!(lang, CodeLang::Css | CodeLang::Html | CodeLang::React) => px_to_rem(&code, root),
|
||||
Some(root) if lang.is_web() => px_to_rem(&code, root),
|
||||
_ => code,
|
||||
};
|
||||
Ok(json!({"code": code, "language": lang.id(), "connected": connected}))
|
||||
@@ -59,17 +59,20 @@ pub enum CodeLang {
|
||||
Html,
|
||||
/// A React function component (TSX) with its stylesheet.
|
||||
React,
|
||||
/// A Leptos component (`view!` markup) with its stylesheet.
|
||||
Leptos,
|
||||
SwiftUi,
|
||||
Compose,
|
||||
}
|
||||
|
||||
impl CodeLang {
|
||||
pub const ALL: [CodeLang; 5] = [CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::SwiftUi, CodeLang::Compose];
|
||||
pub const ALL: [CodeLang; 6] = [CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::Leptos, CodeLang::SwiftUi, CodeLang::Compose];
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
CodeLang::Css => "css",
|
||||
CodeLang::Html => "html",
|
||||
CodeLang::React => "react",
|
||||
CodeLang::Leptos => "leptos",
|
||||
CodeLang::SwiftUi => "swiftui",
|
||||
CodeLang::Compose => "compose",
|
||||
}
|
||||
@@ -79,10 +82,15 @@ impl CodeLang {
|
||||
CodeLang::Css => "CSS",
|
||||
CodeLang::Html => "HTML + CSS",
|
||||
CodeLang::React => "React",
|
||||
CodeLang::Leptos => "Leptos",
|
||||
CodeLang::SwiftUi => "SwiftUI",
|
||||
CodeLang::Compose => "Compose",
|
||||
}
|
||||
}
|
||||
/// Languages whose lengths are CSS pixels (they can be shown in rem).
|
||||
pub fn is_web(self) -> bool {
|
||||
matches!(self, CodeLang::Css | CodeLang::Html | CodeLang::React | CodeLang::Leptos)
|
||||
}
|
||||
pub fn from_id(s: &str) -> Option<CodeLang> {
|
||||
if s.eq_ignore_ascii_case("tsx") || s.eq_ignore_ascii_case("jsx") {
|
||||
return Some(CodeLang::React);
|
||||
@@ -136,7 +144,7 @@ pub fn code_for(doc: &Document, id: NodeId, lang: CodeLang) -> String {
|
||||
match lang {
|
||||
CodeLang::Css => css(doc, id),
|
||||
CodeLang::Html => html(doc, id),
|
||||
CodeLang::React | CodeLang::SwiftUi | CodeLang::Compose => crate::component_codegen::code(doc, id, lang),
|
||||
CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose => crate::component_codegen::code(doc, id, lang),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -561,16 +569,18 @@ fn jsx_text(s: &str) -> String {
|
||||
/// flexboxes, other children are placed absolutely in their parent, text becomes paragraphs.
|
||||
fn html(doc: &Document, id: NodeId) -> String {
|
||||
let ctx = Ctx::new(doc, id);
|
||||
let mut out = Markup::new(&ctx, false);
|
||||
let mut out = Markup::new(&ctx, CodeLang::Html);
|
||||
out.node(id, None, 0, "");
|
||||
format!("{}\n<style>\n{}{}</style>\n", out.markup, out.tokens.root_rule(), out.styles)
|
||||
}
|
||||
|
||||
/// Markup being built for a layer tree: HTML (`class`) or React JSX (`className`, component
|
||||
/// parameters, instances as calls), with its stylesheet.
|
||||
/// Markup being built for a layer tree: HTML (`class`), React JSX (`className`) or Leptos `view!`
|
||||
/// markup (text as Rust string literals); the last two with component parameters and instances
|
||||
/// as calls. With its stylesheet.
|
||||
pub(crate) struct Markup<'c, 'a> {
|
||||
ctx: &'c Ctx<'a>,
|
||||
jsx: bool,
|
||||
/// Html, React or Leptos.
|
||||
lang: CodeLang,
|
||||
used: Vec<String>,
|
||||
pub markup: String,
|
||||
pub styles: String,
|
||||
@@ -584,10 +594,10 @@ pub(crate) struct Markup<'c, 'a> {
|
||||
}
|
||||
|
||||
impl<'c, 'a> Markup<'c, 'a> {
|
||||
pub fn new(ctx: &'c Ctx<'a>, jsx: bool) -> Self {
|
||||
pub fn new(ctx: &'c Ctx<'a>, lang: CodeLang) -> Self {
|
||||
Markup {
|
||||
ctx,
|
||||
jsx,
|
||||
lang,
|
||||
used: Vec::new(),
|
||||
markup: String::new(),
|
||||
styles: String::new(),
|
||||
@@ -598,22 +608,34 @@ impl<'c, 'a> Markup<'c, 'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// JSX indents by two spaces, HTML by four.
|
||||
/// JSX indents by two spaces, HTML and Leptos by four.
|
||||
fn pad(&self, depth: usize) -> String {
|
||||
if self.jsx { " ".repeat(depth) } else { indent(depth) }
|
||||
if self.lang == CodeLang::React { " ".repeat(depth) } else { indent(depth) }
|
||||
}
|
||||
|
||||
/// A layer, shown only while its boolean parameter is on (JSX).
|
||||
/// Component code (React or Leptos): parameters and instances as calls.
|
||||
fn code(&self) -> bool {
|
||||
self.lang != CodeLang::Html
|
||||
}
|
||||
|
||||
/// A parameter's name in the generated code.
|
||||
fn ident(&self, p: &Param) -> String {
|
||||
if self.lang == CodeLang::Leptos { crate::component_codegen::rust_ident(&p.ident) } else { p.ident.clone() }
|
||||
}
|
||||
|
||||
/// A layer, shown only while its boolean parameter is on (component code).
|
||||
pub fn node(&mut self, id: NodeId, parent: Option<&Node>, depth: usize, path: &str) {
|
||||
let Some(n) = self.ctx.doc.node(id) else { return };
|
||||
if self.jsx
|
||||
if self.code()
|
||||
&& id != self.ctx.root
|
||||
&& let Some(p) = self.ctx.param(n, "visible")
|
||||
{
|
||||
let pad = self.pad(depth);
|
||||
self.markup.push_str(&format!("{pad}{{{} && (\n", p.ident));
|
||||
let (pad, p) = (self.pad(depth), self.ident(p));
|
||||
let (open, close) =
|
||||
if self.lang == CodeLang::Leptos { (format!("{{{p}.then(|| view! {{"), "})}") } else { (format!("{{{p} && ("), ")}") };
|
||||
self.markup.push_str(&format!("{pad}{open}\n"));
|
||||
self.element(n, parent, depth + 1, path);
|
||||
self.markup.push_str(&format!("{pad})}}\n"));
|
||||
self.markup.push_str(&format!("{pad}{close}\n"));
|
||||
return;
|
||||
}
|
||||
self.element(n, parent, depth, path);
|
||||
@@ -639,7 +661,7 @@ impl<'c, 'a> Markup<'c, 'a> {
|
||||
None => class_name(&n.name, &mut self.used),
|
||||
};
|
||||
let pad = self.pad(depth);
|
||||
let attr = if self.jsx { "className" } else { "class" };
|
||||
let attr = if self.lang == CodeLang::React { "className" } else { "class" };
|
||||
// How it sits in its parent: in the flex flow, or placed at its position.
|
||||
let mut decls = String::new();
|
||||
let in_flow = parent.is_some_and(|p| p.layout.is_some()) && !n.child_layout.absolute;
|
||||
@@ -663,12 +685,13 @@ impl<'c, 'a> Markup<'c, 'a> {
|
||||
decls.push_str(&format!("transform: rotate({}deg);\ntransform-origin: 0 0;\n", num(-rot)));
|
||||
}
|
||||
}
|
||||
// Nested instances are calls (JSX), in a wrapper that places them when needed.
|
||||
if self.jsx && !root && matches!(n.kind, NodeKind::Instance { .. }) {
|
||||
// Nested instances are calls (component code), in a wrapper that places them when needed.
|
||||
if self.code() && !root && matches!(n.kind, NodeKind::Instance { .. }) {
|
||||
let at = depth + usize::from(!decls.is_empty());
|
||||
let run = if self.lang == CodeLang::Leptos { ".run()" } else { "" };
|
||||
let inner = match self.ctx.param(n, "mainComponent") {
|
||||
Some(p) => Some(format!("{}{{{}}}\n", self.pad(at), p.ident)),
|
||||
None => self.ctx.call(n, CodeLang::React, at),
|
||||
Some(p) => Some(format!("{}{{{}{run}}}\n", self.pad(at), self.ident(p))),
|
||||
None => self.ctx.call(n, self.lang, at),
|
||||
};
|
||||
if let Some(inner) = inner {
|
||||
if decls.is_empty() {
|
||||
@@ -692,19 +715,26 @@ impl<'c, 'a> Markup<'c, 'a> {
|
||||
let open = |tag: &str| format!("{pad}<{tag} {attr}=\"{class}\"{attrs}>");
|
||||
match &n.kind {
|
||||
NodeKind::Text(t) => {
|
||||
let body = match self.ctx.param(n, "characters").filter(|_| self.jsx) {
|
||||
Some(p) => format!("{{{}}}", p.ident),
|
||||
None if self.jsx => jsx_text(&t.characters),
|
||||
None => html_escape(&t.characters),
|
||||
let body = match (self.ctx.param(n, "characters").filter(|_| self.code()), self.lang) {
|
||||
(Some(p), CodeLang::Leptos) => format!("{{{}.clone()}}", self.ident(p)),
|
||||
(Some(p), _) => format!("{{{}}}", p.ident),
|
||||
(None, CodeLang::React) => jsx_text(&t.characters),
|
||||
// `view!` takes text as string literals and void elements self-closed.
|
||||
(None, CodeLang::Leptos) => {
|
||||
t.characters.chars().take(10_000).collect::<String>().lines().map(|l| format!("{l:?}")).collect::<Vec<_>>().join("<br/>")
|
||||
}
|
||||
(None, _) => html_escape(&t.characters),
|
||||
};
|
||||
self.markup.push_str(&format!("{}{body}</p>\n", open("p")));
|
||||
}
|
||||
_ => {
|
||||
let kids = self.ctx.kids(n);
|
||||
let slot = self.ctx.param(n, "slot").filter(|_| self.jsx);
|
||||
let slot = self.ctx.param(n, "slot").filter(|_| self.code());
|
||||
let leptos = self.lang == CodeLang::Leptos;
|
||||
let open = open("div");
|
||||
if let Some(p) = slot.filter(|_| kids.is_empty()) {
|
||||
self.markup.push_str(&format!("{open}{{{}}}</div>\n", p.ident));
|
||||
let content = if leptos { format!("{}.as_ref().map(|c| c.run())", self.ident(p)) } else { p.ident.clone() };
|
||||
self.markup.push_str(&format!("{open}{{{content}}}</div>\n"));
|
||||
} else if kids.is_empty() || self.ctx.too_deep(depth) {
|
||||
self.markup.push_str(&format!("{open}</div>\n"));
|
||||
} else {
|
||||
@@ -712,6 +742,10 @@ impl<'c, 'a> Markup<'c, 'a> {
|
||||
// A slot shows the instance's content, or else the main's.
|
||||
let (p1, p2) = (self.pad(depth + 1), self.pad(depth + 2));
|
||||
let inner = match slot {
|
||||
Some(p) if leptos => {
|
||||
self.markup.push_str(&format!("{p1}{{match &{} {{ Some(c) => c.run(), None => view! {{\n", self.ident(p)));
|
||||
depth + 2
|
||||
}
|
||||
Some(p) => {
|
||||
self.markup.push_str(&format!("{p1}{{{} ?? (\n{p2}<>\n", p.ident));
|
||||
depth + 3
|
||||
@@ -726,8 +760,10 @@ impl<'c, 'a> Markup<'c, 'a> {
|
||||
seen.push(&kn.name);
|
||||
self.node(k, Some(n), inner, &format!("{path}/{}#{count}", kn.name));
|
||||
}
|
||||
if slot.is_some() {
|
||||
self.markup.push_str(&format!("{p2}</>\n{p1})}}\n"));
|
||||
match slot {
|
||||
Some(_) if leptos => self.markup.push_str(&format!("{p1}}}.into_any() }}}}\n")),
|
||||
Some(_) => self.markup.push_str(&format!("{p2}</>\n{p1})}}\n")),
|
||||
None => {}
|
||||
}
|
||||
self.markup.push_str(&format!("{pad}</div>\n"));
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
//! Component code: a component (or component set) becomes a React, SwiftUI or Jetpack Compose
|
||||
//! component whose properties are its parameters (a variant property → a union or enum, boolean →
|
||||
//! Component code: a component (or component set) becomes a React, Leptos, SwiftUI or Jetpack
|
||||
//! Compose component whose properties are its parameters (a variant property → a union or enum, boolean →
|
||||
//! a flag showing the layers bound to it, text → a string, instance swap → a view parameter, slot →
|
||||
//! optional content), and an instance becomes a call passing the values it sets. Variants of a set
|
||||
//! share one definition (the set's default variant); in React their differences become CSS rules on
|
||||
//! `data-*` attributes. Returned by `inspect.componentCode` and used by `inspect.code`. A component
|
||||
//! share one definition (the set's default variant); in React and Leptos their differences become CSS
|
||||
//! rules on `data-*` attributes. Returned by `inspect.componentCode` and used by `inspect.code`. A component
|
||||
//! connected to code (see [`crate::code_connect`]) shows its real snippet instead.
|
||||
|
||||
use figcraft_doc::*;
|
||||
@@ -67,14 +67,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![spec!(
|
||||
"inspect.componentCode",
|
||||
"Component code",
|
||||
"{id? (component, set or instance; default the selection), language?: react|swiftui|compose (default react)} → {component, name, set, properties: [{name, param, type, default, value? (instances)}], definition, usage? (instances; the connected snippet when the component is connected to code), connected: {importPath, name, template, import, code} | null}",
|
||||
"{id? (component, set or instance; default the selection), language?: react|leptos|swiftui|compose (default react)} → {component, name, set, properties: [{name, param, type, default, value? (instances)}], definition, usage? (instances; the connected snippet when the component is connected to code), connected: {importPath, name, template, import, code} | null}",
|
||||
false,
|
||||
|s, p| {
|
||||
let cmd = "inspect.componentCode";
|
||||
let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component or instance"))?;
|
||||
let lang = match string(p, "language").map(|l| CodeLang::from_id(&l)) {
|
||||
Some(Some(l @ (CodeLang::React | CodeLang::SwiftUi | CodeLang::Compose))) => l,
|
||||
Some(_) => return Err(bad(cmd, "language: react|swiftui|compose")),
|
||||
Some(Some(l @ (CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose))) => l,
|
||||
Some(_) => return Err(bad(cmd, "language: react|leptos|swiftui|compose")),
|
||||
None => CodeLang::React,
|
||||
};
|
||||
let doc = s.doc()?;
|
||||
@@ -107,9 +107,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
)]
|
||||
}
|
||||
|
||||
/// Code for any layer in React, SwiftUI or Compose: an instance's call and its component's
|
||||
/// definition, a component's (or set's) definition, or the layer tree (React wraps it in a
|
||||
/// function component).
|
||||
/// Code for any layer in React, Leptos, SwiftUI or Compose: an instance's call and its component's
|
||||
/// definition, a component's (or set's) definition, or the layer tree (React and Leptos wrap it
|
||||
/// in a function component).
|
||||
pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String {
|
||||
let Some(n) = doc.node(id) else { return String::new() };
|
||||
if let Some(code) = crate::code_connect::link(doc, id, lang).and_then(|l| crate::code_connect::connected_code(doc, id, lang, l)) {
|
||||
@@ -120,7 +120,10 @@ pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String {
|
||||
(NodeKind::Instance { .. }, Some(c)) => format!("{}\n{}", usage(doc, id, lang, 0).unwrap_or_default(), definition(doc, &c, lang)),
|
||||
(NodeKind::Component { .. } | NodeKind::ComponentSet { .. }, Some(c)) => definition(doc, &c, lang),
|
||||
_ => match lang {
|
||||
CodeLang::React => react(doc, &Component { comp: id, set: None, type_name: component_type_name(&n.name), params: Vec::new() }),
|
||||
CodeLang::React | CodeLang::Leptos => {
|
||||
let c = Component { comp: id, set: None, type_name: component_type_name(&n.name), params: Vec::new() };
|
||||
if lang == CodeLang::Leptos { leptos(doc, &c) } else { react(doc, &c) }
|
||||
}
|
||||
CodeLang::SwiftUi => {
|
||||
let mut out = String::new();
|
||||
swift(&Ctx::new(doc, id), id, 0, &mut out, &[]);
|
||||
@@ -358,6 +361,52 @@ fn ident(name: &str, used: &mut Vec<String>) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// Rust's keywords, and `children` (Leptos passes a component's children by that name).
|
||||
const RUST_RESERVED: &[&str] = &[
|
||||
"abstract", "as", "async", "await", "become", "box", "break", "children", "const", "continue", "crate", "do", "dyn", "else", "enum", "extern",
|
||||
"false", "final", "fn", "for", "gen", "if", "impl", "in", "let", "loop", "macro", "match", "mod", "move", "mut", "override", "priv", "pub",
|
||||
"ref", "return", "self", "static", "struct", "super", "trait", "true", "try", "type", "typeof", "unsafe", "unsized", "use", "virtual", "where",
|
||||
"while", "yield",
|
||||
];
|
||||
|
||||
/// A parameter's Rust name: its camelCase name in snake_case (`showIcon` → `show_icon`), with a
|
||||
/// trailing `_` when Rust reserves it (`type_`; camelCase names have no `_`, so it stays unique).
|
||||
pub(crate) fn rust_ident(ident: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for (i, ch) in ident.chars().enumerate() {
|
||||
if ch.is_ascii_uppercase() {
|
||||
if i > 0 {
|
||||
out.push('_');
|
||||
}
|
||||
out.push(ch.to_ascii_lowercase());
|
||||
} else {
|
||||
out.push(ch);
|
||||
}
|
||||
}
|
||||
if RUST_RESERVED.contains(&out.as_str()) {
|
||||
out.push('_');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A Rust type name: `Self` is taken.
|
||||
fn rust_type(t: &str) -> String {
|
||||
if t == "Self" { "SelfComponent".into() } else { t.to_string() }
|
||||
}
|
||||
|
||||
/// A Leptos variant enum's name: the component's name and the property's, never the props struct
|
||||
/// `#[component]` makes (`ButtonProps`).
|
||||
fn leptos_enum(c: &Component, p: &Param) -> String {
|
||||
let t = rust_type(&c.type_name);
|
||||
let name = format!("{t}{}", p.enum_name);
|
||||
if name == format!("{t}Props") { format!("{name}Variant") } else { name }
|
||||
}
|
||||
|
||||
/// Rust enum variants for variant values: [`cases`] in PascalCase, `Self` taken.
|
||||
fn rust_cases(values: &[String]) -> Vec<String> {
|
||||
cases(values, false).into_iter().map(|c| if c == "Self" { "SelfValue".into() } else { c }).collect()
|
||||
}
|
||||
|
||||
/// Enum case names for variant values, unique: Swift's lowerCamel (`.small`, `` .`default` ``),
|
||||
/// Kotlin's PascalCase (`Small`).
|
||||
fn cases(values: &[String], swift: bool) -> Vec<String> {
|
||||
@@ -383,6 +432,11 @@ fn cases(values: &[String], swift: bool) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A Rust string literal.
|
||||
pub(crate) fn rust_string(s: &str) -> String {
|
||||
format!("{:?}", s.chars().take(10_000).collect::<String>())
|
||||
}
|
||||
|
||||
/// A JavaScript string literal.
|
||||
pub(crate) fn js_string(s: &str) -> String {
|
||||
let s: String = s.chars().take(10_000).collect();
|
||||
@@ -405,6 +459,11 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value
|
||||
match (&p.kind, lang) {
|
||||
(ParamKind::Boolean, _) => value.as_bool().unwrap_or(false).to_string(),
|
||||
(ParamKind::Text, CodeLang::React) => js_string(value.as_str().unwrap_or("")),
|
||||
(ParamKind::Text, CodeLang::Leptos) => rust_string(value.as_str().unwrap_or("")),
|
||||
(ParamKind::Variant(opts), CodeLang::Leptos) => {
|
||||
let i = opts.iter().position(|o| Some(o.as_str()) == value.as_str()).unwrap_or(0);
|
||||
format!("{}::{}", leptos_enum(c, p), rust_cases(opts).get(i).cloned().unwrap_or_else(|| "Unknown".into()))
|
||||
}
|
||||
(ParamKind::Text, l) => literal(value.as_str().unwrap_or(""), l == CodeLang::Compose),
|
||||
(ParamKind::Variant(opts), l) => {
|
||||
let v = value.as_str().unwrap_or("");
|
||||
@@ -420,6 +479,8 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value
|
||||
let t = id_of(value).and_then(|i| component_type(doc, i));
|
||||
match (t, l) {
|
||||
(Some(t), CodeLang::React) => format!("<{t} />"),
|
||||
(Some(t), CodeLang::Leptos) => format!("|| view! {{ <{} /> }}", rust_type(&t)),
|
||||
(None, CodeLang::Leptos) => "|| ()".into(),
|
||||
(Some(t), CodeLang::SwiftUi) => format!("AnyView({t}())"),
|
||||
(Some(t), _) => format!("{{ {t}() }}"),
|
||||
(None, CodeLang::React) => "null".into(),
|
||||
@@ -486,10 +547,17 @@ pub fn usage(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option
|
||||
}
|
||||
let t = &c.type_name;
|
||||
Some(match lang {
|
||||
CodeLang::React => {
|
||||
CodeLang::React | CodeLang::Leptos => {
|
||||
let leptos = lang == CodeLang::Leptos;
|
||||
// Plain strings read best as attribute values.
|
||||
let attrs: String =
|
||||
args.iter().map(|(name, expr, plain)| if *plain { format!(" {name}={expr}") } else { format!(" {name}={{{expr}}}") }).collect();
|
||||
let attrs: String = args
|
||||
.iter()
|
||||
.map(|(name, expr, plain)| {
|
||||
let name = if leptos { rust_ident(name) } else { name.to_string() };
|
||||
if *plain { format!(" {name}={expr}") } else { format!(" {name}={{{expr}}}") }
|
||||
})
|
||||
.collect();
|
||||
let t = if leptos { rust_type(t) } else { t.clone() };
|
||||
format!("{pad}<{t}{attrs} />\n")
|
||||
}
|
||||
CodeLang::SwiftUi => format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::<Vec<_>>().join(", ")),
|
||||
@@ -504,13 +572,20 @@ fn slot_content(doc: &Document, slot: NodeId, lang: CodeLang, depth: usize) -> S
|
||||
let kids = ctx.kids(n);
|
||||
match lang {
|
||||
CodeLang::React => {
|
||||
let mut m = Markup::new(&ctx, true);
|
||||
let mut m = Markup::new(&ctx, CodeLang::React);
|
||||
for k in kids {
|
||||
m.node(k, Some(n), depth + 2, "");
|
||||
}
|
||||
let (p1, p2) = (" ".repeat(depth), " ".repeat(depth + 1));
|
||||
format!("(\n{p2}<>\n{}{p2}</>\n{p1})", m.markup)
|
||||
}
|
||||
CodeLang::Leptos => {
|
||||
let mut m = Markup::new(&ctx, CodeLang::Leptos);
|
||||
for k in kids {
|
||||
m.node(k, Some(n), depth + 1, "");
|
||||
}
|
||||
format!("|| view! {{\n{}{}}}", m.markup, indent(depth))
|
||||
}
|
||||
CodeLang::SwiftUi => {
|
||||
let mut out = String::new();
|
||||
for k in kids {
|
||||
@@ -535,6 +610,7 @@ pub fn definition(doc: &Document, c: &Component, lang: CodeLang) -> String {
|
||||
match lang {
|
||||
CodeLang::SwiftUi => swift_definition(doc, c),
|
||||
CodeLang::Compose => compose_definition(doc, c),
|
||||
CodeLang::Leptos => leptos(doc, c),
|
||||
_ => react(doc, c),
|
||||
}
|
||||
}
|
||||
@@ -545,23 +621,29 @@ fn data_attr(p: &Param) -> String {
|
||||
format!("data-{}", class_name(&p.prop, &mut used))
|
||||
}
|
||||
|
||||
/// A TSX function component with its props interface, and its stylesheet (variables as custom
|
||||
/// properties, other variants as rules on their `data-*` attributes).
|
||||
fn react(doc: &Document, c: &Component) -> String {
|
||||
/// `c`'s markup in React or Leptos (two levels in) and its stylesheet (variables as custom
|
||||
/// properties, other variants as rules on their `data-*` attributes): (root class, markup, CSS).
|
||||
fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String) {
|
||||
let ctx = Ctx { doc, root: c.comp, comp: Some(c), base: 2 };
|
||||
let root_name = c.set.and_then(|s| doc.node(s)).or_else(|| doc.node(c.comp)).map(|n| n.name.clone()).unwrap_or_default();
|
||||
let root_class = class_name(&root_name, &mut Vec::new());
|
||||
let variants: Vec<&Param> = c.params.iter().filter(|p| matches!(p.kind, ParamKind::Variant(_))).collect();
|
||||
let pass = |root: NodeId| {
|
||||
let vctx = Ctx { doc, root, comp: Some(c), base: 2 };
|
||||
let mut m = Markup::new(&vctx, true);
|
||||
let mut m = Markup::new(&vctx, lang);
|
||||
m.root_class = Some(root_class.clone());
|
||||
m.node(root, None, 2, "");
|
||||
m.rules
|
||||
};
|
||||
let mut m = Markup::new(&ctx, true);
|
||||
let mut m = Markup::new(&ctx, lang);
|
||||
m.root_class = Some(root_class.clone());
|
||||
m.root_attrs = variants.iter().map(|p| format!(" {}={{{}}}", data_attr(p), p.ident)).collect();
|
||||
m.root_attrs = variants
|
||||
.iter()
|
||||
.map(|p| match lang {
|
||||
CodeLang::Leptos => format!(" {}={{{}.as_str()}}", data_attr(p), rust_ident(&p.ident)),
|
||||
_ => format!(" {}={{{}}}", data_attr(p), p.ident),
|
||||
})
|
||||
.collect();
|
||||
m.node(c.comp, None, 2, "");
|
||||
// Other variants: what differs from this one, layer by layer.
|
||||
let mut variant_rules = String::new();
|
||||
@@ -584,6 +666,13 @@ fn react(doc: &Document, c: &Component) -> String {
|
||||
variant_rules.push_str(&format!("{sel} {{\n{}}}\n", diff.iter().map(|l| format!(" {l}\n")).collect::<String>()));
|
||||
}
|
||||
}
|
||||
let css = format!("{}{}{variant_rules}", m.tokens.root_rule(), m.styles);
|
||||
(root_class, m.markup, css)
|
||||
}
|
||||
|
||||
/// A TSX function component with its props interface, and its stylesheet.
|
||||
fn react(doc: &Document, c: &Component) -> String {
|
||||
let (root_class, markup, css) = web(doc, c, CodeLang::React);
|
||||
let t = &c.type_name;
|
||||
let mut out = format!("import \"./{root_class}.css\";\n\n");
|
||||
if c.params.is_empty() {
|
||||
@@ -610,7 +699,46 @@ fn react(doc: &Document, c: &Component) -> String {
|
||||
.collect();
|
||||
out.push_str(&format!("export function {t}({{\n{}}}: {t}Props) {{\n", args.iter().map(|a| format!(" {a},\n")).collect::<String>()));
|
||||
}
|
||||
out.push_str(&format!(" return (\n{} );\n}}\n\n/* {root_class}.css */\n{}{}{variant_rules}", m.markup, m.tokens.root_rule(), m.styles));
|
||||
out.push_str(&format!(" return (\n{markup} );\n}}\n\n/* {root_class}.css */\n{css}"));
|
||||
out
|
||||
}
|
||||
|
||||
/// A Leptos `#[component]` with its variant enums and props (defaults as `#[prop]` attributes),
|
||||
/// and its stylesheet.
|
||||
fn leptos(doc: &Document, c: &Component) -> String {
|
||||
let (root_class, markup, css) = web(doc, c, CodeLang::Leptos);
|
||||
let mut out = String::from("use leptos::prelude::*;\n\n");
|
||||
for p in &c.params {
|
||||
let ParamKind::Variant(opts) = &p.kind else { continue };
|
||||
let (e, cases) = (leptos_enum(c, p), rust_cases(opts));
|
||||
let default = opts.iter().position(|o| Some(o.as_str()) == p.default.as_str()).unwrap_or(0);
|
||||
out.push_str(&format!("#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]\npub enum {e} {{\n"));
|
||||
for (i, case) in cases.iter().enumerate() {
|
||||
out.push_str(&format!("{} {case},\n", if i == default { " #[default]\n" } else { "" }));
|
||||
}
|
||||
out.push_str(&format!("}}\n\nimpl {e} {{\n /// The variant's name, as its `data-*` attribute.\n pub fn as_str(self) -> &'static str {{\n match self {{\n"));
|
||||
for (o, case) in opts.iter().zip(&cases) {
|
||||
out.push_str(&format!(" Self::{case} => {},\n", rust_string(o)));
|
||||
}
|
||||
out.push_str(" }\n }\n}\n\n");
|
||||
}
|
||||
let t = rust_type(&c.type_name);
|
||||
out.push_str(&format!("#[component]\npub fn {t}("));
|
||||
if !c.params.is_empty() {
|
||||
out.push('\n');
|
||||
for p in &c.params {
|
||||
let default = value_expr(doc, c, p, &p.default, CodeLang::Leptos);
|
||||
let (attr, ty) = match &p.kind {
|
||||
ParamKind::Variant(_) => ("optional".to_string(), leptos_enum(c, p)),
|
||||
ParamKind::Boolean => (format!("default = {default}"), "bool".into()),
|
||||
ParamKind::Text => (format!("into, default = {default}.to_string()"), "String".into()),
|
||||
ParamKind::InstanceSwap => (format!("into, default = ({default}).into()"), "ViewFn".into()),
|
||||
ParamKind::Slot => ("optional, into".to_string(), "Option<ViewFn>".into()),
|
||||
};
|
||||
out.push_str(&format!(" #[prop({attr})] {}: {ty},\n", rust_ident(&p.ident)));
|
||||
}
|
||||
}
|
||||
out.push_str(&format!(") -> impl IntoView {{\n view! {{\n{markup} }}\n}}\n\n/* {root_class}.css */\n{css}"));
|
||||
out
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Component code: components become React / SwiftUI / Compose components whose properties are
|
||||
//! parameters, and instances become calls passing the values they set.
|
||||
//! Component code: components become React / Leptos / SwiftUI / Compose components whose
|
||||
//! properties are parameters, and instances become calls passing the values they set.
|
||||
|
||||
use crate::Session;
|
||||
use crate::codegen::{CodeLang, code_for};
|
||||
@@ -71,6 +71,18 @@ fn components_take_their_properties_as_parameters() {
|
||||
for want in ["@Composable\nfun Card(", " showDot: Boolean = true,", " title: String = \"Hello\",", "if (showDot) {", "text = title,"] {
|
||||
assert!(kt.contains(want), "Compose has {want:?}:\n{kt}");
|
||||
}
|
||||
let leptos = code(&mut s, c, "leptos");
|
||||
for want in [
|
||||
"use leptos::prelude::*;\n\n#[component]\npub fn Card(\n",
|
||||
" #[prop(default = true)] show_dot: bool,\n",
|
||||
" #[prop(into, default = \"Hello\".to_string())] title: String,\n",
|
||||
") -> impl IntoView {\n view! {\n",
|
||||
"{show_dot.then(|| view! {",
|
||||
">{title.clone()}</p>",
|
||||
"/* card.css */",
|
||||
] {
|
||||
assert!(leptos.contains(want), "Leptos has {want:?}:\n{leptos}");
|
||||
}
|
||||
// The command describes the properties.
|
||||
let v = component_code(&mut s, c, "react");
|
||||
assert_eq!(v["name"], "Card");
|
||||
@@ -105,11 +117,13 @@ fn instances_are_calls_passing_the_values_they_set() {
|
||||
assert!(react.contains("export function Card({"));
|
||||
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Card(showDot: false, title: \"Buy\")\n");
|
||||
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Buy\")\n");
|
||||
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "<Card show_dot={false} title=\"Buy\" />\n");
|
||||
// Text that needs escaping is an expression in JSX and escaped per language.
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Say \"hi\" $5\nnow"}}));
|
||||
assert_eq!(component_code(&mut s, i, "react")["usage"], "<Card showDot={false} title={\"Say \\\"hi\\\" $5\\nnow\"} />\n");
|
||||
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Card(showDot: false, title: \"Say \\\"hi\\\" $5\\nnow\")\n");
|
||||
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Say \\\"hi\\\" \\$5\\nnow\")\n");
|
||||
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "<Card show_dot={false} title={\"Say \\\"hi\\\" $5\\nnow\"} />\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -125,6 +139,9 @@ fn nested_instances_in_a_layer_tree_are_calls() {
|
||||
assert!(!react.contains("Hello"), "the instance's layers aren't spelled out: {react}");
|
||||
assert!(code(&mut s, screen, "swiftui").contains("Card(title: \"Inbox\")"));
|
||||
assert!(code(&mut s, screen, "compose").contains("Card(title = \"Inbox\")"));
|
||||
let leptos = code(&mut s, screen, "leptos");
|
||||
assert!(leptos.contains("pub fn HomeScreen() -> impl IntoView {") && leptos.contains("<Card title=\"Inbox\" />"), "{leptos}");
|
||||
assert!(!leptos.contains("Hello"), "{leptos}");
|
||||
// HTML and CSS stay plain markup.
|
||||
assert!(code(&mut s, screen, "html").contains("Inbox"));
|
||||
}
|
||||
@@ -156,12 +173,23 @@ fn variant_sets_share_one_definition() {
|
||||
let kt = code(&mut s, set, "compose");
|
||||
assert!(kt.contains("enum class ButtonState { Default, Hover }"), "{kt}");
|
||||
assert!(kt.contains(" state: ButtonState = ButtonState.Default,"), "{kt}");
|
||||
let leptos = code(&mut s, set, "leptos");
|
||||
for want in [
|
||||
"#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]\npub enum ButtonState {\n #[default]\n Default,\n Hover,\n}",
|
||||
" Self::Hover => \"Hover\",",
|
||||
" #[prop(optional)] state: ButtonState,",
|
||||
"data-state={state.as_str()}",
|
||||
".button[data-state=\"Hover\"] .bg {\n background: #0000FF;",
|
||||
] {
|
||||
assert!(leptos.contains(want), "Leptos has {want:?}:\n{leptos}");
|
||||
}
|
||||
// An instance showing the hover variant passes it.
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 600})));
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"State": "Hover"}}));
|
||||
assert_eq!(component_code(&mut s, i, "react")["usage"], "<Button state=\"Hover\" />\n");
|
||||
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Button(state: .hover)\n");
|
||||
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Button(state = ButtonState.Hover)\n");
|
||||
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "<Button state=ButtonState::Hover />\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -184,11 +212,15 @@ fn instance_swap_properties_take_views() {
|
||||
assert!(swift.contains(" var icon: AnyView = AnyView(HeartIcon())"), "{swift}");
|
||||
let kt = code(&mut s, chip, "compose");
|
||||
assert!(kt.contains(" icon: @Composable () -> Unit = { HeartIcon() },") && kt.contains("icon()"), "{kt}");
|
||||
let leptos = code(&mut s, chip, "leptos");
|
||||
assert!(leptos.contains(" #[prop(into, default = (|| view! { <HeartIcon /> }).into())] icon: ViewFn,"), "{leptos}");
|
||||
assert!(leptos.contains("{icon.run()}"), "{leptos}");
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": chip, "x": 0, "y": 100})));
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Icon": star}}));
|
||||
assert_eq!(component_code(&mut s, i, "react")["usage"], "<Chip icon={<StarIcon />} />\n");
|
||||
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Chip(icon: AnyView(StarIcon()))\n");
|
||||
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Chip(icon = { StarIcon() })\n");
|
||||
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "<Chip icon={|| view! { <StarIcon /> }} />\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -203,6 +235,9 @@ fn slots_take_optional_content() {
|
||||
assert!(react.contains(" content?: React.ReactNode;") && react.contains("{content ?? ("), "{react}");
|
||||
assert!(code(&mut s, panel, "swiftui").contains("var content: AnyView? = nil"));
|
||||
assert!(code(&mut s, panel, "compose").contains("content: (@Composable () -> Unit)? = null,"));
|
||||
let leptos = code(&mut s, panel, "leptos");
|
||||
assert!(leptos.contains(" #[prop(optional, into)] content: Option<ViewFn>,"), "{leptos}");
|
||||
assert!(leptos.contains("{match &content { Some(c) => c.run(), None => view! {\n") && leptos.contains("}.into_any() }}\n"), "{leptos}");
|
||||
// An instance filling the slot passes its content.
|
||||
let g = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 400, "width": 40, "height": 20, "name": "Badge"})));
|
||||
let badge = idv(&ex(&mut s, "component.create", json!({"ids": [g]})));
|
||||
@@ -211,6 +246,8 @@ fn slots_take_optional_content() {
|
||||
ex(&mut s, "instance.slotInsert", json!({"id": i, "slot": "Content", "component": badge, "replace": true}));
|
||||
let usage = component_code(&mut s, i, "react")["usage"].as_str().unwrap_or_default().to_string();
|
||||
assert!(usage.starts_with("<Panel content={(\n") && usage.contains("<Badge />"), "{usage}");
|
||||
let usage = component_code(&mut s, i, "leptos")["usage"].as_str().unwrap_or_default().to_string();
|
||||
assert!(usage.starts_with("<Panel content={|| view! {\n") && usage.contains("<Badge />") && usage.ends_with("}} />\n"), "{usage}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -225,6 +262,8 @@ fn identifiers_are_escaped_and_unique() {
|
||||
("label", "text", json!("b")),
|
||||
("2 columns", "boolean", json!(false)),
|
||||
("Color", "variant", json!("Red")),
|
||||
("type", "text", json!("c")),
|
||||
("Props", "variant", json!("Self")),
|
||||
] {
|
||||
ex(&mut s, "component.addProperty", json!({"id": c, "name": name, "type": ty, "default": default}));
|
||||
}
|
||||
@@ -242,6 +281,20 @@ fn identifiers_are_escaped_and_unique() {
|
||||
assert_eq!(color.as_deref(), Some("ColorVariant"), "never shadows SwiftUI's Color");
|
||||
let swift = code_for(doc, c, CodeLang::SwiftUi);
|
||||
assert!(swift.contains("struct TextComponent: View {") && swift.contains("enum ColorVariant: String, CaseIterable {"), "{swift}");
|
||||
// Rust: snake_case, keywords suffixed, never the props struct's name or `Self`.
|
||||
let leptos = code_for(doc, c, CodeLang::Leptos);
|
||||
for want in [
|
||||
"pub fn TextComponent(",
|
||||
"] default_value: bool,",
|
||||
"] show_icon: bool,",
|
||||
"] p2_columns: bool,",
|
||||
"] type_: String,",
|
||||
"pub enum TextComponentPropsVariant {",
|
||||
" SelfValue,",
|
||||
"Self::SelfValue => \"Self\",",
|
||||
] {
|
||||
assert!(leptos.contains(want), "Leptos has {want:?}:\n{leptos}");
|
||||
}
|
||||
// Errors.
|
||||
let r = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})));
|
||||
assert!(s.execute("inspect.componentCode", &json!({"id": r})).is_err(), "not a component");
|
||||
|
||||
@@ -58,6 +58,9 @@ fn close(a: f64, b: f64) -> bool {
|
||||
|
||||
/// A square (100,100)–(200,200), selected, and the text "Hello" at (400, 100), not selected.
|
||||
fn scene() -> (Session, NodeId, NodeId) {
|
||||
// The text is measured with a font: without craft-fonts that is a system one, scanned here
|
||||
// rather than by whichever test happens to run first (#333).
|
||||
figcraft_text::scan_system_fonts();
|
||||
let mut s = Session::new();
|
||||
let r = id(&ex(&mut s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})));
|
||||
let t = id(&ex(&mut s, "text.create", json!({"text": "Hello", "x": 400, "y": 100, "props": {"fontSize": 40}})));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Text made by commands in the app (the interface reported its theme) is readable on its page
|
||||
//! like text drawn with the Text tool: white on a dark page unless a fill is named. A session
|
||||
//! without an interface (CLI, MCP, exports) keeps the model's black everywhere.
|
||||
//! Text made by commands in the app (the interface reported its theme) is readable where it is
|
||||
//! drawn, like text drawn with the Text tool: white on a dark page or a dark frame unless a fill is
|
||||
//! named. A session without an interface (CLI, MCP, exports) keeps the model's black everywhere.
|
||||
|
||||
use figcraft_color::{Color, Paint};
|
||||
use figcraft_doc::NodeId;
|
||||
@@ -62,12 +62,51 @@ fn text_made_by_commands_is_readable_on_its_page() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_in_a_frame_follows_the_page_it_is_on() {
|
||||
// As with the Text tool: the page decides, also inside a frame; made in one undo step.
|
||||
fn text_in_a_frame_is_readable_on_the_frame() {
|
||||
// #362: what the text is drawn on decides: the nearest layer around it with a solid fill (a
|
||||
// new frame's white too), else its page; made in one undo step.
|
||||
let (white, black) = (vec![Paint::solid(Color::WHITE)], vec![Paint::solid(Color::BLACK)]);
|
||||
let mut s = session(Some(true));
|
||||
let frame: NodeId = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100}))["id"]).unwrap();
|
||||
let t = ex(&mut s, "text.create", json!({"text": "Hi", "x": 10, "y": 10, "parent": frame}));
|
||||
assert_eq!(fills(&s, &t), vec![Paint::solid(Color::WHITE)]);
|
||||
let frame = |s: &mut Session, x: i64, parent: Option<NodeId>, fills: Option<Value>| -> NodeId {
|
||||
let mut p = json!({"x": x, "y": 0, "width": 200, "height": 100});
|
||||
if let Some(parent) = parent {
|
||||
p["parent"] = json!(parent);
|
||||
}
|
||||
let f = id_of(&ex(s, "frame.create", p)["id"]).unwrap();
|
||||
if let Some(fills) = fills {
|
||||
ex(s, "node.set", json!({"id": f, "props": {"fills": fills}}));
|
||||
}
|
||||
f
|
||||
};
|
||||
let light = frame(&mut s, 0, None, None);
|
||||
let dark = frame(&mut s, 300, None, Some(json!([{"type": "solid", "color": "#202020"}])));
|
||||
let bare = frame(&mut s, 600, None, Some(json!([])));
|
||||
let inner = frame(&mut s, 0, Some(light), Some(json!([])));
|
||||
let faint = frame(&mut s, 900, None, Some(json!([{"type": "solid", "color": "#FFFFFF", "opacity": 0.2}])));
|
||||
let stacked = frame(&mut s, 1200, None, Some(json!([{"type": "solid", "color": "#202020"}, {"type": "solid", "color": "#FFFFFF"}])));
|
||||
let cases = [
|
||||
(light, &black, "a white frame on a dark page: black text"),
|
||||
(dark, &white, "a dark frame: white"),
|
||||
(bare, &white, "a frame without a fill: the page decides"),
|
||||
(inner, &black, "a frame without a fill in a white one: the white one"),
|
||||
(faint, &white, "a faint fill lets the page show through: the page decides"),
|
||||
(stacked, &black, "the fill on top counts"),
|
||||
];
|
||||
for (parent, want, why) in cases {
|
||||
let t = ex(&mut s, "text.create", json!({"text": "Hi", "x": 10, "y": 10, "parent": parent}));
|
||||
assert_eq!(&fills(&s, &t), want, "{why}");
|
||||
}
|
||||
// The Text tool reads it the same way.
|
||||
use crate::tools::{PointerEvent, PointerKind, ToolId};
|
||||
let at = s.doc().unwrap().world_bounds(light).unwrap().center();
|
||||
s.set_tool(ToolId::Text);
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::at(kind, at.x, at.y, 1.0)).unwrap();
|
||||
}
|
||||
let typed = s.ds().unwrap().text_edit.as_ref().unwrap().id;
|
||||
assert_eq!(s.doc().unwrap().node(typed).unwrap().fills, black, "the Text tool in a white frame");
|
||||
crate::textedit::end(&mut s).unwrap();
|
||||
let t = ex(&mut s, "text.create", json!({"text": "Hi", "x": 10, "y": 10, "parent": light}));
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc().unwrap().node(id_of(&t["id"]).unwrap()).is_none());
|
||||
}
|
||||
@@ -15,6 +15,9 @@ fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
|
||||
/// A frame holding a group of a rectangle and a text layer at (60, 60); returns (group, text).
|
||||
fn scene(s: &mut Session) -> (NodeId, NodeId) {
|
||||
// The text is measured with a font: without craft-fonts that is a system one, scanned here
|
||||
// rather than by whichever test happens to run first (#333).
|
||||
figcraft_text::scan_system_fonts();
|
||||
let f = id_of(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 300}))["id"]).unwrap();
|
||||
let r = id_of(&ex(s, "shape.rectangle", json!({"x": 40, "y": 40, "width": 200, "height": 60, "parent": f}))["id"]).unwrap();
|
||||
let t = id_of(&ex(s, "text.create", json!({"text": "Hi there", "x": 60, "y": 60, "parent": f, "props": {"fontSize": 16}}))["id"]).unwrap();
|
||||
@@ -74,6 +77,26 @@ fn double_clicks_reach_and_edit_text_in_a_group_wherever_they_land() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_text_keeps_other_document_changes_since_open() {
|
||||
let mut s = Session::new();
|
||||
let first = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 80, "height": 40}))["id"]).unwrap();
|
||||
let deleted = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 120, "y": 20, "width": 80, "height": 40}))["id"]).unwrap();
|
||||
let text = id_of(&ex(&mut s, "text.create", json!({"text": "A", "x": 20, "y": 100}))["id"]).unwrap();
|
||||
ex(&mut s, "node.set", json!({"ids": [first], "props": {"x": 650}}));
|
||||
ex(&mut s, "edit.delete", json!({"ids": [deleted]}));
|
||||
|
||||
let bounds = s.doc().unwrap().world_bounds(text).unwrap();
|
||||
double_click(&mut s, bounds.center().x, bounds.center().y, 0.0, 1.0);
|
||||
assert_eq!(editing(&s), Some(text));
|
||||
crate::textedit::insert(&mut s, "B").unwrap();
|
||||
crate::textedit::end(&mut s).unwrap();
|
||||
|
||||
assert_eq!(s.doc().unwrap().world_bounds(first).unwrap().x0, 650.0);
|
||||
assert!(!s.doc().unwrap().contains(deleted));
|
||||
assert_eq!(s.doc().unwrap().node(text).unwrap().kind.text().unwrap().characters, "B");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_press_drags_only_past_the_threshold_in_screen_pixels() {
|
||||
for (zoom, by, moves) in [(1.0, 2.0, false), (1.0, 4.0, true), (4.0, 0.5, false), (4.0, 1.0, true), (0.25, 8.0, false), (0.25, 16.0, true)] {
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Duplicating at a spacing: a copy Alt-dragged off a layer snaps to that layer too (its edges,
|
||||
//! centres and equal spacing), and Duplicate repeats the last duplicate's offset while its copies
|
||||
//! are selected, so Alt-drag (or Duplicate and move) then Cmd+D steps out evenly spaced copies.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
use crate::tools::{PointerEvent, PointerKind};
|
||||
use figcraft_doc::NodeId;
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64, y: f64) -> NodeId {
|
||||
id_of(&ex(s, "shape.rectangle", json!({"x": x, "y": y, "width": 100, "height": 100}))["id"]).unwrap()
|
||||
}
|
||||
|
||||
/// Alt-drag from `from` to `to`: returns the copy (the selection afterwards).
|
||||
fn alt_drag(s: &mut Session, from: (f64, f64), to: (f64, f64)) -> NodeId {
|
||||
let ev = |kind, (x, y): (f64, f64)| PointerEvent { alt: true, ..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 + (to.0 - from.0) * t, from.1 + (to.1 - from.1) * t))).unwrap();
|
||||
}
|
||||
s.pointer(&ev(PointerKind::Up, to)).unwrap();
|
||||
let sel = s.selection();
|
||||
assert_eq!(sel.len(), 1, "one copy selected");
|
||||
sel[0]
|
||||
}
|
||||
|
||||
/// Duplicate the selection: the copy.
|
||||
fn cmd_d(s: &mut Session) -> NodeId {
|
||||
let out = ex(s, "edit.duplicate", json!({}));
|
||||
id_of(&out["ids"][0]).unwrap()
|
||||
}
|
||||
|
||||
fn at(s: &Session, id: NodeId) -> (f64, f64) {
|
||||
let b = s.doc().unwrap().world_bounds(id).unwrap();
|
||||
(b.x0, b.y0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_alt_dragged_copy_snaps_to_the_layer_it_came_from() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0.0, 0.0);
|
||||
// 2 px below the original's top edge: within the snap distance.
|
||||
let copy = alt_drag(&mut s, (50.0, 50.0), (250.0, 52.0));
|
||||
assert_ne!(copy, a);
|
||||
assert_eq!(at(&s, copy), (200.0, 0.0), "the copy's top lines up with the original's");
|
||||
assert_eq!(at(&s, a), (0.0, 0.0), "the original stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_alt_dragged_copy_spaces_equally_with_the_layer_it_came_from() {
|
||||
let mut s = Session::new();
|
||||
rect(&mut s, 0.0, 0.0);
|
||||
rect(&mut s, 200.0, 0.0);
|
||||
// B's copy dropped 2 px short of the 100 gap that A and B have.
|
||||
let copy = alt_drag(&mut s, (250.0, 50.0), (448.0, 50.0));
|
||||
assert_eq!(at(&s, copy), (400.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_repeats_the_step_set_by_moving_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");
|
||||
ex(&mut s, "node.set", json!({"ids": [c1], "props": {"x": 150, "y": 20}}));
|
||||
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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_after_an_alt_drag_repeats_its_step() {
|
||||
let mut s = Session::new();
|
||||
rect(&mut s, 0.0, 0.0);
|
||||
let copy = alt_drag(&mut s, (50.0, 50.0), (180.0, 50.0));
|
||||
assert_eq!(at(&s, copy), (130.0, 0.0));
|
||||
let next = cmd_d(&mut s);
|
||||
assert_eq!(at(&s, next), (260.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_only_repeats_for_the_last_copies() {
|
||||
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, "node.set", json!({"ids": [c1], "props": {"x": 150}}));
|
||||
// Another selection forgets it: the original duplicates in place.
|
||||
ex(&mut s, "select.set", json!({"ids": [a]}));
|
||||
ex(&mut s, "select.set", json!({"ids": [c1]}));
|
||||
let c2 = cmd_d(&mut s);
|
||||
assert_eq!(at(&s, c2), (150.0, 0.0), "the step was forgotten when the selection changed");
|
||||
// An offset given explicitly wins over a remembered one.
|
||||
ex(&mut s, "node.set", json!({"ids": [c2], "props": {"x": 300}}));
|
||||
let out = ex(&mut s, "edit.duplicate", json!({"dx": 10}));
|
||||
let c3 = id_of(&out["ids"][0]).unwrap();
|
||||
assert_eq!(at(&s, c3), (310.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_doesnt_let_a_new_layer_take_a_remembered_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, "node.set", json!({"ids": [c1], "props": {"x": 150}}));
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert!(!s.doc().unwrap().contains(c1));
|
||||
// Undo gives ids back: the new rectangle may well get the undone copy's.
|
||||
let r = rect(&mut s, 500.0, 500.0);
|
||||
ex(&mut s, "select.set", json!({"ids": [r]}));
|
||||
let c = cmd_d(&mut s);
|
||||
assert_eq!(at(&s, c), (500.0, 500.0), "a plain duplicate, in place");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_step_is_measured_in_page_space_inside_a_rotated_frame() {
|
||||
let mut s = Session::new();
|
||||
let f = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 600, "height": 600}))["id"]).unwrap();
|
||||
ex(&mut s, "node.set", json!({"ids": [f], "props": {"rotation": 30}}));
|
||||
let a = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 50, "y": 50, "width": 40, "height": 40, "parent": f}))["id"]).unwrap();
|
||||
ex(&mut s, "select.set", json!({"ids": [a]}));
|
||||
let c1 = cmd_d(&mut s);
|
||||
ex(&mut s, "node.set", json!({"ids": [c1], "props": {"x": 150}}));
|
||||
let (p0, p1) = (at(&s, a), at(&s, c1));
|
||||
let c2 = cmd_d(&mut s);
|
||||
let p2 = at(&s, c2);
|
||||
let close = |a: f64, b: f64| (a - b).abs() < 1e-6;
|
||||
assert!(close(p2.0 - p1.0, p1.0 - p0.0) && close(p2.1 - p1.1, p1.1 - p0.1), "{p0:?} {p1:?} {p2:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_alt_drag_into_auto_layout_sets_no_step() {
|
||||
let mut s = Session::new();
|
||||
let f = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 100}))["id"]).unwrap();
|
||||
for i in 0..2 {
|
||||
ex(&mut s, "shape.rectangle", json!({"x": i * 60, "y": 0, "width": 50, "height": 30, "parent": f}));
|
||||
}
|
||||
ex(&mut s, "layout.add", json!({"ids": [f]}));
|
||||
ex(&mut s, "select.none", json!({}));
|
||||
let kids = s.doc().unwrap().node(f).unwrap().children.clone();
|
||||
let b = s.doc().unwrap().world_bounds(kids[0]).unwrap();
|
||||
let from = (b.center().x, b.center().y);
|
||||
alt_drag(&mut s, from, (from.0 + 70.0, from.1));
|
||||
assert!(s.ds().unwrap().last_duplicate.is_none(), "the layout placed the copy: no step to repeat");
|
||||
let n = s.doc().unwrap().node(f).unwrap().children.len();
|
||||
cmd_d(&mut s);
|
||||
assert_eq!(s.doc().unwrap().node(f).unwrap().children.len(), n + 1, "Duplicate still adds one to the flow");
|
||||
}
|
||||
@@ -85,3 +85,24 @@ fn inspect_and_css_show_the_new_effects() {
|
||||
let css = code_for(doc, r, CodeLang::Css);
|
||||
assert!(css.contains("backdrop-filter: blur(2px);"), "{css}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_or_fractional_index_is_refused_not_item_zero() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s);
|
||||
s.execute("effect.add", &json!({"ids": [r], "type": "layerBlur"})).unwrap();
|
||||
s.execute("export.add", &json!({"ids": [r]})).unwrap();
|
||||
s.execute("export.add", &json!({"ids": [r]})).unwrap();
|
||||
let before = s.doc().unwrap().node(r).unwrap().clone();
|
||||
assert_eq!(before.exports.len(), 2);
|
||||
for index in [json!(-1), json!(-0.5), json!(1.5)] {
|
||||
let set = json!({"ids": [r], "index": index, "effect": {"type": "dropShadow"}});
|
||||
assert!(s.execute("effect.set", &set).is_err(), "effect.set index {index}");
|
||||
let upd = json!({"ids": [r], "index": index, "format": "svg"});
|
||||
assert!(s.execute("export.update", &upd).is_err(), "export.update index {index}");
|
||||
assert!(s.execute("export.remove", &json!({"ids": [r], "index": index})).is_err(), "export.remove index {index}");
|
||||
let n = s.doc().unwrap().node(r).unwrap();
|
||||
assert_eq!(n.effects, before.effects, "index {index}");
|
||||
assert_eq!(n.exports, before.exports, "index {index}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
use crate::Session;
|
||||
use crate::cmd::file::{export_bytes, export_bytes_with};
|
||||
use figcraft_doc::{ExportFormat, ExportSetting, NodeId, NodeKind};
|
||||
use serde_json::json;
|
||||
|
||||
fn nested(depth: usize) -> (Session, NodeId) {
|
||||
let mut s = Session::new();
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let root = doc.create(doc.first_page().unwrap(), NodeKind::Frame, "deep", 0.0, 0.0, 32.0, 32.0).unwrap();
|
||||
let mut parent = root;
|
||||
for _ in 1..depth {
|
||||
parent = doc.create(parent, NodeKind::Group, "group", 0.0, 0.0, 32.0, 32.0).unwrap();
|
||||
}
|
||||
doc.create(parent, NodeKind::Rectangle, "leaf", 3.0, 5.0, 13.0, 17.0).unwrap();
|
||||
(s, root)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_depth_errors_reach_commands_and_preserve_existing_files() {
|
||||
let (mut s, root) = nested(257);
|
||||
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-export-depth-{}-{stamp}", std::process::id()));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
for format in [ExportFormat::Svg, ExportFormat::Pdf] {
|
||||
let path = dir.join(format!("deep.{}", format.ext()));
|
||||
std::fs::write(&path, b"previous export").unwrap();
|
||||
s.doc_mut().unwrap().get_mut(root).unwrap().exports = vec![ExportSetting { format, ..Default::default() }];
|
||||
let mut commands = vec![
|
||||
("file.export", json!({"ids": [root], "format": format.ext(), "size": "512w", "path": path})),
|
||||
("file.exportLayers", json!({"ids": [root], "dir": dir})),
|
||||
];
|
||||
if format == ExportFormat::Pdf {
|
||||
commands.push(("file.exportFramesPdf", json!({"path": path})));
|
||||
}
|
||||
for (command, params) in commands {
|
||||
let error = s.execute(command, ¶ms).unwrap_err().to_string();
|
||||
assert!(error.contains("maximum export depth of 256"), "{command}: {error}");
|
||||
assert!(error.contains("select an inner layer"), "{command}: {error}");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"previous export");
|
||||
}
|
||||
}
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2, "no staging or partial export files");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_depth_is_relative_to_drawn_roots_including_slice_contents() {
|
||||
let (mut s, root) = nested(257);
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let page = doc.first_page().unwrap();
|
||||
let shallow = doc.create(page, NodeKind::Rectangle, "shallow", 0.0, 0.0, 8.0, 8.0).unwrap();
|
||||
let slice = doc.create(page, NodeKind::Slice, "slice", 0.0, 0.0, 32.0, 32.0).unwrap();
|
||||
let inner = doc.node(root).unwrap().children[0];
|
||||
for format in [ExportFormat::Svg, ExportFormat::Pdf] {
|
||||
assert!(export_bytes(doc, &[shallow], format, 1.0).is_ok());
|
||||
assert!(export_bytes(doc, &[inner], format, 1.0).is_ok());
|
||||
assert!(export_bytes(doc, &[slice], format, 1.0).unwrap_err().to_string().contains("maximum export depth"));
|
||||
let setting = ExportSetting { format, contents_only: false, ..Default::default() };
|
||||
assert!(export_bytes_with(doc, &[shallow], &setting, 1.0).unwrap_err().to_string().contains("maximum export depth"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_depth_is_checked_before_recursive_bounds() {
|
||||
let (mut s, root) = nested(2048);
|
||||
// Unclipped frames require recursive descendant bounds unless preflight refuses them first.
|
||||
s.doc_mut().unwrap().get_mut(root).unwrap().clip = false;
|
||||
for format in [ExportFormat::Svg, ExportFormat::Pdf] {
|
||||
assert!(export_bytes(s.doc().unwrap(), &[root], format, 1.0).unwrap_err().to_string().contains("maximum export depth"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//! The documented gradient shorthand (`{"type":"linearGradient","stops":["#hex",…]}`) works
|
||||
//! through every route into the engine, not only MCP (#252): `figcraft-cli run` and the control
|
||||
//! channel send params straight to `Session::execute`.
|
||||
|
||||
use figcraft_color::Paint;
|
||||
use figcraft_doc::NodeId;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::{id_of, paint_of};
|
||||
|
||||
/// The red→blue left-to-right gradient as written in the docs.
|
||||
fn short() -> Value {
|
||||
json!({"type": "linearGradient", "stops": ["#ff0000", "#0000ff"], "from": [0, 0.5], "to": [1, 0.5]})
|
||||
}
|
||||
|
||||
/// The same gradient in the engine's own form.
|
||||
fn long() -> Value {
|
||||
json!({"type": "gradient", "kind": "linear",
|
||||
"stops": [{"position": 0, "color": {"r": 1, "g": 0, "b": 0, "a": 1}},
|
||||
{"position": 1, "color": {"r": 0, "g": 0, "b": 1, "a": 1}}],
|
||||
"from": [0, 0.5], "to": [1, 0.5], "width": [0, 1.5]})
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session) -> NodeId {
|
||||
id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()["id"]).unwrap()
|
||||
}
|
||||
|
||||
fn fills(s: &Session, id: NodeId) -> Vec<Paint> {
|
||||
s.doc().unwrap().node(id).unwrap().fills.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shorthand_reads_as_the_long_form() {
|
||||
assert_eq!(paint_of(&short()).expect("the documented shorthand is a paint"), paint_of(&long()).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_set_takes_the_shorthand() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s);
|
||||
s.execute("node.set", &json!({"ids": [r], "fills": [short()]})).unwrap();
|
||||
s.execute("node.set", &json!({"ids": [r], "strokes": [short()]})).unwrap();
|
||||
let want = paint_of(&long()).unwrap();
|
||||
assert_eq!(fills(&s, r), vec![want.clone()]);
|
||||
assert_eq!(s.doc().unwrap().node(r).unwrap().strokes, vec![want]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_create_fill_add_and_fill_set_take_the_shorthand() {
|
||||
let want = paint_of(&long()).unwrap();
|
||||
let mut s = Session::new();
|
||||
let v = s.execute("node.create", &json!({"type": "rectangle", "width": 200, "height": 100, "props": {"fills": [short()]}})).unwrap();
|
||||
let r = id_of(&v["id"]).unwrap();
|
||||
assert_eq!(fills(&s, r), vec![want.clone()]);
|
||||
s.execute("fill.add", &json!({"ids": [r], "paint": short()})).unwrap();
|
||||
assert_eq!(fills(&s, r), vec![want.clone(), want.clone()]);
|
||||
let r2 = rect(&mut s);
|
||||
s.execute("fill.set", &json!({"ids": [r2], "index": 0, "paint": short()})).unwrap();
|
||||
assert_eq!(fills(&s, r2), vec![want]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shorthand_defaults_match_the_mcp_server() {
|
||||
// No handles: top to bottom, with a square width handle.
|
||||
let p = paint_of(&json!({"type": "linearGradient", "stops": ["#000000", "#ffffff"]})).unwrap();
|
||||
let v = serde_json::to_value(&p).unwrap();
|
||||
assert_eq!(
|
||||
(v["kind"].clone(), v["from"].clone(), v["to"].clone(), v["width"].clone()),
|
||||
(json!("linear"), json!([0.5, 0.0]), json!([0.5, 1.0]), json!([-0.5, 0.0]))
|
||||
);
|
||||
// Plain colour stops are spread evenly.
|
||||
let p = paint_of(&json!({"type": "radialGradient", "stops": ["#000000", "#808080", "#ffffff"]})).unwrap();
|
||||
let v = serde_json::to_value(&p).unwrap();
|
||||
assert_eq!(v["kind"], "radial");
|
||||
let pos: Vec<f64> = v["stops"].as_array().unwrap().iter().map(|s| s["position"].as_f64().unwrap()).collect();
|
||||
assert_eq!(pos, vec![0.0, 0.5, 1.0]);
|
||||
for kind in ["angular", "diamond"] {
|
||||
let p = paint_of(&json!({"type": format!("{kind}Gradient"), "stops": ["#000000", "#ffffff"]})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&p).unwrap()["kind"], kind);
|
||||
}
|
||||
// A CSS colour inside a solid paint or a positioned stop is read too.
|
||||
assert_eq!(paint_of(&json!({"type": "solid", "color": "#00ff00"})), paint_of(&json!("#00ff00")));
|
||||
let p = paint_of(&json!({"type": "gradient", "stops": [{"position": 0, "color": "#ff0000"}, {"position": 1, "color": "#0000ff"}], "from": [0, 0.5], "to": [1, 0.5]})).unwrap();
|
||||
assert_eq!(p, paint_of(&long()).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_is_still_refused() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s);
|
||||
for junk in [
|
||||
json!({"type": "linearGradient", "stops": "red"}),
|
||||
json!({"type": "linearGradient", "stops": [42]}),
|
||||
json!({"type": "fooGradient", "stops": ["#ff0000"]}),
|
||||
json!({"type": "linearGradient", "stops": ["#ff0000"], "from": "left"}),
|
||||
] {
|
||||
let e = s.execute("node.set", &json!({"ids": [r], "fills": [junk.clone()]})).unwrap_err().to_string();
|
||||
assert!(e.contains("`fills` must be paints or colours"), "{junk}: {e}");
|
||||
}
|
||||
// Deep nesting doesn't overflow the stack.
|
||||
let mut deep = json!("#ff0000");
|
||||
for _ in 0..200 {
|
||||
deep = json!({"type": "linearGradient", "stops": [{"position": 0, "color": deep}]});
|
||||
}
|
||||
assert!(paint_of(&deep).is_none());
|
||||
}
|
||||
@@ -57,3 +57,23 @@ fn class_names_are_valid_and_deep_trees_are_bounded() {
|
||||
// Nesting stops at the depth limit instead of running away.
|
||||
assert!(code.matches("<div").count() <= 14, "{}", code.matches("<div").count());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leptos_wraps_the_markup_in_a_component_with_text_as_string_literals() {
|
||||
let mut s = Session::new();
|
||||
let card = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 120})).unwrap());
|
||||
s.execute("node.set", &json!({"id": card, "props": {"name": "Card"}})).unwrap();
|
||||
let t = id(s.execute("text.create", &json!({"text": "Say \"hi\" {x}\nسلام", "x": 0, "y": 60})).unwrap());
|
||||
s.execute("node.reparent", &json!({"ids": [t], "parent": card})).unwrap();
|
||||
|
||||
let code = code_for(s.doc().unwrap(), card, CodeLang::Leptos);
|
||||
assert!(
|
||||
code.starts_with("use leptos::prelude::*;\n\n#[component]\npub fn Card() -> impl IntoView {\n view! {\n <div class=\"card\">"),
|
||||
"{code}"
|
||||
);
|
||||
// Quotes and braces stay inside Rust string literals; lines break with a self-closed tag.
|
||||
assert!(code.contains(r#">"Say \"hi\" {x}"<br/>"سلام"</p>"#), "{code}");
|
||||
assert!(code.contains("/* card.css */\n") && code.contains(".card {\n position: relative;\n"), "{code}");
|
||||
let out = s.execute("inspect.code", &json!({"id": card, "language": "leptos", "unit": "rem"})).unwrap();
|
||||
assert!(out["code"].as_str().unwrap().contains("width: 12.5rem;"), "{}", out["code"]);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! A command that fails part-way (a property that applies, then one that doesn't) leaves nothing
|
||||
//! behind — also during an interaction, whose last valid preview and own snapshot survive it.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
|
||||
fn rect(s: &mut Session) -> crate::NodeId {
|
||||
id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 80, "height": 60})).unwrap()["id"]).unwrap()
|
||||
}
|
||||
|
||||
/// `width` applies (geometry keys go first), then `visible` fails validation.
|
||||
fn partially_applicable(s: &mut Session, id: crate::NodeId) -> crate::Result<serde_json::Value> {
|
||||
s.execute("node.set", &json!({"id": id, "width": 123, "visible": "yes"}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_command_during_an_interaction_keeps_the_prior_preview() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s);
|
||||
s.begin_interaction("Move").unwrap();
|
||||
let props = serde_json::Map::from_iter([("x".to_string(), json!(20.0))]);
|
||||
s.preview(|d, _| crate::cmd::props::apply("node.set", d, r, &props)).unwrap();
|
||||
let (previewed, selection, page) = {
|
||||
let ds = s.ds().unwrap();
|
||||
(ds.doc.clone(), ds.selection.clone(), ds.page)
|
||||
};
|
||||
let before_journal = s.journal.clone();
|
||||
let before_history = s.ds().unwrap().history.undo.len();
|
||||
|
||||
assert!(partially_applicable(&mut s, r).is_err());
|
||||
|
||||
// The rejected edit is gone: the command-entry state (the valid preview) is back.
|
||||
let ds = s.ds().unwrap();
|
||||
assert_eq!(*ds.doc, *previewed, "the document is what the preview showed");
|
||||
assert_eq!(ds.doc.node(r).unwrap().width, 80.0);
|
||||
assert_eq!(ds.selection, selection);
|
||||
assert_eq!(ds.page, page);
|
||||
assert_eq!(ds.history.undo.len(), before_history);
|
||||
assert_eq!(s.journal, before_journal);
|
||||
|
||||
// The interaction is alive and commits the preview, not the rejected mutation.
|
||||
s.commit_interaction().unwrap();
|
||||
let n = s.doc().unwrap().node(r).unwrap();
|
||||
assert_eq!((n.width, n.visible), (80.0, true));
|
||||
assert_eq!(n.transform.as_coeffs()[4], 20.0, "the previewed move is committed");
|
||||
assert_eq!(s.undo_depth(), (before_history + 1, 0));
|
||||
assert_eq!(s.undo_labels(1), vec!["Move".to_string()]);
|
||||
s.undo().unwrap();
|
||||
assert_eq!(s.doc().unwrap().node(r).unwrap().transform.as_coeffs()[4], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_command_without_an_interaction_still_rolls_back() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s);
|
||||
let before_journal = s.journal.clone();
|
||||
let before_history = s.ds().unwrap().history.undo.len();
|
||||
|
||||
assert!(partially_applicable(&mut s, r).is_err());
|
||||
|
||||
let n = s.doc().unwrap().node(r).unwrap();
|
||||
assert_eq!((n.width, n.visible), (80.0, true));
|
||||
assert_eq!(s.ds().unwrap().history.undo.len(), before_history);
|
||||
assert_eq!(s.journal, before_journal);
|
||||
}
|
||||
@@ -162,6 +162,7 @@ pub static KEYS: &[Binding] = &[
|
||||
k(Essential, "app.actions", &["Cmd+K", "Cmd+/"]).label("Actions…"),
|
||||
k(Essential, "help.shortcuts", &["Ctrl+Shift+?"]).both(),
|
||||
k(Essential, "view.toggleUi", &["Cmd+\\"]),
|
||||
k(Essential, "view.fullscreen", &["Ctrl+Cmd+F"]).win(&["F11"]),
|
||||
k(Essential, "edit.pickColor", &["I", "Ctrl+C"]).win(&["I"]),
|
||||
k(Essential, "app.preferences", &["Cmd+,"]),
|
||||
k(Essential, "file.new", &["Cmd+N"]),
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::cmd::edit::opacity_from_digits;
|
||||
use crate::keymap::{Context, GESTURES, Group, KEYS, Mods, Platform, display, lookup, matches, normalize, parse, primary};
|
||||
use crate::params::id_of;
|
||||
use crate::tools::ToolId;
|
||||
use crate::{EngineError, Session};
|
||||
use figcraft_doc::NodeId;
|
||||
|
||||
const PLATFORMS: [Platform; 2] = [Platform::Mac, Platform::Other];
|
||||
@@ -186,6 +186,7 @@ fn opacity_digits() {
|
||||
assert_eq!(opacity_from_digits("00"), Some(0.0));
|
||||
assert_eq!(opacity_from_digits(""), None);
|
||||
assert_eq!(opacity_from_digits("123"), None);
|
||||
assert_eq!(opacity_from_digits("9999999999"), None, "ten digits must not overflow u32");
|
||||
assert_eq!(opacity_from_digits("4x"), None);
|
||||
assert_eq!(opacity_from_digits("٣"), None, "ASCII digits only");
|
||||
|
||||
@@ -201,6 +202,10 @@ fn opacity_digits() {
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(op(&s, a), 0.3, "one undo step");
|
||||
assert!(s.execute("object.opacityKeys", &json!({"digits": "123"})).is_err());
|
||||
assert!(
|
||||
matches!(s.execute("object.opacityKeys", &json!({"digits": "9999999999"})), Err(EngineError::BadParams { .. })),
|
||||
"an overflowing digit string is a parameter error, not a panic"
|
||||
);
|
||||
assert!(s.execute("object.opacityKeys", &json!({})).is_err());
|
||||
ex(&mut s, "select.none", json!({}));
|
||||
assert!(s.execute("object.opacityKeys", &json!({"digits": "1"})).is_err(), "nothing selected");
|
||||
|
||||
+158
-25
@@ -107,6 +107,47 @@ pub fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||||
EngineError::BadParams { cmd: cmd.to_string(), msg: msg.into() }
|
||||
}
|
||||
|
||||
/// Deepest params nesting `resolve_step_refs` walks before giving up.
|
||||
const STEP_REF_MAX_DEPTH: usize = 64;
|
||||
|
||||
/// Replace every `{"$step": n}` / `{"$step": n, "field": "…"}` in `v` with that field (default
|
||||
/// `id`) of `results[n]`, the result of an earlier batch step.
|
||||
fn resolve_step_refs(v: &Value, results: &[Value], step: usize, depth: usize) -> Result<Value> {
|
||||
if depth > STEP_REF_MAX_DEPTH {
|
||||
return Err(bad("command.batch", format!("step {step}: params nest deeper than {STEP_REF_MAX_DEPTH} levels")));
|
||||
}
|
||||
match v {
|
||||
Value::Object(o) if o.contains_key("$step") => {
|
||||
if let Some(k) = o.keys().find(|k| *k != "$step" && *k != "field") {
|
||||
return Err(bad("command.batch", format!("step {step}: a `$step` reference takes only `field`, not `{k}`")));
|
||||
}
|
||||
let n = o
|
||||
.get("$step")
|
||||
.and_then(Value::as_u64)
|
||||
.ok_or_else(|| bad("command.batch", format!("step {step}: `$step` must be a step index (0, 1, …)")))?;
|
||||
let field = match o.get("field") {
|
||||
None => "id",
|
||||
Some(Value::String(f)) => f.as_str(),
|
||||
Some(_) => return Err(bad("command.batch", format!("step {step}: `field` must be a string"))),
|
||||
};
|
||||
let Some(result) = usize::try_from(n).ok().and_then(|n| results.get(n)) else {
|
||||
let range = if step == 0 { "there is none before step 0".to_string() } else { format!("0 to {}", step - 1) };
|
||||
return Err(bad("command.batch", format!("step {step}: `$step` {n} must be an earlier step ({range})")));
|
||||
};
|
||||
result.get(field).cloned().ok_or_else(|| bad("command.batch", format!("step {step}: step {n}'s result has no `{field}`")))
|
||||
}
|
||||
Value::Object(o) => {
|
||||
let mut out = serde_json::Map::with_capacity(o.len());
|
||||
for (k, x) in o {
|
||||
out.insert(k.clone(), resolve_step_refs(x, results, step, depth + 1)?);
|
||||
}
|
||||
Ok(Value::Object(out))
|
||||
}
|
||||
Value::Array(a) => a.iter().map(|x| resolve_step_refs(x, results, step, depth + 1)).collect::<Result<Vec<_>>>().map(Value::Array),
|
||||
_ => Ok(v.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// One undo step.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HistoryEntry {
|
||||
@@ -328,6 +369,9 @@ 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 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>,
|
||||
}
|
||||
|
||||
/// A version shown on the canvas in place of the document: read-only until it's restored or the
|
||||
@@ -383,6 +427,7 @@ impl DocState {
|
||||
focus: None,
|
||||
guide: None,
|
||||
rotation_origin: None,
|
||||
last_duplicate: None,
|
||||
};
|
||||
st.zoom_to_fit();
|
||||
st
|
||||
@@ -538,6 +583,10 @@ impl DocState {
|
||||
if self.rotation_origin.as_ref().is_some_and(|o| o.ids != self.selection) {
|
||||
self.rotation_origin = None;
|
||||
}
|
||||
// So does the last duplicate (undo reuses ids: a new layer could take a copy's).
|
||||
if self.last_duplicate.as_ref().is_some_and(|d| !d.is_selection(&self.selection)) {
|
||||
self.last_duplicate = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -552,6 +601,7 @@ pub struct Prefs {
|
||||
pub highlight_on_hover: bool,
|
||||
pub number_keys_opacity: bool,
|
||||
pub show_dimensions: bool,
|
||||
/// "system" by default; an explicit "light" or "dark" choice stays saved.
|
||||
pub theme: String,
|
||||
/// 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).
|
||||
@@ -564,6 +614,9 @@ 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,
|
||||
/// 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,
|
||||
/// `figcraft://` invite links open this app (`collab.registerLinks`); `None`: on for installed
|
||||
/// builds, off for development builds.
|
||||
pub invite_links: Option<bool>,
|
||||
@@ -591,6 +644,14 @@ pub struct Prefs {
|
||||
/// The Getting started tips on the home screen were closed (Help ▸ Getting started shows them
|
||||
/// again).
|
||||
pub getting_started_dismissed: bool,
|
||||
/// How each home screen section shows its files, by section id (`"recents"`): `"grid"` (cards
|
||||
/// with the picture each file carries) or `"list"` (a table). Missing: the grid.
|
||||
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub home_views: BTreeMap<String, String>,
|
||||
/// What the home screen's recent files are ordered by: `"modified"`, `"name"` or `"created"`.
|
||||
pub home_sort: String,
|
||||
/// Which way: `"desc"` (newest first, Z to A) or `"asc"`.
|
||||
pub home_order: String,
|
||||
}
|
||||
|
||||
impl Default for Prefs {
|
||||
@@ -603,11 +664,12 @@ impl Default for Prefs {
|
||||
highlight_on_hover: true,
|
||||
number_keys_opacity: true,
|
||||
show_dimensions: true,
|
||||
theme: "light".into(),
|
||||
theme: "system".into(),
|
||||
panel_widths: (240.0, 240.0),
|
||||
language: "system".into(),
|
||||
user_name: cmd::comment::DEFAULT_AUTHOR.into(),
|
||||
reduce_motion: false,
|
||||
auto_save: false,
|
||||
invite_links: None,
|
||||
plugins: Vec::new(),
|
||||
plugin_settings: BTreeMap::new(),
|
||||
@@ -616,6 +678,9 @@ impl Default for Prefs {
|
||||
recent_files: Vec::new(),
|
||||
online_files: Vec::new(),
|
||||
getting_started_dismissed: false,
|
||||
home_views: BTreeMap::new(),
|
||||
home_sort: "modified".into(),
|
||||
home_order: "desc".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -652,15 +717,15 @@ pub struct Session {
|
||||
pub prefs: Prefs,
|
||||
pub tool: tools::ToolState,
|
||||
pub ui_requests: Vec<UiRequest>,
|
||||
/// The interface's theme once the UI knows it ([`Session::set_ui_dark`]): new files and pages
|
||||
/// get the dark page colour while it is dark. Headless sessions (CLI, MCP, exports) have no
|
||||
/// The interface's theme once the UI knows it ([`Session::set_ui_dark`]): new files get the
|
||||
/// dark page colour while it is dark (added pages take the current page's). Headless sessions (CLI, MCP, exports) have no
|
||||
/// theme, so what scripts make is the same everywhere.
|
||||
pub ui_dark: Option<bool>,
|
||||
/// Command log (id + params) for actions/automation.
|
||||
pub journal: Vec<(String, Value)>,
|
||||
/// Recently used colours.
|
||||
pub recent_colors: Vec<figcraft_color::Color>,
|
||||
/// The last command executed through [`Session::execute`] (for "Repeat").
|
||||
/// The last undoable command executed through [`Session::execute`], excluding "Repeat".
|
||||
pub last_command: Option<(String, Value)>,
|
||||
/// Status / toast message for the UI.
|
||||
pub message: Option<String>,
|
||||
@@ -710,7 +775,7 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// The background new pages get: the dark page colour while the interface is dark.
|
||||
/// The background a new file's page gets: the dark page colour while the interface is dark.
|
||||
pub fn new_page_background(&self) -> figcraft_color::Color {
|
||||
if self.ui_dark == Some(true) { figcraft_color::Color::DARK_PAGE } else { figcraft_color::Color::DEFAULT_PAGE }
|
||||
}
|
||||
@@ -789,6 +854,10 @@ impl Session {
|
||||
/// as it was.
|
||||
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
// Revert replaces the document's uid: refuse before ending previews or syncing branches.
|
||||
if id == "file.revert" && self.live().doc_uid.is_some() {
|
||||
return Err(EngineError::Disabled(id.into(), "leave the live session before reverting to the saved file".into()));
|
||||
}
|
||||
branches::sync(self);
|
||||
if textpreview::COMMANDS.contains(&id) {
|
||||
// A live preview is no edit: no undo step, normalisation or rollback here.
|
||||
@@ -813,7 +882,7 @@ impl Session {
|
||||
match &result {
|
||||
Ok(_) => {
|
||||
self.after_command(spec, before);
|
||||
if spec.history {
|
||||
if spec.history && id != "edit.repeat" {
|
||||
self.last_command = Some((id.to_string(), params.clone()));
|
||||
}
|
||||
self.journal.push((id.to_string(), params.clone()));
|
||||
@@ -822,10 +891,11 @@ impl Session {
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// Roll back any partial edit.
|
||||
// Roll back any partial edit — also during an interaction, where `before` is the
|
||||
// interaction's last valid preview: restore it and leave the gesture itself (its
|
||||
// own snapshot) alone.
|
||||
if let Some((doc, sel, page, uid)) = before
|
||||
&& let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid)
|
||||
&& ds.interaction.is_none()
|
||||
{
|
||||
ds.doc = doc;
|
||||
ds.selection = sel;
|
||||
@@ -1056,30 +1126,77 @@ impl Session {
|
||||
self.ui_requests.push(UiRequest { kind: kind.to_string(), data });
|
||||
}
|
||||
|
||||
/// Run several commands as one undo step.
|
||||
/// Run several commands as one undo step. A param value `{"$step": n}` is replaced by the `id`
|
||||
/// in the result of the earlier step `n` (`{"$step": n, "field": "…"}` picks another field),
|
||||
/// so a step can target a layer that an earlier step of the same batch created. Any object
|
||||
/// with a `$step` key in a step's params is such a reference, except inside a nested
|
||||
/// `command.batch` step: its references are to its own steps.
|
||||
pub fn batch(&mut self, label: &str, cmds: &[(String, Value)]) -> Result<Vec<Value>> {
|
||||
self.grouped(label, |s| cmds.iter().map(|(id, p)| s.execute(id, p)).collect())
|
||||
self.grouped(label, |s| {
|
||||
let mut results: Vec<Value> = Vec::with_capacity(cmds.len());
|
||||
for (step, (id, p)) in cmds.iter().enumerate() {
|
||||
let p = if id == "command.batch" { p.clone() } else { resolve_step_refs(p, &results, step, 0)? };
|
||||
results.push(s.execute(id, &p)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
}
|
||||
|
||||
/// Run `f` as one undo step called `label`: the commands it executes push no steps of their
|
||||
/// own, and when it fails the document, selection, page and history are put back.
|
||||
/// own, and when it fails the documents stay at their group-start state.
|
||||
pub fn grouped<T>(&mut self, label: &str, f: impl FnOnce(&mut Self) -> Result<T>) -> Result<T> {
|
||||
let ds = self.ds_mut()?;
|
||||
let (before, sel, page, saved_len, uid) = (ds.doc.clone(), ds.selection.clone(), ds.page, ds.history.undo.len(), ds.uid);
|
||||
self.ds()?;
|
||||
let before: Vec<_> =
|
||||
self.docs.iter().map(|ds| (ds.uid, ds.doc.clone(), ds.selection.clone(), ds.page, ds.history.undo.len(), ds.history.limit)).collect();
|
||||
// An edit inside clears the redo steps; a failed group has to bring them back.
|
||||
let redo_before: Vec<_> = self.docs.iter().map(|ds| ds.history.redo.clone()).collect();
|
||||
let active = self.active;
|
||||
let journal_len = self.journal.len();
|
||||
|
||||
// The steps inside are dropped again below, so they mustn't push the oldest ones out.
|
||||
let limit = std::mem::replace(&mut ds.history.limit, usize::MAX);
|
||||
self.live_hold(uid, true);
|
||||
for ds in &mut self.docs {
|
||||
ds.history.limit = usize::MAX;
|
||||
}
|
||||
for (uid, _, _, _, _, _) in &before {
|
||||
self.live_hold(*uid, true);
|
||||
}
|
||||
let result = f(self);
|
||||
self.live_hold(uid, false);
|
||||
let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) else { return result };
|
||||
ds.history.limit = limit;
|
||||
ds.history.undo.truncate(saved_len);
|
||||
for (uid, _, _, _, _, _) in &before {
|
||||
self.live_hold(*uid, false);
|
||||
}
|
||||
|
||||
if result.is_err() {
|
||||
ds.doc = before;
|
||||
ds.selection = sel;
|
||||
ds.page = page;
|
||||
} else if !Arc::ptr_eq(&before, &ds.doc) {
|
||||
self.record(uid, Some(label), before, sel, page);
|
||||
self.docs.retain(|ds| before.iter().any(|(uid, _, _, _, _, _)| *uid == ds.uid));
|
||||
for ((uid, doc, selection, page, undo_len, limit), redo) in before.iter().zip(redo_before) {
|
||||
if let Some(ds) = self.docs.iter_mut().find(|ds| ds.uid == *uid) {
|
||||
ds.history.redo = redo;
|
||||
ds.doc = doc.clone();
|
||||
ds.selection = selection.clone();
|
||||
ds.page = *page;
|
||||
ds.history.undo.truncate(*undo_len);
|
||||
ds.history.limit = *limit;
|
||||
}
|
||||
}
|
||||
self.active = active;
|
||||
self.journal.truncate(journal_len);
|
||||
return result;
|
||||
}
|
||||
|
||||
let changed: Vec<_> = before
|
||||
.iter()
|
||||
.filter_map(|(uid, doc, selection, page, _, _)| {
|
||||
let ds = self.docs.iter().find(|d| d.uid == *uid)?;
|
||||
(!Arc::ptr_eq(doc, &ds.doc) && ds.interaction.is_none()).then(|| (*uid, doc.clone(), selection.clone(), *page))
|
||||
})
|
||||
.collect();
|
||||
for ds in &mut self.docs {
|
||||
if let Some((_, _, _, _, undo_len, limit)) = before.iter().find(|before| before.0 == ds.uid) {
|
||||
ds.history.limit = *limit;
|
||||
ds.history.undo.truncate(*undo_len);
|
||||
}
|
||||
}
|
||||
for (uid, doc, selection, page) in changed {
|
||||
self.record(uid, Some(label), doc, selection, page);
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -1343,6 +1460,10 @@ mod arc_handles_tests;
|
||||
#[cfg(test)]
|
||||
mod autosave_tests;
|
||||
#[cfg(test)]
|
||||
mod batch_rollback_tests;
|
||||
#[cfg(test)]
|
||||
mod batch_step_ref_tests;
|
||||
#[cfg(test)]
|
||||
mod binding_lists_tests;
|
||||
#[cfg(test)]
|
||||
mod booleans_tests;
|
||||
@@ -1391,10 +1512,14 @@ mod double_click_tests;
|
||||
#[cfg(test)]
|
||||
mod draw_tools_tests;
|
||||
#[cfg(test)]
|
||||
mod duplicate_spacing_tests;
|
||||
#[cfg(test)]
|
||||
mod effects_tests;
|
||||
#[cfg(test)]
|
||||
mod export_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod export_depth_tests;
|
||||
#[cfg(test)]
|
||||
mod extended_chain_tests;
|
||||
#[cfg(test)]
|
||||
mod fig_export_assets_tests;
|
||||
@@ -1415,6 +1540,8 @@ mod fx_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod gesture_preview_tests;
|
||||
#[cfg(test)]
|
||||
mod gradient_shorthand_tests;
|
||||
#[cfg(test)]
|
||||
mod hide_publishing_tests;
|
||||
#[cfg(test)]
|
||||
mod history_behaviour_tests;
|
||||
@@ -1425,6 +1552,8 @@ mod import_export_tests;
|
||||
#[cfg(test)]
|
||||
mod inspect_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod interaction_rollback_tests;
|
||||
#[cfg(test)]
|
||||
mod invite_links_tests;
|
||||
#[cfg(test)]
|
||||
mod keymap_tests;
|
||||
@@ -1453,6 +1582,8 @@ mod nested_override_tests;
|
||||
#[cfg(test)]
|
||||
mod online_tests;
|
||||
#[cfg(test)]
|
||||
mod open_issues_tests;
|
||||
#[cfg(test)]
|
||||
mod outline_options_tests;
|
||||
#[cfg(test)]
|
||||
mod paint_reorder_tests;
|
||||
@@ -1463,6 +1594,8 @@ mod panels_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod pdf_print_tests;
|
||||
#[cfg(test)]
|
||||
mod pen_overlay_tests;
|
||||
#[cfg(test)]
|
||||
mod pixel_preview_tests;
|
||||
#[cfg(test)]
|
||||
mod playground_tests;
|
||||
@@ -1471,7 +1604,7 @@ mod plugin_directory_tests;
|
||||
#[cfg(test)]
|
||||
mod plugin_panels_tests;
|
||||
#[cfg(test)]
|
||||
mod plugins_tests;
|
||||
pub(crate) mod plugins_tests;
|
||||
#[cfg(test)]
|
||||
mod progressive_blur_tests;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -517,6 +517,11 @@ pub trait LiveLink: Send {
|
||||
fn background(&mut self, _job: u64, _req: NetRequest) -> bool {
|
||||
false
|
||||
}
|
||||
/// Remove client `client` from session `session`, which this link hosts: they are
|
||||
/// disconnected and can't join again from their address while it runs. Returns at once.
|
||||
fn remove(&mut self, _session: SessionId, _client: u32) -> std::result::Result<(), String> {
|
||||
Err("removing people needs the desktop app or the MCP server".into())
|
||||
}
|
||||
/// End session `session` (stop hosting or leave); the others go on. Nothing for a session
|
||||
/// that is over already.
|
||||
fn leave(&mut self, session: SessionId);
|
||||
@@ -1889,6 +1894,59 @@ fn follow(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"follow": s.live().follow}))
|
||||
}
|
||||
|
||||
/// Remove someone from the open file's session: in a session this app hosts, the hub drops them;
|
||||
/// in a file of yours on a server, the server does (with the file's owner token). They are told
|
||||
/// the session is over for them, keep their copy, and can't join again from their address while
|
||||
/// the session (or the server) runs.
|
||||
fn remove_person(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "collab.removePerson";
|
||||
let client =
|
||||
get(p, "client").and_then(Value::as_u64).and_then(|n| u32::try_from(n).ok()).ok_or_else(|| bad(CMD, "`client` must be a client id"))?;
|
||||
let live = s.live();
|
||||
if live.status != Status::Live {
|
||||
return Err(bad(CMD, "the open file isn't in a live session"));
|
||||
}
|
||||
let name =
|
||||
live.peers.get(&client).map(|p| p.name.clone()).ok_or_else(|| bad(CMD, format!("nobody with client id {client} is in the session")))?;
|
||||
let (id, role, server_file) = (live.id, live.role(), live.server_file());
|
||||
match (role, server_file) {
|
||||
(Some(Role::Host), _) => {
|
||||
let link = s.live.link.as_deref_mut().ok_or_else(|| unavailable(CMD))?;
|
||||
link.remove(id, client).map_err(EngineError::Other)?;
|
||||
}
|
||||
(Some(Role::Guest), Some((server, file))) => {
|
||||
let owner =
|
||||
s.live().published.as_ref().and_then(|p| p.owner.clone()).or_else(|| s.online_owner(&server, &file)).filter(|o| !o.is_empty());
|
||||
let owner = owner.ok_or_else(|| bad(CMD, "only the file's owner can remove people from it"))?;
|
||||
let req = Some(Admin::RemovePeer { file: file.clone(), client });
|
||||
match hosting_request(s, CMD, &server, &file, &owner, req) {
|
||||
Ok((_, Some(AdminReply::Done))) => {}
|
||||
Ok(_) => return Err(EngineError::Other(format!("couldn't remove {name}: the server answered something else"))),
|
||||
// A server older than this request can't read it.
|
||||
Err(e) => return Err(EngineError::Other(format!("couldn't remove {name}: {e} (the server may be older than removing people)"))),
|
||||
}
|
||||
}
|
||||
_ => return Err(bad(CMD, "only the person hosting the session, or the file's owner, can remove people")),
|
||||
}
|
||||
s.message = Some(format!("{name} was removed from the session"));
|
||||
Ok(json!({"removed": client, "name": name}))
|
||||
}
|
||||
|
||||
/// You may remove others from the open file's session: you host it, or it is your file on a
|
||||
/// server.
|
||||
pub fn can_remove_people(s: &Session) -> bool {
|
||||
let live = s.live();
|
||||
live.status == Status::Live
|
||||
&& match live.role() {
|
||||
Some(Role::Host) => true,
|
||||
Some(Role::Guest) => live.server_file().is_some_and(|(server, file)| {
|
||||
live.published.as_ref().and_then(|p| p.owner.as_deref()).is_some_and(|o| !o.is_empty())
|
||||
|| s.online_owner(&server, &file).is_some_and(|o| !o.is_empty())
|
||||
}),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let at = match (num(p, "x"), num(p, "y")) {
|
||||
(Some(x), Some(y)) => Some((x, y)),
|
||||
@@ -1997,6 +2055,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|s, _| Ok(s.live_summary())
|
||||
),
|
||||
spec!("collab.follow", "Follow", "{client? (a peer's id; omit to stop following)} → {follow}", false, follow),
|
||||
spec!(
|
||||
"collab.removePerson",
|
||||
"Remove from session",
|
||||
"{client (a peer's id)} → {removed, name}: they are disconnected (they keep their copy) and can't join again from their address while the session runs; only the person hosting, or the owner of a file on a server, can do it",
|
||||
false,
|
||||
remove_person
|
||||
),
|
||||
spec!(
|
||||
"collab.cursor",
|
||||
"Show cursor at",
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
//! made outside a command are all refused or put back; others' edits still arrive; other tabs stay
|
||||
//! editable). Leaving keeps the file open as your copy.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use figcraft_collab::message::{ClientMsg, ServerMsg};
|
||||
use figcraft_collab::{Client, NoRecompute, Outgoing};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::live::{Access, Event, PUBLISHING, Published, Role, VIEW_ONLY};
|
||||
use crate::live_tests::{info, push, relay, serve_as, session};
|
||||
use crate::live::{Access, Event, PUBLISHING, Published, Role, Status, VIEW_ONLY};
|
||||
use crate::live_tests::{Script, info, push, relay, serve_as, session};
|
||||
use crate::tools::ToolId;
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
@@ -133,3 +135,149 @@ fn a_view_link_makes_the_file_read_only() {
|
||||
assert_eq!(s.active_read_only(), None);
|
||||
assert!(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).is_ok());
|
||||
}
|
||||
|
||||
fn saved_file(s: &mut Session) -> std::path::PathBuf {
|
||||
let path = std::env::temp_dir().join(format!("figcraft-live-revert-{}-{}.figcraft", std::process::id(), s.ds().unwrap().uid));
|
||||
s.execute("file.saveVersion", &json!({"name": "Saved"})).unwrap();
|
||||
s.execute("file.save", &json!({"path": path})).unwrap();
|
||||
s.execute("file.rename", &json!({"name": "Unsaved"})).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn revert_refused_unchanged(s: &mut Session, script: &Arc<Mutex<Script>>) {
|
||||
let uid = s.ds().unwrap().uid;
|
||||
let session = s.live().id;
|
||||
let before = format!("{:?}", s.docs);
|
||||
let live = s.live_summary();
|
||||
let journal = s.journal.clone();
|
||||
let last = s.last_command.clone();
|
||||
let message = s.message.clone();
|
||||
let ui = s.ui_requests.clone();
|
||||
let active = s.active;
|
||||
let script_before = {
|
||||
let sc = script.lock().unwrap();
|
||||
(sc.msgs.len(), sc.sent.len(), sc.left)
|
||||
};
|
||||
let result = s.execute("file.revert", &json!({}));
|
||||
assert!(
|
||||
matches!(result, Err(EngineError::Disabled(ref cmd, _)) if cmd == "file.revert"),
|
||||
"expected refusal, got {result:?}; document UID {uid} -> {}, live session {session} -> {}",
|
||||
s.ds().unwrap().uid,
|
||||
s.live().id
|
||||
);
|
||||
assert_eq!(format!("{:?}", s.docs), before, "all document state, including previews and branch tabs, stays intact");
|
||||
assert_eq!(s.live_summary(), live);
|
||||
assert_eq!((s.active, &s.journal, &s.last_command, &s.message, &s.ui_requests), (active, &journal, &last, &message, &ui));
|
||||
let sc = script.lock().unwrap();
|
||||
assert_eq!((sc.msgs.len(), sc.sent.len(), sc.left), script_before, "no sync, presence or leave sent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revert_refuses_live_documents_without_losing_access_or_sync() {
|
||||
for state in ["editing", "viewing", "publishing", "reconnecting", "hosting"] {
|
||||
let (mut s, script) = session();
|
||||
let path = saved_file(&mut s);
|
||||
let uid = s.ds().unwrap().uid;
|
||||
if state == "hosting" {
|
||||
s.execute("collab.host", &json!({})).unwrap();
|
||||
} else {
|
||||
s.execute("collab.publish", &json!({"server": "ws://127.0.0.1:47900/"})).unwrap();
|
||||
}
|
||||
let access = if state == "viewing" { Access::View } else { Access::Edit };
|
||||
let (mut server, me, welcome) = serve_as(s.doc().unwrap(), access);
|
||||
if !matches!(state, "publishing" | "hosting") {
|
||||
let mut i = info(Role::Guest, me);
|
||||
i.access = access;
|
||||
push(&script, vec![Event::Welcome { info: i, peers: vec![] }, welcome.clone()]);
|
||||
s.live_poll();
|
||||
}
|
||||
if state == "reconnecting" {
|
||||
push(&script, vec![Event::Status(Status::Reconnecting { attempt: 1 })]);
|
||||
s.live_poll();
|
||||
}
|
||||
let read_only = match state {
|
||||
"viewing" => Some(VIEW_ONLY),
|
||||
"publishing" => Some(PUBLISHING),
|
||||
_ => None,
|
||||
};
|
||||
assert_eq!(s.active_read_only(), read_only, "{state}");
|
||||
revert_refused_unchanged(&mut s, &script);
|
||||
assert_eq!(s.live().doc_uid, Some(uid), "{state}");
|
||||
assert_eq!(s.active_read_only(), read_only, "{state}");
|
||||
if let Some(why) = read_only {
|
||||
refused(&mut s, "file.rename", json!({"name": "Refused"}), why);
|
||||
}
|
||||
if matches!(state, "publishing" | "hosting") {
|
||||
if state == "publishing" {
|
||||
push(&script, vec![Event::Welcome { info: info(Role::Guest, me), peers: vec![] }]);
|
||||
}
|
||||
push(&script, vec![welcome]);
|
||||
s.live_poll();
|
||||
assert_eq!(s.live().doc_uid, Some(uid), "the welcome still finds its document");
|
||||
}
|
||||
if access == Access::Edit && state != "reconnecting" {
|
||||
s.execute("file.rename", &json!({"name": "Still syncing"})).unwrap();
|
||||
let answers = relay(&script, &mut server, me);
|
||||
assert_eq!(server.document().name, "Still syncing", "{state}");
|
||||
push(&script, answers);
|
||||
s.live_poll();
|
||||
}
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revert_refusal_preserves_previews_and_branch_tabs() {
|
||||
let (mut s, script) = session();
|
||||
let text = s.execute("text.create", &json!({"text": "Preview"})).unwrap()["id"].clone();
|
||||
let path = saved_file(&mut s);
|
||||
s.execute("branch.create", &json!({"name": "Working"})).unwrap();
|
||||
s.execute("doc.switch", &json!({"index": 0})).unwrap();
|
||||
s.execute("collab.host", &json!({})).unwrap();
|
||||
let (_, _, welcome) = serve_as(s.doc().unwrap(), Access::Edit);
|
||||
push(&script, vec![welcome]);
|
||||
s.live_poll();
|
||||
let main_uid = s.ds().unwrap().uid;
|
||||
// A branch has no local save path; refusal keeps its live parent associated.
|
||||
s.execute("doc.switch", &json!({"index": 1})).unwrap();
|
||||
assert!(s.ds().unwrap().path.is_none());
|
||||
revert_refused_unchanged(&mut s, &script);
|
||||
assert_eq!(s.live_of(main_uid).doc_uid, Some(main_uid));
|
||||
s.execute("doc.switch", &json!({"index": 0})).unwrap();
|
||||
s.execute("version.preview", &json!({"index": 0})).unwrap();
|
||||
assert!(s.ds().unwrap().preview.is_some());
|
||||
// A gesture on a branch has not yet been copied back into its main file.
|
||||
s.docs[1].doc_mut().name = "Branch edits".into();
|
||||
revert_refused_unchanged(&mut s, &script);
|
||||
s.execute("version.exitPreview", &json!({})).unwrap();
|
||||
s.execute("text.preview", &json!({"command": "text.style", "params": {"ids": [text], "fontSize": 32}})).unwrap();
|
||||
assert!(s.ds().unwrap().text_preview.is_some());
|
||||
revert_refused_unchanged(&mut s, &script);
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revert_offline_file_works_while_another_file_is_live_and_after_leaving() {
|
||||
let (mut s, script) = session();
|
||||
let path = saved_file(&mut s);
|
||||
let uid = s.ds().unwrap().uid;
|
||||
s.execute("collab.host", &json!({})).unwrap();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let offline_path = saved_file(&mut s);
|
||||
s.execute("file.revert", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().name, "Untitled");
|
||||
assert!(!s.ds().unwrap().is_dirty());
|
||||
assert_eq!(s.ds().unwrap().versions.len(), 1);
|
||||
assert_eq!(s.live_of(uid).doc_uid, Some(uid));
|
||||
assert_eq!(script.lock().unwrap().left, 0);
|
||||
s.execute("doc.switch", &json!({"index": 0})).unwrap();
|
||||
s.execute("collab.leave", &json!({})).unwrap();
|
||||
s.execute("file.revert", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().name, "Untitled");
|
||||
assert!(!s.ds().unwrap().is_dirty());
|
||||
assert_eq!(s.ds().unwrap().versions.len(), 1);
|
||||
assert_eq!(s.live().doc_uid, None);
|
||||
assert_eq!(script.lock().unwrap().left, 1);
|
||||
std::fs::remove_file(path).unwrap();
|
||||
std::fs::remove_file(offline_path).unwrap();
|
||||
}
|
||||
@@ -28,6 +28,8 @@ pub(crate) struct Script {
|
||||
/// The session each presence in `sent` and each message in `msgs` was for.
|
||||
pub(crate) sent_to: Vec<u64>,
|
||||
pub(crate) msgs_to: Vec<u64>,
|
||||
/// The people removed: `(session, client)`.
|
||||
pub(crate) removed: Vec<(u64, u32)>,
|
||||
}
|
||||
|
||||
pub(crate) struct Mock(pub(crate) Arc<Mutex<Script>>);
|
||||
@@ -62,6 +64,10 @@ impl LiveLink for Mock {
|
||||
s.published.push(opts);
|
||||
Ok(())
|
||||
}
|
||||
fn remove(&mut self, session: u64, client: u32) -> Result<(), String> {
|
||||
self.0.lock().unwrap().removed.push((session, client));
|
||||
Ok(())
|
||||
}
|
||||
fn leave(&mut self, session: u64) {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
s.left += 1;
|
||||
@@ -314,6 +320,43 @@ fn the_host_ending_resets_everything() {
|
||||
assert_eq!(s.message.as_deref(), Some("The host ended the session"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_host_removes_people_and_guests_cant() {
|
||||
let (mut s, script) = session();
|
||||
let remove = |s: &mut Session, client: serde_json::Value| s.execute("collab.removePerson", &json!({ "client": client }));
|
||||
// Not in a session: nobody to remove.
|
||||
assert!(matches!(remove(&mut s, json!(2)), Err(EngineError::BadParams { .. })));
|
||||
assert!(!crate::live::can_remove_people(&s));
|
||||
s.execute("collab.host", &json!({})).unwrap();
|
||||
let id = s.live().id;
|
||||
push(&script, vec![Event::PeerJoined(peer(2, "Ada")), Event::PeerJoined(peer(3, "Bo"))]);
|
||||
s.live_poll();
|
||||
assert!(crate::live::can_remove_people(&s));
|
||||
// Only someone who is here, by a client id.
|
||||
for bad in [json!(9), json!(1), json!(-2), json!("2"), json!(4_294_967_296u64)] {
|
||||
assert!(matches!(remove(&mut s, bad.clone()), Err(EngineError::BadParams { .. })), "{bad}");
|
||||
}
|
||||
let r = remove(&mut s, json!(2)).unwrap();
|
||||
assert_eq!((r["removed"].as_u64(), r["name"].as_str()), (Some(2), Some("Ada")));
|
||||
assert_eq!(script.lock().unwrap().removed, [(id, 2)]);
|
||||
assert_eq!(s.message.as_deref(), Some("Ada was removed from the session"));
|
||||
// The host's own session tells them she left.
|
||||
push(&script, vec![Event::PeerLeft(2)]);
|
||||
s.live_poll();
|
||||
assert_eq!(s.live().peers.keys().copied().collect::<Vec<_>>(), [3]);
|
||||
s.execute("collab.stop", &json!({})).unwrap();
|
||||
|
||||
// A guest in someone else's session can't remove anyone.
|
||||
let (mut s, script) = session();
|
||||
s.execute("collab.join", &json!({"link": "figcraft://join?host=a:1&file=f&token=t"})).unwrap();
|
||||
push(&script, vec![Event::Welcome { info: info(Role::Guest, 2), peers: vec![peer(1, "Hana"), peer(3, "Bo")] }]);
|
||||
s.live_poll();
|
||||
assert!(!crate::live::can_remove_people(&s));
|
||||
let e = remove(&mut s, json!(3)).unwrap_err().to_string();
|
||||
assert!(e.contains("only the person hosting"), "{e}");
|
||||
assert!(script.lock().unwrap().removed.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collab_commands_survive_junk() {
|
||||
let junk = [
|
||||
|
||||
@@ -196,16 +196,17 @@ fn alt_drag_shows_live_distances_to_the_neighbours() {
|
||||
e
|
||||
};
|
||||
s.pointer(&ev(PointerKind::Down, 30.0)).unwrap();
|
||||
// Alt duplicates: the copy moves to 110..160, between the original and the other layer.
|
||||
// Alt duplicates: the copy, dragged to 110..160, snaps to equal spacing between the original
|
||||
// and the other layer (105..155).
|
||||
s.pointer(&ev(PointerKind::Move, 130.0)).unwrap();
|
||||
let g = s.tool.overlay.guides.clone();
|
||||
assert_eq!(
|
||||
labelled(&g),
|
||||
vec![
|
||||
("30".into(), [135, 0, 135, 30]),
|
||||
("30".into(), [135, 70, 135, 100]),
|
||||
("40".into(), [160, 50, 200, 50]),
|
||||
("50".into(), [60, 50, 110, 50])
|
||||
("30".into(), [130, 0, 130, 30]),
|
||||
("30".into(), [130, 70, 130, 100]),
|
||||
("45".into(), [60, 50, 105, 50]),
|
||||
("45".into(), [155, 50, 200, 50])
|
||||
]
|
||||
);
|
||||
s.pointer(&ev(PointerKind::Up, 130.0)).unwrap();
|
||||
|
||||
@@ -33,6 +33,19 @@ fn open_recent_lists_opens_prunes_and_clears() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_recent_file_is_forgotten_and_nothing_opens() {
|
||||
let mut s = Session::empty();
|
||||
s.prefs.recent_files = vec!["/tmp/a.figcraft".into(), "/tmp/b.fig".into(), "/tmp/c.svg".into()];
|
||||
let list = s.execute("file.openRecent", &json!({"remove": "/tmp/b.fig"})).unwrap();
|
||||
assert_eq!(s.prefs.recent_files, vec!["/tmp/a.figcraft".to_string(), "/tmp/c.svg".to_string()]);
|
||||
assert_eq!(list["files"].as_array().map(Vec::len), Some(2), "the list as it is now");
|
||||
assert!(s.docs.is_empty(), "nothing opens");
|
||||
// A path that isn't in the list changes nothing.
|
||||
s.execute("file.openRecent", &json!({"remove": "/tmp/zzz.fig"})).unwrap();
|
||||
assert_eq!(s.prefs.recent_files.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_files_are_capped() {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-recent-cap-{}", std::process::id()));
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Opening a native file that lost something (a damaged image): the session says so, with the
|
||||
//! reason, and the file still opens.
|
||||
|
||||
use figcraft_doc::Document;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// A 1×1 PNG.
|
||||
const PNG: [u8; 67] = [
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x60, 0x00, 0x00, 0x02,
|
||||
0x00, 0x00, 0x01, 0xe2, 0x21, 0xbc, 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
||||
];
|
||||
|
||||
fn file(name: &str, damage: bool) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-open-issues-{}-{name}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut d = Document::new("Pics");
|
||||
figcraft_format::add_image(&mut d, PNG.to_vec()).unwrap();
|
||||
let mut bytes = figcraft_format::save_bytes(&d, None).unwrap();
|
||||
if damage {
|
||||
// Images are stored as they are: change one byte of the PNG, not its checksum.
|
||||
let at = bytes.windows(PNG.len()).position(|w| w == PNG).unwrap();
|
||||
bytes[at + 30] ^= 0xff;
|
||||
}
|
||||
let path = dir.join("pics.figcraft");
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_with_a_damaged_image_opens_and_says_what_was_lost() {
|
||||
let path = file("damaged", true);
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert!(r["repairs"].as_array().is_some_and(|a| a.len() == 1), "{r}");
|
||||
let m = s.message.take().expect("the session says so");
|
||||
assert!(m.starts_with("pics: image ") && m.contains("couldn't be read") && m.contains("drops it"), "{m}");
|
||||
let _ = std::fs::remove_dir_all(path.parent().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_intact_file_opens_without_a_message() {
|
||||
let path = file("intact", false);
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert!(r["repairs"].as_array().is_some_and(Vec::is_empty), "{r}");
|
||||
assert!(s.message.is_none());
|
||||
let _ = std::fs::remove_dir_all(path.parent().unwrap());
|
||||
}
|
||||
@@ -337,6 +337,34 @@ fn pages_by_the_documented_rules() {
|
||||
assert_ne!(s.page().unwrap(), div, "deleting the open page opens a page, never a divider");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_move_refuses_what_is_not_a_page() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s, "r", 30.0, 40.0, None);
|
||||
let before = pages(&mut s);
|
||||
assert!(s.execute("page.move", &json!({"id": r, "index": 0})).is_err(), "a layer id is not a page");
|
||||
assert!(s.execute("page.move", &json!({"id": 999999, "index": 0})).is_err(), "a page that isn't there is not a page");
|
||||
assert_eq!(pages(&mut s), before, "the page list is untouched");
|
||||
assert!(s.doc().unwrap().validate().is_empty(), "the document still checks out");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_select_and_doc_switch_refuse_negative_or_fractional_indexes() {
|
||||
let mut s = Session::new();
|
||||
let p2 = id(&ex(&mut s, "page.add", json!({})));
|
||||
for bad_index in [json!(-1), json!(0.5)] {
|
||||
assert!(s.execute("page.select", &json!({"index": bad_index})).is_err(), "page.select index {bad_index} must fail");
|
||||
assert_eq!(s.page().unwrap(), p2, "page.select index {bad_index} leaves the open page alone");
|
||||
}
|
||||
ex(&mut s, "file.new", json!({}));
|
||||
let active = s.active;
|
||||
assert_eq!(active, 1);
|
||||
for bad_index in [json!(-1), json!(0.5)] {
|
||||
assert!(s.execute("doc.switch", &json!({"index": bad_index})).is_err(), "doc.switch index {bad_index} must fail");
|
||||
assert_eq!(s.active, active, "doc.switch index {bad_index} leaves the active tab alone");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assets_panel_lists_files_and_drops_by_the_documented_rules() {
|
||||
let mut s = Session::new();
|
||||
@@ -386,3 +414,14 @@ fn assets_panel_lists_files_and_drops_by_the_documented_rules() {
|
||||
assert_eq!(kids(&s, row).first(), Some(&into), "an asset dropped in an auto layout frame joins the flow where it is dropped");
|
||||
assert_eq!(s.doc().unwrap().page_of(into), Some(page));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicating_a_layer_named_past_the_counter_does_not_overflow() {
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "pref.renameDuplicates", json!({"on": true}));
|
||||
let r = rect(&mut s, "r", 30.0, 40.0, None);
|
||||
ex(&mut s, "node.set", json!({"id": r, "props": {"name": "Layer 18446744073709551615"}}));
|
||||
let dup = id_of(&ex(&mut s, "edit.duplicate", json!({"ids": [r]}))["ids"][0]).unwrap();
|
||||
assert_eq!(name(&s, dup), "Layer 18446744073709551615 2");
|
||||
assert!(s.doc().unwrap().validate().is_empty(), "the document still checks out");
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
use figcraft_color::{BlendMode, Color, Paint};
|
||||
use figcraft_doc::NodeId;
|
||||
use serde_json::Value;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{EngineError, Result, Session, bad};
|
||||
|
||||
@@ -135,12 +135,80 @@ pub fn color(p: &Value, key: &str) -> Option<Color> {
|
||||
get(p, key).and_then(color_of)
|
||||
}
|
||||
|
||||
/// A paint from JSON: a colour string/object (solid) or a full paint object.
|
||||
/// Read the friendlier paint/effect JSON that the docs and MCP tools describe as the engine's own
|
||||
/// form: any `"color"` given as a CSS string ("#RRGGBB[AA]", "rgb(…)") becomes {r,g,b,a},
|
||||
/// `{"type":"linearGradient"}` becomes `{"type":"gradient","kind":"linear"}`, gradients get default
|
||||
/// handles (top to bottom) when `from`/`to`/`width` are missing, and stops given as plain colours
|
||||
/// are spread evenly. Anything else is left for serde to refuse.
|
||||
pub fn normalize_paint(v: &mut Value) {
|
||||
normalize_paintish(v, 0);
|
||||
}
|
||||
|
||||
fn normalize_paintish(v: &mut Value, depth: usize) {
|
||||
if depth > 16 {
|
||||
return;
|
||||
}
|
||||
match v {
|
||||
Value::Array(a) => {
|
||||
for x in a {
|
||||
normalize_paintish(x, depth + 1);
|
||||
}
|
||||
}
|
||||
Value::Object(o) => {
|
||||
if let Some(c) = o.get("color").filter(|c| c.is_string()).and_then(color_of) {
|
||||
o.insert("color".into(), serde_json::to_value(c).unwrap_or(Value::Null));
|
||||
}
|
||||
let is_gradient = o.get("type").and_then(Value::as_str).is_some_and(|t| t.to_ascii_lowercase().contains("gradient"));
|
||||
if is_gradient {
|
||||
// Accept {"type":"linearGradient"} as well as {"type":"gradient","kind":"linear"}.
|
||||
if let Some(t) = o.get("type").and_then(Value::as_str).map(str::to_ascii_lowercase)
|
||||
&& t != "gradient"
|
||||
{
|
||||
let kind = t.trim_end_matches("gradient").to_string();
|
||||
o.insert("type".into(), json!("gradient"));
|
||||
o.entry("kind").or_insert(json!(if kind.is_empty() { "linear".to_string() } else { kind }));
|
||||
}
|
||||
o.entry("kind").or_insert(json!("linear"));
|
||||
let linear = o.get("kind").and_then(Value::as_str) == Some("linear");
|
||||
let (from, to, width) = if linear { ([0.5, 0.0], [0.5, 1.0], [-0.5, 0.0]) } else { ([0.5, 0.5], [0.5, 1.0], [1.0, 0.5]) };
|
||||
o.entry("from").or_insert(json!(from));
|
||||
o.entry("to").or_insert(json!(to));
|
||||
// A linear gradient's width handle goes square to its axis (else its bands skew,
|
||||
// or vanish when the handle lies on the axis).
|
||||
let pt = |k: &str| o.get(k).and_then(Value::as_array).and_then(|a| Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?)));
|
||||
if linear
|
||||
&& !o.contains_key("width")
|
||||
&& let (Some(f), Some(t)) = (pt("from"), pt("to"))
|
||||
{
|
||||
o.insert("width".into(), json!([f.0 - (t.1 - f.1), f.1 + (t.0 - f.0)]));
|
||||
}
|
||||
o.entry("width").or_insert(json!(width));
|
||||
if let Some(Value::Array(stops)) = o.get_mut("stops") {
|
||||
let n = stops.len();
|
||||
for (i, s) in stops.iter_mut().enumerate() {
|
||||
if let Some(c) = color_of(s).filter(|_| s.is_string()) {
|
||||
let pos = if n > 1 { i as f64 / (n - 1) as f64 } else { 0.0 };
|
||||
*s = json!({"position": pos, "color": c});
|
||||
} else {
|
||||
normalize_paintish(s, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// A paint from JSON: a colour string/object (solid), a full paint object or its shorthand
|
||||
/// ([`normalize_paint`]).
|
||||
pub fn paint_of(v: &Value) -> Option<Paint> {
|
||||
if let Some(c) = color_of(v) {
|
||||
return Some(Paint::solid(c));
|
||||
}
|
||||
let mut p: Paint = serde_json::from_value(v.clone()).ok()?;
|
||||
let mut v = v.clone();
|
||||
normalize_paint(&mut v);
|
||||
let mut p: Paint = serde_json::from_value(v).ok()?;
|
||||
p.opacity = if p.opacity.is_finite() { p.opacity.clamp(0.0, 1.0) } else { 1.0 };
|
||||
Some(p)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//! What the Pen tool shows while a path is drawn (`tool.overlay`): the handles shaping the segments
|
||||
//! at the pen, and the rubber band to the pointer, which follows the last anchor's out handle.
|
||||
|
||||
use crate::Session;
|
||||
use crate::tools::{PointerEvent, PointerKind, ToolId};
|
||||
|
||||
fn at(s: &mut Session, kind: PointerKind, x: f64, y: f64) {
|
||||
s.pointer(&PointerEvent::at(kind, x, y, 1.0)).unwrap();
|
||||
}
|
||||
|
||||
/// A press at (x, y) dragged to (to_x, to_y) and released: a smooth point with out handle
|
||||
/// (to - p).
|
||||
fn drag_point(s: &mut Session, x: f64, y: f64, to_x: f64, to_y: f64) {
|
||||
at(s, PointerKind::Down, x, y);
|
||||
at(s, PointerKind::Move, to_x, to_y);
|
||||
at(s, PointerKind::Up, to_x, to_y);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_dragged_point_shows_its_handles_and_the_one_before_its_out_handle() {
|
||||
let mut s = Session::new();
|
||||
s.set_tool(ToolId::Pen);
|
||||
drag_point(&mut s, 100.0, 100.0, 140.0, 100.0);
|
||||
assert_eq!(s.tool.overlay.pen_handles, vec![[100.0, 100.0, 60.0, 100.0], [100.0, 100.0, 140.0, 100.0]], "in and out of the first point");
|
||||
// The second point, mid-drag.
|
||||
at(&mut s, PointerKind::Down, 200.0, 200.0);
|
||||
assert_eq!(s.tool.overlay.rubber, None, "the press shows the segment itself, not a straight band");
|
||||
at(&mut s, PointerKind::Move, 200.0, 240.0);
|
||||
assert_eq!(
|
||||
s.tool.overlay.pen_handles,
|
||||
vec![[100.0, 100.0, 140.0, 100.0], [200.0, 200.0, 200.0, 160.0], [200.0, 200.0, 200.0, 240.0]],
|
||||
"the one before's out handle, then the dragged point's in and out"
|
||||
);
|
||||
at(&mut s, PointerKind::Up, 200.0, 240.0);
|
||||
// Moving on: the band leaves along the last out handle.
|
||||
at(&mut s, PointerKind::Hover, 300.0, 200.0);
|
||||
assert_eq!(s.tool.overlay.rubber, Some([200.0, 200.0, 300.0, 200.0]));
|
||||
assert_eq!(s.tool.overlay.rubber_handle, Some([200.0, 240.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_points_have_no_handles_and_a_straight_band() {
|
||||
let mut s = Session::new();
|
||||
s.set_tool(ToolId::Pen);
|
||||
for (x, y) in [(100.0, 100.0), (200.0, 100.0)] {
|
||||
at(&mut s, PointerKind::Down, x, y);
|
||||
at(&mut s, PointerKind::Up, x, y);
|
||||
}
|
||||
at(&mut s, PointerKind::Hover, 250.0, 200.0);
|
||||
assert!(s.tool.overlay.pen_handles.is_empty());
|
||||
assert_eq!(s.tool.overlay.rubber, Some([200.0, 100.0, 250.0, 200.0]));
|
||||
assert_eq!(s.tool.overlay.rubber_handle, None, "straight");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finishing_the_path_clears_its_handles_and_band() {
|
||||
let mut s = Session::new();
|
||||
s.set_tool(ToolId::Pen);
|
||||
drag_point(&mut s, 100.0, 100.0, 140.0, 100.0);
|
||||
drag_point(&mut s, 200.0, 200.0, 200.0, 240.0);
|
||||
at(&mut s, PointerKind::Hover, 300.0, 200.0);
|
||||
s.pen_finish().unwrap();
|
||||
let ov = &s.tool.overlay;
|
||||
assert!(ov.pen.is_empty() && ov.pen_handles.is_empty() && ov.rubber.is_none() && ov.rubber_handle.is_none());
|
||||
}
|
||||
@@ -12,7 +12,7 @@ use figcraft_doc::NodeId;
|
||||
/// What a test plugin does when a call is refused: fail the run (status 1), or carry on and report
|
||||
/// the refusal as its output.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum OnRefusal {
|
||||
pub(crate) enum OnRefusal {
|
||||
Fail,
|
||||
Report,
|
||||
}
|
||||
@@ -26,7 +26,7 @@ pub(crate) fn lit(s: &str) -> String {
|
||||
|
||||
/// A module that reads the selection, makes `calls` in order and outputs the selection it read
|
||||
/// (or the first refusal). `tail` runs after the calls (an endless loop, a trap…).
|
||||
fn script(calls: &[(&str, Value)], on_refusal: OnRefusal, tail: &str) -> Vec<u8> {
|
||||
pub(crate) fn script(calls: &[(&str, Value)], on_refusal: OnRefusal, tail: &str) -> Vec<u8> {
|
||||
let mut data = String::new();
|
||||
let mut body = String::from("(local.set $sel (call $reply_read (i32.const 60000) (call $selection)))\n");
|
||||
let mut at = 0usize;
|
||||
@@ -51,6 +51,7 @@ fn script(calls: &[(&str, Value)], on_refusal: OnRefusal, tail: &str) -> Vec<u8>
|
||||
(import "figcraft" "reply_read" (func $reply_read (param i32 i32) (result i32)))
|
||||
(import "figcraft" "set_output" (func $set_output (param i32 i32)))
|
||||
(import "figcraft" "notify" (func $notify (param i32 i32)))
|
||||
(import "figcraft" "log" (func $log (param i32 i32)))
|
||||
(memory (export "memory") 2)
|
||||
{data}
|
||||
(func (export "figcraft_run") (param $n i32) (result i32) (local $sel i32)
|
||||
@@ -113,7 +114,7 @@ fn a_run_reads_the_selection_and_its_edits_are_one_undo_step() {
|
||||
let (layers, undo) = (layer_count(&s), undo_len(&s));
|
||||
let out = ex(&mut s, "plugin.run", json!({"id": "org.test.tidy"}));
|
||||
assert_eq!(out["output"], json!([a.0]), "the plugin read the selection");
|
||||
assert_eq!(out["calls"], json!(4));
|
||||
assert_eq!(out["calls"], json!(6));
|
||||
assert_eq!(node(&s, a).name, "Tidy");
|
||||
assert_eq!(layer_count(&s), layers + 1);
|
||||
assert_eq!(undo_len(&s), undo + 1, "one undo step for the whole run");
|
||||
@@ -196,9 +197,11 @@ fn traps_endless_loops_and_memory_hogs_roll_back() {
|
||||
let a = rect(&mut s, 0.0);
|
||||
ex(&mut s, "select.set", json!({"ids": [a]}));
|
||||
let edit = [("shape.rectangle", json!({"x": 90, "y": 0, "width": 5, "height": 5})), ("object.rename", json!({"name": "Half done"}))];
|
||||
let bulk_output = "(call $set_output (i32.const 0) (i32.const 65536))".repeat(32);
|
||||
let cases = [
|
||||
("org.test.trap", "unreachable", "crashed"),
|
||||
("org.test.loop", "(loop $l (br $l))", "instruction budget"),
|
||||
("org.test.bulk", bulk_output.as_str(), "instruction budget"),
|
||||
("org.test.grow", "(if (i32.eq (memory.grow (i32.const 2000)) (i32.const -1)) (then unreachable))", "crashed"),
|
||||
];
|
||||
for (id, tail, want) in cases {
|
||||
@@ -218,6 +221,26 @@ fn traps_endless_loops_and_memory_hogs_roll_back() {
|
||||
assert!(run_err(&mut s, "org.test.hog").contains("memory limit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exceeding_a_log_limit_rolls_back_the_plugins_edits() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0.0);
|
||||
ex(&mut s, "select.set", json!({"ids": [a]}));
|
||||
let edits = [("object.rename", json!({"name": "Half done"})), ("shape.rectangle", json!({"x": 90, "y": 0, "width": 5, "height": 5}))];
|
||||
let wasm = script(&edits, OnRefusal::Fail, "(call $log (i32.const 80000) (i32.const 8193))");
|
||||
let id = "org.test.log-limit";
|
||||
install(&mut s, manifest(id, "write", &[]), &wasm);
|
||||
let ds = s.ds().unwrap();
|
||||
let (doc, page, selection, undo) = (ds.doc.clone(), ds.page, s.selection(), undo_len(&s));
|
||||
let error = run_err(&mut s, id);
|
||||
assert!(error.contains("log line") && error.contains("8192"), "{error}");
|
||||
assert_eq!(s.doc().unwrap(), doc.as_ref());
|
||||
assert_eq!(s.ds().unwrap().page, page);
|
||||
assert_eq!(s.selection(), selection);
|
||||
assert_eq!(undo_len(&s), undo);
|
||||
assert_eq!(node(&s, a).name, "Rectangle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameters_defaults_and_notifications_reach_the_plugin() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -431,3 +431,31 @@ fn flatten_and_outline_stroke() {
|
||||
let (path, _) = figcraft_render::node_outline(s.doc().unwrap(), &n).unwrap();
|
||||
assert!((path.bounding_box().height() - 10.0).abs() < 1e-6, "the outline is as thick as the stroke was");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_stroke_outlines_every_selected_layer() {
|
||||
// Fixed (#378): with several filled, stroked shapes selected, only the first was outlined.
|
||||
for by_selection in [false, true] {
|
||||
let mut s = Session::new();
|
||||
let a = create(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}));
|
||||
let b = create(&mut s, "shape.rectangle", json!({"x": 100, "y": 0, "width": 50, "height": 50}));
|
||||
let line = create(&mut s, "shape.line", json!({"x": 0, "y": 100, "width": 100, "props": {"strokeWeight": 4}}));
|
||||
ex(&mut s, "node.set", json!({"ids": [a, b], "props": {"strokes": ["#000000"], "strokeWeight": 4}}));
|
||||
let out = if by_selection {
|
||||
s.set_selection(vec![a, b, line]);
|
||||
ex(&mut s, "vector.outlineStroke", json!({}))
|
||||
} else {
|
||||
ex(&mut s, "vector.outlineStroke", json!({"ids": [a, b, line]}))
|
||||
};
|
||||
let ids: Vec<NodeId> = out["ids"].as_array().unwrap().iter().filter_map(id_of).collect();
|
||||
assert_eq!(ids.len(), 3, "one result per layer: {out}");
|
||||
assert!(matches!(node(&s, ids[0]).kind, NodeKind::Group), "filled shape a becomes a group");
|
||||
assert!(matches!(node(&s, ids[1]).kind, NodeKind::Group), "filled shape b becomes a group");
|
||||
assert!(matches!(node(&s, ids[2]).kind, NodeKind::Vector { .. }), "the line becomes a vector");
|
||||
for id in [a, b] {
|
||||
assert!(node(&s, id).strokes.is_empty(), "{id:?} still has its stroke");
|
||||
}
|
||||
assert!(s.doc().unwrap().node(line).is_none(), "the line was replaced by its outline");
|
||||
assert_eq!(s.selection(), ids, "the results are selected");
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,7 @@ static ROWS: &[Row] = &[
|
||||
doc(Essential, "Save to version history", &["Alt+Cmd+S"], &["Ctrl+Alt+S"], Bound("file.saveVersion"), ""),
|
||||
doc(Essential, "Run the last plugin", &["Alt+Cmd+P"], &["Ctrl+Alt+P"], Moved("plugins.runLast", &["Alt+Cmd+P"], &["Ctrl+Alt+Shift+P"]),
|
||||
"conflict: the documentation also gives Ctrl+Alt+P to pixel preview on Windows; pixel preview keeps it, the plugin takes Ctrl+Alt+Shift+P"),
|
||||
pin(Essential, "view.fullscreen", &["Ctrl+Cmd+F"], &["F11"]),
|
||||
pin(Essential, "app.preferences", &["Cmd+,"], &[]),
|
||||
pin(Essential, "file.new", &["Cmd+N"], &[]).reserved(BROWSER_TAB),
|
||||
pin(Essential, "file.open", &["Cmd+O"], &[]),
|
||||
@@ -397,7 +398,7 @@ fn counts_match_the_docs() {
|
||||
let reserved = documented.iter().filter(|r| !r.reserved.is_empty()).count();
|
||||
let pinned = ROWS.len() - documented.len();
|
||||
// docs/ui-parity.md quotes these.
|
||||
assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (129, 128, 1, 0, 2, 48));
|
||||
assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (129, 128, 1, 0, 2, 49));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+12
-10
@@ -74,15 +74,6 @@ impl Targets {
|
||||
// A layer's box in snap space.
|
||||
let bbox = |id: NodeId| doc.node(id).map(|n| transform_rect_bbox(to * doc.world_transform(id), n.local_rect())).filter(|b| b.is_finite());
|
||||
let mut t = Targets { space, moving: moving.iter().filter_map(|m| bbox(*m)).reduce(|a, b| a.union(b)), ..Default::default() };
|
||||
let push = |r: Rect, t: &mut Targets| {
|
||||
for x in [r.x0, r.center().x, r.x1] {
|
||||
t.xs.push((x, r.y0, r.y1));
|
||||
}
|
||||
for y in [r.y0, r.center().y, r.y1] {
|
||||
t.ys.push((y, r.x0, r.x1));
|
||||
}
|
||||
t.boxes.push(r);
|
||||
};
|
||||
let parent = parent_of(doc, page, moving);
|
||||
// The parent frame's own box (and its layout grids).
|
||||
if parent != page
|
||||
@@ -109,7 +100,7 @@ impl Targets {
|
||||
continue;
|
||||
}
|
||||
if let Some(b) = bbox(*c) {
|
||||
push(b, &mut t);
|
||||
t.add_box(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,6 +127,17 @@ impl Targets {
|
||||
t
|
||||
}
|
||||
|
||||
/// A box to snap to (in snap space): its edges and centre lines, and equal spacing with it.
|
||||
pub fn add_box(&mut self, r: Rect) {
|
||||
for x in [r.x0, r.center().x, r.x1] {
|
||||
self.xs.push((x, r.y0, r.y1));
|
||||
}
|
||||
for y in [r.y0, r.center().y, r.y1] {
|
||||
self.ys.push((y, r.x0, r.x1));
|
||||
}
|
||||
self.boxes.push(r);
|
||||
}
|
||||
|
||||
/// A point to snap onto (a vector point, a segment midpoint): lines through it on both axes,
|
||||
/// and a marker when a moving point lands on it.
|
||||
pub fn add_point(&mut self, p: Point) {
|
||||
|
||||
@@ -24,6 +24,42 @@ fn create_select_undo_redo() {
|
||||
assert!(s.doc().unwrap().contains(rid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeat_preserves_command_and_undo_steps() {
|
||||
let mut s = sess();
|
||||
let rid = id(&s.execute("shape.rectangle", &json!({"x": 10, "y": 20, "width": 10, "height": 10})).unwrap());
|
||||
let params = json!({"ids": [rid], "dx": 2, "dy": 3});
|
||||
s.execute("transform.nudge", ¶ms).unwrap();
|
||||
let repeated = Some(("transform.nudge".to_string(), params));
|
||||
for count in 2..=4 {
|
||||
s.execute("edit.repeat", &json!({})).unwrap();
|
||||
assert_eq!(s.last_command, repeated);
|
||||
let bounds = s.doc().unwrap().world_bounds(rid).unwrap();
|
||||
assert_eq!((bounds.x0, bounds.y0), (10.0 + 2.0 * count as f64, 20.0 + 3.0 * count as f64));
|
||||
assert_eq!(s.undo_depth(), (count + 1, 0));
|
||||
}
|
||||
for count in (0..4).rev() {
|
||||
assert!(s.undo().unwrap());
|
||||
let bounds = s.doc().unwrap().world_bounds(rid).unwrap();
|
||||
assert_eq!((bounds.x0, bounds.y0), (10.0 + 2.0 * count as f64, 20.0 + 3.0 * count as f64));
|
||||
}
|
||||
assert_eq!(s.undo_depth(), (1, 4));
|
||||
assert!(s.undo().unwrap());
|
||||
assert!(!s.doc().unwrap().contains(rid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeat_rejects_missing_command_and_self_reference() {
|
||||
for last in [None, Some(("edit.repeat".to_string(), json!({})))] {
|
||||
let mut s = sess();
|
||||
s.last_command = last.clone();
|
||||
assert!(matches!(s.execute("edit.repeat", &json!({})), Err(EngineError::Disabled(cmd, _)) if cmd == "edit.repeat"));
|
||||
assert_eq!(s.last_command, last);
|
||||
assert_eq!(s.undo_depth(), (0, 0));
|
||||
assert!(s.journal.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_set_and_errors_roll_back() {
|
||||
let mut s = sess();
|
||||
@@ -40,6 +76,36 @@ fn node_set_and_errors_roll_back() {
|
||||
assert!(s.execute("no.such", &json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_batch_restores_all_documents_and_history() {
|
||||
let mut s = sess();
|
||||
let original = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
|
||||
s.execute("file.new", &json!({"name": "Second"})).unwrap();
|
||||
s.execute("doc.nextTab", &json!({})).unwrap();
|
||||
let before_journal = s.journal.clone();
|
||||
let before_history: Vec<_> = s.docs.iter().map(|d| d.history.undo.len()).collect();
|
||||
|
||||
let result = s.execute(
|
||||
"command.batch",
|
||||
&json!({
|
||||
"label": "Broken batch",
|
||||
"commands": [
|
||||
{"command": "doc.nextTab"},
|
||||
{"command": "shape.rectangle", "params": {"x": 20, "y": 20, "width": 10, "height": 10}},
|
||||
{"command": "node.set", "params": {"id": 999999, "props": {"name": "must fail"}}}
|
||||
]
|
||||
}),
|
||||
);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(s.active, 0, "the active document is part of the transaction");
|
||||
assert_eq!(s.docs.len(), 2, "a failed batch cannot leave a newly opened tab behind");
|
||||
assert_eq!(s.doc().unwrap().node(original).unwrap().name, "Rectangle");
|
||||
assert_eq!(s.docs[1].doc.node(s.docs[1].page).map_or(0, |page| page.children.len()), 0);
|
||||
assert_eq!(s.docs.iter().map(|d| d.history.undo.len()).collect::<Vec<_>>(), before_history);
|
||||
assert_eq!(s.journal, before_journal, "failed inner commands are not journaled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_ungroup_and_frame() {
|
||||
let mut s = sess();
|
||||
@@ -95,6 +161,28 @@ fn text_create_autosize_and_edit() {
|
||||
assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "Hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_replace_replaces_every_occurrence_when_replacement_contains_find() {
|
||||
for (text, find, replace, want) in [
|
||||
("cat and cat", "cat", "cats", "cats and cats"),
|
||||
("cat and cat", "cat", "category", "category and category"),
|
||||
("banana", "a", "aa", "baanaanaa"),
|
||||
] {
|
||||
let mut s = sess();
|
||||
let t = id(&s.execute("text.create", &json!({"text": text, "x": 0, "y": 0})).unwrap());
|
||||
s.execute("edit.findReplace", &json!({"find": find, "replace": replace})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, want, "{text}: {find} -> {replace}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_replace_rejects_empty_find() {
|
||||
let mut s = sess();
|
||||
let t = id(&s.execute("text.create", &json!({"text": "cat", "x": 0, "y": 0})).unwrap());
|
||||
assert!(s.execute("edit.findReplace", &json!({"find": "", "replace": "x"})).is_err());
|
||||
assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "cat");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn components_and_instances_sync() {
|
||||
let mut s = sess();
|
||||
@@ -187,6 +275,49 @@ fn align_distribute_zorder() {
|
||||
assert_eq!(s.doc().unwrap().node(page).unwrap().children[0], c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_alone_on_the_page_has_nothing_to_align_to() {
|
||||
use crate::cmd::arrange::can_align;
|
||||
let mut s = sess();
|
||||
let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap());
|
||||
let inner = id(&s.execute("shape.rectangle", &json!({"x": 20, "y": 30, "width": 10, "height": 10, "parent": f})).unwrap());
|
||||
let loose = id(&s.execute("shape.rectangle", &json!({"x": 300, "y": 5, "width": 10, "height": 10})).unwrap());
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(!can_align(doc, &[]));
|
||||
assert!(!can_align(doc, &[loose]), "alone on the page");
|
||||
assert!(!can_align(doc, &[f, inner]), "a frame with its own child is the frame alone");
|
||||
assert!(can_align(doc, &[inner]), "one in a frame aligns to it");
|
||||
assert!(can_align(doc, &[f, loose]), "two align to each other");
|
||||
// Where the buttons are off, the command still does nothing (and no harm).
|
||||
let before = doc.world_bounds(loose);
|
||||
s.execute("align.right", &json!({"ids": [loose]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().world_bounds(loose), before);
|
||||
s.execute("align.right", &json!({"ids": [inner]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().world_bounds(inner).unwrap().x1, 200.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_to_back_across_frames_counts_per_parent() {
|
||||
let mut s = sess();
|
||||
let make = |s: &mut Session, p: Value| id(&s.execute("node.create", &p).unwrap());
|
||||
let fa = make(&mut s, json!({"type": "frame", "x": 0, "y": 0, "width": 200, "height": 200}));
|
||||
let fb = make(&mut s, json!({"type": "frame", "x": 300, "y": 0, "width": 200, "height": 200}));
|
||||
let rect = |parent: NodeId| json!({"type": "rectangle", "parent": parent, "x": 10, "y": 10, "width": 20, "height": 20});
|
||||
let a: Vec<NodeId> = (0..2).map(|_| make(&mut s, rect(fa))).collect();
|
||||
let b: Vec<NodeId> = (0..3).map(|_| make(&mut s, rect(fb))).collect();
|
||||
let idx = |s: &Session, n: NodeId| s.doc().unwrap().index_in_parent(n);
|
||||
assert_eq!(idx(&s, a[1]), Some(1));
|
||||
assert_eq!(idx(&s, b[2]), Some(2));
|
||||
s.execute("arrange.back", &json!({"ids": [a[1], b[2]]})).unwrap();
|
||||
assert_eq!(idx(&s, a[1]), Some(0), "top of frame A at the back");
|
||||
assert_eq!(idx(&s, b[2]), Some(0), "top of frame B at the back");
|
||||
// Two from B plus one from A: each parent's selection ends at 0..n in its original order.
|
||||
s.execute("arrange.back", &json!({"ids": [b[1], a[0], b[0]]})).unwrap();
|
||||
let kids = |s: &Session, f: NodeId| s.doc().unwrap().node(f).unwrap().children.clone();
|
||||
assert_eq!(kids(&s, fa), vec![a[0], a[1]]);
|
||||
assert_eq!(kids(&s, fb), vec![b[0], b[1], b[2]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_paste_and_duplicate() {
|
||||
let mut s = sess();
|
||||
@@ -466,3 +597,27 @@ fn guides_add_move_remove() {
|
||||
s.execute("guide.clear", &json!({})).unwrap();
|
||||
assert!(guides(&s).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_replace_matches_and_replaces_case_insensitively() {
|
||||
let mut s = sess();
|
||||
let t = id(&s.execute("text.create", &json!({"text": "Hello", "x": 0, "y": 0})).unwrap());
|
||||
let u = id(&s.execute("text.create", &json!({"text": "İ HELLO hello", "x": 0, "y": 40})).unwrap());
|
||||
let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 80, "width": 50, "height": 50, "name": "HELLO box"})).unwrap());
|
||||
let chars = |s: &Session, i| s.doc().unwrap().node(i).unwrap().kind.text().unwrap().characters.clone();
|
||||
let r = s.execute("edit.findReplace", &json!({"find": "hello", "replace": "bye", "layerNames": true})).unwrap();
|
||||
assert_eq!(r["matches"], json!([t, u, f]));
|
||||
assert_eq!(chars(&s, t), "bye");
|
||||
assert_eq!(chars(&s, u), "İ bye bye");
|
||||
assert_eq!(s.doc().unwrap().node(f).unwrap().name, "bye box");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_replace_changing_only_the_case_replaces_every_occurrence() {
|
||||
let mut s = sess();
|
||||
let t = id(&s.execute("text.create", &json!({"text": "figcraft and FIGCRAFT, figcraft", "x": 0, "y": 0})).unwrap());
|
||||
let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 80, "width": 50, "height": 50, "name": "figcraft / figcraft"})).unwrap());
|
||||
s.execute("edit.findReplace", &json!({"find": "figcraft", "replace": "FigCraft", "layerNames": true})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "FigCraft and FigCraft, FigCraft");
|
||||
assert_eq!(s.doc().unwrap().node(f).unwrap().name, "FigCraft / FigCraft");
|
||||
}
|
||||
@@ -192,3 +192,40 @@ fn paragraph_indent_only_moves_left_aligned_text() {
|
||||
let (x, w2) = first_x(&s);
|
||||
assert!((w2 - (w - 30.0)).abs() < 1e-6 && x.abs() < 1e-6, "centered auto-width text: no indent, the box shrinks by it");
|
||||
}
|
||||
|
||||
/// A new text made with the Text tool by a press at `from`, a drag to `to` and a release, holding
|
||||
/// `chars`: its box and resize mode.
|
||||
fn text_tool(from: (f64, f64), to: (f64, f64), chars: &str) -> (figcraft_geom::Rect, figcraft_doc::TextAutoResize) {
|
||||
use crate::tools::{PointerEvent, PointerKind, ToolId};
|
||||
let mut s = Session::new();
|
||||
s.set_tool(ToolId::Text);
|
||||
s.pointer(&PointerEvent::at(PointerKind::Down, from.0, from.1, 1.0)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Move, to.0, to.1, 1.0)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Up, to.0, to.1, 1.0)).unwrap();
|
||||
crate::textedit::insert(&mut s, chars).unwrap();
|
||||
let t = s.ds().unwrap().text_edit.as_ref().unwrap().id;
|
||||
let doc = s.doc().unwrap();
|
||||
let NodeKind::Text(td) = &doc.node(t).unwrap().kind else { panic!("not a text") };
|
||||
(doc.world_bounds(t).unwrap(), td.auto_resize)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drag_with_the_text_tool_makes_a_fixed_size_box() {
|
||||
// #360: the drag's size, its top-left where the drag started, and its text wraps in it; a
|
||||
// click, or a drag within a few pixels, still makes auto-width text.
|
||||
use figcraft_doc::TextAutoResize;
|
||||
figcraft_text::scan_system_fonts();
|
||||
let long = "A sentence much longer than eighty pixels";
|
||||
let (line, auto) = text_tool((100.0, 100.0), (100.0, 100.0), long);
|
||||
assert_eq!(auto, TextAutoResize::WidthAndHeight);
|
||||
assert_eq!(text_tool((100.0, 100.0), (101.0, 101.0), long).1, TextAutoResize::WidthAndHeight, "a slip of the hand is a click");
|
||||
let (b, auto) = text_tool((100.0, 100.0), (180.0, 400.0), long);
|
||||
assert_eq!(auto, TextAutoResize::None);
|
||||
assert_eq!((b.x0, b.y0, b.width(), b.height()), (100.0, 100.0, 80.0, 300.0), "the text wraps in a box that keeps its size");
|
||||
// Dragged up and to the left, the box is the same.
|
||||
let (b2, _) = text_tool((180.0, 400.0), (100.0, 100.0), long);
|
||||
assert_eq!((b2.x0, b2.y0, b2.width(), b2.height()), (100.0, 100.0, 80.0, 300.0));
|
||||
// A flat drag still makes a box one line tall.
|
||||
let (b3, _) = text_tool((100.0, 100.0), (180.0, 101.0), "A");
|
||||
assert_eq!((b3.width(), b3.height()), (80.0, line.height()));
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! The page colour of new files and pages follows the interface's theme when they are made (dark:
|
||||
//! #1E1E1E, light: #F5F5F5) and is the page's own from then on; a session without an interface
|
||||
//! (CLI, MCP, exports) has no theme and keeps the light default. Text drawn with the Text tool is
|
||||
//! white on a dark page and black on a light one.
|
||||
//! The page colour of a new file follows the interface's theme when it is made (dark: #1E1E1E,
|
||||
//! light: #F5F5F5) and is the page's own from then on; an added page takes the colour of the page
|
||||
//! it was added from. A session without an interface (CLI, MCP, exports) has no theme and keeps the
|
||||
//! light default. Text drawn with the Text tool is white on a dark page and black on a light one.
|
||||
|
||||
use figcraft_color::{Color, Paint};
|
||||
use figcraft_doc::{NodeId, NodeKind};
|
||||
@@ -38,8 +38,8 @@ fn draw_text(s: &mut Session, x: f64, y: f64) -> NodeId {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_files_and_pages_take_the_theme_they_are_made_in() {
|
||||
// (theme the interface reported, colour of a new file's page and of an added page)
|
||||
fn new_files_take_the_theme_they_are_made_in_and_added_pages_follow() {
|
||||
// (theme the interface reported, colour of a new file's page and of a page added to it)
|
||||
for (dark, want) in [(None, "#F5F5F5"), (Some(false), "#F5F5F5"), (Some(true), "#1E1E1E")] {
|
||||
let mut s = Session::empty();
|
||||
if let Some(d) = dark {
|
||||
@@ -62,8 +62,10 @@ fn a_change_of_theme_leaves_existing_pages_alone() {
|
||||
ex(&mut s, "file.new", json!({}));
|
||||
s.set_ui_dark(false);
|
||||
ex(&mut s, "page.add", json!({}));
|
||||
assert_eq!(backgrounds(&s), ["#1E1E1E", "#1E1E1E"], "an added page follows its page, not the theme");
|
||||
ex(&mut s, "page.setBackground", json!({"color": "#F5F5F5"}));
|
||||
s.set_ui_dark(true);
|
||||
assert_eq!(backgrounds(&s), ["#1E1E1E", "#F5F5F5"], "each page keeps the colour it was made with");
|
||||
assert_eq!(backgrounds(&s), ["#1E1E1E", "#F5F5F5"], "each page keeps its colour");
|
||||
// A copy of a page is that page's colour, whatever the theme.
|
||||
let first = s.doc().unwrap().pages[0];
|
||||
s.set_ui_dark(false);
|
||||
@@ -71,6 +73,57 @@ fn a_change_of_theme_leaves_existing_pages_alone() {
|
||||
assert_eq!(background(&s, copy), Color::DARK_PAGE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_added_page_takes_the_current_pages_colour() {
|
||||
// Whatever the theme, and headless too: the page it was added from decides.
|
||||
for dark in [None, Some(false), Some(true)] {
|
||||
let mut s = Session::empty();
|
||||
if let Some(d) = dark {
|
||||
s.set_ui_dark(d);
|
||||
}
|
||||
ex(&mut s, "file.new", json!({}));
|
||||
ex(&mut s, "page.setBackground", json!({"color": "#336699"}));
|
||||
ex(&mut s, "page.add", json!({}));
|
||||
assert_eq!(backgrounds(&s), ["#336699", "#336699"], "theme {dark:?}");
|
||||
// From the second page, now current, after giving it its own colour.
|
||||
ex(&mut s, "page.setBackground", json!({"color": "#FFE08A"}));
|
||||
ex(&mut s, "page.add", json!({"name": "Third"}));
|
||||
assert_eq!(backgrounds(&s), ["#336699", "#FFE08A", "#FFE08A"], "theme {dark:?}");
|
||||
// One undo step, the colour with it.
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(backgrounds(&s), ["#336699", "#FFE08A"], "theme {dark:?} after undo");
|
||||
ex(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(backgrounds(&s), ["#336699", "#FFE08A", "#FFE08A"], "theme {dark:?} after redo");
|
||||
}
|
||||
// From a page other than the first: go there, then add.
|
||||
let mut s = Session::new();
|
||||
s.set_ui_dark(true);
|
||||
ex(&mut s, "page.add", json!({}));
|
||||
ex(&mut s, "page.setBackground", json!({"color": "#0B1F3A"}));
|
||||
ex(&mut s, "page.select", json!({"index": 0}));
|
||||
ex(&mut s, "page.add", json!({}));
|
||||
assert_eq!(backgrounds(&s), ["#1E1E1E", "#0B1F3A", "#1E1E1E"], "the current page decides, not the last");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn added_page_colours_survive_save_and_reopen() {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-added-pages-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("pages.figcraft");
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "page.setBackground", json!({"color": "#336699"}));
|
||||
ex(&mut s, "page.add", json!({}));
|
||||
ex(&mut s, "file.saveAs", json!({"path": path.to_string_lossy()}));
|
||||
let mut s = Session::empty();
|
||||
s.set_ui_dark(true);
|
||||
ex(&mut s, "file.open", json!({"path": path.to_string_lossy()}));
|
||||
assert_eq!(backgrounds(&s), ["#336699", "#336699"]);
|
||||
// And a page added to the reopened file in the dark theme follows the file, not the theme.
|
||||
ex(&mut s, "page.add", json!({}));
|
||||
assert_eq!(backgrounds(&s), ["#336699", "#336699", "#336699"]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_the_app_opened_with_takes_the_theme_once_it_is_known() {
|
||||
// Made before the interface knew its theme, nothing done to it yet.
|
||||
@@ -118,13 +171,14 @@ fn text_drawn_on_a_dark_page_is_white_and_on_a_light_page_black() {
|
||||
let t = draw_text(&mut s, 40.0, 40.0);
|
||||
assert_eq!(s.doc().unwrap().node(t).unwrap().fills, vec![Paint::solid(want)], "text drawn on {page}");
|
||||
}
|
||||
// In a frame on a dark page too; so is text a command makes in the app (`dark_page_text_tests`).
|
||||
// In a (white) frame on a dark page, readable on the frame (#362); so is text a command makes
|
||||
// in the app (`dark_page_text_tests`).
|
||||
let mut s = Session::empty();
|
||||
s.set_ui_dark(true);
|
||||
ex(&mut s, "file.new", json!({}));
|
||||
ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 200}));
|
||||
let t = draw_text(&mut s, 50.0, 50.0);
|
||||
assert_eq!(s.doc().unwrap().node(t).unwrap().fills, vec![Paint::solid(Color::WHITE)]);
|
||||
assert_eq!(s.doc().unwrap().node(t).unwrap().fills, vec![Paint::solid(Color::BLACK)]);
|
||||
let made = id_of(&ex(&mut s, "text.create", json!({"text": "Hi", "x": 0, "y": 300}))["id"]).unwrap();
|
||||
assert_eq!(s.doc().unwrap().node(made).unwrap().fills, vec![Paint::solid(Color::WHITE)]);
|
||||
}
|
||||
+112
-12
@@ -266,6 +266,12 @@ pub struct Overlay {
|
||||
/// Pen tool: the in-progress path's anchor points and the rubber band to the pointer.
|
||||
pub pen: Vec<[f64; 2]>,
|
||||
pub rubber: Option<[f64; 4]>,
|
||||
/// Pen tool: the handles shaping the segments at the pen (anchor x, y, handle x, y): the last
|
||||
/// anchor's in and out handles and the one before's out handle.
|
||||
pub pen_handles: Vec<[f64; 4]>,
|
||||
/// Pen tool: where the last anchor's out handle ends, when it has one: the rubber band is the
|
||||
/// curve a click would make, leaving along it.
|
||||
pub rubber_handle: Option<[f64; 2]>,
|
||||
/// Frame/section the pointer is drawing or dropping into (highlighted).
|
||||
pub drop_target: Option<NodeId>,
|
||||
/// Auto layout insertion indicator (x0, y0, x1, y1).
|
||||
@@ -370,6 +376,13 @@ pub(crate) enum Gesture {
|
||||
TextSelect {
|
||||
id: NodeId,
|
||||
},
|
||||
/// A new text from the Text tool, still pressed: a drag makes it a box the size of the drag
|
||||
/// (at least `line`, the height of its first line, tall).
|
||||
TextBox {
|
||||
id: NodeId,
|
||||
start: Point,
|
||||
line: f64,
|
||||
},
|
||||
PenDrag {
|
||||
anchor: Point,
|
||||
},
|
||||
@@ -397,6 +410,14 @@ pub(crate) enum Gesture {
|
||||
},
|
||||
}
|
||||
|
||||
/// The handles a pen path shows while it is drawn ([`Overlay::pen_handles`]): the last anchor's in
|
||||
/// and out handles and the one before's out handle, those that aren't zero.
|
||||
fn pen_handles(points: &[(Point, Vec2, Vec2)]) -> Vec<[f64; 4]> {
|
||||
let last = points.last().into_iter().flat_map(|(p, i, o)| [(p, i), (p, o)]);
|
||||
let before = points.len().checked_sub(2).and_then(|k| points.get(k)).into_iter().map(|(p, _, o)| (p, o));
|
||||
before.chain(last).filter(|(_, h)| h.hypot() > 0.0).map(|(p, h)| [p.x, p.y, p.x + h.x, p.y + h.y]).collect()
|
||||
}
|
||||
|
||||
/// The pen tool's in-progress path (page coordinates).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct PenPath {
|
||||
@@ -530,12 +551,16 @@ pub fn anchor_name(a: (u8, u8)) -> &'static str {
|
||||
ANCHORS.get(usize::from(a.1.min(2)) * 3 + usize::from(a.0.min(2))).copied().unwrap_or("tl")
|
||||
}
|
||||
|
||||
/// The fill new text takes to be readable where it is drawn: white on a dark `page` (None: the
|
||||
/// model's default, black). For text made in the app: by the Text tool, or by a command that
|
||||
/// names no fill while the interface is up.
|
||||
pub(crate) fn readable_text_fills(doc: &Document, page: NodeId) -> Option<Vec<figcraft_color::Paint>> {
|
||||
matches!(doc.node(page).map(|p| &p.kind), Some(NodeKind::Page { background, .. }) if background.is_dark())
|
||||
.then(|| vec![figcraft_color::Paint::solid(figcraft_color::Color::WHITE)])
|
||||
/// The fill new text takes to be readable where it is drawn, in `parent`: white on a dark
|
||||
/// background (None: the model's default, black). The background is the nearest layer from `parent`
|
||||
/// out whose top visible fill is a mostly opaque solid colour, else the page (#362). For text made
|
||||
/// in the app: by the Text tool, or by a command that names no fill while the interface is up.
|
||||
pub(crate) fn readable_text_fills(doc: &Document, parent: NodeId) -> Option<Vec<figcraft_color::Paint>> {
|
||||
let dark = std::iter::once(parent).chain(doc.ancestors(parent)).filter_map(|id| doc.node(id)).find_map(|n| match &n.kind {
|
||||
NodeKind::Page { background, .. } => Some(background.is_dark()),
|
||||
_ => n.fills.iter().rev().find(|p| p.visible).and_then(|p| p.solid_color()).filter(|c| c.a >= 0.5).map(|c| c.is_dark()),
|
||||
});
|
||||
dark.unwrap_or(false).then(|| vec![figcraft_color::Paint::solid(figcraft_color::Color::WHITE)])
|
||||
}
|
||||
|
||||
const ANCHORS: [&str; 9] = ["tl", "t", "tr", "l", "c", "r", "bl", "b", "br"];
|
||||
@@ -617,6 +642,18 @@ pub fn handle_at(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> Option<
|
||||
handle_at_corners(&corners, p, zoom, is_line)
|
||||
}
|
||||
|
||||
/// Half the side of the square the canvas draws on each corner of the handle box (screen pixels,
|
||||
/// upright whatever the box's rotation).
|
||||
const CORNER_SQUARE_PX: f64 = 4.0;
|
||||
|
||||
/// Whether `p` is on a corner square of the selection's handle box as drawn: there the corner
|
||||
/// resizes, although a corner-radius handle 10 px in reaches that far too.
|
||||
fn on_corner_square(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> bool {
|
||||
let reach = CORNER_SQUARE_PX / zoom.max(1e-6);
|
||||
matches!(handle_at(doc, sel, p, zoom), Some(Handle::Corner(_)))
|
||||
&& selection_box(doc, sel).is_some_and(|c| c.iter().any(|c| (c.x - p.x).abs() <= reach && (c.y - p.y).abs() <= reach))
|
||||
}
|
||||
|
||||
/// A box whose sides are both shorter than this on screen (pixels) would be covered by its
|
||||
/// handles: the canvas draws just its outline (the handles still answer just outside it).
|
||||
pub const SMALL_BOX_PX: f64 = 16.0;
|
||||
@@ -685,7 +722,18 @@ pub(crate) fn handle_cursor(c: &[Point; 4], h: Handle) -> Cursor {
|
||||
Handle::Corner(i) => {
|
||||
let a = c.get(i as usize).copied().unwrap_or_default();
|
||||
let b = c.get((i as usize + 2) % 4).copied().unwrap_or_default();
|
||||
Cursor::Resize(angle(a, b))
|
||||
// Along the box's own axes at 45°, not the diagonal, so wide or tall boxes still get a
|
||||
// diagonal cursor. A box with no width or height falls back to the diagonal.
|
||||
let unit = |v: figcraft_geom::kurbo::Vec2| {
|
||||
let len = v.hypot();
|
||||
(len.is_finite() && len > 1e-9).then(|| v / len)
|
||||
};
|
||||
let next = c.get((i as usize + 1) % 4).copied().unwrap_or_default();
|
||||
let prev = c.get((i as usize + 3) % 4).copied().unwrap_or_default();
|
||||
match (unit(next - a), unit(prev - a)) {
|
||||
(Some(u), Some(v)) => Cursor::Resize((u + v).atan2().to_degrees()),
|
||||
_ => Cursor::Resize(angle(a, b)),
|
||||
}
|
||||
}
|
||||
Handle::Edge(i) => {
|
||||
let a = c.get(i as usize).copied().unwrap_or_default();
|
||||
@@ -982,7 +1030,11 @@ impl Session {
|
||||
let mut measure_box = None;
|
||||
let arc_hover = if self.arc_handles_shown() { crate::arc_handles::hit(&doc, &sel, ev.p(), ev.zoom).map(|(_, h)| h) } else { None };
|
||||
self.tool.overlay.arc_hover = arc_hover;
|
||||
let shape_hover = if self.arc_handles_shown() { crate::shape_handles::hit(&doc, &sel, ev.p(), ev.zoom).map(|(_, h)| h) } else { None };
|
||||
let shape_hover = if self.arc_handles_shown() && !on_corner_square(&doc, &sel, ev.p(), ev.zoom) {
|
||||
crate::shape_handles::hit(&doc, &sel, ev.p(), ev.zoom).map(|(_, h)| h)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.tool.overlay.shape_hover = shape_hover;
|
||||
if matches!(tool, ToolId::Move | ToolId::Scale) && on_edited {
|
||||
cursor = Cursor::Text;
|
||||
@@ -1026,8 +1078,9 @@ impl Session {
|
||||
let (to, g) = if ev.shift { (ev.p(), Vec::new()) } else { self.snap_pen_tool(ev)? };
|
||||
guides = g;
|
||||
self.tool.overlay.cursor = Cursor::Pen(self.pen_cursor(&doc, &sel, to, ev.zoom));
|
||||
if let Some((last, _, _)) = self.tool.pen.as_ref().and_then(|pen| pen.points.last()) {
|
||||
if let Some((last, _, out)) = self.tool.pen.as_ref().and_then(|pen| pen.points.last()) {
|
||||
self.tool.overlay.rubber = Some([last.x, last.y, to.x, to.y]);
|
||||
self.tool.overlay.rubber_handle = (out.hypot() > 0.0).then_some([last.x + out.x, last.y + out.y]);
|
||||
}
|
||||
}
|
||||
self.tool.overlay.guides = guides;
|
||||
@@ -1090,6 +1143,7 @@ impl Session {
|
||||
return Ok(());
|
||||
}
|
||||
if self.arc_handles_shown()
|
||||
&& !on_corner_square(&doc, &sel, ev.p(), ev.zoom)
|
||||
&& let Some((id, handle)) = crate::shape_handles::hit(&doc, &sel, ev.p(), ev.zoom)
|
||||
{
|
||||
self.begin_interaction("Corner radius")?;
|
||||
@@ -1164,7 +1218,13 @@ impl Session {
|
||||
if ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let targets = Targets::gather(&doc, page, &ids);
|
||||
let mut targets = Targets::gather(&doc, page, &ids);
|
||||
// A copy dragged out with Alt snaps to the layers it came from too (they stay put).
|
||||
if ev.alt
|
||||
&& let Some(b) = targets.moving
|
||||
{
|
||||
targets.add_box(b);
|
||||
}
|
||||
self.begin_interaction(if ev.alt { "Duplicate" } else { "Move" })?;
|
||||
self.tool.gesture = Gesture::Move { start: ev.p(), ids, targets, duplicate: ev.alt, moved: false };
|
||||
self.tool.overlay.cursor = if ev.alt { Cursor::Copy } else { Cursor::Default };
|
||||
@@ -1347,6 +1407,11 @@ impl Session {
|
||||
self.tool.gesture = g;
|
||||
return self.draw_drag(ev, start, id, parent, &targets);
|
||||
}
|
||||
Gesture::TextBox { id, start, line } => {
|
||||
let (id, start, line) = (*id, *start, *line);
|
||||
self.tool.gesture = g;
|
||||
return self.text_box_drag(ev, id, start, line);
|
||||
}
|
||||
Gesture::Arc { id, handle, arc } => {
|
||||
let (id, handle, arc) = (*id, *handle, *arc);
|
||||
self.tool.gesture = g;
|
||||
@@ -1675,6 +1740,7 @@ impl Session {
|
||||
// dropped; layers in an auto layout frame already sit in their slot.
|
||||
let page = self.page()?;
|
||||
let sel = self.selection();
|
||||
let sources = if duplicate { ids.clone() } else { Vec::new() };
|
||||
let moving = if duplicate { sel } else { ids };
|
||||
let doc = self.doc()?.clone();
|
||||
let in_al =
|
||||
@@ -1703,6 +1769,10 @@ impl Session {
|
||||
self.cancel_interaction()?;
|
||||
} else {
|
||||
self.commit_interaction()?;
|
||||
// Duplicate repeats a free Alt-drag's step (an auto layout places its own).
|
||||
if duplicate && !in_al && moving.len() == sources.len() {
|
||||
self.ds_mut()?.last_duplicate = Some(crate::cmd::edit::Duplicated { sources, copies: moving });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.cancel_interaction()?;
|
||||
@@ -1760,7 +1830,7 @@ impl Session {
|
||||
self.tool.overlay.pen.clear();
|
||||
self.pencil_finish(&points, &pressure)?;
|
||||
}
|
||||
Gesture::TextSelect { .. } | Gesture::Marquee { .. } | Gesture::None => {}
|
||||
Gesture::TextSelect { .. } | Gesture::TextBox { .. } | Gesture::Marquee { .. } | Gesture::None => {}
|
||||
Gesture::PenDrag { .. } | Gesture::ShapeBuild { .. } | Gesture::Origin => {}
|
||||
Gesture::Vector(_) => self.commit_interaction()?,
|
||||
}
|
||||
@@ -1878,22 +1948,46 @@ impl Session {
|
||||
let at = Point::new(ev.x.round(), (ev.y - 7.0).round());
|
||||
n.transform = pw.inverse() * Affine::translate(at.to_vec2());
|
||||
n.name = String::new();
|
||||
if let Some(fills) = readable_text_fills(d, page) {
|
||||
if let Some(fills) = readable_text_fills(d, parent) {
|
||||
n.fills = fills;
|
||||
}
|
||||
if let Some((w, h)) = figcraft_text::auto_size(&n) {
|
||||
n.width = w.max(1.0);
|
||||
n.height = h;
|
||||
}
|
||||
let line = n.height;
|
||||
d.insert(n, parent, None)?;
|
||||
ds.selection = vec![id];
|
||||
textedit::begin(self, id, Some(0), true)?;
|
||||
self.tool.gesture = Gesture::TextBox { id, start: ev.p(), line };
|
||||
if !self.prefs.keep_tool {
|
||||
self.tool.tool = ToolId::Move;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drag out a new text box: past the drag slop it has the drag's size (at least one line
|
||||
/// tall) and its top-left, and that size is fixed.
|
||||
fn text_box_drag(&mut self, ev: &PointerEvent, id: NodeId, start: Point, line: f64) -> Result<()> {
|
||||
if within_slop(ev, start) {
|
||||
return Ok(());
|
||||
}
|
||||
let r = Rect::from_points(start, ev.p());
|
||||
let d = self.ds_mut()?.doc_mut();
|
||||
let pw = d.node(id).and_then(|n| n.parent).map(|p| d.world_transform(p)).unwrap_or_default();
|
||||
let n = d.get_mut(id)?;
|
||||
n.transform = pw.inverse() * Affine::translate((r.x0.round(), r.y0.round()));
|
||||
n.width = r.width().round().max(1.0);
|
||||
n.height = r.height().round().max(line);
|
||||
n.child_layout.sizing_h = Sizing::Fixed;
|
||||
n.child_layout.sizing_v = Sizing::Fixed;
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
t.auto_resize = TextAutoResize::None;
|
||||
}
|
||||
self.ds_mut()?.revision += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn eyedrop(&mut self, ev: &PointerEvent) -> Result<()> {
|
||||
let ds = self.ds()?;
|
||||
let doc = ds.doc.clone();
|
||||
@@ -1931,6 +2025,9 @@ impl Session {
|
||||
|
||||
fn pen_down(&mut self, ev: &PointerEvent) -> Result<()> {
|
||||
self.tool.overlay.guides.clear();
|
||||
// The segment being placed shows as itself while the press lasts.
|
||||
self.tool.overlay.rubber = None;
|
||||
self.tool.overlay.rubber_handle = None;
|
||||
let p = if ev.shift { ev.p() } else { self.snap_pen_tool(ev)?.0 };
|
||||
let (doc, page) = {
|
||||
let ds = self.ds()?;
|
||||
@@ -1985,6 +2082,7 @@ impl Session {
|
||||
fn pen_update(&mut self) -> Result<()> {
|
||||
let Some(pen) = self.tool.pen.clone() else { return Ok(()) };
|
||||
self.tool.overlay.pen = pen.points.iter().map(|(p, _, _)| [p.x, p.y]).collect();
|
||||
self.tool.overlay.pen_handles = pen_handles(&pen.points);
|
||||
if pen.points.len() < 2 {
|
||||
if let Ok(ds) = self.ds_mut() {
|
||||
ds.revision += 1;
|
||||
@@ -2067,7 +2165,9 @@ impl Session {
|
||||
pub fn pen_finish(&mut self) -> Result<()> {
|
||||
let pen = self.tool.pen.take();
|
||||
self.tool.overlay.pen.clear();
|
||||
self.tool.overlay.pen_handles.clear();
|
||||
self.tool.overlay.rubber = None;
|
||||
self.tool.overlay.rubber_handle = None;
|
||||
self.tool.gesture = Gesture::None;
|
||||
if pen.as_ref().is_some_and(|p| p.id.is_some()) {
|
||||
self.commit_interaction()?;
|
||||
|
||||
@@ -248,6 +248,64 @@ fn design_panel_edits_reach_every_selected_layer() {
|
||||
assert!(near(rotation(&s, a), 30.0) && flipped(&s, a), "{}", rotation(&s, a));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constrain_proportions_keeps_the_other_side_in_ratio() {
|
||||
let mut s = session();
|
||||
// The Layout panel's link is a per-layer property: typing W or H scales the other side by the
|
||||
// node's own ratio; both sides in one edit stay as typed, and so does an unlinked edit.
|
||||
let r = rect(&mut s, 0.0, 0.0, 100.0, 50.0, None);
|
||||
set(&mut s, &[r], json!({"constrainProportions": true}));
|
||||
set(&mut s, &[r], json!({"width": 200}));
|
||||
assert_eq!(size(&s, r), (200.0, 100.0), "W ×2 makes H ×2");
|
||||
set(&mut s, &[r], json!({"height": 25}));
|
||||
assert_eq!(size(&s, r), (50.0, 25.0), "H 25 makes W follow the ratio");
|
||||
set(&mut s, &[r], json!({"width": 30, "height": 40}));
|
||||
assert_eq!(size(&s, r), (30.0, 40.0), "both sides in one edit stay as typed");
|
||||
set(&mut s, &[r], json!({"constrainProportions": false}));
|
||||
set(&mut s, &[r], json!({"width": 60}));
|
||||
assert_eq!(size(&s, r), (60.0, 40.0), "unlinked: the other side stays put");
|
||||
// The short aliases work the same, and each layer of a selection follows its own ratio.
|
||||
let a = rect(&mut s, 0.0, 0.0, 20.0, 10.0, None);
|
||||
let b = rect(&mut s, 100.0, 0.0, 30.0, 10.0, None);
|
||||
set(&mut s, &[a, b], json!({"constrainProportions": true}));
|
||||
set(&mut s, &[a, b], json!({"h": 20}));
|
||||
assert_eq!((size(&s, a), size(&s, b)), ((40.0, 20.0), (60.0, 20.0)), "each layer keeps its own ratio");
|
||||
// A zero side has no ratio to keep: the edit applies to the side typed only.
|
||||
let z = rect(&mut s, 0.0, 0.0, 100.0, 50.0, None);
|
||||
set(&mut s, &[z], json!({"constrainProportions": true, "height": 0}));
|
||||
set(&mut s, &[z], json!({"width": 20}));
|
||||
assert_eq!(size(&s, z), (20.0, 0.0), "a zero height leaves nothing to scale");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_press_anywhere_on_a_corner_handle_resizes() {
|
||||
// The canvas draws each corner handle as an 8 px square centred on the corner, and the
|
||||
// corner-radius handle sits 10 px in along the diagonal: a press on the square's inner half
|
||||
// resizes (both sides, in ratio with the link on), it doesn't pick the radius handle.
|
||||
let radii = |s: &Session, r: NodeId| s.doc().unwrap().node(r).unwrap().corners.radii;
|
||||
for lock in [false, true] {
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 0.0, 0.0, 1000.0, 600.0, None);
|
||||
set(&mut s, &[r], json!({"constrainProportions": lock}));
|
||||
ex(&mut s, "select.set", json!({"ids": [r]}));
|
||||
let on_square = Point::new(997.0, 597.0);
|
||||
s.pointer(&ev(PointerKind::Hover, on_square, NONE)).unwrap();
|
||||
assert!(matches!(s.tool.overlay.cursor, Cursor::Resize(_)), "lock {lock}: {:?}", s.tool.overlay.cursor);
|
||||
drag(&mut s, on_square, Point::new(1197.0, 637.0), NONE);
|
||||
let (w, h) = size(&s, r);
|
||||
let want_h = if lock { 718.2 } else { 637.0 };
|
||||
assert!(near(w, 1197.0) && near(h, want_h), "lock {lock}: both sides follow the corner, got {w}×{h}");
|
||||
assert_eq!(radii(&s, r), [0.0; 4]);
|
||||
}
|
||||
// Further in, past the square, the radius handle still answers.
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 0.0, 0.0, 1000.0, 600.0, None);
|
||||
ex(&mut s, "select.set", json!({"ids": [r]}));
|
||||
drag(&mut s, Point::new(993.0, 593.0), Point::new(980.0, 580.0), NONE);
|
||||
assert_eq!(size(&s, r), (1000.0, 600.0));
|
||||
assert!(radii(&s, r)[0] > 0.0, "the radius handle rounds the corners");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_resize_with_constraints_and_cmd_ignores_them() {
|
||||
let mut s = session();
|
||||
@@ -349,6 +407,30 @@ fn smart_guides_snap_to_edges_centres_and_equal_spacing() {
|
||||
assert_eq!(bounds(&s, b).origin(), Point::new(198.0, 2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_cursors_stay_diagonal_whatever_the_proportions() {
|
||||
let mut s = session();
|
||||
let mod180 = |a: f64, b: f64| near((a - b).rem_euclid(180.0).min((b - a).rem_euclid(180.0)), 0.0);
|
||||
for (w, h) in [(65.0, 15.0), (100.0, 100.0), (60.0, 200.0), (200.0, 50.0)] {
|
||||
let r = rect(&mut s, 0.0, 0.0, w, h, None);
|
||||
let doc = s.doc().unwrap().clone();
|
||||
let q = selection_box(&doc, &[r]).unwrap();
|
||||
for (i, want) in [(0u8, 45.0), (1, 135.0), (2, 45.0), (3, 135.0)] {
|
||||
let Cursor::Resize(a) = handle_cursor(&q, Handle::Corner(i)) else { panic!() };
|
||||
assert!(mod180(a, want), "{w}x{h} corner {i}: {a}, want {want}");
|
||||
}
|
||||
// Turned, the corner cursors turn with the layer's own axes.
|
||||
set(&mut s, &[r], json!({"rotation": 30}));
|
||||
let doc = s.doc().unwrap().clone();
|
||||
let q = selection_box(&doc, &[r]).unwrap();
|
||||
let axis = (q[1] - q[0]).atan2().to_degrees();
|
||||
for (i, off) in [(0u8, 45.0), (1, 135.0), (2, 45.0), (3, 135.0)] {
|
||||
let Cursor::Resize(a) = handle_cursor(&q, Handle::Corner(i)) else { panic!() };
|
||||
assert!(mod180(a, axis + off), "{w}x{h} turned corner {i}: {a}, want {}", axis + off);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_transform_box_and_its_handles() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -4,11 +4,15 @@
|
||||
//! Entering edit mode on a non-vector shape converts it to a vector first (like the incumbent).
|
||||
//! Vertex coordinates are node-local; after each edit the node is re-fitted so its box wraps the
|
||||
//! network again. The network edits themselves live in [`ops`]; pointer gestures in `pointer.rs`,
|
||||
//! the transform box around several points in [`transform`] and point snapping in `point_snap.rs`.
|
||||
//! the transform box around several points in [`transform`], point snapping in `point_snap.rs` and
|
||||
//! hovering, hatching and selecting filled regions in [`regions`].
|
||||
|
||||
pub mod ops;
|
||||
pub(crate) mod point_snap;
|
||||
mod pointer;
|
||||
pub mod regions;
|
||||
#[cfg(test)]
|
||||
mod regions_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
pub mod transform;
|
||||
@@ -111,6 +115,17 @@ pub struct VectorEdit {
|
||||
pub pen_out: Option<(f64, f64)>,
|
||||
/// Variable width tool: the selected width point (an index into the stroke's profile).
|
||||
pub width_point: Option<usize>,
|
||||
/// Regions of the active layer selected by pressing inside them (drawn hatched; their points
|
||||
/// are selected with them).
|
||||
pub regions: Vec<usize>,
|
||||
/// The points in `vertices` selected only through `regions`: they move with the selection but
|
||||
/// are drawn unselected (without their handles).
|
||||
pub region_vertices: Vec<usize>,
|
||||
/// The region of the active layer under the pointer (drawn hatched).
|
||||
pub region_hover: Option<usize>,
|
||||
/// The active layer's [`VectorNetwork::topology_key`] when `regions` and `region_hover` were
|
||||
/// set: region indexes only hold while it stays the same.
|
||||
pub region_topo: u64,
|
||||
}
|
||||
|
||||
/// A layer in edit mode with its selected points and segments.
|
||||
@@ -145,9 +160,38 @@ impl VectorEdit {
|
||||
anchor: None,
|
||||
pen_out: None,
|
||||
width_point: None,
|
||||
regions: vec![],
|
||||
region_vertices: vec![],
|
||||
region_hover: None,
|
||||
region_topo: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the selected and hovered regions (points selected through them stay selected).
|
||||
pub fn clear_regions(&mut self) {
|
||||
self.regions.clear();
|
||||
self.region_vertices.clear();
|
||||
self.region_hover = None;
|
||||
}
|
||||
|
||||
/// Deselect the selected regions and the points selected only through them.
|
||||
pub fn drop_regions(&mut self) {
|
||||
let gone = std::mem::take(&mut self.region_vertices);
|
||||
self.vertices.retain(|v| !gone.contains(v));
|
||||
self.regions.clear();
|
||||
}
|
||||
|
||||
/// The selected points drawn as selected (and showing their handles): not those selected only
|
||||
/// through a region.
|
||||
pub fn shown_vertices(&self) -> Vec<usize> {
|
||||
self.vertices.iter().copied().filter(|v| !self.region_vertices.contains(v)).collect()
|
||||
}
|
||||
|
||||
/// Note the active layer's topology (`net`) the region indexes refer to.
|
||||
pub fn track_regions(&mut self, net: &VectorNetwork) {
|
||||
self.region_topo = net.topology_key();
|
||||
}
|
||||
|
||||
/// Drop indices that no longer exist in the active layer's network (after undo or an edit).
|
||||
pub fn clamp(&mut self, net: &VectorNetwork) {
|
||||
let (nv, ns) = (net.vertices.len(), net.segments.len());
|
||||
@@ -158,6 +202,15 @@ impl VectorEdit {
|
||||
self.anchor = None;
|
||||
self.pen_out = None;
|
||||
}
|
||||
// Region indexes don't survive a change of topology (moving points keeps them).
|
||||
if (!self.regions.is_empty() || self.region_hover.is_some()) && net.topology_key() != self.region_topo {
|
||||
self.clear_regions();
|
||||
}
|
||||
let vertices = &self.vertices;
|
||||
self.region_vertices.retain(|v| vertices.contains(v));
|
||||
if self.regions.is_empty() {
|
||||
self.region_vertices.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::clamp`] every edited layer against `doc`, dropping other layers that are gone or no
|
||||
@@ -200,14 +253,17 @@ impl VectorEdit {
|
||||
self.anchor = None;
|
||||
self.pen_out = None;
|
||||
self.width_point = None;
|
||||
self.clear_regions();
|
||||
true
|
||||
}
|
||||
|
||||
/// Clear the points, segments and handles selected in every layer.
|
||||
/// Clear the points, segments, handles and regions selected in every layer.
|
||||
pub fn clear_selection(&mut self) {
|
||||
self.vertices.clear();
|
||||
self.segments.clear();
|
||||
self.handles.clear();
|
||||
self.regions.clear();
|
||||
self.region_vertices.clear();
|
||||
for o in &mut self.others {
|
||||
o.vertices.clear();
|
||||
o.segments.clear();
|
||||
@@ -330,13 +386,15 @@ pub fn edit<T>(doc: &mut Document, id: NodeId, f: impl FnOnce(&mut VectorNetwork
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Handles drawn and hit-tested: at selected points, and at both ends of selected segments.
|
||||
/// Handles drawn and hit-tested: at selected points (not those selected only through a region),
|
||||
/// and at both ends of selected segments.
|
||||
fn visible_handles(net: &VectorNetwork, ve: &VectorEdit) -> Vec<(usize, bool)> {
|
||||
let mut out = Vec::new();
|
||||
let shown = ve.shown_vertices();
|
||||
for (si, s) in net.segments.iter().enumerate() {
|
||||
let seg_on = ve.segments.contains(&si);
|
||||
for (start, vi, t) in [(true, s.a, s.ta), (false, s.b, s.tb)] {
|
||||
if t != (0.0, 0.0) && (seg_on || ve.vertices.contains(&vi)) {
|
||||
if t != (0.0, 0.0) && (seg_on || shown.contains(&vi)) {
|
||||
out.push((si, start));
|
||||
}
|
||||
}
|
||||
@@ -425,7 +483,7 @@ pub fn overlay(doc: &Document, ve: &VectorEdit) -> Option<EditOverlay> {
|
||||
segments,
|
||||
vertices,
|
||||
handles,
|
||||
selected_vertices: ve.vertices.clone(),
|
||||
selected_vertices: ve.shown_vertices(),
|
||||
selected_segments: ve.segments.clone(),
|
||||
selected_handles: ve.handles.clone(),
|
||||
anchor: ve.anchor,
|
||||
@@ -508,6 +566,7 @@ pub fn set_tool(s: &mut Session, tool: VTool) -> Result<()> {
|
||||
s.tool.overlay = Default::default();
|
||||
set_state(s, |ve| {
|
||||
ve.tool = tool;
|
||||
ve.region_hover = None;
|
||||
if tool != VTool::Pen {
|
||||
ve.anchor = None;
|
||||
ve.pen_out = None;
|
||||
@@ -749,7 +808,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let others: Vec<Value> = ve.others.iter().map(|o| json!({"id": o.id, "vertices": o.vertices, "segments": o.segments})).collect();
|
||||
Ok(json!({"id": ve.id, "tool": ve.tool.id(), "overlay": o, "network": net, "mirroring": mirroring,
|
||||
"widthPoints": width_handles(doc, &ve, 1.0), "widthPoint": ve.width_point, "layers": layers, "others": others,
|
||||
"handles": ve.handles}))
|
||||
"handles": ve.handles, "regions": ve.regions, "regionVertices": ve.region_vertices, "regionHover": ve.region_hover}))
|
||||
}),
|
||||
spec!("vector.edit.tool", "Vector edit tool", "{tool: move|pen|bend|paint|lasso|cut|eraser|width}", false, |s, p| {
|
||||
editing(s)?;
|
||||
@@ -760,7 +819,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"vector.edit.select",
|
||||
"Select points",
|
||||
"{vertices?: [i, ...], segments?: [i, ...], handles?: [[segment, start], ...], id? (an edited layer, made active), add?}",
|
||||
"{vertices?: [i, ...], segments?: [i, ...], handles?: [[segment, start], ...], regions?: [i, ...] (filled regions, with their points), id? (an edited layer, made active), add?}",
|
||||
false,
|
||||
|s, p| {
|
||||
let ve = editing(s)?;
|
||||
@@ -771,11 +830,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let (v, sg, add) =
|
||||
(indices(p, "vertices").unwrap_or_default(), indices(p, "segments").unwrap_or_default(), boolean(p, "add").unwrap_or(false));
|
||||
let handles = handle_list(p, "handles").unwrap_or_default();
|
||||
let regions = indices(p, "regions").unwrap_or_default();
|
||||
let net = network(s.doc()?, layer).cloned().unwrap_or_default();
|
||||
set_state(s, |ve| {
|
||||
if !add {
|
||||
ve.clear_selection();
|
||||
}
|
||||
ve.activate(layer);
|
||||
// Points named here are selected in their own right.
|
||||
ve.region_vertices.retain(|x| !v.contains(x));
|
||||
ve.vertices.extend(v);
|
||||
ve.segments.extend(sg);
|
||||
for h in handles {
|
||||
@@ -783,6 +846,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
ve.handles.push(h);
|
||||
}
|
||||
}
|
||||
for r in regions.into_iter().filter(|r| *r < net.regions.len()).take(100_000) {
|
||||
if !ve.regions.contains(&r) {
|
||||
ve.regions.push(r);
|
||||
for v in net.region_points(r) {
|
||||
if !ve.vertices.contains(&v) {
|
||||
ve.vertices.push(v);
|
||||
ve.region_vertices.push(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ve.track_regions(&net);
|
||||
})?;
|
||||
let doc = s.doc()?.clone();
|
||||
let ds = s.ds_mut()?;
|
||||
|
||||
@@ -9,8 +9,9 @@ use figcraft_doc::*;
|
||||
use figcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
|
||||
use super::point_snap;
|
||||
use super::regions::region_hit;
|
||||
use super::transform::{self, BoxHit, PointBox};
|
||||
use super::{EditPart, VHit, VTool, VectorEdit, edit, editing, hit, hit_any, local_offset, network, ops, points_of, set_state};
|
||||
use super::{EditPart, VHit, VTool, VectorEdit, edit, editing, hit, hit_any, local_offset, network, ops, points_of, selected_points, set_state};
|
||||
use crate::snap::Targets;
|
||||
use crate::tools::{Cursor, Gesture, Handle, PenCursor, PointerEvent, PointerKind};
|
||||
use crate::{Result, Session};
|
||||
@@ -357,6 +358,10 @@ impl Session {
|
||||
_ => Cursor::Crosshair,
|
||||
};
|
||||
let w = doc.world_transform(ve.id);
|
||||
// The Move tool hatches the region under the pointer, clear of anything a press would take
|
||||
// first (another layer's parts, Bezier handles, the transform box's handles). Close to a
|
||||
// point or segment but outside the region clearances, the region wins (a press takes it).
|
||||
let mut region = None;
|
||||
match ve.tool {
|
||||
VTool::Move if !ev.cmd => {
|
||||
// Over the transform box's handles: their cursor, and no point highlighted.
|
||||
@@ -365,6 +370,12 @@ impl Session {
|
||||
{
|
||||
self.tool.overlay.cursor = crate::tools::handle_cursor(&b.corners(), handle);
|
||||
self.tool.overlay.vector_hover = None;
|
||||
} else if shown.is_none_or(|(on, h)| on == ve.id && !matches!(h, VHit::Handle { .. })) {
|
||||
region = Self::region_under(&doc, ve, h, ev);
|
||||
if region.is_some() {
|
||||
self.tool.overlay.vector_hover = None;
|
||||
self.tool.overlay.vector_hover_on = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
VTool::Pen => {
|
||||
@@ -401,12 +412,77 @@ impl Session {
|
||||
VTool::Width => self.width_hover(ve, ev)?,
|
||||
_ => {}
|
||||
}
|
||||
let topo = region.and_then(|_| network(&doc, ve.id)).map(VectorNetwork::topology_key);
|
||||
if let Ok(ds) = self.ds_mut() {
|
||||
if let Some(e) = ds.vector_edit.as_mut()
|
||||
&& e.region_hover != region
|
||||
{
|
||||
e.region_hover = region;
|
||||
if let Some(k) = topo {
|
||||
e.region_topo = k;
|
||||
}
|
||||
}
|
||||
ds.revision += 1;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The region of the active layer a press at `ev` would take (Move tool, `h` the network's part
|
||||
/// there). Inside the transform box around points selected in their own right, the box keeps
|
||||
/// its drag unless the region is already selected.
|
||||
fn region_under(doc: &Document, ve: &VectorEdit, h: Option<VHit>, ev: &PointerEvent) -> Option<usize> {
|
||||
let r = region_hit(doc, ve, ev.p(), ev.zoom)?;
|
||||
let own = !ve.shown_vertices().is_empty() || !ve.segments.is_empty();
|
||||
let in_box = !ev.shift && own && transform::box_hit(doc, ve, h, ev.p(), ev.zoom) == Some(BoxHit::Inside);
|
||||
(!in_box || ve.regions.contains(&r)).then_some(r)
|
||||
}
|
||||
|
||||
/// A press inside region `r` of the active layer (Move tool): select it with its points (Shift
|
||||
/// adds it, or takes it out when it is selected) and drag them. A press on a selected region
|
||||
/// keeps the whole selection and drags it.
|
||||
fn region_down(&mut self, ve: &VectorEdit, doc: &Document, r: usize, ev: &PointerEvent) -> Result<()> {
|
||||
let id = ve.id;
|
||||
let Some(net) = network(doc, id) else { return Ok(()) };
|
||||
let selected = ve.regions.contains(&r);
|
||||
let shift = ev.shift;
|
||||
if shift && selected {
|
||||
// Its points go too, unless they were selected on their own or another selected
|
||||
// region has them.
|
||||
let kept: Vec<usize> = ve.regions.iter().filter(|q| **q != r).flat_map(|q| net.region_points(*q)).collect();
|
||||
let gone: Vec<usize> = net.region_points(r).into_iter().filter(|v| !kept.contains(v) && ve.region_vertices.contains(v)).collect();
|
||||
return set_state(self, |e| {
|
||||
e.regions.retain(|q| *q != r);
|
||||
e.vertices.retain(|v| !gone.contains(v));
|
||||
e.region_vertices.retain(|v| !gone.contains(v));
|
||||
});
|
||||
}
|
||||
if !selected {
|
||||
let pts = net.region_points(r);
|
||||
let topo = net.topology_key();
|
||||
set_state(self, |e| {
|
||||
if !shift {
|
||||
e.clear_selection();
|
||||
}
|
||||
e.handles.clear();
|
||||
e.regions.push(r);
|
||||
for v in pts {
|
||||
if !e.vertices.contains(&v) {
|
||||
e.vertices.push(v);
|
||||
e.region_vertices.push(v);
|
||||
}
|
||||
}
|
||||
e.region_topo = topo;
|
||||
})?;
|
||||
}
|
||||
let now = editing(self)?;
|
||||
let moving = selected_points(net, &now);
|
||||
let others = if shift || selected { Self::others_moving(doc, &now) } else { vec![] };
|
||||
let g = self.points_gesture(id, ev.p(), moving, others, None, false)?;
|
||||
self.begin_interaction("Move points")?;
|
||||
self.vgesture(g);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns false when the click left edit mode (the caller then runs the normal tool).
|
||||
fn vector_down(&mut self, ve: &VectorEdit, ev: &PointerEvent) -> Result<bool> {
|
||||
let doc = self.doc()?.clone();
|
||||
@@ -439,6 +515,26 @@ impl Session {
|
||||
let tool = if ev.cmd && ve.tool == VTool::Move { VTool::Bend } else { ve.tool };
|
||||
let id = ve.id;
|
||||
let w = doc.world_transform(id);
|
||||
// Regions come after Bezier handles (and the transform box's handles), before points and
|
||||
// segments: clear of those, a press inside one selects it.
|
||||
let region = if tool == VTool::Move && !matches!(h, Some(VHit::Handle { .. })) { Self::region_under(&doc, ve, h, ev) } else { None };
|
||||
// Any press without Shift drops the selected regions, except one on a selected region
|
||||
// (which keeps the selection to drag it); no hover hatch while the button is down.
|
||||
let keep_regions = ev.shift || region.is_some_and(|r| ve.regions.contains(&r));
|
||||
set_state(self, |e| {
|
||||
e.region_hover = None;
|
||||
if !keep_regions {
|
||||
e.drop_regions();
|
||||
}
|
||||
})?;
|
||||
// The rest of the press sees the selection without them.
|
||||
let dropped;
|
||||
let ve = if keep_regions || ve.regions.is_empty() {
|
||||
ve
|
||||
} else {
|
||||
dropped = editing(self)?;
|
||||
&dropped
|
||||
};
|
||||
// The transform box (Shift inside it adds to the selection instead; a Shift-click on the
|
||||
// point at one of its corners takes that point out, a Shift-drag scales in proportion).
|
||||
let pick = match h {
|
||||
@@ -446,11 +542,17 @@ impl Session {
|
||||
_ => None,
|
||||
};
|
||||
if tool == VTool::Move
|
||||
&& let Some(bh) = transform::box_hit(&doc, ve, h, ev.p(), ev.zoom).filter(|bh| !(ev.shift && *bh == BoxHit::Inside))
|
||||
&& let Some(bh) = transform::box_hit(&doc, ve, h, ev.p(), ev.zoom).filter(|bh| !((ev.shift || region.is_some()) && *bh == BoxHit::Inside))
|
||||
{
|
||||
self.box_down(ve, bh, ev, pick)?;
|
||||
return Ok(true);
|
||||
}
|
||||
if tool == VTool::Move
|
||||
&& let Some(r) = region
|
||||
{
|
||||
self.region_down(ve, &doc, r, ev)?;
|
||||
return Ok(true);
|
||||
}
|
||||
match tool {
|
||||
VTool::Move => match h {
|
||||
Some(VHit::Handle { seg, start }) => {
|
||||
@@ -478,7 +580,12 @@ impl Session {
|
||||
}
|
||||
}
|
||||
Some(VHit::Vertex { index }) => {
|
||||
let verts = if ev.shift {
|
||||
// Shift on a point drawn unselected (selected only through a region) selects it
|
||||
// in its own right rather than taking it out.
|
||||
let own = ev.shift && ve.region_vertices.contains(&index);
|
||||
let verts = if own {
|
||||
ve.vertices.clone()
|
||||
} else if ev.shift {
|
||||
let mut v = ve.vertices.clone();
|
||||
if let Some(i) = v.iter().position(|x| *x == index) {
|
||||
v.remove(i);
|
||||
@@ -499,6 +606,7 @@ impl Session {
|
||||
e.clear_selection();
|
||||
}
|
||||
e.vertices = verts.clone();
|
||||
e.region_vertices.retain(|v| *v != index);
|
||||
e.handles.clear();
|
||||
if !keep_segs {
|
||||
e.segments.clear();
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
//! Regions in vector edit mode: the closed, filled areas of the edited vector. With the Move tool,
|
||||
//! the region under the pointer (clear of points and segments) is shown hatched, a press selects
|
||||
//! it with all its points (Shift adds it, or takes a selected one out) and a drag moves them.
|
||||
//!
|
||||
//! Only filled areas count: a vector without a visible fill has no regions to hover or press.
|
||||
//!
|
||||
//! Hit testing happens in the vector's local units; clearances are in screen pixels. The hatch is
|
||||
//! built in screen space: parallel diagonal lines ("/") anchored to the vector's origin (so they
|
||||
//! move with the shape when the canvas pans and never slide over it), clipped to the region by its
|
||||
//! own fill rule. Their spacing follows octave folding (see [`hatch_spacing`]): fixed in canvas
|
||||
//! units within each octave of zoom, so on screen it grows with the zoom and drops back at each
|
||||
//! step. Rounded
|
||||
//! points are drawn in, as the fill shows them.
|
||||
|
||||
use figcraft_doc::{Document, VectorNetwork, WindingRule};
|
||||
use figcraft_geom::kurbo::{self, ParamCurveNearest, PathEl};
|
||||
use figcraft_geom::{Affine, Point, Rect, Shape, Vec2};
|
||||
|
||||
use super::{VectorEdit, network};
|
||||
|
||||
/// A point this close to the pointer (screen pixels, at any zoom) keeps it off every region. It is
|
||||
/// tighter than a point hit (6 px): in between, a press takes the region.
|
||||
pub const REGION_POINT_CLEARANCE: f64 = 3.5;
|
||||
/// A segment this close to the pointer (screen pixels, at any zoom) keeps it off every region,
|
||||
/// tighter than for a point.
|
||||
pub const REGION_SEGMENT_CLEARANCE: f64 = 2.0;
|
||||
/// The distance between neighbouring hatch lines at 100% (and every power-of-two zoom), screen
|
||||
/// pixels; see [`hatch_spacing`].
|
||||
pub const HATCH_GAP: f64 = 8.0;
|
||||
/// The direction hatch lines run on screen (y down): 45°, bottom left to top right ("/").
|
||||
pub const HATCH_DIRECTION: Vec2 = Vec2::new(std::f64::consts::FRAC_1_SQRT_2, -std::f64::consts::FRAC_1_SQRT_2);
|
||||
/// Curves are flattened this close (screen pixels) to clip the hatch.
|
||||
const FLATTEN_PX: f64 = 0.25;
|
||||
/// At most this many hatch lines per region (the box is clipped to the view first).
|
||||
const MAX_LINES: f64 = 20_000.0;
|
||||
|
||||
/// Where `zoom` can be used: a positive, finite number.
|
||||
fn usable(zoom: f64) -> bool {
|
||||
zoom.is_finite() && zoom > 0.0
|
||||
}
|
||||
|
||||
/// The on-screen distance between hatch lines at `zoom`, by octave folding: the zoom is folded
|
||||
/// back into one octave around its nearest power of two, `HATCH_GAP * zoom / 2^round(log2(zoom))`. It is [`HATCH_GAP`] at every
|
||||
/// power-of-two zoom, grows linearly with the zoom up to √2 times that and drops to 1/√2 times it
|
||||
/// at each step (√2 · 2ⁿ). `None` for an unusable zoom.
|
||||
pub fn hatch_spacing(zoom: f64) -> Option<f64> {
|
||||
if !usable(zoom) {
|
||||
return None;
|
||||
}
|
||||
let step = zoom.log2().round().clamp(-1000.0, 1000.0);
|
||||
let s = HATCH_GAP * zoom / step.exp2();
|
||||
(s.is_finite() && s > 0.0).then_some(s)
|
||||
}
|
||||
|
||||
/// Whether vector `n` shows a fill (only filled areas are regions).
|
||||
fn filled(n: &figcraft_doc::Node) -> bool {
|
||||
n.fills.iter().any(|f| f.visible)
|
||||
}
|
||||
|
||||
/// The network as its fill draws it: rounded points drawn in (region indexes are kept).
|
||||
fn drawn(net: &VectorNetwork) -> std::borrow::Cow<'_, VectorNetwork> {
|
||||
match net.rounded() {
|
||||
Some(r) => std::borrow::Cow::Owned(r),
|
||||
None => std::borrow::Cow::Borrowed(net),
|
||||
}
|
||||
}
|
||||
|
||||
/// The region of the active layer under page point `p` at `zoom`: none when a point is within
|
||||
/// [`REGION_POINT_CLEARANCE`] or a segment within [`REGION_SEGMENT_CLEARANCE`] screen pixels, or
|
||||
/// outside every region; the last region listed wins where regions overlap.
|
||||
pub fn region_hit(doc: &Document, ve: &VectorEdit, p: Point, zoom: f64) -> Option<usize> {
|
||||
if !usable(zoom) {
|
||||
return None;
|
||||
}
|
||||
let net = network(doc, ve.id)?;
|
||||
if net.regions.is_empty() || !doc.node(ve.id).is_some_and(filled) {
|
||||
return None;
|
||||
}
|
||||
let w = doc.world_transform(ve.id);
|
||||
let lp = w.inverse() * p;
|
||||
if !(lp.x.is_finite() && lp.y.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
// Screen pixels → local units (approximate for non-uniform scale, as point hits are).
|
||||
let k = 1.0 / zoom / w.determinant().abs().sqrt().max(1e-9);
|
||||
let (pc, sc) = (REGION_POINT_CLEARANCE * k, REGION_SEGMENT_CLEARANCE * k);
|
||||
if net.vertices.iter().any(|v| (v.point() - lp).hypot() <= pc) {
|
||||
return None;
|
||||
}
|
||||
if (0..net.segments.len()).filter_map(|i| net.cubic(i)).any(|c| c.nearest(lp, 0.01).distance_sq.sqrt() <= sc) {
|
||||
return None;
|
||||
}
|
||||
drawn(net).region_at(lp)
|
||||
}
|
||||
|
||||
/// The regions to hatch: the selected ones and the hovered one (not while a drag is on), each once.
|
||||
pub fn hatched_regions(ve: &VectorEdit, dragging: bool) -> Vec<usize> {
|
||||
let mut out = ve.regions.clone();
|
||||
if let Some(h) = ve.region_hover.filter(|_| !dragging) {
|
||||
out.push(h);
|
||||
}
|
||||
out.sort_unstable();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
/// One unclipped hatch line: its index (its offset from the anchor is `k` spacings) and two ends.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct HatchLine {
|
||||
pub k: i64,
|
||||
pub a: Point,
|
||||
pub b: Point,
|
||||
}
|
||||
|
||||
/// The unit normal of the hatch lines running along `d`.
|
||||
fn normal(d: Vec2) -> Vec2 {
|
||||
Vec2::new(-d.y, d.x)
|
||||
}
|
||||
|
||||
/// Hatch lines covering box `b`: every line `{X : (X − o)·n = k · spacing}` (`n` the normal of
|
||||
/// direction `d`) that crosses it, each long enough to cross the whole box. Empty for a bad box,
|
||||
/// direction or spacing, or when there would be too many lines.
|
||||
pub fn hatch_lines(b: Rect, o: Point, d: Vec2, spacing: f64) -> Vec<HatchLine> {
|
||||
let len = d.hypot();
|
||||
let finite = [b.x0, b.y0, b.x1, b.y1, o.x, o.y, d.x, d.y, spacing].iter().all(|v| v.is_finite());
|
||||
if !finite || spacing <= 0.0 || len <= 1e-12 || b.width() < 0.0 || b.height() < 0.0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let d = d / len;
|
||||
let n = normal(d);
|
||||
let corners = [Point::new(b.x0, b.y0), Point::new(b.x1, b.y0), Point::new(b.x1, b.y1), Point::new(b.x0, b.y1)];
|
||||
let proj = corners.map(|c| (c - o).dot(n));
|
||||
let lo = proj.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
let hi = proj.iter().copied().fold(f64::NEG_INFINITY, f64::max);
|
||||
let (first, last) = ((lo / spacing).floor(), (hi / spacing).ceil());
|
||||
if !(first.is_finite() && last.is_finite()) || last - first > MAX_LINES {
|
||||
return Vec::new();
|
||||
}
|
||||
let m = b.center();
|
||||
let reach = (b.width().hypot(b.height())).max(spacing);
|
||||
let mut out = Vec::new();
|
||||
let mut k = first;
|
||||
while k <= last {
|
||||
let q = m + n * (k * spacing - (m - o).dot(n));
|
||||
out.push(HatchLine { k: k as i64, a: q - d * reach, b: q + d * reach });
|
||||
k += 1.0;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The parts of segment `a`–`b` inside closed polygons `polys` by fill rule `rule`. The segment's
|
||||
/// ends must lie outside the polygons (hatch lines reach past the area's box).
|
||||
pub fn clip_to_area(polys: &[Vec<Point>], rule: WindingRule, a: Point, b: Point) -> Vec<[Point; 2]> {
|
||||
let u = b - a;
|
||||
let uu = u.hypot2();
|
||||
if !uu.is_finite() || uu <= 1e-18 {
|
||||
return Vec::new();
|
||||
}
|
||||
let n = normal(u / uu.sqrt());
|
||||
// Where each polygon edge crosses the line, and which way (the winding change).
|
||||
let mut xs: Vec<(f64, i32)> = Vec::new();
|
||||
for poly in polys {
|
||||
let count = poly.len();
|
||||
for i in 0..count {
|
||||
let (Some(p), Some(q)) = (poly.get(i), poly.get((i + 1) % count)) else { continue };
|
||||
let (sp, sq) = ((*p - a).dot(n), (*q - a).dot(n));
|
||||
let dir = match (sp <= 0.0, sq <= 0.0) {
|
||||
(true, false) => 1,
|
||||
(false, true) => -1,
|
||||
_ => continue,
|
||||
};
|
||||
let x = *p + (*q - *p) * (sp / (sp - sq));
|
||||
let t = (x - a).dot(u) / uu;
|
||||
if t.is_finite() {
|
||||
xs.push((t, dir));
|
||||
}
|
||||
}
|
||||
}
|
||||
xs.sort_by(|x, y| x.0.total_cmp(&y.0));
|
||||
let inside = |w: i32| match rule {
|
||||
WindingRule::NonZero => w != 0,
|
||||
WindingRule::EvenOdd => w % 2 != 0,
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
let (mut w, mut from) = (0, 0.0_f64);
|
||||
for (t, dir) in xs {
|
||||
if inside(w) {
|
||||
let (t0, t1) = (from.clamp(0.0, 1.0), t.clamp(0.0, 1.0));
|
||||
if t1 > t0 {
|
||||
out.push([a + u * t0, a + u * t1]);
|
||||
}
|
||||
}
|
||||
w += dir;
|
||||
from = t;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Closed polygons of a path, flattened `tol` close.
|
||||
fn polygons(path: &kurbo::BezPath, tol: f64) -> Vec<Vec<Point>> {
|
||||
let mut polys: Vec<Vec<Point>> = Vec::new();
|
||||
kurbo::flatten(path.iter(), tol, |el| match el {
|
||||
PathEl::MoveTo(p) => polys.push(vec![p]),
|
||||
PathEl::LineTo(p) => {
|
||||
if let Some(l) = polys.last_mut() {
|
||||
l.push(p);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
polys.retain(|p| p.len() > 2);
|
||||
polys
|
||||
}
|
||||
|
||||
/// The hatch over regions `regions` of the edited vector, as screen-space line segments: `view`
|
||||
/// maps page to screen and `clip` (screen) limits the lines to what shows. All regions share one
|
||||
/// pattern (anchored to the vector's origin). Empty when the view's zoom is not a positive finite
|
||||
/// number.
|
||||
pub fn region_hatch(doc: &Document, ve: &VectorEdit, regions: &[usize], view: Affine, clip: Option<Rect>) -> Vec<[Point; 2]> {
|
||||
let zoom = view.determinant().abs().sqrt();
|
||||
let Some(spacing) = hatch_spacing(zoom).filter(|_| !regions.is_empty()) else { return Vec::new() };
|
||||
let Some(net) = network(doc, ve.id) else { return Vec::new() };
|
||||
let net = drawn(net);
|
||||
let m = view * doc.world_transform(ve.id);
|
||||
let anchor = m * Point::ZERO;
|
||||
let mut out = Vec::new();
|
||||
let mut seen = Vec::new();
|
||||
for &r in regions {
|
||||
if seen.contains(&r) {
|
||||
continue;
|
||||
}
|
||||
seen.push(r);
|
||||
let (Some(region), Some(mut path)) = (net.regions.get(r), net.region_path(r)) else { continue };
|
||||
path.apply_affine(m);
|
||||
let polys = polygons(&path, FLATTEN_PX);
|
||||
let full = path.bounding_box();
|
||||
let b = clip.map_or(full, |c| full.intersect(c));
|
||||
if polys.is_empty() || b.width() <= 0.0 || b.height() <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
// Only the lines crossing what shows, each reaching past the whole area (clipping needs
|
||||
// its ends outside).
|
||||
let reach = full.width().hypot(full.height());
|
||||
for l in hatch_lines(b, anchor, HATCH_DIRECTION, spacing) {
|
||||
let mid = l.a.midpoint(l.b);
|
||||
let r = reach + (mid - full.center()).hypot() + spacing;
|
||||
out.extend(clip_to_area(&polys, region.winding, mid - HATCH_DIRECTION * r, mid + HATCH_DIRECTION * r));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl crate::Session {
|
||||
/// Vector edit mode: the hatch to draw over the active layer's selected regions and the hovered
|
||||
/// one (not while a drag is on), in screen space (`view`: page → screen; `clip`: the visible
|
||||
/// part of the canvas, screen).
|
||||
pub fn vector_region_hatch(&self, view: Affine, clip: Option<Rect>) -> Vec<[Point; 2]> {
|
||||
let Ok(ds) = self.ds() else { return Vec::new() };
|
||||
let Some(ve) = ds.vector_edit.as_ref() else { return Vec::new() };
|
||||
region_hatch(&ds.doc, ve, &hatched_regions(ve, self.tool.busy()), view, clip)
|
||||
}
|
||||
|
||||
/// The pointer left the canvas: nothing is hovered any more.
|
||||
pub fn vector_pointer_left(&mut self) {
|
||||
if let Ok(ds) = self.ds_mut()
|
||||
&& let Some(ve) = ds.vector_edit.as_mut()
|
||||
&& ve.region_hover.is_some()
|
||||
{
|
||||
ve.region_hover = None;
|
||||
ds.revision += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 235 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user