mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-14 06:44:43 +00:00
Compare commits
180
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e25c48cd56 | ||
|
|
42242e3c8b | ||
|
|
dc184083ef | ||
|
|
9265d554af | ||
|
|
c8b611a802 | ||
|
|
1d928a189c | ||
|
|
03bb164b5c | ||
|
|
91c80a19cc | ||
|
|
68b19422ad | ||
|
|
9a69f79861 | ||
|
|
0a01188c31 | ||
|
|
6afcfc2641 | ||
|
|
c6d6433473 | ||
|
|
bb8b809d46 | ||
|
|
5962b630a1 | ||
|
|
df66ddd3a4 | ||
|
|
e39d24a32e | ||
|
|
c532053396 | ||
|
|
b8f97a5f4f | ||
|
|
2c350ca7fd | ||
|
|
8b3beac6bf | ||
|
|
ba433cc7b0 | ||
|
|
84ffdfc786 | ||
|
|
137425ab25 | ||
|
|
468bc5734d | ||
|
|
8e52c8cac2 | ||
|
|
8495309ea2 | ||
|
|
420ef78842 | ||
|
|
d254d5a6ca | ||
|
|
acfc0352c3 | ||
|
|
725e2c6913 | ||
|
|
e77a02993e | ||
|
|
88925c2f82 | ||
|
|
89174280d1 | ||
|
|
cc10c0de55 | ||
|
|
6b52bddc5b | ||
|
|
8aa5fd1a3b | ||
|
|
f84616702d | ||
|
|
af27ff7d2a | ||
|
|
17758458ef | ||
|
|
2efdeb593b | ||
|
|
96091494a5 | ||
|
|
ac4b98dbd7 | ||
|
|
ab1ae981f3 | ||
|
|
9417d13b26 | ||
|
|
c542af4efe | ||
|
|
23fbd8f935 | ||
|
|
adde034a7c | ||
|
|
64423c58bc | ||
|
|
6cdfe2ab8e | ||
|
|
2e3949f0ba | ||
|
|
fdd4eb5cad | ||
|
|
18b8bc11e9 | ||
|
|
b64ec66244 | ||
|
|
82ae9b1f59 | ||
|
|
1336d449d3 | ||
|
|
98e47487f4 | ||
|
|
28a23a9d1d | ||
|
|
2ebc496d07 | ||
|
|
6a76d4fa85 | ||
|
|
c448b85779 | ||
|
|
41abce53b7 | ||
|
|
05ecbe8d49 | ||
|
|
bacca0a0f0 | ||
|
|
e8fc6d410e | ||
|
|
05d4f1903b | ||
|
|
feeb9f71db | ||
|
|
7482410753 | ||
|
|
3365ae0370 | ||
|
|
0fe7e1e7db | ||
|
|
450faf7269 | ||
|
|
b67b71732f | ||
|
|
cbbe735db0 | ||
|
|
00b0b08680 | ||
|
|
99d097636e | ||
|
|
e17f58ba85 | ||
|
|
e912a2efe8 | ||
|
|
1ede34074e | ||
|
|
e86cae4fe0 | ||
|
|
59d3d7e0f8 | ||
|
|
2945b3042c | ||
|
|
98fcf2472b | ||
|
|
6f2fb6cce8 | ||
|
|
74c2088292 | ||
|
|
432a3e4016 | ||
|
|
c7cba0d626 | ||
|
|
b96e007a2c | ||
|
|
a2e9b5ade2 | ||
|
|
6f8c2a657e | ||
|
|
e2d9e7b493 | ||
|
|
3b66750a5a | ||
|
|
0232031bbe | ||
|
|
734f8efedd | ||
|
|
8a66e7bdc5 | ||
|
|
fec54d9361 | ||
|
|
f5112a3d52 | ||
|
|
234339a786 | ||
|
|
7f7921260c | ||
|
|
b6d98e28dc | ||
|
|
fba23e9ddb | ||
|
|
02b231db6d | ||
|
|
e8260b1b7a | ||
|
|
3c73045088 | ||
|
|
7da7ed226e | ||
|
|
e095ef1fe1 | ||
|
|
7d67d13b62 | ||
|
|
93fc6fd082 | ||
|
|
1503f0743c | ||
|
|
b377fab0a5 | ||
|
|
7bf814f6b4 | ||
|
|
6f0e14a271 | ||
|
|
990e621ec4 | ||
|
|
5e2913db34 | ||
|
|
415722439a | ||
|
|
b3a2891c2e | ||
|
|
54bb194531 | ||
|
|
5ea5590673 | ||
|
|
01ce23ca76 | ||
|
|
5b963dce08 | ||
|
|
abfc0071f1 | ||
|
|
7de7dbc991 | ||
|
|
da76c806cc | ||
|
|
ff418eaaac | ||
|
|
b044038857 | ||
|
|
32266dcd93 | ||
|
|
cd8a88733f | ||
|
|
efe4911eb0 | ||
|
|
a9bcc48df7 | ||
|
|
1b4db71957 | ||
|
|
b0f56ed169 | ||
|
|
8c90ef962a | ||
|
|
554821afef | ||
|
|
0e25cf4c26 | ||
|
|
3065cd6da9 | ||
|
|
ac1a192a3a | ||
|
|
9daf39e24c | ||
|
|
0f4c79b629 | ||
|
|
805a121e15 | ||
|
|
e1d9f24312 | ||
|
|
c5a8420089 | ||
|
|
7d7a724764 | ||
|
|
52eb736241 | ||
|
|
be3912f986 | ||
|
|
4484c2d1b0 | ||
|
|
6d3e7aad20 | ||
|
|
a911ab9614 | ||
|
|
afdb21bad6 | ||
|
|
a4156d2910 | ||
|
|
5be9a8caef | ||
|
|
1ac0e475c1 | ||
|
|
107aa27d0a | ||
|
|
167e06f695 | ||
|
|
cc26c29217 | ||
|
|
a00b2e0583 | ||
|
|
ba3455cc6e | ||
|
|
8538dd740f | ||
|
|
b525fde8d1 | ||
|
|
9c1d65d924 | ||
|
|
5d1c24d061 | ||
|
|
f05eecdaa0 | ||
|
|
8cccc48516 | ||
|
|
c6a3f25224 | ||
|
|
b83a0de7a8 | ||
|
|
c8ba7b4a4b | ||
|
|
ce6726aad3 | ||
|
|
692d52c514 | ||
|
|
0a5eceedf1 | ||
|
|
fd24a6b883 | ||
|
|
35005a571d | ||
|
|
97d09369d1 | ||
|
|
69154a03f1 | ||
|
|
9809caaa30 | ||
|
|
7a5885cde9 | ||
|
|
d0c1c12969 | ||
|
|
1e0641a0bd | ||
|
|
48b62a8b95 | ||
|
|
93a75a4720 | ||
|
|
39d0f2d872 | ||
|
|
7a8e5373b1 | ||
|
|
1ddc2c338b |
No files matched your search
@@ -18,16 +18,6 @@ concurrency:
|
||||
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
|
||||
nix:
|
||||
# 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/nix.yml@main
|
||||
Generated
+45
-5
@@ -1422,11 +1422,14 @@ dependencies = [
|
||||
"fmv-macos-events",
|
||||
"image",
|
||||
"log",
|
||||
"raw-window-handle",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"wat",
|
||||
"wayland-client",
|
||||
"webbrowser",
|
||||
"windows",
|
||||
"winit",
|
||||
"winresource",
|
||||
]
|
||||
|
||||
@@ -1600,7 +1603,7 @@ dependencies = [
|
||||
"krilla",
|
||||
"log",
|
||||
"thiserror 2.0.21",
|
||||
"tiny-skia",
|
||||
"tiny-skia 0.12.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1631,7 +1634,7 @@ dependencies = [
|
||||
"moxcms",
|
||||
"png",
|
||||
"rayon",
|
||||
"tiny-skia",
|
||||
"tiny-skia 0.12.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2570,7 +2573,7 @@ dependencies = [
|
||||
"skrifa 0.42.1",
|
||||
"smallvec",
|
||||
"subsetter",
|
||||
"tiny-skia-path",
|
||||
"tiny-skia-path 0.12.0",
|
||||
"xmp-writer",
|
||||
"yoke",
|
||||
"zune-jpeg",
|
||||
@@ -4018,6 +4021,17 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "sctk-adwaita"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
|
||||
dependencies = [
|
||||
"log",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"tiny-skia 0.11.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "self_cell"
|
||||
version = "1.3.0"
|
||||
@@ -4477,6 +4491,20 @@ dependencies = [
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"bytemuck",
|
||||
"cfg-if",
|
||||
"log",
|
||||
"tiny-skia-path 0.11.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia"
|
||||
version = "0.12.0"
|
||||
@@ -4489,7 +4517,18 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"log",
|
||||
"png",
|
||||
"tiny-skia-path",
|
||||
"tiny-skia-path 0.12.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia-path"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"bytemuck",
|
||||
"strict-num",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4778,7 +4817,7 @@ dependencies = [
|
||||
"skrifa 0.44.0",
|
||||
"strict-num",
|
||||
"svgtypes",
|
||||
"tiny-skia-path",
|
||||
"tiny-skia-path 0.12.0",
|
||||
"unicode-bidi",
|
||||
"unicode-script",
|
||||
"unicode-vo",
|
||||
@@ -5731,6 +5770,7 @@ dependencies = [
|
||||
"raw-window-handle",
|
||||
"redox_syscall 0.4.1",
|
||||
"rustix 0.38.44",
|
||||
"sctk-adwaita",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"smol_str",
|
||||
"tracing",
|
||||
|
||||
@@ -20,3 +20,34 @@ licence texts.
|
||||
Rust crate dependencies keep their own licenses (MIT, Apache-2.0, BSD, ISC, Zlib, OFL for egui's
|
||||
default fonts, CDLA-Permissive-2.0 for the root certificate list webpki-roots compiles in, and
|
||||
similar permissive licenses); see each crate's metadata.
|
||||
|
||||
Linux Wayland window decorations
|
||||
-------------------------------
|
||||
sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita,
|
||||
commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux
|
||||
for vector window controls. Its title-font features are disabled: no Cantarell
|
||||
font is embedded by this dependency and no font file is added to this repo.
|
||||
|
||||
sctk-adwaita's MIT licence:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Bartłomiej Maryńczak
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -228,7 +228,7 @@ The desktop app also offers a loopback-only control channel (`figcraft --control
|
||||
- **Runs in the browser:** the whole engine and UI compile to WebAssembly.
|
||||
- **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes.
|
||||
- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech and Indonesian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)).
|
||||
- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and two example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md).
|
||||
- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and three example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md).
|
||||
- **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md).
|
||||
- **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md).
|
||||
- **Never crashes:** no `unwrap`, `expect` or `panic` in shipped code, enforced by clippy; commands run behind a guard so a bug fails one action, not your session.
|
||||
|
||||
+17
-13
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
|
||||
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
|
||||
@@ -13,7 +13,7 @@ start with [`docs/gaps.md`](docs/gaps.md).
|
||||
|
||||
| | Value | Kind |
|
||||
|---|---|---|
|
||||
| Menu/command breadth | **100%** (211 / 211) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) |
|
||||
| Menu/command breadth | **100%** (212 / 212) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) |
|
||||
| Feature breadth | **~85%** (core editor ~94%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) |
|
||||
| Ready for real work | **~53%** | estimated, same doc (written dimension weights and scores) |
|
||||
| Essentials user | **~59%** | estimated: core features 77.8% × 0.92 launch/stability × 0.94 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) |
|
||||
@@ -99,25 +99,25 @@ native speakers' reviews of the six language drafts need people.
|
||||
| Language | Code | Strings | Status |
|
||||
|---|---|---|---|
|
||||
| English | en | 100% | full |
|
||||
| Simplified Chinese | zh-Hans | 100% | draft (needs native review) |
|
||||
| Spanish | es, es-419 | 100% (es-419: an overlay of 98 entries) | draft (needs native review) |
|
||||
| Simplified Chinese | zh-Hans | 95% | draft (needs native review) |
|
||||
| Spanish | es, es-419 | 100% (es-419: an overlay of 101 entries) | draft (needs native review) |
|
||||
| Hindi | hi | 0% | none |
|
||||
| Arabic | ar | 0% | none |
|
||||
| French | fr | 100% | draft (needs native review) |
|
||||
| Portuguese (Brazil) | pt-BR | 100% | draft (needs native review) |
|
||||
| Indonesian | id | 0% | none |
|
||||
| Japanese | ja | 100% | draft (needs native review) |
|
||||
| German | de | 100% | draft (needs native review) |
|
||||
| Korean | ko | 100% | draft (needs native review) |
|
||||
| French | fr | 95% | draft (needs native review) |
|
||||
| Portuguese (Brazil) | pt-BR | 95% | draft (needs native review) |
|
||||
| Indonesian | id | 99% | draft (needs native review) |
|
||||
| Japanese | ja | 99% | draft (needs native review) |
|
||||
| German | de | 96% | draft (needs native review) |
|
||||
| Korean | ko | 95% | draft (needs native review) |
|
||||
| Vietnamese | vi | 0% | none |
|
||||
|
||||
Also shipped: Swedish (`sv`: every UI string, 120 of 588 command labels and menu wordings; native review welcome). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
|
||||
Percentages measured 2026-10-17 over 2,272 entries (strings added since a draft show English). Also shipped: Russian (`ru`, 99%, reviewed by VanTan), Swedish (`sv`, 99.6%, command labels since 2026-10-17; native review welcome), Turkish (`tr`, 99%) and Czech (`cs`, 99%, draft). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
|
||||
(Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America),
|
||||
French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts (Preferences ▸
|
||||
French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese and Indonesian drafts (Preferences ▸
|
||||
Language; Traditional Chinese has no catalog; CJK UI
|
||||
text uses an installed CJK font, loaded only when needed; adding a language:
|
||||
[`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping,
|
||||
IME) already works. Detail: [`docs/localization-parity.md`](docs/localization-parity.md).
|
||||
IME) already works (macOS IME composition fixed for #747, not yet checked on a Mac). Detail: [`docs/localization-parity.md`](docs/localization-parity.md).
|
||||
|
||||
## Upcoming
|
||||
|
||||
@@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-17** · Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged.
|
||||
- **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged.
|
||||
- **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged.
|
||||
- **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged.
|
||||
@@ -315,6 +316,9 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). `cargo xtask parity` re-run: 212 / 212 menu items (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Language table re-measured from the catalogs (2,272 entries): the drafts read 95–99% (strings added since 2026-10-12 show English), Indonesian has a draft (99%; it read none), and Russian, Swedish (its command labels from #599), Turkish and Czech are listed with their counts. Per-area parity rows for the day's landings in `docs/gaps.md`, `target-app-parity.md` and the area docs. Numbers unchanged |
|
||||
| 2026-10-17 | minor | Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. |
|
||||
| 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. |
|
||||
| 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. |
|
||||
| 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. |
|
||||
| 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged |
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Minimal std-only argument parsing: positionals, `--key value` / `--key=value` / `-o value`
|
||||
//! options, and boolean `--flag`s. Unknown options are usage errors.
|
||||
//! options (given again: the last counts, or all with [`Args::values`]), and boolean `--flag`s.
|
||||
//! Unknown options are usage errors.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -8,7 +9,7 @@ use crate::{CliError, CliResult};
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Args {
|
||||
pub positional: Vec<String>,
|
||||
values: BTreeMap<String, String>,
|
||||
values: BTreeMap<String, Vec<String>>,
|
||||
flags: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -34,7 +35,7 @@ impl Args {
|
||||
Some(v) => v,
|
||||
None => it.next().cloned().ok_or_else(|| CliError::Usage(format!("`{arg}` needs a value")))?,
|
||||
};
|
||||
a.values.insert(name.to_string(), v);
|
||||
a.values.entry(name.to_string()).or_default().push(v);
|
||||
} else if flags.contains(&name) && inline.is_none() {
|
||||
a.flags.push(name.to_string());
|
||||
} else {
|
||||
@@ -45,7 +46,12 @@ impl Args {
|
||||
}
|
||||
|
||||
pub fn value(&self, k: &str) -> Option<String> {
|
||||
self.values.get(k).cloned()
|
||||
self.values.get(k).and_then(|v| v.last()).cloned()
|
||||
}
|
||||
|
||||
/// Every value of an option that may be given more than once, in order.
|
||||
pub fn values(&self, k: &str) -> &[String] {
|
||||
self.values.get(k).map_or(&[], Vec::as_slice)
|
||||
}
|
||||
|
||||
pub fn flag(&self, k: &str) -> bool {
|
||||
@@ -62,9 +68,9 @@ impl Args {
|
||||
/// PDF print options: `--bleed LENGTH` (points, or "3mm", "0.125in"), `--crop-marks`,
|
||||
/// `--bleed-marks`, `--cmyk`.
|
||||
pub fn print(&self) -> CliResult<figcraft_doc::PrintSettings> {
|
||||
let bleed = match self.values.get("bleed") {
|
||||
let bleed = match self.value("bleed") {
|
||||
None => 0.0,
|
||||
Some(s) => figcraft_doc::PrintSettings::parse_length(s)
|
||||
Some(s) => figcraft_doc::PrintSettings::parse_length(&s)
|
||||
.filter(|b| *b <= figcraft_doc::PrintSettings::MAX_BLEED)
|
||||
.ok_or_else(|| CliError::Usage(format!("--bleed takes points or a length like 3mm or 0.125in (up to 2in), got `{s}`")))?,
|
||||
};
|
||||
@@ -72,7 +78,7 @@ impl Args {
|
||||
}
|
||||
|
||||
pub fn scale(&self) -> CliResult<f64> {
|
||||
match self.values.get("scale") {
|
||||
match self.value("scale") {
|
||||
None => Ok(1.0),
|
||||
Some(s) => s
|
||||
.trim_end_matches('x')
|
||||
@@ -85,7 +91,7 @@ impl Args {
|
||||
|
||||
/// A comma-separated list of whole numbers (`--layers 1000,10_000`).
|
||||
pub fn usizes(&self, k: &str, default: &[usize]) -> CliResult<Vec<usize>> {
|
||||
match self.values.get(k) {
|
||||
match self.value(k) {
|
||||
None => Ok(default.to_vec()),
|
||||
Some(s) => s
|
||||
.split(',')
|
||||
@@ -110,6 +116,10 @@ mod tests {
|
||||
assert_eq!(a.value("o").as_deref(), Some("out"));
|
||||
assert_eq!(a.scale().unwrap(), 2.0);
|
||||
assert!(a.flag("json"));
|
||||
let a = Args::parse(&v(&["--root", "a", "--root=b"]), &["root"], &[]).unwrap();
|
||||
assert_eq!(a.values("root"), ["a", "b"]);
|
||||
assert_eq!(a.value("root").as_deref(), Some("b"));
|
||||
assert!(a.values("none").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -333,17 +333,32 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe
|
||||
}
|
||||
None => Session::new(),
|
||||
};
|
||||
for (i, (cmd, params)) in steps.iter().enumerate() {
|
||||
let r = s.execute(cmd, params);
|
||||
s.ui_requests.clear();
|
||||
match r {
|
||||
Ok(v) => println!("{}", json!({"step": i, "command": cmd, "ok": true, "result": v})),
|
||||
Err(e) => {
|
||||
println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string()}));
|
||||
eprintln!("error: step {i} ({cmd}) failed: {e}");
|
||||
return Ok(ExitCode::from(1));
|
||||
}
|
||||
// One undo step and all-or-nothing, the way the same commands behave through `command.batch`
|
||||
// (MCP, the control channel, plugins): `{"$step": n}` resolves against the steps before it, and
|
||||
// a step that fails takes the whole script back instead of leaving the earlier steps applied.
|
||||
// The result lines are held back until the script is through, so none of them reports work that
|
||||
// was rolled back.
|
||||
let mut results: Vec<Value> = Vec::with_capacity(steps.len());
|
||||
let mut failed_at = 0;
|
||||
let outcome = s.grouped(&format!("run {script}"), |s| {
|
||||
for (i, (cmd, params)) in steps.iter().enumerate() {
|
||||
failed_at = i;
|
||||
let v = s.batch_step(&results, i, cmd, params)?;
|
||||
s.ui_requests.clear();
|
||||
results.push(v);
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
if let Err(e) = outcome {
|
||||
let cmd = steps.get(failed_at).map_or("", |(cmd, _)| cmd.as_str());
|
||||
let i = failed_at;
|
||||
println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string(), "rolledBack": true}));
|
||||
eprintln!("error: step {i} ({cmd}) failed: {e}");
|
||||
eprintln!("the script's document changes were rolled back");
|
||||
return Ok(ExitCode::from(1));
|
||||
}
|
||||
for (i, ((cmd, _), v)) in steps.iter().zip(&results).enumerate() {
|
||||
println!("{}", json!({"step": i, "command": cmd, "ok": true, "result": v}));
|
||||
}
|
||||
if let Some(out) = out {
|
||||
let ds = s.ds().map_err(|e| CliError::Failed(e.to_string()))?;
|
||||
@@ -422,4 +437,62 @@ mod tests {
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
fn temp_dir(tag: &str) -> std::path::PathBuf {
|
||||
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-cli-{tag}-{}-{stamp}", std::process::id()));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// A script resolves `{"$step": n}` like the same commands through `command.batch`. Each step
|
||||
/// used to run on its own, so the reference reached `parent` unresolved, `node.create` read it
|
||||
/// as "no parent" and the layer landed on the page — with every step reporting success.
|
||||
#[test]
|
||||
fn a_script_puts_a_layer_in_a_layer_an_earlier_step_made() {
|
||||
let dir = temp_dir("run-step");
|
||||
let script = dir.join("script.json");
|
||||
std::fs::write(
|
||||
&script,
|
||||
r#"[
|
||||
{"command": "node.create", "params": {"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 200, "height": 100}},
|
||||
{"command": "node.create", "params": {"type": "rectangle", "name": "child", "parent": {"$step": 0}, "x": 10, "y": 10, "width": 20, "height": 20}}
|
||||
]"#,
|
||||
)
|
||||
.unwrap();
|
||||
let out = dir.join("out.figcraft");
|
||||
run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap();
|
||||
|
||||
let mut s = Session::empty();
|
||||
s.execute("file.open", &json!({"path": out.to_str().unwrap()})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let page = s.page().unwrap();
|
||||
let top = doc.node(page).unwrap().children.clone();
|
||||
assert_eq!(top.len(), 1, "only the frame is top level, not the rectangle too");
|
||||
assert_eq!(doc.node(top[0]).unwrap().name, "Card");
|
||||
let inner = doc.node(top[0]).unwrap().children.clone();
|
||||
assert_eq!(inner.len(), 1, "the rectangle went inside the frame");
|
||||
assert_eq!(doc.node(inner[0]).unwrap().name, "child");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// A step that fails takes the whole script back: nothing is written, and the failure says so.
|
||||
#[test]
|
||||
fn a_failed_step_leaves_no_output() {
|
||||
let dir = temp_dir("run-rollback");
|
||||
let script = dir.join("script.json");
|
||||
std::fs::write(
|
||||
&script,
|
||||
r#"[
|
||||
{"command": "node.create", "params": {"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 200, "height": 100}},
|
||||
{"command": "node.set", "params": {"ids": [99999], "props": {"name": "nope"}}}
|
||||
]"#,
|
||||
)
|
||||
.unwrap();
|
||||
let out = dir.join("out.figcraft");
|
||||
// The failure is the script's, not the call's: the CLI reports it and exits non-zero.
|
||||
run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap();
|
||||
assert!(!out.exists(), "a script that failed writes nothing");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ USAGE:
|
||||
figcraft-cli render <file> -o <out.png> [--scale N] [--page NAME]
|
||||
figcraft-cli run <script.json|script.jsonl> [--in <file>] [--out <file>]
|
||||
figcraft-cli commands [--json] [--filter TEXT]
|
||||
figcraft-cli mcp [--attach PORT]
|
||||
figcraft-cli mcp [--attach PORT] [--automation-read-root DIR]... [--automation-write-root DIR]...
|
||||
figcraft-cli plugin publish <folder> --server ws://host:port/[?key=KEY]
|
||||
figcraft-cli perf [--layers N[,N...]] [--instances N[,N...]]
|
||||
figcraft-cli help | --help | --version
|
||||
@@ -42,6 +42,10 @@ crop and bleed marks, CMYK colour). `run` executes a JSON array (or JSON lines)
|
||||
`plugin publish` sends a plugin folder (figcraft-plugin.json, its module, an optional assets/ folder)
|
||||
to a figcraft-server's plugin directory as a new version; the server's key comes from the address or
|
||||
the FIGCRAFT_SERVER_KEY environment variable (which keeps it out of the shell history).
|
||||
`mcp --automation-read-root DIR` / `--automation-write-root DIR` (each may be repeated; the folders
|
||||
must exist) confine the files the agent's commands touch: reads only inside a read or write root,
|
||||
writes only inside a write root, everything else refused. A running app confines itself: start it
|
||||
with `figcraft --control PORT` and the same flags (`--attach` refuses them).
|
||||
See docs/cli.md and docs/mcp.md.";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
@@ -133,11 +137,23 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
"mcp" => {
|
||||
let a = Args::parse(rest, &["attach"], &[])?;
|
||||
let a = Args::parse(rest, &["attach", "automation-read-root", "automation-write-root"], &[])?;
|
||||
if !a.positional.is_empty() {
|
||||
return Err(CliError::Usage("mcp takes no positional arguments".into()));
|
||||
}
|
||||
mcp(a.value("attach").as_deref())
|
||||
let (read, write) = (a.values("automation-read-root"), a.values("automation-write-root"));
|
||||
let roots = match (read.is_empty() && write.is_empty(), a.value("attach")) {
|
||||
(true, _) => None,
|
||||
// The app runs the commands: only its own roots can confine them.
|
||||
(false, Some(_)) => {
|
||||
return Err(CliError::Usage(
|
||||
"--attach can't take automation roots: start the app with them instead (`figcraft --control PORT --automation-read-root DIR ...`)"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
(false, None) => Some(figcraft_engine::file_access::Roots::new(read, write).map_err(CliError::Failed)?),
|
||||
};
|
||||
mcp(a.value("attach").as_deref(), roots)
|
||||
}
|
||||
"plugin" => {
|
||||
let a = Args::parse(rest, &["server"], &[])?;
|
||||
@@ -205,7 +221,7 @@ fn publish_plugin(folder: &str, server: &str) -> CliResult<ExitCode> {
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
|
||||
fn mcp(attach: Option<&str>) -> CliResult<ExitCode> {
|
||||
fn mcp(attach: Option<&str>, roots: Option<figcraft_engine::file_access::Roots>) -> CliResult<ExitCode> {
|
||||
let backend: Box<dyn figcraft_mcp::Backend> = match attach {
|
||||
Some(p) => {
|
||||
let addr = if p.contains(':') {
|
||||
@@ -220,7 +236,10 @@ fn mcp(attach: Option<&str>) -> CliResult<ExitCode> {
|
||||
None => Box::new(figcraft_mcp::Headless::new()),
|
||||
};
|
||||
eprintln!("figcraft MCP server ({}) on stdio", backend.describe());
|
||||
let mut server = figcraft_mcp::Server::new(backend);
|
||||
if let Some(r) = &roots {
|
||||
eprintln!("file access confined to {}", r.to_json());
|
||||
}
|
||||
let mut server = figcraft_mcp::Server::new(backend).with_roots(roots);
|
||||
let stdin = std::io::stdin();
|
||||
let stdout = std::io::stdout();
|
||||
server.serve(stdin.lock(), stdout.lock()).map_err(|e| format!("stdio: {e}"))?;
|
||||
|
||||
@@ -25,6 +25,9 @@ fn bad_arguments_exit_2_without_panicking() {
|
||||
&["render", "a", "-o"],
|
||||
&["perf", "--shapes", "lots"],
|
||||
&["mcp", "--attach", "notaport"],
|
||||
// An attached app confines itself: roots go to `figcraft --control`.
|
||||
&["mcp", "--attach", "7979", "--automation-read-root", "."],
|
||||
&["mcp", "--automation-write-root"],
|
||||
&["convert", "a"],
|
||||
] {
|
||||
let out = bin().args(args).output().unwrap();
|
||||
@@ -120,6 +123,49 @@ fn mcp_over_stdio() {
|
||||
assert_eq!(lines[2]["result"]["isError"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_over_stdio_confined_to_automation_roots() {
|
||||
let d = tmp("roots");
|
||||
for sub in ["in", "out", "outside"] {
|
||||
std::fs::create_dir_all(d.join(sub)).unwrap();
|
||||
}
|
||||
let p = |rel: &str| d.join(rel).to_string_lossy().into_owned();
|
||||
// A root that isn't a folder stops the server before it serves.
|
||||
let out = bin().args(["mcp", "--automation-read-root", &p("missing")]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("--automation-read-root"));
|
||||
let mut child = bin()
|
||||
.args(["mcp", "--automation-read-root", &p("in"), "--automation-write-root", &p("out")])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
{
|
||||
let stdin = child.stdin.as_mut().unwrap();
|
||||
let call = |id: u32, tool: &str, args: serde_json::Value| {
|
||||
serde_json::json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": {"name": tool, "arguments": args}}).to_string()
|
||||
};
|
||||
writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap();
|
||||
writeln!(stdin, "{}", call(2, "create_node", serde_json::json!({"type": "ellipse"}))).unwrap();
|
||||
writeln!(stdin, "{}", call(3, "save_file", serde_json::json!({"path": p("outside/a.figcraft")}))).unwrap();
|
||||
writeln!(stdin, "{}", call(4, "save_file", serde_json::json!({"path": p("out/a.figcraft")}))).unwrap();
|
||||
writeln!(stdin, "{}", call(5, "open_file", serde_json::json!({"path": p("outside/a.figcraft")}))).unwrap();
|
||||
writeln!(stdin, "{}", call(6, "open_file", serde_json::json!({"path": p("out/a.figcraft")}))).unwrap();
|
||||
}
|
||||
drop(child.stdin.take());
|
||||
let out = child.wait_with_output().unwrap();
|
||||
assert!(out.status.success());
|
||||
let lines: Vec<serde_json::Value> = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
assert_eq!(lines.len(), 6);
|
||||
assert!(lines[0]["result"]["instructions"].as_str().unwrap().contains("only these folders may be read"));
|
||||
let error = |i: usize| lines[i]["result"]["isError"] == true;
|
||||
assert!(!error(1) && error(2) && !error(3) && error(4) && !error(5), "{lines:?}");
|
||||
assert!(lines[2]["result"]["content"][0]["text"].as_str().unwrap().contains("automation path rejected"));
|
||||
assert!(d.join("out/a.figcraft").is_file() && !d.join("outside/a.figcraft").exists());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_json_lists_everything() {
|
||||
let out = bin().args(["commands", "--json"]).output().unwrap();
|
||||
|
||||
@@ -135,9 +135,23 @@ impl Config {
|
||||
/// abuse contact, or one whose clients would all count as its proxy).
|
||||
pub fn warnings(&self) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let client_max = NetLimits::default().max_server_message >> 20;
|
||||
if usize::try_from(self.file_mb()).is_ok_and(|mb| mb >= client_max) {
|
||||
out.push(format!(
|
||||
"max_file_mb is {}: the app opens files of up to {} MiB, so a file that large can't be opened",
|
||||
self.file_mb(),
|
||||
client_max - 1
|
||||
));
|
||||
}
|
||||
if !self.open_publish {
|
||||
return out;
|
||||
}
|
||||
if self.file_mb() > 64 {
|
||||
out.push(format!(
|
||||
"open_publish is on with max_file_mb = {}: anyone may send messages that large, and the memory set aside for reading and queueing them grows to eight times it; keep it at 64 or less on a public server",
|
||||
self.file_mb()
|
||||
));
|
||||
}
|
||||
if self.operator_contact.trim().is_empty() {
|
||||
out.push("open_publish is on but operator_contact is empty: people can't reach you about abuse; set it (and terms_url)".into());
|
||||
}
|
||||
@@ -253,8 +267,9 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
/// Largest message a client may send when open to the public: a whole file (or plugin bundle)
|
||||
/// and a margin, not the core's 64 MiB.
|
||||
/// Largest message a client may send: a whole file (or plugin bundle) and a margin. Open to the
|
||||
/// public this is also the cap (strangers' files stay small); a private server never goes below
|
||||
/// the core's 64 MiB, and a `max_file_mb` above it raises the cap with it.
|
||||
fn open_message(&self) -> usize {
|
||||
usize::try_from(self.file_mb().max(self.max_plugin_mb).saturating_add(1).saturating_mul(1 << 20)).unwrap_or(usize::MAX)
|
||||
}
|
||||
@@ -265,9 +280,9 @@ impl Config {
|
||||
fn core(&self) -> figcraft_collab::Limits {
|
||||
let base = figcraft_collab::Limits { max_peers: self.max_people, ..figcraft_collab::Limits::default() };
|
||||
if !self.open_publish {
|
||||
return base;
|
||||
return figcraft_collab::Limits { max_message_bytes: base.max_message_bytes.max(self.open_message()), ..base };
|
||||
}
|
||||
let max = self.open_message().min(base.max_message_bytes);
|
||||
let max = self.open_message();
|
||||
let file = usize::try_from(self.file_mb().saturating_mul(1 << 20)).unwrap_or(usize::MAX);
|
||||
figcraft_collab::Limits { max_message_bytes: max, max_log_bytes: (file / 4).max(256 << 10), ..base }
|
||||
}
|
||||
@@ -279,21 +294,24 @@ impl Config {
|
||||
pub fn net(&self) -> NetLimits {
|
||||
let base = NetLimits { max_connections: self.max_connections, trusted_proxy: self.proxy().ok().flatten(), ..NetLimits::default() };
|
||||
if !self.open_publish {
|
||||
return base;
|
||||
let max = base.max_client_message.max(self.open_message());
|
||||
return NetLimits { max_client_message: max, max_queued_bytes: base.max_queued_bytes.max(max.saturating_add(1 << 20)), ..base };
|
||||
}
|
||||
let max = self.open_message().min(base.max_client_message);
|
||||
let max = self.open_message();
|
||||
// Shared budgets: one block of addresses (an IPv4 address, an IPv6 /48) gets at most an
|
||||
// eighth of each, and its biggest consumers are dropped first.
|
||||
let (queued, reading) = (256usize << 20, 192usize << 20);
|
||||
// eighth of each, and its biggest consumers are dropped first. Each still fits one whole
|
||||
// message, or a file as large as `max_file_mb` could never arrive.
|
||||
let one = max.saturating_add(1 << 20);
|
||||
let (queued, reading) = ((256usize << 20).max(one.saturating_mul(8)), (192usize << 20).max(one.saturating_mul(8)));
|
||||
NetLimits {
|
||||
max_per_address: self.max_connections_per_ip,
|
||||
max_client_message: max,
|
||||
max_queued_bytes: max.saturating_add(1 << 20),
|
||||
max_queued_bytes: one,
|
||||
max_queued_block: queued / 8,
|
||||
max_queued_total: queued,
|
||||
max_reading_block: max.saturating_add(1 << 20).min(reading / 8),
|
||||
max_reading_block: one.min(reading / 8),
|
||||
max_reading_total: reading,
|
||||
max_in_flight: 128 << 20,
|
||||
max_in_flight: (128usize << 20).max(one),
|
||||
reserve_for_sessions: self.max_connections / 8,
|
||||
request_idle: std::time::Duration::from_secs(20),
|
||||
..base
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
//! length (u32 LE), CRC-32 (u32 LE), one [`Journal`] entry in the wire encoding.
|
||||
//!
|
||||
//! Compaction writes the next generation's snapshot, then an empty log of that generation. A log
|
||||
//! whose generation isn't the snapshot's is from before a compaction that was cut short: what it
|
||||
//! holds is in the snapshot already. A log ends at its first damaged record (a write cut short).
|
||||
//! whose generation isn't the snapshot's is usually from before a compaction that was cut short
|
||||
//! (what it holds is in the snapshot already), but no checksum covers either generation, so the
|
||||
//! store replays such a log anyway and reports any change the snapshot lacked. A log ends at its
|
||||
//! first damaged record (a write cut short).
|
||||
|
||||
use std::fs::{self, File};
|
||||
use std::io::{self, Read, Write};
|
||||
|
||||
@@ -413,7 +413,7 @@ fn open_mode_has_safe_defaults_and_warns_the_operator() {
|
||||
assert_eq!(open.net().request_idle, Duration::from_secs(20), "idle one-shot requests go quickly");
|
||||
assert_eq!(open.net().reserve_for_sessions, 512 / 8);
|
||||
assert_eq!(open.net().max_client_message, 17 << 20);
|
||||
assert_eq!(private.net().max_client_message, NetLimits::default().max_client_message, "unchanged when private");
|
||||
assert_eq!(private.net().max_client_message, 65 << 20, "private: a 64 MiB file and a margin");
|
||||
let w = open.warnings().join("\n");
|
||||
assert!(w.contains("operator_contact is empty") && w.contains("trusted_proxy"), "{w}");
|
||||
let set = Config { operator_contact: "[email protected]".into(), trusted_proxy: Some("127.0.0.1".into()), max_file_mb: Some(8), ..open };
|
||||
@@ -493,3 +493,34 @@ fn open_connections_never_reach_what_needs_the_key() {
|
||||
assert!(!format!("{info:?}").contains(&k));
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_above_64_mib_fit_every_limit() {
|
||||
// `max_file_mb` may go up to 4096: every cap a whole file's message meets on its way in must
|
||||
// follow it, or the upload is dropped by the transport before the server can say why.
|
||||
for open_publish in [false, true] {
|
||||
let cfg = Config { open_publish, max_file_mb: Some(256), public_url: Some("wss://figcraft.example.com/".into()), ..Config::default() };
|
||||
let (net, core, file) = (cfg.net(), cfg.store().core, 256usize << 20);
|
||||
assert!(net.max_client_message > file, "open={open_publish}: {}", net.max_client_message);
|
||||
assert!(net.max_queued_bytes > file && net.max_reading_block > file && net.max_in_flight > file, "open={open_publish}: {net:?}");
|
||||
assert!(net.max_reading_total >= net.max_reading_block && net.max_queued_total >= net.max_queued_block);
|
||||
assert!(core.max_message_bytes > file, "open={open_publish}: {}", core.max_message_bytes);
|
||||
}
|
||||
// A client takes such a file too, but caps what any server may make it buffer at 1 GiB.
|
||||
assert_eq!(NetLimits::default().max_server_message, 1 << 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_files_warn_the_operator() {
|
||||
let public_url = Some("wss://figcraft.example.com/".into());
|
||||
let warns = |open_publish, mb| {
|
||||
Config { open_publish, max_file_mb: Some(mb), public_url: public_url.clone(), ..Config::default() }.warnings().join(
|
||||
"
|
||||
",
|
||||
)
|
||||
};
|
||||
assert!(!warns(true, 64).contains("max_file_mb"), "{}", warns(true, 64));
|
||||
assert!(warns(true, 65).contains("open_publish is on with max_file_mb = 65"), "{}", warns(true, 65));
|
||||
assert!(!warns(false, 1023).contains("max_file_mb"), "{}", warns(false, 1023));
|
||||
assert!(warns(false, 1024).contains("can't be opened"), "{}", warns(false, 1024));
|
||||
}
|
||||
@@ -440,7 +440,7 @@ impl Store {
|
||||
&& let Ok(log) = disk::read_log(&bytes)
|
||||
&& log.generation != generation
|
||||
{
|
||||
log::info!("file at {}: its log predates the last compaction; it is ignored", path.display());
|
||||
log::info!("file at {}: its log isn't of its snapshot's generation; it is replayed over it when the file opens", path.display());
|
||||
}
|
||||
let bytes = [SNAPSHOT, LOG].iter().filter_map(|n| fs::metadata(path.join(n)).ok()).map(|m| m.len()).sum();
|
||||
Ok(Entry { meta, links, log: None, bytes, forced: 0 })
|
||||
@@ -1126,8 +1126,32 @@ impl Store {
|
||||
fresh_log = true;
|
||||
}
|
||||
}
|
||||
// From before a compaction that was cut short: the snapshot has it all.
|
||||
Ok(_) => {}
|
||||
// From before a compaction that was cut short, the snapshot has it all; or a generation
|
||||
// field damaged, which no checksum covers. Replaying tells them apart without trusting
|
||||
// it: entries the snapshot holds are skipped, so only edits it lacks change anything.
|
||||
Ok(log) => {
|
||||
let before = server.seq();
|
||||
let mut stopped = None;
|
||||
for entry in log.entries {
|
||||
if let Err(e) = server.replay(entry) {
|
||||
stopped = Some(e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let (snap, found, recovered) = (generation, log.generation, server.seq().saturating_sub(before));
|
||||
if recovered > 0 {
|
||||
issues.push(format!(
|
||||
"file {file}: its log's generation ({found}) doesn't match its snapshot's ({snap}), but it held \
|
||||
{recovered} changes the snapshot didn't; they were recovered"
|
||||
));
|
||||
}
|
||||
if let Some(e) = stopped {
|
||||
issues.push(format!(
|
||||
"file {file}: its log's generation ({found}) doesn't match its snapshot's ({snap}) and it stops \
|
||||
making sense after {recovered} newer changes ({e}); the rest was dropped"
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(why) => issues.push(format!("file {file}: {why}; starting a new log")),
|
||||
}
|
||||
for i in issues {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
//! A generation field damaged after an abrupt stop must not make the server drop a log that still
|
||||
//! holds confirmed edits (#408): the log is replayed over the snapshot, which only the changes the
|
||||
//! snapshot lacks survive, and the damage is reported. A genuine leftover of a compaction cut short
|
||||
//! still restores silently.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use figcraft_collab::message::{Access, ClientMsg};
|
||||
use figcraft_collab::{Client, Outgoing, Replica, Server};
|
||||
use figcraft_collab_net::hub::Backend;
|
||||
use figcraft_doc::Document;
|
||||
use figcraft_server::config::Config;
|
||||
use figcraft_server::disk::{self, LOG, SNAPSHOT};
|
||||
use figcraft_server::store::{FILES, Store};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
struct Fixture(PathBuf);
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!("figcraft-header-recovery-{}", figcraft_collab_net::token::file_id().unwrap()));
|
||||
fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn page_name(server: &Server) -> String {
|
||||
let doc = server.document();
|
||||
doc.node(doc.first_page().unwrap()).unwrap().name.clone()
|
||||
}
|
||||
|
||||
/// A published file whose first page was renamed "Kept", flushed to its log (not compacted), then
|
||||
/// the store dropped without stopping (an abrupt stop). Returns the file id and the final state.
|
||||
fn renamed_and_stopped(data: &Path) -> (String, Server) {
|
||||
let mut store = Store::open(data, Config::default().store()).unwrap();
|
||||
let snapshot = Replica::new(Document::new("Fixture")).snapshot().unwrap();
|
||||
let (info, _, _) = store.publish("Fixture", &snapshot).unwrap();
|
||||
let id = info.id;
|
||||
let mut server = store.open(&id).unwrap();
|
||||
let ClientMsg::Hello(hello) = Client::hello("f", "", "Editor", "#FF8800") else { panic!("not a hello") };
|
||||
let (_, messages) = server.join_as(hello, Access::Edit).unwrap();
|
||||
let Some(Outgoing::To(_, welcome)) = messages.into_iter().next() else { panic!("no welcome") };
|
||||
let mut client = Client::join(welcome).unwrap();
|
||||
store.changed(&id, &mut server);
|
||||
let mut edited = client.document().clone();
|
||||
let page = edited.first_page().unwrap();
|
||||
edited.node_mut(page).unwrap().name = "Kept".into();
|
||||
let change = client.edit(&edited).unwrap().unwrap();
|
||||
assert!(!server.receive(client.id(), change).is_empty());
|
||||
store.changed(&id, &mut server);
|
||||
assert!(store.flush().is_empty(), "the edit must reach disk");
|
||||
assert_eq!(page_name(&server), "Kept");
|
||||
let dir = data.join(FILES).join(&id);
|
||||
assert!(!disk::read_log(&fs::read(dir.join(LOG)).unwrap()).unwrap().entries.is_empty());
|
||||
assert_eq!(disk::decode_snapshot(&fs::read(dir.join(SNAPSHOT)).unwrap()).unwrap().1.seq, 0, "the rename is only in the log");
|
||||
drop(store);
|
||||
(id, server)
|
||||
}
|
||||
|
||||
/// Reopen the data directory: the file's first page name and the problems reported.
|
||||
fn reopen(data: &Path, id: &str) -> (String, Vec<String>) {
|
||||
let mut store = Store::open(data, Config::default().store()).unwrap();
|
||||
let server = store.open(id).unwrap();
|
||||
(page_name(&server), store.problems().to_vec())
|
||||
}
|
||||
|
||||
fn flip(path: &Path, at: usize) {
|
||||
let mut bytes = fs::read(path).unwrap();
|
||||
bytes[at] ^= 1;
|
||||
fs::write(path, bytes).unwrap();
|
||||
}
|
||||
|
||||
/// The generation field's first byte in both headers (after the magic and the format byte).
|
||||
const GENERATION_AT: usize = 5;
|
||||
|
||||
#[test]
|
||||
fn an_intact_log_restores_its_edits_without_a_report() {
|
||||
let fixture = Fixture::new();
|
||||
let (id, _) = renamed_and_stopped(&fixture.0);
|
||||
let (name, problems) = reopen(&fixture.0, &id);
|
||||
assert_eq!(name, "Kept");
|
||||
assert!(problems.is_empty(), "{problems:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_log_generation_keeps_the_edits_and_is_reported() {
|
||||
let fixture = Fixture::new();
|
||||
let (id, _) = renamed_and_stopped(&fixture.0);
|
||||
let log = fixture.0.join(FILES).join(&id).join(LOG);
|
||||
flip(&log, GENERATION_AT);
|
||||
// The records themselves still read: only the header's generation changed.
|
||||
let read = disk::read_log(&fs::read(&log).unwrap()).unwrap();
|
||||
assert!(read.damaged.is_none() && !read.entries.is_empty());
|
||||
let (name, problems) = reopen(&fixture.0, &id);
|
||||
assert_eq!(name, "Kept", "the flushed rename must survive");
|
||||
assert!(problems.iter().any(|p| p.contains(&id) && p.contains("generation")), "{problems:?}");
|
||||
// And it stays: the recovered edit is now in the file's snapshot.
|
||||
assert_eq!(reopen(&fixture.0, &id).0, "Kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_snapshot_generation_keeps_the_edits_and_is_reported() {
|
||||
let fixture = Fixture::new();
|
||||
let (id, _) = renamed_and_stopped(&fixture.0);
|
||||
let snapshot = fixture.0.join(FILES).join(&id).join(SNAPSHOT);
|
||||
flip(&snapshot, GENERATION_AT);
|
||||
assert!(disk::check_snapshot(&fs::read(&snapshot).unwrap()).is_ok(), "the generation is outside the checksum");
|
||||
let (name, problems) = reopen(&fixture.0, &id);
|
||||
assert_eq!(name, "Kept", "the flushed rename must survive");
|
||||
assert!(problems.iter().any(|p| p.contains(&id) && p.contains("generation")), "{problems:?}");
|
||||
assert_eq!(reopen(&fixture.0, &id).0, "Kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_log_format_is_still_reported() {
|
||||
let fixture = Fixture::new();
|
||||
let (id, _) = renamed_and_stopped(&fixture.0);
|
||||
flip(&fixture.0.join(FILES).join(&id).join(LOG), 4);
|
||||
let (_, problems) = reopen(&fixture.0, &id);
|
||||
assert!(problems.iter().any(|p| p.contains(&id) && p.contains("format")), "{problems:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_log_left_by_a_compaction_cut_short_restores_without_a_report() {
|
||||
let fixture = Fixture::new();
|
||||
let (id, server) = renamed_and_stopped(&fixture.0);
|
||||
// The next snapshot was written (it holds the rename); the old log wasn't replaced yet.
|
||||
let bytes = disk::encode_snapshot(2, &server.state().unwrap()).unwrap();
|
||||
fs::write(fixture.0.join(FILES).join(&id).join(SNAPSHOT), bytes).unwrap();
|
||||
let (name, problems) = reopen(&fixture.0, &id);
|
||||
assert_eq!(name, "Kept");
|
||||
assert!(problems.is_empty(), "a leftover log is expected, not damage: {problems:?}");
|
||||
}
|
||||
@@ -14,7 +14,16 @@
|
||||
//! join from other machines.
|
||||
//!
|
||||
//! URL query flag: `?webgl` forces the WebGL2 backend instead of WebGPU.
|
||||
//!
|
||||
//! URL fragment: `#join=<invite link or join code>` joins that live session once the page has
|
||||
//! loaded, so a bookmark opens a shared file. `join` takes the rest of the fragment: put it last.
|
||||
//! The link carries the session's secret; a fragment is never sent to the web host, but the address
|
||||
//! bar and the browser history keep it, so the URL is as private as the link itself.
|
||||
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod query;
|
||||
#[cfg(test)]
|
||||
mod query_tests;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
//! The page's URL: the invite link its fragment names.
|
||||
|
||||
/// The invite link in `#join=<link>` (`fragment` is `location.hash`, with or without its `#`), still
|
||||
/// escaped. The fragment, unlike the query, never leaves the browser, so the link's secret stays out
|
||||
/// of the web host's logs and `Referer` headers. `join` takes the rest of the fragment, so a pasted
|
||||
/// `figcraft://join?host=…&file=…&token=…` keeps its own `&`s. Anything longer than an escaped invite
|
||||
/// link can be is ignored; what is left is checked by `collab.join` like a link the desktop app is
|
||||
/// opened with.
|
||||
pub fn join_link(fragment: &str) -> Option<&str> {
|
||||
// An invite link is at most 2 KiB; every byte of it escaped is three.
|
||||
const MAX: usize = 3 * 2048;
|
||||
let fragment = fragment.strip_prefix('#').unwrap_or(fragment);
|
||||
let start = if fragment.starts_with("join=") { 0 } else { fragment.find("&join=")? + 1 };
|
||||
fragment.get(start + "join=".len()..).filter(|link| !link.is_empty() && link.len() <= MAX)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//! `#join=` in the page's URL.
|
||||
|
||||
use crate::query::join_link;
|
||||
|
||||
#[test]
|
||||
fn join_takes_the_rest_of_the_fragment() {
|
||||
let link = "figcraft://join?host=192.168.1.20:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv";
|
||||
assert_eq!(join_link(&format!("#join={link}")), Some(link), "a pasted link keeps its own &s");
|
||||
assert_eq!(join_link(&format!("#x=1&join={link}")), Some(link), "after other entries");
|
||||
assert_eq!(join_link("#join=figcraft%3A%2F%2Fjoin%3Fhost%3Dh"), Some("figcraft%3A%2F%2Fjoin%3Fhost%3Dh"), "escaped, decoded later");
|
||||
assert_eq!(join_link("join=K7M-2QX-9TD"), Some("K7M-2QX-9TD"), "a join code, no leading #");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_join_without_the_entry() {
|
||||
assert_eq!(join_link(""), None);
|
||||
assert_eq!(join_link("#"), None);
|
||||
assert_eq!(join_link("#join="), None);
|
||||
assert_eq!(join_link("#rejoin=x"), None, "only the whole entry name");
|
||||
assert_eq!(join_link("#xjoin=x&a=1"), None);
|
||||
assert_eq!(join_link("?join=x"), None, "the query isn't read: it goes to the web host");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_overlong_link_is_ignored() {
|
||||
let long = format!("#join=figcraft://join?host=h&file=f&token={}", "a".repeat(7000));
|
||||
assert_eq!(join_link(&long), None);
|
||||
assert_eq!(join_link(&format!("#join={}", "é".repeat(4000))), None, "length counts bytes");
|
||||
}
|
||||
@@ -40,7 +40,13 @@ pub fn start() {
|
||||
// on the socket wakes the page for a frame.
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
session.live.install(Box::new(figcraft_collab_net::NetLink::new(std::sync::Arc::new(move || ctx.request_repaint()))));
|
||||
let fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() }));
|
||||
let mut fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() }));
|
||||
// `#join=<link>`: join that session, as opening an invite link does on the desktop.
|
||||
let hash = web_sys::window().and_then(|w| w.location().hash().ok()).unwrap_or_default();
|
||||
if let Some(link) = crate::query::join_link(&hash) {
|
||||
let link = js_sys::decode_uri_component(link).ok().and_then(|s| s.as_string()).unwrap_or_else(|| link.to_string());
|
||||
figcraft_ui_egui::live::open_invite(&mut fig, &link);
|
||||
}
|
||||
Ok(Box::new(WebShell { fig }))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -43,3 +43,9 @@ wat = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
winresource = "0.1"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Use vector window controls without a title-font renderer or embedded Cantarell.
|
||||
winit = { version = "0.30", default-features = false, features = ["wayland-csd-adwaita-notitle"] }
|
||||
raw-window-handle = "0.6"
|
||||
wayland-client = "0.31"
|
||||
@@ -1,4 +1,5 @@
|
||||
//! The command line: `figcraft [--control <port>] [files…] [figcraft://join?…]`. An invite link
|
||||
//! The command line: `figcraft [--control <port>] [--automation-read-root <dir>]…
|
||||
//! [--automation-write-root <dir>]… [files…] [figcraft://join?…]`. An invite link
|
||||
//! (what the system passes when a `figcraft://` link is opened) joins that session once the window
|
||||
//! is up; everything else that isn't a flag is a file to open, as a path or a `file://` URL (the
|
||||
//! Linux desktop entry passes URLs, `%U`, so that it can also hand over invite links).
|
||||
@@ -11,6 +12,11 @@ pub struct Args {
|
||||
pub files: Vec<String>,
|
||||
/// The last invite link given.
|
||||
pub join: Option<String>,
|
||||
/// `--automation-read-root <dir>` / `--automation-write-root <dir>` (each repeatable; `=` works
|
||||
/// too): the folders the app's file access is confined to. A flag without a value gives `""`,
|
||||
/// which is refused at startup.
|
||||
pub read_roots: Vec<String>,
|
||||
pub write_roots: Vec<String>,
|
||||
}
|
||||
|
||||
/// An invite link rather than a file (`figcraft:` links, in any case).
|
||||
@@ -49,7 +55,15 @@ pub fn parse(args: &[String]) -> Args {
|
||||
let mut out = Args::default();
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
if a == "--control" {
|
||||
let (flag, inline) = a.split_once('=').map_or((a.as_str(), None), |(f, v)| (f, Some(v.to_string())));
|
||||
let roots = match flag {
|
||||
"--automation-read-root" => Some(&mut out.read_roots),
|
||||
"--automation-write-root" => Some(&mut out.write_roots),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(roots) = roots {
|
||||
roots.push(inline.or_else(|| it.next().cloned()).unwrap_or_default());
|
||||
} else if a == "--control" {
|
||||
out.control = it.next().and_then(|p| p.parse().ok());
|
||||
} else if is_invite(a) {
|
||||
out.join = Some(a.clone());
|
||||
|
||||
@@ -10,12 +10,22 @@ fn args(a: &[&str]) -> Vec<String> {
|
||||
fn files_and_the_control_port() {
|
||||
assert_eq!(parse(&args(&[])), Args::default());
|
||||
let a = parse(&args(&["--control", "4242", "a.figcraft", "--verbose", "b.svg"]));
|
||||
assert_eq!(a, Args { control: Some(4242), files: args(&["a.figcraft", "b.svg"]), join: None });
|
||||
assert_eq!(a, Args { control: Some(4242), files: args(&["a.figcraft", "b.svg"]), ..Args::default() });
|
||||
// A missing or bad port is ignored, not taken as a file.
|
||||
assert_eq!(parse(&args(&["--control"])), Args::default());
|
||||
assert_eq!(parse(&args(&["--control", "nope"])).control, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automation_roots_repeat_and_are_never_files() {
|
||||
let a = parse(&args(&["--automation-read-root", "in", "--automation-write-root=out", "--automation-read-root=more", "a.figcraft"]));
|
||||
assert_eq!(a.read_roots, args(&["in", "more"]));
|
||||
assert_eq!(a.write_roots, args(&["out"]));
|
||||
assert_eq!(a.files, args(&["a.figcraft"]));
|
||||
// A flag without its folder is kept as an empty one, which startup refuses.
|
||||
assert_eq!(parse(&args(&["--automation-write-root"])).write_roots, args(&[""]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invite_links_join_instead_of_opening() {
|
||||
let link = "figcraft://join?host=192.168.1.20:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv";
|
||||
|
||||
@@ -160,7 +160,11 @@ fn connection_limit_rejects_excess_clients_and_reuses_finished_slots() {
|
||||
assert_eq!(workers.len(), 1);
|
||||
client.write_all(b"invalid\n").unwrap();
|
||||
assert_eq!(reply(&mut BufReader::new(client.try_clone().unwrap()))["ok"], false);
|
||||
client.shutdown(Shutdown::Both).unwrap();
|
||||
// The server closes this connection after answering an invalid request, and shutting down
|
||||
// a socket whose peer has already closed is ENOTCONN on macOS and the BSDs where Linux
|
||||
// returns Ok. Closing the client is what the worker is waiting for, so the result is moot:
|
||||
// the other shutdowns in this file run while the peer is still open and stay checked.
|
||||
let _ = client.shutdown(Shutdown::Both);
|
||||
workers.pop().unwrap().join().unwrap();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
//! Request winit's frame only on Wayland compositors without xdg-decoration.
|
||||
//! Desktop/session names cannot identify the backend of an individual window.
|
||||
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use raw_window_handle::{HasDisplayHandle, RawDisplayHandle};
|
||||
use wayland_client::globals::{GlobalListContents, registry_queue_init};
|
||||
use wayland_client::protocol::wl_registry;
|
||||
use wayland_client::{Connection, Dispatch, QueueHandle};
|
||||
|
||||
/// Capture the socket path before winit consumes an inherited WAYLAND_SOCKET.
|
||||
/// Never consume, duplicate or query that inherited connection: there may be no
|
||||
/// independently addressable display, or WAYLAND_DISPLAY may name another server.
|
||||
pub fn display_socket() -> Option<PathBuf> {
|
||||
if std::env::var_os("WAYLAND_SOCKET").is_some_and(|s| !s.is_empty()) {
|
||||
return None;
|
||||
}
|
||||
let display = std::env::var_os("WAYLAND_DISPLAY").filter(|s| !s.is_empty())?;
|
||||
let path = PathBuf::from(display);
|
||||
if path.is_absolute() {
|
||||
Some(path)
|
||||
} else {
|
||||
std::env::var_os("XDG_RUNTIME_DIR").filter(|s| !s.is_empty()).map(|dir| PathBuf::from(dir).join(path))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn needs_native_frame(cc: &eframe::CreationContext<'_>, socket: Option<PathBuf>) -> bool {
|
||||
let wayland = cc.display_handle().is_ok_and(|h| matches!(h.as_raw(), RawDisplayHandle::Wayland(_)));
|
||||
if !wayland {
|
||||
log::debug!("window controls: non-Wayland window, retaining custom controls");
|
||||
return false;
|
||||
}
|
||||
let server_decorations = socket.and_then(probe);
|
||||
log::debug!("window controls: Wayland xdg-decoration available: {server_decorations:?}");
|
||||
native_frame(wayland, server_decorations)
|
||||
}
|
||||
|
||||
fn native_frame(wayland: bool, server_decorations: Option<bool>) -> bool {
|
||||
wayland && server_decorations == Some(false)
|
||||
}
|
||||
|
||||
fn probe(socket: PathBuf) -> Option<bool> {
|
||||
// The compositor must not be able to block startup on a registry roundtrip.
|
||||
// On failure/timeout retain FigCraft's own working title bar and buttons.
|
||||
let (send, receive) = mpsc::sync_channel(1);
|
||||
std::thread::Builder::new()
|
||||
.name("wayland-decorations".into())
|
||||
.spawn(move || {
|
||||
let _ = send.send(registry_has_decorations(socket));
|
||||
})
|
||||
.ok()?;
|
||||
receive.recv_timeout(Duration::from_millis(500)).ok().flatten()
|
||||
}
|
||||
|
||||
fn registry_has_decorations(socket: PathBuf) -> Option<bool> {
|
||||
// A fresh connection leaves winit's event queue and inherited descriptors alone.
|
||||
let stream = UnixStream::connect(socket).ok()?;
|
||||
let connection = Connection::from_socket(stream).ok()?;
|
||||
let (globals, _queue) = registry_queue_init::<Registry>(&connection).ok()?;
|
||||
Some(globals.contents().with_list(|list| list.iter().any(|g| g.interface == "zxdg_decoration_manager_v1")))
|
||||
}
|
||||
|
||||
struct Registry;
|
||||
|
||||
impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for Registry {
|
||||
fn event(
|
||||
_state: &mut Self,
|
||||
_registry: &wl_registry::WlRegistry,
|
||||
_event: wl_registry::Event,
|
||||
_data: &GlobalListContents,
|
||||
_connection: &Connection,
|
||||
_queue: &QueueHandle<Self>,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn only_wayland_without_server_decorations_uses_the_frame() {
|
||||
for (wayland, server_decorations, expected) in [
|
||||
(false, None, false),
|
||||
(false, Some(false), false),
|
||||
(false, Some(true), false),
|
||||
(true, Some(true), false),
|
||||
(true, Some(false), true),
|
||||
(true, None, false),
|
||||
] {
|
||||
assert_eq!(super::native_frame(wayland, server_decorations), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unavailable_display_keeps_the_custom_controls() {
|
||||
assert_eq!(super::probe("/nonexistent/figcraft-wayland-test".into()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unresponsive_display_cannot_block_window_startup() -> std::io::Result<()> {
|
||||
let socket = std::env::temp_dir().join(format!("figcraft-decoration-timeout-{}", std::process::id()));
|
||||
let listener = std::os::unix::net::UnixListener::bind(&socket)?;
|
||||
let start = std::time::Instant::now();
|
||||
assert_eq!(super::probe(socket.clone()), None);
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(2));
|
||||
drop(listener);
|
||||
let _ = std::fs::remove_file(socket);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,11 @@
|
||||
//! `--control <port>` (or `FIGCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
|
||||
//! `{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":0,"y":0,"width":100,"height":100}}}`
|
||||
//! → `{"id":1,"ok":true,"result":{…}}`. Methods: `figcraft_ui_egui::control`.
|
||||
//!
|
||||
//! `--automation-read-root <dir>` / `--automation-write-root <dir>` (each repeatable) confine the
|
||||
//! files the window and the control channel read and write (`figcraft_engine::file_access`); the
|
||||
//! app's own folders (settings, recovery copies, installed plugins) and opening the files given on
|
||||
//! the command line are not. A root that isn't an existing folder stops the app at startup.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
|
||||
@@ -15,6 +20,8 @@ mod args;
|
||||
mod control_server;
|
||||
mod gamepad;
|
||||
mod graphics;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux_window;
|
||||
mod recovery;
|
||||
mod services;
|
||||
mod startup;
|
||||
@@ -119,6 +126,17 @@ fn main() -> eframe::Result {
|
||||
return Ok(());
|
||||
}
|
||||
let args = args::parse(&args);
|
||||
let roots = match (
|
||||
args.read_roots.is_empty() && args.write_roots.is_empty(),
|
||||
figcraft_engine::file_access::Roots::new(&args.read_roots, &args.write_roots),
|
||||
) {
|
||||
(true, _) => None,
|
||||
(false, Ok(r)) => Some(Arc::new(r)),
|
||||
(false, Err(e)) => {
|
||||
eprintln!("FigCraft: {e}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
let port = args.control.or_else(|| std::env::var("FIGCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()));
|
||||
// System fonts: index them in the background, then wake the UI (an idle window draws no frames)
|
||||
// to lay text out again with them.
|
||||
@@ -174,6 +192,7 @@ fn main() -> eframe::Result {
|
||||
// open (no frame then).
|
||||
figcraft_ui_egui::i18n::set(&session.prefs.language, figcraft_ui_egui::Services::system_language(&svc).as_deref());
|
||||
let mut fig = FigApp::new(session, Box::new(svc));
|
||||
fig.automation_roots = roots;
|
||||
fig.ui.home = false;
|
||||
fig.note_recovered(recovered.copies);
|
||||
if let Some(link) = &args.join {
|
||||
@@ -192,21 +211,18 @@ fn main() -> eframe::Result {
|
||||
.with_inner_size([1440.0, 900.0])
|
||||
.with_min_inner_size([800.0, 500.0])
|
||||
.with_drag_and_drop(true);
|
||||
// The tab strip is the title bar: macOS keeps its window buttons inset into it; elsewhere the
|
||||
// window is frameless and FigCraft draws the buttons and resize edges.
|
||||
if cfg!(target_os = "macos") {
|
||||
viewport = viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false);
|
||||
fig.ui.title_bar = figcraft_ui_egui::TitleBar::Inset;
|
||||
} else {
|
||||
viewport = viewport.with_decorations(false);
|
||||
fig.ui.title_bar = figcraft_ui_egui::TitleBar::Custom;
|
||||
}
|
||||
// Start frameless on Linux; the app creator checks the actual display backend
|
||||
// before enabling a client-side frame. Other windows inherit that decision.
|
||||
fig.ui.title_bar = desktop_title_bar();
|
||||
viewport = window_chrome(viewport, fig.ui.title_bar);
|
||||
if let Some(icon) = icon {
|
||||
viewport = viewport.with_icon(icon);
|
||||
}
|
||||
let mut options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() };
|
||||
graphics::configure(&mut options.wgpu_options);
|
||||
figcraft_ui_egui::windows::install(fig);
|
||||
#[cfg(target_os = "linux")]
|
||||
let linux_display = linux_window::display_socket();
|
||||
// Registered before the event loop starts, so it catches the Finder event that launched the app
|
||||
// as well as later ones. Lives until the event loop returns; the app creator only borrows it.
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -217,6 +233,11 @@ fn main() -> eframe::Result {
|
||||
"FigCraft",
|
||||
options,
|
||||
Box::new(move |_cc| {
|
||||
#[cfg(target_os = "linux")]
|
||||
if linux_window::needs_native_frame(_cc, linux_display) {
|
||||
let _ = with_app(|fig| fig.ui.title_bar = figcraft_ui_egui::TitleBar::Native);
|
||||
_cc.egui_ctx.send_viewport_cmd(egui::ViewportCommand::Decorations(true));
|
||||
}
|
||||
Ok(Box::new(App {
|
||||
queue,
|
||||
ctx_slot,
|
||||
@@ -233,3 +254,28 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn desktop_title_bar() -> figcraft_ui_egui::TitleBar {
|
||||
use figcraft_ui_egui::TitleBar;
|
||||
if cfg!(target_os = "macos") { TitleBar::Inset } else { TitleBar::Custom }
|
||||
}
|
||||
|
||||
fn window_chrome(viewport: egui::ViewportBuilder, title_bar: figcraft_ui_egui::TitleBar) -> egui::ViewportBuilder {
|
||||
use figcraft_ui_egui::TitleBar;
|
||||
match title_bar {
|
||||
TitleBar::Inset => viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false),
|
||||
TitleBar::Native => viewport.with_decorations(true),
|
||||
TitleBar::Custom => viewport.with_decorations(false),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod linux_window_tests {
|
||||
#[test]
|
||||
fn linux_starts_with_the_existing_frameless_title_bar() {
|
||||
let bar = super::desktop_title_bar();
|
||||
assert_eq!(bar, figcraft_ui_egui::TitleBar::Custom);
|
||||
let viewport = super::window_chrome(egui::ViewportBuilder::default(), bar);
|
||||
assert_eq!(viewport.decorations, Some(false));
|
||||
}
|
||||
}
|
||||
@@ -44,3 +44,12 @@ sync_github_name = true
|
||||
|
||||
[people.volkovskey]
|
||||
real_name = "Andrii Volkov"
|
||||
|
||||
[people.marcoarnulfo]
|
||||
real_name = "Marco Arnulfo"
|
||||
display_name = "Marco Arnulfo"
|
||||
sync_github_name = false
|
||||
|
||||
[people.krinry]
|
||||
display_name = "krinry"
|
||||
sync_github_name = true
|
||||
@@ -12,7 +12,9 @@ pub struct NetLimits {
|
||||
/// before: its hello.
|
||||
pub max_client_message: usize,
|
||||
pub max_hello: usize,
|
||||
/// Largest message a client accepts from the host (a welcome carries the whole document).
|
||||
/// Largest message a client accepts from the host (a welcome carries the whole document): 1 GiB,
|
||||
/// room for any file up to 1023 MiB and well past the servers' defaults, while a hostile server
|
||||
/// (any link can name one) can't make the app or a browser tab buffer more than that.
|
||||
pub max_server_message: usize,
|
||||
/// Messages a client may send per second on average, and in a burst; beyond that it is
|
||||
/// disconnected.
|
||||
@@ -61,7 +63,7 @@ impl Default for NetLimits {
|
||||
max_connections: 64,
|
||||
max_client_message: figcraft_collab::Limits::default().max_message_bytes,
|
||||
max_hello: 64 << 10,
|
||||
max_server_message: figcraft_collab::Limits::default().max_message_bytes,
|
||||
max_server_message: 1 << 30,
|
||||
rate: 100.0,
|
||||
burst: 400.0,
|
||||
hello_timeout: Duration::from_secs(10),
|
||||
|
||||
+19
-6
@@ -77,17 +77,19 @@ pub enum DocError {
|
||||
|
||||
pub type DocResult<T> = Result<T, DocError>;
|
||||
|
||||
/// The set of touched node ids, and whether any layer is a mask (worked out when first asked
|
||||
/// after a change: hit testing skips its mask checks without one). Never part of equality or
|
||||
/// The set of touched node ids, whether any layer is a mask (hit testing skips its mask checks
|
||||
/// without one) and the largest background blur radius (renders keep that much overlap between
|
||||
/// their bands), both worked out when first asked after a change. Never part of equality or
|
||||
/// serialisation.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Dirty(pub std::collections::BTreeSet<NodeId>, std::sync::OnceLock<bool>);
|
||||
pub struct Dirty(pub std::collections::BTreeSet<NodeId>, std::sync::OnceLock<bool>, std::sync::OnceLock<f64>);
|
||||
|
||||
impl Dirty {
|
||||
/// Record node `id` as touched (edited, added or removed).
|
||||
fn touch(&mut self, id: NodeId) {
|
||||
self.0.insert(id);
|
||||
self.1 = std::sync::OnceLock::new();
|
||||
self.2 = std::sync::OnceLock::new();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,6 +256,11 @@ impl Document {
|
||||
*self.dirty.1.get_or_init(|| self.nodes.values().any(|n| n.is_mask))
|
||||
}
|
||||
|
||||
/// The largest background blur or glass radius of any layer's effects (0 without any).
|
||||
pub fn max_backdrop_radius(&self) -> f64 {
|
||||
*self.dirty.2.get_or_init(|| self.nodes.values().flat_map(|n| n.effects.iter()).map(Effect::backdrop_radius).fold(0.0, f64::max))
|
||||
}
|
||||
|
||||
/// Node ids touched so far (without clearing).
|
||||
pub fn dirty(&self) -> &std::collections::BTreeSet<NodeId> {
|
||||
&self.dirty.0
|
||||
@@ -1117,8 +1124,14 @@ impl Document {
|
||||
/// mask with the layers above it up to the next mask, or a single layer. An auto layout frame
|
||||
/// whose first child paints on top paints the runs in reverse (see [`Node::paint_order`]).
|
||||
pub fn paint_runs(&self, n: &Node) -> Vec<std::ops::Range<usize>> {
|
||||
let kids = &n.children;
|
||||
let node = |i: usize| kids.get(i).and_then(|k| self.node(*k));
|
||||
let kids: Vec<Option<&Node>> = n.children.iter().map(|k| self.node(*k)).collect();
|
||||
Self::paint_runs_of(&kids, n.layout.as_ref().is_some_and(|l| l.reverse_z))
|
||||
}
|
||||
|
||||
/// [`Self::paint_runs`] of children already looked up (`None`: not in the document), last run
|
||||
/// first when `reverse_z`.
|
||||
pub fn paint_runs_of(kids: &[Option<&Node>], reverse_z: bool) -> Vec<std::ops::Range<usize>> {
|
||||
let node = |i: usize| kids.get(i).copied().flatten();
|
||||
let mut runs = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < kids.len() {
|
||||
@@ -1131,7 +1144,7 @@ impl Document {
|
||||
runs.push(i..j);
|
||||
i = j;
|
||||
}
|
||||
if n.layout.as_ref().is_some_and(|l| l.reverse_z) {
|
||||
if reverse_z {
|
||||
runs.reverse();
|
||||
}
|
||||
runs
|
||||
|
||||
+92
-1
@@ -860,7 +860,35 @@ pub struct Device {
|
||||
|
||||
impl PrototypeSettings {
|
||||
/// The devices to pick from ("None" presents the frame at its own size).
|
||||
pub const DEVICES: [Device; 6] = [
|
||||
pub const DEVICES: [Device; 19] = [
|
||||
Device {
|
||||
name: "iPhone 17",
|
||||
width: 402.0,
|
||||
height: 874.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x38, 0x39, 0x3D), c("White", 0xE9, 0xE9, 0xE5)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 17 Pro",
|
||||
width: 402.0,
|
||||
height: 874.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x38, 0x39, 0x3D), c("Silver", 0xD7, 0xD7, 0xD5)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 17 Pro Max",
|
||||
width: 440.0,
|
||||
height: 956.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x38, 0x39, 0x3D), c("Silver", 0xD7, 0xD7, 0xD5)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone Air",
|
||||
width: 420.0,
|
||||
height: 912.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x38, 0x39, 0x3D), c("Silver", 0xD7, 0xD7, 0xD5)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 16",
|
||||
width: 393.0,
|
||||
@@ -874,6 +902,13 @@ impl PrototypeSettings {
|
||||
c("Blue", 0x72, 0x84, 0xD4),
|
||||
],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 16 Pro",
|
||||
width: 402.0,
|
||||
height: 874.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x3B, 0x3A, 0x38), c("White", 0xEC, 0xEB, 0xE6)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 16 Pro Max",
|
||||
width: 440.0,
|
||||
@@ -881,6 +916,55 @@ impl PrototypeSettings {
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x3B, 0x3A, 0x38), c("White", 0xEC, 0xEB, 0xE6), c("Natural", 0xBF, 0xB9, 0xAF), c("Sand", 0xC6, 0xAC, 0x8F)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 16 Plus",
|
||||
width: 430.0,
|
||||
height: 932.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x3A, 0x3B, 0x3F), c("White", 0xEE, 0xED, 0xE8)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 15 Pro",
|
||||
width: 393.0,
|
||||
height: 852.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x3B, 0x3A, 0x38), c("Silver", 0xD7, 0xD7, 0xD5)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 15 Pro Max",
|
||||
width: 430.0,
|
||||
height: 932.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x3B, 0x3A, 0x38), c("Silver", 0xD7, 0xD7, 0xD5)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 15 Plus",
|
||||
width: 430.0,
|
||||
height: 932.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x3A, 0x3B, 0x3F), c("White", 0xEE, 0xED, 0xE8)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 14 Pro",
|
||||
width: 393.0,
|
||||
height: 852.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x3B, 0x3A, 0x38), c("Silver", 0xD7, 0xD7, 0xD5)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 14 Pro Max",
|
||||
width: 430.0,
|
||||
height: 932.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x3B, 0x3A, 0x38), c("Silver", 0xD7, 0xD7, 0xD5)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 14",
|
||||
width: 390.0,
|
||||
height: 844.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x3A, 0x3B, 0x3F), c("White", 0xEE, 0xED, 0xE8)],
|
||||
},
|
||||
Device {
|
||||
name: "Android Compact",
|
||||
width: 412.0,
|
||||
@@ -888,6 +972,13 @@ impl PrototypeSettings {
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x2E, 0x2F, 0x33), c("Silver", 0xD6, 0xD8, 0xDB), c("Green", 0xA6, 0xC0, 0xA3)],
|
||||
},
|
||||
Device {
|
||||
name: "Android Medium",
|
||||
width: 700.0,
|
||||
height: 840.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x2E, 0x2F, 0x33), c("Silver", 0xD6, 0xD8, 0xDB)],
|
||||
},
|
||||
Device {
|
||||
name: "iPad mini",
|
||||
width: 744.0,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# The workspace settings (../../clippy.toml; clippy reads only the nearest file), plus the engine's
|
||||
# own rule: shipped engine code reaches the file system only through `file_access`, which enforces
|
||||
# the automation roots (#715), so every command is confined without a check of its own.
|
||||
allow-unwrap-in-tests = true
|
||||
allow-expect-in-tests = true
|
||||
allow-panic-in-tests = true
|
||||
too-many-arguments-threshold = 10
|
||||
disallowed-methods = [
|
||||
{ path = "std::fs::read", reason = "use file_access::read (automation roots)" },
|
||||
{ path = "std::fs::read_to_string", reason = "use file_access::read (automation roots)" },
|
||||
{ path = "std::fs::write", reason = "use file_access::write (automation roots)" },
|
||||
{ path = "std::fs::metadata", reason = "use file_access::metadata (automation roots)" },
|
||||
{ path = "std::fs::symlink_metadata", reason = "use file_access (automation roots)" },
|
||||
{ path = "std::fs::read_dir", reason = "use file_access::read_dir (automation roots)" },
|
||||
{ path = "std::fs::canonicalize", reason = "use file_access (automation roots)" },
|
||||
{ path = "std::fs::copy", reason = "use file_access (automation roots)" },
|
||||
{ path = "std::fs::rename", reason = "use file_access (automation roots)" },
|
||||
{ path = "std::fs::create_dir", reason = "use file_access::create_dir_all (automation roots)" },
|
||||
{ path = "std::fs::create_dir_all", reason = "use file_access::create_dir_all (automation roots)" },
|
||||
{ path = "std::fs::remove_file", reason = "use file_access (automation roots)" },
|
||||
{ path = "std::fs::remove_dir", reason = "use file_access (automation roots)" },
|
||||
{ path = "std::fs::remove_dir_all", reason = "use file_access (automation roots)" },
|
||||
{ path = "std::fs::hard_link", reason = "use file_access (automation roots)" },
|
||||
{ path = "std::fs::File::open", reason = "use file_access::open (automation roots)" },
|
||||
{ path = "std::fs::File::create", reason = "use file_access::write (automation roots)" },
|
||||
{ path = "std::fs::File::create_new", reason = "use file_access::write (automation roots)" },
|
||||
{ path = "std::fs::OpenOptions::open", reason = "use file_access (automation roots)" },
|
||||
{ path = "std::path::Path::exists", reason = "use file_access::exists (automation roots)" },
|
||||
{ path = "std::path::Path::try_exists", reason = "use file_access::exists (automation roots)" },
|
||||
{ path = "std::path::Path::is_file", reason = "use file_access::metadata (automation roots)" },
|
||||
{ path = "std::path::Path::is_dir", reason = "use file_access::is_dir (automation roots)" },
|
||||
{ path = "std::path::Path::metadata", reason = "use file_access::metadata (automation roots)" },
|
||||
{ path = "std::path::Path::symlink_metadata", reason = "use file_access (automation roots)" },
|
||||
{ path = "std::path::Path::read_dir", reason = "use file_access::read_dir (automation roots)" },
|
||||
{ path = "std::path::Path::canonicalize", reason = "use file_access (automation roots)" },
|
||||
{ path = "figcraft_format::write_file_atomic", reason = "use file_access::write (automation roots)" },
|
||||
{ path = "figcraft_format::save_file", reason = "use file_access::write (automation roots)" },
|
||||
{ path = "figcraft_format::load_file", reason = "use file_access::read (automation roots)" },
|
||||
]
|
||||
@@ -180,6 +180,23 @@ fn vertical_wrap_columns_take_arrows_and_drops() {
|
||||
assert_eq!(line, Some([30.0, 48.0, 50.0, 48.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adding_auto_layout_to_a_frame_keeps_its_size() {
|
||||
// An existing frame with content hugs it (padding inferred, so the size is preserved);
|
||||
// an empty one keeps its size instead of collapsing to its padding.
|
||||
let mut s = Session::new();
|
||||
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 200})).unwrap());
|
||||
rect(&mut s, f, 40.0, 20.0, "#FF0000");
|
||||
s.execute("layout.add", &json!({"ids": [f]})).unwrap();
|
||||
assert_eq!(sizing(&s, f), (Sizing::Hug, Sizing::Hug));
|
||||
assert_eq!(size(&s, f), (300.0, 200.0));
|
||||
// An empty frame keeps its size too.
|
||||
let e = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 200})).unwrap());
|
||||
s.execute("layout.add", &json!({"ids": [e]})).unwrap();
|
||||
assert_eq!(sizing(&s, e), (Sizing::Fixed, Sizing::Fixed));
|
||||
assert_eq!(size(&s, e), (300.0, 200.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canvas_stacking_paints_and_exports_the_first_child_on_top() {
|
||||
// Documented: the last child is on top by default; "first on top" flips which one is drawn
|
||||
|
||||
@@ -19,6 +19,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("align.bottom", "Align bottom", "{ids?}", true, |s, p| align(s, p, Al::Bottom)),
|
||||
spec!("distribute.horizontal", "Distribute horizontal spacing", "{ids?, spacing?}", true, |s, p| distribute(s, p, true)),
|
||||
spec!("distribute.vertical", "Distribute vertical spacing", "{ids?, spacing?}", true, |s, p| distribute(s, p, false)),
|
||||
spec!(
|
||||
"arrange.spacing",
|
||||
"Spacing",
|
||||
"{ids?, horizontal?, vertical?, fromGesture?} (page units; fromGesture uses the interaction snapshot)",
|
||||
true,
|
||||
spacing
|
||||
),
|
||||
spec!("arrange.tidy", "Tidy up", "{ids?, spacing?}", true, tidy),
|
||||
spec!("arrange.packH", "Pack horizontal", "{ids?}", true, |s, p| pack(s, p, true)),
|
||||
spec!("arrange.packV", "Pack vertical", "{ids?}", true, |s, p| pack(s, p, false)),
|
||||
@@ -546,3 +553,53 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Set the distances between smart-selection tracks without rebuilding/reparenting the layers.
|
||||
fn spacing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("arrange.spacing", s, p)?;
|
||||
// A scrub is absolute from the interaction snapshot, even if tracks overlap or cross.
|
||||
let ds = s.ds()?;
|
||||
let base = ds.interaction.as_ref().filter(|_| boolean(p, "fromGesture") == Some(true)).map(|i| i.doc.clone()).unwrap_or_else(|| ds.doc.clone());
|
||||
let layout = crate::selection_spacing::selection_spacing(&base, &ids);
|
||||
let mut offsets = std::collections::BTreeMap::<NodeId, (Option<f64>, Option<f64>)>::new();
|
||||
let mut any = false;
|
||||
for (key, axis, vertical) in [("horizontal", &layout.horizontal, false), ("vertical", &layout.vertical, true)] {
|
||||
if get(p, key).is_none() {
|
||||
continue;
|
||||
}
|
||||
let value = req_num("arrange.spacing", p, key)?;
|
||||
if value.abs() > 1_000_000.0 {
|
||||
return Err(bad("arrange.spacing", "spacing out of range"));
|
||||
}
|
||||
let axis = axis.as_ref().ok_or_else(|| bad("arrange.spacing", "selection has no spacing on this axis"))?;
|
||||
any = true;
|
||||
for (id, delta) in axis.offsets(value) {
|
||||
if !delta.is_finite() {
|
||||
return Err(bad("arrange.spacing", "non-finite spacing result"));
|
||||
}
|
||||
let pair = offsets.entry(id).or_default();
|
||||
if vertical {
|
||||
pair.1 = Some(delta);
|
||||
} else {
|
||||
pair.0 = Some(delta);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !any {
|
||||
return Err(bad("arrange.spacing", "pass horizontal or vertical spacing"));
|
||||
}
|
||||
let doc = s.doc_mut()?;
|
||||
let mut moves = Vec::new();
|
||||
for (id, (dx, dy)) in offsets {
|
||||
let (Some(before), Some(now)) = (base.world_bounds(id), doc.world_bounds(id)) else { continue };
|
||||
let (dx, dy) = (dx.map_or(0.0, |d| before.x0 + d - now.x0), dy.map_or(0.0, |d| before.y0 + d - now.y0));
|
||||
if !dx.is_finite() || !dy.is_finite() {
|
||||
return Err(bad("arrange.spacing", "non-finite spacing result"));
|
||||
}
|
||||
moves.push((id, dx, dy));
|
||||
}
|
||||
for (id, dx, dy) in moves {
|
||||
move_world(doc, id, dx, dy)?;
|
||||
}
|
||||
ok()
|
||||
}
|
||||
@@ -68,7 +68,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let cmd = "instance.create";
|
||||
let c = req_id(cmd, p, "component")?;
|
||||
let page = s.page()?;
|
||||
let parent = id(p, "parent").unwrap_or(page);
|
||||
let parent = crate::params::opt_id(cmd, p, "parent")?.unwrap_or(page);
|
||||
let doc = s.doc_mut()?;
|
||||
let c = main_of(doc, c).ok_or_else(|| bad(cmd, "`component` must be a component or component set"))?;
|
||||
let (x, y) = match (num(p, "x"), num(p, "y")) {
|
||||
@@ -678,8 +678,8 @@ fn push_changes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !doc.contains(component) {
|
||||
return Err(EngineError::Disabled(cmd.into(), "the main component was deleted (use component.restoreMain)".into()));
|
||||
}
|
||||
// Copy every overridden layer's values to its source in the main (geometry, swaps, property
|
||||
// values and slot content stay with the instance).
|
||||
// Copy every overridden layer's values to its source in the main (geometry, resizing, swaps,
|
||||
// property values and slot content stay with the instance).
|
||||
let mut pushed = 0;
|
||||
for d in doc.subtree(inst) {
|
||||
let Some(n) = doc.node(d).cloned() else { continue };
|
||||
@@ -691,7 +691,9 @@ fn push_changes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let groups: BTreeSet<String> = n
|
||||
.overrides
|
||||
.iter()
|
||||
.filter(|g| !matches!(g.as_str(), "size" | "position" | "component" | "props" | SLOT) && !(d == inst && g.as_str() == "name"))
|
||||
.filter(|g| {
|
||||
!matches!(g.as_str(), "size" | "resizing" | "position" | "component" | "props" | SLOT) && !(d == inst && g.as_str() == "name")
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
for g in &groups {
|
||||
|
||||
@@ -7,7 +7,7 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
use crate::{Result, Session, bad, spec};
|
||||
|
||||
/// Frame presets shown by the Frame tool (category, name, width, height).
|
||||
pub const FRAME_PRESETS: &[(&str, &str, f64, f64)] = &[
|
||||
@@ -167,7 +167,7 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let t = string(p, "type").ok_or_else(|| bad(cmd, "`type` is required"))?;
|
||||
let (mut kind, base) = kind_for(&t).ok_or_else(|| bad(cmd, format!("unknown type `{t}`")))?;
|
||||
let page = s.page()?;
|
||||
let parent = id(p, "parent").unwrap_or(page);
|
||||
let parent = crate::params::opt_id(cmd, p, "parent")?.unwrap_or(page);
|
||||
let doc = s.doc()?;
|
||||
let pn = doc.get(parent)?;
|
||||
if !pn.kind.is_container() {
|
||||
@@ -255,7 +255,7 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
node.width = tw;
|
||||
node.height = th;
|
||||
}
|
||||
let index = num(p, "index").map(|i| i.max(0.0) as usize);
|
||||
let index = crate::params::opt_index(cmd, p, "index")?;
|
||||
doc.insert(node, parent, index)?;
|
||||
if let Some(Value::Object(props)) = get(p, "props") {
|
||||
super::props::apply(cmd, doc, nid, props)?;
|
||||
@@ -313,12 +313,12 @@ fn free_spot(s: &Session, w: f64, h: f64) -> Result<(f64, f64)> {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_path(path: &str) -> Result<Vec<u8>> {
|
||||
std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
crate::file_access::read(path)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn read_path(path: &str) -> Result<Vec<u8>> {
|
||||
Err(EngineError::Other(format!("{path}: files can't be read in the browser")))
|
||||
Err(crate::EngineError::Other(format!("{path}: files can't be read in the browser")))
|
||||
}
|
||||
|
||||
/// Place an image as a rectangle with an image fill (Figma's image layer).
|
||||
@@ -340,7 +340,7 @@ pub fn place_image(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => ImageScaleMode::Fill,
|
||||
};
|
||||
let page = s.page()?;
|
||||
let parent = id(p, "parent").unwrap_or(page);
|
||||
let parent = crate::params::opt_id(cmd, p, "parent")?.unwrap_or(page);
|
||||
let ds = s.ds_mut()?;
|
||||
let doc = ds.doc_mut();
|
||||
let (hash, iw, ih) = figcraft_format::add_image(doc, bytes).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
|
||||
@@ -503,9 +503,14 @@ fn bump_name(n: &str) -> String {
|
||||
}
|
||||
|
||||
/// The copied form of the selection.
|
||||
pub fn copy_to_clipboard(s: &mut Session, ids: &[NodeId]) -> Result<()> {
|
||||
pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<()> {
|
||||
let ds = s.ds()?;
|
||||
let (nodes, images, bounds) = copied(&ds.doc, ids);
|
||||
// Pages are not layers: they can't be pasted anywhere.
|
||||
let layers: Vec<NodeId> = ids.iter().copied().filter(|i| !ds.doc.node(*i).is_some_and(Node::is_page)).collect();
|
||||
if layers.is_empty() && !ids.is_empty() {
|
||||
return Err(EngineError::Disabled(cmd.into(), "pages can't be copied; use page.duplicate".into()));
|
||||
}
|
||||
let (nodes, images, bounds) = copied(&ds.doc, &layers);
|
||||
let (source, source_path, uid) = (ds.doc.clone(), ds.path.clone(), ds.uid);
|
||||
s.clipboard.source = Some(source);
|
||||
s.clipboard.source_path = source_path;
|
||||
@@ -598,14 +603,16 @@ fn paste_design_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("edit.copy", s, p)?;
|
||||
copy_to_clipboard(s, &ids)?;
|
||||
copy_to_clipboard(s, "edit.copy", &ids)?;
|
||||
Ok(json!({"copied": s.clipboard.nodes.len()}))
|
||||
}
|
||||
|
||||
fn cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("edit.cut", s, p)?;
|
||||
copy_to_clipboard(s, &ids)?;
|
||||
delete(s, &json!({"ids": ids}))
|
||||
copy_to_clipboard(s, "edit.cut", &ids)?;
|
||||
let doc = s.doc()?;
|
||||
let layers: Vec<NodeId> = ids.into_iter().filter(|i| !doc.node(*i).is_some_and(Node::is_page)).collect();
|
||||
delete(s, &json!({"ids": layers}))
|
||||
}
|
||||
|
||||
/// Paste the clipboard under `parent` with the copied top-left moved to `at` (page coords).
|
||||
@@ -1149,9 +1156,34 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.collect(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
let mut warnings = font_style_warnings(&s.ds()?.doc, &ids, &props);
|
||||
// Auto layout keys go to the layers that can have auto layout; with none among the targets,
|
||||
// the first refuses them (`props::layout_mut`).
|
||||
let layout_keys: Vec<&str> = props.keys().map(String::as_str).filter(|k| super::props::LAYOUT_KEYS.contains(k)).collect();
|
||||
let mut without_layout = props.clone();
|
||||
let mut skipped = BTreeSet::new();
|
||||
if !layout_keys.is_empty() {
|
||||
let doc = &s.ds()?.doc;
|
||||
let frame_like = |i: &NodeId| doc.node(*i).is_some_and(|n| n.kind.is_frame_like());
|
||||
if ids.iter().any(frame_like) {
|
||||
skipped = ids.iter().copied().filter(|i| !frame_like(i)).collect();
|
||||
without_layout.retain(|k, _| !layout_keys.contains(&k.as_str()));
|
||||
}
|
||||
if !skipped.is_empty() {
|
||||
let names: Vec<String> =
|
||||
skipped.iter().take(10).filter_map(|i| doc.node(*i)).map(|n| format!("{} “{}” ({})", n.id, n.name, n.kind.type_name())).collect();
|
||||
let more = if skipped.len() > names.len() { format!(" and {} more", skipped.len() - names.len()) } else { String::new() };
|
||||
warnings.push(format!(
|
||||
"{} skipped on {}{more}: only frames, components and instances have auto layout",
|
||||
layout_keys.iter().map(|k| format!("`{k}`")).collect::<Vec<_>>().join(", "),
|
||||
names.join(", ")
|
||||
));
|
||||
}
|
||||
}
|
||||
let doc = s.doc_mut()?;
|
||||
for i in ids {
|
||||
super::props::apply("node.set", doc, i, &props)?;
|
||||
let props = if skipped.contains(&i) { &without_layout } else { &props };
|
||||
super::props::apply("node.set", doc, i, props)?;
|
||||
}
|
||||
if let Some(o) = pivot {
|
||||
for (i, q) in pinned {
|
||||
@@ -1161,7 +1193,43 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
ok()
|
||||
if warnings.is_empty() { ok() } else { Ok(json!({"ok": true, "warnings": warnings})) }
|
||||
}
|
||||
|
||||
/// A `fontStyle` draws in the family's face nearest to it, as a text layer resolves it. When that
|
||||
/// face has another weight or slant than the name asks for ("Black" on a family whose heaviest is
|
||||
/// Bold), say which face it is. Families that aren't installed, and names that match a style or a
|
||||
/// named instance (spelled any way: "SemiBold", "Semi Bold"), say nothing.
|
||||
fn font_style_warnings(doc: &figcraft_doc::Document, ids: &[NodeId], props: &serde_json::Map<String, Value>) -> Vec<String> {
|
||||
let Some(style) = props.get("fontStyle").and_then(Value::as_str) else { return Vec::new() };
|
||||
let mut families: Vec<String> = match props.get("fontFamily").and_then(Value::as_str) {
|
||||
Some(f) => vec![f.to_string()],
|
||||
None => ids
|
||||
.iter()
|
||||
.filter_map(|i| doc.node(*i))
|
||||
.filter_map(|n| n.kind.text())
|
||||
.flat_map(|t| t.spans().into_iter().map(|(_, _, st)| st.font_family.clone()).collect::<Vec<_>>())
|
||||
.collect(),
|
||||
};
|
||||
families.sort();
|
||||
families.dedup();
|
||||
let norm = |s: &str| s.to_lowercase().replace([' ', '-', '_'], "");
|
||||
let (want_w, want_i) = (i32::from(figcraft_doc::style_weight(style)), style.to_lowercase().contains("italic"));
|
||||
families
|
||||
.into_iter()
|
||||
.filter_map(|family| {
|
||||
let styles = figcraft_text::family_styles(&family);
|
||||
if styles.is_empty() || styles.iter().any(|x| norm(x) == norm(style)) {
|
||||
return None;
|
||||
}
|
||||
let face = figcraft_text::find_face(&family, style).filter(|f| f.family.eq_ignore_ascii_case(&family))?;
|
||||
let w = face.axis("wght").map_or(i32::from(face.weight), |a| a.clamp(f64::from(want_w)).round() as i32);
|
||||
if face.instance(style).is_some() || (w == want_w && face.italic == want_i) {
|
||||
return None;
|
||||
}
|
||||
Some(format!("{family} has no `{style}` style: it draws in {}, the nearest (it has: {})", face.style, styles.join(", ")))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `stroke.widthProfile`: a preset or points (through `node.set`'s `widthProfile`, so instances
|
||||
@@ -1240,7 +1308,7 @@ fn paint_list(s: &mut Session, p: &Value, fills: bool, add: bool) -> Result<Valu
|
||||
fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
|
||||
let cmd = if fills { "fill.set" } else { "stroke.set" };
|
||||
let ids = req_targets(cmd, s, p)?;
|
||||
let idx = num(p, "index").map(|x| x.max(0.0) as usize);
|
||||
let idx = opt_index(cmd, p, "index")?;
|
||||
let new_paint = match get(p, "paint") {
|
||||
Some(v) => Some(paint_of(v).ok_or_else(|| bad(cmd, "invalid paint"))?),
|
||||
None => None,
|
||||
|
||||
@@ -435,7 +435,7 @@ pub(crate) fn recovered(path: &str) -> Result<DocState> {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read(path: &str) -> Result<Vec<u8>> {
|
||||
std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
crate::file_access::read(path)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -445,11 +445,7 @@ fn read(path: &str) -> Result<Vec<u8>> {
|
||||
|
||||
/// The bytes of file `path`, refused when it's larger than `max`.
|
||||
pub(crate) fn read_capped(path: &str, max: u64) -> Result<Vec<u8>> {
|
||||
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
if !meta.is_file() || meta.len() > max {
|
||||
return Err(EngineError::Other(format!("{path}: not a file of at most {max} bytes")));
|
||||
}
|
||||
read(path)
|
||||
crate::file_access::read_capped(path, max)
|
||||
}
|
||||
|
||||
/// Write `bytes` to `path` through a uniquely named, exclusively created staging file and one
|
||||
@@ -457,7 +453,7 @@ pub(crate) fn read_capped(path: &str, max: u64) -> Result<Vec<u8>> {
|
||||
/// truncates `path`, and no file or symlink at a staging name is ever written through.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn write(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
figcraft_format::write_file_atomic(std::path::Path::new(path), bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
crate::file_access::write(path, bytes)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -577,11 +573,9 @@ fn rest_images(path: &str) -> impl Fn(&str) -> Option<Vec<u8>> {
|
||||
return None;
|
||||
}
|
||||
let dir = dir.as_ref()?;
|
||||
["", ".png", ".jpg", ".jpeg", ".gif", ".webp"].iter().find_map(|e| {
|
||||
let p = dir.join(format!("{r}{e}"));
|
||||
let meta = std::fs::metadata(&p).ok()?;
|
||||
if meta.is_file() && meta.len() <= MAX_IMAGE_FILE { std::fs::read(&p).ok() } else { None }
|
||||
})
|
||||
["", ".png", ".jpg", ".jpeg", ".gif", ".webp"]
|
||||
.iter()
|
||||
.find_map(|e| crate::file_access::read_capped(dir.join(format!("{r}{e}")), MAX_IMAGE_FILE).ok())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -604,11 +598,7 @@ fn sibling_variables(path: &str) -> Option<Value> {
|
||||
let file = std::path::Path::new(path);
|
||||
let dir = file.parent()?;
|
||||
[format!("{}.variables.json", file_stem(path)), "variables.json".to_string()].iter().map(|n| dir.join(n)).filter(|c| c != file).find_map(|c| {
|
||||
let meta = std::fs::metadata(&c).ok()?;
|
||||
if !meta.is_file() || meta.len() > MAX_VARIABLES_FILE {
|
||||
return None;
|
||||
}
|
||||
let v: Value = serde_json::from_slice(&std::fs::read(&c).ok()?).ok()?;
|
||||
let v: Value = serde_json::from_slice(&crate::file_access::read_capped(c, MAX_VARIABLES_FILE).ok()?).ok()?;
|
||||
figcraft_format::rest::is_variables_json(&v).then_some(v)
|
||||
})
|
||||
}
|
||||
@@ -646,20 +636,29 @@ fn remember(s: &mut Session, path: &str) {
|
||||
}
|
||||
|
||||
fn open_recent(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use crate::file_access::{Access, allow, allowed};
|
||||
const CMD: &str = "file.openRecent";
|
||||
// Confined to automation roots, the recent files outside them are never listed, opened, removed
|
||||
// or named: `index` counts the files listed.
|
||||
let listed = |r: &String| allowed(r, Access::Read);
|
||||
if p.get("clear").and_then(Value::as_bool) == Some(true) {
|
||||
s.prefs.recent_files.clear();
|
||||
s.prefs.recent_files.retain(|r| !listed(r));
|
||||
}
|
||||
if let Some(gone) = string(p, "remove") {
|
||||
s.prefs.recent_files.retain(|r| *r != gone);
|
||||
s.prefs.recent_files.retain(|r| *r != gone || !listed(r));
|
||||
}
|
||||
let path = match (string(p, "path"), num(p, "index")) {
|
||||
(Some(path), _) => Some(path),
|
||||
(None, Some(i)) => Some(s.prefs.recent_files.get(i.max(0.0) as usize).cloned().ok_or_else(|| bad(CMD, "no such recent file"))?),
|
||||
(Some(path), _) => {
|
||||
allow(&path, Access::Read)?;
|
||||
Some(path)
|
||||
}
|
||||
(None, Some(i)) => {
|
||||
Some(s.prefs.recent_files.iter().filter(|r| listed(r)).nth(i.max(0.0) as usize).cloned().ok_or_else(|| bad(CMD, "no such recent file"))?)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(path) = path {
|
||||
if !std::path::Path::new(&path).exists() {
|
||||
if !crate::file_access::exists(&path) {
|
||||
s.prefs.recent_files.retain(|r| *r != path);
|
||||
return Err(bad(CMD, format!("{path} no longer exists")));
|
||||
}
|
||||
@@ -669,9 +668,10 @@ fn open_recent(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.prefs
|
||||
.recent_files
|
||||
.iter()
|
||||
.filter(|r| listed(r))
|
||||
.map(|r| {
|
||||
let name = std::path::Path::new(r).file_name().map_or_else(|| r.clone(), |f| f.to_string_lossy().into_owned());
|
||||
json!({"path": r, "name": name, "exists": std::path::Path::new(r).exists()})
|
||||
json!({"path": r, "name": name, "exists": crate::file_access::exists(r)})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({"files": files}))
|
||||
@@ -1183,7 +1183,7 @@ fn export_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = std::path::Path::new(d).join(&job.file);
|
||||
// A layer name with slashes exports into folders.
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
let _ = crate::file_access::create_dir_all(parent);
|
||||
}
|
||||
let path = path.to_string_lossy().to_string();
|
||||
write(&path, &bytes)?;
|
||||
|
||||
@@ -71,6 +71,7 @@ fn registry() -> &'static Registry {
|
||||
list.extend(create::specs());
|
||||
list.extend(edit::specs());
|
||||
list.extend(lists::specs());
|
||||
list.extend(crate::selection_colors::specs());
|
||||
list.extend(arrange::specs());
|
||||
list.extend(structure::specs());
|
||||
list.extend(section::specs());
|
||||
|
||||
@@ -265,6 +265,56 @@ fn ensure_layout(n: &mut Node) -> &mut AutoLayout {
|
||||
n.layout.get_or_insert_with(|| AutoLayout { padding: [0.0; 4], gap: 0.0, ..Default::default() })
|
||||
}
|
||||
|
||||
/// The keys that set a frame's auto layout (through [`layout_mut`]).
|
||||
pub const LAYOUT_KEYS: &[&str] = &[
|
||||
"gap",
|
||||
"itemSpacing",
|
||||
"columnGap",
|
||||
"crossGap",
|
||||
"counterAxisSpacing",
|
||||
"rowGap",
|
||||
"padding",
|
||||
"paddingTop",
|
||||
"paddingRight",
|
||||
"paddingBottom",
|
||||
"paddingLeft",
|
||||
"paddingHorizontal",
|
||||
"paddingVertical",
|
||||
"mainAlign",
|
||||
"primaryAxisAlignItems",
|
||||
"crossAlign",
|
||||
"counterAxisAlignItems",
|
||||
"contentAlign",
|
||||
"counterAxisAlignContent",
|
||||
"wrap",
|
||||
"gridColumns",
|
||||
"gridRows",
|
||||
"strokesIncluded",
|
||||
"reverseZ",
|
||||
"columnTracks",
|
||||
"gridColumnTracks",
|
||||
"rowTracks",
|
||||
"gridRowTracks",
|
||||
];
|
||||
|
||||
/// The auto layout of a frame-like node, created when it has none yet. Other nodes can't hold an
|
||||
/// auto layout: an auto layout key on them is refused, not stored where nothing reads it
|
||||
/// (`node.set` on several layers skips them instead, see `edit::set`).
|
||||
fn layout_mut<'a>(cmd: &str, k: &str, n: &'a mut Node) -> Result<&'a mut AutoLayout> {
|
||||
if !n.kind.is_frame_like() {
|
||||
return Err(bad(
|
||||
cmd,
|
||||
format!(
|
||||
"`{k}` is an auto layout property, and layer {} “{}” is a {} (only frames, components and instances have auto layout)",
|
||||
n.id,
|
||||
n.name,
|
||||
n.kind.type_name()
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(ensure_layout(n))
|
||||
}
|
||||
|
||||
/// Apply a property map to one node. `x`/`y` are relative to the parent.
|
||||
pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Value>) -> Result<()> {
|
||||
let mut unknown = Vec::new();
|
||||
@@ -452,56 +502,56 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
}
|
||||
}
|
||||
"gap" | "itemSpacing" | "columnGap" => {
|
||||
ensure_layout(doc.get_mut(id)?).gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?
|
||||
layout_mut(cmd, k, doc.get_mut(id)?)?.gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?
|
||||
}
|
||||
"crossGap" | "counterAxisSpacing" | "rowGap" => {
|
||||
ensure_layout(doc.get_mut(id)?).cross_gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?
|
||||
layout_mut(cmd, k, doc.get_mut(id)?)?.cross_gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?
|
||||
}
|
||||
"padding" => {
|
||||
let p = four(v).ok_or_else(|| bad(cmd, "padding: n or [t,r,b,l]"))?;
|
||||
ensure_layout(doc.get_mut(id)?).padding = p.map(|x| x.max(0.0));
|
||||
layout_mut(cmd, k, doc.get_mut(id)?)?.padding = p.map(|x| x.max(0.0));
|
||||
}
|
||||
"paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft" => {
|
||||
let i = ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"].iter().position(|x| *x == k).unwrap_or(0);
|
||||
let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0);
|
||||
if let Some(slot) = ensure_layout(doc.get_mut(id)?).padding.get_mut(i) {
|
||||
if let Some(slot) = layout_mut(cmd, k, doc.get_mut(id)?)?.padding.get_mut(i) {
|
||||
*slot = val;
|
||||
}
|
||||
}
|
||||
"paddingHorizontal" => {
|
||||
let val = f64_of(v).unwrap_or(0.0).max(0.0);
|
||||
let l = ensure_layout(doc.get_mut(id)?);
|
||||
let l = layout_mut(cmd, k, doc.get_mut(id)?)?;
|
||||
l.padding[1] = val;
|
||||
l.padding[3] = val;
|
||||
}
|
||||
"paddingVertical" => {
|
||||
let val = f64_of(v).unwrap_or(0.0).max(0.0);
|
||||
let l = ensure_layout(doc.get_mut(id)?);
|
||||
let l = layout_mut(cmd, k, doc.get_mut(id)?)?;
|
||||
l.padding[0] = val;
|
||||
l.padding[2] = val;
|
||||
}
|
||||
"mainAlign" | "primaryAxisAlignItems" => {
|
||||
ensure_layout(doc.get_mut(id)?).main_align =
|
||||
layout_mut(cmd, k, doc.get_mut(id)?)?.main_align =
|
||||
enum_of(v).ok_or_else(|| bad(cmd, "mainAlign: min|center|max|spaceBetween|spaceAround|spaceEvenly"))?
|
||||
}
|
||||
"crossAlign" | "counterAxisAlignItems" => {
|
||||
ensure_layout(doc.get_mut(id)?).cross_align = enum_of(v).ok_or_else(|| bad(cmd, "crossAlign: min|center|max|baseline"))?
|
||||
layout_mut(cmd, k, doc.get_mut(id)?)?.cross_align = enum_of(v).ok_or_else(|| bad(cmd, "crossAlign: min|center|max|baseline"))?
|
||||
}
|
||||
"contentAlign" | "counterAxisAlignContent" => {
|
||||
ensure_layout(doc.get_mut(id)?).content_align = enum_of(v).ok_or_else(|| bad(cmd, "contentAlign: auto|spaceBetween"))?
|
||||
layout_mut(cmd, k, doc.get_mut(id)?)?.content_align = enum_of(v).ok_or_else(|| bad(cmd, "contentAlign: auto|spaceBetween"))?
|
||||
}
|
||||
"wrap" => ensure_layout(doc.get_mut(id)?).wrap = v.as_bool().unwrap_or(false),
|
||||
"wrap" => layout_mut(cmd, k, doc.get_mut(id)?)?.wrap = v.as_bool().unwrap_or(false),
|
||||
"gridColumns" | "gridRows" => {
|
||||
let count = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.clamp(1.0, 1000.0) as u32;
|
||||
let l = ensure_layout(doc.get_mut(id)?);
|
||||
let l = layout_mut(cmd, k, doc.get_mut(id)?)?;
|
||||
// Tracks past the new count go away (new tracks are 1fr).
|
||||
let (n, tracks) =
|
||||
if k == "gridColumns" { (&mut l.grid_columns, &mut l.column_tracks) } else { (&mut l.grid_rows, &mut l.row_tracks) };
|
||||
*n = count;
|
||||
tracks.truncate(count as usize);
|
||||
}
|
||||
"strokesIncluded" => ensure_layout(doc.get_mut(id)?).strokes_included = v.as_bool().unwrap_or(false),
|
||||
"reverseZ" => ensure_layout(doc.get_mut(id)?).reverse_z = v.as_bool().unwrap_or(false),
|
||||
"strokesIncluded" => layout_mut(cmd, k, doc.get_mut(id)?)?.strokes_included = v.as_bool().unwrap_or(false),
|
||||
"reverseZ" => layout_mut(cmd, k, doc.get_mut(id)?)?.reverse_z = v.as_bool().unwrap_or(false),
|
||||
"sizingH" | "layoutSizingHorizontal" => {
|
||||
let s = sizing(v).ok_or_else(|| bad(cmd, "sizing: fixed|hug|fill"))?;
|
||||
set_sizing(cmd, doc, id, s, true)?;
|
||||
@@ -533,7 +583,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
"columnTracks" | "gridColumnTracks" | "rowTracks" | "gridRowTracks" => {
|
||||
let a = v.as_array().ok_or_else(|| bad(cmd, format!("`{k}` must be an array of tracks")))?;
|
||||
let tracks = a.iter().map(track_of).collect::<Option<Vec<_>>>().ok_or_else(|| bad(cmd, TRACK_HELP))?;
|
||||
let l = ensure_layout(doc.get_mut(id)?);
|
||||
let l = layout_mut(cmd, k, doc.get_mut(id)?)?;
|
||||
if k.contains("olumn") {
|
||||
l.grid_columns = (tracks.len() as u32).clamp(1, 1000);
|
||||
l.column_tracks = tracks;
|
||||
@@ -783,12 +833,14 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
if !unknown.is_empty() {
|
||||
return Err(bad(cmd, format!("unknown properties: {} (known: {KEYS})", unknown.join(", "))));
|
||||
}
|
||||
// Inside an instance, record which property groups were overridden (an instance's own export
|
||||
// settings and interactions are its own, not overrides).
|
||||
// Inside an instance, record which property groups were overridden (an instance's own
|
||||
// resizing, export settings and interactions are its own, not overrides).
|
||||
let own_root = matches!(doc.get(id)?.kind, NodeKind::Instance { .. }) && !crate::components::inside_instance(doc, id);
|
||||
let n = doc.get_mut(id)?;
|
||||
if n.source.is_some() {
|
||||
for g in props.keys().map(|k| override_group(k)).filter(|g| *g != "other" && !(own_root && matches!(*g, "exports" | "interactions"))) {
|
||||
for g in
|
||||
props.keys().map(|k| override_group(k)).filter(|g| *g != "other" && !(own_root && matches!(*g, "resizing" | "exports" | "interactions")))
|
||||
{
|
||||
n.overrides.insert(g.to_string());
|
||||
}
|
||||
}
|
||||
@@ -810,7 +862,8 @@ pub fn override_group(k: &str) -> &'static str {
|
||||
"features" | "fontFeatures" | "openTypeFeatures" | "listStyle" | "indentation" | "listSpacing" | "maxLines" | "variationSettings"
|
||||
| "hangingPunctuation" | "hangingList" => "textStyle",
|
||||
"cornerRadius" | "cornerSmoothing" | "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => "corners",
|
||||
"width" | "height" | "w" | "h" | "sizingH" | "sizingV" => "size",
|
||||
"width" | "height" | "w" | "h" => "size",
|
||||
"sizingH" | "sizingV" | "layoutSizingHorizontal" | "layoutSizingVertical" | "minWidth" | "maxWidth" | "minHeight" | "maxHeight" => "resizing",
|
||||
"gap" | "itemSpacing" | "columnGap" | "crossGap" | "counterAxisSpacing" | "rowGap" => "gap",
|
||||
"padding" | "paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft" | "paddingHorizontal" | "paddingVertical" => "padding",
|
||||
"mainAlign" | "primaryAxisAlignItems" | "crossAlign" | "counterAxisAlignItems" | "contentAlign" | "counterAxisAlignContent" => "alignment",
|
||||
|
||||
@@ -544,11 +544,16 @@ fn layout_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pb = doc.node(*b).map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect())).unwrap_or_default();
|
||||
if vertical { pa.y0.total_cmp(&pb.y0) } else { pa.x0.total_cmp(&pb.x0) }
|
||||
});
|
||||
let empty = doc.node(*f).is_some_and(|n| n.children.iter().filter_map(|c| doc.node(*c)).all(|c| !c.visible || c.child_layout.absolute));
|
||||
let n = doc.get_mut(*f)?;
|
||||
n.children = kids;
|
||||
n.layout = Some(al);
|
||||
n.child_layout.sizing_h = Sizing::Hug;
|
||||
n.child_layout.sizing_v = Sizing::Hug;
|
||||
if !all_frames || !empty {
|
||||
// A frame wrapped around the selection hugs it; an existing frame with no flow
|
||||
// children keeps its size instead of collapsing to its padding.
|
||||
n.child_layout.sizing_h = Sizing::Hug;
|
||||
n.child_layout.sizing_v = Sizing::Hug;
|
||||
}
|
||||
}
|
||||
s.set_selection(targets.clone());
|
||||
Ok(json!({"ids": targets}))
|
||||
|
||||
@@ -11,12 +11,12 @@
|
||||
//! Override groups: `fills`, `strokes`, `effects`, `opacity`, `visible`, `name`, `corners`,
|
||||
//! `characters`, `textStyle` (font, size, spacing, resizing, …), `padding`, `gap` and `alignment`
|
||||
//! (of auto layout: its direction and other settings are the main's), `grids`, `exports`,
|
||||
//! `interactions`, `component` (a swapped nested instance), `props` (property values of a nested
|
||||
//! instance) and `slot` (a slot holding the instance's own content). An instance's own export
|
||||
//! settings and interactions are its own, like its place. `size` and `position` overrides of
|
||||
//! layers inside an instance are still read and kept, but new ones are refused
|
||||
//! ([`check_override_edit`]): those layers take their geometry, constraints and resizing from the
|
||||
//! main; the instance itself resizes.
|
||||
//! `interactions`, `size` and `resizing` (hug, fill or fixed, with min and max sizes) of layers
|
||||
//! inside an instance, `component` (a swapped nested instance), `props` (property values of a
|
||||
//! nested instance) and `slot` (a slot holding the instance's own content). An instance's own
|
||||
//! export settings, interactions and resizing are its own, like its place. `position`
|
||||
//! overrides of layers inside an instance are still read and kept, but new ones are refused
|
||||
//! ([`check_override_edit`]): those layers take their place and constraints from the main.
|
||||
//!
|
||||
//! Switching variants keeps an override where the layer's name path matches and the two variants
|
||||
//! agreed on that property (text layers keep all of theirs); swapping from the Assets panel keeps
|
||||
@@ -58,6 +58,7 @@ pub const OVERRIDE_GROUPS: &[&str] = &[
|
||||
"exports",
|
||||
"interactions",
|
||||
"size",
|
||||
"resizing",
|
||||
"position",
|
||||
"component",
|
||||
"props",
|
||||
@@ -388,11 +389,14 @@ pub(crate) fn copy_group(from: &Node, to: &mut Node, g: &str) {
|
||||
"size" => {
|
||||
to.width = from.width;
|
||||
to.height = from.height;
|
||||
to.child_layout = from.child_layout.clone();
|
||||
if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) {
|
||||
b.auto_resize = a.auto_resize;
|
||||
}
|
||||
}
|
||||
"resizing" => {
|
||||
let (a, b) = (&from.child_layout, &mut to.child_layout);
|
||||
(b.sizing_h, b.sizing_v, b.min_w, b.max_w, b.min_h, b.max_h) = (a.sizing_h, a.sizing_v, a.min_w, a.max_w, a.min_h, a.max_h);
|
||||
}
|
||||
"position" => to.transform = from.transform,
|
||||
"component" => {
|
||||
if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&from.kind, &mut to.kind) {
|
||||
@@ -773,6 +777,15 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT
|
||||
for s in &src {
|
||||
let Some(nid) = map.get(s).copied() else { continue };
|
||||
let Some(o) = old.get(&nid) else { continue };
|
||||
// A frame that kept the size its own resizing gave it: its layers (copied at their main
|
||||
// layers' places) follow their constraints from its main layer's size.
|
||||
if o.overrides.contains("resizing")
|
||||
&& !o.overrides.contains("size")
|
||||
&& doc.node(nid).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none())
|
||||
&& let Some((mw, mh)) = doc.node(*s).map(|m| (m.width, m.height))
|
||||
{
|
||||
figcraft_layout::apply_constraints(doc, nid, mw, mh);
|
||||
}
|
||||
if !o.overrides.contains("size") && !o.overrides.contains("position") {
|
||||
continue;
|
||||
}
|
||||
@@ -985,6 +998,9 @@ pub fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> {
|
||||
if (a.width - b.width).abs() > 1e-6 || (a.height - b.height).abs() > 1e-6 {
|
||||
out.push("size");
|
||||
}
|
||||
if resizing(a) != resizing(b) {
|
||||
out.push("resizing");
|
||||
}
|
||||
if a.transform != b.transform {
|
||||
out.push("position");
|
||||
}
|
||||
@@ -1027,6 +1043,12 @@ pub fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The `resizing` group of a layer: how it sizes on each axis, and its min and max sizes.
|
||||
fn resizing(n: &Node) -> (Sizing, Sizing, [Option<f64>; 4]) {
|
||||
let c = &n.child_layout;
|
||||
(c.sizing_h, c.sizing_v, [c.min_w, c.max_w, c.min_h, c.max_h])
|
||||
}
|
||||
|
||||
/// Whether two texts differ in anything but their characters (the `textStyle` group).
|
||||
fn text_style_differs(x: &TextData, y: &TextData) -> bool {
|
||||
x.style != y.style
|
||||
@@ -1107,9 +1129,10 @@ pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId]
|
||||
}
|
||||
let raw = diff_groups(b, n);
|
||||
let mut groups = raw.clone();
|
||||
// An instance's own place, export settings and interactions are its own, not overrides.
|
||||
// An instance's own place, resizing, export settings and interactions are its own, not
|
||||
// overrides.
|
||||
if top_root {
|
||||
groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "exports" | "interactions"));
|
||||
groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "resizing" | "exports" | "interactions"));
|
||||
}
|
||||
if groups.iter().any(|g| matches!(*g, "size" | "position")) {
|
||||
groups.retain(|g| !matches!(*g, "size" | "position") || !geometry_derived(doc, before, id, g));
|
||||
@@ -1414,7 +1437,7 @@ fn patch_structure(doc: &mut Document, before: &Document, seeds: &[NodeId]) -> b
|
||||
fn child_follows_main(doc: &Document, parent: NodeId, absolute: bool) -> bool {
|
||||
let Some(p) = doc.node(parent) else { return false };
|
||||
let constrains = p.layout.is_none() || absolute;
|
||||
let resized = p.overrides.contains("size")
|
||||
let resized = sized_own(p)
|
||||
|| match &p.kind {
|
||||
NodeKind::Instance { component, .. } => {
|
||||
doc.node(*component).is_none_or(|m| (m.width - p.width).abs() > 1e-9 || (m.height - p.height).abs() > 1e-9)
|
||||
@@ -1518,7 +1541,7 @@ fn follows_main_geometry(doc: &Document, id: NodeId) -> bool {
|
||||
chain.windows(2).all(|w| {
|
||||
let (Some(below), Some(a)) = (w.first().and_then(|i| doc.node(*i)), w.get(1).and_then(|i| doc.node(*i))) else { return false };
|
||||
let constrains = a.layout.is_none() || below.child_layout.absolute;
|
||||
if a.overrides.contains("size") && constrains {
|
||||
if sized_own(a) && constrains {
|
||||
return false;
|
||||
}
|
||||
match &a.kind {
|
||||
@@ -1530,6 +1553,12 @@ fn follows_main_geometry(doc: &Document, id: NodeId) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a layer inside an instance overrides its size or resizing: it may not have its main
|
||||
/// layer's size, so the layers in it can't take their main layers' places as they are.
|
||||
fn sized_own(n: &Node) -> bool {
|
||||
n.overrides.contains("size") || n.overrides.contains("resizing")
|
||||
}
|
||||
|
||||
/// Rebuild every instance of `comps` (and of the components that hold those instances, and so on
|
||||
/// outwards), each outermost instance once, inner components first.
|
||||
fn rebuild_instances(doc: &mut Document, comps: &BTreeSet<NodeId>) {
|
||||
@@ -1665,10 +1694,9 @@ pub fn check_override_edit(before: &Document, after: &Document) -> Result<(), St
|
||||
|
||||
/// What about layer `id` an edit from `before` to `after` changed that only its main can: the
|
||||
/// auto layout of an instance or a layer in one but its padding, gap and alignment, or the
|
||||
/// position (rotation too), size, constraints or resizing of a layer inside an instance (the
|
||||
/// instance itself resizes, and its layers follow; text whose box follows its content still
|
||||
/// does). `None` when the edit is fine, also when the main changed the same way in this edit (a
|
||||
/// library update).
|
||||
/// position (rotation too), constraints or place in auto layout (absolute, grid cell) of a layer
|
||||
/// inside an instance (its size and resizing are overrides). `None` when the edit is fine, also
|
||||
/// when the main changed the same way in this edit (a library update).
|
||||
pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Option<&'static str> {
|
||||
let (n, b) = (after.node(id)?, before.node(id)?);
|
||||
if after.is_remote(id) || n.source != b.source || owning_instance(before, id).is_none() || owning_instance(after, id).is_none() {
|
||||
@@ -1684,8 +1712,12 @@ pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Opti
|
||||
l
|
||||
})
|
||||
};
|
||||
let size = |x: &Node| (x.width, x.height);
|
||||
let resized = |x: &Node, y: &Node| (x.width - y.width).abs() > 1e-6 || (x.height - y.height).abs() > 1e-6;
|
||||
// Child layout settings other than the resizing ones.
|
||||
let placing = |x: &Node| {
|
||||
let mut c = x.child_layout.clone();
|
||||
(c.sizing_h, c.sizing_v, c.min_w, c.max_w, c.min_h, c.max_h) = (Sizing::Fixed, Sizing::Fixed, None, None, None, None);
|
||||
c
|
||||
};
|
||||
if mode(n) != mode(b) && mode(ma) == mode(mb) {
|
||||
return Some("The auto layout direction");
|
||||
}
|
||||
@@ -1698,14 +1730,11 @@ pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Opti
|
||||
if n.transform != b.transform && ma.transform == mb.transform && !geometry_derived(after, before, id, "position") {
|
||||
return Some("A layer's position");
|
||||
}
|
||||
if resized(n, b) && size(ma) == size(mb) && !geometry_derived(after, before, id, "size") {
|
||||
return Some("A layer's size");
|
||||
}
|
||||
if n.constraints != b.constraints && ma.constraints == mb.constraints {
|
||||
return Some("A layer's constraints");
|
||||
}
|
||||
if n.child_layout != b.child_layout && ma.child_layout == mb.child_layout {
|
||||
return Some("A layer's resizing");
|
||||
if placing(n) != placing(b) && placing(ma) == placing(mb) {
|
||||
return Some("A layer's place in auto layout");
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -1923,6 +1952,7 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) {
|
||||
let mut groups: Vec<String> = value_groups(m, n)
|
||||
.into_iter()
|
||||
.filter(|g| !matches!(g.as_str(), "name" | "exports" | "interactions") && !from_variables.contains(&g.as_str()))
|
||||
.filter(|g| placed_source.is_some() || g != "resizing")
|
||||
.collect();
|
||||
let named = if placed_source.is_some() { n.name != m.name } else { n.name != default_instance_name(doc, component) };
|
||||
if named {
|
||||
@@ -1946,7 +1976,8 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) {
|
||||
}
|
||||
if let Some(n) = doc.node_mut(inst) {
|
||||
n.source = root_base;
|
||||
n.overrides.retain(|g| !matches!(g.as_str(), "component" | "props" | "position"));
|
||||
// A top-level instance's resizing is its own (a nested one's overrides its main layer's).
|
||||
n.overrides.retain(|g| !matches!(g.as_str(), "component" | "props" | "position") && (placed_source.is_some() || g != "resizing"));
|
||||
n.overrides.extend(root_groups);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,14 +316,6 @@ fn what_instances_cannot_override_is_refused() {
|
||||
("nudge a layer", "transform.nudge", json!({"ids": ["$"], "dx": 1, "dy": 0}), Layer::Bg, "position"),
|
||||
("rotate a layer", "node.set", json!({"id": "$", "props": {"rotation": 15}}), Layer::Bg, "position and rotation come with the layer's place"),
|
||||
("flip a layer", "object.flipH", json!({"ids": ["$"]}), Layer::Bg, "position"),
|
||||
(
|
||||
"resize a layer",
|
||||
"node.set",
|
||||
json!({"id": "$", "props": {"width": 50}}),
|
||||
Layer::Bg,
|
||||
"not among the overridable properties; the instance resizes",
|
||||
),
|
||||
("resize a text box", "node.set", json!({"id": "$", "props": {"width": 150}}), Layer::Title, "\"the bounds of any text layers\""),
|
||||
(
|
||||
"change constraints",
|
||||
"node.set",
|
||||
@@ -331,13 +323,6 @@ fn what_instances_cannot_override_is_refused() {
|
||||
Layer::Bg,
|
||||
"\"any constraints applied to the layers\"",
|
||||
),
|
||||
(
|
||||
"make a layer fill its auto layout",
|
||||
"node.set",
|
||||
json!({"id": "$", "props": {"sizingH": "fill"}}),
|
||||
Layer::Chip,
|
||||
"resizing comes with the layer's size",
|
||||
),
|
||||
(
|
||||
"take a layer out of auto layout",
|
||||
"node.set",
|
||||
|
||||
@@ -67,8 +67,6 @@ fn overrides_survive_main_edits() {
|
||||
ex(&mut s, "node.set", json!({"id": ibg, "props": {"effects": [{"type": "dropShadow"}]}}));
|
||||
ex(&mut s, "text.set", json!({"id": ititle, "characters": "Overridden"}));
|
||||
ex(&mut s, "node.set", json!({"id": idot, "props": {"visible": false}}));
|
||||
// A layer inside an instance keeps its main's size (the instance itself resizes).
|
||||
assert!(s.execute("node.set", &json!({"id": idot, "props": {"width": 30}})).is_err());
|
||||
let ov = node(&s, ibg).overrides;
|
||||
for g in ["fills", "strokes", "effects", "name"] {
|
||||
assert!(ov.contains(g), "{g} in {ov:?}");
|
||||
|
||||
@@ -202,15 +202,16 @@ fn an_edit_an_instance_refuses_shows_not_allowed_and_is_undone() {
|
||||
let before = s.doc().unwrap().world_bounds(inner).unwrap();
|
||||
assert_eq!((before.x0, before.y0, before.x1, before.y1), (450.0, 50.0, 550.0, 150.0));
|
||||
ex(&mut s, "select.set", json!({"ids": [inner]}));
|
||||
// Resizing it by its right edge: only the main component can.
|
||||
assert!(close(resize(hover(&mut s, (550.0, 100.0), NONE)), 0.0), "the handle still shows its arrow");
|
||||
// Resizing it by its right edge: a size of its own (an override).
|
||||
assert!(close(resize(hover(&mut s, (550.0, 100.0), NONE)), 0.0), "the handle shows its arrow");
|
||||
s.pointer(&ev(PointerKind::Down, (550.0, 100.0), NONE)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, (570.0, 100.0), NONE)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, (600.0, 100.0), NONE)).unwrap();
|
||||
assert_eq!(s.tool.overlay.cursor, Cursor::NotAllowed);
|
||||
assert_ne!(s.tool.overlay.cursor, Cursor::NotAllowed);
|
||||
s.pointer(&ev(PointerKind::Up, (600.0, 100.0), NONE)).unwrap();
|
||||
assert_eq!(s.doc().unwrap().world_bounds(inner).unwrap(), before, "the resize was undone");
|
||||
// Moving it: refused too.
|
||||
let before = s.doc().unwrap().world_bounds(inner).unwrap();
|
||||
assert_eq!((before.x0, before.x1), (450.0, 600.0), "the resize stays");
|
||||
// Moving it: only the main component can.
|
||||
s.pointer(&ev(PointerKind::Down, (500.0, 100.0), NONE)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, (510.0, 110.0), NONE)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, (520.0, 120.0), NONE)).unwrap();
|
||||
|
||||
@@ -157,3 +157,49 @@ fn an_alt_drag_into_auto_layout_sets_no_step() {
|
||||
cmd_d(&mut s);
|
||||
assert_eq!(s.doc().unwrap().node(f).unwrap().children.len(), n + 1, "Duplicate still adds one to the flow");
|
||||
}
|
||||
|
||||
/// Drag `from` by `by` with no modifier: moving the selection, not copying it.
|
||||
fn drag_move(s: &mut Session, from: (f64, f64), by: (f64, f64)) {
|
||||
let ev = |kind, (x, y): (f64, f64)| PointerEvent::at(kind, x, y, 1.0);
|
||||
s.pointer(&ev(PointerKind::Down, from)).unwrap();
|
||||
for k in 1..=4 {
|
||||
let t = f64::from(k) / 4.0;
|
||||
s.pointer(&ev(PointerKind::Move, (from.0 + by.0 * t, from.1 + by.1 * t))).unwrap();
|
||||
}
|
||||
s.pointer(&ev(PointerKind::Up, (from.0 + by.0, from.1 + by.1))).unwrap();
|
||||
}
|
||||
|
||||
/// The reported path (#567): Cmd+D, drag the copy with the mouse, Cmd+D again. The other tests
|
||||
/// here set the copy's position with `node.set` or Alt-drag it; dragging is how it is usually done,
|
||||
/// and the step is measured from where the copy ended up, whichever way it got there.
|
||||
#[test]
|
||||
fn duplicate_repeats_the_step_set_by_dragging_the_copy() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0.0, 0.0);
|
||||
ex(&mut s, "select.set", json!({"ids": [a]}));
|
||||
let c1 = cmd_d(&mut s);
|
||||
assert_eq!(at(&s, c1), (0.0, 0.0), "the first copy lands in place");
|
||||
|
||||
drag_move(&mut s, (50.0, 50.0), (150.0, 20.0));
|
||||
assert_eq!(at(&s, c1), (150.0, 20.0), "the drag moved the copy, it did not make another one");
|
||||
assert_eq!(s.selection(), vec![c1], "and left it selected");
|
||||
assert_eq!(at(&s, a), (0.0, 0.0), "the original stays");
|
||||
|
||||
let c2 = cmd_d(&mut s);
|
||||
assert_eq!(at(&s, c2), (300.0, 40.0), "the next one as far again");
|
||||
let c3 = cmd_d(&mut s);
|
||||
assert_eq!(at(&s, c3), (450.0, 60.0), "and again");
|
||||
}
|
||||
|
||||
/// And a step set with the arrow keys, which is how a small offset is usually made exact.
|
||||
#[test]
|
||||
fn duplicate_repeats_a_nudged_step() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0.0, 0.0);
|
||||
ex(&mut s, "select.set", json!({"ids": [a]}));
|
||||
let c1 = cmd_d(&mut s);
|
||||
ex(&mut s, "transform.nudge", json!({"dx": 40, "dy": 0}));
|
||||
assert_eq!(at(&s, c1), (40.0, 0.0));
|
||||
let c2 = cmd_d(&mut s);
|
||||
assert_eq!(at(&s, c2), (80.0, 0.0), "the nudge is the step");
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
//! Automation roots (#715): the folders the files an agent's commands read and write must lie in.
|
||||
//!
|
||||
//! `figcraft-cli mcp --automation-read-root DIR --automation-write-root DIR` and the desktop app
|
||||
//! started with the same flags run their work [`confined`] to [`Roots`]: while a scope is active on
|
||||
//! the thread, every file the engine reads must lie inside a read or a write root and every file it
|
||||
//! writes inside a write root; anything else is an ordinary error. The engine reaches the file
|
||||
//! system only through this module (`crates/engine/clippy.toml` denies `std::fs` and friends
|
||||
//! anywhere else), so every command, present or future, is covered. Without a scope nothing changes.
|
||||
//!
|
||||
//! A path is made absolute against the working directory and resolved before it is compared: its
|
||||
//! links are followed (`std::fs::canonicalize`), and for a file that doesn't exist yet its nearest
|
||||
//! existing folder is resolved and the rest must be plain names. The comparison is by whole
|
||||
//! components (`/work2` is not inside `/work`), and the I/O uses the resolved path, so what was
|
||||
//! checked is what is opened.
|
||||
#![allow(clippy::disallowed_methods)]
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{EngineError, Result};
|
||||
|
||||
/// What a command does with a file.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Access {
|
||||
Read,
|
||||
Write,
|
||||
}
|
||||
|
||||
/// The folders automation may read (read and write roots) and write (write roots), resolved.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Roots {
|
||||
read: Vec<PathBuf>,
|
||||
write: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl Roots {
|
||||
/// Roots from the folders given (relative ones against the working directory). Each must be an
|
||||
/// existing folder.
|
||||
pub fn new(read: &[String], write: &[String]) -> std::result::Result<Self, String> {
|
||||
let resolve = |flag: &str, dirs: &[String]| {
|
||||
dirs.iter()
|
||||
.map(|d| match std::fs::canonicalize(d) {
|
||||
Ok(p) if p.is_dir() => Ok(p),
|
||||
Ok(_) => Err(format!("{flag} {d}: not a folder")),
|
||||
Err(e) => Err(format!("{flag} {d}: {e}")),
|
||||
})
|
||||
.collect::<std::result::Result<Vec<_>, String>>()
|
||||
};
|
||||
Ok(Self { read: resolve("--automation-read-root", read)?, write: resolve("--automation-write-root", write)? })
|
||||
}
|
||||
|
||||
/// The resolved path to use for `access` to `path`, or why it is refused.
|
||||
pub fn check(&self, path: &Path, access: Access) -> std::result::Result<PathBuf, String> {
|
||||
let asked = path.display();
|
||||
let (roots, what) = match access {
|
||||
Access::Read => ([self.read.as_slice(), self.write.as_slice()].concat(), "read"),
|
||||
Access::Write => (self.write.clone(), "written"),
|
||||
};
|
||||
match resolve(path) {
|
||||
Ok(resolved) if roots.iter().any(|r| resolved.starts_with(r)) => return Ok(resolved),
|
||||
Ok(_) => {}
|
||||
// Why a path can't be resolved says what exists there: only told for paths inside a root.
|
||||
Err(why) if lexically_inside(path, &roots) => return Err(format!("automation path rejected: {asked}: {why}")),
|
||||
Err(_) => {}
|
||||
}
|
||||
if roots.is_empty() {
|
||||
return Err(format!("automation path rejected: no folder may be {what} (no root for it was given): {asked}"));
|
||||
}
|
||||
let shown: Vec<String> = roots.iter().map(|r| shown(r)).collect();
|
||||
Err(format!("automation path rejected: {asked} is outside the folders that may be {what}: {}", shown.join(", ")))
|
||||
}
|
||||
|
||||
/// The folders as JSON, resolved: `{read: [every folder that may be read], write: [the write roots]}`.
|
||||
pub fn to_json(&self) -> serde_json::Value {
|
||||
let list = |v: &[PathBuf]| v.iter().map(|p| shown(p)).collect::<Vec<_>>();
|
||||
serde_json::json!({"read": list(&[self.read.as_slice(), self.write.as_slice()].concat()), "write": list(&self.write)})
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `path`, made absolute with its `.` and `..` taken by name (no file system), lies in one
|
||||
/// of `roots` (as resolved, or as shown without Windows' `\\?\` prefix).
|
||||
fn lexically_inside(path: &Path, roots: &[PathBuf]) -> bool {
|
||||
let Ok(abs) = std::path::absolute(path) else { return false };
|
||||
let mut lexical = PathBuf::new();
|
||||
for c in abs.components() {
|
||||
match c {
|
||||
Component::ParentDir => {
|
||||
lexical.pop();
|
||||
}
|
||||
Component::CurDir => {}
|
||||
c => lexical.push(c),
|
||||
}
|
||||
}
|
||||
roots.iter().any(|r| lexical.starts_with(r) || lexical.starts_with(shown(r)))
|
||||
}
|
||||
|
||||
/// Whether `path` lies in the app's own folder `folder`, both resolved (links and `..` followed);
|
||||
/// false when either can't be resolved.
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
pub(crate) fn within_app_folder(path: &Path, folder: &Path) -> bool {
|
||||
matches!((std::fs::canonicalize(path), std::fs::canonicalize(folder)), (Ok(p), Ok(f)) if p.starts_with(&f))
|
||||
}
|
||||
|
||||
/// `p` without Windows' `\\?\` prefix, for messages.
|
||||
fn shown(p: &Path) -> String {
|
||||
let s = p.to_string_lossy();
|
||||
match s.strip_prefix(r"\\?\UNC\") {
|
||||
Some(unc) => format!(r"\\{unc}"),
|
||||
None => s.strip_prefix(r"\\?\").unwrap_or(&s).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `path` made absolute and resolved: links followed, and for a path that doesn't exist yet its
|
||||
/// nearest existing folder resolved with the rest appended (plain names only).
|
||||
fn resolve(path: &Path) -> std::result::Result<PathBuf, String> {
|
||||
if path.as_os_str().is_empty() {
|
||||
return Err("not a file path".into());
|
||||
}
|
||||
let abs = std::path::absolute(path).map_err(|e| e.to_string())?;
|
||||
let mut rest: Vec<OsString> = Vec::new();
|
||||
let mut at = abs.as_path();
|
||||
loop {
|
||||
match std::fs::canonicalize(at) {
|
||||
Ok(mut base) => {
|
||||
base.extend(rest.iter().rev());
|
||||
return Ok(base);
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
// Writing through a link that leads nowhere would create its target, wherever it is.
|
||||
if std::fs::symlink_metadata(at).is_ok() {
|
||||
return Err("a link that leads nowhere".into());
|
||||
}
|
||||
match at.components().next_back() {
|
||||
Some(Component::Normal(name)) => {
|
||||
plain_name(name)?;
|
||||
rest.push(name.to_os_string());
|
||||
}
|
||||
Some(Component::CurDir) => {}
|
||||
_ => return Err("`..` (or a drive) below a folder that doesn't exist".into()),
|
||||
}
|
||||
at = at.parent().ok_or("no existing folder")?;
|
||||
}
|
||||
Err(e) => return Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A name for a file or folder that doesn't exist yet: on Windows no device (`CON`, `NUL`, `COM1`…)
|
||||
/// and no alternate data stream (`a:b`).
|
||||
fn plain_name(name: &OsStr) -> std::result::Result<(), String> {
|
||||
if cfg!(windows) {
|
||||
let n = name.to_string_lossy();
|
||||
let stem = n.split('.').next().unwrap_or_default().trim_end().to_ascii_uppercase();
|
||||
let device = matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$")
|
||||
|| ((stem.starts_with("COM") || stem.starts_with("LPT"))
|
||||
&& stem.chars().count() == 4
|
||||
&& stem.chars().last().is_some_and(|c| c.is_ascii_digit() || matches!(c, '¹' | '²' | '³')));
|
||||
if device || n.contains(':') {
|
||||
return Err(format!("`{n}` is a device or stream name, not a file"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static ROOTS: RefCell<Option<Arc<Roots>>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Run `f` with this thread's file access confined to `roots` (unchanged when `None`).
|
||||
pub fn confined<R>(roots: Option<&Arc<Roots>>, f: impl FnOnce() -> R) -> R {
|
||||
match roots {
|
||||
Some(roots) => scoped(Some(roots.clone()), f),
|
||||
None => f(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f` unconfined: only for the app's own files, never for a path a command was given.
|
||||
// Only plugins read from disk use it, and the browser has no disk.
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
pub(crate) fn app_owned<R>(f: impl FnOnce() -> R) -> R {
|
||||
scoped(None, f)
|
||||
}
|
||||
|
||||
fn scoped<R>(roots: Option<Arc<Roots>>, f: impl FnOnce() -> R) -> R {
|
||||
/// Puts the outer scope's roots back, also when `f` panics.
|
||||
struct Restore(Option<Arc<Roots>>);
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
let outer = self.0.take();
|
||||
ROOTS.with(|r| *r.borrow_mut() = outer);
|
||||
}
|
||||
}
|
||||
let _restore = Restore(ROOTS.with(|r| std::mem::replace(&mut *r.borrow_mut(), roots)));
|
||||
f()
|
||||
}
|
||||
|
||||
/// The roots this thread's file access is confined to now, if any.
|
||||
pub fn current() -> Option<Arc<Roots>> {
|
||||
ROOTS.with(|r| r.borrow().clone())
|
||||
}
|
||||
|
||||
/// The path to use for `access` to `path`: resolved when confined, else as given.
|
||||
pub fn allow(path: impl AsRef<Path>, access: Access) -> Result<PathBuf> {
|
||||
let path = path.as_ref();
|
||||
match current() {
|
||||
Some(roots) => roots.check(path, access).map_err(EngineError::Other),
|
||||
None => Ok(path.to_path_buf()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `path` may be accessed so now.
|
||||
pub fn allowed(path: impl AsRef<Path>, access: Access) -> bool {
|
||||
allow(path, access).is_ok()
|
||||
}
|
||||
|
||||
fn io(path: &Path) -> impl Fn(std::io::Error) -> EngineError + '_ {
|
||||
move |e| EngineError::Other(format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// The bytes of file `path`.
|
||||
pub fn read(path: impl AsRef<Path>) -> Result<Vec<u8>> {
|
||||
let path = path.as_ref();
|
||||
std::fs::read(allow(path, Access::Read)?).map_err(io(path))
|
||||
}
|
||||
|
||||
/// The bytes of file `path`, refused when it isn't a file or is larger than `max`.
|
||||
pub fn read_capped(path: impl AsRef<Path>, max: u64) -> Result<Vec<u8>> {
|
||||
let path = path.as_ref();
|
||||
let p = allow(path, Access::Read)?;
|
||||
let meta = std::fs::metadata(&p).map_err(io(path))?;
|
||||
if !meta.is_file() || meta.len() > max {
|
||||
return Err(EngineError::Other(format!("{}: not a file of at most {max} bytes", path.display())));
|
||||
}
|
||||
std::fs::read(p).map_err(io(path))
|
||||
}
|
||||
|
||||
/// File `path` opened for reading.
|
||||
pub fn open(path: impl AsRef<Path>) -> Result<std::fs::File> {
|
||||
let path = path.as_ref();
|
||||
std::fs::File::open(allow(path, Access::Read)?).map_err(io(path))
|
||||
}
|
||||
|
||||
/// What the file system says about `path` (links followed).
|
||||
pub fn metadata(path: impl AsRef<Path>) -> Result<std::fs::Metadata> {
|
||||
let path = path.as_ref();
|
||||
std::fs::metadata(allow(path, Access::Read)?).map_err(io(path))
|
||||
}
|
||||
|
||||
/// Whether `path` is a folder (false when it may not be read).
|
||||
pub fn is_dir(path: impl AsRef<Path>) -> bool {
|
||||
metadata(path).is_ok_and(|m| m.is_dir())
|
||||
}
|
||||
|
||||
/// Whether `path` exists (false when it may not be read).
|
||||
pub fn exists(path: impl AsRef<Path>) -> bool {
|
||||
metadata(path).is_ok()
|
||||
}
|
||||
|
||||
/// The paths of the entries of folder `path`.
|
||||
pub fn read_dir(path: impl AsRef<Path>) -> Result<Vec<PathBuf>> {
|
||||
let path = path.as_ref();
|
||||
let dir = allow(path, Access::Read)?;
|
||||
Ok(std::fs::read_dir(&dir).map_err(io(path))?.flatten().map(|e| e.path()).collect())
|
||||
}
|
||||
|
||||
/// Write `bytes` to `path` through a uniquely named staging file and one atomic rename (see
|
||||
/// [`figcraft_format::write_file_atomic`]).
|
||||
pub fn write(path: impl AsRef<Path>, bytes: &[u8]) -> Result<()> {
|
||||
let path = path.as_ref();
|
||||
write_atomic(&allow(path, Access::Write)?, bytes).map_err(io(path))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
figcraft_format::write_file_atomic(path, bytes)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn write_atomic(_: &Path, _: &[u8]) -> std::io::Result<()> {
|
||||
Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "no file system in the browser"))
|
||||
}
|
||||
|
||||
/// Create folder `path` and the folders above it that are missing.
|
||||
pub fn create_dir_all(path: impl AsRef<Path>) -> Result<()> {
|
||||
let path = path.as_ref();
|
||||
std::fs::create_dir_all(allow(path, Access::Write)?).map_err(io(path))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "file_access_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,371 @@
|
||||
//! Automation roots: how paths are resolved and compared, and that commands run confined can't
|
||||
//! reach past them.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::{Access, Roots, allow, confined, current};
|
||||
use crate::Session;
|
||||
|
||||
/// A fresh folder for one test: `<tmp>/figcraft-roots-<name>-<pid>`, holding `root/`, `root2/`
|
||||
/// (a sibling whose name starts like the root's) and `outside/`.
|
||||
struct Fixture(PathBuf);
|
||||
|
||||
impl Fixture {
|
||||
fn new(name: &str) -> Self {
|
||||
let d = std::env::temp_dir().join(format!("figcraft-roots-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
for sub in ["root/sub", "root2", "outside"] {
|
||||
std::fs::create_dir_all(d.join(sub)).unwrap();
|
||||
}
|
||||
std::fs::write(d.join("root/in.txt"), b"in").unwrap();
|
||||
std::fs::write(d.join("outside/secret.txt"), b"secret").unwrap();
|
||||
Self(d)
|
||||
}
|
||||
|
||||
fn p(&self, rel: &str) -> String {
|
||||
self.0.join(rel).to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// The root as both read and write root.
|
||||
fn rw(&self) -> Roots {
|
||||
Roots::new(&[], &[self.p("root")]).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn symlink_dir(target: &Path, link: &Path) -> bool {
|
||||
std::os::unix::fs::symlink(target, link).is_ok()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn symlink_dir(target: &Path, link: &Path) -> bool {
|
||||
std::os::windows::fs::symlink_dir(target, link).is_ok()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn symlink_file(target: &Path, link: &Path) -> bool {
|
||||
std::os::unix::fs::symlink(target, link).is_ok()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn symlink_file(target: &Path, link: &Path) -> bool {
|
||||
std::os::windows::fs::symlink_file(target, link).is_ok()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inside_is_allowed_and_resolved_outside_is_refused() {
|
||||
let f = Fixture::new("inside");
|
||||
let r = f.rw();
|
||||
let got = r.check(Path::new(&f.p("root/in.txt")), Access::Read).unwrap();
|
||||
assert_eq!(got, std::fs::canonicalize(f.p("root/in.txt")).unwrap(), "the I/O uses the resolved path");
|
||||
assert!(r.check(Path::new(&f.p("root/new.png")), Access::Write).is_ok());
|
||||
let e = r.check(Path::new(&f.p("outside/secret.txt")), Access::Read).unwrap_err();
|
||||
assert!(e.starts_with("automation path rejected:") && e.contains("outside the folders that may be read"), "{e}");
|
||||
assert!(r.check(Path::new(&f.p("outside/new.png")), Access::Write).is_err());
|
||||
// The root itself is inside.
|
||||
assert!(r.check(Path::new(&f.p("root")), Access::Read).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sibling_whose_name_starts_like_the_root_is_outside() {
|
||||
let f = Fixture::new("prefix");
|
||||
let r = f.rw();
|
||||
for p in ["root2/x.png", "root2", "root.png"] {
|
||||
assert!(r.check(Path::new(&f.p(p)), Access::Write).is_err(), "{p}");
|
||||
assert!(r.check(Path::new(&f.p(p)), Access::Read).is_err(), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn traversal_is_resolved_before_comparing() {
|
||||
let f = Fixture::new("dots");
|
||||
let r = f.rw();
|
||||
assert!(r.check(Path::new(&f.p("root/sub/../../outside/secret.txt")), Access::Read).is_err());
|
||||
assert!(r.check(Path::new(&f.p("root/sub/../../outside/new.txt")), Access::Write).is_err());
|
||||
assert!(r.check(Path::new(&f.p("root/sub/../in.txt")), Access::Read).is_ok());
|
||||
assert!(r.check(Path::new(&f.p("root/./sub/../new.txt")), Access::Write).is_ok());
|
||||
// `..` below a folder that doesn't exist can't be resolved (on Windows it is taken lexically,
|
||||
// as the system does, and stays inside).
|
||||
let deep = f.p("root/missing/../../outside/x.txt");
|
||||
assert!(r.check(Path::new(&deep), Access::Write).is_err(), "{deep}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_that_dont_exist_yet_resolve_through_their_nearest_folder() {
|
||||
let f = Fixture::new("missing");
|
||||
let r = f.rw();
|
||||
let got = r.check(Path::new(&f.p("root/a/b/c.png")), Access::Write).unwrap();
|
||||
assert_eq!(got, std::fs::canonicalize(f.p("root")).unwrap().join("a").join("b").join("c.png"));
|
||||
assert!(r.check(Path::new(&f.p("outside/a/b/c.png")), Access::Write).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_paths_are_resolved_against_the_working_directory() {
|
||||
// `cargo test` runs in the crate's folder, which holds Cargo.toml.
|
||||
let cwd = std::env::current_dir().unwrap().to_string_lossy().into_owned();
|
||||
let here = Roots::new(std::slice::from_ref(&cwd), &[]).unwrap();
|
||||
assert_eq!(here.check(Path::new("Cargo.toml"), Access::Read).unwrap(), std::fs::canonicalize("Cargo.toml").unwrap());
|
||||
assert!(here.check(Path::new("src/../Cargo.toml"), Access::Read).is_ok());
|
||||
let f = Fixture::new("relative");
|
||||
assert!(f.rw().check(Path::new("Cargo.toml"), Access::Read).is_err());
|
||||
// Relative roots too.
|
||||
assert_eq!(Roots::new(&[".".into()], &[]).unwrap(), here);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_and_write_authority_are_separate() {
|
||||
let f = Fixture::new("modes");
|
||||
let read_only = Roots::new(&[f.p("root")], &[]).unwrap();
|
||||
assert!(read_only.check(Path::new(&f.p("root/in.txt")), Access::Read).is_ok());
|
||||
let e = read_only.check(Path::new(&f.p("root/out.png")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("no folder may be written"), "{e}");
|
||||
// A write root may be read too; a read root elsewhere doesn't make it writable.
|
||||
let both = Roots::new(&[f.p("outside")], &[f.p("root")]).unwrap();
|
||||
assert!(both.check(Path::new(&f.p("root/in.txt")), Access::Read).is_ok());
|
||||
assert!(both.check(Path::new(&f.p("outside/secret.txt")), Access::Read).is_ok());
|
||||
assert!(both.check(Path::new(&f.p("outside/new.txt")), Access::Write).is_err());
|
||||
// Several roots of each kind.
|
||||
let many = Roots::new(&[f.p("root"), f.p("root2")], &[f.p("root2"), f.p("outside")]).unwrap();
|
||||
assert!(many.check(Path::new(&f.p("outside/new.txt")), Access::Write).is_ok());
|
||||
assert!(many.check(Path::new(&f.p("root/new.txt")), Access::Write).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roots_must_be_existing_folders() {
|
||||
let f = Fixture::new("bad-roots");
|
||||
assert!(Roots::new(&[f.p("nope")], &[]).unwrap_err().contains("--automation-read-root"));
|
||||
assert!(Roots::new(&[], &[f.p("root/in.txt")]).unwrap_err().contains("not a folder"));
|
||||
assert!(Roots::new(&[], &[String::new()]).is_err());
|
||||
assert!(f.rw().check(Path::new(""), Access::Read).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_that_lead_out_of_a_root_are_outside() {
|
||||
let f = Fixture::new("links");
|
||||
let r = f.rw();
|
||||
if !symlink_dir(&f.0.join("outside"), &f.0.join("root/escape")) || !symlink_file(&f.0.join("outside/secret.txt"), &f.0.join("root/secret.txt")) {
|
||||
eprintln!("skipped: this system doesn't let tests make symbolic links");
|
||||
return;
|
||||
}
|
||||
assert!(r.check(Path::new(&f.p("root/escape/secret.txt")), Access::Read).is_err());
|
||||
assert!(r.check(Path::new(&f.p("root/escape/new.txt")), Access::Write).is_err());
|
||||
assert!(r.check(Path::new(&f.p("root/secret.txt")), Access::Read).is_err());
|
||||
assert!(r.check(Path::new(&f.p("root/secret.txt")), Access::Write).is_err());
|
||||
// A link that leads nowhere: writing through it would create its target.
|
||||
assert!(symlink_file(&f.0.join("outside/not-yet.txt"), &f.0.join("root/dangling.txt")));
|
||||
let e = r.check(Path::new(&f.p("root/dangling.txt")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("leads nowhere"), "{e}");
|
||||
// A link inside the root to the root is fine.
|
||||
assert!(symlink_dir(&f.0.join("root/sub"), &f.0.join("root/alias")));
|
||||
assert!(r.check(Path::new(&f.p("root/alias/x.png")), Access::Write).is_ok());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_case_devices_and_streams() {
|
||||
let f = Fixture::new("windows");
|
||||
let r = f.rw();
|
||||
// Case is taken from the disk: another spelling of the root is the root.
|
||||
let upper = f.0.join("ROOT").join("IN.TXT");
|
||||
assert!(r.check(&upper, Access::Read).is_ok());
|
||||
for name in ["CON", "nul.txt", "Com1.png", "LPT9", "a.txt:stream", "conout$"] {
|
||||
assert!(r.check(&f.0.join("root").join(name), Access::Write).is_err(), "{name}");
|
||||
}
|
||||
assert!(r.check(&f.0.join("root").join("console.png"), Access::Write).is_ok());
|
||||
// The `\\?\` form of a path inside is inside; of a path outside, outside.
|
||||
let verbatim = std::fs::canonicalize(f.p("root")).unwrap().join("v.png");
|
||||
assert!(verbatim.to_string_lossy().starts_with(r"\\?\"));
|
||||
assert!(r.check(&verbatim, Access::Write).is_ok());
|
||||
assert!(r.check(&std::fs::canonicalize(f.p("outside")).unwrap().join("v.png"), Access::Write).is_err());
|
||||
// Messages don't show the prefix.
|
||||
let e = r.check(Path::new(&f.p("outside/x.png")), Access::Write).unwrap_err();
|
||||
assert!(!e.contains(r"\\?\"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scope_confines_the_thread_and_ends_with_it() {
|
||||
let f = Fixture::new("scope");
|
||||
let roots = Arc::new(f.rw());
|
||||
assert!(current().is_none());
|
||||
confined(Some(&roots), || {
|
||||
assert!(allow(f.p("outside/secret.txt"), Access::Read).is_err());
|
||||
assert!(super::read(f.p("outside/secret.txt")).is_err());
|
||||
assert_eq!(super::read(f.p("root/in.txt")).unwrap(), b"in");
|
||||
// Not confined: the app's own files.
|
||||
assert_eq!(super::app_owned(|| super::read(f.p("outside/secret.txt")).unwrap()), b"secret");
|
||||
assert!(current().is_some(), "back in the scope after app_owned");
|
||||
// Another thread isn't confined by this one's scope.
|
||||
let p = f.p("outside/secret.txt");
|
||||
assert!(std::thread::spawn(move || current().is_none() && allow(p, Access::Read).is_ok()).join().unwrap());
|
||||
});
|
||||
assert!(current().is_none());
|
||||
assert!(allow(f.p("outside/secret.txt"), Access::Read).is_ok());
|
||||
// A panic inside the scope still ends it.
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| confined(Some(&roots), || panic!("boom"))));
|
||||
assert!(r.is_err());
|
||||
assert!(current().is_none());
|
||||
// `None` changes nothing.
|
||||
assert!(confined(None, || current().is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_run_confined_read_and_write_only_inside() {
|
||||
let f = Fixture::new("commands");
|
||||
std::fs::create_dir_all(f.0.join("outside/readable")).unwrap();
|
||||
let roots = Arc::new(Roots::new(&[f.p("outside/readable")], &[f.p("root")]).unwrap());
|
||||
// Unconfined, the outside is writable: the bait files a confined command must not reach.
|
||||
let bait = f.p("outside/bait.figcraft");
|
||||
let mut s = Session::new();
|
||||
s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 20, "height": 10})).unwrap();
|
||||
s.execute("file.saveAs", &json!({"path": bait})).unwrap();
|
||||
std::fs::copy(&bait, f.0.join("outside/readable/ok.figcraft")).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 20, "height": 10})).unwrap();
|
||||
s.prefs.recent_files = vec![bait.clone(), f.p("root/in.txt")];
|
||||
confined(Some(&roots), || {
|
||||
let e = s.execute("file.export", &json!({"format": "png", "path": f.p("outside/x.png")})).unwrap_err().to_string();
|
||||
assert!(e.contains("automation path rejected"), "{e}");
|
||||
assert!(s.execute("file.export", &json!({"format": "png", "path": f.p("root/x.png")})).is_ok());
|
||||
assert!(s.execute("file.exportLayers", &json!({"dir": f.p("outside/layers")})).is_err());
|
||||
assert!(s.execute("file.open", &json!({"path": bait})).is_err());
|
||||
assert!(s.execute("file.import", &json!({"path": bait})).is_err());
|
||||
assert!(s.execute("file.placeImage", &json!({"path": f.p("outside/secret.txt")})).is_err());
|
||||
assert!(s.execute("file.open", &json!({"path": f.p("outside/readable/ok.figcraft")})).is_ok(), "read roots may be read");
|
||||
assert!(s.execute("file.save", &json!({})).is_err(), "but not written");
|
||||
assert!(s.execute("file.saveAs", &json!({"path": f.p("root/ok.figcraft")})).is_ok());
|
||||
// Recent files outside the roots aren't listed.
|
||||
let recent = s.execute("file.openRecent", &json!({})).unwrap().to_string();
|
||||
assert!(!recent.contains("bait") && recent.contains("in.txt"), "{recent}");
|
||||
assert!(s.execute("file.openRecent", &json!({"path": bait})).is_err());
|
||||
});
|
||||
assert!(f.0.join("root/x.png").is_file() && !f.0.join("outside/x.png").exists() && !f.0.join("outside/layers").exists());
|
||||
}
|
||||
|
||||
/// Every command, given paths outside the roots in its usual path parameters, writes nothing
|
||||
/// there and opens nothing from there: they all reach files through `file_access`.
|
||||
#[test]
|
||||
fn no_command_reaches_outside_the_roots() {
|
||||
let f = Fixture::new("sweep");
|
||||
let roots = Arc::new(f.rw());
|
||||
let outside = f.0.join("outside");
|
||||
// Bait the sweep could pick up: a document, an SVG, a PNG and variables JSON, each holding a
|
||||
// name that must never show up in a document or a result.
|
||||
let mut s = Session::new();
|
||||
s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 20, "height": 10, "name": "BAIT-LAYER"})).unwrap();
|
||||
s.execute("file.saveAs", &json!({"path": f.p("outside/bait.figcraft")})).unwrap();
|
||||
s.execute("file.export", &json!({"format": "png", "path": f.p("outside/bait.png")})).unwrap();
|
||||
std::fs::write(
|
||||
outside.join("bait.svg"),
|
||||
r#"<svg xmlns="http://www.w3.org/2000/svg" width="9" height="9"><rect id="BAIT-LAYER" width="9" height="9"/></svg>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let before: Vec<_> = std::fs::read_dir(&outside).unwrap().flatten().map(|e| e.file_name()).collect();
|
||||
// The bait is what an unconfined command finds.
|
||||
for (cmd, bait) in [("file.open", "bait.figcraft"), ("file.import", "bait.svg")] {
|
||||
let mut s = Session::new();
|
||||
s.execute(cmd, &json!({"path": f.p(&format!("outside/{bait}"))})).unwrap();
|
||||
assert!(crate::inspect::document(&s, 64, true).to_string().contains("BAIT-LAYER"), "{cmd} {bait}");
|
||||
}
|
||||
let mut s = Session::new();
|
||||
s.execute("file.placeImage", &json!({"path": f.p("outside/bait.png")})).unwrap();
|
||||
assert!(!s.doc().unwrap().images.is_empty());
|
||||
for bait in ["bait.figcraft", "bait.svg", "bait.png", "new.figcraft", "new.png"] {
|
||||
let path = f.p(&format!("outside/{bait}"));
|
||||
let params = json!({"path": path, "dir": f.p("outside"), "file": path, "variables": path, "folder": f.p("outside"), "confirm": true});
|
||||
for c in crate::all_commands() {
|
||||
let mut s = Session::new();
|
||||
let r = confined(Some(&roots), || s.execute(c.id, ¶ms));
|
||||
let shown = format!("{r:?}");
|
||||
assert!(!shown.contains("BAIT-LAYER"), "{} read {bait}: {shown}", c.id);
|
||||
let docs = crate::inspect::document(&s, 64, true).to_string();
|
||||
assert!(!docs.contains("BAIT-LAYER"), "{} opened {bait}", c.id);
|
||||
assert!(s.docs.iter().all(|ds| ds.doc.images.is_empty()), "{} placed {bait}", c.id);
|
||||
let now: Vec<_> = std::fs::read_dir(&outside).unwrap().flatten().map(|e| e.file_name()).collect();
|
||||
assert_eq!(now, before, "{} wrote outside the roots with {bait}", c.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_files_outside_the_roots_are_never_opened_removed_or_named() {
|
||||
let f = Fixture::new("recents");
|
||||
let roots = Arc::new(f.rw());
|
||||
let (bait, gone, ok) = (f.p("outside/bait.figcraft"), f.p("outside/gone.figcraft"), f.p("root/ok.figcraft"));
|
||||
let mut s = Session::new();
|
||||
s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 20, "height": 10})).unwrap();
|
||||
s.execute("file.saveAs", &json!({"path": bait})).unwrap();
|
||||
s.execute("file.saveAs", &json!({"path": ok})).unwrap();
|
||||
let mut s = Session::new();
|
||||
let recents = vec![bait.clone(), gone.clone(), ok.clone()];
|
||||
s.prefs.recent_files = recents.clone();
|
||||
let quiet = |r: &crate::Result<serde_json::Value>| {
|
||||
let shown = format!("{r:?}");
|
||||
assert!(!shown.contains("bait") && !shown.contains("gone"), "an outside recent file was named: {shown}");
|
||||
};
|
||||
confined(Some(&roots), || {
|
||||
let listing = s.execute("file.openRecent", &json!({}));
|
||||
quiet(&listing);
|
||||
assert_eq!(listing.unwrap()["files"].as_array().unwrap().len(), 1);
|
||||
// `index` counts the files listed: 0 is the first one inside the roots.
|
||||
let opened = s.execute("file.openRecent", &json!({"index": 0}));
|
||||
quiet(&opened);
|
||||
assert!(opened.is_ok(), "{opened:?}");
|
||||
assert_eq!(s.ds().unwrap().path.as_deref(), Some(ok.as_str()));
|
||||
for i in [1, 2] {
|
||||
let r = s.execute("file.openRecent", &json!({"index": i}));
|
||||
quiet(&r);
|
||||
assert!(r.is_err());
|
||||
}
|
||||
// A path outside is refused like any outside path, and never dropped from the list.
|
||||
let e = s.execute("file.openRecent", &json!({"path": gone})).unwrap_err().to_string();
|
||||
assert!(e.contains("is outside the folders that may be read") && !e.contains("no longer exists"), "{e}");
|
||||
quiet(&s.execute("file.openRecent", &json!({"remove": bait})));
|
||||
assert!(s.prefs.recent_files.contains(&bait) && s.prefs.recent_files.contains(&gone));
|
||||
// Clear clears what is listed.
|
||||
quiet(&s.execute("file.openRecent", &json!({"clear": true})));
|
||||
});
|
||||
assert_eq!(s.prefs.recent_files, vec![bait, gone]);
|
||||
// Unconfined, a recent file that is gone is still dropped.
|
||||
s.execute("file.openRecent", &json!({"index": 1})).unwrap_err();
|
||||
assert_eq!(s.prefs.recent_files.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outside_paths_are_refused_alike_whatever_exists_there() {
|
||||
let f = Fixture::new("oracle");
|
||||
let r = f.rw();
|
||||
let refusal = |p: &str| r.check(Path::new(p), Access::Write).unwrap_err();
|
||||
let standard = |p: &str| format!("automation path rejected: {} is outside the folders that may be written:", Path::new(p).display());
|
||||
let mut outside = vec![f.p("outside/secret.txt"), f.p("outside/none.txt"), f.p("outside/missing/../x.txt"), f.p("nowhere/deeper/x.txt")];
|
||||
if symlink_file(&f.0.join("outside/not-yet.txt"), &f.0.join("outside/dangling.txt")) {
|
||||
outside.push(f.p("outside/dangling.txt"));
|
||||
}
|
||||
for p in &outside {
|
||||
let e = refusal(p);
|
||||
assert!(e.starts_with(&standard(p)), "{p}: {e}");
|
||||
}
|
||||
// Inside a root the reason is given.
|
||||
if symlink_file(&f.0.join("outside/not-yet.txt"), &f.0.join("root/dangling.txt")) {
|
||||
assert!(refusal(&f.p("root/dangling.txt")).contains("leads nowhere"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_resolved_paths_count_as_the_apps_own_folder() {
|
||||
let f = Fixture::new("own");
|
||||
let install = f.0.join("root");
|
||||
assert!(super::within_app_folder(&install.join("in.txt"), &install));
|
||||
assert!(!super::within_app_folder(&install.join("..").join("outside").join("secret.txt"), &install), "`..` leaves it");
|
||||
assert!(!super::within_app_folder(&f.0.join("root2"), &install), "a folder named like it is another");
|
||||
assert!(!super::within_app_folder(&install.join("missing.wasm"), &install));
|
||||
assert!(!super::within_app_folder(&install.join("in.txt"), &f.0.join("no-such-folder")));
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Commands never leave a layer with a non-finite position or size: such a file saves but can't be
|
||||
//! reopened (JSON writes NaN and infinity as null).
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn rect(s: &mut Session) -> crate::NodeId {
|
||||
let r = s.execute("node.create", &json!({"type": "rectangle", "x": 0, "y": 10, "width": 100, "height": 50})).unwrap();
|
||||
crate::params::id_of(&r["id"]).unwrap()
|
||||
}
|
||||
|
||||
fn assert_roundtrips(s: &Session) {
|
||||
let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
|
||||
assert!(figcraft_format::load_bytes(&bytes).is_ok(), "the saved file reopens");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_moves_are_refused_and_the_file_still_reopens() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s);
|
||||
s.execute("transform.move", &json!({"ids": [r], "dx": 1e308, "dy": 0})).ok();
|
||||
let second = s.execute("transform.move", &json!({"ids": [r], "dx": 1e308, "dy": 1e308}));
|
||||
assert!(second.is_err(), "a move past the f64 range is refused");
|
||||
let n = s.doc().unwrap().node(r).unwrap();
|
||||
assert!(n.transform.as_coeffs().iter().all(|c| c.is_finite()), "{:?}", n.transform);
|
||||
assert_roundtrips(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_scale_is_refused_and_the_file_still_reopens() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s);
|
||||
s.execute("transform.scale", &json!({"ids": [r], "factor": 1e300})).ok();
|
||||
let second = s.execute("transform.scale", &json!({"ids": [r], "factor": 1e300}));
|
||||
assert!(second.is_err(), "a size past the f64 range is refused");
|
||||
let n = s.doc().unwrap().node(r).unwrap();
|
||||
assert!(n.width.is_finite() && n.height.is_finite(), "{} x {}", n.width, n.height);
|
||||
assert_roundtrips(&s);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//! Layer ids in params: naming something that isn't a layer is a mistake, and says so, rather than
|
||||
//! quietly meaning "none".
|
||||
//!
|
||||
//! `node.create`'s `parent` was `id(p, "parent").unwrap_or(page)`, so any value that isn't an id —
|
||||
//! a `{"$step": n}` nothing had resolved, a name, a negative number — became "no parent" and the
|
||||
//! layer landed on the page while the call reported success. The same shape sat on `image.place`
|
||||
//! and `instance.create`'s parents, on `index`, and on the `ids` of every command that targets
|
||||
//! layers (an entry that wasn't an id was dropped and the rest ran).
|
||||
|
||||
use crate::params::id_of;
|
||||
use crate::{EngineError, Session};
|
||||
use figcraft_doc::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// The page's children, by name (bottom first).
|
||||
fn top(s: &Session) -> Vec<String> {
|
||||
let doc = s.doc().unwrap();
|
||||
let page = s.page().unwrap();
|
||||
doc.node(page).unwrap().children.iter().filter_map(|c| doc.node(*c).map(|n| n.name.clone())).collect()
|
||||
}
|
||||
|
||||
/// The message of a failure, or `None` when the call went through.
|
||||
fn failed(r: std::result::Result<Value, EngineError>) -> Option<String> {
|
||||
match r {
|
||||
Ok(_) => None,
|
||||
Err(e) => Some(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn rect(parent: Value) -> Value {
|
||||
json!({"type": "rectangle", "name": "child", "parent": parent, "x": 0, "y": 0, "width": 10, "height": 10})
|
||||
}
|
||||
|
||||
fn card(s: &mut Session) -> NodeId {
|
||||
let v = s.execute("node.create", &json!({"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 100, "height": 100})).unwrap();
|
||||
id_of(&v["id"]).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_parent_that_is_not_an_id_is_refused_rather_than_becoming_the_page() {
|
||||
let mut s = Session::new();
|
||||
let card = card(&mut s);
|
||||
for parent in [json!({"$step": 0}), json!("Card"), json!(-1), json!(true)] {
|
||||
let msg = failed(s.execute("node.create", &rect(parent.clone()))).unwrap_or_else(|| panic!("{parent} must be refused"));
|
||||
assert!(msg.contains("`parent` must be a node id"), "{parent}: {msg}");
|
||||
}
|
||||
assert_eq!(top(&s), ["Card"], "nothing landed on the page");
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(doc.node(card).unwrap().children.is_empty(), "nor inside the frame");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaving_the_parent_out_or_null_still_means_the_page() {
|
||||
let mut s = Session::new();
|
||||
let card = card(&mut s);
|
||||
for parent in [None, Some(json!(null))] {
|
||||
let mut p = rect(json!(0));
|
||||
match parent {
|
||||
None => _ = p.as_object_mut().unwrap().remove("parent"),
|
||||
Some(v) => p["parent"] = v,
|
||||
}
|
||||
s.execute("node.create", &p).unwrap();
|
||||
}
|
||||
assert_eq!(top(&s), ["Card", "child", "child"]);
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(doc.node(card).unwrap().children.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_parent_id_that_names_nothing_is_still_reported() {
|
||||
let mut s = Session::new();
|
||||
card(&mut s);
|
||||
let msg = failed(s.execute("node.create", &rect(json!(99999)))).unwrap_or_else(|| panic!("a missing layer must fail"));
|
||||
assert!(msg.contains("no such node 99999"), "{msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_index_that_is_not_a_whole_non_negative_number_is_refused() {
|
||||
let mut s = Session::new();
|
||||
let card = card(&mut s);
|
||||
// (A boolean is a number to `f64_of` — `true` reads as 1 — as for `x`, `width` and every
|
||||
// other number, so it is deliberately not in this list.)
|
||||
for index in [json!("last"), json!(1.5), json!(-1), json!(-0.5), json!(1e12)] {
|
||||
let mut p = rect(json!(card));
|
||||
p["index"] = index.clone();
|
||||
let msg = failed(s.execute("node.create", &p)).unwrap_or_else(|| panic!("index {index} must be refused"));
|
||||
assert!(msg.contains("`index`"), "{index}: {msg}");
|
||||
}
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(doc.node(card).unwrap().children.is_empty(), "nothing was inserted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_whole_index_still_inserts_where_it_says() {
|
||||
let mut s = Session::new();
|
||||
let card = card(&mut s);
|
||||
for name in ["a", "b"] {
|
||||
s.execute("node.create", &json!({"type": "rectangle", "name": name, "parent": card, "x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
}
|
||||
s.execute("node.create", &json!({"type": "rectangle", "name": "mid", "parent": card, "index": 1, "x": 0, "y": 0, "width": 10, "height": 10}))
|
||||
.unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let names: Vec<&str> = doc.node(card).unwrap().children.iter().filter_map(|c| doc.node(*c).map(|n| n.name.as_str())).collect();
|
||||
assert_eq!(names, ["a", "mid", "b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_entries_that_are_not_ids_are_refused_instead_of_being_dropped() {
|
||||
let mut s = Session::new();
|
||||
let card = card(&mut s);
|
||||
// The junk used to be dropped and the rest applied, so `["nope", card]` renamed the frame.
|
||||
for ids in [json!(["nope"]), json!(["nope", card]), json!([{"$step": 0}, card]), json!([null])] {
|
||||
let msg =
|
||||
failed(s.execute("node.set", &json!({"ids": ids, "props": {"name": "renamed"}}))).unwrap_or_else(|| panic!("{ids} must be refused"));
|
||||
assert!(msg.contains("`ids`"), "{ids}: {msg}");
|
||||
}
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(doc.node(card).unwrap().name, "Card", "nothing was renamed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ids_that_is_not_an_array_is_refused_but_null_means_the_selection() {
|
||||
let mut s = Session::new();
|
||||
let card = card(&mut s);
|
||||
let msg =
|
||||
failed(s.execute("node.set", &json!({"ids": "nope", "props": {"name": "renamed"}}))).unwrap_or_else(|| panic!("a non-array ids must fail"));
|
||||
assert!(msg.contains("`ids` must be an array"), "{msg}");
|
||||
// An explicit null says the same as leaving it out: the selection.
|
||||
s.execute("select.set", &json!({"ids": [card]})).unwrap();
|
||||
s.execute("node.set", &json!({"ids": null, "props": {"name": "Selected"}})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().node(card).unwrap().name, "Selected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_id_that_is_not_an_id_is_refused() {
|
||||
let mut s = Session::new();
|
||||
card(&mut s);
|
||||
let msg =
|
||||
failed(s.execute("node.set", &json!({"id": {"$step": 0}, "props": {"name": "renamed"}}))).unwrap_or_else(|| panic!("a bad id must fail"));
|
||||
assert!(msg.contains("`id` must be a node id"), "{msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_and_an_instance_parent_are_checked_the_same_way() {
|
||||
let mut s = Session::new();
|
||||
card(&mut s);
|
||||
let img = failed(s.execute("image.place", &json!({"data": "aGk=", "parent": {"$step": 0}})));
|
||||
assert!(img.unwrap_or_default().contains("`parent` must be a node id"), "image.place");
|
||||
let inst = failed(s.execute("instance.create", &json!({"component": 99999, "parent": {"$step": 0}})));
|
||||
assert!(inst.is_some(), "instance.create refuses both a bad component and a bad parent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_huge_bad_value_is_cut_short_in_the_error() {
|
||||
let mut s = Session::new();
|
||||
card(&mut s);
|
||||
let junk = "é".repeat(10_000);
|
||||
let msg = failed(s.execute("node.set", &json!({"ids": junk, "props": {"name": "renamed"}}))).unwrap_or_else(|| panic!("must fail"));
|
||||
assert!(msg.contains("`ids` must be an array") && msg.ends_with("…)"), "{msg}");
|
||||
assert!(msg.chars().count() < 200, "{} chars", msg.chars().count());
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
//! Size and resizing overrides of layers inside instances: a layer in an instance can hug, fill or
|
||||
//! keep a fixed size of its own, and that override is kept through relayout, edits of the main
|
||||
//! (only the overridden property stays), undo, save and reopen (native and .fig), REST import,
|
||||
//! detach and reset.
|
||||
|
||||
use base64::Engine;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::components::tests::{copy_of, ex, healthy, idv, node};
|
||||
use figcraft_doc::*;
|
||||
|
||||
/// A "Field" component: a 240×40 fixed horizontal auto layout frame (no padding or gap) holding
|
||||
/// "Atom", an auto layout frame hugging its 20×20 "Dot" with a min width of 100; and an instance of
|
||||
/// it. Returns (component, atom, dot, instance).
|
||||
fn field(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId) {
|
||||
let f = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 240, "height": 40, "name": "Field"})));
|
||||
ex(s, "node.set", json!({"id": f, "props": {"layout": "horizontal", "padding": 0, "gap": 0, "sizingH": "fixed", "sizingV": "fixed"}}));
|
||||
let atom = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 50, "height": 20, "parent": f, "name": "Atom"})));
|
||||
ex(
|
||||
s,
|
||||
"node.set",
|
||||
json!({"id": atom, "props": {"layout": "horizontal", "padding": 0, "gap": 0, "sizingH": "hug", "sizingV": "hug", "minWidth": 100}}),
|
||||
);
|
||||
let dot = idv(&ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20, "parent": atom, "props": {"name": "Dot"}})));
|
||||
let c = idv(&ex(s, "component.create", json!({"ids": [f]})));
|
||||
let i = idv(&ex(s, "instance.create", json!({"component": c, "x": 0, "y": 100})));
|
||||
(c, atom, dot, i)
|
||||
}
|
||||
|
||||
fn sizing_h(s: &Session, id: NodeId) -> Sizing {
|
||||
node(s, id).child_layout.sizing_h
|
||||
}
|
||||
|
||||
/// The layer named `name` below instance `inst`.
|
||||
fn named(s: &Session, inst: NodeId, name: &str) -> NodeId {
|
||||
let doc = s.doc().unwrap();
|
||||
doc.subtree(inst).into_iter().find(|d| *d != inst && doc.node(*d).is_some_and(|n| n.name == name)).unwrap()
|
||||
}
|
||||
|
||||
/// The first instance in the document.
|
||||
fn instance(s: &Session) -> NodeId {
|
||||
s.doc().unwrap().nodes().find(|n| matches!(n.kind, NodeKind::Instance { .. })).unwrap().id
|
||||
}
|
||||
|
||||
fn open(s: &mut Session, bytes: &[u8], name: &str) {
|
||||
ex(s, "file.open", json!({"data": base64::engine::general_purpose::STANDARD.encode(bytes), "name": name}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_in_an_instance_fills_and_keeps_it_through_main_edits() {
|
||||
let mut s = Session::new();
|
||||
let (c, atom, dot, i) = field(&mut s);
|
||||
let (ia, idot) = (copy_of(&s, i, atom), copy_of(&s, i, dot));
|
||||
assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Hug, 100.0), "hugs its dot, at least 100 wide");
|
||||
ex(&mut s, "node.set", json!({"id": ia, "props": {"sizingH": "fill"}}));
|
||||
assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fill, 240.0), "fills the instance");
|
||||
assert!(node(&s, ia).overrides.contains("resizing"), "{:?}", node(&s, ia).overrides);
|
||||
// Edits of the main relay the instance out: the override stays, other properties follow.
|
||||
ex(&mut s, "node.set", json!({"id": dot, "props": {"width": 30, "fill": "#ff0000"}}));
|
||||
assert_eq!((node(&s, ia).width, node(&s, idot).width), (240.0, 30.0));
|
||||
ex(&mut s, "node.set", json!({"id": atom, "props": {"minWidth": 120, "fill": "#00ff00"}}));
|
||||
assert_eq!(node(&s, atom).width, 120.0);
|
||||
assert_eq!((sizing_h(&s, ia), node(&s, ia).width, node(&s, ia).child_layout.min_w), (Sizing::Fill, 240.0, Some(100.0)));
|
||||
assert_eq!(node(&s, ia).fills, node(&s, atom).fills, "the fill isn't overridden: it follows the main");
|
||||
// The main fixing its own width is the same property: the instance keeps filling.
|
||||
ex(&mut s, "node.set", json!({"id": atom, "props": {"width": 80}}));
|
||||
assert_eq!((sizing_h(&s, atom), sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fixed, Sizing::Fill, 240.0));
|
||||
// A structure edit of the main rebuilds the instance: the atom fills what the new layer leaves.
|
||||
ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5, "parent": c}));
|
||||
assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fill, 235.0));
|
||||
healthy(&s);
|
||||
// Back to the main's resizing: no override left.
|
||||
ex(&mut s, "component.resetOverride", json!({"id": ia, "group": "resizing"}));
|
||||
assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fixed, node(&s, atom).width));
|
||||
assert!(!node(&s, ia).overrides.contains("resizing"));
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_and_resizing_overrides_undo_reset_and_detach() {
|
||||
let mut s = Session::new();
|
||||
let (_, atom, dot, i) = field(&mut s);
|
||||
let (ia, idot) = (copy_of(&s, i, atom), copy_of(&s, i, dot));
|
||||
ex(&mut s, "node.set", json!({"id": ia, "props": {"sizingH": "fill"}}));
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Hug, 100.0));
|
||||
assert!(node(&s, ia).overrides.is_empty());
|
||||
// A fixed size of its own: kept when the main resizes the layer.
|
||||
ex(&mut s, "node.set", json!({"id": idot, "props": {"width": 50, "height": 10}}));
|
||||
assert!(node(&s, idot).overrides.contains("size"));
|
||||
ex(&mut s, "node.set", json!({"id": dot, "props": {"width": 30}}));
|
||||
assert_eq!((node(&s, idot).width, node(&s, idot).height, node(&s, dot).width), (50.0, 10.0, 30.0));
|
||||
// A min width of its own.
|
||||
ex(&mut s, "node.set", json!({"id": ia, "props": {"minWidth": 150}}));
|
||||
assert_eq!(node(&s, ia).width, 150.0);
|
||||
ex(&mut s, "node.set", json!({"id": atom, "props": {"minWidth": 90}}));
|
||||
assert_eq!(node(&s, ia).width, 150.0);
|
||||
healthy(&s);
|
||||
// Reset all changes: the main's values again.
|
||||
ex(&mut s, "component.resetInstance", json!({"ids": [i]}));
|
||||
assert_eq!((node(&s, ia).width, node(&s, idot).width), (90.0, 30.0));
|
||||
assert!(node(&s, ia).overrides.is_empty() && node(&s, idot).overrides.is_empty());
|
||||
// Detaching keeps what the instance showed.
|
||||
ex(&mut s, "node.set", json!({"id": ia, "props": {"sizingH": "fill"}}));
|
||||
ex(&mut s, "component.detach", json!({"ids": [i]}));
|
||||
assert_eq!((sizing_h(&s, ia), node(&s, ia).width, node(&s, ia).source), (Sizing::Fill, 240.0, None));
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_in_instances_resize_but_keep_their_place() {
|
||||
let mut s = Session::new();
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})));
|
||||
let title = idv(&ex(&mut s, "text.create", json!({"text": "Hello", "x": 10, "y": 10, "parent": f, "props": {"name": "Title"}})));
|
||||
let c = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0})));
|
||||
let it = copy_of(&s, i, title);
|
||||
ex(&mut s, "node.set", json!({"id": it, "props": {"width": 150}}));
|
||||
assert_eq!(node(&s, it).width, 150.0, "a text box resizes");
|
||||
assert!(node(&s, it).overrides.contains("size"));
|
||||
// Its place, rotation and constraints are still the main's.
|
||||
for props in [json!({"x": 50}), json!({"rotation": 15}), json!({"constraints": {"horizontal": "right"}})] {
|
||||
assert!(s.execute("node.set", &json!({"id": it, "props": props})).is_err(), "{props} is refused");
|
||||
}
|
||||
// The instance's own resizing is its own, not an override.
|
||||
ex(&mut s, "node.set", json!({"id": i, "props": {"minWidth": 100}}));
|
||||
assert!(!node(&s, i).overrides.contains("resizing"));
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resizing_overrides_survive_save_and_reopen() {
|
||||
let mut s = Session::new();
|
||||
let (_, atom, dot, i) = field(&mut s);
|
||||
let (ia, idot) = (copy_of(&s, i, atom), copy_of(&s, i, dot));
|
||||
ex(&mut s, "node.set", json!({"id": ia, "props": {"sizingH": "fill"}}));
|
||||
ex(&mut s, "node.set", json!({"id": idot, "props": {"width": 40}}));
|
||||
let native = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
|
||||
let (fig, _) = figcraft_format::fig::export(s.doc().unwrap(), None).unwrap();
|
||||
for (bytes, name) in [(native, "field.figcraft"), (fig, "field.fig")] {
|
||||
let mut s = Session::new();
|
||||
open(&mut s, &bytes, name);
|
||||
let i = instance(&s);
|
||||
let (ia, idot) = (named(&s, i, "Atom"), named(&s, i, "Dot"));
|
||||
assert!(node(&s, ia).overrides.contains("resizing"), "{name}: {:?}", node(&s, ia).overrides);
|
||||
assert!(node(&s, idot).overrides.contains("size"), "{name}: {:?}", node(&s, idot).overrides);
|
||||
// An edit of the main relays the instance out.
|
||||
let main = |s: &Session, id: NodeId| node(s, id).source.unwrap();
|
||||
let (atom, dot) = (main(&s, ia), main(&s, idot));
|
||||
ex(&mut s, "node.set", json!({"id": atom, "props": {"minWidth": 120}}));
|
||||
ex(&mut s, "node.set", json!({"id": dot, "props": {"width": 30}}));
|
||||
assert_eq!((sizing_h(&s, ia), node(&s, ia).width, node(&s, idot).width), (Sizing::Fill, 240.0, 40.0), "{name}");
|
||||
healthy(&s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_resizing_overrides_survive_relayout() {
|
||||
let at = |x: f64, y: f64| json!([[1, 0, x], [0, 1, y]]);
|
||||
let dot = |id: &str| json!({"id": id, "name": "Dot", "type": "RECTANGLE", "relativeTransform": at(0.0, 0.0), "size": {"x": 20, "y": 20}});
|
||||
let atom = |id: &str, dot_id: &str, w: f64, more: Value| {
|
||||
let mut a = json!({"id": id, "name": "Atom", "type": "FRAME", "relativeTransform": at(0.0, 0.0), "size": {"x": w, "y": 20},
|
||||
"layoutMode": "HORIZONTAL", "primaryAxisSizingMode": "AUTO", "counterAxisSizingMode": "AUTO", "minWidth": 100, "children": [dot(dot_id)]});
|
||||
a.as_object_mut().unwrap().extend(more.as_object().unwrap().clone());
|
||||
a
|
||||
};
|
||||
let field = |id: &str, ty: &str, y: f64, kids: Value| {
|
||||
json!({"id": id, "name": "Field", "type": ty, "relativeTransform": at(0.0, y), "size": {"x": 240, "y": 40}, "layoutMode": "HORIZONTAL",
|
||||
"primaryAxisSizingMode": "FIXED", "counterAxisSizingMode": "FIXED", "children": kids})
|
||||
};
|
||||
let mut inst =
|
||||
field("2:1", "INSTANCE", 100.0, json!([atom("I2:1;1:2", "I2:1;1:3", 240.0, json!({"layoutSizingHorizontal": "FILL", "layoutGrow": 1}))]));
|
||||
inst["componentId"] = json!("1:1");
|
||||
inst["overrides"] = json!([{"id": "I2:1;1:2", "overriddenFields": ["layoutSizingHorizontal", "layoutGrow"]}]);
|
||||
let file = json!({"name": "Field", "document": {"id": "0:0", "type": "DOCUMENT", "children": [
|
||||
{"id": "0:1", "name": "Page", "type": "CANVAS", "children": [field("1:1", "COMPONENT", 0.0, json!([atom("1:2", "1:3", 100.0, json!({}))])), inst]}
|
||||
]}, "components": {"1:1": {"key": "k", "name": "Field", "description": ""}}, "styles": {}});
|
||||
let mut s = Session::new();
|
||||
open(&mut s, file.to_string().as_bytes(), "field.json");
|
||||
let i = instance(&s);
|
||||
let ia = named(&s, i, "Atom");
|
||||
assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fill, 240.0));
|
||||
assert!(node(&s, ia).overrides.contains("resizing"), "{:?}", node(&s, ia).overrides);
|
||||
// The main's first edit relays every instance out from it.
|
||||
let main_atom = node(&s, ia).source.unwrap();
|
||||
ex(&mut s, "node.set", json!({"id": main_atom, "props": {"minWidth": 120}}));
|
||||
assert_eq!((sizing_h(&s, ia), node(&s, ia).width), (Sizing::Fill, 240.0));
|
||||
healthy(&s);
|
||||
}
|
||||
@@ -6,6 +6,8 @@
|
||||
//! change is one undo step. Pointer tools ([`tools`]) and text editing reduce to the same document
|
||||
//! edits and are journaled the same way.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
// Tests make and inspect files directly; shipped code goes through `file_access` (clippy.toml).
|
||||
#![cfg_attr(test, allow(clippy::disallowed_methods))]
|
||||
|
||||
pub mod al_keys;
|
||||
pub mod annotations;
|
||||
@@ -25,6 +27,7 @@ pub mod devcompare;
|
||||
pub mod devinspect;
|
||||
pub mod distance;
|
||||
pub mod drop;
|
||||
pub mod file_access;
|
||||
pub mod guard;
|
||||
pub mod guides;
|
||||
pub mod inspect;
|
||||
@@ -43,6 +46,8 @@ pub mod plugins;
|
||||
pub mod proto_run;
|
||||
pub mod rename;
|
||||
pub mod rotation_origin;
|
||||
pub mod selection_colors;
|
||||
pub mod selection_spacing;
|
||||
pub mod shape_handles;
|
||||
pub mod shapebuilder;
|
||||
pub mod snap;
|
||||
@@ -609,6 +614,9 @@ pub struct Prefs {
|
||||
/// The widths of the main window's left and right panels (the interface keeps them within its
|
||||
/// limits; other windows have their own while they are open).
|
||||
pub panel_widths: (f32, f32),
|
||||
/// The height asked for the main window's pages list, dragged at its lower edge (`None`: a
|
||||
/// share of the panel; the interface keeps it within its limits).
|
||||
pub pages_height: Option<f32>,
|
||||
/// The interface language: a language code ("en", "es", "pt-BR") or "system" (the OS or browser
|
||||
/// language). Languages without a catalog show English.
|
||||
pub language: String,
|
||||
@@ -677,6 +685,7 @@ impl Default for Prefs {
|
||||
show_dimensions: true,
|
||||
theme: "system".into(),
|
||||
panel_widths: (240.0, 240.0),
|
||||
pages_height: None,
|
||||
language: "system".into(),
|
||||
user_name: cmd::comment::DEFAULT_AUTHOR.into(),
|
||||
reduce_motion: false,
|
||||
@@ -905,7 +914,8 @@ impl Session {
|
||||
let result = guard::run(id, || (spec.run)(self, params))
|
||||
.and_then(|v| self.check_instance_structure(id, &before).map(|()| v))
|
||||
.and_then(|v| self.check_preview(id, &before).map(|()| v))
|
||||
.and_then(|v| self.check_live_access(id, &before).map(|()| v));
|
||||
.and_then(|v| self.check_live_access(id, &before).map(|()| v))
|
||||
.and_then(|v| self.check_finite_geometry(id, &before).map(|()| v));
|
||||
match &result {
|
||||
Ok(_) => {
|
||||
self.after_command(spec, before);
|
||||
@@ -947,6 +957,22 @@ impl Session {
|
||||
Err(EngineError::Disabled(id.to_string(), PREVIEW_READ_ONLY.into()))
|
||||
}
|
||||
|
||||
/// A layer's position and size stay finite: infinity and NaN are written as `null`, so a file
|
||||
/// holding them saves but can't be reopened. Refuse such an edit (it is rolled back).
|
||||
fn check_finite_geometry(&self, id: &str, before: &Option<(Arc<Document>, Vec<NodeId>, NodeId, u64)>) -> Result<()> {
|
||||
let Some((bdoc, _, _, uid)) = before else { return Ok(()) };
|
||||
let Some(ds) = self.docs.iter().find(|d| d.uid == *uid) else { return Ok(()) };
|
||||
if Arc::ptr_eq(&ds.doc, bdoc) {
|
||||
return Ok(());
|
||||
}
|
||||
let broken = |n: &Node| !(n.transform.as_coeffs().iter().all(|c| c.is_finite()) && n.width.is_finite() && n.height.is_finite());
|
||||
let untouched = |n: &Node| bdoc.node_arc(n.id).zip(ds.doc.node_arc(n.id)).is_some_and(|(a, b)| Arc::ptr_eq(a, b));
|
||||
match ds.doc.nodes().find(|n| broken(n) && !untouched(n)) {
|
||||
Some(n) => Err(bad(id, format!("\"{}\" would get a position or size beyond the number range", n.name))),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A live session's document you may only view (or that is on its way to a server) is
|
||||
/// read-only: refuse an edit (it is rolled back).
|
||||
fn check_live_access(&self, id: &str, before: &Option<(Arc<Document>, Vec<NodeId>, NodeId, u64)>) -> Result<()> {
|
||||
@@ -1166,13 +1192,22 @@ impl Session {
|
||||
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)?);
|
||||
let v = s.batch_step(&results, step, id, p)?;
|
||||
results.push(v);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
}
|
||||
|
||||
/// One step of a batch: `{"$step": n}` in `params` resolves against `results` (the results of
|
||||
/// the steps before it), then the command runs. [`Session::batch`] runs a list of these as one
|
||||
/// undo step; a caller with its own list — the CLI's script runner — uses this so its steps
|
||||
/// resolve `$step` the same way, inside its own [`Session::grouped`].
|
||||
pub fn batch_step(&mut self, results: &[Value], step: usize, cmd: &str, params: &Value) -> Result<Value> {
|
||||
let p = if cmd == "command.batch" { params.clone() } else { resolve_step_refs(params, results, step, 0)? };
|
||||
self.execute(cmd, &p)
|
||||
}
|
||||
|
||||
/// Run `f` as one undo step called `label`: the commands it executes push no steps of their
|
||||
/// own, and when it fails the documents stay at their group-start state.
|
||||
pub fn grouped<T>(&mut self, label: &str, f: impl FnOnce(&mut Self) -> Result<T>) -> Result<T> {
|
||||
@@ -1561,6 +1596,8 @@ mod fig_export_tests;
|
||||
#[cfg(test)]
|
||||
mod fig_import_tests;
|
||||
#[cfg(test)]
|
||||
mod finite_geometry_tests;
|
||||
#[cfg(test)]
|
||||
mod frame_guides_tests;
|
||||
#[cfg(test)]
|
||||
mod frame_label_tests;
|
||||
@@ -1583,12 +1620,16 @@ mod html_codegen_tests;
|
||||
#[cfg(test)]
|
||||
mod huge_stroke_tests;
|
||||
#[cfg(test)]
|
||||
mod id_params_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod import_export_tests;
|
||||
#[cfg(test)]
|
||||
mod import_journal_tests;
|
||||
#[cfg(test)]
|
||||
mod inspect_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod instance_resizing_tests;
|
||||
#[cfg(test)]
|
||||
mod interaction_rollback_tests;
|
||||
#[cfg(test)]
|
||||
mod invite_links_tests;
|
||||
@@ -1621,16 +1662,22 @@ mod menu_gaps_tests;
|
||||
#[cfg(test)]
|
||||
mod nested_override_tests;
|
||||
#[cfg(test)]
|
||||
mod node_set_refusals_tests;
|
||||
#[cfg(test)]
|
||||
mod online_tests;
|
||||
#[cfg(test)]
|
||||
mod open_issues_tests;
|
||||
#[cfg(test)]
|
||||
mod outline_options_tests;
|
||||
#[cfg(test)]
|
||||
mod page_clipboard_tests;
|
||||
#[cfg(test)]
|
||||
mod page_copy_navigation_tests;
|
||||
#[cfg(test)]
|
||||
mod page_target_tests;
|
||||
#[cfg(test)]
|
||||
mod paint_index_tests;
|
||||
#[cfg(test)]
|
||||
mod paint_reorder_tests;
|
||||
#[cfg(test)]
|
||||
mod paint_tests;
|
||||
@@ -1639,6 +1686,8 @@ mod panels_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod pdf_print_tests;
|
||||
#[cfg(test)]
|
||||
mod pen_end_tests;
|
||||
#[cfg(test)]
|
||||
mod pen_overlay_tests;
|
||||
#[cfg(test)]
|
||||
mod pixel_preview_tests;
|
||||
@@ -1758,3 +1807,6 @@ mod version_tests;
|
||||
mod view_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod widgets_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod selection_spacing_tests;
|
||||
@@ -449,9 +449,27 @@ struct Stamp {
|
||||
#[derive(Clone, Debug)]
|
||||
struct Entry {
|
||||
stamp: Option<Stamp>,
|
||||
/// When the file was read: a stamp only proves the file unchanged once its modification time
|
||||
/// is safely older than that, since file systems keep the time in coarse ticks.
|
||||
read_at: Option<std::time::SystemTime>,
|
||||
result: std::result::Result<Arc<PublishedLibrary>, String>,
|
||||
}
|
||||
|
||||
/// How far a file's modification time must lie before its read for the stamp to be trusted.
|
||||
const RACY_STAMP: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
|
||||
impl Entry {
|
||||
/// A file stamped in the future (clock skew, unpacked archives) never settles: it is re-read on
|
||||
/// every load until the clock passes it.
|
||||
fn still_valid(&self, now: &Stamp) -> bool {
|
||||
let settled = match (now.modified, self.read_at) {
|
||||
(Some(modified), Some(read_at)) => modified.checked_add(RACY_STAMP).is_some_and(|t| t < read_at),
|
||||
_ => false,
|
||||
};
|
||||
settled && self.stamp.as_ref() == Some(now)
|
||||
}
|
||||
}
|
||||
|
||||
/// Library files read by a session, by path (re-read when they change on disk).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Cache {
|
||||
@@ -481,10 +499,11 @@ impl Cache {
|
||||
return Err(EngineError::Other(format!("{path} is too large to use as a library (the limit is {} MB)", max_bytes / 1024 / 1024)));
|
||||
}
|
||||
if let (Some(e), Ok(st)) = (self.entries.get(path), &stamp)
|
||||
&& e.stamp.as_ref() == Some(st)
|
||||
&& e.still_valid(st)
|
||||
{
|
||||
return e.result.clone().map_err(EngineError::Other);
|
||||
}
|
||||
let read_at = now();
|
||||
let result = match &stamp {
|
||||
Ok(_) => read_file(path).map(Arc::new),
|
||||
Err(e) => Err(e.clone()),
|
||||
@@ -492,7 +511,7 @@ impl Cache {
|
||||
if self.entries.len() >= MAX_CACHED && !self.entries.contains_key(path) {
|
||||
self.entries.clear();
|
||||
}
|
||||
self.entries.insert(path.to_string(), Entry { stamp: stamp.ok(), result: result.clone() });
|
||||
self.entries.insert(path.to_string(), Entry { stamp: stamp.ok(), read_at, result: result.clone() });
|
||||
result.map_err(EngineError::Other)
|
||||
}
|
||||
|
||||
@@ -506,13 +525,23 @@ impl Cache {
|
||||
if self.entries.len() >= MAX_CACHED && !self.entries.contains_key(path) {
|
||||
self.entries.clear();
|
||||
}
|
||||
self.entries.insert(path.to_string(), Entry { stamp: None, result });
|
||||
self.entries.insert(path.to_string(), Entry { stamp: None, read_at: None, result });
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn now() -> Option<std::time::SystemTime> {
|
||||
Some(std::time::SystemTime::now())
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn now() -> Option<std::time::SystemTime> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn stat(path: &str) -> std::result::Result<Stamp, String> {
|
||||
let m = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
let m = crate::file_access::metadata(path).map_err(|e| e.to_string())?;
|
||||
if !m.is_file() {
|
||||
return Err(format!("{path} is not a file"));
|
||||
}
|
||||
@@ -526,7 +555,7 @@ fn stat(_path: &str) -> std::result::Result<Stamp, String> {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_file(path: &str) -> std::result::Result<PublishedLibrary, String> {
|
||||
let f = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
let f = crate::file_access::open(path).map_err(|e| e.to_string())?;
|
||||
match figcraft_format::read_library(std::io::BufReader::new(f)) {
|
||||
Ok(Some(lib)) => Ok(lib),
|
||||
Ok(None) => Err(format!("{path} has no published library: open it and publish it first (Libraries ▸ Publish library…)")),
|
||||
|
||||
@@ -445,3 +445,21 @@ fn library_survives_saving_with_versions() {
|
||||
healthy(&user);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_stamp_from_the_moment_it_was_read_does_not_prove_it_unchanged() {
|
||||
use std::time::{Duration, SystemTime};
|
||||
let read_at = SystemTime::now();
|
||||
let entry = |modified: SystemTime| Entry {
|
||||
stamp: Some(Stamp { len: 10, modified: Some(modified) }),
|
||||
read_at: Some(read_at),
|
||||
result: Err("unused".into()),
|
||||
};
|
||||
// Written in the same coarse tick as the read: a same-length rewrite would keep this stamp.
|
||||
let fresh = entry(read_at);
|
||||
assert!(!fresh.still_valid(&Stamp { len: 10, modified: Some(read_at) }));
|
||||
// Written well before the read: an unchanged stamp means an unchanged file.
|
||||
let old = read_at - Duration::from_secs(10);
|
||||
assert!(entry(old).still_valid(&Stamp { len: 10, modified: Some(old) }));
|
||||
assert!(!entry(old).still_valid(&Stamp { len: 11, modified: Some(old) }));
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//! `node.set` doesn't store what can't apply: auto layout keys go only to layers that can have auto
|
||||
//! layout (the others are named in `warnings`, and with none of them the call is refused), and a
|
||||
//! `fontStyle` the family lacks says which face draws instead.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
fn id(v: &Value) -> crate::NodeId {
|
||||
crate::params::id_of(v).unwrap()
|
||||
}
|
||||
|
||||
fn bad_msg(r: crate::Result<Value>) -> String {
|
||||
match r {
|
||||
Err(EngineError::BadParams { msg, .. }) => msg,
|
||||
other => panic!("expected BadParams, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_layout_keys_on_layers_without_auto_layout_are_refused() {
|
||||
let mut s = Session::new();
|
||||
let t = id(&ex(&mut s, "node.create", json!({"type": "text", "text": "Hi"}))["id"]);
|
||||
let r = id(&ex(&mut s, "node.create", json!({"type": "rectangle"}))["id"]);
|
||||
for (target, key, value) in [(t, "gap", json!(8)), (r, "padding", json!(4)), (t, "mainAlign", json!("center")), (r, "wrap", json!(true))] {
|
||||
let msg = bad_msg(s.execute("node.set", &json!({"ids": [target], "props": {key: value}})));
|
||||
assert!(msg.contains(key) && msg.contains("auto layout"), "{msg}");
|
||||
assert!(s.doc().unwrap().node(target).unwrap().layout.is_none(), "no hidden auto layout on {target}");
|
||||
}
|
||||
// Frames still take them, with or without a layout mode first.
|
||||
let f = id(&ex(&mut s, "node.create", json!({"type": "frame"}))["id"]);
|
||||
let out = ex(&mut s, "node.set", json!({"ids": [f], "props": {"gap": 8, "padding": 4}}));
|
||||
assert_eq!(s.doc().unwrap().node(f).unwrap().layout.as_ref().map(|l| l.gap), Some(8.0));
|
||||
assert!(out.get("warnings").is_none(), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_several_layers_the_frames_take_auto_layout_keys_and_the_others_are_named() {
|
||||
let mut s = Session::new();
|
||||
let f = id(&ex(&mut s, "node.create", json!({"type": "frame", "name": "Card"}))["id"]);
|
||||
let t = id(&ex(&mut s, "node.create", json!({"type": "text", "text": "Hi", "name": "Title"}))["id"]);
|
||||
let out = ex(&mut s, "node.set", json!({"ids": [f, t], "props": {"gap": 8, "opacity": 0.5}}));
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(doc.node(f).unwrap().layout.as_ref().map(|l| l.gap), Some(8.0));
|
||||
assert!(doc.node(t).unwrap().layout.is_none(), "no hidden auto layout on the text");
|
||||
assert_eq!(doc.node(t).unwrap().opacity, 0.5, "the text still gets the other keys");
|
||||
let w = out["warnings"][0].as_str().unwrap();
|
||||
assert!(w.contains("`gap`") && w.contains(&format!("{t} “Title”")) && w.contains("TEXT"), "{w}");
|
||||
// One undo step for all of it.
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc().unwrap().node(f).unwrap().layout.as_ref().is_none_or(|l| l.gap != 8.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_font_style_draws_in_the_nearest_face_and_says_so_when_it_differs() {
|
||||
figcraft_text::scan_system_fonts();
|
||||
// Any installed family with a regular and a bold face and nothing heavier (CI machines differ).
|
||||
let pick = figcraft_text::family_names().into_iter().find_map(|f| {
|
||||
let st = figcraft_text::family_styles(&f);
|
||||
let has = |w: u16| st.iter().any(|x| figcraft_doc::style_weight(x) == w && !x.to_lowercase().contains("italic"));
|
||||
(has(400) && has(700) && !st.iter().any(|x| figcraft_doc::style_weight(x) > 700)).then_some((f, st))
|
||||
});
|
||||
let Some((family, styles)) = pick else { return };
|
||||
let mut s = Session::new();
|
||||
let t = id(&ex(&mut s, "node.create", json!({"type": "text", "text": "Hi", "props": {"fontFamily": family}}))["id"]);
|
||||
let set = |s: &mut Session, style: &str| ex(s, "node.set", json!({"ids": [t], "props": {"fontStyle": style}}));
|
||||
// Any spelling of a style it has, and names that resolve to the weight they ask for, say nothing.
|
||||
let bold = styles.iter().find(|x| figcraft_doc::style_weight(x) == 700).unwrap().clone();
|
||||
for style in [bold.clone(), bold.to_uppercase(), bold.replace(' ', ""), "Roman".into(), "Normal".into()] {
|
||||
let out = set(&mut s, &style);
|
||||
assert!(out.get("warnings").is_none(), "{style}: {out}");
|
||||
}
|
||||
// A heavier weight than the family has applies (as a text layer resolves it) and says which face draws.
|
||||
let out = set(&mut s, "Black");
|
||||
let w = out["warnings"][0].as_str().unwrap();
|
||||
assert!(w.contains(&family) && w.contains("`Black`") && w.contains("nearest"), "{w}");
|
||||
assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().spans()[0].2.font_style, "Black");
|
||||
// A family that isn't installed keeps any style name without a word (files from elsewhere name theirs).
|
||||
let out = ex(&mut s, "node.set", json!({"ids": [t], "props": {"fontFamily": "No Such Family 7f3a", "fontStyle": "Hairline"}}));
|
||||
assert!(out.get("warnings").is_none(), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_call_changes_nothing() {
|
||||
let mut s = Session::new();
|
||||
let t = id(&ex(&mut s, "node.create", json!({"type": "text", "text": "Hi"}))["id"]);
|
||||
let before = s.doc().unwrap().node(t).unwrap().fills.clone();
|
||||
// `fill` comes before `gap` (keys apply in order), so this checks the rollback.
|
||||
assert!(s.execute("node.set", &json!({"ids": [t], "props": {"fill": "#FF0000", "gap": 8}})).is_err());
|
||||
assert_eq!(s.doc().unwrap().node(t).unwrap().fills, before);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! `edit.copy` / `edit.cut` skip pages (they aren't layers and can't be pasted), and refuse a
|
||||
//! selection of only pages under their own command id.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
#[test]
|
||||
fn copy_and_cut_ignore_pages_so_paste_keeps_the_document_valid() {
|
||||
for cmd in ["edit.copy", "edit.cut"] {
|
||||
let mut s = Session::new();
|
||||
let page = s.page().unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
match s.execute(cmd, &json!({"ids": [page.0]})) {
|
||||
Err(EngineError::Disabled(id, _)) => assert_eq!(id, cmd),
|
||||
r => panic!("{cmd} on a page: {r:?}"),
|
||||
}
|
||||
let _ = s.execute("edit.paste", &json!({}));
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(doc.validate().is_empty(), "{cmd}: {:?}", doc.validate());
|
||||
assert!(doc.node(page).is_some(), "{cmd} removed the page");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_of_a_page_and_a_layer_moves_only_the_layer() {
|
||||
let mut s = Session::new();
|
||||
let page = s.page().unwrap();
|
||||
let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
let rect = crate::params::id_of(&r["id"]).unwrap();
|
||||
s.execute("edit.cut", &json!({"ids": [page.0, rect.0]})).unwrap();
|
||||
assert!(s.doc().unwrap().node(page).is_some());
|
||||
assert!(s.doc().unwrap().node(rect).is_none());
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().validate().is_empty());
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//! `fill.set` / `stroke.set` reject a negative, fractional or non-finite `index` instead of
|
||||
//! clamping or truncating it onto another paint (like `fill.remove` / `stroke.remove`).
|
||||
|
||||
use figcraft_doc::NodeId;
|
||||
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} {p}: {e}"))
|
||||
}
|
||||
|
||||
fn paints(s: &Session, id: NodeId) -> String {
|
||||
let n = s.doc().unwrap().node(id).unwrap();
|
||||
format!("{:?} {:?}", n.fills, n.strokes)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paint_set_rejects_bad_index_and_leaves_the_paints_alone() {
|
||||
let mut s = Session::new();
|
||||
let r = NodeId(ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"].as_u64().unwrap());
|
||||
for add in ["fill.add", "stroke.add"] {
|
||||
for c in ["#0000FF", "#FFFF00"] {
|
||||
ex(&mut s, add, json!({"ids": [r], "color": c}));
|
||||
}
|
||||
}
|
||||
let before = paints(&s, r);
|
||||
for cmd in ["fill.set", "stroke.set"] {
|
||||
for bad in [json!(-1), json!(0.5), json!(1.9), json!("-1"), json!("1.5"), json!("NaN"), json!("inf"), json!("abc")] {
|
||||
let res = s.execute(cmd, &json!({"ids": [r], "index": bad, "color": "#FF0000"}));
|
||||
assert!(res.is_err(), "{cmd} index {bad} was accepted: {res:?}");
|
||||
assert_eq!(paints(&s, r), before, "{cmd} index {bad} changed the document");
|
||||
}
|
||||
ex(&mut s, cmd, json!({"ids": [r], "index": 1, "color": "#FF0000"}));
|
||||
assert_ne!(paints(&s, r), before, "{cmd} with a valid index still edits");
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(paints(&s, r), before);
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,36 @@ pub fn req_id(cmd: &str, p: &Value, key: &str) -> Result<NodeId> {
|
||||
id(p, key).ok_or_else(|| bad(cmd, format!("`{key}` must be a node id")))
|
||||
}
|
||||
|
||||
/// The offending value for an error message, cut to a few dozen characters so a huge param
|
||||
/// doesn't end up in the error whole.
|
||||
fn shown(v: &Value) -> String {
|
||||
const MAX: usize = 40;
|
||||
let text = v.to_string();
|
||||
match text.char_indices().nth(MAX) {
|
||||
Some((cut, _)) => format!("{}…", &text[..cut]),
|
||||
None => text,
|
||||
}
|
||||
}
|
||||
|
||||
/// `key` when it is there: a layer id, or an error naming it. Missing and `null` mean "none", so a
|
||||
/// command can tell "no parent given" (use the page) from "a parent was given that isn't an id" —
|
||||
/// which is a mistake, not the default. Without this, `{"$step": 0}` reaching a param that isn't
|
||||
/// resolved yet silently becomes "no parent" and the layer lands on the page.
|
||||
pub fn opt_id(cmd: &str, p: &Value, key: &str) -> Result<Option<NodeId>> {
|
||||
match get(p, key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => id_of(v).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be a node id (got {})", shown(v)))),
|
||||
}
|
||||
}
|
||||
|
||||
/// `key` when it is there: a whole, non-negative number, or an error. Missing means "none".
|
||||
pub fn opt_index(cmd: &str, p: &Value, key: &str) -> Result<Option<usize>> {
|
||||
match get(p, key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(_) => req_index(cmd, p, key).map(Some),
|
||||
}
|
||||
}
|
||||
|
||||
/// `ids` (array), `id` (single), or the current selection.
|
||||
pub fn targets(s: &Session, p: &Value) -> Vec<NodeId> {
|
||||
if let Some(Value::Array(a)) = get(p, "ids") {
|
||||
@@ -99,8 +129,25 @@ pub fn targets(s: &Session, p: &Value) -> Vec<NodeId> {
|
||||
s.selection()
|
||||
}
|
||||
|
||||
/// Like [`targets`] but an empty result is an error.
|
||||
/// Like [`targets`] but an unparseable `ids` entry, an unparseable `id`, or an empty result is an
|
||||
/// error: naming something that isn't a layer is a mistake, not an empty selection. An explicit
|
||||
/// `null` says the same as leaving the key out.
|
||||
pub fn req_targets(cmd: &str, s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
match get(p, "ids") {
|
||||
Some(Value::Array(a)) => {
|
||||
if let Some(v) = a.iter().find(|v| id_of(v).is_none()) {
|
||||
return Err(bad(cmd, format!("`ids`: every entry must be a node id (got {})", shown(v))));
|
||||
}
|
||||
}
|
||||
Some(Value::Null) | None => {
|
||||
if let Some(v) = get(p, "id").filter(|v| !v.is_null())
|
||||
&& id_of(v).is_none()
|
||||
{
|
||||
return Err(bad(cmd, format!("`id` must be a node id (got {})", shown(v))));
|
||||
}
|
||||
}
|
||||
Some(v) => return Err(bad(cmd, format!("`ids` must be an array (got {})", shown(v)))),
|
||||
}
|
||||
let t = targets(s, p);
|
||||
if t.is_empty() {
|
||||
return Err(EngineError::Disabled(cmd.to_string(), "nothing is selected (pass `ids`)".into()));
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
//! Ending a pen path with Escape or Enter (#760): the path's vector stays in edit mode with the pen,
|
||||
//! so the next path is drawn into the same layer; another Escape leaves edit mode.
|
||||
|
||||
use crate::Session;
|
||||
use crate::tools::{PointerEvent, PointerKind, ToolId};
|
||||
use crate::vectoredit::VTool;
|
||||
use figcraft_doc::NodeKind;
|
||||
|
||||
fn click(s: &mut Session, x: f64, y: f64) {
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::at(kind, x, y, 1.0)).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn layers(s: &Session) -> usize {
|
||||
s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len()
|
||||
}
|
||||
|
||||
/// (points, segments) of vector `id`.
|
||||
fn shape(s: &Session, id: figcraft_doc::NodeId) -> (usize, usize) {
|
||||
match &s.doc().unwrap().node(id).unwrap().kind {
|
||||
NodeKind::Vector { network } => (network.vertices.len(), network.segments.len()),
|
||||
k => panic!("not a vector: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A two-point pen path, ended with `end`.
|
||||
fn drawn(end: fn(&mut Session)) -> (Session, figcraft_doc::NodeId) {
|
||||
let mut s = Session::new();
|
||||
s.set_tool(ToolId::Pen);
|
||||
click(&mut s, 100.0, 100.0);
|
||||
click(&mut s, 200.0, 100.0);
|
||||
let id = s.tool.pen.as_ref().and_then(|p| p.id).unwrap();
|
||||
let undo = s.undo_depth().0;
|
||||
end(&mut s);
|
||||
assert!(s.tool.pen.is_none(), "the path ended");
|
||||
assert_eq!(s.undo_depth().0, undo + 1, "the path is one undo step");
|
||||
let ve = s.ds().unwrap().vector_edit.clone().expect("still editing the vector");
|
||||
assert_eq!((ve.id, ve.tool, ve.anchor), (id, VTool::Pen, None), "the pen, with no open end");
|
||||
(s, id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_and_enter_keep_the_vector_in_edit_mode_with_the_pen() {
|
||||
// Escape, and Enter (its key runs `pen_end`).
|
||||
let ends: [fn(&mut Session); 2] = [|s| s.escape().unwrap(), |s| s.pen_end().unwrap()];
|
||||
for end in ends {
|
||||
let (mut s, id) = drawn(end);
|
||||
// The next path goes into the same layer, unconnected to the first.
|
||||
click(&mut s, 100.0, 300.0);
|
||||
click(&mut s, 200.0, 300.0);
|
||||
assert_eq!(layers(&s), 1, "no new layer");
|
||||
assert_eq!(shape(&s, id), (4, 2), "two separate paths in one vector network");
|
||||
// Escape ends that path, the next one leaves edit mode.
|
||||
s.escape().unwrap();
|
||||
assert!(s.ds().unwrap().vector_edit.is_some());
|
||||
s.escape().unwrap();
|
||||
assert!(s.ds().unwrap().vector_edit.is_none(), "the second Escape leaves edit mode");
|
||||
assert_eq!(s.ds().unwrap().selection, vec![id]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_before_a_segment_leaves_nothing_to_edit() {
|
||||
let mut s = Session::new();
|
||||
s.set_tool(ToolId::Pen);
|
||||
click(&mut s, 100.0, 100.0);
|
||||
s.escape().unwrap();
|
||||
assert!(s.tool.pen.is_none());
|
||||
assert!(s.ds().unwrap().vector_edit.is_none());
|
||||
assert_eq!(layers(&s), 0);
|
||||
}
|
||||
@@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
if let Some(path) = string(p, "path") {
|
||||
let scale = num(p, "scale").filter(|k| k.is_finite()).map_or(2.0, |k| k.clamp(0.01, 16.0));
|
||||
let png = crate::cmd::file::export_bytes(©, &[inst], ExportFormat::Png, scale)?;
|
||||
std::fs::write(&path, png).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
crate::file_access::write(&path, &png)?;
|
||||
out["path"] = json!(path);
|
||||
}
|
||||
Ok(out)
|
||||
|
||||
@@ -147,6 +147,9 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// Keep an installed plugin's files in its own folder under the install folder (named by the hash
|
||||
/// of its id, so no id reaches the file system), its module renamed; the manifest's path. Without
|
||||
/// an install folder (the browser, the CLI and tests) the plugin lasts for the session.
|
||||
// The install folder is the app's own (set by the desktop app, never by a command), so automation
|
||||
// roots don't apply to it.
|
||||
#[allow(clippy::disallowed_methods)]
|
||||
fn save(s: &Session, m: &mut Manifest, b: &Bundle) -> std::result::Result<Option<String>, String> {
|
||||
let Some(root) = s.plugins.install_dir.clone() else { return Ok(None) };
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -189,6 +192,7 @@ fn save(s: &Session, m: &mut Manifest, b: &Bundle) -> std::result::Result<Option
|
||||
}
|
||||
|
||||
/// Delete the folder of a plugin installed from a directory (only one inside the install folder).
|
||||
#[allow(clippy::disallowed_methods)]
|
||||
pub(crate) fn delete_installed(s: &Session, p: &InstalledPlugin) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let (Some(_), Some(path), Some(root)) = (&p.source, &p.path, &s.plugins.install_dir)
|
||||
@@ -208,7 +212,7 @@ pub(crate) fn delete_installed(s: &Session, p: &InstalledPlugin) {
|
||||
fn bundle_from_disk(cmd: &str, path: &str) -> Result<Bundle> {
|
||||
use crate::plugins::{module_file, read_file};
|
||||
let mut file = std::path::PathBuf::from(crate::library::absolute_path(path.trim()));
|
||||
if file.is_dir() {
|
||||
if crate::file_access::is_dir(&file) {
|
||||
file.push(MANIFEST_FILE);
|
||||
}
|
||||
let text =
|
||||
@@ -217,9 +221,12 @@ fn bundle_from_disk(cmd: &str, path: &str) -> Result<Bundle> {
|
||||
let wasm = module_file(&file, &m)?;
|
||||
let mut assets = Vec::new();
|
||||
let dir = file.parent().map(|d| d.join(ASSETS));
|
||||
if let Some(entries) = dir.as_ref().and_then(|d| std::fs::read_dir(d).ok()) {
|
||||
let mut names: Vec<(String, std::path::PathBuf)> =
|
||||
entries.flatten().filter(|e| e.path().is_file()).map(|e| (e.file_name().to_string_lossy().into_owned(), e.path())).collect();
|
||||
if let Some(entries) = dir.as_ref().and_then(|d| crate::file_access::read_dir(d).ok()) {
|
||||
let mut names: Vec<(String, std::path::PathBuf)> = entries
|
||||
.into_iter()
|
||||
.filter(|p| crate::file_access::metadata(p).is_ok_and(|m| m.is_file()))
|
||||
.map(|p| (p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), p))
|
||||
.collect();
|
||||
names.sort();
|
||||
if names.len() > MAX_ASSETS {
|
||||
return Err(bad(cmd, format!("a plugin's assets folder holds at most {MAX_ASSETS} files")));
|
||||
|
||||
@@ -251,12 +251,11 @@ pub(crate) fn installed<'a>(s: &'a Session, cmd: &str, id: &str) -> Result<&'a I
|
||||
/// Read a file of at most `max` bytes.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn read_file(path: &std::path::Path, max: usize) -> Result<Vec<u8>> {
|
||||
let shown = path.display();
|
||||
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{shown}: {e}")))?;
|
||||
let meta = crate::file_access::metadata(path)?;
|
||||
if !meta.is_file() || meta.len() > max as u64 {
|
||||
return Err(EngineError::Other(format!("{shown}: not a file of at most {} KB", max / 1024)));
|
||||
return Err(EngineError::Other(format!("{}: not a file of at most {} KB", path.display(), max / 1024)));
|
||||
}
|
||||
std::fs::read(path).map_err(|e| EngineError::Other(format!("{shown}: {e}")))
|
||||
crate::file_access::read(path)
|
||||
}
|
||||
|
||||
/// The manifest at `path` (the file, or the folder holding `figcraft-plugin.json`), its absolute
|
||||
@@ -264,7 +263,7 @@ pub(crate) fn read_file(path: &std::path::Path, max: usize) -> Result<Vec<u8>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn load_from_disk(cmd: &str, path: &str) -> Result<(Manifest, String, Vec<u8>)> {
|
||||
let mut file = std::path::PathBuf::from(crate::library::absolute_path(path.trim()));
|
||||
if file.is_dir() {
|
||||
if crate::file_access::is_dir(&file) {
|
||||
file.push(MANIFEST_FILE);
|
||||
}
|
||||
let text = read_file(&file, figcraft_plugin::manifest::MAX_MANIFEST_BYTES)?;
|
||||
@@ -295,10 +294,22 @@ pub(crate) fn module_of(s: &Session, p: &InstalledPlugin) -> Result<Arc<Vec<u8>>
|
||||
let Some(path) = &p.path else {
|
||||
return Err(EngineError::Other(format!("{} was imported without a manifest file: import it again", p.manifest.name)));
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
return module_file(std::path::Path::new(path), &p.manifest).map(Arc::new);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
return Err(EngineError::Other(format!("{path}: there are no files in the browser")));
|
||||
module_from_disk(s, p, path)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn module_from_disk(s: &Session, p: &InstalledPlugin, path: &str) -> Result<Arc<Vec<u8>>> {
|
||||
let file = std::path::Path::new(path);
|
||||
let read = || module_file(file, &p.manifest).map(Arc::new);
|
||||
// A plugin installed from a directory lives in the app's own install folder, not among the
|
||||
// files automation roots confine.
|
||||
let own = p.source.is_some() && s.plugins.install_dir.as_deref().is_some_and(|d| crate::file_access::within_app_folder(file, d));
|
||||
if own { crate::file_access::app_owned(read) } else { read() }
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn module_from_disk(_: &Session, _: &InstalledPlugin, path: &str) -> Result<Arc<Vec<u8>>> {
|
||||
Err(EngineError::Other(format!("{path}: there are no files in the browser")))
|
||||
}
|
||||
|
||||
/// The sandbox limits for a run, with `fuel` lowering the instruction budget.
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
//! Paints used by the selection, including descendants and effective text-range fills.
|
||||
//! Targets name paint slots rather than colors: a live edit must not capture other slots when
|
||||
//! its color happens to become equal to another selection color.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use figcraft_color::{Paint, PaintKind};
|
||||
use figcraft_doc::{Document, NodeId, NodeKind, StyleKind};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cmd::lists::{BoundList, list_binding};
|
||||
use crate::params::*;
|
||||
use crate::{CommandSpec, Result, Session, bad, spec};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct PaintTarget {
|
||||
pub id: NodeId,
|
||||
pub fills: bool,
|
||||
pub index: usize,
|
||||
/// Character offsets, not byte offsets. None means the node's fill or stroke list.
|
||||
pub range: Option<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl PaintTarget {
|
||||
pub fn paints(&self, doc: &Document) -> Option<Vec<Paint>> {
|
||||
let n = doc.node(self.id)?;
|
||||
if let Some((start, end)) = self.range {
|
||||
let t = n.kind.text()?;
|
||||
if !self.fills || start > end || end > t.char_count() || (start == end && end != 0) {
|
||||
return None;
|
||||
}
|
||||
Some(t.style_at(start).fills.clone().unwrap_or_else(|| n.fills.clone()))
|
||||
} else {
|
||||
Some(if self.fills { n.fills.clone() } else { n.strokes.clone() })
|
||||
}
|
||||
}
|
||||
|
||||
pub fn paint(&self, doc: &Document) -> Option<Paint> {
|
||||
self.paints(doc)?.get(self.index).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct SelectionColor {
|
||||
pub paint: Paint,
|
||||
pub style: Option<String>,
|
||||
pub variable: Option<String>,
|
||||
pub targets: Vec<PaintTarget>,
|
||||
}
|
||||
|
||||
/// Deterministic order, one target per slot even when a parent and child are both selected.
|
||||
/// Images and patterns are assets rather than selection colors. Hidden paints are omitted.
|
||||
pub fn collect(doc: &Document, ids: &[NodeId]) -> Vec<SelectionColor> {
|
||||
let mut groups: Vec<SelectionColor> = Vec::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut by_paint: BTreeMap<(Option<String>, Option<String>, String), usize> = BTreeMap::new();
|
||||
let mut pending: Vec<_> = ids.iter().rev().copied().collect();
|
||||
while let Some(id) = pending.pop() {
|
||||
if !seen.insert(id) {
|
||||
continue;
|
||||
}
|
||||
let Some(n) = doc.node(id) else { continue };
|
||||
if n.is_mask {
|
||||
continue;
|
||||
}
|
||||
if !matches!(n.kind, NodeKind::BooleanOp { .. }) {
|
||||
pending.extend(n.children.iter().rev().copied());
|
||||
}
|
||||
let mut add = |paints: &[Paint], fills: bool, range: Option<(usize, usize)>| {
|
||||
for (index, paint) in paints.iter().enumerate() {
|
||||
if !paint.visible || !matches!(paint.kind, PaintKind::Solid { .. } | PaintKind::Gradient(_)) {
|
||||
continue;
|
||||
}
|
||||
let target = PaintTarget { id, fills, index, range };
|
||||
let inherited = range.is_none_or(|(start, _)| n.kind.text().is_some_and(|t| t.style_at(start).fills.is_none()));
|
||||
let style = inherited.then(|| if fills { n.styles.fill.clone() } else { n.styles.stroke.clone() }).flatten();
|
||||
let list = if fills { BoundList::Fills } else { BoundList::Strokes };
|
||||
let variable = inherited
|
||||
.then(|| {
|
||||
n.bound.iter().find_map(|(key, var)| {
|
||||
list_binding(key).filter(|(l, i, field)| *l == list && *i == index && *field == "color").map(|_| var.clone())
|
||||
})
|
||||
})
|
||||
.flatten();
|
||||
let Ok(value) = serde_json::to_string(paint) else { continue };
|
||||
let key = (style.clone(), variable.clone(), value);
|
||||
if let Some(index) = by_paint.get(&key).copied() {
|
||||
if let Some(g) = groups.get_mut(index) {
|
||||
g.targets.push(target);
|
||||
}
|
||||
} else {
|
||||
by_paint.insert(key, groups.len());
|
||||
groups.push(SelectionColor { paint: paint.clone(), style, variable, targets: vec![target] });
|
||||
}
|
||||
}
|
||||
};
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
let spans = t.spans();
|
||||
if spans.iter().all(|(_, _, style)| style.fills.is_none()) {
|
||||
add(&n.fills, true, None);
|
||||
} else {
|
||||
for (start, end, style) in spans {
|
||||
add(style.fills.as_deref().unwrap_or(&n.fills), true, Some((start, end)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
add(&n.fills, true, None);
|
||||
}
|
||||
add(&n.strokes, false, None);
|
||||
}
|
||||
groups
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!(
|
||||
"selection.colors",
|
||||
"Selection colors",
|
||||
"{ids?} → {colors: [{paint, targets}]}; includes descendants and text ranges",
|
||||
false,
|
||||
|s, p| { Ok(json!({"colors": collect(s.doc()?, &targets(s, p))})) }
|
||||
),
|
||||
spec!("selection.setPaint", "Change selection color", "{targets: [{id, fills, index, range: [start,end] | null}], paint}", true, set_paint),
|
||||
spec!(
|
||||
"selection.applyColorStyle",
|
||||
"Apply selection color style",
|
||||
"{targets, style}; applies the color style to matching fill/stroke lists (text ranges keep typography)",
|
||||
true,
|
||||
apply_style
|
||||
),
|
||||
spec!(
|
||||
"selection.selectColor",
|
||||
"Select layers with color",
|
||||
"{targets} → {ids}; selects the layers owning these paint slots",
|
||||
false,
|
||||
select_color
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn checked_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<PaintTarget>> {
|
||||
let ts: Vec<PaintTarget> = parse(cmd, p, "targets")?.ok_or_else(|| bad(cmd, "`targets` is required"))?;
|
||||
if ts.is_empty() {
|
||||
return Err(bad(cmd, "`targets` must not be empty"));
|
||||
}
|
||||
for t in &ts {
|
||||
if t.paint(s.doc()?).is_none() {
|
||||
return Err(bad(cmd, "a selection color target no longer exists"));
|
||||
}
|
||||
}
|
||||
Ok(ts)
|
||||
}
|
||||
|
||||
/// Detach variables only where the explicit edit changed their value. In particular, an
|
||||
/// opacity edit keeps the color binding and a range edit keeps other characters' node bindings.
|
||||
fn unbind_changed(n: &mut figcraft_doc::Node, t: &PaintTarget, old: &Paint, new: &Paint) {
|
||||
// Range fills override the node's paints; the node binding still belongs to other chars.
|
||||
if t.range.is_some() {
|
||||
return;
|
||||
}
|
||||
let list = if t.fills { BoundList::Fills } else { BoundList::Strokes };
|
||||
n.bound.retain(|key, _| {
|
||||
let Some((l, index, field)) = list_binding(key) else { return true };
|
||||
if l != list || index != t.index {
|
||||
return true;
|
||||
}
|
||||
match field {
|
||||
"color" => match (&old.kind, &new.kind) {
|
||||
(PaintKind::Solid { color: a }, PaintKind::Solid { color: b }) => a == b,
|
||||
_ => false,
|
||||
},
|
||||
"opacity" => old.opacity == new.opacity,
|
||||
_ => {
|
||||
let stop = field.strip_prefix("stops/").and_then(|s| s.strip_suffix("/color")).and_then(|s| s.parse::<usize>().ok());
|
||||
match (stop, &old.kind, &new.kind) {
|
||||
(Some(i), PaintKind::Gradient(a), PaintKind::Gradient(b)) => a.stops.get(i).map(|s| s.color) == b.stops.get(i).map(|s| s.color),
|
||||
(Some(_), _, _) => false,
|
||||
_ => old == new,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn set_paint(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "selection.setPaint";
|
||||
let targets = checked_targets(s, p, CMD)?;
|
||||
let paint = get(p, "paint").and_then(paint_of).ok_or_else(|| bad(CMD, "`paint` must be a paint"))?;
|
||||
for target in &targets {
|
||||
let mut paints = target.paints(s.doc()?).ok_or_else(|| bad(CMD, "invalid target"))?;
|
||||
let Some(slot) = paints.get_mut(target.index) else { return Err(bad(CMD, "invalid paint index")) };
|
||||
if *slot == paint {
|
||||
continue;
|
||||
}
|
||||
let old = slot.clone();
|
||||
*slot = paint.clone();
|
||||
let n = s.doc_mut()?.get_mut(target.id)?;
|
||||
unbind_changed(n, target, &old, &paint);
|
||||
if let Some((start, end)) = target.range {
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
t.restyle(start, end, |style| style.fills = Some(paints.clone()));
|
||||
}
|
||||
} else if target.fills {
|
||||
n.fills = paints;
|
||||
n.styles.fill = None;
|
||||
} else {
|
||||
n.strokes = paints;
|
||||
n.styles.stroke = None;
|
||||
}
|
||||
}
|
||||
Ok(json!({"ok": true}))
|
||||
}
|
||||
|
||||
fn apply_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "selection.applyColorStyle";
|
||||
let targets = checked_targets(s, p, CMD)?;
|
||||
let name = req_string(CMD, p, "style")?;
|
||||
let style = s.doc()?.style(&name).cloned().ok_or_else(|| bad(CMD, "no such color style"))?;
|
||||
if !matches!(style.kind, StyleKind::Paint { .. }) {
|
||||
return Err(bad(CMD, "a color style is required"));
|
||||
}
|
||||
for target in &targets {
|
||||
if let Some((start, end)) = target.range {
|
||||
// Text ranges have typography-style links, not paint-style links. Copy resolved paint
|
||||
// values just as an explicit range fill does; never replace its font or size.
|
||||
let StyleKind::Paint { paints } = crate::style_vars::kind_for(s.doc()?, &style, target.id).into_owned() else { continue };
|
||||
let n = s.doc_mut()?.get_mut(target.id)?;
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
t.restyle(start, end, |st| st.fills = Some(paints.clone()));
|
||||
}
|
||||
} else {
|
||||
crate::cmd::styles::apply_style_ref(s.doc_mut()?, target.id, if target.fills { "fillStyle" } else { "strokeStyle" }, Some(&style.id))?;
|
||||
}
|
||||
}
|
||||
Ok(json!({"ok": true}))
|
||||
}
|
||||
|
||||
fn select_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ts = checked_targets(s, p, "selection.selectColor")?;
|
||||
let ids: Vec<_> = ts.iter().map(|t| t.id).collect::<BTreeSet<_>>().into_iter().collect();
|
||||
crate::cmd::edit::select_and_page(s, ids.clone())?;
|
||||
Ok(json!({"ids": ids}))
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Smart-selection spacing in page coordinates. Tracks contain aligned siblings (columns for
|
||||
//! horizontal spacing, rows for vertical spacing); moving a track preserves its internal layout.
|
||||
use figcraft_doc::{Document, NodeId, NodeKind};
|
||||
use figcraft_geom::Rect;
|
||||
|
||||
const EPS: f64 = 0.01;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Track {
|
||||
pub ids: Vec<NodeId>,
|
||||
pub bounds: Rect,
|
||||
anchor: Rect,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Axis {
|
||||
pub tracks: Vec<Track>,
|
||||
pub vertical: bool,
|
||||
/// None means the existing distances are mixed; setting a value makes them uniform.
|
||||
pub value: Option<f64>,
|
||||
}
|
||||
|
||||
impl Axis {
|
||||
pub fn start(&self, b: Rect) -> f64 {
|
||||
if self.vertical { b.y0 } else { b.x0 }
|
||||
}
|
||||
pub fn end(&self, b: Rect) -> f64 {
|
||||
if self.vertical { b.y1 } else { b.x1 }
|
||||
}
|
||||
pub fn gap(&self, a: &Track, b: &Track) -> f64 {
|
||||
self.start(b.bounds) - self.end(a.bounds)
|
||||
}
|
||||
/// Desired page-space translations, keeping the first track fixed.
|
||||
pub fn offsets(&self, value: f64) -> Vec<(NodeId, f64)> {
|
||||
let mut pos = self.tracks.first().map_or(0.0, |t| self.start(t.bounds));
|
||||
let mut out = Vec::new();
|
||||
for t in &self.tracks {
|
||||
let delta = pos - self.start(t.bounds);
|
||||
out.extend(t.ids.iter().map(|id| (*id, delta)));
|
||||
pos += self.end(t.bounds) - self.start(t.bounds) + value;
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct SelectionSpacing {
|
||||
pub horizontal: Option<Axis>,
|
||||
pub vertical: Option<Axis>,
|
||||
}
|
||||
|
||||
/// Available only for movable, visible siblings outside normal auto-layout flow. Parent/child
|
||||
/// selections, instance internals and locked layers must not be moved indirectly by a gap edit.
|
||||
pub fn selection_spacing(doc: &Document, ids: &[NodeId]) -> SelectionSpacing {
|
||||
let Some(first) = ids.first().and_then(|id| doc.node(*id)) else { return SelectionSpacing::default() };
|
||||
if ids.len() < 2 || ids.len() > 2000 {
|
||||
return SelectionSpacing::default();
|
||||
}
|
||||
let mut items = Vec::new();
|
||||
for id in ids {
|
||||
let Some(n) = doc.node(*id) else { return SelectionSpacing::default() };
|
||||
if n.parent != first.parent
|
||||
|| !n.visible
|
||||
|| doc.ancestors(*id).into_iter().any(|a| doc.node(a).is_some_and(|n| !n.visible))
|
||||
|| crate::tools::locked_in(doc, *id)
|
||||
|| crate::components::inside_instance(doc, *id)
|
||||
|| matches!(n.kind, NodeKind::Page { .. })
|
||||
|| n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some() && !n.child_layout.absolute)
|
||||
{
|
||||
return SelectionSpacing::default();
|
||||
}
|
||||
let determinant = n.parent.map(|p| doc.world_transform(p).determinant()).unwrap_or(1.0);
|
||||
if !determinant.is_finite() || determinant.abs() < 1e-12 {
|
||||
return SelectionSpacing::default();
|
||||
}
|
||||
let Some(b) = doc.world_bounds(*id) else { return SelectionSpacing::default() };
|
||||
if ![b.x0, b.y0, b.x1, b.y1].into_iter().all(f64::is_finite) || b.width() <= EPS || b.height() <= EPS {
|
||||
return SelectionSpacing::default();
|
||||
}
|
||||
if !items.iter().any(|(i, _)| i == id) {
|
||||
items.push((*id, b));
|
||||
}
|
||||
}
|
||||
SelectionSpacing { horizontal: axis(&items, false), vertical: axis(&items, true) }
|
||||
}
|
||||
|
||||
fn axis(items: &[(NodeId, Rect)], vertical: bool) -> Option<Axis> {
|
||||
let edges = |b: Rect| if vertical { (b.y0, b.y1) } else { (b.x0, b.x1) };
|
||||
let mut items = items.to_vec();
|
||||
items.sort_by(|a, b| edges(a.1).0.total_cmp(&edges(b.1).0).then(a.0.cmp(&b.0)));
|
||||
let mut tracks: Vec<Track> = Vec::new();
|
||||
for (id, b) in items {
|
||||
let (lo, hi) = edges(b);
|
||||
let aligned = tracks.iter_mut().find(|t| {
|
||||
let (a, z) = edges(t.anchor);
|
||||
(lo - a).abs() <= EPS || (hi - z).abs() <= EPS || ((lo + hi - a - z) * 0.5).abs() <= EPS
|
||||
});
|
||||
if let Some(t) = aligned {
|
||||
t.ids.push(id);
|
||||
t.bounds = t.bounds.union(b);
|
||||
} else {
|
||||
tracks.push(Track { ids: vec![id], bounds: b, anchor: b });
|
||||
}
|
||||
}
|
||||
if tracks.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
tracks.sort_by(|a, b| edges(a.bounds).0.total_cmp(&edges(b.bounds).0));
|
||||
// Diagonally scattered layers aren't a row/column/grid selection.
|
||||
let cross = |b: Rect| if vertical { (b.x0, b.x1) } else { (b.y0, b.y1) };
|
||||
let low = tracks.iter().map(|t| cross(t.bounds).0).fold(f64::NEG_INFINITY, f64::max);
|
||||
let high = tracks.iter().map(|t| cross(t.bounds).1).fold(f64::INFINITY, f64::min);
|
||||
if high - low <= EPS {
|
||||
return None;
|
||||
}
|
||||
let mut result = Axis { tracks, vertical, value: None };
|
||||
let gaps: Vec<f64> = result.tracks.windows(2).filter_map(|p| p.first().zip(p.get(1)).map(|(a, b)| result.gap(a, b))).collect();
|
||||
result.value = gaps.first().copied().filter(|v| gaps.iter().all(|g| (g - v).abs() <= EPS));
|
||||
Some(result)
|
||||
}
|
||||
|
||||
/// A spacing gesture never changes selection. Roll it back in its owning document, preserving
|
||||
/// a subsequent tab/page/selection change and leaving another document's gesture alone.
|
||||
pub fn cancel_gesture(session: &mut crate::Session, uid: u64) {
|
||||
if let Some(ds) = session.docs.iter_mut().find(|d| d.uid == uid)
|
||||
&& let Some(it) = ds.interaction.take()
|
||||
{
|
||||
ds.doc = it.doc;
|
||||
ds.revision += 1;
|
||||
ds.clean_selection();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
use crate::{Session, selection_spacing::selection_spacing};
|
||||
use figcraft_doc::NodeId;
|
||||
use serde_json::json;
|
||||
|
||||
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64, parent: Option<NodeId>) -> NodeId {
|
||||
NodeId(s.execute("shape.rectangle", &json!({"x":x,"y":y,"width":w,"height":h,"parent":parent})).unwrap()["id"].as_u64().unwrap())
|
||||
}
|
||||
fn xy(s: &Session, id: NodeId) -> (f64, f64) {
|
||||
let b = s.doc().unwrap().world_bounds(id).unwrap();
|
||||
(b.x0, b.y0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_columns_and_grids_keep_track_contents_and_sizes() {
|
||||
let mut s = Session::new();
|
||||
// Unequal widths, left-aligned columns. The column widths are 50 and 80; gaps 30 and 20.
|
||||
let a = rect(&mut s, 100., 100., 50., 40., None);
|
||||
let b = rect(&mut s, 180., 100., 80., 40., None);
|
||||
let c = rect(&mut s, 100., 160., 30., 60., None);
|
||||
let d = rect(&mut s, 180., 160., 40., 60., None);
|
||||
let ids = [a, b, c, d];
|
||||
let layout = selection_spacing(s.doc().unwrap(), &ids);
|
||||
assert_eq!(layout.horizontal.unwrap().value, Some(30.));
|
||||
assert_eq!(layout.vertical.unwrap().value, Some(20.));
|
||||
s.execute("arrange.spacing", &json!({"ids":ids,"horizontal":12,"vertical":25})).unwrap();
|
||||
assert_eq!(xy(&s, a), (100., 100.));
|
||||
assert_eq!(xy(&s, b), (162., 100.));
|
||||
assert_eq!(xy(&s, c), (100., 165.));
|
||||
assert_eq!(xy(&s, d), (162., 165.));
|
||||
|
||||
assert_eq!(s.doc().unwrap().node(d).unwrap().width, 40.);
|
||||
s.undo().unwrap();
|
||||
assert_eq!(xy(&s, d), (180., 160.));
|
||||
s.redo().unwrap();
|
||||
assert_eq!(xy(&s, d), (162., 165.));
|
||||
s.begin_interaction("Spacing").unwrap();
|
||||
s.execute("arrange.spacing", &json!({"ids":ids,"horizontal":22,"fromGesture":true})).unwrap();
|
||||
s.execute("arrange.spacing", &json!({"ids":ids,"vertical":35,"fromGesture":true})).unwrap();
|
||||
assert_eq!(xy(&s, d), (172., 175.), "the unrequested axis keeps the earlier edit");
|
||||
s.commit_interaction().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_gaps_normalize_and_fractional_negative_gaps_are_supported() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0., 0., 50., 40., None);
|
||||
let b = rect(&mut s, 70., 0., 50., 40., None);
|
||||
let c = rect(&mut s, 160., 0., 50., 40., None);
|
||||
assert_eq!(selection_spacing(s.doc().unwrap(), &[a, b, c]).horizontal.unwrap().value, None);
|
||||
s.execute("arrange.spacing", &json!({"ids":[a,b,c],"horizontal":-5.5})).unwrap();
|
||||
assert_eq!(xy(&s, b), (44.5, 0.));
|
||||
assert_eq!(xy(&s, c), (89., 0.));
|
||||
assert_eq!(selection_spacing(s.doc().unwrap(), &[a, b, c]).horizontal.unwrap().value, Some(-5.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scrub_uses_the_original_tracks_after_crossing_and_makes_one_undo_step() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0., 0., 50., 40., None);
|
||||
let b = rect(&mut s, 70., 0., 50., 40., None);
|
||||
let c = rect(&mut s, 140., 0., 50., 40., None);
|
||||
let before = s.undo_depth().0;
|
||||
s.begin_interaction("Spacing").unwrap();
|
||||
for v in [-100., 0., 30.] {
|
||||
s.execute("arrange.spacing", &json!({"ids":[a,b,c],"horizontal":v,"fromGesture":true})).unwrap();
|
||||
}
|
||||
assert_eq!(xy(&s, b), (80., 0.));
|
||||
assert_eq!(xy(&s, c), (160., 0.));
|
||||
s.commit_interaction().unwrap();
|
||||
assert_eq!(s.undo_depth().0, before + 1);
|
||||
s.undo().unwrap();
|
||||
assert_eq!(xy(&s, c), (140., 0.));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsafe_selections_and_invalid_axes_are_rejected_without_changes() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0., 0., 50., 40., None);
|
||||
let b = rect(&mut s, 70., 0., 50., 40., None);
|
||||
for props in [json!({"locked":true}), json!({"visible":false})] {
|
||||
s.execute("node.set", &json!({"id":b,"props":props})).unwrap();
|
||||
assert!(s.execute("arrange.spacing", &json!({"ids":[a,b],"horizontal":5})).is_err());
|
||||
s.execute("node.set", &json!({"id":b,"props":{"locked":false,"visible":true}})).unwrap();
|
||||
}
|
||||
for args in [
|
||||
json!({"ids":[a,b],"vertical":5}),
|
||||
json!({"ids":[a,b],"horizontal":5,"vertical":5}),
|
||||
json!({"ids":[a,b],"horizontal":"bad"}),
|
||||
json!({"ids":[a,b],"horizontal":1e300}),
|
||||
] {
|
||||
assert!(s.execute("arrange.spacing", &args).is_err());
|
||||
assert_eq!(xy(&s, b), (70., 0.));
|
||||
}
|
||||
let f = NodeId(
|
||||
s.execute("frame.create", &json!({"x":0,"y":0,"width":300,"height":100,"props":{"layout":"horizontal"}})).unwrap()["id"].as_u64().unwrap(),
|
||||
);
|
||||
let x = rect(&mut s, 0., 0., 50., 40., Some(f));
|
||||
let y = rect(&mut s, 70., 0., 50., 40., Some(f));
|
||||
assert!(selection_spacing(s.doc().unwrap(), &[x, y]).horizontal.is_none());
|
||||
assert!(selection_spacing(s.doc().unwrap(), &[a, x]).horizontal.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_parents_use_page_space_and_cancellation_stays_with_its_document() {
|
||||
let mut s = Session::new();
|
||||
let f = NodeId(
|
||||
s.execute("frame.create", &json!({"x":100,"y":100,"width":300,"height":200,"props":{"rotation":90}})).unwrap()["id"].as_u64().unwrap(),
|
||||
);
|
||||
let a = rect(&mut s, 0., 0., 50., 40., Some(f));
|
||||
let b = rect(&mut s, 70., 0., 50., 40., Some(f));
|
||||
let before = xy(&s, b);
|
||||
let uid = s.ds().unwrap().uid;
|
||||
s.begin_interaction("Spacing").unwrap();
|
||||
s.execute("arrange.spacing", &json!({"ids":[a,b],"vertical":40,"fromGesture":true})).unwrap();
|
||||
assert!(((xy(&s, b).1 - xy(&s, a).1).abs() - 90.0).abs() < 1e-7, "50-high rotated rectangles must have a 40-point gap");
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.begin_interaction("Unrelated").unwrap();
|
||||
crate::selection_spacing::cancel_gesture(&mut s, uid);
|
||||
assert!(s.ds().unwrap().interaction.is_some());
|
||||
s.active = 0;
|
||||
assert_eq!(xy(&s, b), before);
|
||||
assert!(s.ds().unwrap().interaction.is_none());
|
||||
}
|
||||
@@ -415,6 +415,9 @@ fn every_command_survives_junk_params() {
|
||||
// Online files: ids, servers, refreshes, actions and names as junk.
|
||||
json!({"file": [1], "server": 7, "refresh": "maybe", "action": "share", "name": {"x": 1}}),
|
||||
json!({"file": "../x", "server": "wss://\u{0}", "refresh": true, "action": "unshare", "name": 5}),
|
||||
// Selection color slots: malformed indexes, ranges and paints.
|
||||
json!({"targets": [{"id": 2, "fills": true, "index": -1, "range": [0, 1e308]}], "paint": {"type": "solid", "color": "invalid"}}),
|
||||
json!({"targets": [{"id": 2, "fills": "yes", "index": 0, "range": [4, 1]}], "style": 5}),
|
||||
// Rotation origins and placing with a tool as junk.
|
||||
json!({"tool": [1], "x": 1e308, "y": "NaN", "reset": "maybe", "on": {"x": 1}}),
|
||||
json!({"tool": "frame", "x": -1e308, "y": 1e308, "reset": true, "on": "yes"}),
|
||||
|
||||
@@ -2051,13 +2051,7 @@ impl Session {
|
||||
if self.tool.pen.is_none()
|
||||
&& let Some((id, v)) = selected_end_at(&doc, &self.selection(), ev.p(), 8.0 / ev.zoom.max(1e-6))
|
||||
{
|
||||
self.set_tool(ToolId::Move);
|
||||
crate::vectoredit::begin(self, id)?;
|
||||
crate::vectoredit::set_tool(self, crate::vectoredit::VTool::Pen)?;
|
||||
return crate::vectoredit::set_state(self, |e| {
|
||||
e.anchor = Some(v);
|
||||
e.vertices = vec![v];
|
||||
});
|
||||
return self.pen_edit(id, Some(v));
|
||||
}
|
||||
let pen = self.tool.pen.get_or_insert_with(PenPath::default);
|
||||
// Clicking the first anchor closes the path.
|
||||
@@ -2175,7 +2169,30 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Finish the pen path (Enter/Escape/double-click).
|
||||
/// Edit `id` as a vector with the pen, going on from point `anchor` (or, without one, starting
|
||||
/// a new path in the same layer at the next click).
|
||||
fn pen_edit(&mut self, id: NodeId, anchor: Option<usize>) -> Result<()> {
|
||||
self.set_tool(ToolId::Move);
|
||||
crate::vectoredit::begin(self, id)?;
|
||||
crate::vectoredit::set_tool(self, crate::vectoredit::VTool::Pen)?;
|
||||
crate::vectoredit::set_state(self, |e| {
|
||||
e.anchor = anchor;
|
||||
e.vertices = anchor.into_iter().collect();
|
||||
})
|
||||
}
|
||||
|
||||
/// Enter/Escape while the pen draws: the path ends but its layer stays in edit mode with the
|
||||
/// pen, so the next path joins the same vector (another Escape leaves edit mode).
|
||||
pub fn pen_end(&mut self) -> Result<()> {
|
||||
let id = self.tool.pen.as_ref().and_then(|p| p.id);
|
||||
self.pen_finish()?;
|
||||
match id {
|
||||
Some(id) if self.doc()?.contains(id) => self.pen_edit(id, None),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Finish the pen path (and keep the pen tool: double-click, Delete, a tool switch).
|
||||
pub fn pen_finish(&mut self) -> Result<()> {
|
||||
let pen = self.tool.pen.take();
|
||||
self.tool.overlay.pen.clear();
|
||||
@@ -2320,7 +2337,7 @@ impl Session {
|
||||
return Ok(());
|
||||
}
|
||||
if self.tool.pen.is_some() {
|
||||
return self.pen_finish();
|
||||
return self.pen_end();
|
||||
}
|
||||
if self.tool.busy() {
|
||||
self.tool.gesture = Gesture::None;
|
||||
|
||||
@@ -508,16 +508,15 @@ pub fn begin_many(s: &mut Session, ids: &[NodeId]) -> Result<()> {
|
||||
let before = s.ds()?.doc.clone();
|
||||
let mut edited = Vec::new();
|
||||
let mut first_err = None;
|
||||
{
|
||||
let doc = s.doc_mut()?;
|
||||
for id in ids.iter().take(MAX_EDITED) {
|
||||
if edited.contains(id) {
|
||||
continue;
|
||||
}
|
||||
match ensure_vector(doc, *id) {
|
||||
Ok(()) => edited.push(*id),
|
||||
Err(e) => first_err = first_err.or(Some(e)),
|
||||
}
|
||||
for id in ids.iter().take(MAX_EDITED) {
|
||||
if edited.contains(id) {
|
||||
continue;
|
||||
}
|
||||
// Vectors are left untouched, so editing only them records no undo step.
|
||||
let is_vector = before.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Vector { .. }));
|
||||
match if is_vector { Ok(()) } else { ensure_vector(s.doc_mut()?, *id) } {
|
||||
Ok(()) => edited.push(*id),
|
||||
Err(e) => first_err = first_err.or(Some(e)),
|
||||
}
|
||||
}
|
||||
let Some((&id, rest)) = edited.split_first() else {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! 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)]
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::disallowed_methods)]
|
||||
|
||||
use figcraft_doc::{NodeId, StyleKind};
|
||||
use figcraft_engine::Session;
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use figcraft_color::Paint;
|
||||
use figcraft_doc::NodeId;
|
||||
use figcraft_engine::{
|
||||
Session,
|
||||
selection_colors::{SelectionColor, collect},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap()
|
||||
}
|
||||
fn rectangle(s: &mut Session, color: &str) -> NodeId {
|
||||
NodeId(run(s, "shape.rectangle", json!({"width":40,"height":40,"props":{"fill":color}}))["id"].as_u64().unwrap())
|
||||
}
|
||||
fn group(s: &Session, ids: &[NodeId], hex: &str) -> SelectionColor {
|
||||
collect(s.doc().unwrap(), ids).into_iter().find(|g| g.paint.solid_color().is_some_and(|c| c.to_hex() == hex)).unwrap()
|
||||
}
|
||||
fn fill(s: &Session, id: NodeId) -> String {
|
||||
s.doc().unwrap().node(id).unwrap().fills[0].solid_color().unwrap().to_hex()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregate_descendants_deduplicate_roots_and_ignore_images_and_hidden_paints() {
|
||||
let mut s = Session::new();
|
||||
let a = rectangle(&mut s, "#ff0000");
|
||||
let b = rectangle(&mut s, "#00ff00");
|
||||
run(&mut s, "stroke.add", json!({"id":b,"color":"#ff0000"}));
|
||||
let parent = NodeId(run(&mut s, "object.group", json!({"ids":[a,b]}))["id"].as_u64().unwrap());
|
||||
let colors = collect(s.doc().unwrap(), &[parent, a]);
|
||||
assert_eq!(colors.len(), 2);
|
||||
assert_eq!(group(&s, &[parent, a], "FF0000").targets.len(), 2);
|
||||
run(&mut s, "fill.add", json!({"id":a,"paint":Paint::image("synthetic",figcraft_color::ImageScaleMode::Fill)}));
|
||||
run(&mut s, "fill.add", json!({"id":a,"paint":{"type":"solid","color":"#0000ff","visible":false}}));
|
||||
assert_eq!(collect(s.doc().unwrap(), &[parent]).len(), 2);
|
||||
run(&mut s, "select.set", json!({"ids":[parent]}));
|
||||
assert_eq!(run(&mut s, "selection.colors", json!({}))["colors"].as_array().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_only_matching_slots_is_one_undo_step_and_keeps_selection() {
|
||||
let mut s = Session::new();
|
||||
let a = rectangle(&mut s, "#ff0000");
|
||||
let b = rectangle(&mut s, "#0000ff");
|
||||
let c = rectangle(&mut s, "#00ff00");
|
||||
run(&mut s, "stroke.add", json!({"id":b,"color":"#ff0000"}));
|
||||
run(&mut s, "select.set", json!({"ids":[a,b]}));
|
||||
let red = group(&s, &[a, b], "FF0000");
|
||||
let before = s.undo_depth();
|
||||
run(&mut s, "selection.setPaint", json!({"targets":red.targets,"paint":"#ffff00"}));
|
||||
assert_eq!(fill(&s, a), "FFFF00");
|
||||
assert_eq!(fill(&s, b), "0000FF");
|
||||
assert_eq!(fill(&s, c), "00FF00");
|
||||
assert_eq!(s.doc().unwrap().node(b).unwrap().strokes[0].solid_color().unwrap().to_hex(), "FFFF00");
|
||||
assert_eq!(s.selection(), vec![a, b]);
|
||||
assert_eq!(s.undo_depth().0, before.0 + 1);
|
||||
s.undo().unwrap();
|
||||
assert_eq!(fill(&s, a), "FF0000");
|
||||
s.redo().unwrap();
|
||||
assert_eq!(fill(&s, a), "FFFF00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_picker_targets_do_not_expand_when_colors_merge() {
|
||||
let mut s = Session::new();
|
||||
let a = rectangle(&mut s, "#ff0000");
|
||||
let b = rectangle(&mut s, "#0000ff");
|
||||
let red = group(&s, &[a, b], "FF0000");
|
||||
let before = s.undo_depth();
|
||||
s.begin_interaction("Color").unwrap();
|
||||
for hex in ["#0000ff", "#00ff00"] {
|
||||
run(&mut s, "selection.setPaint", json!({"targets":red.targets,"paint":hex}));
|
||||
}
|
||||
s.commit_interaction().unwrap();
|
||||
assert_eq!(fill(&s, a), "00FF00");
|
||||
assert_eq!(fill(&s, b), "0000FF");
|
||||
assert_eq!(s.undo_depth().0, before.0 + 1);
|
||||
s.undo().unwrap();
|
||||
assert_eq!(fill(&s, a), "FF0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_range_colors_use_character_offsets_and_preserve_typography() {
|
||||
let mut s = Session::new();
|
||||
let id = NodeId(run(&mut s, "text.create", json!({"text":"Яab","props":{"fill":"#ff0000"}}))["id"].as_u64().unwrap());
|
||||
run(&mut s, "text.style", json!({"id":id,"start":1,"end":2,"fontSize":30,"textFills":["#0000ff"]}));
|
||||
let blue = group(&s, &[id], "0000FF");
|
||||
assert_eq!(blue.targets[0].range, Some((1, 2)));
|
||||
run(&mut s, "selection.setPaint", json!({"targets":blue.targets,"paint":"#00ff00"}));
|
||||
let n = s.doc().unwrap().node(id).unwrap();
|
||||
let t = n.kind.text().unwrap();
|
||||
assert_eq!(t.characters, "Яab");
|
||||
assert_eq!(t.style_at(1).font_size, 30.0);
|
||||
assert_eq!(t.style_at(1).fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "00FF00");
|
||||
assert_eq!(t.style_at(0).fills.as_deref().unwrap_or(&n.fills)[0].solid_color().unwrap().to_hex(), "FF0000");
|
||||
assert_eq!(t.style_at(2).fills.as_deref().unwrap_or(&n.fills)[0].solid_color().unwrap().to_hex(), "FF0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_color_selects_its_unique_owners_without_document_history() {
|
||||
let mut s = Session::new();
|
||||
let a = rectangle(&mut s, "#ff0000");
|
||||
let b = rectangle(&mut s, "#0000ff");
|
||||
run(&mut s, "stroke.add", json!({"id":a,"color":"#ff0000"}));
|
||||
let red = group(&s, &[a, b], "FF0000");
|
||||
let before = s.undo_depth();
|
||||
run(&mut s, "selection.selectColor", json!({"targets":red.targets}));
|
||||
assert_eq!(s.selection(), vec![a]);
|
||||
assert_eq!(s.undo_depth(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applying_color_styles_links_matching_fill_and_stroke_lists_only() {
|
||||
let mut s = Session::new();
|
||||
let a = rectangle(&mut s, "#ff0000");
|
||||
let b = rectangle(&mut s, "#0000ff");
|
||||
run(&mut s, "stroke.add", json!({"id":b,"color":"#ff0000"}));
|
||||
let red = group(&s, &[a, b], "FF0000");
|
||||
let style = run(&mut s, "style.create", json!({"type":"paint","name":"Brand","value":"#00ff00"}))["id"].clone();
|
||||
let before = s.undo_depth();
|
||||
run(&mut s, "selection.applyColorStyle", json!({"targets":red.targets,"style":style}));
|
||||
assert_eq!(fill(&s, a), "00FF00");
|
||||
assert_eq!(fill(&s, b), "0000FF");
|
||||
assert_eq!(s.doc().unwrap().node(a).unwrap().styles.fill.as_deref(), style.as_str());
|
||||
assert_eq!(s.doc().unwrap().node(b).unwrap().styles.stroke.as_deref(), style.as_str());
|
||||
assert_eq!(s.undo_depth().0, before.0 + 1);
|
||||
run(&mut s, "style.update", json!({"style":style,"value":"#ffff00"}));
|
||||
assert_eq!(fill(&s, a), "FFFF00");
|
||||
assert_eq!(fill(&s, b), "0000FF");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_color_detaches_its_binding_but_keeps_other_bindings() {
|
||||
let mut s = Session::new();
|
||||
let a = rectangle(&mut s, "#ff0000");
|
||||
run(&mut s, "variable.createCollection", json!({"name":"Theme"}));
|
||||
let v = run(&mut s, "variable.create", json!({"collection":"Theme","name":"Brand","type":"color","value":"#ff0000"}))["id"].clone();
|
||||
run(&mut s, "variable.bind", json!({"id":a,"field":"fill","variable":v}));
|
||||
let red = group(&s, &[a], "FF0000");
|
||||
run(&mut s, "selection.setPaint", json!({"targets":red.targets,"paint":"#0000ff"}));
|
||||
assert_eq!(fill(&s, a), "0000FF");
|
||||
assert!(s.doc().unwrap().node(a).unwrap().bound.is_empty());
|
||||
s.undo().unwrap();
|
||||
assert_eq!(fill(&s, a), "FF0000");
|
||||
assert!(!s.doc().unwrap().node(a).unwrap().bound.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_or_stale_targets_roll_back_atomically() {
|
||||
let mut s = Session::new();
|
||||
let a = rectangle(&mut s, "#ff0000");
|
||||
let mut red = group(&s, &[a], "FF0000");
|
||||
let mut bad = red.targets[0].clone();
|
||||
bad.index = 999;
|
||||
red.targets.push(bad);
|
||||
let before = s.undo_depth();
|
||||
assert!(s.execute("selection.setPaint", &json!({"targets":red.targets,"paint":"#0000ff"})).is_err());
|
||||
assert_eq!(fill(&s, a), "FF0000");
|
||||
assert_eq!(s.undo_depth(), before);
|
||||
for command in ["selection.setPaint", "selection.selectColor", "selection.applyColorStyle"] {
|
||||
for params in [json!({}), json!({"targets":[]}), json!({"targets":[{"id":a,"fills":true,"index":0,"range":[0,1]}]})] {
|
||||
assert!(s.execute(command, ¶ms).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_resolved_colors_from_different_styles_or_variables_stay_separate() {
|
||||
let mut s = Session::new();
|
||||
let a = rectangle(&mut s, "#ff0000");
|
||||
let b = rectangle(&mut s, "#ff0000");
|
||||
let c = rectangle(&mut s, "#ff0000");
|
||||
let style = run(&mut s, "style.create", json!({"type":"paint","name":"Brand","value":"#ff0000"}))["id"].clone();
|
||||
run(&mut s, "style.apply", json!({"id":a,"style":style,"target":"fill"}));
|
||||
run(&mut s, "variable.createCollection", json!({"name":"Theme"}));
|
||||
let variable = run(&mut s, "variable.create", json!({"collection":"Theme","name":"Accent","type":"color","value":"#ff0000"}))["id"].clone();
|
||||
run(&mut s, "variable.bind", json!({"id":b,"field":"fill","variable":variable}));
|
||||
let colors = collect(s.doc().unwrap(), &[a, b, c]);
|
||||
assert_eq!(colors.len(), 3);
|
||||
assert_eq!(colors[0].style.as_deref(), style.as_str());
|
||||
assert_eq!(colors[1].variable.as_deref(), variable.as_str());
|
||||
assert!(colors[2].style.is_none() && colors[2].variable.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradients_and_multiple_paints_are_edited_without_replacing_other_slots() {
|
||||
use figcraft_color::{Color, Gradient, GradientKind};
|
||||
let mut s = Session::new();
|
||||
let a = rectangle(&mut s, "#ff0000");
|
||||
let paint = Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::BLACK));
|
||||
run(&mut s, "fill.add", json!({"id":a,"paint":paint}));
|
||||
let colors = collect(s.doc().unwrap(), &[a]);
|
||||
assert_eq!(colors.len(), 2);
|
||||
let gradient = colors.iter().find(|g| g.paint == paint).unwrap();
|
||||
run(&mut s, "selection.setPaint", json!({"targets":gradient.targets,"paint":"#00ff00"}));
|
||||
let paints = &s.doc().unwrap().node(a).unwrap().fills;
|
||||
assert_eq!(paints.len(), 2);
|
||||
assert_eq!(paints[0].solid_color().unwrap().to_hex(), "FF0000");
|
||||
assert_eq!(paints[1].solid_color().unwrap().to_hex(), "00FF00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_and_boolean_operands_are_not_selection_colors() {
|
||||
let mut s = Session::new();
|
||||
let a = rectangle(&mut s, "#ff0000");
|
||||
let b = rectangle(&mut s, "#00ff00");
|
||||
let boolean = NodeId(run(&mut s, "bool.union", json!({"ids":[a,b]}))["id"].as_u64().unwrap());
|
||||
run(&mut s, "fill.set", json!({"id":boolean,"color":"#0000ff"}));
|
||||
let colors = collect(s.doc().unwrap(), &[boolean]);
|
||||
assert_eq!(colors.len(), 1);
|
||||
assert_eq!(colors[0].paint.solid_color().unwrap().to_hex(), "0000FF");
|
||||
s.doc_mut().unwrap().get_mut(boolean).unwrap().is_mask = true;
|
||||
assert!(collect(s.doc().unwrap(), &[boolean]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_edit_keeps_a_node_binding_that_other_characters_inherit() {
|
||||
let mut s = Session::new();
|
||||
let id = NodeId(run(&mut s, "text.create", json!({"text":"abc","props":{"fill":"#ff0000"}}))["id"].as_u64().unwrap());
|
||||
run(&mut s, "variable.createCollection", json!({"name":"Theme"}));
|
||||
let v = run(&mut s, "variable.create", json!({"collection":"Theme","name":"Brand","type":"color","value":"#ff0000"}))["id"].clone();
|
||||
run(&mut s, "variable.bind", json!({"id":id,"field":"fill","variable":v}));
|
||||
run(&mut s, "text.style", json!({"id":id,"start":1,"end":2,"textFills":["#0000ff"]}));
|
||||
let blue = group(&s, &[id], "0000FF");
|
||||
run(&mut s, "selection.setPaint", json!({"targets":blue.targets,"paint":"#00ff00"}));
|
||||
assert!(!s.doc().unwrap().node(id).unwrap().bound.is_empty());
|
||||
assert_eq!(fill(&s, id), "FF0000");
|
||||
assert_eq!(
|
||||
s.doc().unwrap().node(id).unwrap().kind.text().unwrap().style_at(1).fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(),
|
||||
"00FF00"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_color_edits_respect_read_only_version_previews() {
|
||||
let mut s = Session::new();
|
||||
let a = rectangle(&mut s, "#ff0000");
|
||||
run(&mut s, "file.saveVersion", json!({"name":"Before"}));
|
||||
run(&mut s, "version.preview", json!({"index":0}));
|
||||
let red = group(&s, &[a], "FF0000");
|
||||
let before = s.undo_depth();
|
||||
assert!(s.execute("selection.setPaint", &json!({"targets":red.targets,"paint":"#0000ff"})).is_err());
|
||||
assert_eq!(fill(&s, a), "FF0000");
|
||||
assert_eq!(s.undo_depth(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opacity_edits_keep_the_color_variable_binding() {
|
||||
let mut s = Session::new();
|
||||
let a = rectangle(&mut s, "#ff0000");
|
||||
run(&mut s, "variable.createCollection", json!({"name":"Theme"}));
|
||||
let v = run(&mut s, "variable.create", json!({"collection":"Theme","name":"Brand","type":"color","value":"#ff0000"}))["id"].clone();
|
||||
run(&mut s, "variable.bind", json!({"id":a,"field":"fill","variable":v}));
|
||||
let mut red = group(&s, &[a], "FF0000");
|
||||
red.paint.opacity = 0.5;
|
||||
run(&mut s, "selection.setPaint", json!({"targets":red.targets,"paint":red.paint}));
|
||||
let n = s.doc().unwrap().node(a).unwrap();
|
||||
assert_eq!(n.fills[0].opacity, 0.5);
|
||||
assert!(!n.bound.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_text_fill_keeps_a_color_style_link() {
|
||||
let mut s = Session::new();
|
||||
let id = NodeId(run(&mut s, "text.create", json!({"text":"Text","props":{"fill":"#ff0000"}}))["id"].as_u64().unwrap());
|
||||
let red = group(&s, &[id], "FF0000");
|
||||
assert_eq!(red.targets[0].range, None);
|
||||
let style = run(&mut s, "style.create", json!({"type":"paint","name":"Brand","value":"#0000ff"}))["id"].clone();
|
||||
run(&mut s, "selection.applyColorStyle", json!({"targets":red.targets,"style":style}));
|
||||
assert_eq!(s.doc().unwrap().node(id).unwrap().styles.fill.as_deref(), style.as_str());
|
||||
run(&mut s, "style.update", json!({"style":style,"value":"#00ff00"}));
|
||||
assert_eq!(fill(&s, id), "00FF00");
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
//! How auto layout frames and text size on import (hug or fixed), on encoded `.fig` files built in
|
||||
//! code and REST file JSON: the sizing a file records, an auto layout frame that records none, and
|
||||
//! text that hugs by its resize mode. Also how distributed main-axis alignment reads.
|
||||
|
||||
use figcraft_doc::{Document, MainAlign, Sizing};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::kiwi::{Kv, Schema};
|
||||
|
||||
const SCHEMA: &str = "
|
||||
enum NodeType NONE DOCUMENT CANVAS FRAME TEXT SYMBOL INSTANCE
|
||||
enum StackMode NONE HORIZONTAL VERTICAL
|
||||
enum StackSize FIXED RESIZE_TO_FIT RESIZE_TO_FIT_WITH_IMPLICIT_SIZE
|
||||
enum StackJustify MIN CENTER MAX SPACE_EVENLY SPACE_BETWEEN SPACE_AROUND SPACE_EVENLY_CSS
|
||||
enum TextAutoResize NONE WIDTH_AND_HEIGHT HEIGHT
|
||||
struct GUID uint sessionID, uint localID
|
||||
struct Vector float x, float y
|
||||
struct Matrix float m00, float m01, float m02, float m10, float m11, float m12
|
||||
message ParentIndex GUID guid, string position
|
||||
message TextData string characters
|
||||
message SymbolData GUID symbolID
|
||||
message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, StackMode stackMode, StackSize stackPrimarySizing, StackSize stackCounterSizing, StackJustify stackPrimaryAlignItems, TextAutoResize textAutoResize, float fontSize, TextData textData, SymbolData symbolData
|
||||
message Message NodeChange[] nodeChanges
|
||||
";
|
||||
|
||||
type Fields = Vec<(&'static str, Kv<'static>)>;
|
||||
|
||||
fn o(fields: Fields) -> Kv<'static> {
|
||||
Kv::Object(fields)
|
||||
}
|
||||
|
||||
fn g(id: u64) -> Kv<'static> {
|
||||
o(vec![("sessionID", Kv::Uint(1)), ("localID", Kv::Uint(id))])
|
||||
}
|
||||
|
||||
fn xy(x: f64, y: f64) -> Kv<'static> {
|
||||
o(vec![("x", Kv::Float(x as f32)), ("y", Kv::Float(y as f32))])
|
||||
}
|
||||
|
||||
fn at(x: f64, y: f64) -> Kv<'static> {
|
||||
let f = |v: f64| Kv::Float(v as f32);
|
||||
o(vec![("m00", f(1.0)), ("m01", f(0.0)), ("m02", f(x)), ("m10", f(0.0)), ("m11", f(1.0)), ("m12", f(y))])
|
||||
}
|
||||
|
||||
fn node(id: u64, parent: u64, pos: &str, ty: &'static str, name: &str, mut more: Fields) -> Kv<'static> {
|
||||
let mut v = vec![("guid", g(id)), ("parentIndex", o(vec![("guid", g(parent)), ("position", Kv::Str(pos.into()))]))];
|
||||
v.extend([("type", Kv::Enum(ty)), ("name", Kv::Str(name.into()))]);
|
||||
v.append(&mut more);
|
||||
o(v)
|
||||
}
|
||||
|
||||
/// An auto layout frame's fields: its direction and the sizing it records (`None`: none).
|
||||
fn stack(mode: &'static str, primary: Option<&'static str>, counter: Option<&'static str>) -> Fields {
|
||||
let mut f = vec![("stackMode", Kv::Enum(mode))];
|
||||
f.extend(primary.map(|p| ("stackPrimarySizing", Kv::Enum(p))));
|
||||
f.extend(counter.map(|c| ("stackCounterSizing", Kv::Enum(c))));
|
||||
f
|
||||
}
|
||||
|
||||
/// A text layer "<name> text" in `parent`, with resize mode `resize` (`None`: none recorded).
|
||||
fn label(id: u64, parent: u64, name: &str, resize: Option<&'static str>) -> Kv<'static> {
|
||||
let mut f = vec![("size", xy(40.0, 16.0)), ("transform", at(0.0, 0.0)), ("fontSize", Kv::Float(12.0))];
|
||||
f.push(("textData", o(vec![("characters", Kv::Str(name.into()))])));
|
||||
f.extend(resize.map(|r| ("textAutoResize", Kv::Enum(r))));
|
||||
node(id, parent, "!", "TEXT", &format!("{name} text"), f)
|
||||
}
|
||||
|
||||
/// A component at `x` holding a text layer, with `fields` of its own.
|
||||
fn component(id: u64, x: f64, name: &str, fields: Fields, resize: Option<&'static str>) -> Vec<Kv<'static>> {
|
||||
let mut f = vec![("size", xy(40.0, 16.0)), ("transform", at(x, 0.0))];
|
||||
f.extend(fields);
|
||||
vec![node(id, 1, &format!("{id}"), "SYMBOL", name, f), label(id + 1, id, name, resize)]
|
||||
}
|
||||
|
||||
fn import(nodes: Vec<Kv<'static>>) -> Document {
|
||||
let mut all = vec![
|
||||
o(vec![
|
||||
("guid", o(vec![("sessionID", Kv::Uint(0)), ("localID", Kv::Uint(0))])),
|
||||
("type", Kv::Enum("DOCUMENT")),
|
||||
("name", Kv::Str("D".into())),
|
||||
]),
|
||||
o(vec![
|
||||
("guid", g(1)),
|
||||
("parentIndex", o(vec![("guid", o(vec![("sessionID", Kv::Uint(0)), ("localID", Kv::Uint(0))])), ("position", Kv::Str("!".into()))])),
|
||||
("type", Kv::Enum("CANVAS")),
|
||||
("name", Kv::Str("Page".into())),
|
||||
]),
|
||||
];
|
||||
all.extend(nodes);
|
||||
let schema = Schema::parse(SCHEMA).unwrap();
|
||||
let bytes = super::tests::canvas(&schema, &o(vec![("nodeChanges", Kv::Array(all))]));
|
||||
super::import(&bytes, "sizing").unwrap().0
|
||||
}
|
||||
|
||||
fn sizing(doc: &Document, name: &str) -> (Sizing, Sizing) {
|
||||
let n = doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no layer {name}"));
|
||||
(n.child_layout.sizing_h, n.child_layout.sizing_v)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_layout_frames_hug_as_recorded_and_by_default_on_their_primary_axis() {
|
||||
use Sizing::{Fixed, Hug};
|
||||
let fit = Some("RESIZE_TO_FIT");
|
||||
// (component, its auto layout fields, expected (horizontal, vertical), derivation)
|
||||
let rows: Vec<(&str, Fields, (Sizing, Sizing), &str)> = vec![
|
||||
("Button", stack("HORIZONTAL", fit, fit), (Hug, Hug), "both axes hug, as recorded"),
|
||||
("Implicit", stack("HORIZONTAL", Some("RESIZE_TO_FIT_WITH_IMPLICIT_SIZE"), fit), (Hug, Hug), "hugging with an implicit size"),
|
||||
("Fixed", stack("HORIZONTAL", Some("FIXED"), Some("FIXED")), (Fixed, Fixed), "both axes fixed, as recorded"),
|
||||
("Legacy", stack("HORIZONTAL", None, fit), (Hug, Hug), "no primary sizing: the primary axis sizes by its content"),
|
||||
("Legacy column", stack("VERTICAL", None, None), (Fixed, Hug), "no sizing: the primary (vertical) axis hugs, the counter axis is fixed"),
|
||||
];
|
||||
let mut nodes = Vec::new();
|
||||
for (k, (name, fields, _, _)) in rows.iter().enumerate() {
|
||||
nodes.extend(component(10 * (k as u64 + 1), 60.0 * k as f64, name, fields.clone(), Some("WIDTH_AND_HEIGHT")));
|
||||
}
|
||||
let doc = import(nodes);
|
||||
for (name, _, want, why) in rows {
|
||||
assert_eq!(sizing(&doc, name), want, "{name}: {why}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_hugs_by_its_resize_mode() {
|
||||
use Sizing::{Fixed, Hug};
|
||||
let fit = Some("RESIZE_TO_FIT");
|
||||
let mut nodes = Vec::new();
|
||||
for (k, (name, resize)) in
|
||||
[("Auto width", Some("WIDTH_AND_HEIGHT")), ("Auto height", Some("HEIGHT")), ("Fixed box", Some("NONE")), ("Unset", None)]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
nodes.extend(component(10 * (k as u64 + 1), 60.0 * k as f64, name, stack("HORIZONTAL", fit, fit), resize));
|
||||
}
|
||||
// An instance of the auto width one: it hugs, and so does its copy of the text.
|
||||
nodes.push(node(
|
||||
90,
|
||||
1,
|
||||
"~",
|
||||
"INSTANCE",
|
||||
"Auto width instance",
|
||||
[vec![("size", xy(40.0, 16.0)), ("transform", at(0.0, 100.0)), ("symbolData", o(vec![("symbolID", g(10))]))], stack("HORIZONTAL", fit, fit)]
|
||||
.concat(),
|
||||
));
|
||||
// One that records nothing of its auto layout: it has its main's.
|
||||
nodes.push(node(
|
||||
91,
|
||||
1,
|
||||
"~~",
|
||||
"INSTANCE",
|
||||
"Bare instance",
|
||||
vec![("size", xy(40.0, 16.0)), ("transform", at(0.0, 150.0)), ("symbolData", o(vec![("symbolID", g(10))]))],
|
||||
));
|
||||
let doc = import(nodes);
|
||||
for (name, want) in
|
||||
[("Auto width text", (Hug, Hug)), ("Auto height text", (Fixed, Hug)), ("Fixed box text", (Fixed, Fixed)), ("Unset text", (Fixed, Fixed))]
|
||||
{
|
||||
assert_eq!(sizing(&doc, name), want, "{name}");
|
||||
}
|
||||
let inst = doc.nodes().find(|n| n.name == "Auto width instance").unwrap();
|
||||
assert_eq!((inst.child_layout.sizing_h, inst.child_layout.sizing_v), (Hug, Hug));
|
||||
let copy = inst.children.first().and_then(|c| doc.node(*c)).unwrap();
|
||||
assert_eq!((copy.child_layout.sizing_h, copy.child_layout.sizing_v), (Hug, Hug));
|
||||
let bare = doc.nodes().find(|n| n.name == "Bare instance").unwrap();
|
||||
assert_eq!(bare.layout.as_ref().map(|l| l.mode), Some(figcraft_doc::LayoutMode::Horizontal));
|
||||
assert_eq!((bare.child_layout.sizing_h, bare.child_layout.sizing_v), (Hug, Hug));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rest_files_read_left_out_sizing_modes_as_hug_and_text_by_its_resize_mode() {
|
||||
use Sizing::{Fixed, Hug};
|
||||
let at = json!([[1, 0, 0], [0, 1, 0]]);
|
||||
let text = |id: &str, name: &str, more: Value| {
|
||||
let mut t = json!({"id": id, "name": name, "type": "TEXT", "relativeTransform": at, "size": {"x": 40, "y": 16}, "characters": name,
|
||||
"style": {"fontFamily": "Inter", "fontSize": 12, "textAutoResize": "WIDTH_AND_HEIGHT"}});
|
||||
t.as_object_mut().unwrap().extend(more.as_object().unwrap().clone());
|
||||
t
|
||||
};
|
||||
let frame = |id: &str, name: &str, more: Value, kids: Value| {
|
||||
let mut f = json!({"id": id, "name": name, "type": "COMPONENT", "relativeTransform": at, "size": {"x": 40, "y": 16},
|
||||
"layoutMode": "HORIZONTAL", "children": kids});
|
||||
f.as_object_mut().unwrap().extend(more.as_object().unwrap().clone());
|
||||
f
|
||||
};
|
||||
let file = json!({"name": "Sizing", "document": {"id": "0:0", "type": "DOCUMENT", "children": [{"id": "0:1", "name": "Page", "type": "CANVAS", "children": [
|
||||
frame("1:1", "Defaults", json!({}), json!([text("1:2", "Label", json!({}))])),
|
||||
frame("2:1", "Fixed", json!({"primaryAxisSizingMode": "FIXED", "counterAxisSizingMode": "FIXED"}),
|
||||
json!([text("2:2", "Fixed label", json!({"layoutSizingHorizontal": "FIXED"}))])),
|
||||
]}]}});
|
||||
let (doc, _) = crate::rest::import(&file, &crate::rest::no_images).unwrap();
|
||||
// The REST API leaves out the default sizing modes (AUTO); explicit per-axis sizing wins.
|
||||
for (name, want) in [("Defaults", (Hug, Hug)), ("Label", (Hug, Hug)), ("Fixed", (Fixed, Fixed)), ("Fixed label", (Fixed, Hug))] {
|
||||
assert_eq!(sizing(&doc, name), want, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distributed_alignment_reads_the_older_evenly_name_as_space_between() {
|
||||
let rows = [
|
||||
("Between", "SPACE_BETWEEN", MainAlign::SpaceBetween),
|
||||
("Older evenly", "SPACE_EVENLY", MainAlign::SpaceBetween),
|
||||
("Around", "SPACE_AROUND", MainAlign::SpaceAround),
|
||||
("Evenly", "SPACE_EVENLY_CSS", MainAlign::SpaceEvenly),
|
||||
];
|
||||
let nodes = rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(i, (name, align, _))| {
|
||||
let mut fields = stack("HORIZONTAL", None, None);
|
||||
fields.push(("stackPrimaryAlignItems", Kv::Enum(align)));
|
||||
component(10 + 2 * i as u64, 50.0 * i as f64, name, fields, None)
|
||||
})
|
||||
.collect();
|
||||
let doc = import(nodes);
|
||||
for (name, _, want) in rows {
|
||||
let n = doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no layer {name}"));
|
||||
assert_eq!(n.layout.as_ref().map(|l| l.main_align), Some(want), "{name}");
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@ enum StrokeJoin MITER=0 BEVEL=1 ROUND=2
|
||||
enum ConstraintType MIN=0 CENTER=1 MAX=2 STRETCH=3 SCALE=4
|
||||
enum StackMode NONE=0 HORIZONTAL=1 VERTICAL=2 GRID=3
|
||||
enum StackAlign MIN=0 CENTER=1 MAX=2 BASELINE=3
|
||||
enum StackJustify MIN=0 CENTER=1 MAX=2 SPACE_EVENLY=3 SPACE_BETWEEN=4 SPACE_AROUND=5
|
||||
enum StackJustify MIN=0 CENTER=1 MAX=2 SPACE_EVENLY=3 SPACE_BETWEEN=4 SPACE_AROUND=5 SPACE_EVENLY_CSS=6
|
||||
enum StackCounterAlign MIN=0 CENTER=1 MAX=2 STRETCH=3 AUTO=4 BASELINE=5
|
||||
enum StackSize FIXED=0 RESIZE_TO_FIT=1
|
||||
enum StackWrap NO_WRAP=0 WRAP=1
|
||||
@@ -1371,7 +1371,7 @@ impl<'d> Exporter<'d> {
|
||||
MainAlign::Max => "MAX",
|
||||
MainAlign::SpaceBetween => "SPACE_BETWEEN",
|
||||
MainAlign::SpaceAround => "SPACE_AROUND",
|
||||
MainAlign::SpaceEvenly => "SPACE_EVENLY",
|
||||
MainAlign::SpaceEvenly => "SPACE_EVENLY_CSS",
|
||||
};
|
||||
let cross = match l.cross_align {
|
||||
CrossAlign::Min => "MIN",
|
||||
@@ -1858,12 +1858,13 @@ impl<'d> Exporter<'d> {
|
||||
let named = set.is_none_or(|s| s.name != n.name);
|
||||
let root_mine: Fields = f.iter().filter(|(k, _)| !OWN.contains(k) && (named || *k != "name")).cloned().collect();
|
||||
// Each layer below: its id path, the fields that differ from the main's layer, that layer's
|
||||
// fields, and the component a nested instance was swapped for.
|
||||
// fields, whether its size is its own (a nested instance, a size override) and the
|
||||
// component a nested instance was swapped for.
|
||||
struct Row {
|
||||
path: Kv<'static>,
|
||||
changed: Fields,
|
||||
theirs: Fields,
|
||||
nested: bool,
|
||||
sized: bool,
|
||||
swap: Option<Kv<'static>>,
|
||||
}
|
||||
let mut rows: Vec<Row> = Vec::new();
|
||||
@@ -1891,8 +1892,8 @@ impl<'d> Exporter<'d> {
|
||||
(NodeKind::Instance { component, .. }, _) => self.guid_of(*component),
|
||||
_ => None,
|
||||
};
|
||||
let nested = matches!(d.kind, NodeKind::Instance { .. });
|
||||
rows.push(Row { path: o(vec![("guids", arr(guids))]), changed: changed(mine, &theirs), theirs, nested, swap });
|
||||
let sized = matches!(d.kind, NodeKind::Instance { .. }) || d.overrides.contains("size");
|
||||
rows.push(Row { path: o(vec![("guids", arr(guids))]), changed: changed(mine, &theirs), theirs, sized, swap });
|
||||
}
|
||||
// An instance scaled as a whole (the Scale tool) is `factor` times its main in every length:
|
||||
// sizes, places and stroke weights, its own and its layers'. A resized one keeps its weights.
|
||||
@@ -1927,8 +1928,8 @@ impl<'d> Exporter<'d> {
|
||||
let (mut of, mut df): (Fields, Fields) = (vec![("guidPath", r.path.clone())], vec![("guidPath", r.path)]);
|
||||
for (k, v) in r.changed {
|
||||
let follows = follows(k, &v, &r.theirs);
|
||||
// A nested instance's size is its own to set.
|
||||
if follows && r.nested && k == "size" {
|
||||
// A nested instance's size is its own to set, as is a size the layer overrides.
|
||||
if follows && r.sized && k == "size" {
|
||||
of.push((k, v.clone()));
|
||||
}
|
||||
if follows { &mut df } else { &mut of }.push((k, v));
|
||||
|
||||
@@ -595,6 +595,17 @@ fn normalize(n: &mut Node, ix: &dyn Fn(NodeId) -> NodeId) {
|
||||
n.source = None;
|
||||
n.overrides.clear();
|
||||
n.constrain_proportions = false;
|
||||
// A text's resize mode is its sizing (the editor keeps them together; generated documents
|
||||
// don't): auto width hugs both axes, auto height its height.
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
let cl = &mut n.child_layout;
|
||||
let hug = |s: &mut Sizing| *s = if *s == Sizing::Fixed { Sizing::Hug } else { *s };
|
||||
match t.auto_resize {
|
||||
TextAutoResize::WidthAndHeight => [&mut cl.sizing_h, &mut cl.sizing_v].into_iter().for_each(hug),
|
||||
TextAutoResize::Height => hug(&mut cl.sizing_v),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let fold = |ps: &mut Vec<Paint>| {
|
||||
for p in ps.iter_mut() {
|
||||
if let PaintKind::Solid { color } = &mut p.kind {
|
||||
@@ -619,9 +630,6 @@ fn normalize(n: &mut Node, ix: &dyn Fn(NodeId) -> NodeId) {
|
||||
}
|
||||
}
|
||||
if let Some(l) = &mut n.layout {
|
||||
if matches!(l.main_align, MainAlign::SpaceAround | MainAlign::SpaceEvenly) {
|
||||
l.main_align = MainAlign::SpaceBetween;
|
||||
}
|
||||
// Track counts only matter (and are only written) for grids.
|
||||
if l.mode != LayoutMode::Grid {
|
||||
let d = AutoLayout::default();
|
||||
|
||||
@@ -860,6 +860,8 @@ impl<'a> Mapper<'a> {
|
||||
"wireFrame": own == "FRAME",
|
||||
"wireInstance": own == "INSTANCE",
|
||||
"slotContent": inst.is_some_and(|i| i.slot_content),
|
||||
// A size the instance gives this layer itself (kept when the instance relays out).
|
||||
"sizeOverride": entries.iter().any(|e| e.entry == Entry::Override && e.row.get("size").is_some()),
|
||||
}),
|
||||
);
|
||||
o.insert("type".into(), json!(ty));
|
||||
@@ -922,7 +924,16 @@ impl<'a> Mapper<'a> {
|
||||
} else if ty == "INSTANCE" {
|
||||
if let Some((sub, sym)) = self.instance(&mut o, &view, id, inst, &rest_id) {
|
||||
let main = self.nodes.get(&sym).map(|m| View::of(m));
|
||||
let fit = Fit::frame(main.and_then(|m| m.size()), view.size());
|
||||
let fit = Fit::frame(main.as_ref().and_then(View::size), view.size());
|
||||
// An instance that records no auto layout of its own has its main's.
|
||||
let view = match main {
|
||||
Some(m) if view.get("stackMode").is_none() && m.get("stackMode").is_some() => {
|
||||
let layered = View { fitted: view.fitted, ..m.with(view.layers.iter().copied()) };
|
||||
self.auto_layout(&mut o, &layered);
|
||||
layered
|
||||
}
|
||||
_ => view.clone(),
|
||||
};
|
||||
for c in self.children(&sym) {
|
||||
// The main's branch can live outside the cached context being mapped.
|
||||
kids.extend(self.layer_filtered(&c, Some(&sub), child_space, Some(&view), fit, depth + 1, None));
|
||||
@@ -1393,7 +1404,10 @@ impl<'a> Mapper<'a> {
|
||||
let main = match v.str("stackPrimaryAlignItems").or_else(|| v.str("stackJustify")) {
|
||||
Some("CENTER") => "CENTER",
|
||||
Some("MAX") => "MAX",
|
||||
Some("SPACE_BETWEEN" | "SPACE_EVENLY" | "SPACE_AROUND" | "SPACE_EVENLY_CSS") => "SPACE_BETWEEN",
|
||||
Some("SPACE_AROUND") => "SPACE_AROUND",
|
||||
// `SPACE_EVENLY` is the format's older name for equal gaps without edge space.
|
||||
Some("SPACE_EVENLY_CSS") => "SPACE_EVENLY",
|
||||
Some("SPACE_BETWEEN" | "SPACE_EVENLY") => "SPACE_BETWEEN",
|
||||
_ => "MIN",
|
||||
};
|
||||
o.insert("primaryAxisAlignItems".into(), json!(main));
|
||||
@@ -1405,7 +1419,10 @@ impl<'a> Mapper<'a> {
|
||||
};
|
||||
o.insert("counterAxisAlignItems".into(), json!(cross));
|
||||
let sizing = |k: &str| if v.str(k).is_some_and(|s| s.starts_with("RESIZE_TO_FIT")) { "AUTO" } else { "FIXED" };
|
||||
o.insert("primaryAxisSizingMode".into(), json!(sizing("stackPrimarySizing")));
|
||||
// A flow that records no sizing for its primary axis sizes it by its content: auto layout
|
||||
// did before that axis could be fixed.
|
||||
let primary = if v.get("stackPrimarySizing").is_none() && mode != "GRID" { "AUTO" } else { sizing("stackPrimarySizing") };
|
||||
o.insert("primaryAxisSizingMode".into(), json!(primary));
|
||||
o.insert("counterAxisSizingMode".into(), json!(sizing("stackCounterSizing")));
|
||||
if let Some(b) = v.flag("bordersTakeSpace") {
|
||||
o.insert("strokesIncludedInLayout".into(), json!(b));
|
||||
|
||||
@@ -13,6 +13,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 auto_layout_sizing_tests;
|
||||
#[cfg(test)]
|
||||
mod connector_label_tests;
|
||||
mod export;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
//! REST import of grid track sizes (`gridColumnsSizing`, `gridRowsSizing`: CSS
|
||||
//! `grid-template-columns`/`-rows` strings). Fixtures are synthetic, written by hand.
|
||||
|
||||
use super::*;
|
||||
use figcraft_doc::GridTrack;
|
||||
use serde_json::json;
|
||||
|
||||
fn tracks(columns: Value, rows: Value) -> (Vec<GridTrack>, Vec<GridTrack>) {
|
||||
let file = json!({"name": "Fixture", "document": {"id": "0:0", "name": "Document", "type": "DOCUMENT", "children": [
|
||||
{"id": "0:1", "name": "Page", "type": "CANVAS", "children": [
|
||||
{"id": "1", "name": "Grid", "type": "FRAME", "relativeTransform": [[1, 0, 0], [0, 1, 0]], "size": {"x": 400, "y": 200},
|
||||
"layoutMode": "GRID", "gridColumnCount": 7, "gridRowCount": 2, "gridColumnsSizing": columns, "gridRowsSizing": rows}
|
||||
]}
|
||||
]}});
|
||||
let (doc, _) = import(&file, &no_images).unwrap();
|
||||
let layout = doc.nodes().find(|n| n.name == "Grid").unwrap().layout.clone().unwrap();
|
||||
(layout.column_tracks, layout.row_tracks)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_track_strings_set_the_track_sizes() {
|
||||
let (cols, rows) = tracks(json!("120px 2fr auto"), json!("repeat(2, minmax(0, 1fr))"));
|
||||
assert_eq!(cols, [GridTrack::fixed(120.0), GridTrack::fill(2.0), GridTrack::hug()]);
|
||||
assert_eq!(rows, [GridTrack::fill(1.0); 2]);
|
||||
let (cols, rows) = tracks(json!(" repeat(2, 40px 1.5fr) minmax(64px, 64px) "), json!("min-content max-content"));
|
||||
assert_eq!(cols, [GridTrack::fixed(40.0), GridTrack::fill(1.5), GridTrack::fixed(40.0), GridTrack::fill(1.5), GridTrack::fixed(64.0)]);
|
||||
assert_eq!(rows, [GridTrack::hug(); 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_or_junk_tracks_are_equal_tracks() {
|
||||
// Ranges the grid can't express, units it doesn't have and junk keep their place as 1fr.
|
||||
let (cols, rows) = tracks(json!("minmax(10px, 80px) 5em -3px 0fr NaNpx repeat(x, 1px) 10px"), json!(7));
|
||||
assert_eq!(cols, [[GridTrack::default(); 6].as_slice(), &[GridTrack::fixed(10.0)]].concat());
|
||||
assert!(rows.is_empty(), "a non-string is ignored");
|
||||
// Tracks past the grid's own count are left out; huge repeats and deep nesting stay bounded.
|
||||
let (cols, rows) = tracks(json!(format!("repeat(999999999999, 1px) {}", "minmax(".repeat(10_000))), json!("1px 2px 3px"));
|
||||
assert_eq!(cols, [GridTrack::fixed(1.0); 7]);
|
||||
assert_eq!(rows, [GridTrack::fixed(1.0), GridTrack::fixed(2.0)]);
|
||||
let (cols, rows) = tracks(json!("1e9px"), json!("( ) )) repeat(2,)"));
|
||||
assert_eq!((cols, rows.len()), (vec![GridTrack::default()], 2));
|
||||
}
|
||||
@@ -29,6 +29,8 @@ mod fig_sources;
|
||||
#[cfg(test)]
|
||||
mod gamepad_tests;
|
||||
#[cfg(test)]
|
||||
mod grid_track_tests;
|
||||
#[cfg(test)]
|
||||
mod import_regression_tests;
|
||||
pub(crate) mod props;
|
||||
pub(crate) mod proto;
|
||||
@@ -481,9 +483,11 @@ impl<'a> Ctx<'a> {
|
||||
let id = self.doc.alloc_id();
|
||||
let label = kind.label();
|
||||
let mut n = Node::new(id, kind, text(j, "name").map_or(label, clip_str), w, h);
|
||||
if self.fig_sources.is_some() && j.get(FIG_SOURCE_FIELD).and_then(|source| source.get("slotOverride")).and_then(Value::as_bool) == Some(true)
|
||||
{
|
||||
n.overrides.insert("slot".into());
|
||||
// Overrides the .fig names explicitly (the rest are found by comparing with the main).
|
||||
for (flag, group) in [("slotOverride", "slot"), ("sizeOverride", "size")] {
|
||||
if self.fig_sources.is_some() && j.get(FIG_SOURCE_FIELD).and_then(|source| source.get(flag)).and_then(Value::as_bool) == Some(true) {
|
||||
n.overrides.insert(group.into());
|
||||
}
|
||||
}
|
||||
n.transform = local(space.world, world);
|
||||
self.common(&mut n, j, parent_mode);
|
||||
@@ -1152,6 +1156,16 @@ fn override_group(field: &str) -> Option<&'static str> {
|
||||
| "textDecorationSkipInk"
|
||||
| "textDecorationColor" => "textStyle",
|
||||
"width" | "height" | "size" => "size",
|
||||
"layoutSizingHorizontal"
|
||||
| "layoutSizingVertical"
|
||||
| "layoutGrow"
|
||||
| "layoutAlign"
|
||||
| "primaryAxisSizingMode"
|
||||
| "counterAxisSizingMode"
|
||||
| "minWidth"
|
||||
| "maxWidth"
|
||||
| "minHeight"
|
||||
| "maxHeight" => "resizing",
|
||||
"paddingLeft" | "paddingRight" | "paddingTop" | "paddingBottom" | "horizontalPadding" | "verticalPadding" => "padding",
|
||||
"itemSpacing" | "counterAxisSpacing" => "gap",
|
||||
"primaryAxisAlignItems" | "counterAxisAlignItems" => "alignment",
|
||||
|
||||
@@ -424,6 +424,7 @@ pub(super) fn auto_layout(v: &Value, fig_tracks: bool) -> Option<AutoLayout> {
|
||||
let pad = |k: &str| len(v, k).unwrap_or(0.0);
|
||||
let count = |k: &str, d: u32| num(v, k).filter(|n| *n >= 1.0).map_or(d, |n| n.min(1000.0) as u32);
|
||||
let grid = mode == LayoutMode::Grid;
|
||||
let (columns, rows) = (count("gridColumnCount", 2), count("gridRowCount", 2));
|
||||
Some(AutoLayout {
|
||||
mode,
|
||||
wrap: text(v, "layoutWrap") == Some("WRAP"),
|
||||
@@ -448,15 +449,84 @@ pub(super) fn auto_layout(v: &Value, fig_tracks: bool) -> Option<AutoLayout> {
|
||||
Some("SPACE_BETWEEN") => ContentAlign::SpaceBetween,
|
||||
_ => ContentAlign::Auto,
|
||||
},
|
||||
grid_columns: count("gridColumnCount", 2),
|
||||
grid_rows: count("gridRowCount", 2),
|
||||
grid_columns: columns,
|
||||
grid_rows: rows,
|
||||
strokes_included: flag(v, "strokesIncludedInLayout").unwrap_or(false),
|
||||
reverse_z: flag(v, "itemReverseZIndex").unwrap_or(false),
|
||||
column_tracks: if fig_tracks && grid { fig_grid_tracks(v, "columnTracks") } else { Vec::new() },
|
||||
row_tracks: if fig_tracks && grid { fig_grid_tracks(v, "rowTracks") } else { Vec::new() },
|
||||
column_tracks: grid_tracks(v, grid, fig_tracks, "columnTracks", "gridColumnsSizing", columns),
|
||||
row_tracks: grid_tracks(v, grid, fig_tracks, "rowTracks", "gridRowsSizing", rows),
|
||||
})
|
||||
}
|
||||
|
||||
/// A grid axis's track sizes: from the `.fig` source when there is one, else the API's CSS
|
||||
/// `grid-template-*` string (at most `count` tracks, the axis's own).
|
||||
fn grid_tracks(v: &Value, grid: bool, fig_tracks: bool, fig_axis: &str, css_key: &str, count: u32) -> Vec<GridTrack> {
|
||||
match (grid, fig_tracks) {
|
||||
(false, _) => Vec::new(),
|
||||
(true, true) => fig_grid_tracks(v, fig_axis),
|
||||
(true, false) => text(v, css_key).map(|css| css_grid_tracks(css, count as usize)).unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The first `max` tracks of a CSS `grid-template-columns`/`-rows` value: `120px`, `2fr`, `auto`
|
||||
/// (hug), `minmax(0, 1fr)` and `repeat(3, …)`; any other track is an equal (1fr) one.
|
||||
fn css_grid_tracks(css: &str, max: usize) -> Vec<GridTrack> {
|
||||
let mut out = Vec::new();
|
||||
for item in css_split(css, char::is_whitespace) {
|
||||
let repeated = item.strip_prefix("repeat(").and_then(|r| r.strip_suffix(')')).and_then(|r| {
|
||||
let (count, tracks) = r.split_once(',')?;
|
||||
Some((count.trim().parse::<usize>().ok()?, css_split(tracks, char::is_whitespace)))
|
||||
});
|
||||
let (count, tracks) = repeated.unwrap_or((1, vec![item]));
|
||||
for t in tracks.iter().cycle().take(count.saturating_mul(tracks.len())) {
|
||||
if out.len() >= max {
|
||||
return out;
|
||||
}
|
||||
out.push(css_track(t));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn css_track(t: &str) -> GridTrack {
|
||||
let Some(args) = t.strip_prefix("minmax(").and_then(|r| r.strip_suffix(')')) else { return css_size(t).unwrap_or_default() };
|
||||
// A flexible maximum is the track's share whatever its minimum; other ranges only when both ends agree.
|
||||
match css_split(args, |c| c == ',').iter().map(|a| css_size(a.trim())).collect::<Vec<_>>()[..] {
|
||||
[_, Some(max)] if max.sizing == Sizing::Fill => max,
|
||||
[Some(min), Some(max)] if min == max => min,
|
||||
_ => GridTrack::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn css_size(t: &str) -> Option<GridTrack> {
|
||||
let n = |s: &str| s.parse::<f64>().ok().filter(|n| n.is_finite() && (0.0..=MAX_SIZE).contains(n));
|
||||
if let Some(px) = t.strip_suffix("px").and_then(n) {
|
||||
Some(GridTrack::fixed(px))
|
||||
} else if let Some(fr) = t.strip_suffix("fr").and_then(n) {
|
||||
(fr > 0.0).then(|| GridTrack::fill(fr))
|
||||
} else {
|
||||
matches!(t, "auto" | "min-content" | "max-content").then(GridTrack::hug)
|
||||
}
|
||||
}
|
||||
|
||||
/// `s` split at the `sep` characters outside parentheses, empty parts dropped.
|
||||
fn css_split(s: &str, sep: fn(char) -> bool) -> Vec<&str> {
|
||||
let (mut parts, mut depth, mut start) = (Vec::new(), 0usize, 0);
|
||||
for (i, c) in s.char_indices() {
|
||||
match c {
|
||||
'(' => depth += 1,
|
||||
')' => depth = depth.saturating_sub(1),
|
||||
c if depth == 0 && sep(c) => {
|
||||
parts.extend(s.get(start..i).filter(|p| !p.trim().is_empty()));
|
||||
start = i + c.len_utf8();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
parts.extend(s.get(start..).filter(|p| !p.trim().is_empty()));
|
||||
parts
|
||||
}
|
||||
|
||||
fn fig_grid_tracks(v: &Value, axis: &str) -> Vec<GridTrack> {
|
||||
v.get(crate::rest::FIG_SOURCE_FIELD)
|
||||
.and_then(|source| source.get("gridTracks"))
|
||||
@@ -484,8 +554,17 @@ pub(super) fn child_layout(v: &Value, own: Option<LayoutMode>, parent_mode: Opti
|
||||
let mut cl = ChildLayout::default();
|
||||
let horizontal_main = |m: LayoutMode| m != LayoutMode::Vertical;
|
||||
// Legacy fields first, the explicit per-axis sizing wins.
|
||||
// A text's resize mode is its sizing: auto width hugs both axes, auto height its height.
|
||||
if text(v, "type") == Some("TEXT") && v.get("style").and_then(|s| text(s, "textTruncation")) != Some("ENDING") {
|
||||
match v.get("style").and_then(|s| text(s, "textAutoResize")) {
|
||||
Some("WIDTH_AND_HEIGHT") => (cl.sizing_h, cl.sizing_v) = (Sizing::Hug, Sizing::Hug),
|
||||
Some("HEIGHT") => cl.sizing_v = Sizing::Hug,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(m) = own {
|
||||
let hug = |k: &str| text(v, k) == Some("AUTO");
|
||||
// Both default to AUTO (hug) when the file leaves them out.
|
||||
let hug = |k: &str| text(v, k) != Some("FIXED");
|
||||
let (main, cross) = (hug("primaryAxisSizingMode"), hug("counterAxisSizingMode"));
|
||||
let (h, w) = if horizontal_main(m) { (main, cross) } else { (cross, main) };
|
||||
if h {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
//! FIG export + import keeps all three distributed main-axis alignments distinct.
|
||||
|
||||
use figcraft_doc::{AutoLayout, Document, MainAlign, NodeKind};
|
||||
use figcraft_format::fig;
|
||||
|
||||
#[test]
|
||||
fn distributed_alignment_round_trip() {
|
||||
let mut results = Vec::new();
|
||||
for expected in [MainAlign::SpaceBetween, MainAlign::SpaceAround, MainAlign::SpaceEvenly] {
|
||||
let mut doc = Document::new("Alignment");
|
||||
let page = doc.first_page().unwrap();
|
||||
let frame = doc.create(page, NodeKind::Frame, "Frame", 0.0, 0.0, 200.0, 40.0).unwrap();
|
||||
doc.node_mut(frame).unwrap().layout = Some(AutoLayout { main_align: expected, ..Default::default() });
|
||||
let (native, _) = figcraft_format::load_bytes(&figcraft_format::save_bytes(&doc, None).unwrap()).unwrap();
|
||||
assert_eq!(native.node(frame).unwrap().layout.as_ref().unwrap().main_align, expected);
|
||||
let (bytes, export_warnings) = fig::export(&doc, None).unwrap();
|
||||
let (imported, import_warnings) = fig::import(&bytes, "Alignment").unwrap();
|
||||
let actual = imported.nodes().find(|n| n.name == "Frame").unwrap().layout.as_ref().unwrap().main_align;
|
||||
assert!(export_warnings.is_empty());
|
||||
assert!(import_warnings.is_empty());
|
||||
results.push((expected, actual));
|
||||
}
|
||||
assert!(results.iter().all(|(expected, actual)| expected == actual), "{results:?}");
|
||||
}
|
||||
@@ -22,10 +22,9 @@ pub trait Backend {
|
||||
fn describe(&self) -> String;
|
||||
|
||||
/// PNG bytes of the document: `ids` (in page coordinates, their union) or, when empty, the
|
||||
/// current page's content. The default goes through `ui.render` and a temporary file.
|
||||
/// current page's content. The default goes through `ui.render`, the PNG coming back as data.
|
||||
fn render_png(&mut self, ids: &[u64], scale: f64) -> Result<Vec<u8>, String> {
|
||||
let path = temp_png("render");
|
||||
let mut params = json!({"path": path, "scale": scale});
|
||||
let mut params = json!({"scale": scale});
|
||||
if !ids.is_empty() {
|
||||
params["ids"] = json!(ids);
|
||||
} else {
|
||||
@@ -36,8 +35,7 @@ pub trait Backend {
|
||||
}
|
||||
params["ids"] = json!(kids);
|
||||
}
|
||||
self.call("ui.render", params)?;
|
||||
read_and_remove(&path)
|
||||
decode_data(&self.call("ui.render", params)?)
|
||||
}
|
||||
|
||||
/// PNG bytes of what the user sees: the app window when attached, the current viewport when
|
||||
@@ -52,11 +50,10 @@ pub trait Backend {
|
||||
}
|
||||
}
|
||||
|
||||
fn capture<B: Backend + ?Sized>(b: &mut B, mut params: Value) -> Result<Vec<u8>, String> {
|
||||
let path = temp_png("screenshot");
|
||||
params["path"] = json!(path);
|
||||
b.call("ui.screenshot", params)?;
|
||||
read_and_remove(&path)
|
||||
/// A window capture, the PNG coming back as data (no file: an app confined to automation roots
|
||||
/// writes none outside them).
|
||||
fn capture<B: Backend + ?Sized>(b: &mut B, params: Value) -> Result<Vec<u8>, String> {
|
||||
decode_data(&b.call("ui.screenshot", params)?)
|
||||
}
|
||||
|
||||
/// The ids of the current page's top-level layers.
|
||||
@@ -72,17 +69,6 @@ pub fn page_children<B: Backend + ?Sized>(b: &mut B) -> Result<Vec<u64>, String>
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
fn temp_png(what: &str) -> String {
|
||||
let n = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
|
||||
std::env::temp_dir().join(format!("figcraft-mcp-{what}-{}-{n}.png", std::process::id())).to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn read_and_remove(path: &str) -> Result<Vec<u8>, String> {
|
||||
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
let _ = std::fs::remove_file(path);
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Decode a `data` field (base64, optionally a data: URL) from an export result.
|
||||
pub fn decode_data(v: &Value) -> Result<Vec<u8>, String> {
|
||||
let d = v.get("data").and_then(Value::as_str).ok_or("the export returned no data")?;
|
||||
|
||||
@@ -160,7 +160,7 @@ impl Headless {
|
||||
self.execute("text.edit.begin", &json!({"id": self.single_text()}))?;
|
||||
}
|
||||
"select.children" if self.session.tool.pen.is_some() => {
|
||||
let _ = self.session.pen_finish();
|
||||
let _ = self.session.pen_end();
|
||||
}
|
||||
// Enter on shapes edits them as vectors (several at once).
|
||||
"select.children" if self.vector_targets().is_some() => {
|
||||
@@ -215,7 +215,7 @@ impl Headless {
|
||||
let (w, h) = crate::png_size(&bytes).unwrap_or((0, 0));
|
||||
match p.get("path").and_then(Value::as_str) {
|
||||
Some(path) => {
|
||||
std::fs::write(path, &bytes).map_err(|e| format!("{path}: {e}"))?;
|
||||
figcraft_engine::file_access::write(path, &bytes).map_err(|e| e.to_string())?;
|
||||
Ok(json!({"path": path, "width": w, "height": h, "bytes": bytes.len()}))
|
||||
}
|
||||
None => Ok(json!({"format": "png", "width": w, "height": h, "data": base64::engine::general_purpose::STANDARD.encode(&bytes)})),
|
||||
@@ -278,7 +278,7 @@ impl Backend for Headless {
|
||||
let (w, h) = crate::png_size(&bytes).unwrap_or((0, 0));
|
||||
match p.get("path").and_then(Value::as_str) {
|
||||
Some(path) => {
|
||||
std::fs::write(path, &bytes).map_err(|e| format!("{path}: {e}"))?;
|
||||
figcraft_engine::file_access::write(path, &bytes).map_err(|e| e.to_string())?;
|
||||
Ok(json!({"path": path, "width": w, "height": h}))
|
||||
}
|
||||
None => Ok(json!({"format": "png", "width": w, "height": h, "data": base64::engine::general_purpose::STANDARD.encode(&bytes)})),
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
//! JSON-RPC 2.0 framing and the MCP lifecycle / tools / resources / prompts methods.
|
||||
|
||||
use std::io::{BufRead, Read, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_engine::file_access::{Roots, confined};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -42,6 +45,9 @@ Resources: figcraft://document, figcraft://node/{id}, figcraft://command/{id}. p
|
||||
pub struct Server {
|
||||
backend: Box<dyn Backend>,
|
||||
initialized: bool,
|
||||
/// The automation roots every request's file access is confined to (`--automation-read-root`,
|
||||
/// `--automation-write-root`), if any.
|
||||
roots: Option<Arc<Roots>>,
|
||||
}
|
||||
|
||||
fn response(id: Value, result: Value) -> Value {
|
||||
@@ -58,7 +64,14 @@ fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
|
||||
|
||||
impl Server {
|
||||
pub fn new(backend: Box<dyn Backend>) -> Self {
|
||||
Self { backend, initialized: false }
|
||||
Self { backend, initialized: false, roots: None }
|
||||
}
|
||||
|
||||
/// Confine the files every request reads and writes to `roots` (see
|
||||
/// [`figcraft_engine::file_access`]).
|
||||
pub fn with_roots(mut self, roots: Option<Roots>) -> Self {
|
||||
self.roots = roots.map(Arc::new);
|
||||
self
|
||||
}
|
||||
|
||||
/// A server over a fresh headless session.
|
||||
@@ -150,15 +163,23 @@ impl Server {
|
||||
return Some(error(id, INVALID_PARAMS, "`params` must be an object"));
|
||||
}
|
||||
// A bug that panics fails this request only; the server keeps serving.
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.request(method, ¶ms))).unwrap_or_else(|p| {
|
||||
Err((INTERNAL_ERROR, format!("internal error in `{method}`: {} (please report this bug)", panic_message(p.as_ref()))))
|
||||
});
|
||||
let roots = self.roots.clone();
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| confined(roots.as_ref(), || self.request(method, ¶ms)))).unwrap_or_else(
|
||||
|p| Err((INTERNAL_ERROR, format!("internal error in `{method}`: {} (please report this bug)", panic_message(p.as_ref())))),
|
||||
);
|
||||
Some(match r {
|
||||
Ok(r) => response(id, r),
|
||||
Err((code, m)) => error(id, code, m),
|
||||
})
|
||||
}
|
||||
|
||||
/// What `initialize` says about the automation roots.
|
||||
fn roots_note(&self) -> String {
|
||||
let Some(r) = &self.roots else { return String::new() };
|
||||
let j = r.to_json();
|
||||
format!(" Files: only these folders may be read: {}; written: {} (any other path is refused).", j["read"], j["write"])
|
||||
}
|
||||
|
||||
fn notification(&mut self, method: &str) {
|
||||
match method {
|
||||
"notifications/initialized" => self.initialized = true,
|
||||
@@ -180,7 +201,7 @@ impl Server {
|
||||
"prompts": {"listChanged": false},
|
||||
},
|
||||
"serverInfo": {"name": "figcraft", "title": "FigCraft", "version": env!("CARGO_PKG_VERSION")},
|
||||
"instructions": format!("{INSTRUCTIONS} Backend: {}.", self.backend.describe()),
|
||||
"instructions": format!("{INSTRUCTIONS} Backend: {}.{}", self.backend.describe(), self.roots_note()),
|
||||
}))
|
||||
}
|
||||
"ping" => Ok(json!({})),
|
||||
|
||||
@@ -163,7 +163,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"set_properties",
|
||||
"Set properties",
|
||||
"Set properties on one or more layers as one undo step (engine `node.set`). Keys: name, visible, locked, opacity (0-1), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), fill (colour) / fills (array of paints: \"#hex\" or {\"type\":\"solid\",\"color\":\"#hex\"} or {\"type\":\"linearGradient\",\"stops\":[\"#hex\",\"#hex\"]} or stops [{\"position\":0..1,\"color\":\"#hex\"}], optional from/to handles in the unit box; radialGradient too), stroke / strokes, strokeWeight, strokeAlign (inside|center|outside), cornerRadius (n or [tl,tr,br,bl]), effects (array, e.g. {\"type\":\"dropShadow\",\"color\":\"#00000040\",\"offset\":[0,4],\"radius\":8}), clip, layout (null|horizontal|vertical|grid), gap, padding (n or [t,r,b,l]), mainAlign (min|center|max|spaceBetween|spaceAround|spaceEvenly), crossAlign, wrap, contentAlign (auto|spaceBetween: wrapped lines), sizingH / sizingV (fixed|hug|fill), constraints {horizontal, vertical}, characters, fontFamily, fontStyle, fontSize, lineHeight, letterSpacing, textAlign, textAutoResize. Run list_commands with filter \"node.set\" for the full list. Unknown keys are an error. Returns the updated layers.",
|
||||
"Set properties on one or more layers as one undo step (engine `node.set`). Keys: name, visible, locked, opacity (0-1), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), fill (colour) / fills (array of paints: \"#hex\" or {\"type\":\"solid\",\"color\":\"#hex\"} or {\"type\":\"linearGradient\",\"stops\":[\"#hex\",\"#hex\"]} or stops [{\"position\":0..1,\"color\":\"#hex\"}], optional from/to handles in the unit box; radialGradient too), stroke / strokes, strokeWeight, strokeAlign (inside|center|outside), cornerRadius (n or [tl,tr,br,bl]), effects (array, e.g. {\"type\":\"dropShadow\",\"color\":\"#00000040\",\"offset\":[0,4],\"radius\":8}), clip, layout (null|horizontal|vertical|grid), gap, padding (n or [t,r,b,l]), mainAlign (min|center|max|spaceBetween|spaceAround|spaceEvenly), crossAlign, wrap, contentAlign (auto|spaceBetween: wrapped lines), sizingH / sizingV (fixed|hug|fill), constraints {horizontal, vertical}, characters, fontFamily, fontStyle, fontSize, lineHeight, letterSpacing, textAlign, textAutoResize. Auto layout keys (gap, padding, mainAlign, crossAlign, wrap, …) apply to frames, components and instances only: other layers among the ids are skipped and named in `warnings`, and with none that can take them the call is an error. A fontStyle draws in the family's nearest face; `warnings` says when that face has another weight or slant than the name asks for. Run list_commands with filter \"node.set\" for the full list. Unknown keys are an error. Returns the updated layers.",
|
||||
obj(
|
||||
json!({"ids": ids_schema("Layer ids (default: the selection)"), "props": {"type": "object", "description": "Property values to set"}}),
|
||||
&["props"],
|
||||
@@ -544,7 +544,7 @@ fn set_properties(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolRes
|
||||
}
|
||||
params["ids"] = json!(ids);
|
||||
}
|
||||
exec(b, "node.set", params)?;
|
||||
let warnings = exec(b, "node.set", params)?.get("warnings").cloned();
|
||||
let ids = match ids {
|
||||
Some(i) => i,
|
||||
None => b
|
||||
@@ -555,7 +555,11 @@ fn set_properties(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolRes
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
let nodes: Vec<Value> = ids.iter().take(20).filter_map(|i| b.call("node.get", json!({"id": i, "depth": 0})).ok()).collect();
|
||||
Ok(ToolResult::json(&json!({"ok": true, "nodes": nodes})))
|
||||
let mut out = json!({"ok": true, "nodes": nodes});
|
||||
if let Some(w) = warnings {
|
||||
out["warnings"] = w;
|
||||
}
|
||||
Ok(ToolResult::json(&out))
|
||||
}
|
||||
|
||||
fn inspect_document(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult, String> {
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
//! `--automation-read-root` / `--automation-write-root` over MCP (#715): a tool call reads only
|
||||
//! inside the read and write roots and writes only inside the write roots.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::disallowed_methods)]
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use figcraft_engine::file_access::Roots;
|
||||
use figcraft_mcp::Server;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// `<tmp>/figcraft-mcp-roots-<name>-<pid>` with `in/` (read root), `out/` (write root),
|
||||
/// `outside/` and `out2/` (named like the write root, outside it).
|
||||
struct Dirs(PathBuf);
|
||||
|
||||
impl Dirs {
|
||||
fn new(name: &str) -> Self {
|
||||
let d = std::env::temp_dir().join(format!("figcraft-mcp-roots-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
for sub in ["in", "out", "outside", "out2"] {
|
||||
std::fs::create_dir_all(d.join(sub)).unwrap();
|
||||
}
|
||||
Self(d)
|
||||
}
|
||||
|
||||
fn p(&self, rel: &str) -> String {
|
||||
self.0.join(rel).to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn server(&self) -> Server {
|
||||
Server::headless().with_roots(Some(Roots::new(&[self.p("in")], &[self.p("out")]).unwrap()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Dirs {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn call(s: &mut Server, tool: &str, args: Value) -> Value {
|
||||
let line = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": tool, "arguments": args}}).to_string();
|
||||
let r: Value = serde_json::from_str(&s.handle_line(&line).unwrap()).unwrap();
|
||||
r["result"].clone()
|
||||
}
|
||||
|
||||
fn text(r: &Value) -> String {
|
||||
r["content"][0]["text"].as_str().unwrap_or_default().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_go_only_into_the_write_root() {
|
||||
let d = Dirs::new("write");
|
||||
let mut s = d.server();
|
||||
assert_eq!(call(&mut s, "create_node", json!({"type": "frame", "width": 40, "height": 20}))["isError"], false);
|
||||
assert_eq!(call(&mut s, "run_command", json!({"command": "export.add"}))["isError"], false);
|
||||
for (tool, args) in [
|
||||
("save_file", json!({"path": d.p("outside/a.figcraft")})),
|
||||
("save_file", json!({"path": d.p("out2/a.figcraft")})),
|
||||
("save_file", json!({"path": d.p("out/../outside/a.figcraft")})),
|
||||
("save_file", json!({"path": d.p("in/a.figcraft")})),
|
||||
("export", json!({"format": "png", "path": d.p("outside/a.png")})),
|
||||
("run_command", json!({"command": "file.export", "params": {"format": "svg", "path": d.p("outside/a.svg")}})),
|
||||
("run_command", json!({"command": "file.exportLayers", "params": {"dir": d.p("outside")}})),
|
||||
("batch", json!({"commands": [{"command": "file.saveAs", "params": {"path": d.p("outside/b.figcraft")}}]})),
|
||||
] {
|
||||
let r = call(&mut s, tool, args.clone());
|
||||
assert_eq!(r["isError"], true, "{tool} {args}: {r}");
|
||||
assert!(text(&r).contains("automation path rejected"), "{tool}: {r}");
|
||||
}
|
||||
for sub in ["outside", "out2", "in"] {
|
||||
assert_eq!(std::fs::read_dir(d.0.join(sub)).unwrap().count(), 0, "nothing written in {sub}");
|
||||
}
|
||||
assert_eq!(call(&mut s, "save_file", json!({"path": d.p("out/a.figcraft")}))["isError"], false);
|
||||
assert_eq!(call(&mut s, "export", json!({"format": "png", "path": d.p("out/a.png")}))["isError"], false);
|
||||
assert!(d.0.join("out/a.figcraft").is_file() && d.0.join("out/a.png").is_file());
|
||||
// Without a path the bytes come back, written nowhere.
|
||||
let r = call(&mut s, "export", json!({"format": "png"}));
|
||||
assert_eq!(r["content"][0]["type"], "image", "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_go_only_into_the_read_and_write_roots() {
|
||||
let d = Dirs::new("read");
|
||||
// Files made without roots: one in each folder.
|
||||
let mut free = Server::headless();
|
||||
assert_eq!(call(&mut free, "create_node", json!({"type": "frame", "width": 40, "height": 20, "name": "Made"}))["isError"], false);
|
||||
for f in ["in/a.figcraft", "out/b.figcraft", "outside/c.figcraft"] {
|
||||
assert_eq!(call(&mut free, "export", json!({"format": "svg", "path": d.p(&f.replace(".figcraft", ".svg"))}))["isError"], false);
|
||||
assert_eq!(call(&mut free, "run_command", json!({"command": "file.saveAs", "params": {"path": d.p(f)}}))["isError"], false);
|
||||
}
|
||||
let mut s = d.server();
|
||||
for (tool, args) in [
|
||||
("open_file", json!({"path": d.p("outside/c.figcraft")})),
|
||||
("open_file", json!({"path": d.p("in/../outside/c.figcraft")})),
|
||||
("open_file", json!({"path": d.p("in/a.figcraft"), "variables": d.p("outside/c.svg")})),
|
||||
("run_command", json!({"command": "file.import", "params": {"path": d.p("outside/c.svg")}})),
|
||||
("run_command", json!({"command": "file.placeImage", "params": {"path": d.p("outside/c.svg")}})),
|
||||
("run_command", json!({"command": "library.add", "params": {"path": d.p("outside/c.figcraft")}})),
|
||||
] {
|
||||
let r = call(&mut s, tool, args.clone());
|
||||
assert_eq!(r["isError"], true, "{tool} {args}: {r}");
|
||||
assert!(text(&r).contains("automation path rejected"), "{tool}: {r}");
|
||||
}
|
||||
assert_eq!(call(&mut s, "open_file", json!({"path": d.p("in/a.figcraft")}))["isError"], false);
|
||||
assert_eq!(call(&mut s, "open_file", json!({"path": d.p("out/b.figcraft")}))["isError"], false, "a write root may be read");
|
||||
assert_eq!(call(&mut s, "run_command", json!({"command": "file.import", "params": {"path": d.p("in/a.svg")}}))["isError"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initialize_names_the_roots_and_no_roots_changes_nothing() {
|
||||
let d = Dirs::new("init");
|
||||
let mut s = d.server();
|
||||
let r: Value =
|
||||
serde_json::from_str(&s.handle_line(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}"#).unwrap())
|
||||
.unwrap();
|
||||
let ins = r["result"]["instructions"].as_str().unwrap();
|
||||
assert!(ins.contains("only these folders may be read") && ins.contains("figcraft-mcp-roots-init"), "{ins}");
|
||||
let mut free = Server::headless();
|
||||
let r: Value = serde_json::from_str(
|
||||
&free.handle_line(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}"#).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!r["result"]["instructions"].as_str().unwrap().contains("only these folders"));
|
||||
assert_eq!(call(&mut free, "create_node", json!({"type": "frame", "width": 4, "height": 4}))["isError"], false);
|
||||
assert_eq!(call(&mut free, "export", json!({"format": "png", "path": d.p("outside/free.png")}))["isError"], false);
|
||||
}
|
||||
@@ -208,3 +208,14 @@ fn serve_accepts_a_limit_sized_frame_and_a_final_unterminated_frame() {
|
||||
assert_eq!(lines[0]["result"], json!({}));
|
||||
assert_eq!(lines[1]["id"], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_properties_passes_on_what_it_skipped() {
|
||||
let mut c = Client::new();
|
||||
let f = c.ok("create_node", json!({"type": "frame"}))["id"].clone();
|
||||
let t = c.ok("create_node", json!({"type": "text", "text": "Hi"}))["id"].clone();
|
||||
let r = c.ok("set_properties", json!({"ids": [f, t], "props": {"gap": 8}}));
|
||||
assert!(r["warnings"][0].as_str().is_some_and(|w| w.contains("`gap`") && w.contains("TEXT")), "{r}");
|
||||
assert!(c.ok("set_properties", json!({"ids": [f], "props": {"gap": 4}})).get("warnings").is_none());
|
||||
assert!(c.err("set_properties", json!({"ids": [t], "props": {"gap": 4}})).contains("auto layout"));
|
||||
}
|
||||
@@ -59,6 +59,13 @@ pub fn closed(path: &BezPath) -> BezPath {
|
||||
/// Every coordinate is finite and of a sane magnitude (the sweep needs bounded input).
|
||||
pub use figcraft_geom::measure::finite_path as finite;
|
||||
|
||||
/// Run the sweep library on `f`. Version 0.4 can panic on rare near-degenerate input (an internal
|
||||
/// `unwrap` while ordering curves, hit by some font outlines): that is bad input, not a crash.
|
||||
// shortcut: no catch where panics abort (wasm32); drop this once a linesweeper release no longer panics there.
|
||||
fn sweep<T>(f: impl FnOnce() -> Result<T, PathOpsError>) -> Result<T, PathOpsError> {
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).unwrap_or(Err(PathOpsError::BadInput))
|
||||
}
|
||||
|
||||
fn binary(a: &BezPath, b: &BezPath, op: linesweeper::BinaryOp) -> Result<BezPath, PathOpsError> {
|
||||
if a.elements().is_empty() && b.elements().is_empty() {
|
||||
return Ok(BezPath::new());
|
||||
@@ -66,7 +73,7 @@ fn binary(a: &BezPath, b: &BezPath, op: linesweeper::BinaryOp) -> Result<BezPath
|
||||
if !finite(a) || !finite(b) {
|
||||
return Err(PathOpsError::BadInput);
|
||||
}
|
||||
let contours = linesweeper::binary_op(&closed(a), &closed(b), linesweeper::FillRule::NonZero, op).map_err(|_| PathOpsError::BadInput)?;
|
||||
let contours = sweep(|| linesweeper::binary_op(&closed(a), &closed(b), linesweeper::FillRule::NonZero, op).map_err(|_| PathOpsError::BadInput))?;
|
||||
let mut out = BezPath::new();
|
||||
for c in contours.contours() {
|
||||
out.extend(c.path.elements().iter().copied());
|
||||
@@ -192,9 +199,14 @@ pub fn faces(paths: &[(BezPath, bool)]) -> Result<Vec<ArrangementFace>, PathOpsE
|
||||
let bbox = closed_paths.iter().filter(|p| !p.elements().is_empty()).map(Shape::bounding_box).reduce(|a, b| a.union(b));
|
||||
let m = bbox.map(|b| b.x0.abs().max(b.x1.abs()).max(b.y0.abs()).max(b.y1.abs())).unwrap_or(0.0);
|
||||
let eps = (m * f64::EPSILON * 64.0).max(1e-6);
|
||||
let even_odd: Vec<bool> = paths.iter().map(|(_, e)| *e).collect();
|
||||
sweep(|| arrange(&closed_paths, &even_odd, eps))
|
||||
}
|
||||
|
||||
/// [`faces`] of the closed `paths`, swept with tolerance `eps`.
|
||||
fn arrange(closed_paths: &[BezPath], even_odd: &[bool], eps: f64) -> Result<Vec<ArrangementFace>, PathOpsError> {
|
||||
let topo = linesweeper::topology::Topology::<Windings>::from_paths(closed_paths.iter().enumerate().map(|(i, p)| (p, i)), eps)
|
||||
.map_err(|_| PathOpsError::BadInput)?;
|
||||
let even_odd: Vec<bool> = paths.iter().map(|(_, e)| *e).collect();
|
||||
let cover_of = |w: &Windings| -> Vec<usize> {
|
||||
w.0.iter()
|
||||
.enumerate()
|
||||
@@ -453,6 +465,13 @@ mod tests {
|
||||
p.area().abs()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panic_in_the_sweep_is_bad_input() {
|
||||
let r: Result<(), PathOpsError> = sweep(|| panic!("sweep bug"));
|
||||
assert!(matches!(r, Err(PathOpsError::BadInput)));
|
||||
assert!(matches!(sweep(|| Ok(1)), Ok(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn union_intersect_subtract_exclude() {
|
||||
let a = sq(0.0, 0.0, 10.0);
|
||||
|
||||
@@ -116,7 +116,7 @@ pub(crate) fn rule(r: figcraft_render::FillRule) -> FillRule {
|
||||
}
|
||||
|
||||
/// The renderer's stroke fill rule: even-odd ring paths for aligned strokes, else non-zero.
|
||||
fn stroke_rule(n: &Node, closed: bool) -> FillRule {
|
||||
pub(crate) fn stroke_rule(n: &Node, closed: bool) -> FillRule {
|
||||
if n.stroke.sides.is_some() || (n.stroke.dashes.is_empty() && closed && n.stroke.align != StrokeAlign::Center) {
|
||||
FillRule::EvenOdd
|
||||
} else {
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::collections::BTreeSet;
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_doc::{Node, NodeKind, StrokeAlign, StrokeProps};
|
||||
use figcraft_doc::{Node, NodeKind};
|
||||
use figcraft_geom::{Affine, BezPath};
|
||||
use figcraft_text::fontdb::FontBytes;
|
||||
use figcraft_text::{Face, Glyph, TextLayout};
|
||||
@@ -16,7 +16,7 @@ use krilla::text::{Font, GlyphId, KrillaGlyph};
|
||||
|
||||
use figcraft_color::{GradientKind, Paint, PaintKind};
|
||||
|
||||
use crate::draw::{Exporter, above, affine_ok, blend, to_xf, vector_paint};
|
||||
use crate::draw::{Exporter, above, affine_ok, blend, stroke_rule, to_xf, vector_paint};
|
||||
use crate::raster::Raster;
|
||||
|
||||
/// Glyphs of one face and size on one line, written as one text run.
|
||||
@@ -84,11 +84,11 @@ pub(crate) fn draw_text(ex: &mut Exporter, s: &mut Surface, n: &Node, xf: Affine
|
||||
}
|
||||
}
|
||||
ex.place_inner(s, inner);
|
||||
let props = StrokeProps { align: StrokeAlign::Center, ..n.stroke.clone() };
|
||||
// Glyph outlines are closed: inside and outside strokes clip to them, as on the canvas (#615).
|
||||
for outlines in &stroked {
|
||||
let area = figcraft_pathops::stroke_center(outlines, props.weight, &props);
|
||||
let area = figcraft_render::draw::stroke_area(n, outlines, true);
|
||||
for p in &n.strokes {
|
||||
ex.fill(s, &area, FillRule::NonZero, p, w, h, gxf);
|
||||
ex.fill(s, &area, stroke_rule(n, true), p, w, h, gxf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Paint bounds worked out once per render: the bounds kept for a layer are the ones worked out
|
||||
//! without keeping any, and the largest background blur follows the document's edits.
|
||||
|
||||
use super::*;
|
||||
use figcraft_doc::{Effect, EffectKind};
|
||||
|
||||
#[test]
|
||||
fn kept_paint_bounds_are_the_bounds_worked_out_afresh() {
|
||||
// Unclipped frames nested past the 64-level walk, a rectangle at the bottom: bounds asked for
|
||||
// deep first and then from the top, and the other way round, are those worked out alone.
|
||||
let mut d = Document::new("deep");
|
||||
let p = d.first_page().unwrap();
|
||||
let mut chain = Vec::new();
|
||||
let mut parent = p;
|
||||
for k in 0..80 {
|
||||
let f = d.create(parent, NodeKind::Frame, &format!("F{k}"), 1.0, 1.0, 10.0, 10.0).unwrap();
|
||||
d.node_mut(f).unwrap().clip = false;
|
||||
chain.push(f);
|
||||
parent = f;
|
||||
}
|
||||
d.create(parent, NodeKind::Rectangle, "R", 500.0, 0.0, 10.0, 10.0).unwrap();
|
||||
for order in [chain.iter().rev().copied().collect::<Vec<_>>(), chain.clone()] {
|
||||
let memo = draw::Bounds::default();
|
||||
for id in &order {
|
||||
let n = d.node(*id).unwrap();
|
||||
for depth in [0, 20] {
|
||||
let kept = draw::bounds_within(&d, n, depth, None, Some(&memo)).0;
|
||||
assert_eq!(kept, draw::local_paint_bounds(&d, n, depth, None), "{} at depth {depth}", n.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_largest_background_blur_follows_edits() {
|
||||
let mut d = Document::new("blur");
|
||||
let p = d.first_page().unwrap();
|
||||
let r = d.create(p, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
assert_eq!(max_backdrop_blur(&d), 0.0);
|
||||
let mut blur = Effect::background_blur();
|
||||
if let EffectKind::BackgroundBlur(b) = &mut blur.kind {
|
||||
b.radius = 12.0;
|
||||
}
|
||||
d.node_mut(r).unwrap().effects.push(blur);
|
||||
assert_eq!(max_backdrop_blur(&d), 12.0);
|
||||
// A copy of the document keeps it, and an edit of the copy changes only the copy's.
|
||||
let mut copy = d.clone();
|
||||
if let Some(EffectKind::BackgroundBlur(b)) = copy.node_mut(r).unwrap().effects.first_mut().map(|e| &mut e.kind) {
|
||||
b.radius = 30.0;
|
||||
}
|
||||
assert_eq!(max_backdrop_blur(©), 30.0);
|
||||
assert_eq!(max_backdrop_blur(&d), 12.0);
|
||||
copy.node_mut(r).unwrap().effects.clear();
|
||||
assert_eq!(max_backdrop_blur(©), 0.0);
|
||||
// A deleted layer's blur no longer counts.
|
||||
d.remove(r).unwrap();
|
||||
assert_eq!(max_backdrop_blur(&d), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_splat_answers_are_the_answers_worked_out_afresh() {
|
||||
use figcraft_color::{ImageScaleMode, Paint};
|
||||
// Groups nested past the 64-level walk, with an image-filled rectangle at the bottom of one
|
||||
// branch and a plain one at the bottom of the other: answers asked for deep-first and
|
||||
// top-first, at depths 0 and 20, are those worked out alone.
|
||||
let mut d = Document::new("deep");
|
||||
let p = d.first_page().unwrap();
|
||||
let mut chain = Vec::new();
|
||||
for image in [false, true] {
|
||||
let mut parent = p;
|
||||
for k in 0..80 {
|
||||
let g = d.create(parent, NodeKind::Frame, &format!("{image} {k}"), 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
d.node_mut(g).unwrap().clip = false;
|
||||
chain.push(g);
|
||||
parent = g;
|
||||
}
|
||||
let r = d.create(parent, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
if image {
|
||||
d.node_mut(r).unwrap().fills = vec![Paint::image("missing", ImageScaleMode::Fill)];
|
||||
}
|
||||
}
|
||||
for order in [chain.iter().rev().copied().collect::<Vec<_>>(), chain.clone()] {
|
||||
let memo = draw::Bounds::default();
|
||||
for id in &order {
|
||||
let n = d.node(*id).unwrap();
|
||||
for depth in [0, 20] {
|
||||
let kept = lod::splattable_within(&d, n, depth, Some(&memo)).0;
|
||||
assert_eq!(kept, lod::splattable(&d, n, depth), "{} at depth {depth}", n.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+124
-24
@@ -16,8 +16,9 @@ use crate::{blur, grain, lod, shadow};
|
||||
|
||||
/// Shared state for one render.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Ctx<'a> {
|
||||
pub doc: &'a Document,
|
||||
/// `'d`: the document's (layers looked up for the render are kept that long), `'a`: the rest.
|
||||
pub struct Ctx<'a, 'd> {
|
||||
pub doc: &'d Document,
|
||||
/// Device pixels per document unit (for effect radii).
|
||||
pub scale: f64,
|
||||
pub outline_mode: bool,
|
||||
@@ -33,8 +34,67 @@ pub struct Ctx<'a> {
|
||||
pub under: Option<&'a Under<'a>>,
|
||||
/// Seconds into animated GIFs (0: their first frame; only the presenter plays them).
|
||||
pub time: f64,
|
||||
/// Paint bounds already worked out in this render.
|
||||
pub bounds: &'a Bounds<'d>,
|
||||
}
|
||||
|
||||
/// What one render works out about layers with children, which a walk down their subtree finds:
|
||||
/// their local paint bounds by greeking scale, and whether they can be splatted (see
|
||||
/// [`lod::splattable`]). Culling asks for a node's and then for each of its children's, so without
|
||||
/// them every layer's subtree was walked again at each level above it. Each answer keeps how many
|
||||
/// levels its walk went down.
|
||||
/// It also keeps the layers it looked up: the walks for culling, splatting and drawing go down the
|
||||
/// same layers, and in a large document a lookup is a good part of a layer's cost.
|
||||
#[derive(Default)]
|
||||
pub struct Bounds<'a> {
|
||||
paint: std::cell::RefCell<IdMap<BoundsKey, (Rect, usize)>>,
|
||||
pub(crate) splat: std::cell::RefCell<IdMap<NodeId, (bool, usize)>>,
|
||||
nodes: std::cell::RefCell<IdMap<NodeId, Option<&'a Node>>>,
|
||||
}
|
||||
|
||||
impl<'a> Bounds<'a> {
|
||||
/// Node `id` of `doc`, looked up once for the render.
|
||||
pub(crate) fn node(&self, doc: &'a Document, id: NodeId) -> Option<&'a Node> {
|
||||
if let Some(n) = self.nodes.borrow().get(&id) {
|
||||
return *n;
|
||||
}
|
||||
let n = doc.node(id);
|
||||
self.nodes.borrow_mut().insert(id, n);
|
||||
n
|
||||
}
|
||||
}
|
||||
|
||||
/// A hash map keyed by node ids, which need no SipHash: they are counters (or a peer's range of
|
||||
/// them), and SipHash took a good part of a render.
|
||||
type IdMap<K, V> = HashMap<K, V, std::hash::BuildHasherDefault<IdHash>>;
|
||||
|
||||
/// Mixes each word in with a multiply (Fibonacci hashing), so ids that differ in low bits spread.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct IdHash(u64);
|
||||
|
||||
impl Hasher for IdHash {
|
||||
fn finish(&self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
fn write(&mut self, bytes: &[u8]) {
|
||||
for b in bytes {
|
||||
self.write_u64(u64::from(*b));
|
||||
}
|
||||
}
|
||||
fn write_u64(&mut self, v: u64) {
|
||||
self.0 = (self.0.rotate_left(5) ^ v).wrapping_mul(0x9E37_79B9_7F4A_7C15);
|
||||
}
|
||||
fn write_usize(&mut self, v: usize) {
|
||||
self.write_u64(v as u64);
|
||||
}
|
||||
}
|
||||
|
||||
/// A layer, and the scale its text is greeked at (none: drawn in full).
|
||||
type BoundsKey = (NodeId, Option<u64>);
|
||||
|
||||
/// Deepest nesting the paint bounds walk follows.
|
||||
const MAX_BOUNDS_DEPTH: usize = 64;
|
||||
|
||||
pub fn ts_transform(a: Affine) -> Transform {
|
||||
let c = a.as_coeffs();
|
||||
Transform::from_row(c[0] as f32, c[1] as f32, c[2] as f32, c[3] as f32, c[4] as f32, c[5] as f32)
|
||||
@@ -279,32 +339,63 @@ fn text_box_bounds(n: &Node) -> Rect {
|
||||
/// Local bounds a node paints into (its box grown by stroke/effect reach, plus unclipped children).
|
||||
/// Text that overflows its box counts where its glyphs are, so it isn't culled while visible;
|
||||
/// text greeked at `greek_scale` (see [`lod::greeked`]) paints only inside [`text_box_bounds`].
|
||||
fn local_paint_bounds(doc: &Document, n: &Node, depth: usize, greek_scale: Option<f64>) -> Rect {
|
||||
pub(crate) fn local_paint_bounds(doc: &Document, n: &Node, depth: usize, greek_scale: Option<f64>) -> Rect {
|
||||
bounds_within(doc, n, depth, greek_scale, None).0
|
||||
}
|
||||
|
||||
/// [`local_paint_bounds`], kept in and read from `memo` when there is one. Also returns how many
|
||||
/// levels the walk went down, or `None` when the nesting cap cut it short: such bounds depend on
|
||||
/// the depth they were asked at and aren't kept, and kept bounds are read only where their levels
|
||||
/// still fit under the cap.
|
||||
pub(crate) fn bounds_within<'a>(
|
||||
doc: &'a Document,
|
||||
n: &Node,
|
||||
depth: usize,
|
||||
greek_scale: Option<f64>,
|
||||
memo: Option<&Bounds<'a>>,
|
||||
) -> (Rect, Option<usize>) {
|
||||
// A layer without children is quicker to work out again than to look up.
|
||||
let memo = memo.filter(|_| !n.children.is_empty());
|
||||
let key = (n.id, greek_scale.map(f64::to_bits));
|
||||
if let Some((r, levels)) = memo.and_then(|m| m.paint.borrow().get(&key).copied())
|
||||
&& depth + levels <= MAX_BOUNDS_DEPTH
|
||||
{
|
||||
return (r, Some(levels));
|
||||
}
|
||||
let reach = n.paint_reach();
|
||||
let mut r = n.local_rect().inflate(reach, reach);
|
||||
let mut levels = Some(0);
|
||||
let clips = n.clip && n.kind.is_frame_like();
|
||||
if (!clips || n.kind.bounds_follow_children()) && depth < 64 && !n.hides_contents() {
|
||||
for c in n.children.iter().filter_map(|c| doc.node(*c)) {
|
||||
if !c.visible {
|
||||
continue;
|
||||
if (!clips || n.kind.bounds_follow_children()) && !n.hides_contents() {
|
||||
if depth < MAX_BOUNDS_DEPTH {
|
||||
let mut below = Some(0);
|
||||
for c in n.children.iter().filter_map(|c| memo.map_or_else(|| doc.node(*c), |m| m.node(doc, *c))) {
|
||||
if !c.visible {
|
||||
continue;
|
||||
}
|
||||
let (cb, c_levels) = bounds_within(doc, c, depth + 1, greek_scale, memo);
|
||||
below = below.zip(c_levels).map(|(b, l)| b.max(l));
|
||||
r = r.union(figcraft_geom::transform_rect_bbox(c.transform, cb));
|
||||
}
|
||||
let cb = local_paint_bounds(doc, c, depth + 1, greek_scale);
|
||||
r = r.union(figcraft_geom::transform_rect_bbox(c.transform, cb));
|
||||
levels = below.map(|b| b + 1);
|
||||
} else if !n.children.is_empty() {
|
||||
levels = None;
|
||||
}
|
||||
}
|
||||
// Text can overflow its box.
|
||||
if let NodeKind::Text(_) = n.kind {
|
||||
r = r.union(text_box_bounds(n).inflate(reach, reach));
|
||||
if greek_scale.is_some_and(|s| lod::greeked(n, s)) {
|
||||
return r;
|
||||
}
|
||||
if let Some((l, dy)) = figcraft_text::layout_node(n)
|
||||
if !greek_scale.is_some_and(|s| lod::greeked(n, s))
|
||||
&& let Some((l, dy)) = figcraft_text::layout_node(n)
|
||||
&& let Some(ink) = l.ink_bounds()
|
||||
{
|
||||
r = r.union(ink + figcraft_geom::kurbo::Vec2::new(0.0, dy));
|
||||
}
|
||||
}
|
||||
r
|
||||
if let (Some(m), Some(l)) = (memo, levels) {
|
||||
m.paint.borrow_mut().insert(key, (r, l));
|
||||
}
|
||||
(r, levels)
|
||||
}
|
||||
|
||||
/// How far (document units) effects that read outside a node's paint bounds reach: blurs, glass,
|
||||
@@ -367,10 +458,17 @@ const MAX_EFFECT_MARGIN_PX: f64 = 2048.0;
|
||||
|
||||
/// Draw node `id` onto `pix`, where `parent_xf` maps the parent's space to `pix` pixels.
|
||||
pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, depth: usize) {
|
||||
if let Some(n) = ctx.doc.node(id) {
|
||||
draw_layer(ctx, pix, n, parent_xf, depth);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`draw_node`] for a node already looked up.
|
||||
fn draw_layer(ctx: &Ctx, pix: &mut Pixmap, n: &Node, parent_xf: Affine, depth: usize) {
|
||||
let id = n.id;
|
||||
if depth > crate::MAX_EXPORT_DEPTH || ctx.skip.contains(&id) {
|
||||
return;
|
||||
}
|
||||
let Some(n) = ctx.doc.node(id) else { return };
|
||||
let outline = ctx.outline_mode || ctx.outlined.contains(&id);
|
||||
if n.is_mask || n.opacity <= 0.0 && n.visible {
|
||||
return;
|
||||
@@ -385,7 +483,7 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep
|
||||
}
|
||||
let xf = parent_xf * n.transform;
|
||||
let (pw, ph) = (pix.width(), pix.height());
|
||||
let local = local_paint_bounds(ctx.doc, n, 0, ctx.lod.then_some(ctx.scale));
|
||||
let local = bounds_within(ctx.doc, n, 0, ctx.lod.then_some(ctx.scale), Some(ctx.bounds)).0;
|
||||
let dev = device_rect(xf, local);
|
||||
// Effects that read outside (blur) need a margin when culling. Absurd effects (a spread or
|
||||
// blur of millions of pixels) keep a sane layer: what lies further off reads as empty.
|
||||
@@ -398,7 +496,7 @@ pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, dep
|
||||
draw_outline(&Ctx { outline_mode: true, ..*ctx }, pix, n, xf, depth);
|
||||
return;
|
||||
}
|
||||
if ctx.lod && dev.width().max(dev.height()) < lod::TINY_PX && lod::splattable(ctx.doc, n, 0) {
|
||||
if ctx.lod && dev.width().max(dev.height()) < lod::TINY_PX && lod::splattable_within(ctx.doc, n, 0, Some(ctx.bounds)).0 {
|
||||
lod::splat_subtree(ctx, pix, n, xf, None, 1.0, depth);
|
||||
return;
|
||||
}
|
||||
@@ -621,11 +719,11 @@ fn draw_content(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize,
|
||||
f(pix);
|
||||
}
|
||||
if n.has_visible_stroke() {
|
||||
let props = figcraft_doc::StrokeProps { align: StrokeAlign::Center, ..n.stroke.clone() };
|
||||
// Glyph outlines are closed: inside and outside strokes clip to them (#615).
|
||||
for path in keys.iter().filter_map(|k| by_style.get(k)) {
|
||||
let s = figcraft_pathops::stroke_center(path, props.weight, &props);
|
||||
let s = stroke_area(n, path, true);
|
||||
if let Some(ts) = ts_path(&s) {
|
||||
fill_with(pix, &ts, &n.strokes, w, h, gxf, FillRule::Winding, ctx, None);
|
||||
fill_with(pix, &ts, &n.strokes, w, h, gxf, stroke_rule(n, true), ctx, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -672,13 +770,15 @@ fn draw_content(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize,
|
||||
|
||||
fn draw_children(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize) {
|
||||
let kids = &n.children;
|
||||
for run in ctx.doc.paint_runs(n) {
|
||||
let Some(&c) = kids.get(run.start) else { continue };
|
||||
if ctx.doc.node(c).is_some_and(|cn| cn.is_mask && cn.visible) {
|
||||
// Each child looked up once: in a large document a lookup is a good part of a layer's cost.
|
||||
let nodes: Vec<Option<&Node>> = kids.iter().map(|c| ctx.bounds.node(ctx.doc, *c)).collect();
|
||||
for run in Document::paint_runs_of(&nodes, n.layout.as_ref().is_some_and(|l| l.reverse_z)) {
|
||||
let (Some(&c), Some(Some(cn))) = (kids.get(run.start), nodes.get(run.start)) else { continue };
|
||||
if cn.is_mask && cn.visible {
|
||||
// Everything above the mask (until the next mask) is clipped by its alpha.
|
||||
draw_masked(ctx, pix, c, kids.get(run.start + 1..run.end).unwrap_or(&[]), xf, depth);
|
||||
} else {
|
||||
draw_node(ctx, pix, c, xf, depth + 1);
|
||||
draw_layer(ctx, pix, cn, xf, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ fn new_pixmap(w: u32, h: u32) -> Option<Pixmap> {
|
||||
/// The largest backdrop radius (background blur, glass: see [`figcraft_doc::Effect::backdrop_radius`])
|
||||
/// in the document (0 without any).
|
||||
pub(crate) fn max_backdrop_blur(doc: &Document) -> f64 {
|
||||
doc.nodes().flat_map(|n| n.effects.iter()).map(figcraft_doc::Effect::backdrop_radius).fold(0.0, f64::max)
|
||||
doc.max_backdrop_radius()
|
||||
}
|
||||
|
||||
/// How far (device pixels) a background blur of `radius` reads its backdrop at `scale`.
|
||||
@@ -170,6 +170,7 @@ pub fn render_view_single(doc: &Document, page: NodeId, view: &View, opts: &Opti
|
||||
pix.fill(paint::ts_color(bg));
|
||||
}
|
||||
let lod = opts.lod && !opts.outline_mode && view.scale <= lod::MAX_SCALE;
|
||||
let bounds = draw::Bounds::default();
|
||||
let ctx = draw::Ctx {
|
||||
doc,
|
||||
scale: view.scale,
|
||||
@@ -180,6 +181,7 @@ pub fn render_view_single(doc: &Document, page: NodeId, view: &View, opts: &Opti
|
||||
lod,
|
||||
under: None,
|
||||
time: 0.0,
|
||||
bounds: &bounds,
|
||||
};
|
||||
let xf = view.transform();
|
||||
let Some(p) = doc.node(page) else { return Some(pix) };
|
||||
@@ -201,6 +203,7 @@ pub fn render_nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut
|
||||
|
||||
/// [`render_nodes_into`] `time` seconds into animated GIFs.
|
||||
fn nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap, time: f64) {
|
||||
let bounds = draw::Bounds::default();
|
||||
let ctx = draw::Ctx {
|
||||
doc,
|
||||
scale: view.scale,
|
||||
@@ -211,6 +214,7 @@ fn nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap, tim
|
||||
lod: false,
|
||||
under: None,
|
||||
time,
|
||||
bounds: &bounds,
|
||||
};
|
||||
for id in ids {
|
||||
draw::draw_node(&ctx, pix, *id, view.transform() * parent_transform(doc, *id), 0);
|
||||
@@ -434,6 +438,8 @@ mod band_tests;
|
||||
#[cfg(test)]
|
||||
mod boolean_cache_tests;
|
||||
#[cfg(test)]
|
||||
mod bounds_memo_tests;
|
||||
#[cfg(test)]
|
||||
mod effect_order_tests;
|
||||
#[cfg(test)]
|
||||
mod effects_behaviour_tests;
|
||||
|
||||
@@ -60,18 +60,47 @@ fn text_paints<'a>(n: &'a Node, t: &'a TextData) -> impl Iterator<Item = &'a Pai
|
||||
/// Whether the subtree of `n` can be splatted: no image or pattern paints (their colours aren't known
|
||||
/// without sampling them) and no masks (which change what their siblings show).
|
||||
pub fn splattable(doc: &Document, n: &Node, depth: usize) -> bool {
|
||||
splattable_within(doc, n, depth, None).0
|
||||
}
|
||||
|
||||
/// [`splattable`], kept in and read from `memo` when there is one (layers with children only).
|
||||
/// Also returns how many levels the walk went down, or `None` when the nesting cap decided it: such
|
||||
/// answers depend on the depth they were asked at and aren't kept, and kept answers are read only
|
||||
/// where their levels still fit under the cap.
|
||||
pub(crate) fn splattable_within<'a>(doc: &'a Document, n: &Node, depth: usize, memo: Option<&crate::draw::Bounds<'a>>) -> (bool, Option<usize>) {
|
||||
if depth > MAX_DEPTH {
|
||||
return false;
|
||||
return (false, None);
|
||||
}
|
||||
let memo = memo.filter(|_| !n.children.is_empty());
|
||||
if let Some((ok, levels)) = memo.and_then(|m| m.splat.borrow().get(&n.id).copied())
|
||||
&& depth + levels <= MAX_DEPTH
|
||||
{
|
||||
return (ok, Some(levels));
|
||||
}
|
||||
let image = |p: &Paint| p.visible && matches!(p.kind, PaintKind::Image(_) | PaintKind::Pattern(_));
|
||||
let own = match &n.kind {
|
||||
NodeKind::Text(t) => text_paints(n, t).any(image),
|
||||
_ => n.fills.iter().any(image),
|
||||
};
|
||||
if own || n.strokes.iter().any(image) {
|
||||
return false;
|
||||
let (mut ok, mut below) = (!(own || n.strokes.iter().any(image)), Some(0));
|
||||
if ok {
|
||||
for c in n.children.iter().filter_map(|c| memo.map_or_else(|| doc.node(*c), |m| m.node(doc, *c))).filter(|c| c.visible) {
|
||||
if c.is_mask {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
let (c_ok, c_levels) = splattable_within(doc, c, depth + 1, memo);
|
||||
below = below.zip(c_levels).map(|(b, l)| b.max(l + 1));
|
||||
if !c_ok {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
n.children.iter().filter_map(|c| doc.node(*c)).filter(|c| c.visible).all(|c| !c.is_mask && splattable(doc, c, depth + 1))
|
||||
if let (Some(m), Some(l)) = (memo, below) {
|
||||
m.splat.borrow_mut().insert(n.id, (ok, l));
|
||||
}
|
||||
(ok, below)
|
||||
}
|
||||
|
||||
/// A paint's average colour as straight RGBA (alpha times the paint's opacity); `None` for
|
||||
@@ -199,7 +228,7 @@ pub fn splat_subtree(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, clip: Op
|
||||
} else {
|
||||
clip
|
||||
};
|
||||
for c in n.paint_order().filter_map(|c| ctx.doc.node(c)) {
|
||||
for c in n.paint_order().filter_map(|c| ctx.bounds.node(ctx.doc, c)) {
|
||||
splat_subtree(ctx, pix, c, xf * c.transform, clip, alpha, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +154,7 @@ fn exports_never_use_lod() {
|
||||
crate::render_nodes_into(&d, &[card], &v, &mut exact);
|
||||
assert_eq!(png.data(), exact.data());
|
||||
let mut preview = Pixmap::new(v.width, v.height).unwrap();
|
||||
let bounds = crate::draw::Bounds::default();
|
||||
let ctx = crate::draw::Ctx {
|
||||
doc: &d,
|
||||
scale,
|
||||
@@ -164,6 +165,7 @@ fn exports_never_use_lod() {
|
||||
lod: true,
|
||||
under: None,
|
||||
time: 0.0,
|
||||
bounds: &bounds,
|
||||
};
|
||||
let parent = d.world_transform(p);
|
||||
crate::draw::draw_node(&ctx, &mut preview, card, v.transform() * parent, 0);
|
||||
|
||||
@@ -630,3 +630,35 @@ fn changing_the_colour_profile_converts_or_keeps_values() {
|
||||
let seen = draw(&kept);
|
||||
assert!(seen[0] > before[0] && seen[2] < before[2], "kept values look more saturated: {seen:?} vs {before:?}");
|
||||
}
|
||||
|
||||
// ---- strokes on text ----
|
||||
|
||||
#[test]
|
||||
fn text_strokes_follow_their_alignment() {
|
||||
// #615: as on other shapes (and in SVG export), an inside stroke stays in the glyphs, a centre
|
||||
// one reaches half its weight past them and an outside one all of it.
|
||||
figcraft_text::scan_system_fonts();
|
||||
let left_edge = |align: StrokeAlign| -> Option<u32> {
|
||||
let (mut d, p) = page();
|
||||
let mut td = figcraft_doc::TextData::new("I");
|
||||
td.style.font_size = 120.0;
|
||||
let t = d.create(p, NodeKind::Text(td), "T", 60.0, 30.0, 1.0, 1.0).unwrap();
|
||||
let n = d.get_mut(t).unwrap();
|
||||
n.fills.clear();
|
||||
n.strokes = vec![Paint::solid(hex("#000000"))];
|
||||
n.stroke.weight = 12.0;
|
||||
n.stroke.align = align;
|
||||
let (w, h) = figcraft_text::auto_size(n)?;
|
||||
(n.width, n.height) = (w, h);
|
||||
let pix = render(&d);
|
||||
(0..200).find(|x| (0..200).any(|y| px(&pix, *x, y)[0] < 128))
|
||||
};
|
||||
let (Some(inside), Some(centre), Some(outside)) =
|
||||
(left_edge(StrokeAlign::Inside), left_edge(StrokeAlign::Center), left_edge(StrokeAlign::Outside))
|
||||
else {
|
||||
eprintln!("no font to draw text with; skipping");
|
||||
return;
|
||||
};
|
||||
assert!((5..=7).contains(&(inside - centre)), "centre reaches half the weight past inside: {inside} → {centre}");
|
||||
assert!((5..=7).contains(&(centre - outside)), "outside reaches the rest: {centre} → {outside}");
|
||||
}
|
||||
@@ -9,7 +9,7 @@ use figcraft_doc::{Document, NodeId};
|
||||
use figcraft_geom::Affine;
|
||||
use tiny_skia::{FilterQuality, Pixmap, SpreadMode, Transform};
|
||||
|
||||
use crate::draw::{Ctx, draw_node};
|
||||
use crate::draw::{Bounds, Ctx, draw_node};
|
||||
use crate::paint::Prepared;
|
||||
|
||||
/// The largest side of a cell's pixmap; a cell showing larger is drawn coarser.
|
||||
@@ -46,6 +46,7 @@ pub fn cell_pixmap(doc: &Document, pt: &PatternPaint, w: f64, h: f64, scale: f64
|
||||
let (pw, ph) = ((cell.size.0 * k).ceil().clamp(1.0, MAX_CELL_PX), (cell.size.1 * k).ceil().clamp(1.0, MAX_CELL_PX));
|
||||
let (kx, ky) = (pw / cell.size.0, ph / cell.size.1);
|
||||
let mut pix = Pixmap::new(pw as u32, ph as u32)?;
|
||||
let bounds = Bounds::default();
|
||||
let ctx = Ctx {
|
||||
doc,
|
||||
scale: kx.max(ky) * cell.scale,
|
||||
@@ -56,6 +57,7 @@ pub fn cell_pixmap(doc: &Document, pt: &PatternPaint, w: f64, h: f64, scale: f64
|
||||
lod: false,
|
||||
under: None,
|
||||
time,
|
||||
bounds: &bounds,
|
||||
};
|
||||
let unrotated = src.transform.inverse();
|
||||
nested(|| {
|
||||
|
||||
@@ -406,7 +406,7 @@ fn names(f: &skrifa::FontRef) -> (Option<String>, Option<String>) {
|
||||
}
|
||||
|
||||
/// Families tried, in order, for characters a text's own font lacks.
|
||||
const FALLBACK_FAMILIES: [&str; 18] = [
|
||||
const FALLBACK_FAMILIES: [&str; 20] = [
|
||||
"Inter",
|
||||
"Helvetica Neue",
|
||||
"Arial",
|
||||
@@ -416,6 +416,8 @@ const FALLBACK_FAMILIES: [&str; 18] = [
|
||||
"BIZ UDPGothic",
|
||||
"Hiragino Sans",
|
||||
"PingFang SC",
|
||||
"Hiragino Sans GB",
|
||||
"Heiti SC",
|
||||
"Noto Sans CJK SC",
|
||||
"Microsoft YaHei",
|
||||
"Apple SD Gothic Neo",
|
||||
@@ -691,11 +693,17 @@ fn system_font_dirs() -> Vec<std::path::PathBuf> {
|
||||
let mut v: Vec<std::path::PathBuf> = Vec::new();
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// Subfolders (`Supplemental`) are walked too.
|
||||
v.push("/System/Library/Fonts".into());
|
||||
v.push("/Library/Fonts".into());
|
||||
if let Some(h) = std::env::var_os("HOME") {
|
||||
v.push(std::path::PathBuf::from(h).join("Library/Fonts"));
|
||||
}
|
||||
// Fonts the system downloads (PingFang and other CJK faces on current versions) live in
|
||||
// its asset store; `Assets` is the older versions' store.
|
||||
for store in ["/System/Library/AssetsV2", "/System/Library/Assets"] {
|
||||
v.extend(font_asset_dirs(std::path::Path::new(store)));
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
@@ -714,6 +722,15 @@ fn system_font_dirs() -> Vec<std::path::PathBuf> {
|
||||
v
|
||||
}
|
||||
|
||||
/// The font folders (`com_apple_MobileAsset_Font*`, each holding `<id>.asset/AssetData/*.ttc`) in
|
||||
/// the macOS asset store `store`: only these are walked, not the store's voices, dictionaries and
|
||||
/// models.
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn font_asset_dirs(store: &std::path::Path) -> Vec<std::path::PathBuf> {
|
||||
let Ok(rd) = std::fs::read_dir(store) else { return Vec::new() };
|
||||
rd.flatten().filter(|e| e.file_name().to_str().is_some_and(|n| n.starts_with("com_apple_MobileAsset_Font"))).map(|e| e.path()).collect()
|
||||
}
|
||||
|
||||
// Fontconfig's default configuration uses $XDG_DATA_HOME/fonts for user fonts.
|
||||
// Ignore relative XDG paths, as required by the XDG Base Directory Specification.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
|
||||
@@ -70,6 +70,44 @@ fn the_scan_reads_names_like_a_whole_file_read() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_font_assets_are_found_in_the_asset_store() {
|
||||
// A store laid out like macOS's: fonts downloaded on demand sit in `<id>.asset/AssetData`
|
||||
// folders under `com_apple_MobileAsset_Font*`; other assets are never walked. Untested on a Mac.
|
||||
struct TempDir(std::path::PathBuf);
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
let tmp = TempDir(std::env::temp_dir().join(format!("figcraft-font-assets-{}", std::process::id())));
|
||||
let store = tmp.0.join("AssetsV2");
|
||||
let fonts = store.join("com_apple_MobileAsset_Font7");
|
||||
let data = |kind: &std::path::Path| kind.join("0f1e2d.asset").join("AssetData");
|
||||
for dir in [data(&fonts), data(&store.join("com_apple_MobileAsset_VoiceServices"))] {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
}
|
||||
// A file under a font-like name is listed but walks to nothing.
|
||||
std::fs::write(store.join("com_apple_MobileAsset_Font7.plist"), b"not a folder").unwrap();
|
||||
let found = font_asset_dirs(&store);
|
||||
assert!(found.contains(&fonts) && !found.iter().any(|d| d.ends_with("com_apple_MobileAsset_VoiceServices")), "{found:?}");
|
||||
assert!(font_asset_dirs(&tmp.0.join("missing")).is_empty());
|
||||
// With an installed font to copy in (none on CI), the scan finds it only in the font folder.
|
||||
let installed = system_font_dirs()
|
||||
.into_iter()
|
||||
.find_map(|d| std::fs::read_dir(d).into_iter().flatten().flatten().map(|e| e.path()).find(|p| p.extension().is_some_and(|x| x == "ttf")));
|
||||
let Some(font) = installed else { return };
|
||||
for kind in ["com_apple_MobileAsset_Font7", "com_apple_MobileAsset_VoiceServices"] {
|
||||
std::fs::copy(&font, data(&store.join(kind)).join("Font.ttf")).unwrap();
|
||||
}
|
||||
let mut pending = BTreeMap::new();
|
||||
for dir in found {
|
||||
scan_dir(&dir, 0, &mut pending);
|
||||
}
|
||||
let paths: Vec<_> = pending.values().flat_map(|(_, p)| p.iter().map(|p| p.path.clone())).collect();
|
||||
assert!(!paths.is_empty() && paths.iter().all(|p| p.starts_with(&fonts)), "{paths:?}");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn the_scan_skips_named_pipes() {
|
||||
|
||||
@@ -1191,6 +1191,12 @@ impl TextLayout {
|
||||
Some(next.unwrap_or(if right != line.rtl { index + 1 } else { index.saturating_sub(1) }))
|
||||
}
|
||||
|
||||
/// Left edge of a left-to-right line's first char (its end when it has none).
|
||||
fn line_start_x(&self, line: &Line) -> f64 {
|
||||
let gl = self.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]);
|
||||
gl.iter().find(|g| g.char_end > g.char_start).map_or_else(|| self.line_end_x(line), |g| g.x)
|
||||
}
|
||||
|
||||
/// Where a line's text ends: its right edge, or its left edge for a right-to-left paragraph.
|
||||
fn line_end_x(&self, line: &Line) -> f64 {
|
||||
let glyphs = self.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]).iter().filter(|g| g.char_end > g.char_start);
|
||||
@@ -1251,7 +1257,16 @@ impl TextLayout {
|
||||
let (a, b) = (start.min(end), start.max(end));
|
||||
let mut out = Vec::new();
|
||||
for (li, line) in self.lines.iter().enumerate() {
|
||||
if b <= line.char_start || a > line.char_end || (a == line.char_end && li + 1 < self.lines.len()) {
|
||||
let last = li + 1 == self.lines.len();
|
||||
// The empty line after a trailing break has no chars, but a selection reaching the
|
||||
// end of the text covers it: it shows a paragraph-break mark at the line's start.
|
||||
let trailing_empty = last && a < b && b >= line.char_start && line.char_start == line.char_end;
|
||||
if (b <= line.char_start && !trailing_empty) || a > line.char_end || (a == line.char_end && !last) {
|
||||
continue;
|
||||
}
|
||||
if trailing_empty {
|
||||
let x = self.line_end_x(line);
|
||||
out.push(Rect::new(x, line.top, x + 4.0, line.top + line.height));
|
||||
continue;
|
||||
}
|
||||
let gl = self.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]);
|
||||
@@ -1259,10 +1274,12 @@ impl TextLayout {
|
||||
out.extend(Self::mixed_selection(gl, a, b, line));
|
||||
continue;
|
||||
}
|
||||
let (x0, _) = self.caret(a.max(line.char_start));
|
||||
// From the line's own start: `caret(char_start)` after a hard break is the end of the
|
||||
// line above.
|
||||
let x0 = if a <= line.char_start { self.line_start_x(line) } else { self.caret(a).0 };
|
||||
let x1 = if b >= line.char_end {
|
||||
// The last line has no paragraph break to show, so it ends at its glyphs.
|
||||
self.line_end_x(line) + if line.para_end && li + 1 < self.lines.len() && b > line.char_end.saturating_sub(1) { 4.0 } else { 0.0 }
|
||||
self.line_end_x(line) + if line.para_end && !last && b > line.char_end.saturating_sub(1) { 4.0 } else { 0.0 }
|
||||
} else {
|
||||
self.caret(b).0
|
||||
};
|
||||
|
||||
@@ -437,3 +437,47 @@ fn selection_through_text_end_stays_inside_auto_width_box() {
|
||||
let first = r.first().map_or(f64::NAN, |r| r.x1);
|
||||
assert!(first > l.caret(5).0 + 1.0, "{first}");
|
||||
}
|
||||
|
||||
/// The selection rect drawn on line `li` of `l`, if any.
|
||||
fn rect_on(l: &TextLayout, rects: &[figcraft_geom::Rect], li: usize) -> Option<figcraft_geom::Rect> {
|
||||
let top = l.lines.get(li)?.top;
|
||||
rects.iter().find(|r| (r.y0 - top).abs() < 1e-6).copied()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_all_highlights_an_empty_last_line() {
|
||||
if !have_fonts() {
|
||||
return;
|
||||
}
|
||||
// "Hello\n": the trailing break leaves an empty second line (#665).
|
||||
let l = layout(&TextData::new("Hello\n"), None, None);
|
||||
assert_eq!(l.lines.len(), 2);
|
||||
let Some(r) = rect_on(&l, &l.selection_rects(0, 6), 1) else { panic!("no highlight on the empty last line") };
|
||||
// A paragraph-break mark at the line's start, not at the end of "Hello" above.
|
||||
assert!(r.x0.abs() < 1e-6 && (r.width() - 4.0).abs() < 1e-6, "{r:?}");
|
||||
assert!(rect_on(&l, &l.selection_rects(5, 6), 1).is_some());
|
||||
// Selecting part of the first line, or a caret, leaves the empty line alone.
|
||||
assert!(l.selection_rects(0, 1).iter().all(|r| r.y0 < l.lines[1].top - 1e-6), "{:?}", l.selection_rects(0, 1));
|
||||
assert!(l.selection_rects(6, 6).is_empty());
|
||||
// A right-to-left paragraph marks its empty last line too.
|
||||
let rtl = layout(&TextData::new("\u{5E9}\u{5DC}\u{5D5}\u{5DD}\n"), None, None);
|
||||
let Some(r) = rect_on(&rtl, &rtl.selection_rects(0, 5), 1) else { panic!("no highlight on the empty right-to-left line") };
|
||||
assert!(r.x0.abs() < 1e-6 && (r.width() - 4.0).abs() < 1e-6, "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_after_a_line_break_starts_at_the_line_start() {
|
||||
if !have_fonts() {
|
||||
return;
|
||||
}
|
||||
// Not at the end of the line above, where `caret` puts the index after the break.
|
||||
let l = layout(&TextData::new("Hello\nworld"), None, None);
|
||||
let Some(r) = rect_on(&l, &l.selection_rects(0, 11), 1) else { panic!("no highlight on the second line") };
|
||||
assert!(r.x0.abs() < 1e-6 && r.x1 > l.caret(10).0, "{r:?}");
|
||||
// An empty line in the middle shows its break mark at its start.
|
||||
let m = layout(&TextData::new("Hello\n\nworld"), None, None);
|
||||
let Some(r) = rect_on(&m, &m.selection_rects(0, 12), 1) else { panic!("no highlight on the empty line") };
|
||||
assert!(r.x0.abs() < 1e-6 && (r.width() - 4.0).abs() < 1e-6, "{r:?}");
|
||||
// A break followed by text still doesn't highlight the next line's start.
|
||||
assert!(rect_on(&l, &l.selection_rects(0, 6), 1).is_none());
|
||||
}
|
||||
@@ -333,6 +333,7 @@ status = "draft — needs native review"
|
||||
"Current version" = "Aktuální verze"
|
||||
"Cursor chat" = "Chat u kurzoru"
|
||||
"Custom" = "Vlastní"
|
||||
"Custom size" = "Vlastní velikost"
|
||||
"Cut" = "Vyjmout"
|
||||
"Dark" = "Tmavý"
|
||||
"Darken" = "Ztmavit"
|
||||
@@ -638,6 +639,7 @@ status = "draft — needs native review"
|
||||
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Návrh rozhraní a prototypování. Svobodný nástroj s otevřeným zdrojovým kódem, napsaný od základu v jazyce Rust nezávisle na kódu jiných produktů. Součást sady ArtCraft Crafting Apps."
|
||||
"Intersect" = "Průnik"
|
||||
"Intersects" = "Průniky"
|
||||
"Invalid color" = "Neplatná barva"
|
||||
"Invite link" = "Odkaz s pozvánkou"
|
||||
"Invite link copied" = "Odkaz s pozvánkou zkopírován"
|
||||
"It asks permission to:" = "Žádá o oprávnění:"
|
||||
@@ -795,6 +797,7 @@ status = "draft — needs native review"
|
||||
"Nested instance" = "Vnořená instance"
|
||||
"New design file" = "Nový soubor návrhu"
|
||||
"Newest first" = "Od nejnovějších"
|
||||
"Next frame" = "Další rámec"
|
||||
"Next or previous control in the focused region" = "Další nebo předchozí ovládací prvek v zaměřené oblasti"
|
||||
"Next ready for dev" = "Další připravené pro vývoj"
|
||||
"Next size option" = "Další možnost velikosti"
|
||||
@@ -804,6 +807,7 @@ status = "draft — needs native review"
|
||||
"No branches. A branch is a copy of the file to try changes in, then review and merge." = "Žádné větve. Větev je kopie souboru pro vyzkoušení změn, které pak zkontrolujete a sloučíte."
|
||||
"No category" = "Bez kategorie"
|
||||
"No changes" = "Žádné změny"
|
||||
"No color styles" = "Žádné barevné styly"
|
||||
"No colour variables fit here." = "Žádné vhodné barevné proměnné."
|
||||
"No colour variables yet. Create them in Local variables." = "Zatím žádné barevné proměnné. Vytvořte je v Místních proměnných."
|
||||
"No comments yet" = "Zatím žádné komentáře"
|
||||
@@ -811,6 +815,7 @@ status = "draft — needs native review"
|
||||
"No components yet. Select layers and press {keys}." = "Zatím žádné komponenty. Vyberte vrstvy a stiskněte {keys}."
|
||||
"No conflicts." = "Žádné konflikty."
|
||||
"No contributor data was built into this copy." = "Toto sestavení neobsahuje údaje o přispěvatelích."
|
||||
"No device" = "Bez zařízení"
|
||||
"No fonts match" = "Žádná odpovídající písma"
|
||||
"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Kód pro připojení není dostupný (server kódy nepodporuje nebo tuto adresu nelze do kódu vložit): sdílejte odkaz."
|
||||
"No layer changes to show." = "Žádné změny vrstev k zobrazení."
|
||||
@@ -994,6 +999,7 @@ status = "draft — needs native review"
|
||||
"Preview" = "Náhled"
|
||||
"Preview this flow" = "Náhled tohoto toku"
|
||||
"Preview this frame" = "Náhled tohoto rámce"
|
||||
"Previous frame" = "Předchozí rámec"
|
||||
"Previous ready for dev" = "Předchozí připravené pro vývoj"
|
||||
"Progressive" = "Postupné"
|
||||
"Properties" = "Vlastnosti"
|
||||
@@ -1087,6 +1093,7 @@ status = "draft — needs native review"
|
||||
"Resize by the nudge amount" = "Změnit velikost o velikost posunu"
|
||||
"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Přizpůsobit velikost rámců obrazovce zařízení (nebo oknu bez zařízení): omezení a automatické rozvržení se přizpůsobí"
|
||||
"Resize to fit" = "Přizpůsobit velikost obsahu"
|
||||
"Resizing" = "Změna velikosti"
|
||||
"Resolve" = "Vyřešit"
|
||||
"Resolve change" = "Vyřešit změnu"
|
||||
"Resolved" = "Vyřešené"
|
||||
@@ -1160,12 +1167,14 @@ status = "draft — needs native review"
|
||||
"Select all with same" = "Vybrat vše se stejným"
|
||||
"Select layer" = "Vybrat vrstvu"
|
||||
"Select layers to apply the style to" = "Vyberte vrstvy, na které chcete použít styl"
|
||||
"Select layers with this color" = "Vybrat vrstvy s touto barvou"
|
||||
"Select overlapping shapes" = "Vyberte překrývající se tvary"
|
||||
"Select points to edit their position, radius and mirroring" = "Vyberte body a upravte jejich polohu, poloměr a zrcadlení"
|
||||
"Select slot" = "Vybrat slot"
|
||||
"Selection" = "Výběr"
|
||||
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Rámeček výběru, {w} × {h} na pozici {x}, {y}: šipky posouvají, Shift+šipky nebo {cmd}+šipky mění velikost, Enter vybere, Escape zruší"
|
||||
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Rámeček výběru: šipky posouvají, Shift+šipky nebo {cmd}+šipky mění velikost, Enter vybere, Escape zruší"
|
||||
"Selection colors" = "Barvy výběru"
|
||||
"Send backward" = "Přesunout o úroveň níž"
|
||||
"Send report" = "Odeslat hlášení"
|
||||
"Send to back" = "Přesunout do pozadí"
|
||||
@@ -1499,13 +1508,25 @@ status = "draft — needs native review"
|
||||
"can edit the document" = "může upravovat dokument"
|
||||
"conditional prototype actions nested too deeply were skipped" = "podmíněné akce prototypu vnořené příliš hluboko byly přeskočeny"
|
||||
"conditional prototype actions with conditions that can't be evaluated were skipped" = "podmíněné akce prototypu s podmínkami, které nelze vyhodnotit, byly přeskočeny"
|
||||
"connector label background was skipped: style data is missing" = "pozadí popisku spojnice bylo přeskočeno: chybí data stylu"
|
||||
"connector label was skipped: background bounds are invalid" = "popisek spojnice byl přeskočen: hranice pozadí jsou neplatné"
|
||||
"connector label was skipped: background transform is invalid" = "popisek spojnice byl přeskočen: transformace pozadí je neplatná"
|
||||
"connector label was skipped: composed text transform is invalid" = "popisek spojnice byl přeskočen: složená transformace textu je neplatná"
|
||||
"connector label was skipped: derived background bounds are missing" = "popisek spojnice byl přeskočen: chybí odvozené hranice pozadí"
|
||||
"connector label was skipped: derived text bounds are missing" = "popisek spojnice byl přeskočen: chybí odvozené hranice textu"
|
||||
"connector label was skipped: text bounds are invalid" = "popisek spojnice byl přeskočen: hranice textu jsou neplatné"
|
||||
"connector label was skipped: text style data is missing" = "popisek spojnice byl přeskočen: chybí data stylu textu"
|
||||
"connector label was skipped: text transform is invalid" = "popisek spojnice byl přeskočen: transformace textu je neplatná"
|
||||
"connector labels were skipped" = "popisky spojnic byly přeskočeny"
|
||||
"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "vrstvy spojnic se importují jako statické snímky rámců nebo vektorů, s popisky, pokud existují; živé vazby koncových bodů se nezachovávají"
|
||||
"connector snapshot vector data is missing; connector was left empty" = "chybí vektorová data snímku spojnice; spojnice zůstala prázdná"
|
||||
"connectors whose ends can't be placed are empty" = "spojnice, jejichž konce nelze umístit, jsou prázdné"
|
||||
"crop marks" = "ořezové značky"
|
||||
"damaged vector geometry was skipped" = "poškozená vektorová geometrie byla přeskočena"
|
||||
"damaged vector networks import as outlines" = "poškozené vektorové sítě se importují jako obrysy"
|
||||
"development: {path}" = "vývoj: {path}"
|
||||
"document children other than pages were skipped" = "potomci dokumentu, kteří nejsou stránkami, byli přeskočeni"
|
||||
"duplicate grid track sizing entries use equal tracks" = "duplicitní položky velikosti stop mřížky používají stejně velké stopy"
|
||||
"e.g. count + 1" = "např. count + 1"
|
||||
"e.g. count >= 3 and not done" = "např. count >= 3 and not done"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "lomené a zakřivené spojnice bez uložené cesty se importují jako přímky"
|
||||
@@ -1514,14 +1535,20 @@ status = "draft — needs native review"
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "vyplněné oblasti vektoru s vlastní výplní používají výplň vrstvy"
|
||||
"from {server}" = "ze serveru {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "spouštěče herního ovladače bez právě jednoho známého tlačítka ovladače byly přeskočeny"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "seznam stop mřížky přesahuje 1000 položek; stopy používají výchozí stejnou velikost"
|
||||
"grid track sizing is unsupported or malformed; affected tracks use equal sizing" = "velikost stop mřížky není podporována nebo je poškozená; dotčené stopy mají stejnou velikost"
|
||||
"grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "seznam velikostí stop mřížky přesahuje 1000 položek; stopy používají výchozí stejnou velikost"
|
||||
"hidden" = "skryté"
|
||||
"iOS" = "iOS"
|
||||
"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "filtry obrázku jsou nejednoznačné, protože jsou přítomny oba kontejnery filtrů; převod filtrů byl přeskočen"
|
||||
"image filters use FigCraft's approximate renderer" = "filtry obrázku používají přibližné vykreslování FigCraftu"
|
||||
"instance swap properties whose default component isn't in the file were skipped" = "vlastnosti výměny instance, jejichž výchozí komponenta není v souboru, byly přeskočeny"
|
||||
"instances inside their own main component were imported as frames" = "instance uvnitř vlastní hlavní komponenty byly importovány jako rámce"
|
||||
"instances of components that aren't in the file show no layers" = "instance komponent, které nejsou v souboru, nezobrazují žádné vrstvy"
|
||||
"instances of components that aren't in the file were imported as frames" = "instance komponent, které nejsou v souboru, byly importovány jako rámce"
|
||||
"instances that would nest a component inside itself were imported as frames" = "instance, které by vnořily komponentu samu do sebe, byly importovány jako rámce"
|
||||
"instances without a main component were imported as empty frames" = "instance bez hlavní komponenty byly importovány jako prázdné rámce"
|
||||
"invalid internal .fig text path data was ignored" = "neplatná interní data textu na cestě ze souboru .fig byla ignorována"
|
||||
"invalid or oversized vector networks import as outlines" = "neplatné nebo příliš velké vektorové sítě se importují jako obrysy"
|
||||
"invalid or oversized vector path data was skipped" = "neplatná nebo příliš velká data vektorových cest byla přeskočena"
|
||||
"just now" = "právě teď"
|
||||
@@ -1529,12 +1556,18 @@ status = "draft — needs native review"
|
||||
"key up triggers import as key presses" = "spouštěče uvolnění klávesy se importují jako stisky klávesy"
|
||||
"layers without a position were placed at their parent's origin" = "vrstvy bez pozice byly umístěny do počátku nadřazeného prvku"
|
||||
"layers without a type were skipped" = "vrstvy bez typu byly přeskočeny"
|
||||
"legacy image filter container is unsupported; filter conversion was skipped" = "starší kontejner filtrů obrázku není podporován; převod filtrů byl přeskočen"
|
||||
"locked" = "zamknuté"
|
||||
"malformed image filters container was skipped" = "poškozený kontejner filtrů obrázku byl přeskočen"
|
||||
"malformed image paintFilter container was skipped" = "poškozený kontejner paintFilter obrázku byl přeskočen"
|
||||
"malformed slot content assignment was skipped" = "poškozené přiřazení obsahu slotu bylo přeskočeno"
|
||||
"no bleed" = "bez spadávky"
|
||||
"nobody else here yet" = "zatím tu není nikdo další"
|
||||
"non-default slot settings are not represented and were skipped" = "jiná než výchozí nastavení slotů nelze znázornit a byla přeskočena"
|
||||
"now" = "nyní"
|
||||
"or" = "nebo"
|
||||
"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "spojení tahu v bodech byla zachována; přerušované tahy a tahy s proměnnou šířkou se kreslí se spojením vrstvy"
|
||||
"preferred slot component keys cannot be resolved to file nodes and were skipped" = "klíče preferovaných komponent slotů nelze přiřadit k vrstvám souboru, byly přeskočeny"
|
||||
"prototype actions of unknown types were skipped" = "akce prototypu neznámých typů byly přeskočeny"
|
||||
"prototype actions without a destination in the file were skipped" = "akce prototypu bez cíle v souboru byly přeskočeny"
|
||||
"prototype expressions on variables that aren't in the variables JSON were skipped" = "výrazy prototypu s proměnnými, které nejsou v JSON proměnných, byly přeskočeny"
|
||||
@@ -1552,8 +1585,14 @@ status = "draft — needs native review"
|
||||
"set-variable actions with values that don't fit the variable were skipped" = "akce nastavení proměnné s hodnotami, které neodpovídají proměnné, byly přeskočeny"
|
||||
"set-variable actions without a value were skipped" = "akce nastavení proměnné bez hodnoty byly přeskočeny"
|
||||
"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "akce nastavení režimu proměnných u kolekcí nebo režimů, které nejsou v JSON proměnných, byly přeskočeny"
|
||||
"slot content assignment target is missing or is not slot content" = "cíl přiřazení obsahu slotu chybí nebo není obsahem slotu"
|
||||
"slot content assignment uses the empty GUID sentinel and was skipped" = "přiřazení obsahu slotu používá prázdné GUID jako zarážku a bylo přeskočeno"
|
||||
"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "některé vektorové body odkazují na nastavení bodů, která v souboru chybí; používají spojení vrstvy a nezaoblené rohy"
|
||||
"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "některé vektorové segmenty odkazují na nastavení stylu, která v souboru chybí; používají tah vrstvy"
|
||||
"stale or invalid grid track sizing entries were ignored" = "zastaralé nebo neplatné položky velikosti stop mřížky byly ignorovány"
|
||||
"stars and polygons without point counts or geometry use the default point count" = "hvězdy a mnohoúhelníky bez počtu bodů nebo geometrie používají výchozí počet bodů"
|
||||
"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "nastavení stylu některých vektorových segmentů nejsou podporována; tyto segmenty používají tah vrstvy"
|
||||
"text path underline or strikethrough decoration is not rendered" = "podtržení ani přeškrtnutí textu na cestě se nevykresluje"
|
||||
"text style overrides refer to missing table entries" = "přepsání textových stylů odkazují na chybějící položky tabulky"
|
||||
"the file has no pages" = "soubor nemá žádné stránky"
|
||||
"the plugin" = "plugin"
|
||||
@@ -1684,6 +1723,7 @@ status = "draft — needs native review"
|
||||
"arrange.packH" = "Rozmístit těsně vodorovně"
|
||||
"arrange.packV" = "Rozmístit těsně svisle"
|
||||
"arrange.roundToPixels" = "Zaokrouhlit na pixely"
|
||||
"arrange.spacing" = "Rozestupy"
|
||||
"arrange.tidy" = "Uspořádat"
|
||||
"bool.exclude" = "Vyloučit výběr"
|
||||
"bool.flatten" = "Sloučit booleovskou skupinu do vektoru"
|
||||
@@ -1991,6 +2031,10 @@ status = "draft — needs native review"
|
||||
"select.sameStroke" = "Vybrat stejný tah"
|
||||
"select.sameText" = "Vybrat stejné vlastnosti textu"
|
||||
"select.set" = "Vybrat"
|
||||
"selection.applyColorStyle" = "Použít barevný styl na výběr"
|
||||
"selection.colors" = "Barvy výběru"
|
||||
"selection.selectColor" = "Vybrat vrstvy s touto barvou"
|
||||
"selection.setPaint" = "Změnit barvu výběru"
|
||||
"shape.arrow" = "Šipka"
|
||||
"shape.ellipse" = "Elipsa"
|
||||
"shape.line" = "Čára"
|
||||
|
||||
@@ -329,6 +329,7 @@ status = "draft — needs native review"
|
||||
"Current version" = "Aktuelle Version"
|
||||
"Cursor chat" = "Cursor-Chat"
|
||||
"Custom" = "Benutzerdefiniert"
|
||||
"Custom size" = "Benutzerdefinierte Größe"
|
||||
"Cut" = "Ausschneiden"
|
||||
"Dark" = "Dunkel"
|
||||
"Darken" = "Abdunkeln"
|
||||
@@ -623,6 +624,7 @@ status = "draft — needs native review"
|
||||
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Interface-Design und Prototyping. Ein kostenloses Open-Source-Designwerkzeug, als Clean-Room-Entwicklung von Grund auf in Rust geschrieben, Teil der ArtCraft Crafting Apps."
|
||||
"Intersect" = "Schnittmenge"
|
||||
"Intersects" = "Schnittmengen"
|
||||
"Invalid color" = "Ungültige Farbe"
|
||||
"Invite link" = "Einladungslink"
|
||||
"Invite link copied" = "Einladungslink kopiert"
|
||||
"It asks permission to:" = "Es möchte:"
|
||||
@@ -768,6 +770,7 @@ status = "draft — needs native review"
|
||||
"Navigate to" = "Navigieren zu"
|
||||
"Nested instance" = "Verschachtelte Instanz"
|
||||
"New design file" = "Neue Designdatei"
|
||||
"Next frame" = "Nächster Rahmen"
|
||||
"Next or previous control in the focused region" = "Nächstes oder vorheriges Steuerelement im fokussierten Bereich"
|
||||
"Next ready for dev" = "Nächstes bereit für Entwicklung"
|
||||
"Next size option" = "Nächste Größenoption"
|
||||
@@ -777,6 +780,7 @@ status = "draft — needs native review"
|
||||
"No branches. A branch is a copy of the file to try changes in, then review and merge." = "Keine Branches. Ein Branch ist eine Kopie der Datei, in der Sie Änderungen ausprobieren, prüfen und dann zusammenführen."
|
||||
"No category" = "Keine Kategorie"
|
||||
"No changes" = "Keine Änderungen"
|
||||
"No color styles" = "Keine Farbstile"
|
||||
"No colour variables fit here." = "Hier passen keine Farbvariablen."
|
||||
"No colour variables yet. Create them in Local variables." = "Noch keine Farbvariablen. Erstellen Sie sie unter Lokale Variablen."
|
||||
"No comments yet" = "Noch keine Kommentare"
|
||||
@@ -784,6 +788,7 @@ status = "draft — needs native review"
|
||||
"No components yet. Select layers and press {keys}." = "Noch keine Komponenten. Wählen Sie Ebenen aus und drücken Sie {keys}."
|
||||
"No conflicts." = "Keine Konflikte."
|
||||
"No contributor data was built into this copy." = "Diese Kopie enthält keine Daten zu Mitwirkenden."
|
||||
"No device" = "Kein Gerät"
|
||||
"No fonts match" = "Keine passenden Schriften"
|
||||
"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Hier gibt es keinen Beitrittscode (der Server ist älter als Beitrittscodes, oder diese Adresse passt in keinen): Teilen Sie stattdessen den Link."
|
||||
"No layer changes to show." = "Keine Ebenenänderungen vorhanden."
|
||||
@@ -960,6 +965,7 @@ status = "draft — needs native review"
|
||||
"Preview" = "Vorschau"
|
||||
"Preview this flow" = "Vorschau dieses Ablaufs"
|
||||
"Preview this frame" = "Vorschau dieses Rahmens"
|
||||
"Previous frame" = "Vorheriger Rahmen"
|
||||
"Previous ready for dev" = "Vorheriges bereit für Entwicklung"
|
||||
"Progressive" = "Progressiv"
|
||||
"Properties" = "Eigenschaften"
|
||||
@@ -1053,6 +1059,7 @@ status = "draft — needs native review"
|
||||
"Resize by the nudge amount" = "Um die Schrittweite skalieren"
|
||||
"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Passt Rahmen an den Bildschirm des Geräts an (ohne Gerät an das Fenster): ihre Constraints und ihr Auto-Layout reagieren"
|
||||
"Resize to fit" = "Größe an Inhalt anpassen"
|
||||
"Resizing" = "Größenanpassung"
|
||||
"Resolve" = "Erledigen"
|
||||
"Resolve change" = "Änderung erledigen"
|
||||
"Resolved" = "Erledigt"
|
||||
@@ -1124,12 +1131,14 @@ status = "draft — needs native review"
|
||||
"Select all with same" = "Alle auswählen mit gleicher"
|
||||
"Select layer" = "Ebene auswählen"
|
||||
"Select layers to apply the style to" = "Wählen Sie die Ebenen aus, auf die der Stil angewendet werden soll"
|
||||
"Select layers with this color" = "Ebenen mit dieser Farbe auswählen"
|
||||
"Select overlapping shapes" = "Wählen Sie überlappende Formen aus"
|
||||
"Select points to edit their position, radius and mirroring" = "Wählen Sie Punkte aus, um Position, Radius und Spiegelung zu bearbeiten"
|
||||
"Select slot" = "Slot auswählen"
|
||||
"Selection" = "Auswahl"
|
||||
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Auswahlrahmen, {w} × {h} bei {x}, {y}: Pfeiltasten verschieben ihn, Umschalt+Pfeiltasten oder {cmd}+Pfeiltasten ändern die Größe, Eingabe wählt aus, Esc bricht ab"
|
||||
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Auswahlrahmen: Pfeiltasten verschieben ihn, Umschalt+Pfeiltasten oder {cmd}+Pfeiltasten ändern die Größe, Eingabe wählt aus, Esc bricht ab"
|
||||
"Selection colors" = "Auswahlfarben"
|
||||
"Send backward" = "Eine Ebene nach hinten"
|
||||
"Send report" = "Meldung senden"
|
||||
"Send to back" = "In den Hintergrund"
|
||||
@@ -1449,13 +1458,25 @@ status = "draft — needs native review"
|
||||
"can edit the document" = "kann das Dokument bearbeiten"
|
||||
"conditional prototype actions nested too deeply were skipped" = "zu tief verschachtelte bedingte Prototyp-Aktionen wurden übersprungen"
|
||||
"conditional prototype actions with conditions that can't be evaluated were skipped" = "bedingte Prototyp-Aktionen mit nicht auswertbaren Bedingungen wurden übersprungen"
|
||||
"connector label background was skipped: style data is missing" = "Hintergrund der Verbinderbeschriftung wurde übersprungen: Stildaten fehlen"
|
||||
"connector label was skipped: background bounds are invalid" = "Verbinderbeschriftung wurde übersprungen: Grenzen des Hintergrunds sind ungültig"
|
||||
"connector label was skipped: background transform is invalid" = "Verbinderbeschriftung wurde übersprungen: Transformation des Hintergrunds ist ungültig"
|
||||
"connector label was skipped: composed text transform is invalid" = "Verbinderbeschriftung wurde übersprungen: zusammengesetzte Texttransformation ist ungültig"
|
||||
"connector label was skipped: derived background bounds are missing" = "Verbinderbeschriftung wurde übersprungen: abgeleitete Grenzen des Hintergrunds fehlen"
|
||||
"connector label was skipped: derived text bounds are missing" = "Verbinderbeschriftung wurde übersprungen: abgeleitete Textgrenzen fehlen"
|
||||
"connector label was skipped: text bounds are invalid" = "Verbinderbeschriftung wurde übersprungen: Textgrenzen sind ungültig"
|
||||
"connector label was skipped: text style data is missing" = "Verbinderbeschriftung wurde übersprungen: Textstildaten fehlen"
|
||||
"connector label was skipped: text transform is invalid" = "Verbinderbeschriftung wurde übersprungen: Texttransformation ist ungültig"
|
||||
"connector labels were skipped" = "Verbinderbeschriftungen wurden übersprungen"
|
||||
"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "Verbinderebenen werden als statische Rahmen- oder Vektor-Momentaufnahmen importiert, mit Beschriftungen, sofern vorhanden; dynamische Bindungen der Endpunkte bleiben nicht erhalten"
|
||||
"connector snapshot vector data is missing; connector was left empty" = "Vektordaten der Verbinder-Momentaufnahme fehlen; der Verbinder bleibt leer"
|
||||
"connectors whose ends can't be placed are empty" = "Verbinder, deren Enden sich nicht platzieren lassen, sind leer"
|
||||
"crop marks" = "Schnittmarken"
|
||||
"damaged vector geometry was skipped" = "beschädigte Vektorgeometrie wurde übersprungen"
|
||||
"damaged vector networks import as outlines" = "beschädigte Vektornetze werden als Umrisse importiert"
|
||||
"development: {path}" = "Entwicklung: {path}"
|
||||
"document children other than pages were skipped" = "Dokumentinhalte, die keine Seiten sind, wurden übersprungen"
|
||||
"duplicate grid track sizing entries use equal tracks" = "doppelte Größeneinträge für Rasterspuren verwenden gleich große Spuren"
|
||||
"e.g. count + 1" = "z. B. count + 1"
|
||||
"e.g. count >= 3 and not done" = "z. B. count >= 3 and not done"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "gewinkelte und gebogene Verbinder ohne gespeicherten Pfad werden als gerade Linien importiert"
|
||||
@@ -1464,26 +1485,38 @@ status = "draft — needs native review"
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "gefüllte Bereiche eines Vektors mit eigener Füllung verwenden die Füllung der Ebene"
|
||||
"from {server}" = "von {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "Gamepad-Auslöser ohne genau eine bekannte Controllertaste wurden übersprungen"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "die Liste der Rasterspuren hat mehr als 1000 Einträge; die Spuren verwenden die standardmäßige gleiche Größe"
|
||||
"grid track sizing is unsupported or malformed; affected tracks use equal sizing" = "die Größe der Rasterspuren wird nicht unterstützt oder ist fehlerhaft; betroffene Spuren sind gleich groß"
|
||||
"grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "die Liste der Rasterspurgrößen hat mehr als 1000 Einträge; die Spuren verwenden die standardmäßige gleiche Größe"
|
||||
"hidden" = "ausgeblendet"
|
||||
"iOS" = "iOS"
|
||||
"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "Bildfilter sind mehrdeutig, weil beide Filtercontainer vorhanden sind; die Filterumwandlung wurde übersprungen"
|
||||
"image filters use FigCraft's approximate renderer" = "Bildfilter verwenden die näherungsweise Darstellung von FigCraft"
|
||||
"instance swap properties whose default component isn't in the file were skipped" = "Instanztausch-Eigenschaften, deren Standardkomponente nicht in der Datei ist, wurden übersprungen"
|
||||
"instances inside their own main component were imported as frames" = "Instanzen innerhalb ihrer eigenen Hauptkomponente wurden als Rahmen importiert"
|
||||
"instances of components that aren't in the file show no layers" = "Instanzen von Komponenten, die nicht in der Datei sind, zeigen keine Ebenen"
|
||||
"instances of components that aren't in the file were imported as frames" = "Instanzen von Komponenten, die nicht in der Datei sind, wurden als Rahmen importiert"
|
||||
"instances that would nest a component inside itself were imported as frames" = "Instanzen, die eine Komponente in sich selbst verschachteln würden, wurden als Rahmen importiert"
|
||||
"instances without a main component were imported as empty frames" = "Instanzen ohne Hauptkomponente wurden als leere Rahmen importiert"
|
||||
"invalid internal .fig text path data was ignored" = "ungültige interne .fig-Daten für Text auf Pfad wurden ignoriert"
|
||||
"invalid or oversized vector networks import as outlines" = "ungültige oder zu große Vektornetze werden als Umrisse importiert"
|
||||
"invalid or oversized vector path data was skipped" = "ungültige oder zu große Vektorpfaddaten wurden übersprungen"
|
||||
"key triggers without a supported key were skipped" = "Tastenauslöser ohne unterstützte Taste wurden übersprungen"
|
||||
"key up triggers import as key presses" = "Auslöser beim Loslassen einer Taste werden als Tastendruck importiert"
|
||||
"layers without a position were placed at their parent's origin" = "Ebenen ohne Position wurden am Ursprung ihres übergeordneten Elements platziert"
|
||||
"layers without a type were skipped" = "Ebenen ohne Typ wurden übersprungen"
|
||||
"legacy image filter container is unsupported; filter conversion was skipped" = "der veraltete Bildfiltercontainer wird nicht unterstützt; die Filterumwandlung wurde übersprungen"
|
||||
"locked" = "gesperrt"
|
||||
"malformed image filters container was skipped" = "fehlerhafter Bildfiltercontainer wurde übersprungen"
|
||||
"malformed image paintFilter container was skipped" = "fehlerhafter paintFilter-Container des Bildes wurde übersprungen"
|
||||
"malformed slot content assignment was skipped" = "fehlerhafte Zuweisung von Slot-Inhalt wurde übersprungen"
|
||||
"no bleed" = "kein Anschnitt"
|
||||
"nobody else here yet" = "noch niemand sonst hier"
|
||||
"non-default slot settings are not represented and were skipped" = "vom Standard abweichende Slot-Einstellungen werden nicht abgebildet und wurden übersprungen"
|
||||
"now" = "jetzt"
|
||||
"or" = "oder"
|
||||
"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "die Konturecken der Punkte wurden übernommen; gestrichelte Konturen und Konturen mit variabler Breite werden mit der Ecke der Ebene gezeichnet"
|
||||
"preferred slot component keys cannot be resolved to file nodes and were skipped" = "bevorzugte Komponentenschlüssel von Slots lassen sich keinen Ebenen der Datei zuordnen und wurden übersprungen"
|
||||
"prototype actions of unknown types were skipped" = "Prototyp-Aktionen unbekannter Art wurden übersprungen"
|
||||
"prototype actions without a destination in the file were skipped" = "Prototyp-Aktionen ohne Ziel in der Datei wurden übersprungen"
|
||||
"prototype expressions on variables that aren't in the variables JSON were skipped" = "Prototyp-Ausdrücke mit Variablen, die nicht im Variablen-JSON sind, wurden übersprungen"
|
||||
@@ -1501,8 +1534,14 @@ status = "draft — needs native review"
|
||||
"set-variable actions with values that don't fit the variable were skipped" = "Aktionen „Variable festlegen“ mit Werten, die nicht zur Variable passen, wurden übersprungen"
|
||||
"set-variable actions without a value were skipped" = "Aktionen „Variable festlegen“ ohne Wert wurden übersprungen"
|
||||
"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "Aktionen „Variablenmodus festlegen“ für Sammlungen oder Modi, die nicht im Variablen-JSON sind, wurden übersprungen"
|
||||
"slot content assignment target is missing or is not slot content" = "das Ziel der Slot-Inhaltszuweisung fehlt oder ist kein Slot-Inhalt"
|
||||
"slot content assignment uses the empty GUID sentinel and was skipped" = "die Slot-Inhaltszuweisung verwendet die leere GUID als Platzhalterwert und wurde übersprungen"
|
||||
"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "einige Vektorpunkte verweisen auf Punkteinstellungen, die in der Datei fehlen; sie übernehmen die Ecken der Ebenenkontur und bleiben ungerundet"
|
||||
"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "einige Vektorsegmente verweisen auf Stileinstellungen, die in der Datei fehlen; sie verwenden die Kontur der Ebene"
|
||||
"stale or invalid grid track sizing entries were ignored" = "veraltete oder ungültige Größeneinträge für Rasterspuren wurden ignoriert"
|
||||
"stars and polygons without point counts or geometry use the default point count" = "Sterne und Polygone ohne Punktanzahl oder Geometrie verwenden die Standard-Punktanzahl"
|
||||
"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "Stileinstellungen einiger Vektorsegmente werden nicht unterstützt; diese Segmente verwenden die Kontur der Ebene"
|
||||
"text path underline or strikethrough decoration is not rendered" = "Unter- oder Durchstreichung von Text auf Pfad wird nicht dargestellt"
|
||||
"text style overrides refer to missing table entries" = "Textstil-Überschreibungen verweisen auf fehlende Tabelleneinträge"
|
||||
"the file has no pages" = "die Datei hat keine Seiten"
|
||||
"the plugin" = "das Plugin"
|
||||
@@ -1631,6 +1670,7 @@ status = "draft — needs native review"
|
||||
"arrange.packH" = "Horizontal zusammenschieben"
|
||||
"arrange.packV" = "Vertikal zusammenschieben"
|
||||
"arrange.roundToPixels" = "Auf Pixel runden"
|
||||
"arrange.spacing" = "Abstand"
|
||||
"arrange.tidy" = "Aufräumen"
|
||||
"bool.exclude" = "Auswahl ausschließen"
|
||||
"bool.flatten" = "Boolesche Gruppe zusammenfügen"
|
||||
@@ -1934,6 +1974,10 @@ status = "draft — needs native review"
|
||||
"select.sameStroke" = "Gleiche Kontur auswählen"
|
||||
"select.sameText" = "Gleiche Texteigenschaften auswählen"
|
||||
"select.set" = "Auswählen"
|
||||
"selection.applyColorStyle" = "Farbstil auf Auswahl anwenden"
|
||||
"selection.colors" = "Auswahlfarben"
|
||||
"selection.selectColor" = "Ebenen mit dieser Farbe auswählen"
|
||||
"selection.setPaint" = "Farbe der Auswahl ändern"
|
||||
"shape.arrow" = "Pfeil"
|
||||
"shape.ellipse" = "Ellipse"
|
||||
"shape.line" = "Linie"
|
||||
|
||||
@@ -341,6 +341,7 @@ name = "English"
|
||||
"Current version" = "Current version"
|
||||
"Cursor chat" = "Cursor chat"
|
||||
"Custom" = "Custom"
|
||||
"Custom size" = "Custom size"
|
||||
"Cut" = "Cut"
|
||||
"Dark" = "Dark"
|
||||
"Darken" = "Darken"
|
||||
@@ -651,6 +652,7 @@ name = "English"
|
||||
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps."
|
||||
"Intersect" = "Intersect"
|
||||
"Intersects" = "Intersects"
|
||||
"Invalid color" = "Invalid color"
|
||||
"Invite link" = "Invite link"
|
||||
"Invite link copied" = "Invite link copied"
|
||||
"It asks permission to:" = "It asks permission to:"
|
||||
@@ -809,6 +811,7 @@ name = "English"
|
||||
"New design file" = "New design file"
|
||||
"Newest first" = "Newest first"
|
||||
"Next or previous control in the focused region" = "Next or previous control in the focused region"
|
||||
"Next frame" = "Next frame"
|
||||
"Next ready for dev" = "Next ready for dev"
|
||||
"Next size option" = "Next size option"
|
||||
"No assets: no export settings, icons or images. Add export settings in the Design tab." = "No assets: no export settings, icons or images. Add export settings in the Design tab."
|
||||
@@ -817,6 +820,7 @@ name = "English"
|
||||
"No branches. A branch is a copy of the file to try changes in, then review and merge." = "No branches. A branch is a copy of the file to try changes in, then review and merge."
|
||||
"No category" = "No category"
|
||||
"No changes" = "No changes"
|
||||
"No color styles" = "No color styles"
|
||||
"No colour variables fit here." = "No colour variables fit here."
|
||||
"No colour variables yet. Create them in Local variables." = "No colour variables yet. Create them in Local variables."
|
||||
"No comments yet" = "No comments yet"
|
||||
@@ -824,6 +828,7 @@ name = "English"
|
||||
"No components yet. Select layers and press {keys}." = "No components yet. Select layers and press {keys}."
|
||||
"No conflicts." = "No conflicts."
|
||||
"No contributor data was built into this copy." = "No contributor data was built into this copy."
|
||||
"No device" = "No device"
|
||||
"No fonts match" = "No fonts match"
|
||||
"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead."
|
||||
"No layer changes to show." = "No layer changes to show."
|
||||
@@ -838,6 +843,7 @@ name = "English"
|
||||
"No model credits were built into this copy." = "No model credits were built into this copy."
|
||||
"No number variables fit here." = "No number variables fit here."
|
||||
"No number variables yet. Create them in Local variables." = "No number variables yet. Create them in Local variables."
|
||||
"No other pages" = "No other pages"
|
||||
"No paints" = "No paints"
|
||||
"No plugins on {server}." = "No plugins on {server}."
|
||||
"No plugins yet. Import a development plugin from its figcraft-plugin.json." = "No plugins yet. Import a development plugin from its figcraft-plugin.json."
|
||||
@@ -1012,6 +1018,7 @@ name = "English"
|
||||
"Preview" = "Preview"
|
||||
"Preview this flow" = "Preview this flow"
|
||||
"Preview this frame" = "Preview this frame"
|
||||
"Previous frame" = "Previous frame"
|
||||
"Previous ready for dev" = "Previous ready for dev"
|
||||
"Progressive" = "Progressive"
|
||||
"Properties" = "Properties"
|
||||
@@ -1106,6 +1113,7 @@ name = "English"
|
||||
"Resize by the nudge amount" = "Resize by the nudge amount"
|
||||
"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond"
|
||||
"Resize to fit" = "Resize to fit"
|
||||
"Resizing" = "Resizing"
|
||||
"Resolve" = "Resolve"
|
||||
"Resolve change" = "Resolve change"
|
||||
"Resolved" = "Resolved"
|
||||
@@ -1180,9 +1188,11 @@ name = "English"
|
||||
"Select all with same" = "Select all with same"
|
||||
"Select layer" = "Select layer"
|
||||
"Select layers to apply the style to" = "Select layers to apply the style to"
|
||||
"Select layers with this color" = "Select layers with this color"
|
||||
"Select overlapping shapes" = "Select overlapping shapes"
|
||||
"Select points to edit their position, radius and mirroring" = "Select points to edit their position, radius and mirroring"
|
||||
"Select slot" = "Select slot"
|
||||
"Selection colors" = "Selection colors"
|
||||
"Selection" = "Selection"
|
||||
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels"
|
||||
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels"
|
||||
@@ -1526,13 +1536,25 @@ name = "English"
|
||||
"can edit the document" = "can edit the document"
|
||||
"conditional prototype actions nested too deeply were skipped" = "conditional prototype actions nested too deeply were skipped"
|
||||
"conditional prototype actions with conditions that can't be evaluated were skipped" = "conditional prototype actions with conditions that can't be evaluated were skipped"
|
||||
"connector label background was skipped: style data is missing" = "connector label background was skipped: style data is missing"
|
||||
"connector label was skipped: background bounds are invalid" = "connector label was skipped: background bounds are invalid"
|
||||
"connector label was skipped: background transform is invalid" = "connector label was skipped: background transform is invalid"
|
||||
"connector label was skipped: composed text transform is invalid" = "connector label was skipped: composed text transform is invalid"
|
||||
"connector label was skipped: derived background bounds are missing" = "connector label was skipped: derived background bounds are missing"
|
||||
"connector label was skipped: derived text bounds are missing" = "connector label was skipped: derived text bounds are missing"
|
||||
"connector label was skipped: text bounds are invalid" = "connector label was skipped: text bounds are invalid"
|
||||
"connector label was skipped: text style data is missing" = "connector label was skipped: text style data is missing"
|
||||
"connector label was skipped: text transform is invalid" = "connector label was skipped: text transform is invalid"
|
||||
"connector labels were skipped" = "connector labels were skipped"
|
||||
"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained"
|
||||
"connector snapshot vector data is missing; connector was left empty" = "connector snapshot vector data is missing; connector was left empty"
|
||||
"connectors whose ends can't be placed are empty" = "connectors whose ends can't be placed are empty"
|
||||
"crop marks" = "crop marks"
|
||||
"damaged vector geometry was skipped" = "damaged vector geometry was skipped"
|
||||
"damaged vector networks import as outlines" = "damaged vector networks import as outlines"
|
||||
"development: {path}" = "development: {path}"
|
||||
"document children other than pages were skipped" = "document children other than pages were skipped"
|
||||
"duplicate grid track sizing entries use equal tracks" = "duplicate grid track sizing entries use equal tracks"
|
||||
"e.g. count + 1" = "e.g. count + 1"
|
||||
"e.g. count >= 3 and not done" = "e.g. count >= 3 and not done"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "elbowed and curved connectors without a saved path import as straight lines"
|
||||
@@ -1541,14 +1563,20 @@ name = "English"
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "filled areas of a vector with their own fill use the layer's fill"
|
||||
"from {server}" = "from {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "gamepad triggers without exactly one known controller button were skipped"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "grid track list exceeds 1000 entries; the tracks use the default equal sizing"
|
||||
"grid track sizing is unsupported or malformed; affected tracks use equal sizing" = "grid track sizing is unsupported or malformed; affected tracks use equal sizing"
|
||||
"grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing"
|
||||
"hidden" = "hidden"
|
||||
"iOS" = "iOS"
|
||||
"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "image filters are ambiguous because both filter containers are present; filter conversion was skipped"
|
||||
"image filters use FigCraft's approximate renderer" = "image filters use FigCraft's approximate renderer"
|
||||
"instance swap properties whose default component isn't in the file were skipped" = "instance swap properties whose default component isn't in the file were skipped"
|
||||
"instances inside their own main component were imported as frames" = "instances inside their own main component were imported as frames"
|
||||
"instances of components that aren't in the file show no layers" = "instances of components that aren't in the file show no layers"
|
||||
"instances of components that aren't in the file were imported as frames" = "instances of components that aren't in the file were imported as frames"
|
||||
"instances that would nest a component inside itself were imported as frames" = "instances that would nest a component inside itself were imported as frames"
|
||||
"instances without a main component were imported as empty frames" = "instances without a main component were imported as empty frames"
|
||||
"invalid internal .fig text path data was ignored" = "invalid internal .fig text path data was ignored"
|
||||
"invalid or oversized vector networks import as outlines" = "invalid or oversized vector networks import as outlines"
|
||||
"invalid or oversized vector path data was skipped" = "invalid or oversized vector path data was skipped"
|
||||
"just now" = "just now"
|
||||
@@ -1556,12 +1584,18 @@ name = "English"
|
||||
"key up triggers import as key presses" = "key up triggers import as key presses"
|
||||
"layers without a position were placed at their parent's origin" = "layers without a position were placed at their parent's origin"
|
||||
"layers without a type were skipped" = "layers without a type were skipped"
|
||||
"legacy image filter container is unsupported; filter conversion was skipped" = "legacy image filter container is unsupported; filter conversion was skipped"
|
||||
"locked" = "locked"
|
||||
"malformed image filters container was skipped" = "malformed image filters container was skipped"
|
||||
"malformed image paintFilter container was skipped" = "malformed image paintFilter container was skipped"
|
||||
"malformed slot content assignment was skipped" = "malformed slot content assignment was skipped"
|
||||
"no bleed" = "no bleed"
|
||||
"nobody else here yet" = "nobody else here yet"
|
||||
"non-default slot settings are not represented and were skipped" = "non-default slot settings are not represented and were skipped"
|
||||
"now" = "now"
|
||||
"or" = "or"
|
||||
"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "point stroke joins were preserved; dashed or variable-width strokes render with the layer join"
|
||||
"preferred slot component keys cannot be resolved to file nodes and were skipped" = "preferred slot component keys cannot be resolved to file nodes and were skipped"
|
||||
"prototype actions of unknown types were skipped" = "prototype actions of unknown types were skipped"
|
||||
"prototype actions without a destination in the file were skipped" = "prototype actions without a destination in the file were skipped"
|
||||
"prototype expressions on variables that aren't in the variables JSON were skipped" = "prototype expressions on variables that aren't in the variables JSON were skipped"
|
||||
@@ -1579,8 +1613,14 @@ name = "English"
|
||||
"set-variable actions with values that don't fit the variable were skipped" = "set-variable actions with values that don't fit the variable were skipped"
|
||||
"set-variable actions without a value were skipped" = "set-variable actions without a value were skipped"
|
||||
"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped"
|
||||
"slot content assignment target is missing or is not slot content" = "slot content assignment target is missing or is not slot content"
|
||||
"slot content assignment uses the empty GUID sentinel and was skipped" = "slot content assignment uses the empty GUID sentinel and was skipped"
|
||||
"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "some vector points refer to point settings missing from the file; they use the layer's joins and plain corners"
|
||||
"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "some vector segments refer to style settings missing from the file; they use the layer's stroke"
|
||||
"stale or invalid grid track sizing entries were ignored" = "stale or invalid grid track sizing entries were ignored"
|
||||
"stars and polygons without point counts or geometry use the default point count" = "stars and polygons without point counts or geometry use the default point count"
|
||||
"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "style settings of some vector segments aren't supported; those segments use the layer's stroke"
|
||||
"text path underline or strikethrough decoration is not rendered" = "text path underline or strikethrough decoration is not rendered"
|
||||
"text style overrides refer to missing table entries" = "text style overrides refer to missing table entries"
|
||||
"the file has no pages" = "the file has no pages"
|
||||
"the plugin" = "the plugin"
|
||||
@@ -1685,4 +1725,4 @@ name = "English"
|
||||
"⌘ arrows" = "⌘ arrows"
|
||||
"⌘ click" = "⌘ click"
|
||||
"⌘ scroll" = "⌘ scroll"
|
||||
"⌥ drag" = "⌥ drag"
|
||||
"⌥ drag" = "⌥ drag"
|
||||
@@ -83,6 +83,9 @@ status = "draft — needs native review"
|
||||
"You'll get the shared file as a new tab and edit it together." = "Recibirás el archivo compartido en una pestaña nueva y lo editarán en conjunto."
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "se omitieron los desencadenantes de control sin exactamente un botón de control conocido"
|
||||
"key up triggers import as key presses" = "los desencadenantes al soltar una tecla se importan como presiones de tecla"
|
||||
"non-default slot settings are not represented and were skipped" = "la configuración de ranura no predeterminada no se representa y se omitió"
|
||||
"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "algunos segmentos vectoriales hacen referencia a configuraciones de estilo que faltan en el archivo; usan el trazo de la capa"
|
||||
"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "la configuración de estilo de algunos segmentos vectoriales no es compatible; esos segmentos usan el trazo de la capa"
|
||||
"{name} is forgotten on this computer; the file stays on {server}" = "{name} se olvidó en esta computadora; el archivo se queda en {server}"
|
||||
"{n} added" = { one = "{n} agregada", other = "{n} agregadas" }
|
||||
|
||||
|
||||
Loaded 100 of 168 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user