mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-14 06:44:43 +00:00
Compare commits
301
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7fb03f25d | ||
|
|
9415ef6579 | ||
|
|
2e32a1659c | ||
|
|
edc2845324 | ||
|
|
8f9be76fac | ||
|
|
fd98f572d2 | ||
|
|
4a572572db | ||
|
|
f757044d8c | ||
|
|
d0539b83e9 | ||
|
|
7cb0b68d73 | ||
|
|
3881cce291 | ||
|
|
3f36adfa90 | ||
|
|
add7806197 | ||
|
|
de28945484 | ||
|
|
313a38c0a8 | ||
|
|
1e85d54952 | ||
|
|
9e28e11954 | ||
|
|
48d74df226 | ||
|
|
2ac4ce3a73 | ||
|
|
d84aa7f1e1 | ||
|
|
ca94b5fe6f | ||
|
|
3fadbf4ba9 | ||
|
|
6233b90ba7 | ||
|
|
c605bd5781 | ||
|
|
ec325c648c | ||
|
|
d9e79d604b | ||
|
|
15533338e8 | ||
|
|
ee3e3f0bd4 | ||
|
|
0dac325868 | ||
|
|
0ef8966844 | ||
|
|
f44d7ed598 | ||
|
|
69806c9edd | ||
|
|
c42207fdee | ||
|
|
b6820d6710 | ||
|
|
ab6cf4fd58 | ||
|
|
b4d58a362e | ||
|
|
6e15e30790 | ||
|
|
fa4821bcea | ||
|
|
b0e9e87129 | ||
|
|
52df73c44c | ||
|
|
40c744da10 | ||
|
|
d1d8c3f652 | ||
|
|
41d465a678 | ||
|
|
d8e64a73bf | ||
|
|
23bdebd973 | ||
|
|
d9584410af | ||
|
|
c4536f412b | ||
|
|
7fd44641f2 | ||
|
|
3723fcffbd | ||
|
|
120b6830ff | ||
|
|
a52e2bb6b3 | ||
|
|
8408cc72b8 | ||
|
|
0b40964202 | ||
|
|
3489e01a22 | ||
|
|
d2606b3f51 | ||
|
|
4b29d8c34d | ||
|
|
d331db00aa | ||
|
|
f40c600e67 | ||
|
|
9c2d61f6ae | ||
|
|
e4fe308cdf | ||
|
|
4ee413acde | ||
|
|
1f6010e07c | ||
|
|
90d7521840 | ||
|
|
665a578957 | ||
|
|
44ac32db0c | ||
|
|
f948e08fbc | ||
|
|
65f5771a6e | ||
|
|
33d59932f9 | ||
|
|
049f97a4c3 | ||
|
|
62a291fcb8 | ||
|
|
7459cc7f86 | ||
|
|
d27ae11d15 | ||
|
|
b640069961 | ||
|
|
9f935718fe | ||
|
|
1c863aa9f5 | ||
|
|
891f0acb3c | ||
|
|
55c05338a1 | ||
|
|
4e3fd82bd1 | ||
|
|
41b08a2393 | ||
|
|
730e377418 | ||
|
|
175fdfa428 | ||
|
|
d60a27b675 | ||
|
|
fd6a7e0ba3 | ||
|
|
db8bf6ba33 | ||
|
|
4d9cde8fee | ||
|
|
f8458b70a8 | ||
|
|
0a84dee777 | ||
|
|
46316c9895 | ||
|
|
0e1ccb55d2 | ||
|
|
ec8a506353 | ||
|
|
eb741a362d | ||
|
|
97e422226f | ||
|
|
8ba09001cd | ||
|
|
ecad646330 | ||
|
|
87cb799783 | ||
|
|
466fd249fe | ||
|
|
a4ba67ac22 | ||
|
|
99b6495b7d | ||
|
|
6eb9f38487 | ||
|
|
bf4d9e0f6f | ||
|
|
9418e2c8bb | ||
|
|
c4c88900c4 | ||
|
|
aadaa2d975 | ||
|
|
580eee3572 | ||
|
|
e359d856f2 | ||
|
|
e08c1c598f | ||
|
|
4df3702dbb | ||
|
|
b7c692171d | ||
|
|
d95aef1d0c | ||
|
|
887e374c58 | ||
|
|
34dcf852d5 | ||
|
|
5e7eb51e46 | ||
|
|
d40fd80be7 | ||
|
|
1c5b4d8450 | ||
|
|
ef69a7f8e9 | ||
|
|
a1849aae15 | ||
|
|
1e25e239c2 | ||
|
|
7bda68876b | ||
|
|
362bdce73b | ||
|
|
5e99e71728 | ||
|
|
09e85240ea | ||
|
|
7998845515 | ||
|
|
e8eca728fe | ||
|
|
438d468c76 | ||
|
|
07130947f9 | ||
|
|
16e26b69ff | ||
|
|
cd5c70a244 | ||
|
|
e6240f6229 | ||
|
|
1498c0ea52 | ||
|
|
67cf5ecfd5 | ||
|
|
0c3c00db99 | ||
|
|
7988e48b5d | ||
|
|
0105724108 | ||
|
|
2894195ab3 | ||
|
|
02156c0edc | ||
|
|
06844dc703 | ||
|
|
2d1a7c973d | ||
|
|
77c9ccd649 | ||
|
|
95b32053d5 | ||
|
|
b1760ff098 | ||
|
|
b0aa597787 | ||
|
|
dcb36e376f | ||
|
|
139195cb0c | ||
|
|
991cb604c2 | ||
|
|
1ef42e6504 | ||
|
|
d7de56f046 | ||
|
|
4bc63d506c | ||
|
|
567110f063 | ||
|
|
ffde863e92 | ||
|
|
e2b567d5b3 | ||
|
|
77b6f1fc12 | ||
|
|
97b10b637d | ||
|
|
7da4cf5e31 | ||
|
|
a8416b27c1 | ||
|
|
9552e530be | ||
|
|
fe5a22e468 | ||
|
|
59fe9b5ea8 | ||
|
|
862d73bf9f | ||
|
|
9dcfe4ecbc | ||
|
|
2fe7a9449a | ||
|
|
916ee3276c | ||
|
|
97f9c2fc5d | ||
|
|
4a2d19f0db | ||
|
|
a7bcc6d6ff | ||
|
|
f4d53a659b | ||
|
|
3074db1cbb | ||
|
|
4f9023d80f | ||
|
|
825bb708df | ||
|
|
55db465d8e | ||
|
|
e9e2d87405 | ||
|
|
44128f7e97 | ||
|
|
782c9757f3 | ||
|
|
5454bdc244 | ||
|
|
843b00b22f | ||
|
|
9fa99f8594 | ||
|
|
85d97a831c | ||
|
|
7d0f9d7518 | ||
|
|
4588832c80 | ||
|
|
bc5b474293 | ||
|
|
18e3070d4c | ||
|
|
3062c6c4d6 | ||
|
|
4376f659ac | ||
|
|
0ba8e30fbc | ||
|
|
fdf397d7dc | ||
|
|
d1fcf69b7b | ||
|
|
49010614fd | ||
|
|
778b8c6807 | ||
|
|
1593b163fb | ||
|
|
102429e6b5 | ||
|
|
6ce65b24f4 | ||
|
|
a4f4869443 | ||
|
|
a57976e88f | ||
|
|
3c28f605d1 | ||
|
|
76a67e7ee5 | ||
|
|
330b237c9c | ||
|
|
7689af50d6 | ||
|
|
d769320d45 | ||
|
|
01c0b38669 | ||
|
|
b61122ccca | ||
|
|
4fbe8d2976 | ||
|
|
524b538908 | ||
|
|
72b826d65b | ||
|
|
0d125374d3 | ||
|
|
745e599118 | ||
|
|
ffc01cbac9 | ||
|
|
d2e0ac40aa | ||
|
|
4ed5190d7f | ||
|
|
f32e8c5d2d | ||
|
|
4812b3ebfa | ||
|
|
2c51dbe4e3 | ||
|
|
261225340f | ||
|
|
0444dc39a0 | ||
|
|
01463fb78e | ||
|
|
3acffcd362 | ||
|
|
2ea8c3f86f | ||
|
|
9ba207e900 | ||
|
|
6551f870b6 | ||
|
|
7a9e9de38c | ||
|
|
1d5c9ae010 | ||
|
|
8199623267 | ||
|
|
6ea957ec5d | ||
|
|
51549f9a60 | ||
|
|
b29cc01d55 | ||
|
|
22d5f17828 | ||
|
|
4dc7c1b59c | ||
|
|
d0285d236e | ||
|
|
7a819dd190 | ||
|
|
6291e43605 | ||
|
|
cc80bd2a5d | ||
|
|
908906f3f4 | ||
|
|
9894ee0e65 | ||
|
|
62a10ea460 | ||
|
|
8f58c39bd7 | ||
|
|
2d78f40e60 | ||
|
|
f6437548ba | ||
|
|
62ed3452d6 | ||
|
|
cf06f2ca30 | ||
|
|
dd84097b6d | ||
|
|
466bf6a813 | ||
|
|
fbe746a776 | ||
|
|
50f1237617 | ||
|
|
9853ffce61 | ||
|
|
2f4d3894b5 | ||
|
|
bf506712ba | ||
|
|
f2eb75da0b | ||
|
|
7d6a3ce137 | ||
|
|
ac7c91fc9c | ||
|
|
aeeb1a7cde | ||
|
|
8bcb0f78dd | ||
|
|
54f9212a2f | ||
|
|
e11a182afd | ||
|
|
34565964b0 | ||
|
|
6d9024e0d0 | ||
|
|
121ba72957 | ||
|
|
adf96ca2e7 | ||
|
|
9b1c79277a | ||
|
|
b3b6b14eb4 | ||
|
|
be56ca2836 | ||
|
|
9687c0ffc0 | ||
|
|
b75e5bea3b | ||
|
|
8616f3d1c5 | ||
|
|
bebe509a25 | ||
|
|
8b0649da3a | ||
|
|
3293a12542 | ||
|
|
42e6f267b8 | ||
|
|
831b8fed43 | ||
|
|
72a3c8522f | ||
|
|
ec9b1197c9 | ||
|
|
43e6cba2ef | ||
|
|
8e56820168 | ||
|
|
376d524a57 | ||
|
|
61bc027072 | ||
|
|
4cf1da167c | ||
|
|
dee13aefc5 | ||
|
|
7dd71bc2da | ||
|
|
ef10edfe29 | ||
|
|
c3330c4260 | ||
|
|
e4b4fb0cac | ||
|
|
4f9cbf702f | ||
|
|
6a2eeec928 | ||
|
|
c6fdf9bd4c | ||
|
|
e8a30df944 | ||
|
|
ee2640d18e | ||
|
|
9eadb37622 | ||
|
|
8c74c72b50 | ||
|
|
e4126949bf | ||
|
|
099465212e | ||
|
|
13691c1cc0 | ||
|
|
01a0f4f332 | ||
|
|
b87d2e476f | ||
|
|
8efb66ba19 | ||
|
|
f387d3f464 | ||
|
|
fa0365470e | ||
|
|
3e777d2485 | ||
|
|
9404226235 | ||
|
|
698e249fbf | ||
|
|
1aeb5abb74 | ||
|
|
ed0e7a9b2d | ||
|
|
00ac44f6ca | ||
|
|
8b8825ce68 | ||
|
|
bd482ea6dd |
No files matched your search
@@ -1,13 +1,12 @@
|
||||
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
|
||||
# (GitHub has no FreeBSD runners).
|
||||
# It then runs packaging/freebsd/package.sh, the script the release job runs, and checks the
|
||||
# tarball, so a broken FreeBSD package fails here before it can fail a release. Nothing is
|
||||
# uploaded and no secrets are used.
|
||||
# (GitHub has no FreeBSD runners). It then runs packaging/freebsd/package.sh, the script
|
||||
# the release job runs, and checks the tarball, so a broken FreeBSD package fails here
|
||||
# before it can fail a release. Nothing is uploaded and no secrets are used.
|
||||
name: FreeBSD
|
||||
|
||||
# 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.
|
||||
# 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.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 2 * * *"
|
||||
@@ -29,36 +28,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
freebsd:
|
||||
name: FreeBSD 14 (build + test + package)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 300
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build, test and package in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
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 dejavu
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
run: |
|
||||
set -e
|
||||
. "$HOME/.cargo/env"
|
||||
# The checkout is synced in under another owner; let tests that call git read it.
|
||||
git config --global --add safe.directory "*"
|
||||
rustc --version
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
|
||||
# The release package, as release.yml builds it. Free the debug build's disk first.
|
||||
rm -rf target/debug
|
||||
CARGO_BUILD_JOBS=4 bash packaging/freebsd/package.sh
|
||||
tar -tzf dist/release/figcraft-*-freebsd-x86_64.tar.gz >"$HOME/freebsd-contents.txt"
|
||||
for f in '/bin/figcraft$' '/share/applications/ai\.storyteller\.figcraft\.desktop$' '/share/doc/figcraft/LICENSE-MIT$'; do
|
||||
grep -E "$f" "$HOME/freebsd-contents.txt"
|
||||
done
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
with:
|
||||
run-package-check: true
|
||||
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
@@ -0,0 +1,33 @@
|
||||
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
|
||||
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
|
||||
name: Nix
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
pull_request:
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: nix-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
eval:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
|
||||
- name: nix flake check --no-build
|
||||
run: |
|
||||
if [ -f flake.nix ]; then
|
||||
nix flake check --no-build --all-systems
|
||||
else
|
||||
echo "No flake.nix present in this repository; skipping check."
|
||||
fi
|
||||
@@ -19,58 +19,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
|
||||
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
|
||||
| tar -xz -C "$RUNNER_TEMP" actionlint
|
||||
- name: actionlint
|
||||
run: |
|
||||
"$RUNNER_TEMP/actionlint" -color
|
||||
- name: shellcheck
|
||||
run: shellcheck packaging/*.sh packaging/*/*.sh packaging/linux/AppRun
|
||||
- name: Server install script (Caddyfile handling)
|
||||
run: bash packaging/server/install-test.sh
|
||||
- name: AppImage AppRun desktop integration
|
||||
run: bash packaging/linux/apprun-test.sh
|
||||
- name: PowerShell
|
||||
shell: pwsh
|
||||
run: |
|
||||
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
|
||||
$errors = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
|
||||
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
|
||||
Write-Output "ok $($f.Name)"
|
||||
}
|
||||
./packaging/windows/check-icons.ps1
|
||||
- name: FreeBSD package layout (dry run, no build)
|
||||
run: bash packaging/freebsd/package.sh --dry-run
|
||||
- name: No template leftovers from other apps in package metadata
|
||||
run: |
|
||||
if grep -rniE 'photoshop|psd|photocraft|vectorcraft|illustrator|adobe|figma' packaging/ .github/ --exclude=packaging-lint.yml; then
|
||||
echo "::error::package metadata mentions another product (see above)"
|
||||
exit 1
|
||||
fi
|
||||
- name: XML, desktop entry, AppStream, Flatpak manifest
|
||||
run: |
|
||||
xmllint --noout packaging/windows/figcraft.wxs packaging/windows/figcraft.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml*
|
||||
desktop-file-validate packaging/linux/ai.storyteller.figcraft.desktop
|
||||
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.figcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.figcraft.metainfo.xml"
|
||||
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.figcraft.metainfo.xml"
|
||||
python3 - <<'EOF'
|
||||
import yaml
|
||||
src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.figcraft.yml"))
|
||||
bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.figcraft.bundle.yml"))
|
||||
for m in (src, bundle):
|
||||
assert m["id"] == "ai.storyteller.figcraft" and m["command"] == "figcraft", m["id"]
|
||||
for key in ("runtime", "runtime-version", "sdk", "finish-args"):
|
||||
assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
|
||||
print("Flatpak manifests ok")
|
||||
EOF
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
|
||||
with:
|
||||
extra-checks-script: packaging/check-app-specific.sh
|
||||
+24
-541
@@ -1,11 +1,3 @@
|
||||
# Release pipeline: every push to `release` builds signed installers for every platform and
|
||||
# creates or updates the draft GitHub Release "FigCraft v<version>". See docs/releasing.md.
|
||||
#
|
||||
# Only the jobs that read secrets (macOS and Windows signing, and the draft release) run in the
|
||||
# `release` environment (restricted to the `release` branch), which holds the signing secrets; the
|
||||
# rest build on any branch, so `gh workflow run release.yml --ref <branch>` is a real dry run.
|
||||
# Every secret is optional: missing ones produce unsigned artifacts and a warning, never a failed
|
||||
# build. Playbook: storytold/craftrules release/playbook.md.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
@@ -19,543 +11,34 @@ on:
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
# Never cancel a run mid-notarization or mid-upload; a newer push waits its turn.
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
RUST_BACKTRACE: 1
|
||||
FIGCRAFT_BUILD_SHA: ${{ github.sha }}
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
# craft-fonts (https://github.com/storytold/craft-fonts): shared font assets every release
|
||||
# embeds (Inter and the Latin presentation fonts from fonts/latin-manifest.txt; the web build
|
||||
# embeds Inter only, see crates/text/build.rs). An optional build input for everyone else.
|
||||
# crates/text/build.rs needs fonts/latin-manifest.txt, so the pin must include it. Bump
|
||||
# deliberately; every build job checks out this commit.
|
||||
CRAFT_FONTS_REF: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
|
||||
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.
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
date: ${{ steps.v.outputs.date }}
|
||||
prerelease: ${{ steps.v.outputs.prerelease }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- id: v
|
||||
name: Resolve version
|
||||
run: |
|
||||
. packaging/env.sh
|
||||
v="${INPUT_VERSION#v}"
|
||||
v="${v:-$VERSION}"
|
||||
if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
|
||||
echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1"
|
||||
exit 1
|
||||
fi
|
||||
case "$v" in *-*) pre=true ;; *) pre=false ;; esac
|
||||
{
|
||||
echo "version=$v"
|
||||
echo "date=$(date -u +%Y-%m-%d)"
|
||||
echo "prerelease=$pre"
|
||||
} >>"$GITHUB_OUTPUT"
|
||||
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)
|
||||
environment: release
|
||||
env:
|
||||
FIGCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts (embedded fonts)
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-macos-universal
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION"
|
||||
- name: Import signing certificate
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
run: packaging/macos/import-cert.sh
|
||||
- name: Build, sign, notarize
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/macos/package.sh --arch universal
|
||||
# Unpacks the CLI zip and checks the binary users get: Developer ID, hardened runtime,
|
||||
# timestamp, and Gatekeeper's notarization lookup (codesign --verify, spctl). Also the DMG's
|
||||
# stapled ticket. Strict when the signing secrets are present, warnings otherwise.
|
||||
- name: Verify signatures and notarization
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: packaging/macos/verify.sh --arch universal
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-macos
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
- name: Remove temporary keychain
|
||||
if: always()
|
||||
run: |
|
||||
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
|
||||
environment: release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x64, target: x86_64-pc-windows-msvc }
|
||||
- { arch: x86, target: i686-pc-windows-msvc }
|
||||
# Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above.
|
||||
# .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware.
|
||||
- { arch: arm64, target: aarch64-pc-windows-msvc }
|
||||
env:
|
||||
FIGCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts (embedded fonts)
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-windows-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
shell: bash
|
||||
run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION"
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: |
|
||||
dotnet tool install --global wix --version 5.0.2
|
||||
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
|
||||
- name: Build, sign, package
|
||||
shell: pwsh
|
||||
env:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-windows-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
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+,
|
||||
# Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
|
||||
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
|
||||
env:
|
||||
FIGCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
NFPM_VERSION: 2.47.0
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts (embedded fonts)
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- name: Install build and packaging tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \
|
||||
desktop-file-utils appstream python3-yaml zsync
|
||||
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
|
||||
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
|
||||
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-linux-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION"
|
||||
- name: Build and package (AppImage, deb, rpm, tar.gz)
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/linux/package.sh
|
||||
- name: Check the packages
|
||||
run: |
|
||||
dpkg-deb --info dist/release/*.deb
|
||||
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/figcraft$|metainfo|applications'
|
||||
ldd target/release/figcraft
|
||||
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.figcraft.yml')); assert m['id'] == 'ai.storyteller.figcraft'"
|
||||
for appimage in dist/release/*.AppImage; do
|
||||
chmod +x "$appimage"
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
|
||||
# Update information is embedded, and the .zsync for delta updates sits beside it.
|
||||
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
|
||||
test -s "$appimage.zsync"
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
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
|
||||
# with the binary, licences, docs/server.md and the deployment examples. Same runners as the
|
||||
# app, so the same glibc floor; no craft-fonts (the server draws no text).
|
||||
# shortcut: Linux only (where servers run); add macOS/Windows archives, signed like figcraft-cli,
|
||||
# when people host there.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-22.04 }
|
||||
- { arch: aarch64, runner: ubuntu-22.04-arm }
|
||||
env:
|
||||
FIGCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-server-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION"
|
||||
- name: Build and package (tar.gz)
|
||||
run: packaging/server/package.sh
|
||||
- name: Check the archive
|
||||
run: |
|
||||
tar -tzf "dist/release/figcraft-server-$FIGCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz" | tee "$RUNNER_TEMP/server-contents.txt"
|
||||
for f in '/figcraft-server$' '/figcraft-server\.service$' '/Caddyfile$' '/nginx\.conf$' '/install\.sh$' '/figcraft-backup\.timer$' '/server\.md$' '/LICENSE-MIT$'; do
|
||||
grep -E "$f" "$RUNNER_TEMP/server-contents.txt"
|
||||
done
|
||||
ldd target/release/figcraft-server
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-server-linux-${{ matrix.arch }}
|
||||
path: dist/release/*.tar.gz
|
||||
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
|
||||
# build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-24.04 }
|
||||
- { arch: aarch64, runner: ubuntu-24.04-arm }
|
||||
env:
|
||||
FIGCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/linux
|
||||
- name: Install flatpak-builder
|
||||
run: |
|
||||
sudo apt-get update
|
||||
# librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's
|
||||
# `appstreamcli compose`, which renders the scalable icon and fails without it.
|
||||
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
|
||||
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
- name: Build the Flatpak bundle
|
||||
run: packaging/linux/flatpak-bundle.sh "dist/linux/figcraft-$FIGCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-flatpak-${{ matrix.arch }}
|
||||
path: dist/release/*.flatpak
|
||||
if-no-files-found: error
|
||||
|
||||
freebsd:
|
||||
name: FreeBSD x86_64
|
||||
needs: version
|
||||
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM, the same image and packages as
|
||||
# .github/workflows/freebsd.yml. A private repo's ubuntu-latest has 2 vCPUs and 7 GB, too small
|
||||
# for this VM (QEMU "cannot set up guest memory"), so it runs on the org's 16-core larger runner.
|
||||
runs-on: github-ubuntu-16core
|
||||
timeout-minutes: 180
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
env:
|
||||
FIGCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts (embedded fonts)
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- name: Build and package in a FreeBSD VM (tar.gz)
|
||||
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Passed into the VM by name; never templated into the script below.
|
||||
envs: "FIGCRAFT_VERSION FIGCRAFT_BUILD_DATE FIGCRAFT_BUILD_SHA INPUT_VERSION"
|
||||
# The FreeBSD CI packages, plus bash for the packaging script.
|
||||
prepare: |
|
||||
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
|
||||
run: |
|
||||
set -e
|
||||
. "$HOME/.cargo/env"
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# Build outside the checkout so only dist/ is copied back out of the VM (copying the
|
||||
# target dir back fails on large builds). 4 jobs: more runs the VM out of memory.
|
||||
export CARGO_TARGET_DIR=/var/tmp/figcraft-target CARGO_BUILD_JOBS=4
|
||||
if [ -n "$INPUT_VERSION" ]; then cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION"; fi
|
||||
# craft-fonts was checked out next to the sources and synced into the VM with them.
|
||||
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
|
||||
bash packaging/freebsd/package.sh
|
||||
tar -tzf dist/release/figcraft-*-freebsd-x86_64.tar.gz >"$HOME/freebsd-contents.txt"
|
||||
for f in '/bin/figcraft$' '/share/applications/ai\.storyteller\.figcraft\.desktop$' '/share/doc/figcraft/LICENSE-MIT$'; do
|
||||
grep -E "$f" "$HOME/freebsd-contents.txt"
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-freebsd-x86_64
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
web:
|
||||
timeout-minutes: 90
|
||||
name: Web (wasm)
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
|
||||
env:
|
||||
FIGCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
TRUNK_VERSION: 0.21.14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-web
|
||||
- name: Install trunk
|
||||
run: |
|
||||
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
|
||||
| tar -xz -C "$HOME/.cargo/bin" trunk
|
||||
trunk --version
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION"
|
||||
# The wasm32 build embeds only Inter from craft-fonts (crates/text/build.rs): browsers have
|
||||
# no system fonts to fall back on, and the rest would push the wasm towards
|
||||
# packaging/web/package.sh's 24 MiB gate.
|
||||
- name: Check out craft-fonts (embedded fonts)
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- name: Build and zip
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/web/package.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-web
|
||||
path: dist/release/*
|
||||
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
|
||||
# from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's
|
||||
# riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the
|
||||
# CLI is smoke-tested under QEMU.
|
||||
runs-on: ubuntu-24.04
|
||||
# No `environment: release`: this job reads no secrets, so a branch dry run can run it.
|
||||
env:
|
||||
FIGCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check out craft-fonts (embedded fonts)
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: riscv64gc-unknown-linux-gnu
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-linux-riscv64
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION"
|
||||
- name: Install cross-compilation dependencies
|
||||
run: |
|
||||
. /etc/os-release
|
||||
sudo dpkg --add-architecture riscv64
|
||||
# Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports.
|
||||
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
|
||||
sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
|
||||
fi
|
||||
if [ -f /etc/apt/sources.list ]; then
|
||||
sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list
|
||||
sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list
|
||||
fi
|
||||
for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do
|
||||
echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse"
|
||||
done | sudo tee /etc/apt/sources.list.d/riscv64.list
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \
|
||||
libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \
|
||||
libxi-dev:riscv64 libgl1-mesa-dev:riscv64
|
||||
- name: Build and package (tar.gz)
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
|
||||
PKG_CONFIG_SYSROOT_DIR: /
|
||||
PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
|
||||
PKG_CONFIG_ALLOW_CROSS: "1"
|
||||
CROSS_ARCH: riscv64
|
||||
CROSS_TARGET: riscv64gc-unknown-linux-gnu
|
||||
CROSS_COMPILE: riscv64-linux-gnu-
|
||||
EMULATOR: qemu-riscv64
|
||||
QEMU_LD_PREFIX: /usr/riscv64-linux-gnu
|
||||
run: packaging/linux/package.sh --formats tar
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-linux-riscv64
|
||||
path: dist/release/*.tar.gz
|
||||
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
|
||||
environment: release
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
PRERELEASE: ${{ needs.version.outputs.prerelease }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: release-*
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
- name: Checksums
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
sha256sum -- * | tee SHA256SUMS.txt
|
||||
- name: Create or update the draft release
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
tag="v$VERSION"
|
||||
title="FigCraft v$VERSION"
|
||||
pre=()
|
||||
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
|
||||
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
|
||||
if [ "$draft" != "true" ]; then
|
||||
echo "::error::Release $tag is already published. Bump the version (cargo run -p xtask -- version set X.Y.Z) before pushing to release."
|
||||
exit 1
|
||||
fi
|
||||
echo "Updating draft $tag: replacing its assets"
|
||||
gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do
|
||||
gh release delete-asset "$tag" "$asset" --yes
|
||||
done
|
||||
gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE"
|
||||
gh release upload "$tag" --clobber -- *
|
||||
else
|
||||
echo "Creating draft $tag"
|
||||
gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- *
|
||||
fi
|
||||
{
|
||||
echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:"
|
||||
echo
|
||||
sed 's/^/ /' SHA256SUMS.txt
|
||||
} >>"$GITHUB_STEP_SUMMARY"
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
app-name: figcraft
|
||||
app-title: FigCraft
|
||||
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
has-server: true
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
@@ -24,77 +24,10 @@ concurrency:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
|
||||
jobs:
|
||||
package:
|
||||
name: package (cross-compiled on x64)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable
|
||||
with:
|
||||
targets: aarch64-pc-windows-msvc
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||
with:
|
||||
key: windows-arm64-package
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: |
|
||||
dotnet tool install --global wix --version 5.0.2
|
||||
wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2
|
||||
- name: Build and package (unsigned)
|
||||
shell: pwsh
|
||||
env:
|
||||
DIST: ${{ github.workspace }}\dist\arm64
|
||||
run: ./packaging/windows/package.ps1 -Arch arm64
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: figcraft-windows-arm64
|
||||
path: dist/arm64/*
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
install:
|
||||
name: install and run on ARM64
|
||||
needs: package
|
||||
runs-on: windows-11-arm
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: figcraft-windows-arm64
|
||||
path: dist
|
||||
- name: Install the MSI, check it, run it, uninstall it
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName
|
||||
$p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" }
|
||||
$dir = Join-Path $env:ProgramFiles 'FigCraft'
|
||||
# figcraft-cli.exe ships once apps/figcraft-cli exists; check it when installed.
|
||||
foreach ($exe in 'figcraft.exe', 'figcraft-cli.exe') {
|
||||
$path = Join-Path $dir $exe
|
||||
if (-not (Test-Path $path)) {
|
||||
if ($exe -eq 'figcraft-cli.exe') { Write-Output "skip $exe (not in this build)"; continue }
|
||||
throw "$exe not installed in $dir"
|
||||
}
|
||||
$bytes = [System.IO.File]::ReadAllBytes($path)
|
||||
$machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4)
|
||||
if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" }
|
||||
Write-Output "ok $path (ARM64)"
|
||||
}
|
||||
# The installed binary runs natively on ARM64 (the CLI when present, else the app's --version).
|
||||
$cli = Join-Path $dir 'figcraft-cli.exe'
|
||||
if (-not (Test-Path $cli)) { $cli = Join-Path $dir 'figcraft.exe' }
|
||||
$out = & $cli --version 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "$cli --version exited $LASTEXITCODE" }
|
||||
Write-Output "ok $(Split-Path -Leaf $cli) --version: $($out.Trim())"
|
||||
$p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" }
|
||||
if (Test-Path (Join-Path $dir 'figcraft.exe')) { throw 'uninstall left figcraft.exe behind' }
|
||||
Write-Output 'ok uninstalled'
|
||||
windows-arm64:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
|
||||
with:
|
||||
app-name: figcraft
|
||||
app-dir: FigCraft
|
||||
Generated
+1
@@ -1721,6 +1721,7 @@ dependencies = [
|
||||
"figcraft-collab-net",
|
||||
"figcraft-engine",
|
||||
"figcraft-ui-egui",
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
|
||||
+11
-2
@@ -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-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)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
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
|
||||
@@ -111,7 +111,7 @@ native speakers' reviews of the six language drafts need people.
|
||||
| Korean | ko | 100% | draft (needs native review) |
|
||||
| Vietnamese | vi | 0% | none |
|
||||
|
||||
No other languages. Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
|
||||
Also shipped: Swedish (`sv`: every UI string, 120 of 588 command labels and menu wordings; native review welcome). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
|
||||
(Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America),
|
||||
French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts (Preferences ▸
|
||||
Language; Traditional Chinese has no catalog; CJK UI
|
||||
@@ -131,6 +131,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged.
|
||||
- **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged.
|
||||
- **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged.
|
||||
- **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.
|
||||
@@ -313,6 +315,13 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. |
|
||||
| 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. |
|
||||
| 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. |
|
||||
| 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. |
|
||||
| 2026-10-15 | minor | Canvas retains a bounded margin while panning so newly exposed strips can reuse the previous render. Numbers unchanged. |
|
||||
| 2026-10-15 | minor | Typography spacing icons scrub with the shared number-field gesture, modifier rates and one Undo step per drag (#373). Units and selected-character targeting are preserved. Numbers unchanged. |
|
||||
| 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 |
|
||||
|
||||
@@ -38,7 +38,7 @@ fn write(path: &str, bytes: &[u8]) -> CliResult<()> {
|
||||
|
||||
fn page_named(doc: &Document, name: Option<&str>) -> CliResult<NodeId> {
|
||||
match name {
|
||||
None => doc.first_page().ok_or_else(|| CliError::Failed("the document has no pages".into())),
|
||||
None => doc.first_open_page().or_else(|| doc.first_page()).ok_or_else(|| CliError::Failed("the document has no pages".into())),
|
||||
Some(n) => doc
|
||||
.pages
|
||||
.iter()
|
||||
@@ -380,6 +380,22 @@ mod tests {
|
||||
assert_eq!(sanitize(""), "layer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_page_skips_leading_divider() {
|
||||
let mut doc = Document::new("divider");
|
||||
let page = doc.first_page().unwrap();
|
||||
doc.create(page, figcraft_doc::NodeKind::Frame, "First", 0.0, 0.0, 40.0, 30.0).unwrap();
|
||||
let divider = doc.add_page("- Divider");
|
||||
doc.pages.retain(|p| *p != divider);
|
||||
doc.pages.insert(0, divider);
|
||||
assert!(doc.is_divider(divider));
|
||||
assert_eq!(page_named(&doc, None).unwrap(), page);
|
||||
assert_eq!(page_named(&doc, Some("- Divider")).unwrap(), divider);
|
||||
let pm = render_page(&doc, page_named(&doc, None).unwrap(), 1.0).unwrap();
|
||||
assert_eq!((pm.width(), pm.height()), (40, 30));
|
||||
assert!(render_page(&doc, divider, 1.0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_depth_errors_preserve_conversion_destinations() {
|
||||
let mut doc = Document::new("export depth");
|
||||
|
||||
@@ -438,44 +438,66 @@ fn measure_components(table: &mut Table, col: usize, instances: usize) -> CliRes
|
||||
mean(3, |i| exec(&mut s, "node.set", json!({"ids": labels, "props": {"fontSize": 13 + (i % 2) * 2}}))),
|
||||
);
|
||||
let doc = s.doc().map_err(failed)?.clone();
|
||||
let (card, _) = instance_with(&doc, &inst, ds.card, &["Footer button", "Icon", "Glyph"]).ok_or_else(|| failed("no card"))?;
|
||||
table.put(
|
||||
col,
|
||||
"switch a card's variant (nested overrides kept)",
|
||||
mean(5, |i| {
|
||||
exec(&mut s, "instance.setProperties", json!({"ids": [card], "properties": {"Size": if i % 2 == 0 { "Large" } else { "Small" }}}))
|
||||
}),
|
||||
);
|
||||
let (nav, _) = instance_with(&doc, &inst, ds.nav, &["Profile"]).ok_or_else(|| failed("no nav"))?;
|
||||
table.put(col, "reset all changes on a nav", timed(&mut s, "component.resetInstance", json!({"ids": [nav]}), true));
|
||||
table.put(col, "detach a nav", timed(&mut s, "component.detach", json!({"ids": [nav]}), true));
|
||||
let (button, bglyph) = instance_with(&doc, &inst, ds.button, &["Icon", "Glyph"]).ok_or_else(|| failed("no button"))?;
|
||||
table.put(col, "push a button's changes to its main", timed(&mut s, "component.pushChanges", json!({"id": button}), true));
|
||||
// A button glyph the drop really lands on: screens can overlap (text sizes differ without
|
||||
// fonts), so ask the hit test the swap uses.
|
||||
let now = s.doc().map_err(failed)?.clone();
|
||||
let page = s.page().map_err(failed)?;
|
||||
let b = std::iter::once(bglyph)
|
||||
.chain(inst.iter().filter_map(|i| {
|
||||
let NodeKind::Instance { component, .. } = &now.node(*i)?.kind else { return None };
|
||||
(figcraft_engine::components::variant_set(&now, *component) == Some(ds.button)).then(|| find_path(&now, *i, &["Icon", "Glyph"]))?
|
||||
}))
|
||||
.filter_map(|g| now.world_bounds(g))
|
||||
.find(|b| figcraft_engine::drop::swap_target(&now, page, b.center(), None, None).is_some())
|
||||
.ok_or_else(|| failed("no button glyph to drop on"))?;
|
||||
let other = ds.icons.get(5).copied().unwrap_or(icon0);
|
||||
table.put(
|
||||
col,
|
||||
"Alt-drop swap onto an instance",
|
||||
timed(&mut s, "instance.swapAt", json!({"component": other, "x": b.center().x, "y": b.center().y}), true),
|
||||
);
|
||||
exec(&mut s, "edit.copy", json!({"ids": [nav]}));
|
||||
table.put(col, "copy + paste a nav", timed(&mut s, "edit.paste", json!({}), true));
|
||||
// Small workloads may not contain every instance type; the rows needing a missing one stay empty.
|
||||
if let Some((card, _)) = instance_with(&doc, &inst, ds.card, &["Footer button", "Icon", "Glyph"]) {
|
||||
table.put(
|
||||
col,
|
||||
"switch a card's variant (nested overrides kept)",
|
||||
mean(5, |i| {
|
||||
exec(&mut s, "instance.setProperties", json!({"ids": [card], "properties": {"Size": if i % 2 == 0 { "Large" } else { "Small" }}}))
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
table.put(col, "switch a card's variant (nested overrides kept)", f64::NAN);
|
||||
}
|
||||
let nav = instance_with(&doc, &inst, ds.nav, &["Profile"]).map(|(nav, _)| nav);
|
||||
if let Some(nav) = nav {
|
||||
table.put(col, "reset all changes on a nav", timed(&mut s, "component.resetInstance", json!({"ids": [nav]}), true));
|
||||
table.put(col, "detach a nav", timed(&mut s, "component.detach", json!({"ids": [nav]}), true));
|
||||
} else {
|
||||
table.put(col, "reset all changes on a nav", f64::NAN);
|
||||
table.put(col, "detach a nav", f64::NAN);
|
||||
}
|
||||
if let Some((button, bglyph)) = instance_with(&doc, &inst, ds.button, &["Icon", "Glyph"]) {
|
||||
table.put(col, "push a button's changes to its main", timed(&mut s, "component.pushChanges", json!({"id": button}), true));
|
||||
// A button glyph the drop really lands on: screens can overlap (text sizes differ without
|
||||
// fonts), so ask the hit test the swap uses.
|
||||
let now = s.doc().map_err(failed)?.clone();
|
||||
let page = s.page().map_err(failed)?;
|
||||
let drop_at = std::iter::once(bglyph)
|
||||
.chain(inst.iter().filter_map(|i| {
|
||||
let NodeKind::Instance { component, .. } = &now.node(*i)?.kind else { return None };
|
||||
(figcraft_engine::components::variant_set(&now, *component) == Some(ds.button)).then(|| find_path(&now, *i, &["Icon", "Glyph"]))?
|
||||
}))
|
||||
.filter_map(|g| now.world_bounds(g))
|
||||
.find(|b| figcraft_engine::drop::swap_target(&now, page, b.center(), None, None).is_some());
|
||||
let other = ds.icons.get(5).copied().unwrap_or(icon0);
|
||||
table.put(
|
||||
col,
|
||||
"Alt-drop swap onto an instance",
|
||||
match drop_at {
|
||||
Some(b) => timed(&mut s, "instance.swapAt", json!({"component": other, "x": b.center().x, "y": b.center().y}), true),
|
||||
None => f64::NAN,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
table.put(col, "push a button's changes to its main", f64::NAN);
|
||||
table.put(col, "Alt-drop swap onto an instance", f64::NAN);
|
||||
}
|
||||
if let Some(nav) = nav {
|
||||
exec(&mut s, "edit.copy", json!({"ids": [nav]}));
|
||||
table.put(col, "copy + paste a nav", timed(&mut s, "edit.paste", json!({}), true));
|
||||
} else {
|
||||
table.put(col, "copy + paste a nav", f64::NAN);
|
||||
}
|
||||
table.put(col, "undo (deep main edit)", mean(10, |_| _ = s.undo()));
|
||||
table.put(col, "redo", mean(10, |_| _ = s.redo()));
|
||||
|
||||
// As a library: publish it, use it from another file, publish a change, accept the update.
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-perf-{}", std::process::id()));
|
||||
// One directory per run, so concurrent runs in a process don't delete each other's files.
|
||||
static RUNS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
let run = RUNS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-perf-{}-{run}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).map_err(failed)?;
|
||||
let path = dir.join("design-system.figcraft").to_string_lossy().into_owned();
|
||||
let (lib_doc, lib_ds) = design_system_document(&spec).map_err(failed)?;
|
||||
@@ -533,6 +555,14 @@ mod tests {
|
||||
assert!(t.rows.iter().all(|r| r.1.len() == 1 && r.1[0].is_finite()), "{:?}", t.rows);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measures_components_with_a_single_instance() {
|
||||
let mut t = super::Table { rows: Vec::new() };
|
||||
super::measure_components(&mut t, 0, 1).unwrap();
|
||||
assert!(t.rows.iter().all(|r| r.1.len() == 1), "{:?}", t.rows);
|
||||
assert!(t.rows.iter().any(|r| r.0.starts_with("build design system") && r.1[0].is_finite()), "{:?}", t.rows);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measures_a_small_document() {
|
||||
let mut t = super::Table { rows: Vec::new() };
|
||||
|
||||
@@ -347,14 +347,14 @@ impl Store {
|
||||
let key = load_key(root)?;
|
||||
let dir = root.join(FILES);
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?;
|
||||
let (directory, problems) = Directory::open(root, cfg.directory);
|
||||
let (directory, startup_problems) = Directory::open(root, cfg.directory);
|
||||
let abuse = Abuse::new(cfg.hosting.abuse);
|
||||
let mut store = Store {
|
||||
root: root.to_path_buf(),
|
||||
cfg,
|
||||
key,
|
||||
files: BTreeMap::new(),
|
||||
problems,
|
||||
problems: Vec::new(),
|
||||
directory,
|
||||
abuse,
|
||||
served: figcraft_collab_net::net::Bucket::new(LIBRARY_RATE, LIBRARY_BURST),
|
||||
@@ -368,6 +368,9 @@ impl Store {
|
||||
free: None,
|
||||
_lock: lock,
|
||||
};
|
||||
for problem in startup_problems {
|
||||
store.note(problem);
|
||||
}
|
||||
store.scan(&dir);
|
||||
store.blocked = store.read_list::<String>(BLOCKED).iter().filter_map(|s| s.parse().ok()).map(ip_key).collect();
|
||||
store.reports = store.read_list(REPORTS);
|
||||
@@ -385,7 +388,7 @@ impl Store {
|
||||
match disk::read_capped(&path, 16 << 20).and_then(|b| serde_json::from_slice(&b).map_err(|e| format!("{name} is damaged ({e})"))) {
|
||||
Ok(list) => list,
|
||||
Err(e) => {
|
||||
self.problems.push(e);
|
||||
self.note(e);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
@@ -394,7 +397,7 @@ impl Store {
|
||||
fn scan(&mut self, dir: &Path) {
|
||||
let entries = match fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(e) => return self.problems.push(format!("can't list {}: {e}", dir.display())),
|
||||
Err(e) => return self.note(format!("can't list {}: {e}", dir.display())),
|
||||
};
|
||||
let mut ids: Vec<(String, PathBuf)> = entries.flatten().map(|e| (e.file_name().to_string_lossy().into_owned(), e.path())).collect();
|
||||
ids.sort();
|
||||
@@ -403,18 +406,18 @@ impl Store {
|
||||
continue;
|
||||
}
|
||||
if !valid_id(&id) {
|
||||
self.problems.push(format!("skipped {}: not a file folder of this server", path.display()));
|
||||
self.note(format!("skipped {}: not a file folder of this server", path.display()));
|
||||
continue;
|
||||
}
|
||||
if self.files.len() >= self.cfg.max_files {
|
||||
self.problems.push(format!("skipped file {id}: over the {}-file cap", self.cfg.max_files));
|
||||
self.note(format!("skipped file {id}: over the {}-file cap", self.cfg.max_files));
|
||||
continue;
|
||||
}
|
||||
match self.check(&path) {
|
||||
Ok(entry) => {
|
||||
self.files.insert(id, entry);
|
||||
}
|
||||
Err(why) => self.problems.push(format!("skipped file {id}: {why}")),
|
||||
Err(why) => self.note(format!("skipped file {id}: {why}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1489,6 +1492,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());
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
use figcraft_server::config::Config;
|
||||
use figcraft_server::store::{FILES, Store};
|
||||
|
||||
#[test]
|
||||
fn startup_problem_retention() {
|
||||
let root = std::env::temp_dir().join(format!("figcraft-problem-cap-{}", std::process::id()));
|
||||
assert!(!root.exists());
|
||||
let result = (|| -> Result<(), String> {
|
||||
for count in [3u32, 200, 201] {
|
||||
let data = root.join(count.to_string());
|
||||
for i in 0..count {
|
||||
std::fs::create_dir_all(data.join(FILES).join(format!("{i:016x}"))).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let cfg = Config { open_publish: true, data: data.clone(), ..Config::default() };
|
||||
cfg.check()?;
|
||||
let store = Store::open(&data, cfg.store())?;
|
||||
let expected = (count as usize).min(200);
|
||||
let problems = store.problems().to_vec();
|
||||
let no_loaded_files = store.is_empty();
|
||||
drop(store);
|
||||
if !problems.iter().all(|p| p.contains("meta.json")) || !no_loaded_files {
|
||||
return Err("unexpected diagnostic or loaded file".into());
|
||||
}
|
||||
if problems.len() != expected {
|
||||
return Err(format!("expected {expected} retained problems, got {}", problems.len()));
|
||||
}
|
||||
// The latest problems stay, in scan order: the first folder's is the one dropped.
|
||||
let first_kept = count as usize - expected;
|
||||
for (n, p) in problems.iter().enumerate() {
|
||||
let id = format!("{:016x}", first_kept + n);
|
||||
if !p.contains(&id) {
|
||||
return Err(format!("problem {n} should be about {id}: {p}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
assert!(result.is_ok(), "{}", result.unwrap_err());
|
||||
}
|
||||
@@ -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"] }
|
||||
@@ -69,9 +69,9 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
}
|
||||
|
||||
/// Browser services. File dialogs return nothing for now (the trait's defaults). Copies go to the
|
||||
/// system clipboard through egui's output; pastes arrive as paste events with their text (a page
|
||||
/// can't read the clipboard when it likes).
|
||||
/// Browser services. 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>,
|
||||
@@ -79,6 +79,19 @@ struct WebServices {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -116,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;
|
||||
@@ -111,7 +111,13 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
};
|
||||
writeln!(out, "{reply}")?;
|
||||
}
|
||||
|
||||
@@ -53,9 +53,8 @@ fn valid_frames_keep_their_order_ids_and_parameters() {
|
||||
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{bad JSON}\n{\"id\":\"test\",\"method\":\"engine.execute\",\"params\":{\"command\":\"shape.rectangle\"}}\r\n").unwrap();
|
||||
client.write_all(b"\r\n{\"id\":\"test\",\"method\":\"engine.execute\",\"params\":{\"command\":\"shape.rectangle\"}}\r\n").unwrap();
|
||||
let mut reader = BufReader::new(client);
|
||||
assert_eq!(reply(&mut reader)["ok"], false);
|
||||
let start = Instant::now();
|
||||
let r = loop {
|
||||
if let Some(r) = pending.lock().unwrap().pop() {
|
||||
@@ -73,6 +72,23 @@ fn valid_frames_keep_their_order_ids_and_parameters() {
|
||||
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();
|
||||
|
||||
@@ -17,6 +17,7 @@ mod gamepad;
|
||||
mod graphics;
|
||||
mod recovery;
|
||||
mod services;
|
||||
mod startup;
|
||||
|
||||
#[cfg(all(test, target_os = "macos"))]
|
||||
mod apple_events_tests;
|
||||
@@ -30,6 +31,8 @@ mod recovery_online_tests;
|
||||
mod recovery_tests;
|
||||
#[cfg(test)]
|
||||
mod services_tests;
|
||||
#[cfg(test)]
|
||||
mod startup_tests;
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -167,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);
|
||||
@@ -207,7 +213,7 @@ fn main() -> eframe::Result {
|
||||
let apple_events = apple_events::AppleEvents::install();
|
||||
#[cfg(target_os = "macos")]
|
||||
let apple_events = &apple_events;
|
||||
eframe::run_native(
|
||||
let result = eframe::run_native(
|
||||
"FigCraft",
|
||||
options,
|
||||
Box::new(move |_cc| {
|
||||
@@ -220,5 +226,10 @@ fn main() -> eframe::Result {
|
||||
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
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -38,4 +38,9 @@ real_name = "Justin John"
|
||||
[people.Losminek]
|
||||
|
||||
[people.RevDonz]
|
||||
|
||||
[people.Nael-Nathanael]
|
||||
sync_github_name = true
|
||||
|
||||
[people.volkovskey]
|
||||
real_name = "Andrii Volkov"
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
+12
-16
@@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize};
|
||||
mod blend;
|
||||
mod hsx;
|
||||
mod icc;
|
||||
mod named;
|
||||
mod profile;
|
||||
pub use blend::BlendMode;
|
||||
pub use hsx::*;
|
||||
@@ -139,7 +140,13 @@ impl Color {
|
||||
Some(Self::from_rgba8(r, g, b, a))
|
||||
}
|
||||
|
||||
/// Parse a CSS colour: hex, `rgb()/rgba()`, `hsl()/hsla()` or a few common names.
|
||||
/// What a colour field accepts: hex as [`Color::from_hex`] reads it, or a CSS colour name
|
||||
/// (`red` → `FF0000`, `Cornflower Blue` → `6495ED`; opaque).
|
||||
pub fn from_hex_or_name(s: &str) -> Option<Self> {
|
||||
Self::from_hex(s).or_else(|| named::lookup(s).map(|[r, g, b]| Self::from_rgba8(r, g, b, 255)))
|
||||
}
|
||||
|
||||
/// Parse a CSS colour: hex, `rgb()/rgba()`, `hsl()/hsla()`, a CSS colour name or `transparent`.
|
||||
pub fn parse_css(s: &str) -> Option<Self> {
|
||||
let t = s.trim().to_ascii_lowercase();
|
||||
if t.starts_with('#') {
|
||||
@@ -166,21 +173,10 @@ impl Color {
|
||||
let alpha = parts.get(3).and_then(|a| parse_alpha(a)).unwrap_or(1.0);
|
||||
return Some(Hsl { h, s, l }.to_color().with_alpha(alpha));
|
||||
}
|
||||
let named = match t.as_str() {
|
||||
"black" => Color::BLACK,
|
||||
"white" => Color::WHITE,
|
||||
"transparent" => Color::TRANSPARENT,
|
||||
"red" => Color::from_rgb8_const(255, 0, 0),
|
||||
"green" => Color::from_rgb8_const(0, 128, 0),
|
||||
"lime" => Color::from_rgb8_const(0, 255, 0),
|
||||
"blue" => Color::from_rgb8_const(0, 0, 255),
|
||||
"yellow" => Color::from_rgb8_const(255, 255, 0),
|
||||
"orange" => Color::from_rgb8_const(255, 165, 0),
|
||||
"purple" => Color::from_rgb8_const(128, 0, 128),
|
||||
"gray" | "grey" => Color::from_rgb8_const(128, 128, 128),
|
||||
_ => return Self::from_hex(&t),
|
||||
};
|
||||
Some(named)
|
||||
if t == "transparent" {
|
||||
return Some(Color::TRANSPARENT);
|
||||
}
|
||||
Self::from_hex_or_name(&t)
|
||||
}
|
||||
|
||||
/// CSS `rgba(...)` / hex text for copy-as-CSS.
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
//! The CSS named colours (CSS Color Module Level 4 §6.1), so a colour field takes `red` as well as
|
||||
//! `FF0000`.
|
||||
|
||||
/// Every CSS named colour except `transparent`, sorted by name for binary search.
|
||||
const NAMED: [(&str, [u8; 3]); 148] = [
|
||||
("aliceblue", [240, 248, 255]),
|
||||
("antiquewhite", [250, 235, 215]),
|
||||
("aqua", [0, 255, 255]),
|
||||
("aquamarine", [127, 255, 212]),
|
||||
("azure", [240, 255, 255]),
|
||||
("beige", [245, 245, 220]),
|
||||
("bisque", [255, 228, 196]),
|
||||
("black", [0, 0, 0]),
|
||||
("blanchedalmond", [255, 235, 205]),
|
||||
("blue", [0, 0, 255]),
|
||||
("blueviolet", [138, 43, 226]),
|
||||
("brown", [165, 42, 42]),
|
||||
("burlywood", [222, 184, 135]),
|
||||
("cadetblue", [95, 158, 160]),
|
||||
("chartreuse", [127, 255, 0]),
|
||||
("chocolate", [210, 105, 30]),
|
||||
("coral", [255, 127, 80]),
|
||||
("cornflowerblue", [100, 149, 237]),
|
||||
("cornsilk", [255, 248, 220]),
|
||||
("crimson", [220, 20, 60]),
|
||||
("cyan", [0, 255, 255]),
|
||||
("darkblue", [0, 0, 139]),
|
||||
("darkcyan", [0, 139, 139]),
|
||||
("darkgoldenrod", [184, 134, 11]),
|
||||
("darkgray", [169, 169, 169]),
|
||||
("darkgreen", [0, 100, 0]),
|
||||
("darkgrey", [169, 169, 169]),
|
||||
("darkkhaki", [189, 183, 107]),
|
||||
("darkmagenta", [139, 0, 139]),
|
||||
("darkolivegreen", [85, 107, 47]),
|
||||
("darkorange", [255, 140, 0]),
|
||||
("darkorchid", [153, 50, 204]),
|
||||
("darkred", [139, 0, 0]),
|
||||
("darksalmon", [233, 150, 122]),
|
||||
("darkseagreen", [143, 188, 143]),
|
||||
("darkslateblue", [72, 61, 139]),
|
||||
("darkslategray", [47, 79, 79]),
|
||||
("darkslategrey", [47, 79, 79]),
|
||||
("darkturquoise", [0, 206, 209]),
|
||||
("darkviolet", [148, 0, 211]),
|
||||
("deeppink", [255, 20, 147]),
|
||||
("deepskyblue", [0, 191, 255]),
|
||||
("dimgray", [105, 105, 105]),
|
||||
("dimgrey", [105, 105, 105]),
|
||||
("dodgerblue", [30, 144, 255]),
|
||||
("firebrick", [178, 34, 34]),
|
||||
("floralwhite", [255, 250, 240]),
|
||||
("forestgreen", [34, 139, 34]),
|
||||
("fuchsia", [255, 0, 255]),
|
||||
("gainsboro", [220, 220, 220]),
|
||||
("ghostwhite", [248, 248, 255]),
|
||||
("gold", [255, 215, 0]),
|
||||
("goldenrod", [218, 165, 32]),
|
||||
("gray", [128, 128, 128]),
|
||||
("green", [0, 128, 0]),
|
||||
("greenyellow", [173, 255, 47]),
|
||||
("grey", [128, 128, 128]),
|
||||
("honeydew", [240, 255, 240]),
|
||||
("hotpink", [255, 105, 180]),
|
||||
("indianred", [205, 92, 92]),
|
||||
("indigo", [75, 0, 130]),
|
||||
("ivory", [255, 255, 240]),
|
||||
("khaki", [240, 230, 140]),
|
||||
("lavender", [230, 230, 250]),
|
||||
("lavenderblush", [255, 240, 245]),
|
||||
("lawngreen", [124, 252, 0]),
|
||||
("lemonchiffon", [255, 250, 205]),
|
||||
("lightblue", [173, 216, 230]),
|
||||
("lightcoral", [240, 128, 128]),
|
||||
("lightcyan", [224, 255, 255]),
|
||||
("lightgoldenrodyellow", [250, 250, 210]),
|
||||
("lightgray", [211, 211, 211]),
|
||||
("lightgreen", [144, 238, 144]),
|
||||
("lightgrey", [211, 211, 211]),
|
||||
("lightpink", [255, 182, 193]),
|
||||
("lightsalmon", [255, 160, 122]),
|
||||
("lightseagreen", [32, 178, 170]),
|
||||
("lightskyblue", [135, 206, 250]),
|
||||
("lightslategray", [119, 136, 153]),
|
||||
("lightslategrey", [119, 136, 153]),
|
||||
("lightsteelblue", [176, 196, 222]),
|
||||
("lightyellow", [255, 255, 224]),
|
||||
("lime", [0, 255, 0]),
|
||||
("limegreen", [50, 205, 50]),
|
||||
("linen", [250, 240, 230]),
|
||||
("magenta", [255, 0, 255]),
|
||||
("maroon", [128, 0, 0]),
|
||||
("mediumaquamarine", [102, 205, 170]),
|
||||
("mediumblue", [0, 0, 205]),
|
||||
("mediumorchid", [186, 85, 211]),
|
||||
("mediumpurple", [147, 112, 219]),
|
||||
("mediumseagreen", [60, 179, 113]),
|
||||
("mediumslateblue", [123, 104, 238]),
|
||||
("mediumspringgreen", [0, 250, 154]),
|
||||
("mediumturquoise", [72, 209, 204]),
|
||||
("mediumvioletred", [199, 21, 133]),
|
||||
("midnightblue", [25, 25, 112]),
|
||||
("mintcream", [245, 255, 250]),
|
||||
("mistyrose", [255, 228, 225]),
|
||||
("moccasin", [255, 228, 181]),
|
||||
("navajowhite", [255, 222, 173]),
|
||||
("navy", [0, 0, 128]),
|
||||
("oldlace", [253, 245, 230]),
|
||||
("olive", [128, 128, 0]),
|
||||
("olivedrab", [107, 142, 35]),
|
||||
("orange", [255, 165, 0]),
|
||||
("orangered", [255, 69, 0]),
|
||||
("orchid", [218, 112, 214]),
|
||||
("palegoldenrod", [238, 232, 170]),
|
||||
("palegreen", [152, 251, 152]),
|
||||
("paleturquoise", [175, 238, 238]),
|
||||
("palevioletred", [219, 112, 147]),
|
||||
("papayawhip", [255, 239, 213]),
|
||||
("peachpuff", [255, 218, 185]),
|
||||
("peru", [205, 133, 63]),
|
||||
("pink", [255, 192, 203]),
|
||||
("plum", [221, 160, 221]),
|
||||
("powderblue", [176, 224, 230]),
|
||||
("purple", [128, 0, 128]),
|
||||
("rebeccapurple", [102, 51, 153]),
|
||||
("red", [255, 0, 0]),
|
||||
("rosybrown", [188, 143, 143]),
|
||||
("royalblue", [65, 105, 225]),
|
||||
("saddlebrown", [139, 69, 19]),
|
||||
("salmon", [250, 128, 114]),
|
||||
("sandybrown", [244, 164, 96]),
|
||||
("seagreen", [46, 139, 87]),
|
||||
("seashell", [255, 245, 238]),
|
||||
("sienna", [160, 82, 45]),
|
||||
("silver", [192, 192, 192]),
|
||||
("skyblue", [135, 206, 235]),
|
||||
("slateblue", [106, 90, 205]),
|
||||
("slategray", [112, 128, 144]),
|
||||
("slategrey", [112, 128, 144]),
|
||||
("snow", [255, 250, 250]),
|
||||
("springgreen", [0, 255, 127]),
|
||||
("steelblue", [70, 130, 180]),
|
||||
("tan", [210, 180, 140]),
|
||||
("teal", [0, 128, 128]),
|
||||
("thistle", [216, 191, 216]),
|
||||
("tomato", [255, 99, 71]),
|
||||
("turquoise", [64, 224, 208]),
|
||||
("violet", [238, 130, 238]),
|
||||
("wheat", [245, 222, 179]),
|
||||
("white", [255, 255, 255]),
|
||||
("whitesmoke", [245, 245, 245]),
|
||||
("yellow", [255, 255, 0]),
|
||||
("yellowgreen", [154, 205, 50]),
|
||||
];
|
||||
|
||||
/// Longest name in [`NAMED`]; anything longer can't match.
|
||||
const LONGEST: usize = 20;
|
||||
|
||||
/// The opaque sRGB bytes of a CSS colour name. Case, spaces, `-` and `_` are ignored, so
|
||||
/// `Light Blue`, `light-blue` and `lightblue` are the same colour.
|
||||
pub fn lookup(s: &str) -> Option<[u8; 3]> {
|
||||
let key: String = s.chars().filter(|c| !c.is_whitespace() && *c != '-' && *c != '_').take(LONGEST + 1).collect::<String>().to_ascii_lowercase();
|
||||
if key.is_empty() || key.len() > LONGEST {
|
||||
return None;
|
||||
}
|
||||
NAMED.binary_search_by(|(name, _)| name.cmp(&key.as_str())).ok().and_then(|i| NAMED.get(i)).map(|(_, rgb)| *rgb)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn table_is_sorted_and_unique() {
|
||||
assert!(NAMED.windows(2).all(|w| w[0].0 < w[1].0));
|
||||
assert_eq!(NAMED.iter().map(|(n, _)| n.len()).max(), Some(LONGEST));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_name_is_found() {
|
||||
for (name, rgb) in NAMED {
|
||||
assert_eq!(lookup(name), Some(rgb), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_normalises() {
|
||||
assert_eq!(lookup("Red"), Some([255, 0, 0]));
|
||||
assert_eq!(lookup(" CORNFLOWER blue "), Some([100, 149, 237]));
|
||||
assert_eq!(lookup("light-goldenrod_yellow"), Some([250, 250, 210]));
|
||||
assert_eq!(lookup(""), None);
|
||||
assert_eq!(lookup("transparent"), None);
|
||||
assert_eq!(lookup("reddish"), None);
|
||||
assert_eq!(lookup("rédé"), None);
|
||||
assert_eq!(lookup(&"a".repeat(10_000)), None);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,23 @@ fn css_parsing() {
|
||||
assert_eq!(Color::parse_css("rgba(0 0 255 / 50%)").unwrap().to_rgba8(), [0, 0, 255, 128]);
|
||||
assert_eq!(Color::parse_css("hsl(120, 100%, 50%)").unwrap().to_hex(), "00FF00");
|
||||
assert_eq!(Color::parse_css("white").unwrap(), Color::WHITE);
|
||||
assert_eq!(Color::parse_css("RebeccaPurple").unwrap().to_hex(), "663399");
|
||||
assert_eq!(Color::parse_css("transparent").unwrap(), Color::TRANSPARENT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_fields_take_names() {
|
||||
assert_eq!(Color::from_hex_or_name("red").unwrap().to_css_hex(), "#FF0000");
|
||||
assert_eq!(Color::from_hex_or_name(" Cornflower Blue ").unwrap().to_hex(), "6495ED");
|
||||
assert_eq!(Color::from_hex_or_name("darkblue").unwrap().to_rgba8(), [0, 0, 139, 255]);
|
||||
// Hex wins: every digit string still reads as hex.
|
||||
assert_eq!(Color::from_hex_or_name("abc").unwrap().to_hex(), "AABBCC");
|
||||
assert_eq!(Color::from_hex_or_name("#0D99FF").unwrap().to_hex(), "0D99FF");
|
||||
// Names are opaque; `transparent` is not a field colour.
|
||||
assert!(Color::from_hex_or_name("transparent").is_none());
|
||||
for junk in ["", "#", "redd", "re d d", "12345", "\u{0}", "🟥", &"x".repeat(100_000)] {
|
||||
assert!(Color::from_hex_or_name(junk).is_none(), "{junk:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -781,6 +781,23 @@ impl Document {
|
||||
self.paste_nodes(&src_nodes, &map, id, parent, index)
|
||||
}
|
||||
|
||||
/// Deep-copy each of `ids`' subtrees to the end of `parent`, in order, returning the new root
|
||||
/// ids. One id map covers every copy, so references between the copied subtrees (a navigation
|
||||
/// from one copied frame to another) point at the copies.
|
||||
pub fn duplicate_all_into(&mut self, ids: &[NodeId], parent: NodeId) -> DocResult<Vec<NodeId>> {
|
||||
let mut map = BTreeMap::new();
|
||||
let mut trees = Vec::with_capacity(ids.len());
|
||||
for &id in ids {
|
||||
let src_nodes: Vec<Node> = self.subtree(id).into_iter().filter_map(|i| self.node(i).cloned()).collect();
|
||||
for n in &src_nodes {
|
||||
let nid = self.alloc_id();
|
||||
map.insert(n.id, nid);
|
||||
}
|
||||
trees.push((id, src_nodes));
|
||||
}
|
||||
trees.into_iter().map(|(id, src_nodes)| self.paste_nodes(&src_nodes, &map, id, parent, None)).collect()
|
||||
}
|
||||
|
||||
/// Insert copies of `nodes` (a subtree rooted at `root`, from this or another document) with ids
|
||||
/// remapped by `map`. Interactions, instance references and pattern sources pointing inside the
|
||||
/// subtree are remapped too.
|
||||
|
||||
@@ -143,7 +143,7 @@ impl Expr {
|
||||
return "…".into();
|
||||
}
|
||||
match self {
|
||||
Expr::Value(VariableValue::Alias(id)) => vars.get(id).map_or_else(|| "(missing variable)".into(), |v| v.name.clone()),
|
||||
Expr::Value(VariableValue::Alias(id)) => vars.get(id).map_or_else(|| "(missing variable)".into(), |v| var_ref(vars, v)),
|
||||
Expr::Value(VariableValue::String(s)) => quote(s),
|
||||
// Exact numbers and `#` colours, so the text reads back as the same expression.
|
||||
Expr::Value(VariableValue::Number(n)) => format!("{n}"),
|
||||
@@ -178,6 +178,18 @@ impl Expr {
|
||||
}
|
||||
}
|
||||
|
||||
/// A variable as written in an expression: its bare name when that reads back as this variable,
|
||||
/// otherwise `{name}` (or `{id}` when even the braced name wouldn't), so names like `1`, `2x` or
|
||||
/// `"q"` don't read back as literals or errors.
|
||||
fn var_ref(vars: &Variables, v: &crate::Variable) -> String {
|
||||
let this = Expr::var(v.id.clone());
|
||||
if Expr::parse(&v.name, vars).is_ok_and(|e| e == this) {
|
||||
return v.name.clone();
|
||||
}
|
||||
let braced = !v.name.contains('}') && v.name.trim() == v.name && vars.get(&v.name).is_some_and(|x| x.id == v.id);
|
||||
if braced { format!("{{{}}}", v.name) } else { format!("{{{}}}", v.id) }
|
||||
}
|
||||
|
||||
/// A collection or mode name as written inside `mode(…)` or `[…]`: as it is, or quoted when it has
|
||||
/// spaces at its ends, quotes or brackets.
|
||||
fn name_arg(name: &str) -> String {
|
||||
|
||||
@@ -176,3 +176,22 @@ fn hostile_text_is_bounded() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_that_look_like_literals_read_back_as_variables() {
|
||||
for name in ["count", "count total", "2x", "1", "-3", ".5", "(x)", "#ff", "\"q\"", "not", "true"] {
|
||||
let mut v = vars();
|
||||
if let Some(var) = v.variables.iter_mut().find(|x| x.id == "n") {
|
||||
var.name = name.into();
|
||||
}
|
||||
for e in [Expr::var("n"), Expr::op(ExprOp::Add, vec![Expr::var("n"), n(1.0)]), Expr::InMode { variable: "n".into(), mode: "dark".into() }] {
|
||||
let shown = e.display(&v);
|
||||
assert_eq!(Expr::parse(&shown, &v), Ok(e.clone()), "{name:?} shown as {shown:?}");
|
||||
}
|
||||
}
|
||||
// Plain names stay bare, and literals still read as literals.
|
||||
let v = vars();
|
||||
assert_eq!(Expr::var("n").display(&v), "n-name");
|
||||
assert_eq!(parse("1"), n(1.0));
|
||||
assert_eq!(parse("\"q\""), Expr::Value(VariableValue::String("q".into())));
|
||||
}
|
||||
@@ -168,6 +168,27 @@ fn text_runs_restyle_and_replace() {
|
||||
assert_eq!(t.characters, "");
|
||||
}
|
||||
|
||||
/// A run that reaches past the text (a file can carry one: nothing clamps `runs` on load)
|
||||
/// must not overflow the arithmetic that shifts the runs along, and must not leave the
|
||||
/// text unwritable.
|
||||
#[test]
|
||||
fn text_runs_past_the_text_never_overflow() {
|
||||
let style = || TextStyle { font_style: "Bold".into(), ..Default::default() };
|
||||
for end in [usize::MAX, usize::MAX - 1, 1_000_000] {
|
||||
let mut t = TextData::new("hi");
|
||||
t.runs = vec![StyleRun { start: 0, end, style: style() }];
|
||||
// Typing at the caret, and replacing a selection: both shift `end`.
|
||||
t.replace_range(0, 0, "x");
|
||||
assert_eq!(t.characters, "xhi", "end {end}");
|
||||
t.replace_range(0, 3, "yo");
|
||||
assert_eq!(t.characters, "yo", "end {end}");
|
||||
// The text is still writable and the runs are back inside it.
|
||||
t.replace_range(0, 0, "!");
|
||||
assert_eq!(t.characters, "!yo", "end {end}");
|
||||
assert!(t.runs.iter().all(|r| r.start < r.end && r.end <= t.char_count()), "end {end}: {:?}", t.runs);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_case() {
|
||||
assert_eq!(TextCase::Title.apply("hello big world"), "Hello Big World");
|
||||
@@ -381,3 +402,65 @@ fn custom_easings() {
|
||||
assert_eq!(back, Easing::CustomSpring { mass: 2.0, stiffness: 50.0, damping: 5.0 });
|
||||
assert_eq!(serde_json::from_value::<Easing>(serde_json::json!("easeInOut")).unwrap(), Easing::EaseInOut);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_style_updates_merge_equal_runs() {
|
||||
let def = TextStyle { font_size: 14.0, ..Default::default() };
|
||||
let mut t = TextData::new("abcd");
|
||||
t.link_text_style(0, 2, &def, "S");
|
||||
t.link_text_style(2, 4, &def, "S");
|
||||
t.restyle(2, 4, |s| s.font_size = 24.0);
|
||||
assert_eq!(t.runs.len(), 2);
|
||||
t.update_text_style("S", &TextStyle { font_size: 24.0, ..def.clone() });
|
||||
let spans = t.spans();
|
||||
assert_eq!(spans.len(), 1);
|
||||
assert_eq!((spans[0].0, spans[0].1), (0, 4));
|
||||
assert_eq!(spans[0].2.font_size, 24.0);
|
||||
assert_eq!(spans[0].2.style_id.as_deref(), Some("S"));
|
||||
|
||||
// Remapping two links to one id merges the runs.
|
||||
let mut t = TextData::new("ab");
|
||||
t.link_text_style(0, 1, &def, "A");
|
||||
t.link_text_style(1, 2, &def, "B");
|
||||
t.map_text_styles(|_| Some("C".into()));
|
||||
assert_eq!(t.spans().len(), 1);
|
||||
assert_eq!(t.style_at(1).style_id.as_deref(), Some("C"));
|
||||
|
||||
// Different values stay separate.
|
||||
let mut t = TextData::new("ab");
|
||||
t.link_text_style(0, 1, &def, "A");
|
||||
t.link_text_style(1, 2, &TextStyle { font_size: 30.0, ..Default::default() }, "B");
|
||||
t.map_text_styles(|_| Some("C".into()));
|
||||
let spans = t.spans();
|
||||
assert_eq!(spans.len(), 2);
|
||||
assert_eq!((spans[0].2.font_size, spans[1].2.font_size), (14.0, 30.0));
|
||||
|
||||
// Unlinking a run equal to the base drops it.
|
||||
let mut t = TextData::new("ab");
|
||||
let base = t.style.clone();
|
||||
t.link_text_style(0, 1, &base, "S");
|
||||
t.map_text_styles(|_| None);
|
||||
assert!(t.runs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_style_empty_range_links_and_unlinks_all() {
|
||||
let def = TextStyle { font_style: "Bold".into(), ..Default::default() };
|
||||
let mut t = TextData::new("abcd");
|
||||
t.link_text_style(2, 2, &def, "S");
|
||||
assert!((0..4).all(|i| t.style_at(i).style_id.as_deref() == Some("S") && t.style_at(i).font_style == "Bold"));
|
||||
t.unlink_text_style(2, 2);
|
||||
assert!((0..4).all(|i| t.style_at(i).style_id.is_none() && t.style_at(i).font_style == "Bold"));
|
||||
// Partial ranges stay partial.
|
||||
t.link_text_style(0, 4, &def, "S");
|
||||
t.unlink_text_style(1, 3);
|
||||
assert_eq!(t.style_at(0).style_id.as_deref(), Some("S"));
|
||||
assert!(t.style_at(1).style_id.is_none() && t.style_at(2).style_id.is_none());
|
||||
assert_eq!(t.style_at(3).style_id.as_deref(), Some("S"));
|
||||
// Empty text changes the base style.
|
||||
let mut e = TextData::new("");
|
||||
e.link_text_style(0, 0, &def, "S");
|
||||
assert_eq!(e.style.style_id.as_deref(), Some("S"));
|
||||
e.unlink_text_style(0, 0);
|
||||
assert!(e.style.style_id.is_none());
|
||||
}
|
||||
+14
-2
@@ -562,7 +562,7 @@ impl TextData {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop empty/out-of-range runs and runs equal to the base style.
|
||||
/// Drop empty/out-of-range runs and runs equal to the base style, and merge touching equal runs.
|
||||
pub fn normalize(&mut self) {
|
||||
let n = self.char_count();
|
||||
let base = self.style.clone();
|
||||
@@ -571,6 +571,14 @@ impl TextData {
|
||||
r.end = r.end.min(n);
|
||||
}
|
||||
self.runs.sort_by_key(|r| r.start);
|
||||
let mut merged: Vec<StyleRun> = Vec::with_capacity(self.runs.len());
|
||||
for r in std::mem::take(&mut self.runs) {
|
||||
match merged.last_mut() {
|
||||
Some(prev) if prev.end == r.start && prev.style == r.style => prev.end = r.end,
|
||||
_ => merged.push(r),
|
||||
}
|
||||
}
|
||||
self.runs = merged;
|
||||
}
|
||||
|
||||
/// Replace chars `start..end` with `text`, shifting runs (inserted text takes the style at `start`).
|
||||
@@ -602,7 +610,9 @@ impl TextData {
|
||||
if p <= start {
|
||||
p
|
||||
} else if p >= end {
|
||||
p - removed + inserted
|
||||
// Runs of a file that reach past the text (nothing clamps them on load)
|
||||
// would otherwise overflow here; `normalize` below drops what is left.
|
||||
p.saturating_sub(removed).saturating_add(inserted)
|
||||
} else {
|
||||
start + inserted
|
||||
}
|
||||
@@ -675,11 +685,13 @@ impl TextData {
|
||||
/// Link chars `start..end` (all of them when the range is empty or the whole text) to text
|
||||
/// style `id`, defined as `def`.
|
||||
pub fn link_text_style(&mut self, start: usize, end: usize, def: &TextStyle, id: &str) {
|
||||
let (start, end) = if start == end { (0, self.char_count()) } else { (start, end) };
|
||||
self.restyle(start, end, |s| s.take_text_style(def, id));
|
||||
}
|
||||
|
||||
/// Unlink chars `start..end` (all when empty or whole) from their text styles; values stay.
|
||||
pub fn unlink_text_style(&mut self, start: usize, end: usize) {
|
||||
let (start, end) = if start == end { (0, self.char_count()) } else { (start, end) };
|
||||
self.restyle(start, end, |s| s.style_id = None);
|
||||
}
|
||||
|
||||
|
||||
@@ -136,8 +136,8 @@ impl VectorNetwork {
|
||||
}
|
||||
let k = key(&face.loops);
|
||||
match (self.regions.iter().position(|r| key(&r.loops) == k), want) {
|
||||
(Some(i), None | Some(false)) => {
|
||||
self.regions.remove(i);
|
||||
(Some(_), None | Some(false)) => {
|
||||
self.regions.retain(|r| key(&r.loops) != k);
|
||||
Some(false)
|
||||
}
|
||||
(Some(_), Some(true)) => Some(true),
|
||||
@@ -525,4 +525,21 @@ mod tests {
|
||||
let _ = j.paint(Point::new(f64::NAN, 5.0));
|
||||
assert!(interior_point(&BezPath::new()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bucket_unfills_a_face_with_duplicate_regions_in_one_click() {
|
||||
let c = Point::new(50.0, 50.0);
|
||||
let mut n = square(0.0, 0.0, 100.0);
|
||||
n.regions.push(n.regions[0].clone());
|
||||
assert_eq!(n.paint(c), Some(false));
|
||||
assert!(!n.fill_contains(c));
|
||||
assert_eq!(n.region_at(c), None);
|
||||
assert_eq!(n.paint(c), Some(true));
|
||||
assert!(n.fill_contains(c));
|
||||
|
||||
let mut e = square(0.0, 0.0, 100.0);
|
||||
e.regions.push(e.regions[0].clone());
|
||||
assert_eq!(e.paint_to(c, Some(false)), Some(false));
|
||||
assert!(!e.fill_contains(c));
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,9 @@ impl VectorNetwork {
|
||||
/// 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)) {
|
||||
let mut existing = lp.iter().filter_map(|i| self.segments.get(*i));
|
||||
let Some(first) = existing.next() else { return Vec::new() };
|
||||
let forward_first = match existing.next() {
|
||||
Some(second) => first.b == second.a || first.b == second.b,
|
||||
None => true,
|
||||
};
|
||||
@@ -102,10 +103,18 @@ impl VectorNetwork {
|
||||
(0..self.regions.len()).rev().find(|r| self.region_contains(*r, p))
|
||||
}
|
||||
|
||||
/// Every point on region `r`'s loops (holes' too), each once, sorted.
|
||||
/// Every point on region `r`'s loops (holes' too), each once, sorted (segments or points that
|
||||
/// don't exist are left out).
|
||||
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();
|
||||
let mut out: Vec<usize> = region
|
||||
.loops
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter_map(|i| self.segments.get(*i))
|
||||
.flat_map(|s| [s.a, s.b])
|
||||
.filter(|v| *v < self.vertices.len())
|
||||
.collect();
|
||||
out.sort_unstable();
|
||||
out.dedup();
|
||||
out
|
||||
|
||||
@@ -148,6 +148,28 @@ fn a_loop_that_does_not_close_is_closed_implicitly() {
|
||||
assert_eq!(n.region_at(p(300.0, 100.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_segments_are_skipped_wherever_they_sit_in_a_loop() {
|
||||
use figcraft_geom::Shape;
|
||||
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)];
|
||||
let control = n.region_path(0).unwrap();
|
||||
let control_walk = n.loop_walk(&[0, 1, 2]);
|
||||
for lp in [vec![99, 0, 1, 2], vec![0, 99, 1, 2], vec![99, 98, 0, 1, 2]] {
|
||||
n.regions = vec![region(vec![lp.clone()], WindingRule::NonZero)];
|
||||
assert_eq!(n.loop_walk(&lp), control_walk, "{lp:?}");
|
||||
assert_eq!(n.region_at(p(100.0, 100.0)), Some(0), "{lp:?}");
|
||||
assert!(n.region_contains(0, p(100.0, 100.0)), "{lp:?}");
|
||||
let path = n.region_path(0).unwrap();
|
||||
assert_eq!(path.elements().len(), control.elements().len(), "{lp:?}");
|
||||
assert!((path.area().abs() - control.area().abs()).abs() < 1e-6, "{lp:?}");
|
||||
}
|
||||
n.regions = vec![region(vec![vec![99]], WindingRule::NonZero)];
|
||||
assert!(n.region_path(0).is_none());
|
||||
assert!(!n.region_contains(0, p(100.0, 100.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_regions_points_are_listed_once() {
|
||||
let mut n = nested();
|
||||
@@ -186,6 +208,19 @@ fn empty_and_damaged_networks_have_no_regions_and_never_panic() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_points_leave_out_segment_ends_that_do_not_exist() {
|
||||
let mut n = VectorNetwork::default();
|
||||
let ring = square(&mut n, 0.0, 0.0, 100.0);
|
||||
n.regions = vec![region(vec![ring], WindingRule::NonZero)];
|
||||
assert_eq!(n.region_points(0), vec![0, 1, 2, 3]);
|
||||
n.segments[3] = Segment::line(3, 9);
|
||||
assert_eq!(n.region_points(0), vec![0, 1, 2, 3]);
|
||||
assert!(n.region_path(0).is_some());
|
||||
n.regions = vec![region(vec![vec![99]], WindingRule::NonZero)];
|
||||
assert!(n.region_points(0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_topology_key_ignores_moved_points_but_not_new_segments() {
|
||||
let mut n = nested();
|
||||
|
||||
@@ -74,6 +74,27 @@ fn along_the_flow_skips_hidden_and_absolute_siblings() {
|
||||
assert_eq!(kids(&s, col), vec![c1, c0], "Right does nothing in a lone column");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_item_wrapped_lines_move_and_drop_by_line() {
|
||||
let mut s = Session::new();
|
||||
// Full-width items: one per row.
|
||||
let row = al(&mut s, None, "horizontal", 160.0, 200.0);
|
||||
set(&mut s, row, json!({"wrap": true}));
|
||||
let k: Vec<NodeId> = (0..2).map(|_| rect(&mut s, row, 160.0, 20.0)).collect();
|
||||
assert_eq!(s.doc().unwrap().world_bounds(k[1]).unwrap().y0, 30.0, "second row");
|
||||
let slot = crate::drop::auto_layout_slot(s.doc().unwrap(), row, figcraft_geom::Point::new(10.0, 35.0), &[]);
|
||||
assert_eq!(slot, Some((1, Some([-2.0, 30.0, -2.0, 50.0]))));
|
||||
arrow(&mut s, &[k[1]], 0, -1);
|
||||
assert_eq!(kids(&s, row), vec![k[1], k[0]]);
|
||||
|
||||
// Full-height items in a vertical wrap: one per column.
|
||||
let col = al(&mut s, None, "vertical", 200.0, 160.0);
|
||||
set(&mut s, col, json!({"wrap": true}));
|
||||
let c: Vec<NodeId> = (0..3).map(|_| rect(&mut s, col, 20.0, 160.0)).collect();
|
||||
arrow(&mut s, &[c[2]], -1, 0);
|
||||
assert_eq!(kids(&s, col), vec![c[0], c[2], c[1]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapped_rows_move_up_and_down_a_row() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -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,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");
|
||||
}
|
||||
@@ -216,7 +216,7 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
if let NodeKind::Text(td) = &mut kind {
|
||||
td.characters = string(p, "text").or_else(|| string(p, "characters")).unwrap_or_default();
|
||||
td.characters = super::props::normalize_line_endings(&string(p, "text").or_else(|| string(p, "characters")).unwrap_or_default());
|
||||
if w > 0.0 {
|
||||
td.auto_resize = TextAutoResize::Height;
|
||||
}
|
||||
|
||||
@@ -1003,7 +1003,8 @@ fn find_replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if find.is_empty() {
|
||||
return Err(bad("edit.findReplace", "`find` is empty"));
|
||||
}
|
||||
let replace = string(p, "replace");
|
||||
// Line breaks stored as the editor stores them (CRLF, CR, U+2028 and U+2029 become LF).
|
||||
let replace = string(p, "replace").map(|r| super::props::normalize_line_endings(&r));
|
||||
let names = boolean(p, "layerNames").unwrap_or(false);
|
||||
let page = s.page()?;
|
||||
let doc = s.doc_mut()?;
|
||||
|
||||
+210
-15
@@ -30,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"file.open",
|
||||
"Open",
|
||||
"{path, variables? (REST: the local variables JSON as an object, JSON text or a path; default `<name>.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .svg, .png, .jpg …); REST and .fig imports return `warnings`",
|
||||
"{path | data (base64) + name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `<name>.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .svg, .png, .jpg …); REST and .fig imports return `warnings`",
|
||||
false,
|
||||
open
|
||||
),
|
||||
@@ -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
|
||||
),
|
||||
@@ -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) {
|
||||
@@ -526,6 +529,7 @@ fn load_with(
|
||||
/// innermost first so outer instances settle last.
|
||||
fn link_instances(doc: &mut Document) {
|
||||
crate::style_vars::after_load(doc);
|
||||
super::styles::apply_paint_bindings_on_load(doc);
|
||||
let mut insts: Vec<(usize, NodeId)> =
|
||||
doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { .. })).map(|n| (doc.ancestors(n.id).len(), n.id)).collect();
|
||||
insts.sort_by_key(|a| std::cmp::Reverse(a.0));
|
||||
@@ -650,8 +654,13 @@ fn open_recent(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = req_string("file.open", p, "path")?;
|
||||
let variables = variables_param("file.open", p)?;
|
||||
if string(p, "path").is_none()
|
||||
&& let Some(d) = string(p, "data")
|
||||
{
|
||||
return open_data(s, &d, p, variables.as_ref());
|
||||
}
|
||||
let path = req_string("file.open", p, "path")?;
|
||||
// Already open: switch to it.
|
||||
if let Some(i) = s.docs.iter().position(|d| d.path.as_deref() == Some(path.as_str())) {
|
||||
s.active = i;
|
||||
@@ -676,6 +685,21 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// [`open`] for a file given as base64 `data` (a file dropped without a path on disk): `name` tells
|
||||
/// the format. It opens as a new document with no path, so the first save asks where to put it.
|
||||
fn open_data(s: &mut Session, data: &str, p: &Value, variables: Option<&Value>) -> Result<Value> {
|
||||
let name = req_string("file.open", p, "name")?;
|
||||
let data = data.split_once(',').map_or(data, |(_, b)| b);
|
||||
let bytes = base64::engine::general_purpose::STANDARD.decode(data.trim()).map_err(|e| bad("file.open", e.to_string()))?;
|
||||
let (loaded, rest) = load_with(&bytes, &name, variables, &figcraft_format::rest::no_images)?;
|
||||
let warnings = rest || ext(&name) == "svg";
|
||||
s.docs.push(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches));
|
||||
s.active = s.docs.len() - 1;
|
||||
let mut out = json!({"index": s.active});
|
||||
out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Thumbnail PNG of the first page's content (for file browsers / the home screen).
|
||||
pub fn thumbnail(doc: &Document) -> Option<Vec<u8>> {
|
||||
let page = doc.first_page()?;
|
||||
@@ -693,10 +717,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> {
|
||||
@@ -721,9 +806,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()}))
|
||||
}
|
||||
@@ -762,12 +845,21 @@ pub fn export_bytes(doc: &Document, ids: &[NodeId], format: ExportFormat, scale:
|
||||
}
|
||||
|
||||
/// The page area exporting `ids` with `setting` covers: what they paint (a clipping frame's own
|
||||
/// area), or their boxes with "include bounding box" and for slices.
|
||||
/// area), or their boxes with "include bounding box" and for slices. Hidden layers paint nothing,
|
||||
/// so they take no room next to visible ones (a hidden-only export keeps its own area).
|
||||
pub fn export_area(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> Option<figcraft_geom::Rect> {
|
||||
let any_visible = ids.iter().any(|i| doc.node(*i).is_some_and(|n| n.visible));
|
||||
ids.iter()
|
||||
.filter_map(|i| {
|
||||
let slice = doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Slice));
|
||||
if setting.include_bounding_box || slice { doc.world_bounds(*i) } else { figcraft_render::export_bounds(doc, *i) }
|
||||
let node = doc.node(*i);
|
||||
let slice = node.is_some_and(|n| matches!(n.kind, NodeKind::Slice));
|
||||
if setting.include_bounding_box || slice {
|
||||
doc.world_bounds(*i)
|
||||
} else if any_visible && node.is_some_and(|n| !n.visible) {
|
||||
None
|
||||
} else {
|
||||
figcraft_render::export_bounds(doc, *i)
|
||||
}
|
||||
})
|
||||
.reduce(|a, b| a.union(b))
|
||||
}
|
||||
@@ -1181,6 +1273,19 @@ fn append_pages(doc: &mut Document, other: &Document) -> Result<Vec<NodeId>> {
|
||||
map.insert(id, doc.alloc_id());
|
||||
}
|
||||
}
|
||||
// Library copies a .fig import keeps (cached definitions) have no library record to follow:
|
||||
// the layers of its store come along into the store of `doc`. Recorded copies are left to
|
||||
// `merge_store`, which shares them with copies `doc` already has.
|
||||
let cached: Vec<NodeId> = other
|
||||
.library_store()
|
||||
.and_then(|s| other.node(s))
|
||||
.map(|s| s.children.iter().copied().filter(|root| other.libraries.import_of(*root).is_none()).collect())
|
||||
.unwrap_or_default();
|
||||
for root in &cached {
|
||||
for id in other.subtree(*root) {
|
||||
map.insert(id, doc.alloc_id());
|
||||
}
|
||||
}
|
||||
let mut new_pages = Vec::new();
|
||||
for pg in &other.pages {
|
||||
let Some(pn) = other.node(*pg) else { continue };
|
||||
@@ -1198,6 +1303,13 @@ fn append_pages(doc: &mut Document, other: &Document) -> Result<Vec<NodeId>> {
|
||||
}
|
||||
new_pages.push(np);
|
||||
}
|
||||
if !cached.is_empty() {
|
||||
let store = doc.ensure_library_store();
|
||||
for root in &cached {
|
||||
let nodes: Vec<Node> = other.subtree(*root).into_iter().filter_map(|i| other.node(i).cloned()).collect();
|
||||
doc.paste_nodes(&nodes, &map, *root, store, None)?;
|
||||
}
|
||||
}
|
||||
for new in map.values() {
|
||||
let src = doc.node(*new).and_then(|n| n.source).and_then(|s| map.get(&s).copied());
|
||||
if let (Some(src), Some(n)) = (src, doc.node_mut(*new)) {
|
||||
@@ -1209,7 +1321,7 @@ fn append_pages(doc: &mut Document, other: &Document) -> Result<Vec<NodeId>> {
|
||||
}
|
||||
|
||||
fn close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = num(p, "index").map(|x| x as usize).unwrap_or(s.active);
|
||||
let i = if get(p, "index").is_none() { s.active } else { req_index("file.close", p, "index")? };
|
||||
let Some(ds) = s.docs.get(i) else { return Err(bad("file.close", "no such document")) };
|
||||
// A branch tab's edits are in its main file already (which then has unsaved changes).
|
||||
if ds.branch_of.is_none() && ds.is_dirty() && !boolean(p, "force").unwrap_or(false) {
|
||||
@@ -1263,6 +1375,70 @@ mod rest_import_tests {
|
||||
doc.nodes().find(|n| matches!(n.kind, NodeKind::Instance { .. })).unwrap()
|
||||
}
|
||||
|
||||
/// What a .fig import with a cached library component leaves: the main in the hidden store, a
|
||||
/// visible instance of it and a layer linked to the main's layer.
|
||||
fn cached_import() -> Document {
|
||||
let mut doc = Document::new("Cached");
|
||||
let page = doc.first_page().unwrap();
|
||||
let store = doc.ensure_library_store();
|
||||
let main = doc
|
||||
.create(store, NodeKind::Component { description: String::new(), props: Default::default() }, "Cached main", 0.0, 0.0, 20.0, 20.0)
|
||||
.unwrap();
|
||||
let body = doc.create(main, NodeKind::Rectangle, "Body", 0.0, 0.0, 20.0, 20.0).unwrap();
|
||||
let inst =
|
||||
doc.create(page, NodeKind::Instance { component: main, props: Default::default() }, "Cached instance", 0.0, 0.0, 20.0, 20.0).unwrap();
|
||||
let mut copy = doc.node(body).unwrap().clone();
|
||||
copy.id = doc.alloc_id();
|
||||
copy.parent = None;
|
||||
copy.source = Some(body);
|
||||
let copy_id = copy.id;
|
||||
doc.insert(copy, inst, None).unwrap();
|
||||
assert!(doc.is_remote(main) && doc.node(copy_id).unwrap().source == Some(body));
|
||||
doc
|
||||
}
|
||||
|
||||
/// Pasting layers that use a cached main into another document leaves the main behind, as it
|
||||
/// does for other mains that are not published: no link points at a layer of the target.
|
||||
#[test]
|
||||
fn pasting_instances_of_cached_components_leaves_no_dangling_links() {
|
||||
let src = cached_import();
|
||||
let mut doc = Document::new("Target");
|
||||
let page = doc.first_page().unwrap();
|
||||
for i in 0..8 {
|
||||
doc.create(page, NodeKind::Rectangle, &format!("Filler {i}"), 0.0, 0.0, 5.0, 5.0).unwrap();
|
||||
}
|
||||
let inst = src.nodes().find(|n| n.name == "Cached instance").unwrap().id;
|
||||
let ids = src.subtree(inst);
|
||||
let map: std::collections::BTreeMap<NodeId, NodeId> = ids.iter().map(|i| (*i, doc.alloc_id())).collect();
|
||||
let nodes: Vec<Node> = ids.iter().filter_map(|i| src.node(*i).cloned()).collect();
|
||||
doc.paste_nodes(&nodes, &map, inst, page, None).unwrap();
|
||||
crate::library::adopt_pasted(&mut doc, &src, None, &map).unwrap();
|
||||
let pasted = doc.node(map[&inst]).unwrap();
|
||||
let NodeKind::Instance { component, .. } = pasted.kind else { panic!() };
|
||||
assert!(!doc.contains(component), "the main stays behind: {component:?}");
|
||||
assert!(doc.node(pasted.children[0]).unwrap().source.is_none_or(|s| !doc.contains(s)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appending_pages_with_cached_library_components_brings_the_components() {
|
||||
let other = cached_import();
|
||||
let mut doc = Document::new("Target");
|
||||
let page = doc.first_page().unwrap();
|
||||
// Ids of the target that would collide with the other document's store.
|
||||
for i in 0..8 {
|
||||
doc.create(page, NodeKind::Rectangle, &format!("Filler {i}"), 0.0, 0.0, 5.0, 5.0).unwrap();
|
||||
}
|
||||
append_pages(&mut doc, &other).unwrap();
|
||||
assert!(doc.validate().is_empty(), "{:?}", doc.validate());
|
||||
assert!(crate::components::mirror_issues(&doc).is_empty(), "{:?}", crate::components::mirror_issues(&doc));
|
||||
let inst = doc.nodes().find(|n| n.name == "Cached instance").unwrap();
|
||||
let NodeKind::Instance { component, .. } = inst.kind else { panic!() };
|
||||
assert_eq!(doc.node(component).unwrap().name, "Cached main");
|
||||
assert!(doc.is_remote(component), "the main comes along into the store");
|
||||
let layer = doc.node(inst.children[0]).unwrap();
|
||||
assert_eq!(layer.source, doc.node(component).unwrap().children.first().copied());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rest_json_loads_with_linked_instances() {
|
||||
let bytes = serde_json::to_vec(&kit()).unwrap();
|
||||
@@ -1280,6 +1456,25 @@ mod rest_import_tests {
|
||||
assert!(thumbnail(&doc).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_takes_data_and_a_name_as_a_new_unsaved_document() {
|
||||
let mut saved = Session::new();
|
||||
saved.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
let bytes = file_bytes(saved.ds().unwrap()).unwrap();
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
let mut s = Session::new();
|
||||
let recent = s.prefs.recent_files.clone();
|
||||
let r = s.execute("file.open", &json!({"data": data, "name": "Dropped.figcraft"})).unwrap();
|
||||
let open = s.docs.len();
|
||||
assert_eq!(r["index"], open - 1, "a document of its own, made active");
|
||||
assert_eq!(s.ds().unwrap().path, None, "no path to save over");
|
||||
assert!(s.doc().unwrap().nodes().any(|n| matches!(n.kind, NodeKind::Rectangle)));
|
||||
assert_eq!(s.prefs.recent_files, recent, "nothing on disk to remember");
|
||||
assert!(s.execute("file.open", &json!({"data": data})).is_err(), "the name tells the format");
|
||||
assert!(s.execute("file.open", &json!({"data": "not base64!", "name": "x.svg"})).is_err());
|
||||
assert_eq!(s.docs.len(), open, "a refused open adds nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_and_import_rest_json() {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-rest-{}", std::process::id()));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Pages.
|
||||
|
||||
use figcraft_doc::proto::Flow;
|
||||
use figcraft_doc::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -23,6 +24,15 @@ fn settle(s: &mut Session, cmd: &str) -> crate::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The page a command targets: the explicit `id` (which must be a page) or the open page.
|
||||
fn page_target(s: &Session, cmd: &str, p: &Value) -> crate::Result<NodeId> {
|
||||
let Some(target) = id(p, "id") else { return s.page() };
|
||||
if !s.doc()?.pages.contains(&target) {
|
||||
return Err(bad(cmd, format!("{target} is not a page")));
|
||||
}
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!("page.add", "Add page", "{name?} (a name starting with a hyphen adds a divider)", true, |s, p| {
|
||||
@@ -71,13 +81,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
spec!("page.rename", "Rename page", "{id?, name} (an empty page named with a leading hyphen becomes a divider)", true, |s, p| {
|
||||
let name = req_string("page.rename", p, "name")?;
|
||||
let id = id(p, "id").unwrap_or(s.page()?);
|
||||
let id = page_target(s, "page.rename", p)?;
|
||||
s.doc_mut()?.get_mut(id)?.name = name;
|
||||
settle(s, "page.rename")?;
|
||||
ok()
|
||||
}),
|
||||
spec!("page.delete", "Delete page", "{id?}", true, |s, p| {
|
||||
let id = id(p, "id").unwrap_or(s.page()?);
|
||||
let id = page_target(s, "page.delete", p)?;
|
||||
s.doc_mut()?.remove(id)?;
|
||||
settle(s, "page.delete")?;
|
||||
ok()
|
||||
@@ -87,13 +97,25 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let doc = s.doc_mut()?;
|
||||
let name = format!("Copy of {}", doc.get(src)?.name);
|
||||
let np = doc.add_page(&name);
|
||||
if let NodeKind::Page { background, .. } = doc.get(src)?.kind.clone()
|
||||
&& let NodeKind::Page { background: b, .. } = &mut doc.get_mut(np)?.kind
|
||||
// One copy with one id map, so navigation between the copied frames points at the copies;
|
||||
// then each layer and its copy (also those in sections), so flows can follow their start frames.
|
||||
let children = doc.get(src)?.children.clone();
|
||||
let copied = doc.duplicate_all_into(&children, np)?;
|
||||
let mut copies = Vec::new();
|
||||
for (c, copy) in children.iter().zip(&copied) {
|
||||
copies.extend(doc.subtree(*c).into_iter().zip(doc.subtree(*copy)));
|
||||
}
|
||||
// The copy keeps the page's colour, flows (pointing at the copied frames), prototype settings and guides.
|
||||
if let NodeKind::Page { background, flows, prototype, guides } = doc.get(src)?.kind.clone()
|
||||
&& let NodeKind::Page { background: b, flows: f, prototype: pr, guides: g } = &mut doc.get_mut(np)?.kind
|
||||
{
|
||||
*b = background;
|
||||
}
|
||||
for c in doc.get(src)?.children.clone() {
|
||||
doc.duplicate_into(c, np, None)?;
|
||||
*f = flows
|
||||
.into_iter()
|
||||
.filter_map(|fl| copies.iter().find(|(old, _)| *old == fl.start).map(|(_, new)| Flow { start: *new, ..fl }))
|
||||
.collect();
|
||||
*pr = prototype;
|
||||
*g = guides;
|
||||
}
|
||||
// Keep the new page right after the source.
|
||||
if let Some(si) = doc.pages.iter().position(|x| *x == src) {
|
||||
@@ -117,7 +139,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
spec!("page.setBackground", "Page background", "{id?, color}", true, |s, p| {
|
||||
let c = color(p, "color").ok_or_else(|| bad("page.setBackground", "`color` required"))?;
|
||||
let id = id(p, "id").unwrap_or(s.page()?);
|
||||
let id = page_target(s, "page.setBackground", p)?;
|
||||
if let NodeKind::Page { background, .. } = &mut s.doc_mut()?.get_mut(id)?.kind {
|
||||
*background = c;
|
||||
}
|
||||
@@ -163,8 +185,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
guides.remove(k);
|
||||
let ds = s.ds_mut()?;
|
||||
if ds.guide == Some((owner, k)) {
|
||||
ds.guide = None;
|
||||
match ds.guide {
|
||||
Some((o, i)) if o == owner && i == k => ds.guide = None,
|
||||
// A later guide of the same owner moves up one place; keep the selection on it.
|
||||
Some((o, i)) if o == owner && i > k => ds.guide = Some((o, i - 1)),
|
||||
_ => {}
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
|
||||
@@ -30,6 +30,11 @@ fontVariations / variationSettings ({axis: number}, e.g. {\"wght\": 550, \"wdth\
|
||||
arcInner, booleanOperation (a boolean group's operation, or an operand's own; null: the group's), operandOperation (a layer in a boolean \
|
||||
group: how it combines with the operands below it; null: the group's), constrainProportions, description, interactions, overflow, pluginData {k: v}";
|
||||
|
||||
/// CRLF, bare CR, U+2028 and U+2029 become LF, the only line ending text layout understands (editor insertion does the same).
|
||||
pub(crate) fn normalize_line_endings(s: &str) -> String {
|
||||
s.replace("\r\n", "\n").replace(['\r', '\u{2028}', '\u{2029}'], "\n")
|
||||
}
|
||||
|
||||
/// camelCase object keys → snake_case (accept the inspect/camel forms for serde-backed values).
|
||||
pub fn snake(v: &Value) -> Value {
|
||||
match v {
|
||||
@@ -599,7 +604,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
let n = doc.get_mut(id)?;
|
||||
let t = text_mut(cmd, n)?;
|
||||
let len = t.char_count();
|
||||
t.replace_range(0, len, &s);
|
||||
t.replace_range(0, len, &normalize_line_endings(&s));
|
||||
n.overrides.insert("characters".into());
|
||||
}
|
||||
"fontFamily"
|
||||
|
||||
@@ -26,10 +26,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"object.convertToFrame",
|
||||
"Convert to frame",
|
||||
"{ids?} (groups become frames of the same box in place, their layers staying where they are: no fill, not clipping. Frames are left alone; instances, main components and layers inside instances are refused)",
|
||||
"{ids?} (groups become frames of the same box in place, their layers staying where they are: no fill, not clipping; sections become frames that keep their fill and don't clip. Frames are left alone; instances, main components, layers inside instances and sections holding sections are refused)",
|
||||
true,
|
||||
|s, p| convert(s, p, "object.convertToFrame", true)
|
||||
),
|
||||
spec!(
|
||||
"object.convertToSection",
|
||||
"Convert to section",
|
||||
"{ids?} (frames on the page or in a section become sections in place, keeping their fill and layers; what only a frame has goes: auto layout, clipping, layout grids, guides and scrolling. Sections are left alone; nested frames, instances and main components are refused)",
|
||||
true,
|
||||
convert_to_section
|
||||
),
|
||||
spec!(
|
||||
"object.wrapInSection",
|
||||
"Wrap in new section",
|
||||
@@ -199,6 +206,13 @@ fn convert(s: &mut Session, p: &Value, cmd: &'static str, to_frame: bool) -> Res
|
||||
let n = doc.get(i)?;
|
||||
let refuse = |why: &str| Err(bad(cmd, format!("\"{}\" {why}", n.name)));
|
||||
match (&n.kind, to_frame) {
|
||||
// A frame can't hold sections.
|
||||
(NodeKind::Section { .. }, true)
|
||||
if n.children.iter().any(|c| doc.node(*c).is_some_and(|c| matches!(c.kind, NodeKind::Section { .. }))) =>
|
||||
{
|
||||
return refuse("holds sections, which can't go inside a frame: convert those first");
|
||||
}
|
||||
(NodeKind::Section { .. }, true) => todo.push(i),
|
||||
(NodeKind::Instance { .. }, _) => return refuse("is an instance: its layers come from its main component (detach it first)"),
|
||||
(NodeKind::Component { .. }, _) => return refuse("is a main component, which stays a frame"),
|
||||
(NodeKind::Frame | NodeKind::Group, _) if crate::components::owning_instance(doc, i).is_some() => {
|
||||
@@ -212,12 +226,15 @@ fn convert(s: &mut Session, p: &Value, cmd: &'static str, to_frame: bool) -> Res
|
||||
}
|
||||
for i in &todo {
|
||||
if to_frame {
|
||||
// The group's box as it is; nothing painted or clipped, so the look stays.
|
||||
// The box as it is and nothing clipped, so the look stays: a group has nothing painted, a
|
||||
// section keeps its fill.
|
||||
let n = doc.get_mut(*i)?;
|
||||
if matches!(n.kind, NodeKind::Group) {
|
||||
n.fills.clear();
|
||||
n.strokes.clear();
|
||||
}
|
||||
n.kind = NodeKind::Frame;
|
||||
n.clip = false;
|
||||
n.fills.clear();
|
||||
n.strokes.clear();
|
||||
continue;
|
||||
}
|
||||
let n = doc.get_mut(*i)?;
|
||||
@@ -242,6 +259,43 @@ fn convert(s: &mut Session, p: &Value, cmd: &'static str, to_frame: bool) -> Res
|
||||
Ok(json!({"ids": todo}))
|
||||
}
|
||||
|
||||
/// Frames on the page or in a section become sections in place (sections only sit there).
|
||||
fn convert_to_section(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "object.convertToSection";
|
||||
let ids = req_targets(cmd, s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let mut todo = Vec::new();
|
||||
for i in ids {
|
||||
let n = doc.get(i)?;
|
||||
let refuse = |why: &str| Err(bad(cmd, format!("\"{}\" {why}", n.name)));
|
||||
let on_board = n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.is_page() || matches!(p.kind, NodeKind::Section { .. }));
|
||||
match &n.kind {
|
||||
NodeKind::Section { .. } => {}
|
||||
NodeKind::Instance { .. } => return refuse("is an instance: its layers come from its main component (detach it first)"),
|
||||
NodeKind::Component { .. } | NodeKind::ComponentSet { .. } => return refuse("is a main component, which stays a frame"),
|
||||
NodeKind::Frame if !on_board => return refuse("is inside a frame or group: only frames on the page or in a section become sections"),
|
||||
NodeKind::Frame => todo.push(i),
|
||||
_ => return refuse("is not a frame"),
|
||||
}
|
||||
}
|
||||
for i in &todo {
|
||||
let n = doc.get_mut(*i)?;
|
||||
n.kind = NodeKind::Section { contents_hidden: false };
|
||||
(n.layout, n.clip, n.overflow) = (None, false, proto::Overflow::None);
|
||||
n.grids.clear();
|
||||
n.guides.clear();
|
||||
n.styles.grid = None;
|
||||
n.bound.retain(|k, _| !k.starts_with("padding") && !matches!(k.as_str(), "gap" | "counterAxisSpacing"));
|
||||
let kids = n.children.clone();
|
||||
for k in kids {
|
||||
// Out of the flow there is nothing to fill or to be absolute against.
|
||||
doc.get_mut(k)?.child_layout.absolute = false;
|
||||
}
|
||||
figcraft_layout::coherent_sizing(doc, *i);
|
||||
}
|
||||
Ok(json!({"ids": todo}))
|
||||
}
|
||||
|
||||
fn ungroup(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("object.ungroup", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
|
||||
@@ -112,6 +112,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("variable.createCollection", "Create variable collection", "{name, modes? [names]}", true, |s, p| {
|
||||
let name = req_string("variable.createCollection", p, "name")?;
|
||||
let doc = s.doc_mut()?;
|
||||
if doc.variables.collections.iter().any(|c| c.name == name) {
|
||||
return Err(bad("variable.createCollection", format!("a collection named `{name}` already exists")));
|
||||
}
|
||||
let id = doc.new_collection_id();
|
||||
let modes: Vec<String> = match get(p, "modes") {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(|m| m.as_str().map(str::to_string)).collect(),
|
||||
@@ -910,6 +913,24 @@ pub fn apply_bindings(doc: &mut Document) {
|
||||
|
||||
/// [`apply_bindings`] for the layers `only` (every bound layer when `None`).
|
||||
pub fn apply_bindings_to(doc: &mut Document, only: Option<&std::collections::BTreeSet<NodeId>>) {
|
||||
apply_bindings_matching(doc, only, |_| true);
|
||||
}
|
||||
|
||||
/// Settle imported paint colours before the document is first shown. Other bindings can affect
|
||||
/// layout or component structure, which must be resolved through the normal edit pipeline.
|
||||
pub(crate) fn apply_paint_bindings_on_load(doc: &mut Document) {
|
||||
let pending = doc.take_dirty();
|
||||
apply_bindings_matching(doc, None, |field| {
|
||||
super::lists::list_binding(field).is_some_and(|(list, _, prop)| {
|
||||
matches!(list, super::lists::BoundList::Fills | super::lists::BoundList::Strokes)
|
||||
&& (prop == "color" || super::lists::stop_binding(prop).is_some())
|
||||
})
|
||||
});
|
||||
doc.take_dirty();
|
||||
doc.mark_dirty(pending);
|
||||
}
|
||||
|
||||
fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::BTreeSet<NodeId>>, include: impl Fn(&str) -> bool) {
|
||||
use super::lists::{BoundList, list_binding};
|
||||
if doc.variables.variables.is_empty() {
|
||||
return;
|
||||
@@ -929,6 +950,9 @@ pub fn apply_bindings_to(doc: &mut Document, only: Option<&std::collections::BTr
|
||||
let mut grid_sets: Vec<(usize, String, f64)> = Vec::new();
|
||||
let mut prop_sets: Vec<(String, VariableValue)> = Vec::new();
|
||||
for (field, var) in binds {
|
||||
if !include(&field) {
|
||||
continue;
|
||||
}
|
||||
let Some(val) = doc.variables.resolve(&var, &modes) else { continue };
|
||||
// One list item's value: a paint's colour, an effect's or a layout grid's field.
|
||||
if let Some((list, i, prop)) = list_binding(&field) {
|
||||
|
||||
@@ -87,7 +87,7 @@ fn update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let scopes = match get(p, "scopes") {
|
||||
let scopes = match p.get("scopes") {
|
||||
None => None,
|
||||
Some(Value::Null) => Some(None),
|
||||
Some(Value::Array(a)) => {
|
||||
|
||||
@@ -138,8 +138,16 @@ fn page_step(s: &mut Session, dir: i64) -> Result<Value> {
|
||||
let pages = &ds.doc.pages;
|
||||
let i = pages.iter().position(|p| *p == ds.page).unwrap_or(0) as i64;
|
||||
let n = pages.len() as i64;
|
||||
let next = (i + dir).clamp(0, n - 1) as usize;
|
||||
let Some(pg) = pages.get(next).copied() else { return ok() };
|
||||
// Dividers never open: step over them, and stay put when only dividers lie ahead.
|
||||
let mut next = i + dir;
|
||||
let pg = loop {
|
||||
let page = usize::try_from(next).ok().and_then(|k| pages.get(k).copied());
|
||||
match page {
|
||||
Some(p) if !ds.doc.is_divider(p) => break p,
|
||||
Some(_) if dir != 0 && next >= 0 && next < n => next += dir,
|
||||
_ => break ds.page,
|
||||
}
|
||||
};
|
||||
let ds = s.ds_mut()?;
|
||||
ds.page = pg;
|
||||
ds.selection.clear();
|
||||
@@ -334,6 +342,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);
|
||||
|
||||
@@ -113,8 +113,58 @@ pub(crate) fn num(v: f64) -> String {
|
||||
}
|
||||
|
||||
/// `code` with every pixel length (`12px`) in rem of `root` pixels (`0.75rem`); names that end
|
||||
/// in "px" are left alone.
|
||||
/// in "px", markup lines (layer text), string literals and comments are left alone.
|
||||
pub fn px_to_rem(code: &str, root: f64) -> String {
|
||||
let mut out = String::with_capacity(code.len());
|
||||
let mut in_comment = false;
|
||||
for line in code.split_inclusive('\n') {
|
||||
if !in_comment && line.trim_start().starts_with('<') {
|
||||
out.push_str(line);
|
||||
continue;
|
||||
}
|
||||
// Lengths outside strings and comments convert; `plain` is where that stretch starts.
|
||||
let mut plain = 0;
|
||||
let mut chars = line.char_indices().peekable();
|
||||
while let Some((i, c)) = chars.next() {
|
||||
if in_comment {
|
||||
if c == '*' && chars.peek().is_some_and(|(_, n)| *n == '/') {
|
||||
chars.next();
|
||||
out.push_str(line.get(plain..i + 2).unwrap_or(""));
|
||||
plain = i + 2;
|
||||
in_comment = false;
|
||||
}
|
||||
} else if c == '/' && chars.peek().is_some_and(|(_, n)| *n == '*') {
|
||||
chars.next();
|
||||
out.push_str(&lengths_to_rem(line.get(plain..i).unwrap_or(""), root));
|
||||
plain = i;
|
||||
in_comment = true;
|
||||
} else if c == '"' {
|
||||
out.push_str(&lengths_to_rem(line.get(plain..i).unwrap_or(""), root));
|
||||
let mut end = line.len();
|
||||
while let Some((j, s)) = chars.next() {
|
||||
if s == '\\' {
|
||||
chars.next();
|
||||
} else if s == '"' {
|
||||
end = j + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
out.push_str(line.get(i..end).unwrap_or(""));
|
||||
plain = end;
|
||||
}
|
||||
}
|
||||
let rest = line.get(plain..).unwrap_or("");
|
||||
if in_comment {
|
||||
out.push_str(rest);
|
||||
} else {
|
||||
out.push_str(&lengths_to_rem(rest, root));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `code` with every pixel length in rem of `root` pixels; names that end in "px" are left alone.
|
||||
fn lengths_to_rem(code: &str, root: f64) -> String {
|
||||
let ident = |c: char| c.is_alphanumeric() || c == '_';
|
||||
let mut out = String::with_capacity(code.len());
|
||||
let mut rest = code;
|
||||
|
||||
@@ -615,10 +615,10 @@ pub fn definition(doc: &Document, c: &Component, lang: CodeLang) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// `data-*` attribute names for variant parameters.
|
||||
fn data_attr(p: &Param) -> String {
|
||||
/// `data-*` attribute names for variant parameters, unique among them (in order).
|
||||
fn data_attrs(variants: &[&Param]) -> Vec<String> {
|
||||
let mut used = Vec::new();
|
||||
format!("data-{}", class_name(&p.prop, &mut used))
|
||||
variants.iter().map(|p| format!("data-{}", class_name(&p.prop, &mut used))).collect()
|
||||
}
|
||||
|
||||
/// `c`'s markup in React or Leptos (two levels in) and its stylesheet (variables as custom
|
||||
@@ -628,6 +628,7 @@ fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String
|
||||
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 attr_names = data_attrs(&variants);
|
||||
let pass = |root: NodeId| {
|
||||
let vctx = Ctx { doc, root, comp: Some(c), base: 2 };
|
||||
let mut m = Markup::new(&vctx, lang);
|
||||
@@ -639,9 +640,10 @@ fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String
|
||||
m.root_class = Some(root_class.clone());
|
||||
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),
|
||||
.zip(&attr_names)
|
||||
.map(|(p, attr)| match lang {
|
||||
CodeLang::Leptos => format!(" {attr}={{{}.as_str()}}", rust_ident(&p.ident)),
|
||||
_ => format!(" {attr}={{{}}}", p.ident),
|
||||
})
|
||||
.collect();
|
||||
m.node(c.comp, None, 2, "");
|
||||
@@ -653,7 +655,8 @@ fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String
|
||||
let pairs = variant_props(&vn.name);
|
||||
let attrs: String = variants
|
||||
.iter()
|
||||
.filter_map(|p| pairs.get(&p.prop).map(|val| format!("[{}=\"{}\"]", data_attr(p), val.replace(['"', '\\'], ""))))
|
||||
.zip(&attr_names)
|
||||
.filter_map(|(p, attr)| pairs.get(&p.prop).map(|val| format!("[{attr}=\"{}\"]", val.replace(['"', '\\'], ""))))
|
||||
.collect();
|
||||
for (path, _, decls) in pass(v) {
|
||||
let Some((_, class, base)) = m.rules.iter().find(|(p, _, _)| *p == path) else { continue };
|
||||
|
||||
@@ -314,3 +314,32 @@ fn bound_variables_become_custom_properties() {
|
||||
let react = code(&mut s, c, "react");
|
||||
assert!(react.contains(":root {\n --colors-brand-500: #3366FF;\n}"), "{react}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_colliding_variant_properties_get_distinct_data_attributes() {
|
||||
let mut s = Session::new();
|
||||
let mut variants = Vec::new();
|
||||
for (k, (first, second, fill)) in [("Small", "Big", "#0000FF"), ("Large", "Small", "#FF0000")].iter().enumerate() {
|
||||
let name = format!("Size={first}, size={second}");
|
||||
let f = idv(&ex(
|
||||
&mut s,
|
||||
"frame.create",
|
||||
json!({"x": k as f64 * 150.0, "y": 0, "width": 100, "height": 40, "name": name, "props": {"fill": fill}}),
|
||||
));
|
||||
variants.push(idv(&ex(&mut s, "component.create", json!({"ids": [f]}))));
|
||||
}
|
||||
let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": variants})));
|
||||
ex(&mut s, "node.set", json!({"id": set, "props": {"name": "Chip"}}));
|
||||
for lang in ["react", "leptos"] {
|
||||
let v = component_code(&mut s, set, lang);
|
||||
let def = v["definition"].as_str().unwrap_or_default();
|
||||
let root = def.lines().find(|l| l.trim_start().starts_with("<div ")).unwrap_or_default();
|
||||
let attrs: Vec<&str> =
|
||||
root.split_whitespace().filter_map(|w| w.strip_prefix("data-")).map(|w| w.split('=').next().unwrap_or_default()).collect();
|
||||
assert_eq!(attrs.len(), 2, "{lang}: {root}");
|
||||
assert_ne!(attrs[0], attrs[1], "{lang}: distinct attributes in {root}");
|
||||
let sel = format!(".chip[data-{}=\"Large\"][data-{}=\"Small\"]", attrs[0], attrs[1]);
|
||||
assert!(def.contains(&sel), "{lang}: {sel} in:\n{def}");
|
||||
assert!(def.contains("#FF0000"), "{lang}:\n{def}");
|
||||
}
|
||||
}
|
||||
+205
-25
@@ -472,11 +472,11 @@ fn apply_prop(doc: &Document, n: &mut Node, field: &str, val: &Value) {
|
||||
}
|
||||
}
|
||||
"characters" => {
|
||||
if let (NodeKind::Text(t), Some(sv)) = (&mut n.kind, val.as_str())
|
||||
if let (NodeKind::Text(t), Some(sv)) = (&mut n.kind, val.as_str().map(crate::cmd::props::normalize_line_endings))
|
||||
&& t.characters != sv
|
||||
{
|
||||
let len = t.char_count();
|
||||
t.replace_range(0, len, sv);
|
||||
t.replace_range(0, len, &sv);
|
||||
}
|
||||
}
|
||||
"mainComponent" => {
|
||||
@@ -1710,6 +1710,165 @@ pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Opti
|
||||
None
|
||||
}
|
||||
|
||||
/// Bounded source and parent lookups while a rebase reads an unchanged document.
|
||||
struct RebaseSources<'a> {
|
||||
doc: &'a Document,
|
||||
chains: BTreeMap<NodeId, Option<Vec<NodeId>>>,
|
||||
parents: BTreeMap<(NodeId, NodeId), Option<bool>>,
|
||||
main_changes: BTreeMap<NodeId, bool>,
|
||||
}
|
||||
|
||||
impl<'a> RebaseSources<'a> {
|
||||
const PARENTS: usize = 512;
|
||||
const CACHE: usize = 8192;
|
||||
|
||||
fn new(doc: &'a Document) -> Self {
|
||||
Self { doc, chains: BTreeMap::new(), parents: BTreeMap::new(), main_changes: BTreeMap::new() }
|
||||
}
|
||||
|
||||
fn chain(&mut self, source: NodeId) -> Option<Vec<NodeId>> {
|
||||
if let Some(chain) = self.chains.get(&source) {
|
||||
return chain.clone();
|
||||
}
|
||||
let read = || {
|
||||
let mut chain = Vec::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut current = Some(source);
|
||||
for _ in 0..MAX_DEPTH {
|
||||
let Some(id) = current else { return Some(chain) };
|
||||
if !seen.insert(id) {
|
||||
return None;
|
||||
}
|
||||
let n = self.doc.node(id)?;
|
||||
chain.push(id);
|
||||
current = n.source;
|
||||
}
|
||||
current.is_none().then_some(chain)
|
||||
};
|
||||
let chain = read();
|
||||
if self.chains.len() < Self::CACHE {
|
||||
self.chains.insert(source, chain.clone());
|
||||
}
|
||||
chain
|
||||
}
|
||||
|
||||
/// Validate the complete parent chain, even if `main` was found before a cycle.
|
||||
fn in_main(&mut self, id: NodeId, main: NodeId) -> Option<bool> {
|
||||
if let Some(found) = self.parents.get(&(id, main)) {
|
||||
return *found;
|
||||
}
|
||||
let read = || {
|
||||
let mut current = Some(id);
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut found = false;
|
||||
for _ in 0..Self::PARENTS {
|
||||
let Some(id) = current else { return Some(found) };
|
||||
if !seen.insert(id) {
|
||||
return None;
|
||||
}
|
||||
found |= id == main;
|
||||
current = self.doc.node(id)?.parent;
|
||||
}
|
||||
current.is_none().then_some(found)
|
||||
};
|
||||
let found = read();
|
||||
if self.parents.len() < Self::CACHE {
|
||||
self.parents.insert((id, main), found);
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
fn member(&mut self, source: NodeId, main: NodeId, target: NodeId) -> Option<NodeId> {
|
||||
let chain = self.chain(source)?;
|
||||
if chain.contains(&target) {
|
||||
return None;
|
||||
}
|
||||
let mut member = None;
|
||||
for id in chain {
|
||||
if self.in_main(id, main)? && id != main && member.is_none() {
|
||||
member = Some(id);
|
||||
}
|
||||
}
|
||||
member
|
||||
}
|
||||
|
||||
/// Nearest enclosing instances and their current mains. Slots with own content end scope.
|
||||
fn owners(&self, id: NodeId) -> Option<Vec<(NodeId, NodeId)>> {
|
||||
let mut owners = Vec::new();
|
||||
let mut current = self.doc.node(id)?.parent;
|
||||
let mut seen = BTreeSet::from([id]);
|
||||
for _ in 0..Self::PARENTS {
|
||||
let Some(id) = current else { return Some(owners) };
|
||||
if !seen.insert(id) {
|
||||
return None;
|
||||
}
|
||||
let n = self.doc.node(id)?;
|
||||
if holds_content(n) {
|
||||
return Some(owners);
|
||||
}
|
||||
if let NodeKind::Instance { component, .. } = &n.kind {
|
||||
owners.push((id, *component));
|
||||
}
|
||||
current = n.parent;
|
||||
}
|
||||
current.is_none().then_some(owners)
|
||||
}
|
||||
|
||||
fn contextual(&mut self, source: NodeId, owners: &[(NodeId, NodeId)]) -> bool {
|
||||
owners.iter().any(|(_, main)| self.in_main(source, *main) == Some(true) && source != *main)
|
||||
}
|
||||
|
||||
/// A contextual child belongs to the same placed slot as its enclosing instance.
|
||||
fn valid(&mut self, source: NodeId, target: NodeId, owners: &[(NodeId, NodeId)]) -> bool {
|
||||
let Some((owner, main)) = owners.first() else { return false };
|
||||
if self.member(source, *main, target).is_none() {
|
||||
return false;
|
||||
}
|
||||
if self.in_main(source, *main) == Some(true) {
|
||||
return true;
|
||||
}
|
||||
let Some(source_owner) = self.doc.node(*owner).and_then(|n| n.source) else { return false };
|
||||
matches!(self.doc.node(source_owner).map(|n| &n.kind), Some(NodeKind::Instance { component, .. }) if component == main)
|
||||
&& self.owners(source).and_then(|owners| owners.first().copied()).is_some_and(|(id, _)| id == source_owner)
|
||||
}
|
||||
|
||||
fn placed(&mut self, id: NodeId, owners: &[(NodeId, NodeId)]) -> Option<NodeId> {
|
||||
let source = self.doc.node(id)?.source?;
|
||||
if !matches!(self.doc.node(source)?.kind, NodeKind::Instance { .. } | NodeKind::Frame)
|
||||
|| !self.valid(source, id, owners)
|
||||
|| !self.contextual(source, owners)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(source)
|
||||
}
|
||||
|
||||
fn beneath_swap(&mut self, owners: &[(NodeId, NodeId)], stop: NodeId) -> bool {
|
||||
for (index, (id, main)) in owners.iter().enumerate() {
|
||||
if *id == stop {
|
||||
break;
|
||||
}
|
||||
let changed = if let Some(changed) = self.main_changes.get(id) {
|
||||
*changed
|
||||
} else {
|
||||
let changed = self.placed(*id, &owners[index + 1..]).is_some_and(|source| match self.doc.node(source).map(|n| &n.kind) {
|
||||
Some(NodeKind::Instance { component, .. }) => component != main,
|
||||
Some(NodeKind::Frame) => true,
|
||||
_ => false,
|
||||
});
|
||||
if self.main_changes.len() < Self::CACHE {
|
||||
self.main_changes.insert(*id, changed);
|
||||
}
|
||||
changed
|
||||
};
|
||||
if changed {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-derive `source` links and overrides of instance `inst` against its own main by comparing
|
||||
/// values (after detaching an outer instance, restoring a main, or copying a nested instance out):
|
||||
/// the look is kept exactly. Values a component property sets aren't overrides.
|
||||
@@ -1722,22 +1881,28 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) {
|
||||
diff_groups(a, b).into_iter().filter(|g| !matches!(*g, "size" | "position")).map(str::to_string).collect()
|
||||
};
|
||||
let mut updates = Vec::new();
|
||||
let mut sources = RebaseSources::new(doc);
|
||||
let root_owners = sources.owners(inst).unwrap_or_default();
|
||||
let placed_source = sources.placed(inst, &root_owners);
|
||||
let mut contexts = vec![(inst, component)];
|
||||
contexts.extend_from_slice(&root_owners);
|
||||
for (id, path) in &mine {
|
||||
let Some(n) = doc.node(*id) else { continue };
|
||||
// Follow the source chain into the main first (exact), then fall back to the name path.
|
||||
let mut main = None;
|
||||
let mut cur = n.source;
|
||||
for _ in 0..MAX_DEPTH {
|
||||
let Some(c) = cur else { break };
|
||||
if c != component && doc.contains(c) && doc.is_ancestor(component, c) {
|
||||
main = Some(c);
|
||||
break;
|
||||
}
|
||||
cur = doc.node(c).and_then(|x| x.source);
|
||||
let owners = sources.owners(*id).unwrap_or_default();
|
||||
let nearest_main = owners.first().map(|(_, main)| *main);
|
||||
let valid_source = n.source.filter(|source| sources.valid(*source, *id, &owners));
|
||||
let mut main = valid_source.and_then(|source| sources.member(source, component, *id));
|
||||
// A swapped or Frame-to-Instance subtree is compared to its current main by the inner
|
||||
// rebase. An outer name path must not rebind those children to the old expanded copies.
|
||||
if main.is_none() && valid_source.is_some() && sources.beneath_swap(&owners, inst) {
|
||||
updates.push((*id, valid_source, n.overrides.clone()));
|
||||
continue;
|
||||
}
|
||||
let main = main.or_else(|| theirs.get(path).copied());
|
||||
main = main
|
||||
.or_else(|| theirs.get(path).copied().filter(|source| nearest_main.is_some_and(|scope| sources.member(*source, scope, *id).is_some())));
|
||||
let source = valid_source.filter(|source| sources.contextual(*source, &contexts)).filter(|_| main.is_some()).or(main);
|
||||
let mut overrides: BTreeSet<String> = n.overrides.iter().filter(|g| !matches!(g.as_str(), "component" | "props")).cloned().collect();
|
||||
if let Some(m) = main.and_then(|m| doc.node(m)) {
|
||||
if let Some(m) = source.and_then(|source| doc.node(source)) {
|
||||
let mut bound: Vec<&str> = m.prop_refs.keys().map(|f| field_group(f)).collect();
|
||||
bound.extend(variable_groups(n, m));
|
||||
overrides.extend(value_groups(m, n).into_iter().filter(|g| !bound.contains(&g.as_str())));
|
||||
@@ -1745,36 +1910,51 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) {
|
||||
overrides.insert("component".into());
|
||||
}
|
||||
}
|
||||
updates.push((*id, main, overrides));
|
||||
updates.push((*id, source, overrides));
|
||||
}
|
||||
for (id, main, overrides) in updates {
|
||||
if let Some(n) = doc.node_mut(id) {
|
||||
n.source = main;
|
||||
n.overrides = if main.is_some() { overrides } else { BTreeSet::new() };
|
||||
}
|
||||
}
|
||||
let root_groups = match (doc.node(component), doc.node(inst)) {
|
||||
// A placed nested root compares to its actual slot. A detached root compares to its own main.
|
||||
let root_base = placed_source.or_else(|| {
|
||||
let chain = sources.chain(component)?;
|
||||
(!chain.contains(&inst) && sources.in_main(component, inst) == Some(false)).then_some(component)
|
||||
});
|
||||
let root_groups = match (root_base.and_then(|source| doc.node(source)), doc.node(inst)) {
|
||||
(Some(m), Some(n)) => {
|
||||
let from_variables = variable_groups(n, m);
|
||||
let mut groups: Vec<String> = value_groups(m, n)
|
||||
.into_iter()
|
||||
.filter(|g| !matches!(g.as_str(), "name" | "exports" | "interactions") && !from_variables.contains(&g.as_str()))
|
||||
.collect();
|
||||
// A layer name of its own (a nested instance renamed in its old main) stays.
|
||||
if n.name != default_instance_name(doc, component) {
|
||||
let named = if placed_source.is_some() { n.name != m.name } else { n.name != default_instance_name(doc, component) };
|
||||
if named {
|
||||
groups.push("name".into());
|
||||
}
|
||||
if placed_source.is_some()
|
||||
&& matches!((&m.kind, &n.kind), (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) if a != b)
|
||||
{
|
||||
groups.push("component".into());
|
||||
}
|
||||
groups
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
drop(sources);
|
||||
for (id, main, overrides) in updates {
|
||||
if let Some(n) = doc.node_mut(id) {
|
||||
n.source = main;
|
||||
n.overrides = if main.is_some() { overrides } else { BTreeSet::new() };
|
||||
}
|
||||
}
|
||||
if let Some(n) = doc.node_mut(inst) {
|
||||
n.source = Some(component);
|
||||
n.source = root_base;
|
||||
n.overrides.retain(|g| !matches!(g.as_str(), "component" | "props" | "position"));
|
||||
n.overrides.extend(root_groups);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "components_rebase_source_tests.rs"]
|
||||
mod rebase_source_tests;
|
||||
|
||||
/// A set's default variant: the one in its top-left corner (nearest to it, then the top-most, then
|
||||
/// the left-most).
|
||||
pub fn default_variant(doc: &Document, set: NodeId) -> Option<NodeId> {
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
//! Contextual import sources survive rebasing; detached and swapped subtrees use their current mains.
|
||||
|
||||
use super::*;
|
||||
|
||||
fn add(doc: &mut Document, parent: NodeId, kind: NodeKind, name: &str) -> NodeId {
|
||||
doc.create(parent, kind, name, 0.0, 0.0, 20.0, 20.0).unwrap()
|
||||
}
|
||||
|
||||
fn component(doc: &mut Document, parent: NodeId, name: &str) -> NodeId {
|
||||
add(doc, parent, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, name)
|
||||
}
|
||||
|
||||
fn mirror(doc: &mut Document, source: NodeId, parent: NodeId) -> NodeId {
|
||||
let mut n = doc.node(source).unwrap().clone();
|
||||
let children = std::mem::take(&mut n.children);
|
||||
n.id = doc.alloc_id();
|
||||
n.parent = None;
|
||||
n.source = Some(source);
|
||||
let id = n.id;
|
||||
doc.insert(n, parent, None).unwrap();
|
||||
for child in children {
|
||||
mirror(doc, child, id);
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
fn instance(doc: &mut Document, main: NodeId, parent: NodeId, name: &str) -> NodeId {
|
||||
let mut n = doc.node(main).unwrap().clone();
|
||||
let children = std::mem::take(&mut n.children);
|
||||
n.id = doc.alloc_id();
|
||||
n.kind = NodeKind::Instance { component: main, props: BTreeMap::new() };
|
||||
n.name = name.into();
|
||||
n.parent = None;
|
||||
n.source = None;
|
||||
let id = n.id;
|
||||
doc.insert(n, parent, None).unwrap();
|
||||
for child in children {
|
||||
mirror(doc, child, id);
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
struct Scene {
|
||||
doc: Document,
|
||||
inner: NodeId,
|
||||
base: NodeId,
|
||||
slot: NodeId,
|
||||
context: NodeId,
|
||||
root: NodeId,
|
||||
nested: NodeId,
|
||||
child: NodeId,
|
||||
}
|
||||
|
||||
fn scene() -> Scene {
|
||||
let mut doc = Document::new("Source context");
|
||||
let page = doc.first_page().unwrap();
|
||||
let inner = component(&mut doc, page, "Inner");
|
||||
let base = add(&mut doc, inner, NodeKind::Rectangle, "Body");
|
||||
doc.node_mut(base).unwrap().transform = Affine::translate((2.0, 0.0));
|
||||
let outer = component(&mut doc, page, "Outer");
|
||||
let slot = instance(&mut doc, inner, outer, "Nested");
|
||||
doc.node_mut(slot).unwrap().source = Some(inner);
|
||||
let context = doc.node(slot).unwrap().children[0];
|
||||
doc.node_mut(context).unwrap().transform = Affine::translate((3.0, 0.0));
|
||||
doc.node_mut(context).unwrap().opacity = 0.3;
|
||||
let root = instance(&mut doc, outer, page, "Outer copy");
|
||||
let nested = doc.node(root).unwrap().children[0];
|
||||
let child = doc.node(nested).unwrap().children[0];
|
||||
doc.node_mut(child).unwrap().transform = Affine::translate((2.0, 0.0));
|
||||
doc.node_mut(child).unwrap().opacity = 0.2;
|
||||
Scene { doc, inner, base, slot, context, root, nested, child }
|
||||
}
|
||||
|
||||
fn both(s: &mut Scene) {
|
||||
rebase_instance(&mut s.doc, s.nested);
|
||||
rebase_instance(&mut s.doc, s.root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebase_default_only_sources_promote_to_the_actual_outer_context() {
|
||||
let mut s = scene();
|
||||
s.doc.node_mut(s.nested).unwrap().source = None;
|
||||
s.doc.node_mut(s.child).unwrap().source = None;
|
||||
both(&mut s);
|
||||
assert_eq!(s.doc.node(s.nested).unwrap().source, Some(s.slot));
|
||||
assert_eq!(s.doc.node(s.child).unwrap().source, Some(s.context));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebase_rejects_a_context_source_from_another_nested_slot() {
|
||||
let mut s = scene();
|
||||
let outer = s.doc.node(s.slot).unwrap().parent.unwrap();
|
||||
let sibling = instance(&mut s.doc, s.inner, outer, "Other nested slot");
|
||||
s.doc.node_mut(sibling).unwrap().source = Some(s.inner);
|
||||
let sibling_child = s.doc.node(sibling).unwrap().children[0];
|
||||
mirror(&mut s.doc, sibling, s.root);
|
||||
s.doc.node_mut(s.child).unwrap().source = Some(sibling_child);
|
||||
both(&mut s);
|
||||
assert_eq!(s.doc.node(s.nested).unwrap().source, Some(s.slot));
|
||||
assert_eq!(s.doc.node(s.child).unwrap().source, Some(s.context));
|
||||
assert_eq!(s.doc.node(sibling_child).unwrap().transform.translation().x, 2.0);
|
||||
assert_eq!(s.doc.node(s.context).unwrap().transform.translation().x, 3.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebase_does_not_mark_inherited_context_values_as_local_overrides() {
|
||||
let mut s = scene();
|
||||
s.doc.node_mut(s.child).unwrap().opacity = 0.3;
|
||||
both(&mut s);
|
||||
assert_eq!(s.doc.node(s.child).unwrap().source, Some(s.context));
|
||||
assert!(!s.doc.node(s.child).unwrap().overrides.contains("opacity"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebase_preserves_multilevel_context_without_shortening_source_chains() {
|
||||
let mut doc = Document::new("Nested source contexts");
|
||||
let page = doc.first_page().unwrap();
|
||||
let inner = component(&mut doc, page, "Inner");
|
||||
let base = add(&mut doc, inner, NodeKind::Rectangle, "Body");
|
||||
doc.node_mut(base).unwrap().transform = Affine::translate((2.0, 0.0));
|
||||
let wrapper = component(&mut doc, page, "Wrapper");
|
||||
let default_slot = instance(&mut doc, inner, wrapper, "Nested");
|
||||
doc.node_mut(default_slot).unwrap().source = Some(inner);
|
||||
let outer = component(&mut doc, page, "Outer");
|
||||
let wrapper_slot = instance(&mut doc, wrapper, outer, "Wrapper slot");
|
||||
doc.node_mut(wrapper_slot).unwrap().source = Some(wrapper);
|
||||
let source_slot = doc.node(wrapper_slot).unwrap().children[0];
|
||||
let context = doc.node(source_slot).unwrap().children[0];
|
||||
doc.node_mut(context).unwrap().transform = Affine::translate((3.0, 0.0));
|
||||
let root = instance(&mut doc, outer, page, "Outer copy");
|
||||
let wrapper_copy = doc.node(root).unwrap().children[0];
|
||||
let nested = doc.node(wrapper_copy).unwrap().children[0];
|
||||
let child = doc.node(nested).unwrap().children[0];
|
||||
doc.node_mut(nested).unwrap().name = "Renamed nested".into();
|
||||
doc.node_mut(child).unwrap().transform = Affine::translate((2.0, 0.0));
|
||||
for id in [nested, wrapper_copy, root] {
|
||||
rebase_instance(&mut doc, id);
|
||||
assert_eq!(doc.node(nested).unwrap().source, Some(source_slot));
|
||||
assert_eq!(doc.node(child).unwrap().source, Some(context));
|
||||
}
|
||||
assert_eq!(doc.node(context).unwrap().source, Some(doc.node(default_slot).unwrap().children[0]));
|
||||
assert_eq!(doc.node(context).unwrap().transform.translation().x, 3.0);
|
||||
assert_eq!(doc.node(child).unwrap().transform.translation().x, 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebase_preserves_placed_frame_to_instance_and_current_main_children() {
|
||||
let mut doc = Document::new("Typed nested source");
|
||||
let page = doc.first_page().unwrap();
|
||||
let inner = component(&mut doc, page, "Inner");
|
||||
let body = add(&mut doc, inner, NodeKind::Rectangle, "Body");
|
||||
let outer = component(&mut doc, page, "Outer");
|
||||
let slot = add(&mut doc, outer, NodeKind::Frame, "Empty slot");
|
||||
let root = instance(&mut doc, outer, page, "Outer copy");
|
||||
let nested = doc.node(root).unwrap().children[0];
|
||||
doc.node_mut(nested).unwrap().kind = NodeKind::Instance { component: inner, props: BTreeMap::new() };
|
||||
let child = mirror(&mut doc, body, nested);
|
||||
rebase_instance(&mut doc, nested);
|
||||
rebase_instance(&mut doc, root);
|
||||
assert_eq!(doc.node(nested).unwrap().source, Some(slot));
|
||||
assert_eq!(doc.node(child).unwrap().source, Some(body));
|
||||
assert!(matches!(doc.node(nested).unwrap().kind, NodeKind::Instance { component, .. } if component == inner));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebase_keeps_current_main_sources_and_component_delta_beneath_swap() {
|
||||
let mut s = scene();
|
||||
let page = s.doc.first_page().unwrap();
|
||||
let replacement = component(&mut s.doc, page, "Replacement");
|
||||
let body = add(&mut s.doc, replacement, NodeKind::Rectangle, "Body");
|
||||
s.doc.node_mut(body).unwrap().transform = Affine::translate((7.0, 0.0));
|
||||
s.doc.node_mut(s.nested).unwrap().kind = NodeKind::Instance { component: replacement, props: BTreeMap::new() };
|
||||
s.doc.remove(s.child).unwrap();
|
||||
s.child = mirror(&mut s.doc, body, s.nested);
|
||||
s.doc.node_mut(s.child).unwrap().transform = Affine::translate((8.0, 0.0));
|
||||
s.doc.node_mut(s.nested).unwrap().name = "Renamed swap".into();
|
||||
rebase_instance(&mut s.doc, s.nested);
|
||||
assert_eq!(s.doc.node(s.nested).unwrap().source, Some(s.slot));
|
||||
assert!(s.doc.node(s.nested).unwrap().overrides.contains("component"));
|
||||
rebase_instance(&mut s.doc, s.root);
|
||||
assert_eq!(s.doc.node(s.nested).unwrap().source, Some(s.slot));
|
||||
assert_eq!(s.doc.node(s.child).unwrap().source, Some(body));
|
||||
assert!(s.doc.node(s.nested).unwrap().overrides.contains("component"));
|
||||
assert_eq!(s.doc.node(s.child).unwrap().transform.translation().x, 8.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebase_detached_nested_uses_own_main_and_keeps_inherited_geometry() {
|
||||
let mut s = scene();
|
||||
let page = s.doc.first_page().unwrap();
|
||||
let detached = mirror(&mut s.doc, s.nested, page);
|
||||
let child = s.doc.node(detached).unwrap().children[0];
|
||||
// The old source references still exist, but their Outer context is absent here.
|
||||
s.doc.node_mut(detached).unwrap().source = Some(s.slot);
|
||||
s.doc.node_mut(child).unwrap().source = Some(s.context);
|
||||
s.doc.node_mut(child).unwrap().transform = Affine::translate((3.0, 0.0));
|
||||
rebase_instance(&mut s.doc, detached);
|
||||
assert_eq!(s.doc.node(detached).unwrap().source, Some(s.inner));
|
||||
assert_eq!(s.doc.node(child).unwrap().source, Some(s.base));
|
||||
assert_eq!(s.doc.node(child).unwrap().transform.translation().x, 3.0);
|
||||
assert_eq!(s.doc.node(s.base).unwrap().transform.translation().x, 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebase_does_not_preserve_existing_foreign_or_stale_sources() {
|
||||
let mut s = scene();
|
||||
let page = s.doc.first_page().unwrap();
|
||||
let foreign = component(&mut s.doc, page, "Foreign");
|
||||
let foreign_body = add(&mut s.doc, foreign, NodeKind::Rectangle, "Body");
|
||||
s.doc.node_mut(s.child).unwrap().source = Some(foreign_body);
|
||||
both(&mut s);
|
||||
assert_ne!(s.doc.node(s.child).unwrap().source, Some(foreign_body));
|
||||
assert_eq!(s.doc.node(s.child).unwrap().source, Some(s.context));
|
||||
let mut s = scene();
|
||||
let page = s.doc.first_page().unwrap();
|
||||
let replacement = component(&mut s.doc, page, "Replacement");
|
||||
let body = add(&mut s.doc, replacement, NodeKind::Rectangle, "Body");
|
||||
s.doc.node_mut(s.nested).unwrap().kind = NodeKind::Instance { component: replacement, props: BTreeMap::new() };
|
||||
s.doc.node_mut(s.child).unwrap().source = Some(s.context);
|
||||
both(&mut s);
|
||||
assert_ne!(s.doc.node(s.child).unwrap().source, Some(s.context), "the old default-main child is stale after a swap");
|
||||
assert!(s.doc.node(s.child).unwrap().source.is_none() || s.doc.node(s.child).unwrap().source == Some(body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebase_rejects_source_and_parent_cycles_with_bounded_lookups() {
|
||||
let mut s = scene();
|
||||
s.doc.node_mut(s.base).unwrap().source = Some(s.context);
|
||||
both(&mut s);
|
||||
assert_eq!(s.doc.node(s.child).unwrap().source, None);
|
||||
let mut s = scene();
|
||||
s.doc.node_mut(s.context).unwrap().parent = Some(s.context);
|
||||
both(&mut s);
|
||||
assert_ne!(s.doc.node(s.child).unwrap().source, Some(s.context));
|
||||
let mut s = scene();
|
||||
s.doc.node_mut(s.nested).unwrap().parent = Some(s.child);
|
||||
s.doc.node_mut(s.child).unwrap().parent = Some(s.nested);
|
||||
let sources = RebaseSources::new(&s.doc);
|
||||
assert!(sources.owners(s.child).is_none());
|
||||
let mut s = scene();
|
||||
s.doc.node_mut(s.root).unwrap().kind = NodeKind::Instance { component: s.root, props: BTreeMap::new() };
|
||||
rebase_instance(&mut s.doc, s.root);
|
||||
assert_ne!(s.doc.node(s.root).unwrap().source, Some(s.root));
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Frames and groups converted into each other in place: the layers stay where they are on the
|
||||
//! page, what only a frame has goes with it, what can't convert is refused, one undo step.
|
||||
//! Frames, groups and sections converted into each other in place: the layers stay where they are on
|
||||
//! the page, what only a frame has goes with it, what can't convert is refused, one undo step.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -136,7 +136,7 @@ fn what_cannot_convert_is_refused_and_nothing_changes() {
|
||||
#[test]
|
||||
fn junk_parameters_are_refused_not_fatal() {
|
||||
let junk = [json!(null), json!(true), json!(-1), json!(1e308), json!("x"), json!([[1]]), json!({"a": 1}), json!([0, 999_999, "7"])];
|
||||
for cmd in ["object.convertToGroup", "object.convertToFrame"] {
|
||||
for cmd in ["object.convertToGroup", "object.convertToFrame", "object.convertToSection"] {
|
||||
for j in &junk {
|
||||
let mut s = Session::new();
|
||||
let f = id(ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 50, "height": 50})));
|
||||
@@ -148,3 +148,49 @@ fn junk_parameters_are_refused_not_fatal() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_on_the_page_becomes_a_section_and_back() {
|
||||
let mut s = Session::new();
|
||||
let f = id(ex(&mut s, "frame.create", json!({"x": 100, "y": 50, "width": 300, "height": 200, "name": "Flow", "props": {"fill": "#FFEEDD"}})));
|
||||
let (a, b) = (rect(&mut s, f, 20.0, 30.0), rect(&mut s, f, 400.0, 90.0));
|
||||
ex(&mut s, "layout.add", json!({"ids": [f], "direction": "horizontal"}));
|
||||
ex(&mut s, "node.set", json!({"id": f, "props": {"width": 300, "height": 200}}));
|
||||
let before = [page_box(&s, a), page_box(&s, b), page_box(&s, f)];
|
||||
assert_eq!(ex(&mut s, "object.convertToSection", json!({"ids": [f]}))["ids"], json!([f]));
|
||||
let n = s.doc().unwrap().node(f).unwrap().clone();
|
||||
assert!(matches!(n.kind, NodeKind::Section { contents_hidden: false }));
|
||||
assert_eq!((n.name.as_str(), n.children.clone(), n.fills.len()), ("Flow", vec![a, b], 1), "same name, layers and fill");
|
||||
assert!(n.layout.is_none() && !n.clip, "no auto layout or clipping on a section");
|
||||
assert_eq!([page_box(&s, a), page_box(&s, b), page_box(&s, f)], before, "nothing moves");
|
||||
// And back: a frame that keeps the fill and doesn't clip, so the look stays.
|
||||
assert_eq!(ex(&mut s, "object.convertToFrame", json!({"ids": [f]}))["ids"], json!([f]));
|
||||
let n = s.doc().unwrap().node(f).unwrap().clone();
|
||||
assert!(matches!(n.kind, NodeKind::Frame) && n.fills.len() == 1 && !n.clip);
|
||||
assert_eq!([page_box(&s, a), page_box(&s, b), page_box(&s, f)], before);
|
||||
// Each conversion is one undo step.
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert!(matches!(kind(&s, f), NodeKind::Section { .. }));
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert!(matches!(kind(&s, f), NodeKind::Frame));
|
||||
assert!(s.doc().unwrap().node(f).unwrap().layout.is_some(), "the frame's auto layout is back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_frames_on_the_page_or_in_a_section_become_sections() {
|
||||
let mut s = Session::new();
|
||||
let outer = id(ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 300})));
|
||||
let inner = id(ex(&mut s, "frame.create", json!({"parent": outer, "x": 10, "y": 10, "width": 100, "height": 100})));
|
||||
let err = s.execute("object.convertToSection", &json!({"ids": [inner]})).unwrap_err().to_string();
|
||||
assert!(err.contains("inside a frame"), "{err}");
|
||||
let r = rect(&mut s, outer, 150.0, 150.0);
|
||||
assert!(s.execute("object.convertToSection", &json!({"ids": [r]})).is_err(), "a rectangle isn't a frame");
|
||||
// A frame inside a section converts; a section holding a section can't become a frame.
|
||||
let sec = id(ex(&mut s, "node.create", json!({"type": "section", "x": 500, "y": 0, "width": 400, "height": 400})));
|
||||
let in_sec = id(ex(&mut s, "frame.create", json!({"parent": sec, "x": 20, "y": 20, "width": 100, "height": 100})));
|
||||
ex(&mut s, "object.convertToSection", json!({"ids": [in_sec]}));
|
||||
assert!(matches!(kind(&s, in_sec), NodeKind::Section { .. }));
|
||||
let err = s.execute("object.convertToFrame", &json!({"ids": [sec]})).unwrap_err().to_string();
|
||||
assert!(err.contains("holds sections"), "{err}");
|
||||
assert!(matches!(kind(&s, sec), NodeKind::Section { .. }), "nothing changed");
|
||||
}
|
||||
@@ -219,6 +219,24 @@ fn an_edit_an_instance_refuses_shows_not_allowed_and_is_undone() {
|
||||
assert_eq!(s.doc().unwrap().world_bounds(inner).unwrap(), before, "the move was undone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pen_click_that_wobbles_a_pixel_is_still_a_click() {
|
||||
let mut s = Session::new();
|
||||
s.set_tool(ToolId::Pen);
|
||||
// A real click: the mouse moves a pixel or two between press and release.
|
||||
s.pointer(&ev(PointerKind::Down, (100.0, 100.0), NONE)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, (102.0, 101.0), NONE)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Up, (102.0, 101.0), NONE)).unwrap();
|
||||
let out = s.tool.pen.as_ref().and_then(|p| p.points.first()).map(|(_, _, o)| *o);
|
||||
assert_eq!(out.map(|o| o.hypot()), Some(0.0), "no handle pulled out");
|
||||
assert!(s.tool.overlay.pen_handles.is_empty());
|
||||
// Past the slop it is a drag: the handle follows the pointer from where it was pressed.
|
||||
s.pointer(&ev(PointerKind::Down, (200.0, 100.0), NONE)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, (204.0, 100.0), NONE)).unwrap();
|
||||
let out = s.tool.pen.as_ref().and_then(|p| p.points.last()).map(|(_, _, o)| (o.x, o.y));
|
||||
assert_eq!(out, Some((4.0, 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pen_shows_what_a_click_would_do() {
|
||||
// The Pen tool drawing a new path: three points, then over each of them.
|
||||
|
||||
@@ -93,3 +93,29 @@ fn only_the_focused_frame_is_hit_and_escape_leaves() {
|
||||
click(&mut s, 350.0, 100.0);
|
||||
assert_eq!(s.selection(), vec![b], "everything is hit again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_frames_stay_out_of_the_ready_list_and_its_navigation() {
|
||||
let mut s = Session::new();
|
||||
let screen = frame(&mut s, 0.0, "Screen");
|
||||
let card = idv(&ex(&mut s, "frame.create", json!({"parent": screen, "width": 100, "height": 50, "name": "Card"})));
|
||||
// A status a nested frame already stores (an older file, or set through node.set) is ignored.
|
||||
ex(&mut s, "node.set", json!({"id": card, "props": {"devStatus": "readyForDev"}}));
|
||||
assert_eq!(ex(&mut s, "inspect.readyForDev", json!({"status": "any"}))["items"], json!([]));
|
||||
assert!(s.execute("dev.focus", &json!({"direction": "next"})).is_err(), "nothing is ready");
|
||||
ex(&mut s, "node.set", json!({"id": screen, "props": {"devStatus": "readyForDev"}}));
|
||||
let ids = |v: serde_json::Value| -> Vec<serde_json::Value> {
|
||||
v["items"].as_array().map(|a| a.iter().map(|i| i["id"].clone()).collect()).unwrap_or_default()
|
||||
};
|
||||
assert_eq!(ids(ex(&mut s, "inspect.readyForDev", json!({}))), vec![json!(screen)]);
|
||||
ex(&mut s, "dev.focus", json!({"id": screen}));
|
||||
for direction in ["next", "previous"] {
|
||||
let v = ex(&mut s, "dev.focus", json!({"direction": direction}));
|
||||
assert_eq!((v["id"].clone(), v["count"].clone()), (json!(screen), json!(1)), "{direction} wraps to the only ready frame");
|
||||
}
|
||||
// A frame directly inside a section still counts.
|
||||
let sec = idv(&ex(&mut s, "node.create", json!({"type": "section", "x": 1000, "y": 0, "width": 400, "height": 300})));
|
||||
let in_sec = idv(&ex(&mut s, "frame.create", json!({"parent": sec, "width": 100, "height": 100, "name": "In section"})));
|
||||
ex(&mut s, "node.set", json!({"ids": [sec, in_sec], "props": {"devStatus": "readyForDev"}}));
|
||||
assert_eq!(ids(ex(&mut s, "inspect.readyForDev", json!({}))), vec![json!(screen), json!(sec), json!(in_sec)]);
|
||||
}
|
||||
@@ -231,7 +231,7 @@ pub fn has_status(doc: &Document, id: NodeId) -> bool {
|
||||
}
|
||||
|
||||
/// The layers with handoff status `status` (`None`: any status) on `page` (`None`: every page),
|
||||
/// in page and layer order (bounded).
|
||||
/// in page and layer order (bounded). Only layers that can carry a status are listed.
|
||||
pub fn dev_items(doc: &Document, status: Option<DevStatus>, page: Option<NodeId>) -> Vec<DevItem> {
|
||||
let mut out = Vec::new();
|
||||
let mut visited = 0usize;
|
||||
@@ -243,7 +243,7 @@ pub fn dev_items(doc: &Document, status: Option<DevStatus>, page: Option<NodeId>
|
||||
return out;
|
||||
}
|
||||
let Some(n) = doc.node(id) else { continue };
|
||||
if !n.dev_status.is_none() && status.is_none_or(|s| s == n.dev_status) {
|
||||
if !n.dev_status.is_none() && status.is_none_or(|s| s == n.dev_status) && has_status(doc, id) {
|
||||
out.push(DevItem { id, name: n.name.clone(), page: pg, status: n.dev_status });
|
||||
}
|
||||
stack.extend(n.children.iter().rev().copied());
|
||||
|
||||
@@ -61,12 +61,15 @@ pub fn flip(r: figcraft_geom::Rect, vertical: bool) -> figcraft_geom::Rect {
|
||||
|
||||
/// The visual rows (columns when `vertical`) of a wrapping layout's placed children `kids`, each
|
||||
/// child as (its index in `kids`, its page bounds [`flip`]ped so the flow runs along x). Children
|
||||
/// wrap in flow order, so a new row starts where a child sits before the one before.
|
||||
/// wrap in flow order, so a new row starts where a child sits before the one before, or where it
|
||||
/// does not follow the one before along the flow but sits past it across (one child per line).
|
||||
pub fn wrap_rows(doc: &Document, kids: &[NodeId], vertical: bool) -> Vec<Vec<(usize, figcraft_geom::Rect)>> {
|
||||
let mut rows: Vec<Vec<(usize, figcraft_geom::Rect)>> = Vec::new();
|
||||
for (i, b) in kids.iter().enumerate().filter_map(|(i, c)| doc.world_bounds(*c).map(|b| (i, flip(b, vertical)))) {
|
||||
match rows.last_mut() {
|
||||
Some(row) if row.last().is_some_and(|(_, prev)| b.x0 >= prev.x0 - 0.5) => row.push((i, b)),
|
||||
Some(row) if row.last().is_some_and(|(_, prev)| b.x0 >= prev.x0 - 0.5 && !(b.x0 < prev.x1 - 0.5 && b.y0 >= prev.y1 - 0.5)) => {
|
||||
row.push((i, b))
|
||||
}
|
||||
_ => rows.push(vec![(i, b)]),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,3 +50,43 @@ fn local_samples_match_their_exports() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Layers of an instance copy keep their link to the layer they stand for when the file is
|
||||
/// opened, also after they were renamed in the copy (a name is no identity), and the override
|
||||
/// that a rename is stays an override.
|
||||
#[test]
|
||||
fn opened_fig_links_instance_layers_by_identity_not_by_name() {
|
||||
use base64::Engine as _;
|
||||
let mut s = Session::new();
|
||||
let make = |s: &mut Session, cmd: &str, p: serde_json::Value| s.execute(cmd, &p).unwrap()["id"].as_u64().map(figcraft_doc::NodeId).unwrap();
|
||||
let body = make(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 40, "height": 20, "name": "Inner"}));
|
||||
let label = make(&mut s, "text.create", json!({"text": "Label", "x": 2, "y": 2, "parent": body, "props": {"name": "Label"}}));
|
||||
let inner = make(&mut s, "component.create", json!({"ids": [body]}));
|
||||
let frame = make(&mut s, "frame.create", json!({"x": 100, "y": 0, "width": 200, "height": 60, "name": "Outer"}));
|
||||
let outer = make(&mut s, "component.create", json!({"ids": [frame]}));
|
||||
let slot = make(&mut s, "instance.create", json!({"component": inner, "x": 0, "y": 0, "parent": outer}));
|
||||
s.execute("node.set", &json!({"id": slot, "props": {"name": "Slot"}})).unwrap();
|
||||
let copy = make(&mut s, "instance.create", json!({"component": outer, "x": 0, "y": 200}));
|
||||
let copy_slot = s.doc().unwrap().node(copy).unwrap().children[0];
|
||||
let copy_label = s.doc().unwrap().node(copy_slot).unwrap().children[0];
|
||||
s.execute("node.set", &json!({"id": copy_slot, "props": {"name": "Renamed slot"}})).unwrap();
|
||||
s.execute("node.set", &json!({"id": copy_label, "props": {"name": "Renamed label"}})).unwrap();
|
||||
let _ = label;
|
||||
|
||||
let exported = s.execute("file.export", &json!({"format": "fig"})).unwrap();
|
||||
let path = std::env::temp_dir().join(format!("figcraft-slots-{}.fig", std::process::id()));
|
||||
std::fs::write(&path, base64::engine::general_purpose::STANDARD.decode(exported["data"].as_str().unwrap()).unwrap()).unwrap();
|
||||
let mut opened = Session::new();
|
||||
let result = opened.execute("file.open", &json!({"path": path.to_string_lossy()}));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
result.unwrap();
|
||||
let doc = opened.doc().unwrap();
|
||||
let outer = doc.nodes().find(|n| n.name == "Outer" && matches!(n.kind, NodeKind::Component { .. })).unwrap();
|
||||
let copy = doc.nodes().find(|n| n.name == "Outer" && matches!(n.kind, NodeKind::Instance { .. })).unwrap();
|
||||
let slot = doc.node(copy.children[0]).unwrap();
|
||||
let label = doc.node(slot.children[0]).unwrap();
|
||||
assert_eq!((slot.name.as_str(), label.name.as_str()), ("Renamed slot", "Renamed label"));
|
||||
assert_eq!(slot.source, Some(outer.children[0]));
|
||||
assert_eq!(label.source, Some(doc.node(outer.children[0]).unwrap().children[0]));
|
||||
assert!(slot.overrides.contains("name") && label.overrides.contains("name"));
|
||||
}
|
||||
@@ -77,3 +77,43 @@ fn leptos_wraps_the_markup_in_a_component_with_text_as_string_literals() {
|
||||
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"]);
|
||||
}
|
||||
|
||||
fn web_code(s: &mut Session, id: figcraft_doc::NodeId, language: &str, unit: &str) -> String {
|
||||
let out = s.execute("inspect.code", &json!({"id": id, "language": language, "unit": unit})).unwrap();
|
||||
out["code"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rem_preserves_text_in_web_markup() {
|
||||
let mut s = Session::new();
|
||||
let frame = id(s.execute("frame.create", &json!({"width": 200, "height": 100, "name": "Card"})).unwrap());
|
||||
s.execute("text.create", &json!({"text": "Tile 24px", "parent": frame, "name": "Label"})).unwrap();
|
||||
let mut changed = Vec::new();
|
||||
for language in ["html", "react", "leptos"] {
|
||||
let px = web_code(&mut s, frame, language, "px");
|
||||
let rem = web_code(&mut s, frame, language, "rem");
|
||||
assert!(px.contains("Tile 24px"), "{language}: px control: {px}");
|
||||
assert!(rem.contains("width: 12.5rem;"), "{language}: width: {rem}");
|
||||
assert!(rem.contains("font-size: 0.75rem;"), "{language}: font: {rem}");
|
||||
if !rem.contains("Tile 24px") {
|
||||
changed.push(language);
|
||||
}
|
||||
}
|
||||
assert!(changed.is_empty(), "text rewritten in {changed:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rem_preserves_text_property_default() {
|
||||
let mut s = Session::new();
|
||||
let frame = id(s.execute("frame.create", &json!({"width": 100, "height": 40, "name": "Chip"})).unwrap());
|
||||
let label = id(s.execute("text.create", &json!({"text": "24px", "parent": frame, "name": "Label"})).unwrap());
|
||||
let component = id(s.execute("component.create", &json!({"ids": [frame]})).unwrap());
|
||||
s.execute("component.addProperty", &json!({"id": component, "name": "Label", "type": "text", "default": "24px", "layer": label})).unwrap();
|
||||
for unit in ["px", "rem"] {
|
||||
let code = web_code(&mut s, component, "react", unit);
|
||||
assert!(code.contains("label = \"24px\""), "{unit}: changed default: {code}");
|
||||
if unit == "rem" {
|
||||
assert!(code.contains("width: 6.25rem;"), "rem still converts lengths: {code}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -360,6 +401,8 @@ impl DocState {
|
||||
doc.repair();
|
||||
}
|
||||
doc.link_layer_text_styles();
|
||||
cmd::styles::apply_paint_bindings_on_load(&mut doc);
|
||||
// Initial import settlement belongs to the saved baseline, not the user's first edit.
|
||||
let page = doc.current_page.filter(|p| doc.pages.contains(p)).or_else(|| doc.first_page()).unwrap_or(NodeId(0));
|
||||
let doc = Arc::new(doc);
|
||||
let mut st = Self {
|
||||
@@ -573,6 +616,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>,
|
||||
@@ -625,6 +671,7 @@ impl Default for Prefs {
|
||||
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(),
|
||||
@@ -1081,9 +1128,20 @@ 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
|
||||
@@ -1180,8 +1238,8 @@ pub fn changed_nodes(doc: &Document, before: &Document) -> Vec<NodeId> {
|
||||
pub fn normalize(doc: &mut Document, before: &Document) {
|
||||
// Only what was touched since the last normalisation (edited, added or removed).
|
||||
let mut touched: BTreeSet<NodeId> = doc.take_dirty();
|
||||
// A variable edit changes no layer directly, but bound layers must follow it.
|
||||
if touched.is_empty() && doc.variables == before.variables {
|
||||
// A variable or style edit changes no layer directly, but bound and linked layers must follow it.
|
||||
if touched.is_empty() && doc.variables == before.variables && doc.styles == before.styles {
|
||||
return;
|
||||
}
|
||||
let m = measure::TextMeasure;
|
||||
@@ -1406,6 +1464,8 @@ 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;
|
||||
@@ -1500,8 +1560,12 @@ mod invite_links_tests;
|
||||
#[cfg(test)]
|
||||
mod keymap_tests;
|
||||
#[cfg(test)]
|
||||
mod library_range_styles_tests;
|
||||
#[cfg(test)]
|
||||
mod library_variables_tests;
|
||||
#[cfg(test)]
|
||||
mod line_endings_tests;
|
||||
#[cfg(test)]
|
||||
mod live_access_tests;
|
||||
#[cfg(test)]
|
||||
mod live_docs_tests;
|
||||
@@ -1528,6 +1592,10 @@ mod open_issues_tests;
|
||||
#[cfg(test)]
|
||||
mod outline_options_tests;
|
||||
#[cfg(test)]
|
||||
mod page_copy_navigation_tests;
|
||||
#[cfg(test)]
|
||||
mod page_target_tests;
|
||||
#[cfg(test)]
|
||||
mod paint_reorder_tests;
|
||||
#[cfg(test)]
|
||||
mod paint_tests;
|
||||
|
||||
@@ -134,9 +134,12 @@ pub fn closure(doc: &Document, roots: &[NodeId]) -> Vec<NodeId> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The style ids a layer links to (fill, stroke, effect, grid, text).
|
||||
/// The style ids a layer links to (fill, stroke, effect, grid, text, and text styles on
|
||||
/// character ranges).
|
||||
pub(crate) fn style_links(n: &Node) -> impl Iterator<Item = &String> {
|
||||
[&n.styles.fill, &n.styles.stroke, &n.styles.effect, &n.styles.grid, &n.styles.text].into_iter().flatten()
|
||||
let chars =
|
||||
n.kind.text().into_iter().flat_map(|t| std::iter::once(&t.style).chain(t.runs.iter().map(|r| &r.style))).filter_map(|s| s.style_id.as_ref());
|
||||
[&n.styles.fill, &n.styles.stroke, &n.styles.effect, &n.styles.grid, &n.styles.text].into_iter().flatten().chain(chars)
|
||||
}
|
||||
|
||||
/// Style ids the layers under `roots` link to.
|
||||
@@ -582,11 +585,15 @@ fn local_key(lid: &str, key: &str, taken: impl Fn(&str) -> bool) -> String {
|
||||
}
|
||||
|
||||
/// Copy library styles `keys` into `doc` as linked remote styles (with `update`, copies older than
|
||||
/// the library's are refreshed). Returns library style id → local id.
|
||||
/// the library's are refreshed). The variables a written copy binds are imported too and its
|
||||
/// bindings point at their copies (never at this file's variables of the same ids). Returns library
|
||||
/// style id → local id.
|
||||
pub fn import_styles(doc: &mut Document, lib: &PublishedLibrary, keys: &BTreeSet<String>, update: bool) -> BTreeMap<String, String> {
|
||||
let lid = lib.manifest.id.clone();
|
||||
let mut out = BTreeMap::new();
|
||||
let mut hashes = BTreeSet::new();
|
||||
// Local ids of the copies written now, with the library style they copy.
|
||||
let mut written: Vec<(String, &Style)> = Vec::new();
|
||||
for key in keys {
|
||||
let Some(style) = lib.doc.styles.iter().find(|s| s.id == *key) else { continue };
|
||||
let version = lib.manifest.style(key).map_or(lib.manifest.version, |s| s.version);
|
||||
@@ -598,6 +605,7 @@ pub fn import_styles(doc: &mut Document, lib: &PublishedLibrary, keys: &BTreeSet
|
||||
{
|
||||
*slot = Style { id: r.local.clone(), ..style.clone() };
|
||||
set_record(&mut doc.libraries_mut().styles, ImportedStyle { version, ..r.clone() });
|
||||
written.push((r.local.clone(), style));
|
||||
}
|
||||
r.local
|
||||
}
|
||||
@@ -605,6 +613,7 @@ pub fn import_styles(doc: &mut Document, lib: &PublishedLibrary, keys: &BTreeSet
|
||||
let local = local_key(&lid, key, |l| doc.styles.iter().any(|s| s.id == l));
|
||||
doc.styles.push(Style { id: local.clone(), ..style.clone() });
|
||||
set_record(&mut doc.libraries_mut().styles, ImportedStyle { library: lid.clone(), key: key.clone(), local: local.clone(), version });
|
||||
written.push((local.clone(), style));
|
||||
local
|
||||
}
|
||||
};
|
||||
@@ -613,6 +622,17 @@ pub fn import_styles(doc: &mut Document, lib: &PublishedLibrary, keys: &BTreeSet
|
||||
}
|
||||
out.insert(key.clone(), local);
|
||||
}
|
||||
let bound: BTreeSet<VariableId> = written.iter().flat_map(|(_, s)| s.bound.values().cloned()).collect();
|
||||
if !bound.is_empty() {
|
||||
let vars = import_variables(doc, lib, &bound, &BTreeSet::new(), update);
|
||||
for (local, _) in &written {
|
||||
if let Some(slot) = doc.styles.iter_mut().find(|s| s.id == *local) {
|
||||
// Bindings to variables the library lacks are dropped (the published value stays).
|
||||
slot.bound =
|
||||
std::mem::take(&mut slot.bound).into_iter().filter_map(|(f, v)| vars.variables.get(&v).map(|l| (f, l.clone()))).collect();
|
||||
}
|
||||
}
|
||||
}
|
||||
copy_images(doc, &lib.doc, &hashes);
|
||||
out
|
||||
}
|
||||
@@ -1201,9 +1221,16 @@ pub fn check_read_only(cmd: &str, before: &Document, after: &Document) -> std::r
|
||||
if !variables_unchanged(before, after) {
|
||||
return Err(READ_ONLY.into());
|
||||
}
|
||||
let Some(store) = after.library_store().or_else(|| before.library_store()) else { return Ok(()) };
|
||||
let touched = |d: &Document, id: NodeId| d.contains(id) && d.page_of(id) == Some(store);
|
||||
if after.dirty().iter().any(|id| touched(before, *id) || touched(after, *id)) {
|
||||
let changed_remote = |id| {
|
||||
if !before.is_remote(id) && !after.is_remote(id) {
|
||||
return false;
|
||||
}
|
||||
match (before.node_arc(id), after.node_arc(id)) {
|
||||
(Some(old), Some(new)) => !Arc::ptr_eq(old, new),
|
||||
_ => true,
|
||||
}
|
||||
};
|
||||
if after.dirty().iter().any(|id| changed_remote(*id)) {
|
||||
return Err(READ_ONLY.into());
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//! Text styles linked on character ranges travel with components (publish and place).
|
||||
|
||||
use crate::Session;
|
||||
use figcraft_doc::NodeId;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
||||
s.execute(command, ¶ms).unwrap_or_else(|e| panic!("{command}: {e}"))
|
||||
}
|
||||
|
||||
fn node(value: Value) -> NodeId {
|
||||
NodeId(value["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
/// A component with "Hello" (chars 0..2 linked to a range style) and "World" (whole-layer style).
|
||||
fn fixture() -> (Session, NodeId, String, String) {
|
||||
let mut s = Session::new();
|
||||
let frame = node(run(&mut s, "frame.create", json!({"name":"Card", "width":100, "height":40})));
|
||||
let text = node(run(&mut s, "text.create", json!({"parent":frame, "text":"Hello"})));
|
||||
let range = run(&mut s, "style.create", json!({"type":"text", "name":"Emphasis", "value":{"fontSize":32}}))["id"].as_str().unwrap().to_owned();
|
||||
run(&mut s, "style.apply", json!({"id":text, "style":range, "start":0, "end":2}));
|
||||
let whole_text = node(run(&mut s, "text.create", json!({"parent":frame, "text":"World"})));
|
||||
let whole = run(&mut s, "style.create", json!({"type":"text", "name":"Whole", "value":{"fontSize":24}}))["id"].as_str().unwrap().to_owned();
|
||||
run(&mut s, "style.apply", json!({"id":whole_text, "style":whole}));
|
||||
let component = node(run(&mut s, "component.create", json!({"ids":[frame]})));
|
||||
let source = s.doc().unwrap().node(text).unwrap();
|
||||
let chars = source.kind.text().unwrap();
|
||||
assert!(source.styles.text.is_none());
|
||||
assert_eq!(chars.style_at(0).style_id.as_deref(), Some(range.as_str()));
|
||||
assert_eq!(chars.style_at(2).style_id, None);
|
||||
(s, component, range, whole)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_publish_keeps_range_text_style() {
|
||||
let (mut s, component, range, whole) = fixture();
|
||||
run(&mut s, "library.publish", json!({"include":[component]}));
|
||||
let snapshot = &s.doc().unwrap().published.as_ref().unwrap().doc;
|
||||
assert!(snapshot.style(&whole).is_some());
|
||||
assert!(snapshot.style(&range).is_some(), "character-range style missing from snapshot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_place_imports_range_text_style() {
|
||||
let (mut source, component, range, whole) = fixture();
|
||||
let publication = run(&mut source, "library.publish", json!({}));
|
||||
let library = source.doc().unwrap().published.as_ref().unwrap().clone();
|
||||
assert!(library.doc.style(&range).is_some());
|
||||
let mut target = Session::new();
|
||||
let path = "figcraft://library.test/range-styles";
|
||||
target.libraries.insert(path, Ok(library));
|
||||
run(&mut target, "library.add", json!({"path":path}));
|
||||
let instance = node(run(&mut target, "library.place", json!({"library":publication["id"], "component":component})));
|
||||
let doc = target.doc().unwrap();
|
||||
let children = &doc.node(instance).unwrap().children;
|
||||
let ranged = doc.node(children[0]).unwrap().kind.text().unwrap();
|
||||
let full = doc.node(children[1]).unwrap().kind.text().unwrap();
|
||||
let whole_record = doc.libraries.styles.iter().find(|r| r.key == whole).unwrap();
|
||||
assert_eq!(full.style_at(0).style_id.as_deref(), Some(whole_record.local.as_str()));
|
||||
assert_eq!(ranged.style_at(0).font_size, 32.0);
|
||||
assert!(ranged.style_at(2).style_id.is_none());
|
||||
let record = doc.libraries.styles.iter().find(|r| r.key == range);
|
||||
assert!(record.is_some(), "character-range style missing after placement");
|
||||
assert_eq!(ranged.style_at(0).style_id.as_deref(), record.map(|r| r.local.as_str()));
|
||||
assert!(doc.style(ranged.style_at(0).style_id.as_deref().unwrap()).is_some());
|
||||
}
|
||||
@@ -45,6 +45,38 @@ fn path_str(p: &std::path::Path) -> String {
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn dirty_library_document() -> (Document, NodeId) {
|
||||
let mut doc = Document::new("Dirty library cache");
|
||||
let store = doc.ensure_library_store();
|
||||
let remote = doc.alloc_id();
|
||||
doc.insert(Node::new(remote, NodeKind::Frame, "Remote", 10.0, 10.0), store, None).unwrap();
|
||||
(doc, remote)
|
||||
}
|
||||
|
||||
/// An imported cache is dirty until the first edit; that alone is no edit of the library copies.
|
||||
#[test]
|
||||
fn read_only_allows_unchanged_predirty_remote_nodes() {
|
||||
let (before, remote) = dirty_library_document();
|
||||
assert!(before.dirty().contains(&remote));
|
||||
|
||||
let mut after = before.clone();
|
||||
after.add_page("Unrelated page");
|
||||
assert!(after.dirty().contains(&remote), "the imported remote node remains in the pending dirty set");
|
||||
check_read_only("page.add", &before, &after).unwrap();
|
||||
|
||||
let mut edited = before.clone();
|
||||
edited.get_mut(remote).unwrap().opacity = 0.5;
|
||||
assert_eq!(check_read_only("node.set", &before, &edited), Err(READ_ONLY.into()));
|
||||
|
||||
let mut moved = before.clone();
|
||||
moved.reparent(remote, before.first_page().unwrap(), None).unwrap();
|
||||
assert_eq!(check_read_only("node.reparent", &before, &moved), Err(READ_ONLY.into()));
|
||||
|
||||
let mut deleted = before.clone();
|
||||
deleted.remove(remote).unwrap();
|
||||
assert_eq!(check_read_only("edit.delete", &before, &deleted), Err(READ_ONLY.into()));
|
||||
}
|
||||
|
||||
/// The library file's layers.
|
||||
struct Kit {
|
||||
button: NodeId,
|
||||
@@ -179,8 +211,16 @@ fn add_place_and_update_keep_overrides() {
|
||||
NodeKind::Instance { component, .. } => component,
|
||||
_ => panic!("not an instance"),
|
||||
};
|
||||
let unchanged = s.doc().unwrap().clone();
|
||||
let e = s.execute("node.set", &json!({"id": remote_button, "props": {"fill": "#ff0000"}})).unwrap_err();
|
||||
assert!(e.to_string().contains("can't be edited"), "{e}");
|
||||
assert_eq!(s.doc().unwrap(), &unchanged, "a refused remote edit rolls back");
|
||||
for (cmd, args) in
|
||||
[("node.reparent", json!({"ids": [remote_button], "parent": s.page().unwrap()})), ("edit.delete", json!({"ids": [remote_button]}))]
|
||||
{
|
||||
assert!(s.execute(cmd, &args).is_err(), "{cmd} must preserve read-only copies");
|
||||
assert_eq!(s.doc().unwrap(), &unchanged, "refused {cmd} rolls back");
|
||||
}
|
||||
let style = s.doc().unwrap().styles[0].id.clone();
|
||||
assert!(s.execute("style.update", &json!({"style": style, "name": "Mine"})).is_err());
|
||||
assert!(s.execute("style.delete", &json!({"style": style})).is_err());
|
||||
@@ -281,10 +321,16 @@ fn library_styles_apply_and_import_with_pages() {
|
||||
let copy = doc.nodes().find(|n| n.name == node(&s, inst).name && matches!(n.kind, NodeKind::Instance { .. }) && !doc.is_remote(n.id)).unwrap();
|
||||
let NodeKind::Instance { component, .. } = copy.kind else { panic!("not an instance") };
|
||||
assert!(doc.is_remote(component));
|
||||
let store_roots = |s: &Session| {
|
||||
let doc = s.doc().unwrap();
|
||||
doc.library_store().and_then(|id| doc.node(id)).map_or(0, |n| n.children.len())
|
||||
};
|
||||
assert_eq!(store_roots(&t), 2, "recorded copies are not copied twice");
|
||||
// A second import shares the copies it already has.
|
||||
ex(&mut t, "file.import", json!({"path": path}));
|
||||
healthy(&t);
|
||||
assert_eq!(t.doc().unwrap().libraries.components.len(), 2);
|
||||
assert_eq!(store_roots(&t), 2);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
|
||||
@@ -314,3 +314,33 @@ fn library_variable_commands_fail_cleanly() {
|
||||
}
|
||||
assert!(s.doc().unwrap().libraries.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_style_bindings_ignore_unrelated_local_variables() {
|
||||
let d = dir("style-bound");
|
||||
let path = path_str(&d.join("lib.figcraft"));
|
||||
let mut lib = Session::new();
|
||||
ex(&mut lib, "variable.createCollection", json!({"name": "Tokens"}));
|
||||
let var = ex(&mut lib, "variable.create", json!({"collection": "Tokens", "name": "Brand", "type": "color", "value": "#0000ff"}))["id"].clone();
|
||||
let style = ex(&mut lib, "style.create", json!({"type": "paint", "name": "Brand", "value": "#0000ff"}))["id"].clone();
|
||||
ex(&mut lib, "style.bind", json!({"style": style, "field": "fill", "variable": var}));
|
||||
let lid = publish_to(&mut lib, &path);
|
||||
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "Local"}));
|
||||
let local = ex(&mut s, "variable.create", json!({"collection": "Local", "name": "Unrelated", "type": "color", "value": "#ff0000"}))["id"].clone();
|
||||
assert_eq!(local, var, "the ids must collide for this test");
|
||||
let r = idv(&ex(&mut s, "shape.rectangle", json!({"width": 40, "height": 40})));
|
||||
ex(&mut s, "library.add", json!({"path": path}));
|
||||
ex(&mut s, "library.applyStyle", json!({"library": lid, "style": "Brand", "id": r}));
|
||||
assert_eq!(fill(&s, r), "0000FF");
|
||||
healthy(&s);
|
||||
|
||||
// An updated style follows the library's variable, not the local one.
|
||||
ex(&mut lib, "variable.set", json!({"variable": "Brand", "value": "#00ffff"}));
|
||||
republish(&mut lib);
|
||||
ex(&mut s, "library.acceptUpdates", json!({}));
|
||||
assert_eq!(fill(&s, r), "00FFFF");
|
||||
healthy(&s);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Text set by a command stores every line ending as LF, like editor insertion does, so layout
|
||||
//! sees two lines and no stray glyph for CRLF, bare CR, U+2028 or U+2029.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::components::tests::{card, chars, copy_of, ex, idv};
|
||||
use crate::params::id_of;
|
||||
|
||||
const INPUTS: [&str; 5] = ["a\nb", "a\r\nb", "a\rb", "a\u{2028}b", "a\u{2029}b"];
|
||||
|
||||
#[test]
|
||||
fn text_set_normalizes_line_endings() {
|
||||
let mut s = Session::new();
|
||||
let created = s.execute("text.create", &json!({"text": "", "x": 0, "y": 0})).unwrap();
|
||||
let id = id_of(&created["id"]).unwrap();
|
||||
for input in INPUTS {
|
||||
s.execute("text.set", &json!({"id": id, "characters": input})).unwrap();
|
||||
let t = s.doc().unwrap().node(id).unwrap().kind.text().unwrap().clone();
|
||||
assert_eq!(t.characters, "a\nb", "text.set {input:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_create_normalizes_line_endings() {
|
||||
let mut s = Session::new();
|
||||
for input in INPUTS {
|
||||
let created = s.execute("text.create", &json!({"text": input, "x": 0, "y": 0})).unwrap();
|
||||
let id = id_of(&created["id"]).unwrap();
|
||||
let t = s.doc().unwrap().node(id).unwrap().kind.text().unwrap().clone();
|
||||
assert_eq!(t.characters, "a\nb", "text.create {input:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_replace_normalizes_line_endings() {
|
||||
for input in INPUTS {
|
||||
let mut s = Session::new();
|
||||
let id = idv(&ex(&mut s, "text.create", json!({"text": "x", "x": 0, "y": 0})));
|
||||
ex(&mut s, "edit.findReplace", json!({"find": "x", "replace": input}));
|
||||
assert_eq!(chars(&s, id), "a\nb", "edit.findReplace {input:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_property_values_normalize_line_endings() {
|
||||
let mut s = Session::new();
|
||||
let (c, _bg, title, _dot) = card(&mut s);
|
||||
ex(&mut s, "component.addProperty", json!({"id": c, "name": "Title", "type": "text", "layer": title}));
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0})));
|
||||
let copy = copy_of(&s, i, title);
|
||||
for input in INPUTS {
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": input}}));
|
||||
assert_eq!(chars(&s, copy), "a\nb", "instance.setProperties {input:?}");
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -49,3 +49,16 @@ fn an_intact_file_opens_without_a_message() {
|
||||
assert!(s.message.is_none());
|
||||
let _ = std::fs::remove_dir_all(path.parent().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_close_rejects_a_bad_index_instead_of_closing_another_document() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let docs = s.docs.len();
|
||||
for index in [json!(-1), json!(1.5), json!(99)] {
|
||||
let r = s.execute("file.close", &json!({"index": index, "force": true}));
|
||||
assert!(r.is_err(), "index {index} must be rejected, got {r:?}");
|
||||
assert_eq!(s.docs.len(), docs, "index {index} closed a document");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//! Duplicating a page copies its prototype connections: navigation between two frames that are both
|
||||
//! on the page leads between the copies, while a destination outside the copied contents is kept.
|
||||
|
||||
use figcraft_doc::NodeId;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
fn frame(s: &mut Session, x: f64) -> NodeId {
|
||||
id_of(&ex(s, "frame.create", json!({"x": x, "y": 0, "width": 100, "height": 100}))["id"]).unwrap()
|
||||
}
|
||||
|
||||
fn dest(s: &Session, id: NodeId) -> NodeId {
|
||||
s.doc().unwrap().node(id).unwrap().interactions[0].actions[0].dest().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_copy_navigation_uses_the_copied_destination() {
|
||||
let mut s = Session::new();
|
||||
let first = frame(&mut s, 0.0);
|
||||
let second = frame(&mut s, 200.0);
|
||||
ex(&mut s, "proto.interaction.add", json!({"id": first, "action": {"type": "navigate", "dest": second}}));
|
||||
let copy = id_of(&ex(&mut s, "page.duplicate", json!({}))["id"]).unwrap();
|
||||
let children = s.doc().unwrap().node(copy).unwrap().children.clone();
|
||||
assert_eq!(children.len(), 2);
|
||||
assert_eq!(dest(&s, children[0]), children[1]);
|
||||
let visited = Default::default();
|
||||
assert_eq!(crate::proto_run::navigate_target(s.doc().unwrap(), dest(&s, children[0]), &visited), Some(children[1]));
|
||||
assert_eq!(dest(&s, first), second, "the original keeps its destination");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_copy_keeps_an_outside_destination() {
|
||||
let mut s = Session::new();
|
||||
let first = frame(&mut s, 0.0);
|
||||
let second = frame(&mut s, 200.0);
|
||||
ex(&mut s, "proto.interaction.add", json!({"id": first, "action": {"type": "navigate", "dest": second}}));
|
||||
ex(&mut s, "select.set", json!({"ids": [first]}));
|
||||
ex(&mut s, "edit.duplicate", json!({}));
|
||||
let copy = s.selection()[0];
|
||||
assert_ne!(copy, first);
|
||||
assert_eq!(dest(&s, copy), second);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//! Page commands act on pages: a layer id (a rectangle, a frame) is refused before anything about
|
||||
//! the layer changes, as `page.select` already does.
|
||||
|
||||
use figcraft_doc::{NodeId, NodeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
fn id_of(v: &Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn name(s: &Session, id: NodeId) -> String {
|
||||
s.doc().unwrap().get(id).unwrap().name.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_commands_reject_a_rectangle() {
|
||||
let mut s = Session::new();
|
||||
let page = s.page().unwrap();
|
||||
let r = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})));
|
||||
let before = name(&s, r);
|
||||
assert!(s.execute("page.select", &json!({"id": r})).is_err());
|
||||
assert!(s.execute("page.rename", &json!({"id": r, "name": "Not a page"})).is_err(), "rename refuses a layer");
|
||||
assert_eq!(name(&s, r), before, "the layer keeps its name");
|
||||
assert!(s.execute("page.setBackground", &json!({"id": r, "color": "#123456"})).is_err(), "background refuses a layer");
|
||||
assert!(s.execute("page.delete", &json!({"id": r})).is_err(), "delete refuses a layer");
|
||||
assert!(s.doc().unwrap().contains(r), "the layer is still there");
|
||||
assert!(s.doc().unwrap().contains(page));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_delete_keeps_a_frame_and_its_child() {
|
||||
let mut s = Session::new();
|
||||
let f = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
|
||||
let c = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 5, "y": 5, "width": 10, "height": 10, "parent": f})));
|
||||
assert_eq!(s.doc().unwrap().get(c).unwrap().parent, Some(f));
|
||||
assert!(s.execute("page.delete", &json!({"id": f})).is_err());
|
||||
assert!(s.doc().unwrap().contains(f) && s.doc().unwrap().contains(c), "the frame and its child remain");
|
||||
assert!(s.execute("page.rename", &json!({"id": f, "name": "Not a page"})).is_err());
|
||||
assert_ne!(name(&s, f), "Not a page");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_commands_still_work_on_pages() {
|
||||
let mut s = Session::new();
|
||||
let first = s.page().unwrap();
|
||||
let second = id_of(&ex(&mut s, "page.add", json!({})));
|
||||
ex(&mut s, "page.rename", json!({"id": second, "name": "Flows"}));
|
||||
assert_eq!(name(&s, second), "Flows");
|
||||
ex(&mut s, "page.setBackground", json!({"id": second, "color": "#123456"}));
|
||||
match s.doc().unwrap().get(second).unwrap().kind {
|
||||
NodeKind::Page { background, .. } => assert_eq!(background.to_css_hex().to_uppercase(), "#123456"),
|
||||
_ => panic!("not a page"),
|
||||
}
|
||||
ex(&mut s, "page.delete", json!({"id": second}));
|
||||
assert!(!s.doc().unwrap().contains(second));
|
||||
assert!(s.execute("page.delete", &json!({"id": first})).is_err(), "the last ordinary page stays");
|
||||
}
|
||||
@@ -425,3 +425,25 @@ fn duplicating_a_layer_named_past_the_counter_does_not_overflow() {
|
||||
assert_eq!(name(&s, dup), "Layer 18446744073709551615 2");
|
||||
assert!(s.doc().unwrap().validate().is_empty(), "the document still checks out");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_navigation_skips_dividers() {
|
||||
// A trailing divider: Next page stays on the ordinary page.
|
||||
let mut s = Session::new();
|
||||
let first = s.page().unwrap();
|
||||
let div = id(&ex(&mut s, "page.add", json!({"name": "-"})));
|
||||
assert!(s.execute("page.select", &json!({"id": div})).is_err(), "a divider isn't a page to open");
|
||||
let r = ex(&mut s, "view.nextPage", json!({}));
|
||||
assert_eq!(s.page().unwrap(), first, "Next page doesn't open a trailing divider");
|
||||
assert_eq!(id_of(&r["page"]), Some(first), "the result names the current page");
|
||||
// A middle divider: Previous page crosses it, Next page crosses it back.
|
||||
let later = id(&ex(&mut s, "page.add", json!({"name": "Later"})));
|
||||
assert_eq!(s.page().unwrap(), later);
|
||||
let r = ex(&mut s, "view.prevPage", json!({}));
|
||||
assert_eq!(s.page().unwrap(), first, "Previous page crosses a divider");
|
||||
assert_eq!(id_of(&r["page"]), Some(first));
|
||||
ex(&mut s, "view.nextPage", json!({}));
|
||||
assert_eq!(s.page().unwrap(), later, "Next page crosses a divider");
|
||||
ex(&mut s, "view.nextPage", json!({}));
|
||||
assert_eq!(s.page().unwrap(), later, "Next page stops on the last page");
|
||||
}
|
||||
@@ -111,6 +111,10 @@ const DENIED: &[&str] = &[
|
||||
"edit.redo",
|
||||
"edit.repeat",
|
||||
"command.batch",
|
||||
// Network sessions, online files and their recovery copies on disk.
|
||||
"collab.",
|
||||
// Opens files in new windows.
|
||||
"window.",
|
||||
"view.theme",
|
||||
];
|
||||
/// Read-only exceptions to [`DENIED`].
|
||||
@@ -128,6 +132,17 @@ fn refusal(m: &Manifest, command: &str, params: &Value) -> Option<String> {
|
||||
if command == "image.place" && get(params, "path").is_some() {
|
||||
return Some("plugins can't read files: pass the image as `data`".into());
|
||||
}
|
||||
// `path` here is a file on disk (elsewhere it is a vector path or an action's place).
|
||||
if matches!(command, "variable.export" | "codeConnect.export" | "component.playground") && get(params, "path").is_some() {
|
||||
return Some("plugins can't write files: leave out `path` to get the result back".into());
|
||||
}
|
||||
if command == "codeConnect.import" && get(params, "path").is_some() {
|
||||
return Some("plugins can't read files: pass the connections as `json`".into());
|
||||
}
|
||||
// Variables JSON may be given as a path: only objects and JSON text for plugins.
|
||||
if command == "variable.import" && get(params, "variables").and_then(Value::as_str).is_some_and(|t| !t.trim_start().starts_with('{')) {
|
||||
return Some("plugins can't read files: pass the variables JSON itself".into());
|
||||
}
|
||||
if !m.permissions.allows(command) {
|
||||
return Some(format!("`{command}` isn't in the commands {} asked permission for", m.name));
|
||||
}
|
||||
|
||||
@@ -169,6 +169,13 @@ fn files_dialogs_history_and_plugins_are_off_limits() {
|
||||
("plugin.remove", json!({"id": "org.test.deny"})),
|
||||
("edit.undo", json!({})),
|
||||
("command.batch", json!({"commands": [{"command": "file.saveAs", "params": {"path": target}}]})),
|
||||
("variable.export", json!({"format": "css", "path": target})),
|
||||
("codeConnect.export", json!({"path": target})),
|
||||
("codeConnect.import", json!({"path": "/etc/passwd"})),
|
||||
("component.playground", json!({"id": a, "path": target})),
|
||||
("variable.import", json!({"variables": "/etc/passwd"})),
|
||||
("collab.recoverFile", json!({"path": "copy.figcraft", "file": "x", "session": 1, "client": 1, "seq": 0})),
|
||||
("window.new", json!({"path": "C:/Windows/win.ini"})),
|
||||
("doc.switch", json!({"index": 0})),
|
||||
("pref.keepTool", json!({})),
|
||||
// Without a name it would open inline renaming in the layers panel: a UI request.
|
||||
|
||||
@@ -322,7 +322,7 @@ fn apply(s: &mut Session, cmd: &str, p: &Value, merge: bool) -> Result<Value> {
|
||||
let whole_shape =
|
||||
touched.len() == 1 && a.regions.iter().enumerate().all(|(i, r)| pick.contains(&i) || !r.sources.iter().any(|x| touched.contains(x)));
|
||||
if merge && whole_shape {
|
||||
return Ok(json!({"id": null, "changed": [], "removed": [], "regions": pick}));
|
||||
return Ok(json!({"id": null, "changed": [], "removed": [], "outlines": [], "regions": pick}));
|
||||
}
|
||||
let doc = s.doc_mut()?;
|
||||
let created = if merge {
|
||||
|
||||
@@ -66,7 +66,8 @@ pub fn large_document(layers: usize) -> DocResult<(Document, NodeId)> {
|
||||
made += 4;
|
||||
}
|
||||
// A card group (5 layers).
|
||||
if budget(made) >= 5 {
|
||||
// The card and the instances leave one layer for the grid, which starts with a rectangle.
|
||||
if budget(made) > 5 {
|
||||
let card = doc.create(screen, NodeKind::Group, "Card", 12.0, 52.0, 160.0, 64.0)?;
|
||||
// Children are in the group's space; normalising fits the group around them.
|
||||
let cbg = doc.create(card, NodeKind::Rectangle, "Card background", 0.0, 0.0, 160.0, 64.0)?;
|
||||
@@ -78,7 +79,7 @@ pub fn large_document(layers: usize) -> DocResult<(Document, NodeId)> {
|
||||
}
|
||||
// Three button instances (3 layers each).
|
||||
for k in 0..3 {
|
||||
if budget(made) < 3 {
|
||||
if budget(made) < 3 + 1 {
|
||||
break;
|
||||
}
|
||||
crate::components::create_instance(&mut doc, button, screen, 12.0 + k as f64 * 108.0, SCREEN_H - 44.0)?;
|
||||
@@ -89,12 +90,14 @@ pub fn large_document(layers: usize) -> DocResult<(Document, NodeId)> {
|
||||
for k in 0..rest {
|
||||
let (gx, gy) = (184.0 + (k % 8) as f64 * 21.0, 52.0 + (k / 8) as f64 * 34.0);
|
||||
let hue = ((s * PER_SCREEN + k) % 360) as f32 / 360.0;
|
||||
let id = match k % 5 {
|
||||
// The grid starts with a rectangle so even the smallest workload has one.
|
||||
let kind = (k + 2) % 5;
|
||||
let id = match kind {
|
||||
0 => doc.create(screen, text(&format!("Item {k}")), "Text", gx, gy, 20.0, 14.0)?,
|
||||
1 | 3 => doc.create(screen, ellipse(), "Ellipse", gx, gy, 18.0, 18.0)?,
|
||||
_ => doc.create(screen, NodeKind::Rectangle, "Rectangle", gx, gy, 18.0, 18.0)?,
|
||||
};
|
||||
if k % 5 != 0 {
|
||||
if kind != 0 {
|
||||
fill(&mut doc, id, Color::new(hue, 0.5, 1.0 - hue, 1.0));
|
||||
if let Some(n) = doc.node_mut(id) {
|
||||
n.corners.radii = [3.0; 4];
|
||||
@@ -124,4 +127,16 @@ mod tests {
|
||||
assert!(doc.page_content_bounds(page).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_documents_hold_the_benchmark_targets() {
|
||||
for n in [10, 11, 12, 13, 14, 15] {
|
||||
let (doc, page) = super::large_document(n).unwrap();
|
||||
let nodes: Vec<_> = doc.subtree(page).into_iter().filter_map(|id| doc.node(id)).collect();
|
||||
let has = |f: &dyn Fn(&figcraft_doc::Node) -> bool| nodes.iter().any(|node| f(node));
|
||||
assert!(has(&|x| matches!(x.kind, figcraft_doc::NodeKind::Rectangle) && x.name == "Rectangle"), "{n}: no rectangle");
|
||||
assert!(has(&|x| x.name == "Title"), "{n}: no title");
|
||||
assert!(has(&|x| matches!(x.kind, figcraft_doc::NodeKind::Component { .. })), "{n}: no component");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -38,16 +38,17 @@ pub fn end(s: &mut Session) -> Result<()> {
|
||||
let _ = doc.remove(te.id);
|
||||
s.ds_mut()?.selection.clear();
|
||||
}
|
||||
s.commit_interaction()?;
|
||||
if empty && te.created {
|
||||
// Nothing to undo for a text box that was never filled.
|
||||
// Nothing to undo for a text box that was never filled. The gesture snapshot was taken
|
||||
// after the layer was added, so it still holds it: re-base it on the document without the
|
||||
// layer, and committing then records no step (and leaves the redo stack alone).
|
||||
let ds = s.ds_mut()?;
|
||||
if let Some(last) = ds.history.undo.last()
|
||||
&& *last.doc == *ds.doc
|
||||
{
|
||||
ds.history.undo.pop();
|
||||
let doc = ds.doc.clone();
|
||||
if let Some(it) = ds.interaction.as_mut() {
|
||||
it.doc = doc;
|
||||
}
|
||||
}
|
||||
s.commit_interaction()?;
|
||||
let ds = s.ds_mut()?;
|
||||
ds.revision += 1;
|
||||
Ok(())
|
||||
@@ -82,12 +83,7 @@ fn edit(s: &mut Session, f: impl FnOnce(&mut TextData, &mut TextEdit)) -> Result
|
||||
/// Type (or paste) `text` replacing the selection. Line endings are normalised and other control
|
||||
/// characters dropped, so pasted plain text from any platform lands cleanly.
|
||||
pub fn insert(s: &mut Session, text: &str) -> Result<()> {
|
||||
let text: String = text
|
||||
.replace("\r\n", "\n")
|
||||
.replace(['\r', '\u{2028}', '\u{2029}'], "\n")
|
||||
.chars()
|
||||
.filter(|c| !c.is_control() || matches!(c, '\n' | '\t'))
|
||||
.collect();
|
||||
let text: String = crate::cmd::props::normalize_line_endings(text).chars().filter(|c| !c.is_control() || matches!(c, '\n' | '\t')).collect();
|
||||
if text.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -146,9 +142,15 @@ fn list_key(s: &mut Session, key: &str, shift: bool) -> Result<bool> {
|
||||
}
|
||||
|
||||
/// Cmd/Ctrl+] and Cmd/Ctrl+[ while editing: the selected list items a level deeper or out, like
|
||||
/// Tab and Shift+Tab. Returns whether anything changed (text that isn't a list stays as it is).
|
||||
/// Tab and Shift+Tab. Returns whether anything changed (text that isn't a list and items already
|
||||
/// at the deepest level stay as they are).
|
||||
pub fn list_indent(s: &mut Session, out: bool) -> Result<bool> {
|
||||
list_key(s, "Tab", out)
|
||||
let Some(id) = s.ds()?.text_edit.as_ref().map(|te| te.id) else { return Ok(false) };
|
||||
let before = text_of(s, id)?;
|
||||
if !list_key(s, "Tab", out)? {
|
||||
return Ok(false);
|
||||
}
|
||||
Ok(text_of(s, id)? != before)
|
||||
}
|
||||
|
||||
/// The kind of word char `i` belongs to: 0 for spaces and punctuation, else its script, so a word
|
||||
|
||||
@@ -182,3 +182,49 @@ fn text_drawn_on_a_dark_page_is_white_and_on_a_light_page_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)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_of_a_page_keeps_its_flows_prototype_settings_and_guides() {
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100}));
|
||||
let start = ex(&mut s, "frame.create", json!({"x": 200, "y": 0, "width": 100, "height": 100}))["id"].clone();
|
||||
ex(&mut s, "proto.flow.add", json!({"start": start, "name": "Checkout"}));
|
||||
ex(&mut s, "proto.settings", json!({"device": "iPhone 16", "responsive": true}));
|
||||
ex(&mut s, "guide.add", json!({"vertical": true, "offset": 25}));
|
||||
ex(&mut s, "select.none", json!({}));
|
||||
let source = s.page().unwrap();
|
||||
let copy = id_of(&ex(&mut s, "page.duplicate", json!({}))["id"]).unwrap();
|
||||
let frames = s.doc().unwrap().node(copy).unwrap().children.clone();
|
||||
let state = |s: &Session, id: NodeId| match &s.doc().unwrap().node(id).unwrap().kind {
|
||||
NodeKind::Page { flows, prototype, guides, .. } => (flows.clone(), prototype.clone(), guides.clone()),
|
||||
_ => panic!("expected a page"),
|
||||
};
|
||||
let (flows, prototype, guides) = state(&s, copy);
|
||||
let (src_flows, src_prototype, src_guides) = state(&s, source);
|
||||
assert_eq!(flows.len(), 1);
|
||||
assert_eq!(flows[0].name, "Checkout");
|
||||
assert_eq!(flows[0].start, frames[1], "the flow starts at the copied frame");
|
||||
assert_eq!(src_flows[0].start, id_of(&start).unwrap(), "the source keeps its own");
|
||||
assert_eq!(prototype, src_prototype);
|
||||
assert_eq!(prototype.device, "iPhone 16");
|
||||
assert!(prototype.responsive);
|
||||
assert_eq!(guides, src_guides);
|
||||
assert_eq!(guides.len(), 1);
|
||||
assert_eq!(ex(&mut s, "proto.present", json!({}))["start"].as_u64(), Some(frames[1].0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_of_a_page_keeps_a_flow_that_starts_in_a_section() {
|
||||
let mut s = Session::new();
|
||||
let start = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100}))["id"]).unwrap();
|
||||
ex(&mut s, "object.wrapInSection", json!({"ids": [start]}));
|
||||
ex(&mut s, "proto.flow.add", json!({"start": start, "name": "Onboarding"}));
|
||||
let copy = id_of(&ex(&mut s, "page.duplicate", json!({}))["id"]).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let section = doc.node(copy).unwrap().children[0];
|
||||
let frame = doc.node(section).unwrap().children[0];
|
||||
match &doc.node(copy).unwrap().kind {
|
||||
NodeKind::Page { flows, .. } => assert_eq!(flows.iter().map(|f| (f.name.as_str(), f.start)).collect::<Vec<_>>(), vec![("Onboarding", frame)]),
|
||||
_ => panic!("expected a page"),
|
||||
}
|
||||
}
|
||||
@@ -376,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,
|
||||
},
|
||||
@@ -1400,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;
|
||||
@@ -1444,7 +1456,8 @@ impl Session {
|
||||
}
|
||||
Gesture::Vector(_) | Gesture::ShapeBuild { .. } | Gesture::Origin => Ok(()),
|
||||
Gesture::PenDrag { anchor } => {
|
||||
let out = ev.p() - *anchor;
|
||||
// A click's wobble pulls out no handle.
|
||||
let out = if within_slop(ev, *anchor) { Vec2::ZERO } else { ev.p() - *anchor };
|
||||
if let Some(pen) = self.tool.pen.as_mut()
|
||||
&& let Some(last) = pen.points.last_mut()
|
||||
{
|
||||
@@ -1818,7 +1831,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()?,
|
||||
}
|
||||
@@ -1844,9 +1857,10 @@ impl Session {
|
||||
self.widget_click(ev.p(), ev.tol() * 2.0)?;
|
||||
return Ok(());
|
||||
}
|
||||
// The second press began a move or a resize: a double-click isn't one.
|
||||
if matches!(self.tool.gesture, Gesture::Move { .. } | Gesture::Resize { .. }) {
|
||||
// The second press began a move, a resize, a shape or a marquee: a double-click isn't one.
|
||||
if matches!(self.tool.gesture, Gesture::Move { .. } | Gesture::Resize { .. } | Gesture::Draw { .. } | Gesture::Marquee { .. }) {
|
||||
self.tool.gesture = Gesture::None;
|
||||
self.tool.overlay.marquee = None;
|
||||
self.cancel_interaction()?;
|
||||
}
|
||||
let Some(leaf) = self.hit(&doc, page, ev.p(), ev.tol() * 2.0) else { return Ok(()) };
|
||||
@@ -1943,15 +1957,39 @@ impl Session {
|
||||
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();
|
||||
|
||||
@@ -5,6 +5,79 @@ use serde_json::json;
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
|
||||
fn stale_bound_color_document() -> (figcraft_doc::Document, figcraft_doc::NodeId) {
|
||||
let mut s = Session::new();
|
||||
let id = id_of(&s.execute("shape.rectangle", &json!({"x": 4000, "y": 4000, "width": 23, "height": 17})).unwrap()["id"]).unwrap();
|
||||
let page = s.doc().unwrap().first_page().unwrap();
|
||||
s.execute("variable.createCollection", &json!({"name": "Theme", "modes": ["Light", "Dark"]})).unwrap();
|
||||
s.execute("variable.create", &json!({"collection": "Theme", "name": "surface", "type": "color", "value": "#FFFFFF"})).unwrap();
|
||||
s.execute("variable.create", &json!({"collection": "Theme", "name": "size", "type": "number", "value": 23})).unwrap();
|
||||
s.execute("variable.set", &json!({"variable": "surface", "mode": "Dark", "value": "#202124"})).unwrap();
|
||||
s.execute("variable.set", &json!({"variable": "size", "mode": "Dark", "value": 99})).unwrap();
|
||||
s.execute("variable.bind", &json!({"ids": [id], "field": "fill", "variable": "surface"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"ids": [id], "field": "width", "variable": "size"})).unwrap();
|
||||
s.execute("variable.setMode", &json!({"collection": "Theme", "mode": "Dark"})).unwrap();
|
||||
let mut doc = s.doc().unwrap().clone();
|
||||
let node = doc.get_mut(id).unwrap();
|
||||
node.name = "load-binding-sentinel".into();
|
||||
node.fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::WHITE)];
|
||||
node.width = 5.0;
|
||||
node.source = Some(page);
|
||||
node.overrides.insert("sentinel".into());
|
||||
doc.take_dirty();
|
||||
(doc, id)
|
||||
}
|
||||
|
||||
fn assert_dark_bound_color(doc: &figcraft_doc::Document, transform: figcraft_doc::geom::Affine) -> figcraft_doc::NodeId {
|
||||
let node = doc.nodes().find(|n| n.name == "load-binding-sentinel").unwrap();
|
||||
assert_eq!(node.fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()), Some("202124".into()));
|
||||
assert_eq!((node.transform, node.width, node.height), (transform, 5.0, 17.0));
|
||||
assert!(node.bound.contains_key("fills/0/color") || node.bound.contains_key("fill"));
|
||||
node.id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_color_bindings_settle_in_constructor_and_loaders() {
|
||||
let (stale, _id) = stale_bound_color_document();
|
||||
let name_transform = stale.nodes().find(|n| n.name == "load-binding-sentinel").unwrap().transform;
|
||||
|
||||
let mut state = Session::with_document(stale.clone());
|
||||
let id = assert_dark_bound_color(state.doc().unwrap(), name_transform);
|
||||
assert_eq!(state.doc().unwrap().node(id).unwrap().source, stale.first_page());
|
||||
assert!(state.doc().unwrap().node(id).unwrap().overrides.contains("sentinel"));
|
||||
assert!(!state.ds().unwrap().is_dirty());
|
||||
assert!(state.doc().unwrap().dirty().is_empty());
|
||||
assert_eq!(state.undo_depth(), (0, 0));
|
||||
state.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 2, "height": 2})).unwrap();
|
||||
assert_dark_bound_color(state.doc().unwrap(), name_transform);
|
||||
|
||||
let native = figcraft_format::save_bytes(&stale, None).unwrap();
|
||||
let (loaded, _) = crate::cmd::file::load_bytes(&native, "bindings.figcraft").unwrap();
|
||||
// A native file settles when it opens as a document, not in the loader.
|
||||
let opened = Session::with_document(loaded);
|
||||
let loaded = opened.doc().unwrap();
|
||||
let loaded_id = assert_dark_bound_color(loaded, name_transform);
|
||||
assert_eq!(loaded_id, id);
|
||||
assert_eq!(loaded.node(id).unwrap().source, stale.first_page());
|
||||
assert!(loaded.node(id).unwrap().overrides.contains("sentinel"));
|
||||
assert!(loaded.dirty().is_empty());
|
||||
|
||||
let (fig, _) = figcraft_format::fig::export(&stale, None).unwrap();
|
||||
let (loaded, _) = crate::cmd::file::load_bytes(&fig, "bindings.fig").unwrap();
|
||||
assert_dark_bound_color(&loaded, name_transform);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_paint_settlement_preserves_existing_dirty_nodes() {
|
||||
let (mut doc, _) = stale_bound_color_document();
|
||||
let page = doc.first_page().unwrap();
|
||||
doc.mark_dirty([page]);
|
||||
|
||||
crate::cmd::styles::apply_paint_bindings_on_load(&mut doc);
|
||||
|
||||
assert_eq!(doc.dirty().iter().copied().collect::<Vec<_>>(), [page]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bound_layers_follow_variable_values_and_unbind() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -114,3 +114,14 @@ fn field_scopes_by_property_and_layer() {
|
||||
assert_eq!(scope(f, "paddingHorizontal"), Some("GAP"));
|
||||
assert_eq!(scope(f, "visible"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_collection_refuses_a_taken_name() {
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "Theme"}));
|
||||
let before = s.doc().unwrap().variables.collections.len();
|
||||
assert!(s.execute("variable.createCollection", &json!({"name": "Theme"})).is_err());
|
||||
assert_eq!(s.doc().unwrap().variables.collections.len(), before);
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "Theme 2"}));
|
||||
assert_eq!(s.doc().unwrap().variables.collections.len(), before + 1);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
//! `vectoredit.rs` re-fit the node afterwards.
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_geom::kurbo::{CubicBez, ParamCurve};
|
||||
use figcraft_geom::kurbo::{CubicBez, ParamCurve, ParamCurveNearest};
|
||||
use figcraft_geom::{Affine, Point, Vec2};
|
||||
|
||||
fn tvec(t: (f64, f64)) -> Vec2 {
|
||||
@@ -236,6 +236,12 @@ pub fn bend(net: &mut VectorNetwork, seg: usize, t: f64, p: Point) {
|
||||
pub fn split_segment(net: &mut VectorNetwork, seg: usize, t: f64) -> Option<usize> {
|
||||
let s = net.segments.get(seg).copied()?;
|
||||
let c: CubicBez = net.cubic(seg)?;
|
||||
// Which way each region loop walks `seg`, read before the split changes its endpoints.
|
||||
let reversed: Vec<Vec<bool>> = net
|
||||
.regions
|
||||
.iter()
|
||||
.map(|reg| reg.loops.iter().map(|lp| net.loop_walk(lp).iter().find(|(i, _)| *i == seg).is_some_and(|(_, rev)| *rev)).collect())
|
||||
.collect();
|
||||
let t = if t.is_finite() { t.clamp(0.01, 0.99) } else { 0.5 };
|
||||
let l = c.subsegment(0.0..t);
|
||||
let r = c.subsegment(t..1.0);
|
||||
@@ -263,10 +269,12 @@ pub fn split_segment(net: &mut VectorNetwork, seg: usize, t: f64) -> Option<usiz
|
||||
};
|
||||
net.segments.push(new_seg);
|
||||
let ni = net.segments.len() - 1;
|
||||
for reg in &mut net.regions {
|
||||
for lp in &mut reg.loops {
|
||||
for (r, reg) in net.regions.iter_mut().enumerate() {
|
||||
for (l, lp) in reg.loops.iter_mut().enumerate() {
|
||||
if let Some(pos) = lp.iter().position(|x| *x == seg) {
|
||||
lp.insert(pos + 1, ni);
|
||||
// Walked backwards, the new half (mid→b) comes before `seg` (a→mid).
|
||||
let rev = reversed.get(r).and_then(|v| v.get(l)).copied().unwrap_or(false);
|
||||
lp.insert(if rev { pos } else { pos + 1 }, ni);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -766,6 +774,19 @@ fn inside_ranges(net: &VectorNetwork, seg: usize, c: Point, r: f64) -> Vec<(f64,
|
||||
if ia { b } else { a }
|
||||
};
|
||||
let n = 64;
|
||||
// Upper bound on the curve's speed: no point of a sample interval is farther than the whole
|
||||
// interval length from either end, so only intervals that come that close need a closer look.
|
||||
let speed = 3.0 * [(cb.p1 - cb.p0).hypot(), (cb.p2 - cb.p1).hypot(), (cb.p3 - cb.p2).hypot()].into_iter().fold(0.0, f64::max);
|
||||
let reach = speed / f64::from(n);
|
||||
// A crossing narrower than an interval whose ends are both outside: the closest point.
|
||||
let dip = |a: f64, b: f64| -> Option<f64> {
|
||||
if (cb.eval(a) - c).hypot().min((cb.eval(b) - c).hypot()) > r + reach {
|
||||
return None;
|
||||
}
|
||||
let near = cb.subsegment(a..b).nearest(c, 1e-12);
|
||||
let m = a + (b - a) * near.t;
|
||||
(m > a && m < b && inside(m)).then_some(m)
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
let mut start: Option<f64> = if inside(0.0) { Some(0.0) } else { None };
|
||||
let mut prev = 0.0;
|
||||
@@ -773,6 +794,11 @@ fn inside_ranges(net: &VectorNetwork, seg: usize, c: Point, r: f64) -> Vec<(f64,
|
||||
let t = f64::from(i) / f64::from(n);
|
||||
match (start, inside(t)) {
|
||||
(None, true) => start = Some(edge(prev, t)),
|
||||
(None, false) => {
|
||||
if let Some(m) = dip(prev, t) {
|
||||
out.push((edge(prev, m), edge(m, t)));
|
||||
}
|
||||
}
|
||||
(Some(s0), false) => {
|
||||
out.push((s0, edge(prev, t)));
|
||||
start = None;
|
||||
@@ -856,6 +882,38 @@ mod tests {
|
||||
assert!((a + b).hypot() < 1e-9, "made symmetric: {a:?} {b:?}");
|
||||
}
|
||||
|
||||
fn loop_is_continuous(n: &VectorNetwork, lp: &[usize]) -> bool {
|
||||
let ends: Vec<(usize, usize)> = n
|
||||
.loop_walk(lp)
|
||||
.iter()
|
||||
.map(|&(i, rev)| {
|
||||
let s = n.segments[i];
|
||||
if rev { (s.b, s.a) } else { (s.a, s.b) }
|
||||
})
|
||||
.collect();
|
||||
(0..ends.len()).all(|k| ends[k].1 == ends[(k + 1) % ends.len()].0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_keeps_region_loop_in_ring_order() {
|
||||
// Triangle closed 0→2: its loop [2, 1, 0] walks segment 1 backwards.
|
||||
let mut n = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0)], false);
|
||||
n.segments.push(Segment::line(0, 2));
|
||||
n.regions.push(Region { loops: vec![vec![2, 1, 0]], winding: WindingRule::NonZero });
|
||||
let p = Point::new(25.0, 10.0);
|
||||
assert!(n.fill_contains(p));
|
||||
for (seg, lp) in [(1, vec![2, 1, 0]), (2, vec![2, 1, 0]), (1, vec![0, 1, 2])] {
|
||||
let mut m = n.clone();
|
||||
m.regions[0].loops = vec![lp];
|
||||
let mid = split_segment(&mut m, seg, 0.5).unwrap();
|
||||
assert!(loop_is_continuous(&m, &m.regions[0].loops[0]), "loop {:?}", m.regions[0].loops);
|
||||
assert!(m.fill_contains(p), "split of {seg} removed {p:?} from the fill");
|
||||
if seg == 1 {
|
||||
assert_eq!(m.vertices[mid].point(), Point::new(100.0, 50.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inferred_mirroring() {
|
||||
let mut n = open_line();
|
||||
@@ -999,4 +1057,38 @@ mod tests {
|
||||
assert!(!erase(&mut m, Point::new(f64::NAN, 0.0), 5.0));
|
||||
assert!(!erase(&mut m, Point::new(0.0, 0.0), -1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eraser_catches_narrow_crossing_between_samples() {
|
||||
// A radius-1 brush on a long line: the crossing fits between the sampled parameters.
|
||||
let mut n = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(6400.0, 0.0)], false);
|
||||
assert!(erase(&mut n, Point::new(5050.0, 0.0), 1.0));
|
||||
assert_eq!(n.segments.len(), 2);
|
||||
let ends: Vec<f64> = n.vertices.iter().map(|v| v.x).collect();
|
||||
assert!(ends.iter().any(|x| (x - 5049.0).abs() < 0.01) && ends.iter().any(|x| (x - 5051.0).abs() < 0.01), "{ends:?}");
|
||||
assert!(ends.iter().any(|x| x.abs() < 1e-9) && ends.iter().any(|x| (x - 6400.0).abs() < 1e-9), "{ends:?}");
|
||||
// A curved segment that only grazes the brush between samples.
|
||||
let mut c = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(6400.0, 0.0)], false);
|
||||
c.segments[0].ta = (0.0, 3000.0);
|
||||
c.segments[0].tb = (0.0, 3000.0);
|
||||
let cb = c.cubic(0).unwrap();
|
||||
let t = 0.7039;
|
||||
let p = cb.eval(t);
|
||||
let d = figcraft_geom::kurbo::ParamCurveDeriv::deriv(&cb).eval(t).to_vec2().normalize();
|
||||
let normal = Vec2::new(-d.y, d.x);
|
||||
assert!(erase(&mut c, p + normal * 0.5, 1.0));
|
||||
assert_eq!(c.segments.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eraser_catches_crossing_at_full_speed_between_samples() {
|
||||
// The line moves fastest at t = 0.5; centre a tiny brush halfway between two samples there.
|
||||
let mut n = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(6400.0, 0.0)], false);
|
||||
let centre = n.cubic(0).unwrap().eval(32.5 / 64.0);
|
||||
assert!(erase(&mut n, centre, 0.5));
|
||||
assert_eq!(n.segments.len(), 2);
|
||||
let ends: Vec<f64> = n.vertices.iter().map(|v| v.x).collect();
|
||||
assert!(ends.iter().any(|x| (x - (centre.x - 0.5)).abs() < 0.01), "{ends:?}");
|
||||
assert!(ends.iter().any(|x| (x - (centre.x + 0.5)).abs() < 0.01), "{ends:?}");
|
||||
}
|
||||
}
|
||||
@@ -456,6 +456,23 @@ fn a_selected_guide_is_deleted_and_nudged_with_the_keyboard() {
|
||||
assert!(s.execute("guide.select", &json!({"index": 7})).is_err(), "only an existing guide can be selected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_an_earlier_guide_keeps_the_selected_guide() {
|
||||
let mut s = Session::new();
|
||||
for offset in [100, 200, 300] {
|
||||
ex(&mut s, "guide.add", json!({"vertical": true, "offset": offset}));
|
||||
}
|
||||
let page = s.page().unwrap();
|
||||
ex(&mut s, "guide.select", json!({"index": 1}));
|
||||
ex(&mut s, "guide.remove", json!({"index": 0}));
|
||||
assert_eq!(s.ds().unwrap().selected_guide(), Some((page, 0)), "the guide at 200 stays selected at its new index");
|
||||
assert!(s.tool_key("ArrowRight", false, false, false).unwrap());
|
||||
assert_eq!(guide_list(&s, page), vec![(true, 201.0), (true, 300.0)], "the arrow nudges the selected guide, not its neighbour");
|
||||
// A later guide leaves the selection alone.
|
||||
ex(&mut s, "guide.remove", json!({"index": 1}));
|
||||
assert_eq!(s.ds().unwrap().selected_guide(), Some((page, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guides_snap_and_measure() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use figcraft_engine::{
|
||||
Session,
|
||||
doc::NodeKind,
|
||||
tools::{PointerEvent, PointerKind},
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
fn text_count(s: &Session) -> usize {
|
||||
let doc = s.doc().unwrap();
|
||||
doc.node(s.page().unwrap()).unwrap().children.iter().filter(|id| matches!(doc.node(**id).unwrap().kind, NodeKind::Text(_))).count()
|
||||
}
|
||||
|
||||
fn rect_count(s: &Session) -> usize {
|
||||
let doc = s.doc().unwrap();
|
||||
doc.node(s.page().unwrap()).unwrap().children.len() - text_count(s)
|
||||
}
|
||||
|
||||
fn click_and_abandon(s: &mut Session) {
|
||||
s.execute("tool.text", &json!({})).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Down, 300.0, 300.0, 1.0)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Up, 300.0, 300.0, 1.0)).unwrap();
|
||||
assert_eq!(text_count(s), 1);
|
||||
s.execute("text.edit.end", &json!({})).unwrap();
|
||||
assert_eq!(text_count(s), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abandoning_empty_text_does_not_create_an_undo_step() {
|
||||
let mut s = Session::new();
|
||||
assert_eq!(s.undo_depth(), (0, 0));
|
||||
click_and_abandon(&mut s);
|
||||
assert_eq!(s.undo_depth(), (0, 0));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text_count(&s), 0, "Undo restored the abandoned empty layer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abandoning_empty_text_keeps_the_previous_undo_step_first() {
|
||||
let mut s = Session::new();
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
click_and_abandon(&mut s);
|
||||
assert_eq!(s.undo_labels(2), vec!["Rectangle".to_string()]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text_count(&s), 0);
|
||||
assert_eq!(rect_count(&s), 0, "the first Undo must remove the rectangle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abandoning_empty_text_keeps_the_redo_stack() {
|
||||
let mut s = Session::new();
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.undo_depth(), (0, 1));
|
||||
click_and_abandon(&mut s);
|
||||
assert_eq!(s.undo_depth(), (0, 1), "the document is back where it was, so Redo still applies");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(rect_count(&s), 1);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//! Accepting a library update that changes only a paint style refreshes the layers linked to it.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use figcraft_doc::{NodeId, StyleKind};
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
||||
s.execute(command, ¶ms).unwrap()
|
||||
}
|
||||
|
||||
fn layer_paint(s: &Session, id: NodeId) -> String {
|
||||
s.doc().unwrap().node(id).unwrap().fills[0].solid_color().unwrap().to_hex()
|
||||
}
|
||||
|
||||
fn style_paint(s: &Session, id: &str) -> String {
|
||||
match &s.doc().unwrap().style(id).unwrap().kind {
|
||||
StyleKind::Paint { paints } => paints[0].solid_color().unwrap().to_hex(),
|
||||
_ => panic!("expected paint style"),
|
||||
}
|
||||
}
|
||||
|
||||
struct Fixture(std::path::PathBuf);
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepted_library_paint_reaches_linked_layer() {
|
||||
let fixture = Fixture(std::env::temp_dir().join(format!("figcraft-style-accept-{}", std::process::id())));
|
||||
let _ = std::fs::remove_dir_all(&fixture.0);
|
||||
std::fs::create_dir(&fixture.0).unwrap();
|
||||
let path = fixture.0.join("library.figcraft");
|
||||
let mut publisher = Session::new();
|
||||
run(&mut publisher, "style.create", json!({"type":"paint","name":"Brand","value":"#0000ff"}));
|
||||
let publication = run(&mut publisher, "library.publish", json!({}));
|
||||
assert_eq!(publication["components"], 0);
|
||||
assert_eq!(publication["styles"], 1);
|
||||
run(&mut publisher, "file.saveAs", json!({"path":path}));
|
||||
let mut consumer = Session::new();
|
||||
let rectangle = run(&mut consumer, "shape.rectangle", json!({"width":40,"height":40}));
|
||||
let layer = NodeId(rectangle["id"].as_u64().unwrap());
|
||||
run(&mut consumer, "library.add", json!({"path":path}));
|
||||
let applied = run(&mut consumer, "library.applyStyle", json!({"library":publication["id"],"style":"Brand","id":layer}));
|
||||
let style = applied["style"].as_str().unwrap();
|
||||
assert_eq!(layer_paint(&consumer, layer), "0000FF");
|
||||
run(&mut publisher, "style.update", json!({"style":"Brand","value":"#00ff00"}));
|
||||
run(&mut publisher, "library.publish", json!({}));
|
||||
run(&mut publisher, "file.save", json!({}));
|
||||
assert_eq!(run(&mut consumer, "library.updates", json!({}))["count"], 1);
|
||||
let accepted = run(&mut consumer, "library.acceptUpdates", json!({}));
|
||||
assert_eq!(accepted["styles"], 1);
|
||||
assert_eq!(accepted["components"], 0);
|
||||
assert_eq!(accepted["variables"], 0);
|
||||
assert_eq!(style_paint(&consumer, style), "00FF00");
|
||||
assert_eq!(consumer.doc().unwrap().node(layer).unwrap().styles.fill.as_deref(), Some(style));
|
||||
// Captured before any reapply so a later action can't hide a missed refresh.
|
||||
let after_accept = layer_paint(&consumer, layer);
|
||||
assert_eq!(after_accept, "00FF00");
|
||||
assert_eq!(run(&mut consumer, "library.updates", json!({}))["count"], 0);
|
||||
consumer.undo().unwrap();
|
||||
assert_eq!(style_paint(&consumer, style), "0000FF");
|
||||
assert_eq!(layer_paint(&consumer, layer), "0000FF");
|
||||
consumer.redo().unwrap();
|
||||
assert_eq!(style_paint(&consumer, style), "00FF00");
|
||||
assert_eq!(layer_paint(&consumer, layer), "00FF00");
|
||||
run(&mut consumer, "style.apply", json!({"style":style,"id":layer}));
|
||||
assert_eq!(layer_paint(&consumer, layer), "00FF00");
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! `text.listIndent` / `text.listOutdent` report `changed` only when the document changed.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use figcraft_engine::{
|
||||
Session,
|
||||
doc::{ListStyle, NodeId, NodeKind},
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
fn editing_list(indent: u8) -> (Session, NodeId) {
|
||||
let mut s = Session::new();
|
||||
let value = s.execute("node.create", &json!({"type":"text", "props":{"characters":"a"}})).unwrap();
|
||||
let id = NodeId(value["id"].as_u64().unwrap());
|
||||
s.execute("text.listStyle", &json!({"ids":[id.0], "style":"bulleted"})).unwrap();
|
||||
s.execute("node.set", &json!({"id":id.0, "props":{"indentation":indent}})).unwrap();
|
||||
s.execute("text.edit.begin", &json!({"id":id.0, "caret":0})).unwrap();
|
||||
(s, id)
|
||||
}
|
||||
|
||||
fn list_state(s: &Session, id: NodeId) -> (ListStyle, u8) {
|
||||
let NodeKind::Text(t) = &s.doc().unwrap().node(id).unwrap().kind else { panic!("not text") };
|
||||
let p = t.para_list(0);
|
||||
(p.list, p.indent)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indent_at_cap_reports_false() {
|
||||
let (mut s, id) = editing_list(8);
|
||||
let before = s.doc().unwrap().clone();
|
||||
let result = s.execute("text.listIndent", &json!({})).unwrap();
|
||||
assert_eq!(list_state(&s, id), (ListStyle::Bulleted, 8));
|
||||
assert_eq!(before, *s.doc().unwrap());
|
||||
assert_eq!(result["changed"], json!(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indent_from_zero_reports_true() {
|
||||
let (mut s, id) = editing_list(0);
|
||||
let result = s.execute("text.listIndent", &json!({})).unwrap();
|
||||
assert_eq!(list_state(&s, id), (ListStyle::Bulleted, 1));
|
||||
assert_eq!(result["changed"], json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outdent_from_zero_reports_true() {
|
||||
let (mut s, id) = editing_list(0);
|
||||
let result = s.execute("text.listOutdent", &json!({})).unwrap();
|
||||
assert_eq!(list_state(&s, id).0, ListStyle::None);
|
||||
assert_eq!(result["changed"], json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indent_plain_text_reports_false() {
|
||||
let mut s = Session::new();
|
||||
let value = s.execute("node.create", &json!({"type":"text", "props":{"characters":"a"}})).unwrap();
|
||||
let id = NodeId(value["id"].as_u64().unwrap());
|
||||
s.execute("text.edit.begin", &json!({"id":id.0, "caret":0})).unwrap();
|
||||
let before = s.doc().unwrap().clone();
|
||||
let result = s.execute("text.listIndent", &json!({})).unwrap();
|
||||
assert_eq!(before, *s.doc().unwrap());
|
||||
assert_eq!(result["changed"], json!(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_keys_still_nest_and_unnest() {
|
||||
let (mut s, id) = editing_list(0);
|
||||
s.execute("text.edit.key", &json!({"key":"Tab"})).unwrap();
|
||||
assert_eq!(list_state(&s, id), (ListStyle::Bulleted, 1));
|
||||
s.execute("text.edit.key", &json!({"key":"Tab", "shift":true})).unwrap();
|
||||
assert_eq!(list_state(&s, id), (ListStyle::Bulleted, 0));
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
fn scopes(session: &Session, variable: &str) -> Option<Vec<String>> {
|
||||
session.doc().unwrap().variables.get(variable).unwrap().scopes.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_restores_all_supported_properties() {
|
||||
let mut session = Session::new();
|
||||
session.execute("variable.createCollection", &json!({"name": "Theme"})).unwrap();
|
||||
let result = session.execute("variable.create", &json!({"collection": "Theme", "name": "Accent", "type": "color"})).unwrap();
|
||||
let variable = result["id"].as_str().unwrap();
|
||||
assert_eq!(scopes(&session, variable), None);
|
||||
session.execute("variable.update", &json!({"variable": variable, "scopes": []})).unwrap();
|
||||
assert_eq!(scopes(&session, variable), Some(vec![]));
|
||||
session.execute("variable.update", &json!({"variable": variable, "name": "Accent2"})).unwrap();
|
||||
assert_eq!(scopes(&session, variable), Some(vec![]));
|
||||
assert_eq!(session.execute("variable.update", &json!({"variable": variable, "scopes": null})).unwrap(), json!({"ok": true}));
|
||||
assert_eq!(scopes(&session, variable), None);
|
||||
session.execute("variable.update", &json!({"variable": variable, "scopes": []})).unwrap();
|
||||
session.execute("variable.update", &json!({"variable": variable, "scopes": ["ALL_SCOPES"]})).unwrap();
|
||||
assert_eq!(scopes(&session, variable), None);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn rectangle(s: &mut Session, x: f64) -> Value {
|
||||
s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 100, "height": 100})).unwrap()["id"].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_response_includes_outlines_for_no_op() {
|
||||
let mut s = Session::new();
|
||||
let id = rectangle(&mut s, 0.0);
|
||||
let regions = s.execute("shapeBuilder.regions", &json!({"ids": [id]})).unwrap();
|
||||
assert_eq!(regions["regions"].as_array().unwrap().len(), 1);
|
||||
let before = serde_json::to_value(s.doc().unwrap()).unwrap();
|
||||
let result = s.execute("shapeBuilder.merge", &json!({"ids": [id], "regions": [0]})).unwrap();
|
||||
assert_eq!(serde_json::to_value(s.doc().unwrap()).unwrap(), before);
|
||||
assert_eq!(result["id"], Value::Null);
|
||||
assert_eq!(result["changed"], json!([]));
|
||||
assert_eq!(result["removed"], json!([]));
|
||||
assert_eq!(result["regions"], json!([0]));
|
||||
assert_eq!(result.get("outlines"), Some(&json!([])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_response_control_includes_outlines() {
|
||||
let mut s = Session::new();
|
||||
let a = rectangle(&mut s, 0.0);
|
||||
let b = rectangle(&mut s, 50.0);
|
||||
let regions = s.execute("shapeBuilder.regions", &json!({"ids": [a, b]})).unwrap();
|
||||
assert_eq!(regions["regions"].as_array().unwrap().len(), 3);
|
||||
let result = s.execute("shapeBuilder.merge", &json!({"ids": [a, b], "regions": [1]})).unwrap();
|
||||
assert!(result["id"].is_u64());
|
||||
assert_eq!(result.get("outlines"), Some(&json!([])));
|
||||
assert_eq!(result.as_object().unwrap().len(), 5);
|
||||
}
|
||||
@@ -450,6 +450,38 @@ fn shared_styles_and_their_links_round_trip() {
|
||||
assert_eq!(named(&nodes, "Title")["styleIdForText"]["guid"], named(&nodes, "Heading")["guid"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_styled_layers_fill_variable_survives_a_round_trip() {
|
||||
let mut doc = Document::new("Overlap");
|
||||
let page = doc.first_page().unwrap();
|
||||
let mode = |id: &str, name: &str| Mode { id: id.into(), name: name.into() };
|
||||
doc.variables.collections =
|
||||
vec![Collection { id: "c-theme".into(), name: "Theme".into(), modes: vec![mode("m-light", "Light"), mode("m-dark", "Dark")], extends: None }];
|
||||
let light = Color::new(0.1, 0.3, 0.7, 1.0);
|
||||
let dark = VariableValue::Color(Color::new(0.8, 0.9, 1.0, 1.0));
|
||||
doc.variables.variables =
|
||||
vec![var("v-accent", "Accent color", "c-theme", VariableType::Color, &[("m-light", VariableValue::Color(light)), ("m-dark", dark)])];
|
||||
doc.styles = vec![Style {
|
||||
id: "s-accent".into(),
|
||||
name: "Accent".into(),
|
||||
description: String::new(),
|
||||
kind: StyleKind::Paint { paints: vec![Paint::solid(light)] },
|
||||
bound: Default::default(),
|
||||
}];
|
||||
add(&mut doc, page, NodeKind::Rectangle, "Swatch", (0.0, 0.0), (120.0, 80.0), &|n| {
|
||||
n.fills = vec![Paint::solid(light)];
|
||||
n.styles.fill = Some("s-accent".into());
|
||||
n.bound.insert("fill".into(), "v-accent".into());
|
||||
});
|
||||
let (back, _) = import(&export(&doc, None).unwrap().0, "x").unwrap();
|
||||
let var = back.variables.variables.iter().find(|v| v.name == "Accent color").unwrap().id.clone();
|
||||
let swatch = back.nodes().find(|n| n.name == "Swatch").unwrap();
|
||||
let on_layer = swatch.bound.get("fill") == Some(&var) || swatch.bound.get("fills/0/color") == Some(&var);
|
||||
let style = back.styles.iter().find(|s| Some(&s.id) == swatch.styles.fill.as_ref()).unwrap();
|
||||
let on_style = style.bound.get("fills/0/color") == Some(&var);
|
||||
assert!(on_layer || on_style, "the fill binding was lost: layer {:?}, style {:?}", swatch.bound, style.bound);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_styles_on_part_of_a_text_are_written_as_values() {
|
||||
let mut doc = styled();
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
use super::{kiwi::Kv, map};
|
||||
|
||||
fn obj(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
|
||||
Kv::Object(fields)
|
||||
}
|
||||
|
||||
fn scalar(value: f32) -> Kv<'static> {
|
||||
Kv::Float(value)
|
||||
}
|
||||
|
||||
fn guid(local_id: u64) -> Kv<'static> {
|
||||
obj(vec![("sessionID", Kv::Uint(40_000_000)), ("localID", Kv::Uint(local_id))])
|
||||
}
|
||||
|
||||
fn record(local_id: u64, children: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
|
||||
let mut fields = vec![("guidPath", obj(vec![("guids", Kv::Array(vec![guid(local_id)]))]))];
|
||||
fields.extend(children);
|
||||
obj(fields)
|
||||
}
|
||||
|
||||
fn transform(x: f32, y: f32) -> Kv<'static> {
|
||||
obj(vec![("m00", scalar(1.0)), ("m01", scalar(0.0)), ("m02", scalar(x)), ("m10", scalar(0.0)), ("m11", scalar(1.0)), ("m12", scalar(y))])
|
||||
}
|
||||
|
||||
fn point(x: f32, y: f32) -> Kv<'static> {
|
||||
obj(vec![("x", scalar(x)), ("y", scalar(y))])
|
||||
}
|
||||
|
||||
fn path_blob() -> Vec<u8> {
|
||||
let mut bytes = vec![1]; // MoveTo
|
||||
bytes.extend(0.0f32.to_le_bytes());
|
||||
bytes.extend(0.0f32.to_le_bytes());
|
||||
bytes.push(2); // LineTo
|
||||
bytes.extend(20.0f32.to_le_bytes());
|
||||
bytes.extend(10.0f32.to_le_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
fn paint() -> Kv<'static> {
|
||||
obj(vec![
|
||||
("type", Kv::Enum("SOLID")),
|
||||
("color", obj(vec![("r", scalar(0.1)), ("g", scalar(0.2)), ("b", scalar(0.8)), ("a", scalar(1.0))])),
|
||||
("visible", Kv::Bool(true)),
|
||||
])
|
||||
}
|
||||
|
||||
fn synthetic_connector(version: f32, include_text: bool, text_x: f32) -> Kv<'static> {
|
||||
let line_geometry = Kv::Array(vec![obj(vec![("commandsBlob", Kv::Uint(0))])]);
|
||||
let line = record(0, vec![("transform", transform(7.0, 9.0)), ("size", point(20.0, 10.0)), ("fillGeometry", line_geometry)]);
|
||||
let text = record(1, vec![("transform", transform(text_x, 3.0)), ("size", point(80.0, 24.0))]);
|
||||
let background = record(2, vec![("transform", transform(100.0, 30.0)), ("size", point(90.0, 30.0))]);
|
||||
let mut derived = vec![line];
|
||||
if include_text {
|
||||
derived.extend([text, background]);
|
||||
}
|
||||
let line_style = record(0, vec![("strokePaints", Kv::Array(vec![paint()]))]);
|
||||
let text_style = record(
|
||||
1,
|
||||
vec![
|
||||
("textData", obj(vec![("characters", Kv::Str("synthetic label".into()))])),
|
||||
("fontSize", scalar(24.0)),
|
||||
("fontName", obj(vec![("family", Kv::Str("Inter".into())), ("style", Kv::Str("Regular".into()))])),
|
||||
("fillPaints", Kv::Array(vec![paint()])),
|
||||
("visible", Kv::Bool(false)),
|
||||
],
|
||||
);
|
||||
let bg_style = record(2, vec![("fillPaints", Kv::Array(vec![])), ("visible", Kv::Bool(false))]);
|
||||
let mut styles = vec![line_style];
|
||||
if include_text {
|
||||
styles.extend([text_style, bg_style]);
|
||||
}
|
||||
obj(vec![
|
||||
(
|
||||
"nodeChanges",
|
||||
Kv::Array(vec![
|
||||
obj(vec![("guid", guid(0)), ("type", Kv::Enum("DOCUMENT")), ("name", Kv::Str("Doc".into()))]),
|
||||
obj(vec![
|
||||
("guid", guid(1)),
|
||||
("parentIndex", obj(vec![("guid", guid(0)), ("position", Kv::Str("!".into()))])),
|
||||
("type", Kv::Enum("CANVAS")),
|
||||
("name", Kv::Str("Page".into())),
|
||||
]),
|
||||
obj(vec![
|
||||
("guid", obj(vec![("sessionID", Kv::Uint(1)), ("localID", Kv::Uint(123))])),
|
||||
("parentIndex", obj(vec![("guid", guid(1)), ("position", Kv::Str("!".into()))])),
|
||||
("type", Kv::Enum("CONNECTOR")),
|
||||
("name", Kv::Str("synthetic connector".into())),
|
||||
("size", point(140.0, 50.0)),
|
||||
("opacity", scalar(0.5)),
|
||||
("connectorType", Kv::Enum("MANUAL")),
|
||||
("connectorVersion", scalar(version)),
|
||||
("connectorLineStyle", Kv::Enum("STRAIGHT")),
|
||||
("connectorStartCap", Kv::Enum("NONE")),
|
||||
("connectorEndCap", Kv::Enum("ARROW")),
|
||||
("derivedImmutableFrameData", obj(vec![("version", scalar(1.0)), ("overrides", Kv::Array(derived))])),
|
||||
("nodeGenerationData", obj(vec![("overrides", Kv::Array(styles))])),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
("blobs", Kv::Array(vec![obj(vec![("bytes", Kv::Bytes(path_blob()))])])),
|
||||
])
|
||||
}
|
||||
|
||||
fn map_synthetic(version: f32, include_text: bool, text_x: f32) -> (serde_json::Value, Vec<String>) {
|
||||
map::to_rest(&synthetic_connector(version, include_text, text_x), "synthetic")
|
||||
}
|
||||
|
||||
fn find_node<'a>(doc: &'a figcraft_doc::Document, name: &str) -> &'a figcraft_doc::Node {
|
||||
doc.nodes().find(|node| node.name == name).expect("native node")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_label_style_transform_and_container_opacity_survive_native_roundtrip() {
|
||||
let (rest, warnings) = map_synthetic(1.0, true, 8.5);
|
||||
let connector = &rest["document"]["children"][0]["children"][0];
|
||||
assert_eq!(connector["type"], "FRAME");
|
||||
assert_eq!(connector["opacity"], 0.5);
|
||||
assert_eq!(connector["children"].as_array().unwrap().len(), 2, "empty/hidden background is omitted; hidden text remains explicit");
|
||||
let text = &connector["children"][1];
|
||||
assert_eq!(text["type"], "TEXT");
|
||||
assert_eq!(text["visible"], false);
|
||||
assert_eq!(text["fills"].as_array().unwrap().len(), 1);
|
||||
assert!((text["fills"][0]["color"]["b"].as_f64().unwrap() - 0.8).abs() < 1e-6);
|
||||
assert_eq!(text["style"]["fontSize"], 24.0);
|
||||
assert_eq!(text["relativeTransform"], serde_json::json!([[1.0, 0.0, 108.5], [0.0, 1.0, 33.0]]));
|
||||
assert!(warnings.iter().any(|warning| warning.contains("live endpoint bindings are not retained")));
|
||||
|
||||
let (native, _) = crate::rest::import_fig(&rest, &|_| None).unwrap();
|
||||
let bytes = crate::save_bytes(&native, None).unwrap();
|
||||
let (reopened, _) = crate::load_bytes(&bytes).unwrap();
|
||||
let frame = find_node(&reopened, "synthetic connector");
|
||||
assert_eq!(frame.opacity, 0.5);
|
||||
let text = find_node(&reopened, "synthetic label");
|
||||
assert!(!text.visible);
|
||||
assert_eq!(text.opacity, 1.0, "root opacity must not be copied to child");
|
||||
assert_eq!(text.transform.as_coeffs()[4..], [108.5, 33.0]);
|
||||
assert_eq!(text.fills.len(), 1);
|
||||
let figcraft_doc::NodeKind::Text(data) = &text.kind else { panic!("text child kind") };
|
||||
assert_eq!(data.style.font_size, 24.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_connector_missing_optional_derived_label_keeps_vector() {
|
||||
let (rest, warnings) = map_synthetic(1.0, false, 0.0);
|
||||
let connector = &rest["document"]["children"][0]["children"][0];
|
||||
assert_eq!(connector["type"], "VECTOR", "an unlabelled connector keeps the single-vector shape");
|
||||
assert!(connector.get("children").is_none());
|
||||
assert!(warnings.iter().all(|warning| !warning.contains("connector labels were skipped")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_unlabelled_connector_imports_as_one_vector_with_line_style() {
|
||||
let (rest, warnings) = map_synthetic(1.0, false, 0.0);
|
||||
let connector = &rest["document"]["children"][0]["children"][0];
|
||||
assert_eq!(connector["type"], "VECTOR");
|
||||
assert_eq!(connector["name"], "synthetic connector");
|
||||
assert_eq!(connector["opacity"], 0.5);
|
||||
assert_eq!(connector["strokes"].as_array().map(Vec::len), Some(1));
|
||||
assert_eq!(connector["strokeCapEnd"], "ARROW");
|
||||
assert!(warnings.iter().all(|warning| !warning.contains("connector label")), "{warnings:?}");
|
||||
|
||||
let (native, _) = crate::rest::import_fig(&rest, &|_| None).unwrap();
|
||||
let node = find_node(&native, "synthetic connector");
|
||||
assert!(matches!(node.kind, figcraft_doc::NodeKind::Vector { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_connector_unsupported_version_uses_legacy_warning() {
|
||||
let (rest, warnings) = map_synthetic(2.0, true, 8.5);
|
||||
let connector = &rest["document"]["children"][0]["children"][0];
|
||||
assert_eq!(connector["type"], "VECTOR");
|
||||
assert!(warnings.iter().any(|warning| warning == "connector labels were skipped"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_connector_malformed_label_bounds_or_transform_keep_vector() {
|
||||
let mut malformed_bounds = synthetic_connector(1.0, true, 8.5);
|
||||
set_override_field(&mut malformed_bounds, "derivedImmutableFrameData", 1, "size", point(f32::NAN, 24.0));
|
||||
let (rest, warnings) = map::to_rest(&malformed_bounds, "synthetic");
|
||||
let connector = &rest["document"]["children"][0]["children"][0];
|
||||
assert_eq!(connector["children"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(connector["children"][0]["type"], "VECTOR");
|
||||
assert!(warnings.iter().any(|warning| warning == "connector label was skipped: text bounds are invalid"));
|
||||
|
||||
let mut malformed_transform = synthetic_connector(1.0, true, 8.5);
|
||||
set_override_field(&mut malformed_transform, "derivedImmutableFrameData", 1, "transform", transform(f32::NAN, 3.0));
|
||||
let (rest, warnings) = map::to_rest(&malformed_transform, "synthetic");
|
||||
let connector = &rest["document"]["children"][0]["children"][0];
|
||||
assert_eq!(connector["children"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(connector["children"][0]["type"], "VECTOR");
|
||||
assert!(warnings.iter().any(|warning| warning == "connector label was skipped: text transform is invalid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_connector_authored_hidden_background_is_retained() {
|
||||
let mut message = synthetic_connector(1.0, true, 8.5);
|
||||
set_override_field(&mut message, "nodeGenerationData", 2, "fillPaints", Kv::Array(vec![paint()]));
|
||||
let (rest, _) = map::to_rest(&message, "synthetic");
|
||||
let connector = &rest["document"]["children"][0]["children"][0];
|
||||
let background = connector["children"].as_array().unwrap().iter().find(|child| child["type"] == "RECTANGLE").expect("authored background");
|
||||
assert_eq!(background["visible"], false);
|
||||
assert_eq!(background["fills"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
fn field_mut<'a, 's>(value: &'a mut Kv<'s>, key: &str) -> Option<&'a mut Kv<'s>> {
|
||||
let Kv::Object(fields) = value else { return None };
|
||||
fields.iter_mut().find(|(name, _)| *name == key).map(|(_, value)| value)
|
||||
}
|
||||
|
||||
fn connector_mut<'a, 's>(message: &'a mut Kv<'s>, local_id: u64) -> Option<&'a mut Kv<'s>> {
|
||||
let Kv::Object(fields) = message else { return None };
|
||||
let node_changes = fields.iter_mut().find(|(name, _)| *name == "nodeChanges").map(|(_, value)| value)?;
|
||||
let Kv::Array(nodes) = node_changes else { return None };
|
||||
nodes.iter_mut().find(|node| {
|
||||
node.get("type").and_then(Kv::str) == Some("CONNECTOR")
|
||||
&& node.get("guid").and_then(|guid| guid.get("localID")).and_then(Kv::f64) == Some(local_id as f64)
|
||||
})
|
||||
}
|
||||
|
||||
fn set_override_field(message: &mut Kv<'_>, field: &str, local_id: u64, key: &'static str, value: Kv<'static>) {
|
||||
let connector = connector_mut(message, 123).expect("synthetic connector");
|
||||
let section = field_mut(connector, field).expect("connector override section");
|
||||
let overrides = field_mut(section, "overrides").expect("override list");
|
||||
let Kv::Array(rows) = overrides else { panic!("override list array") };
|
||||
let row = rows
|
||||
.iter_mut()
|
||||
.find(|row| {
|
||||
row.get("guidPath")
|
||||
.and_then(|path| path.get("guids"))
|
||||
.and_then(|ids| ids.list().first())
|
||||
.and_then(|id| id.get("localID"))
|
||||
.and_then(Kv::f64)
|
||||
== Some(local_id as f64)
|
||||
})
|
||||
.expect("override row");
|
||||
let Kv::Object(fields) = row else { panic!("override row object") };
|
||||
if let Some((_, current)) = fields.iter_mut().find(|(name, _)| *name == key) {
|
||||
*current = value;
|
||||
} else {
|
||||
fields.push((key, value));
|
||||
}
|
||||
}
|
||||
@@ -1429,9 +1429,15 @@ impl<'d> Exporter<'d> {
|
||||
}
|
||||
}
|
||||
|
||||
/// How a layer sizes and sits in its auto layout parent.
|
||||
/// How a layer sizes and sits in its auto layout parent. Min/max sizes are the layer's own
|
||||
/// and are written whatever its parent is.
|
||||
fn child_layout(&mut self, n: &Node, parent: Option<&Node>, f: &mut Fields) {
|
||||
let cl = &n.child_layout;
|
||||
for (key, w, h) in [("minSize", cl.min_w, cl.min_h), ("maxSize", cl.max_w, cl.max_h)] {
|
||||
if w.is_some() || h.is_some() {
|
||||
f.push((key, o(vec![("value", xy(w.unwrap_or(0.0), h.unwrap_or(0.0)))])));
|
||||
}
|
||||
}
|
||||
let Some(pl) = parent.and_then(|p| p.layout.as_ref()) else { return };
|
||||
let (fill_h, fill_v) = (cl.sizing_h == Sizing::Fill, cl.sizing_v == Sizing::Fill);
|
||||
let (grow, stretch) = if pl.mode == LayoutMode::Vertical { (fill_v, fill_h) } else { (fill_h, fill_v) };
|
||||
@@ -1444,11 +1450,6 @@ impl<'d> Exporter<'d> {
|
||||
if cl.absolute {
|
||||
f.push(("stackPositioning", e("ABSOLUTE")));
|
||||
}
|
||||
for (key, w, h) in [("minSize", cl.min_w, cl.min_h), ("maxSize", cl.max_w, cl.max_h)] {
|
||||
if w.is_some() || h.is_some() {
|
||||
f.push((key, o(vec![("value", xy(w.unwrap_or(0.0), h.unwrap_or(0.0)))])));
|
||||
}
|
||||
}
|
||||
if pl.mode == LayoutMode::Grid
|
||||
&& let Some((cols, rows)) = parent.and_then(|p| self.tracks.get(&self.main_layer(p.id)))
|
||||
{
|
||||
@@ -1501,7 +1502,6 @@ impl<'d> Exporter<'d> {
|
||||
("textExplicitLayoutVersion", u(1)),
|
||||
("textBidiVersion", u(1)),
|
||||
("textTracking", fl(0.0)),
|
||||
("textDecorationSkipInk", b(true)),
|
||||
("emojiImageSet", e("APPLE")),
|
||||
]);
|
||||
if td.auto_resize == TextAutoResize::Truncate {
|
||||
@@ -1551,6 +1551,10 @@ impl<'d> Exporter<'d> {
|
||||
.map(|(st, id)| {
|
||||
let mut ef: Fields = vec![("styleID", u(id))];
|
||||
style_fields(st, &mut ef);
|
||||
// An empty link clears the text's own (a missing one would inherit it).
|
||||
if st.link.is_none() && td.style.link.is_some() {
|
||||
ef.push(("hyperlink", o(vec![("url", s(""))])));
|
||||
}
|
||||
if let Some(p) = st.fills.as_deref().or(td.style.fills.as_ref().map(|_| fills)) {
|
||||
ef.push(("fillPaints", self.paints(p, true, &Bound::default())));
|
||||
}
|
||||
@@ -1584,7 +1588,7 @@ impl<'d> Exporter<'d> {
|
||||
self.warn("nested list levels were written as the first level");
|
||||
}
|
||||
let styles = std::iter::once(&td.style).chain(td.runs.iter().map(|r| &r.style));
|
||||
if styles.clone().any(|st| st.decoration_look.is_some() || !st.variations.is_empty()) {
|
||||
if styles.clone().any(|st| unsupported_decoration_look(st) || !st.variations.is_empty()) {
|
||||
self.warn("text decoration looks and variable font settings were left out");
|
||||
}
|
||||
if td.path.is_some() {
|
||||
@@ -1664,7 +1668,13 @@ impl<'d> Exporter<'d> {
|
||||
let segments = net
|
||||
.segments
|
||||
.iter()
|
||||
.map(|sg| network::Segment { a: sg.a, ta: (finite(sg.ta.0), finite(sg.ta.1)), b: sg.b, tb: (finite(sg.tb.0), finite(sg.tb.1)) })
|
||||
.map(|sg| network::Segment {
|
||||
style: 0,
|
||||
a: sg.a,
|
||||
ta: (finite(sg.ta.0), finite(sg.ta.1)),
|
||||
b: sg.b,
|
||||
tb: (finite(sg.tb.0), finite(sg.tb.1)),
|
||||
})
|
||||
.collect();
|
||||
let regions = net
|
||||
.regions
|
||||
@@ -2388,6 +2398,13 @@ fn key_codes(key: &str) -> Option<(Vec<i64>, bool)> {
|
||||
Some((codes, false))
|
||||
}
|
||||
|
||||
/// Whether the decoration look has more than skip ink, which the file can't hold.
|
||||
fn unsupported_decoration_look(st: &TextStyle) -> bool {
|
||||
let mut look = st.look();
|
||||
look.skip_ink = false;
|
||||
look != Default::default()
|
||||
}
|
||||
|
||||
/// The text style fields of a layer or of an override table entry.
|
||||
fn style_fields(st: &TextStyle, f: &mut Fields) {
|
||||
let line_height = match st.line_height {
|
||||
@@ -2420,6 +2437,9 @@ fn style_fields(st: &TextStyle, f: &mut Fields) {
|
||||
("letterSpacing", letter_spacing),
|
||||
("textCase", e(case)),
|
||||
("textDecoration", e(decoration)),
|
||||
// Skip ink only shows on an underline; other text gets the files' default (on), the value
|
||||
// the import leaves out again.
|
||||
("textDecorationSkipInk", b(st.look().skip_ink || st.decoration != TextDecoration::Underline)),
|
||||
]);
|
||||
if let Some(url) = &st.link {
|
||||
f.push(("hyperlink", o(vec![("url", s(url))])));
|
||||
|
||||
@@ -1014,6 +1014,37 @@ fn junk_documents_never_panic() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A frame's own min/max size survives whatever its parent is, not only under auto layout.
|
||||
#[test]
|
||||
fn frame_size_limits_survive_without_auto_layout_parent() {
|
||||
fn limits(doc: &Document) -> [Option<f64>; 4] {
|
||||
let (bytes, ew) = export(doc, None).unwrap();
|
||||
let (back, iw) = import(&bytes, "File").unwrap();
|
||||
assert!(ew.is_empty() && iw.is_empty(), "export {ew:?}, import {iw:?}");
|
||||
let cl = &back.nodes().find(|n| n.name == "Subject").unwrap().child_layout;
|
||||
[cl.min_w, cl.max_w, cl.min_h, cl.max_h]
|
||||
}
|
||||
let mut doc = Document::new("File");
|
||||
let page = doc.first_page().unwrap();
|
||||
let id = doc.create(page, NodeKind::Frame, "Subject", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
let node = doc.node_mut(id).unwrap();
|
||||
node.layout = Some(AutoLayout::default());
|
||||
node.child_layout.min_w = Some(80.0);
|
||||
node.child_layout.max_w = Some(150.0);
|
||||
node.child_layout.min_h = Some(60.0);
|
||||
node.child_layout.max_h = Some(170.0);
|
||||
let expected = [Some(80.0), Some(150.0), Some(60.0), Some(170.0)];
|
||||
|
||||
let mut nested = doc.clone();
|
||||
let wrapper = nested.create(page, NodeKind::Frame, "Wrapper", 0.0, 0.0, 300.0, 300.0).unwrap();
|
||||
nested.node_mut(wrapper).unwrap().layout = Some(AutoLayout::default());
|
||||
nested.reparent(id, wrapper, None).unwrap();
|
||||
assert_eq!(limits(&nested), expected, "auto layout parent");
|
||||
nested.node_mut(wrapper).unwrap().layout = None;
|
||||
assert_eq!(limits(&nested), expected, "plain frame parent");
|
||||
assert_eq!(limits(&doc), expected, "page parent");
|
||||
}
|
||||
|
||||
/// Link each instance's layers to its main's by position, as they are imported (the engine
|
||||
/// links them by path after loading).
|
||||
fn link(doc: &mut Document) {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Image paint filters read from `.fig` files, on fixtures built in code with the parent module's
|
||||
//! helpers.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The shared schema plus the image filter containers only these tests use.
|
||||
fn image_filter_schema() -> Schema {
|
||||
let text = SCHEMA
|
||||
.replace(
|
||||
"message Paint ",
|
||||
"message FilterColorAdjust float tint, float shadows, float highlights, float detail, float exposure, float vignette, float temperature, float vibrance\n\
|
||||
message PaintFilterMessage float tint, float shadows, float highlights, float detail, float exposure, float vignette, float temperature, float vibrance, float contrast, float brightness, float mystery\n\
|
||||
message Paint ",
|
||||
)
|
||||
.replace("message Paint PaintType type,", "message Paint PaintFilterMessage paintFilter, FilterColorAdjust filterColorAdjust, PaintType type,");
|
||||
Schema::parse(&text).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_filters_import_from_current_container_and_skip_ambiguous_data() {
|
||||
let filter = |fields: Vec<(&'static str, Kv<'static>)>| o(fields);
|
||||
let image_paint = |id: u64, name: &'static str, mut extra: Vec<(&'static str, Kv<'static>)>| {
|
||||
let mut paint = vec![("type", e("IMAGE")), ("image", o(vec![("hash", Kv::Bytes(HASH.to_vec()))]))];
|
||||
paint.append(&mut extra);
|
||||
node(id, Some((1, "!")), "RECTANGLE", name, vec![("size", xy(2.0, 1.0)), ("fillPaints", arr(vec![o(paint)]))])
|
||||
};
|
||||
let nodes = vec![
|
||||
node(0, None, "DOCUMENT", "Document", vec![]),
|
||||
node(1, Some((0, "!")), "CANVAS", "Page", vec![]),
|
||||
image_paint(
|
||||
2,
|
||||
"Current",
|
||||
vec![(
|
||||
"paintFilter",
|
||||
filter(vec![
|
||||
("exposure", f(0.25)),
|
||||
("contrast", f(0.3)),
|
||||
("vibrance", f(-0.4)),
|
||||
("temperature", f(0.5)),
|
||||
("tint", f(1.0)),
|
||||
("highlights", f(-0.6)),
|
||||
("shadows", f(0.7)),
|
||||
]),
|
||||
)],
|
||||
),
|
||||
image_paint(3, "Negative tint", vec![("paintFilter", filter(vec![("tint", f(-1.0)), ("contrast", f(-0.3))]))]),
|
||||
image_paint(4, "Neutral", vec![]),
|
||||
image_paint(5, "Explicit neutral", vec![("paintFilter", filter(vec![("exposure", f(0.0)), ("vibrance", f(0.0))]))]),
|
||||
image_paint(6, "Legacy", vec![("filterColorAdjust", filter(vec![("exposure", f(0.8))]))]),
|
||||
image_paint(7, "Both", vec![("filterColorAdjust", filter(vec![("exposure", f(0.8))])), ("paintFilter", filter(vec![("contrast", f(0.2))]))]),
|
||||
image_paint(
|
||||
8,
|
||||
"Unsupported",
|
||||
vec![("paintFilter", filter(vec![("brightness", f(0.4)), ("detail", f(-0.2)), ("vignette", f(0.3)), ("mystery", f(0.1))]))],
|
||||
),
|
||||
];
|
||||
let bytes = zip_file(&canvas(&image_filter_schema(), &o(vec![("nodeChanges", arr(nodes))])));
|
||||
let (doc, warnings) = import(&bytes, "ImageFilters.fig").unwrap();
|
||||
let get = |name: &str| {
|
||||
let node = doc.nodes().find(|node| node.name == name).unwrap();
|
||||
let figcraft_doc::color::PaintKind::Image(image) = &node.fills[0].kind else { panic!("{name} is not an image fill") };
|
||||
image
|
||||
};
|
||||
let current = get("Current");
|
||||
assert!((current.filters.exposure - 0.25).abs() < 1e-6);
|
||||
assert!((current.filters.contrast - 0.3).abs() < 1e-6, "observed raw contrast remains 0.3");
|
||||
assert!((current.filters.saturation + 0.4).abs() < 1e-6, "vibrance maps to saturation");
|
||||
assert!((current.filters.temperature - 0.5).abs() < 1e-6);
|
||||
assert!((current.filters.tint + 1.0).abs() < 1e-6, "a positive (magenta) tint in the file is a negative native tint");
|
||||
assert!((current.filters.highlights + 0.6).abs() < 1e-6);
|
||||
assert!((current.filters.shadows - 0.7).abs() < 1e-6);
|
||||
assert!((get("Negative tint").filters.tint - 1.0).abs() < 1e-6);
|
||||
assert!((get("Negative tint").filters.contrast + 0.3).abs() < 1e-6);
|
||||
assert_eq!(get("Neutral").filters, Default::default());
|
||||
assert_eq!(get("Explicit neutral").filters, Default::default());
|
||||
assert_eq!(doc.images.get(¤t.image).map(|image| image.bytes.as_slice()), Some(png().as_slice()));
|
||||
assert!(warnings.iter().any(|w| w.contains("legacy image filter container is unsupported")), "{warnings:?}");
|
||||
assert!(warnings.iter().any(|w| w.contains("both filter containers are present")), "{warnings:?}");
|
||||
for field in ["brightness", "detail", "vignette", "mystery"] {
|
||||
assert!(warnings.iter().any(|w| w.contains(field)), "missing {field} warning: {warnings:?}");
|
||||
}
|
||||
assert_eq!(warnings.iter().filter(|w| w.contains("approximate renderer")).count(), 1, "deduplicated approximation warning: {warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warnings_naming_unknown_filter_fields_are_capped() {
|
||||
// A schema can name any number of fields; each nonzero unknown one is a warning of its own.
|
||||
let names: Vec<&'static str> = (0..300).map(|i| &*Box::leak(format!("unknown{i}").into_boxed_str())).collect();
|
||||
let fields: Vec<String> = names.iter().map(|n| format!("float {n}")).collect();
|
||||
let text = SCHEMA
|
||||
.replace("message Paint ", &format!("message ManyFilters {}\nmessage Paint ", fields.join(", ")))
|
||||
.replace("message Paint PaintType type,", "message Paint ManyFilters paintFilter, PaintType type,");
|
||||
let filters = o(names.iter().map(|n| (*n, f(0.5))).collect());
|
||||
let paint = o(vec![("type", e("IMAGE")), ("image", o(vec![("hash", Kv::Bytes(HASH.to_vec()))])), ("paintFilter", filters)]);
|
||||
let nodes = vec![
|
||||
node(0, None, "DOCUMENT", "Document", vec![]),
|
||||
node(1, Some((0, "!")), "CANVAS", "Page", vec![]),
|
||||
node(2, Some((1, "!")), "RECTANGLE", "Photo", vec![("size", xy(2.0, 1.0)), ("fillPaints", arr(vec![paint]))]),
|
||||
];
|
||||
let bytes = zip_file(&canvas(&Schema::parse(&text).unwrap(), &o(vec![("nodeChanges", arr(nodes))])));
|
||||
let (_, warnings) = import(&bytes, "ManyFilters.fig").unwrap();
|
||||
let named = warnings.iter().filter(|w| w.contains("unsupported image filter")).count();
|
||||
assert!((100..=crate::rest::MAX_WARNINGS).contains(&named), "{named} of {} warnings", warnings.len());
|
||||
}
|
||||
+1106
-80
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,145 @@
|
||||
//! Connector snapshot flattening stays within the node and depth budgets.
|
||||
|
||||
use super::{MAX_DEPTH, MAX_NODES, Mapper, View};
|
||||
use crate::fig::kiwi::Kv;
|
||||
use figcraft_doc::geom::Affine;
|
||||
|
||||
fn obj(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
|
||||
Kv::Object(fields)
|
||||
}
|
||||
|
||||
fn guid(local_id: u64) -> Kv<'static> {
|
||||
obj(vec![("sessionID", Kv::Uint(40_000_000)), ("localID", Kv::Uint(local_id))])
|
||||
}
|
||||
|
||||
fn path_blob() -> Vec<u8> {
|
||||
let mut bytes = vec![1];
|
||||
bytes.extend(0.0f32.to_le_bytes());
|
||||
bytes.extend(0.0f32.to_le_bytes());
|
||||
bytes.push(2);
|
||||
bytes.extend(20.0f32.to_le_bytes());
|
||||
bytes.extend(10.0f32.to_le_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
fn record(local_id: u64, mut fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
|
||||
fields.push(("guidPath", obj(vec![("guids", Kv::Array(vec![guid(local_id)]))])));
|
||||
obj(fields)
|
||||
}
|
||||
|
||||
fn message() -> Kv<'static> {
|
||||
let line = record(
|
||||
0,
|
||||
vec![
|
||||
(
|
||||
"transform",
|
||||
obj(vec![
|
||||
("m00", Kv::Float(1.0)),
|
||||
("m01", Kv::Float(0.0)),
|
||||
("m02", Kv::Float(0.0)),
|
||||
("m10", Kv::Float(0.0)),
|
||||
("m11", Kv::Float(1.0)),
|
||||
("m12", Kv::Float(0.0)),
|
||||
]),
|
||||
),
|
||||
("size", obj(vec![("x", Kv::Float(20.0)), ("y", Kv::Float(10.0))])),
|
||||
("fillGeometry", Kv::Array(vec![obj(vec![("commandsBlob", Kv::Uint(0))])])),
|
||||
],
|
||||
);
|
||||
let text = record(
|
||||
1,
|
||||
vec![
|
||||
(
|
||||
"transform",
|
||||
obj(vec![
|
||||
("m00", Kv::Float(1.0)),
|
||||
("m01", Kv::Float(0.0)),
|
||||
("m02", Kv::Float(0.0)),
|
||||
("m10", Kv::Float(0.0)),
|
||||
("m11", Kv::Float(1.0)),
|
||||
("m12", Kv::Float(0.0)),
|
||||
]),
|
||||
),
|
||||
("size", obj(vec![("x", Kv::Float(50.0)), ("y", Kv::Float(20.0))])),
|
||||
],
|
||||
);
|
||||
let background = record(
|
||||
2,
|
||||
vec![
|
||||
(
|
||||
"transform",
|
||||
obj(vec![
|
||||
("m00", Kv::Float(1.0)),
|
||||
("m01", Kv::Float(0.0)),
|
||||
("m02", Kv::Float(0.0)),
|
||||
("m10", Kv::Float(0.0)),
|
||||
("m11", Kv::Float(1.0)),
|
||||
("m12", Kv::Float(0.0)),
|
||||
]),
|
||||
),
|
||||
("size", obj(vec![("x", Kv::Float(60.0)), ("y", Kv::Float(30.0))])),
|
||||
],
|
||||
);
|
||||
let line_style = record(0, vec![]);
|
||||
let text_style = record(1, vec![("textData", obj(vec![("characters", Kv::Str("budget label".into()))]))]);
|
||||
let background_style = record(2, vec![("fillPaints", Kv::Array(vec![]))]);
|
||||
obj(vec![
|
||||
(
|
||||
"nodeChanges",
|
||||
Kv::Array(vec![obj(vec![
|
||||
("guid", obj(vec![("sessionID", Kv::Uint(1)), ("localID", Kv::Uint(123))])),
|
||||
("type", Kv::Enum("CONNECTOR")),
|
||||
("name", Kv::Str("budget connector".into())),
|
||||
("connectorType", Kv::Enum("MANUAL")),
|
||||
("connectorVersion", Kv::Float(1.0)),
|
||||
("connectorLineStyle", Kv::Enum("STRAIGHT")),
|
||||
("derivedImmutableFrameData", obj(vec![("version", Kv::Float(1.0)), ("overrides", Kv::Array(vec![line, text, background]))])),
|
||||
("nodeGenerationData", obj(vec![("overrides", Kv::Array(vec![line_style, text_style, background_style]))])),
|
||||
])]),
|
||||
),
|
||||
("blobs", Kv::Array(vec![obj(vec![("bytes", Kv::Bytes(path_blob()))])])),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connector_snapshot_respects_last_and_zero_child_slots() {
|
||||
let message = message();
|
||||
let node = message.get("nodeChanges").unwrap().list().first().unwrap();
|
||||
let view = View::of(node);
|
||||
let mut mapper = Mapper::new(&message);
|
||||
mapper.count = MAX_NODES - 1;
|
||||
let children = mapper.connector_snapshot_children(&view, "1:123");
|
||||
assert_eq!(children.len(), 1, "the final available slot preserves the saved vector");
|
||||
assert_eq!(children[0]["type"], "VECTOR");
|
||||
assert!(!children[0]["fillGeometry"].as_array().unwrap().is_empty(), "saved outline survives the budget limit");
|
||||
assert_eq!(children[0]["fillGeometry"][0]["path"], "M 0 0 L 20 10");
|
||||
assert_eq!(mapper.count, MAX_NODES);
|
||||
assert!(mapper.warnings.iter().any(|warning| warning == &format!("the file has more than {MAX_NODES} layers; connector label was skipped")));
|
||||
|
||||
let mut mapper = Mapper::new(&message);
|
||||
mapper.count = MAX_NODES;
|
||||
let children = mapper.connector_snapshot_children(&view, "1:123");
|
||||
assert!(children.is_empty());
|
||||
assert_eq!(mapper.count, MAX_NODES, "the node budget is never exceeded");
|
||||
assert!(mapper.warnings.iter().any(|warning| warning.contains("connector snapshot children were skipped")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connector_snapshot_flattens_vector_at_depth_and_node_limits() {
|
||||
let message = message();
|
||||
let mut mapper = Mapper::new(&message);
|
||||
let depth_limited = mapper.layer("1:123", None, Affine::IDENTITY, None, None, MAX_DEPTH).expect("terminal connector");
|
||||
assert_eq!(depth_limited["type"], "VECTOR");
|
||||
assert!(!depth_limited["fillGeometry"].as_array().unwrap().is_empty());
|
||||
assert!(depth_limited.get("children").is_none());
|
||||
assert!(mapper.warnings.iter().any(|warning| warning == "connector label was skipped: depth limit prevents nested snapshot children"));
|
||||
|
||||
let mut mapper = Mapper::new(&message);
|
||||
mapper.count = MAX_NODES - 1;
|
||||
let budget_limited = mapper.layer("1:123", None, Affine::IDENTITY, None, None, 0).expect("last allowed connector");
|
||||
assert_eq!(budget_limited["type"], "VECTOR");
|
||||
assert!(!budget_limited["fillGeometry"].as_array().unwrap().is_empty());
|
||||
assert!(budget_limited.get("children").is_none());
|
||||
assert_eq!(mapper.count, MAX_NODES);
|
||||
assert!(mapper.warnings.iter().any(|warning| warning == "connector label was skipped: node budget prevents nested snapshot children"));
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//! Malformed image paint filters are skipped with a warning.
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn malformed_non_finite_filter_is_skipped_with_warning() {
|
||||
let paint = Kv::Object(vec![("type", Kv::Enum("IMAGE")), ("paintFilter", Kv::Object(vec![("exposure", Kv::Float(f32::NAN))]))]);
|
||||
let mut warnings = Vec::new();
|
||||
let value = super::paint(&paint, &mut |warning| warnings.push(warning)).unwrap();
|
||||
assert!(value.get("filters").is_none(), "{value}");
|
||||
assert!(warnings.iter().any(|warning| warning.contains("malformed image filter `exposure`")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boolean_filter_values_are_malformed_even_when_false() {
|
||||
for (field, value, expected_warning) in [
|
||||
("exposure", Kv::Bool(true), "malformed image filter `exposure`"),
|
||||
("exposure", Kv::Bool(false), "malformed image filter `exposure`"),
|
||||
("mystery", Kv::Bool(false), "malformed unsupported image filter `mystery`"),
|
||||
] {
|
||||
let paint = Kv::Object(vec![("type", Kv::Enum("IMAGE")), ("paintFilter", Kv::Object(vec![(field, value)]))]);
|
||||
let mut warnings = Vec::new();
|
||||
let value = super::paint(&paint, &mut |warning| warnings.push(warning)).unwrap();
|
||||
assert!(value.get("filters").is_none(), "{value}");
|
||||
assert!(warnings.iter().any(|warning| warning.contains(expected_warning)), "{warnings:?}");
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@
|
||||
//! tables, …) come in as the closest frames, text and vectors. Anything not understood is skipped
|
||||
//! with a warning; malformed input never panics.
|
||||
|
||||
#[cfg(test)]
|
||||
mod connector_label_tests;
|
||||
mod export;
|
||||
#[cfg(test)]
|
||||
mod export_tests;
|
||||
@@ -21,7 +23,20 @@ pub mod kiwi;
|
||||
mod map;
|
||||
mod network;
|
||||
#[cfg(test)]
|
||||
mod slot_tests;
|
||||
#[cfg(test)]
|
||||
mod source_identity_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod text_paragraph_tests;
|
||||
mod text_path;
|
||||
#[cfg(test)]
|
||||
mod text_path_tests;
|
||||
#[cfg(test)]
|
||||
mod text_skip_ink_tests;
|
||||
#[cfg(test)]
|
||||
mod text_style_tests;
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
@@ -64,7 +79,7 @@ pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec<String>), Forma
|
||||
let file_name = archive.file_name.as_deref().filter(|n| !n.trim().is_empty()).unwrap_or(name);
|
||||
let (json, mut warnings) = map::to_rest(&msg, file_name);
|
||||
let images = |r: &str| archive.image(r);
|
||||
let (doc, more) = crate::rest::import(&json, &images)?;
|
||||
let (doc, more) = crate::rest::import_fig(&json, &images)?;
|
||||
warnings.extend(more.into_iter().filter_map(map::adapt_warning));
|
||||
Ok((doc, warnings))
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ pub(super) struct Vertex {
|
||||
}
|
||||
|
||||
pub(super) struct Segment {
|
||||
pub style: u32,
|
||||
pub a: usize,
|
||||
pub ta: (f64, f64),
|
||||
pub b: usize,
|
||||
@@ -76,8 +77,8 @@ pub(super) fn decode(b: &[u8]) -> Option<Network> {
|
||||
net.vertices.push(Vertex { style: r.u32()?, x: r.f32()?, y: r.f32()? });
|
||||
}
|
||||
for _ in 0..ns {
|
||||
r.u32()?;
|
||||
net.segments.push(Segment { a: r.index(nv)?, ta: (r.f32()?, r.f32()?), b: r.index(nv)?, tb: (r.f32()?, r.f32()?) });
|
||||
let style = r.u32()?;
|
||||
net.segments.push(Segment { style, a: r.index(nv)?, ta: (r.f32()?, r.f32()?), b: r.index(nv)?, tb: (r.f32()?, r.f32()?) });
|
||||
}
|
||||
let mut entries = 0usize;
|
||||
for _ in 0..nr {
|
||||
@@ -110,7 +111,7 @@ pub(super) fn encode(net: &Network) -> Vec<u8> {
|
||||
fl(v.y, &mut out);
|
||||
}
|
||||
for s in &net.segments {
|
||||
u(0, &mut out);
|
||||
u(s.style, &mut out);
|
||||
u(s.a as u32, &mut out);
|
||||
fl(s.ta.0, &mut out);
|
||||
fl(s.ta.1, &mut out);
|
||||
|
||||
Loaded 100 of 217 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user