mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-22 19:08:51 +00:00
Compare commits
128
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
104b80cd58 | ||
|
|
55a0712b11 | ||
|
|
bec13a4d07 | ||
|
|
04e04f3275 | ||
|
|
67610bbba4 | ||
|
|
401af64bba | ||
|
|
27fedf0aa8 | ||
|
|
abd03d9f51 | ||
|
|
bcfaa3daef | ||
|
|
db43dc5a4a | ||
|
|
cf6bd4eeb2 | ||
|
|
47438dd4a2 | ||
|
|
84a86e1d84 | ||
|
|
c846f157dc | ||
|
|
3f5f673ebc | ||
|
|
c7acd1a81d | ||
|
|
f7188de93d | ||
|
|
37419afe14 | ||
|
|
d574eaac1d | ||
|
|
154baa4467 | ||
|
|
e25c48cd56 | ||
|
|
42242e3c8b | ||
|
|
89c5d3dfe6 | ||
|
|
7fd936c8ba | ||
|
|
cbf6d2df41 | ||
|
|
cc12b8a3ec | ||
|
|
7ee2772da8 | ||
|
|
694c3114e8 | ||
|
|
a7352c1860 | ||
|
|
913634bec8 | ||
|
|
4a038a5f63 | ||
|
|
76f0edce08 | ||
|
|
a7da760f9c | ||
|
|
f33ffb4367 | ||
|
|
e03c2a65f7 | ||
|
|
43bc7a3032 | ||
|
|
dc184083ef | ||
|
|
9265d554af | ||
|
|
c8b611a802 | ||
|
|
1d928a189c | ||
|
|
03bb164b5c | ||
|
|
91c80a19cc | ||
|
|
68b19422ad | ||
|
|
9a69f79861 | ||
|
|
0a01188c31 | ||
|
|
6afcfc2641 | ||
|
|
cae69a7ae8 | ||
|
|
6ce46b3532 | ||
|
|
a3c5025186 | ||
|
|
b84ee179d4 | ||
|
|
c6d6433473 | ||
|
|
825cb1e7af | ||
|
|
bb8b809d46 | ||
|
|
5962b630a1 | ||
|
|
df66ddd3a4 | ||
|
|
e39d24a32e | ||
|
|
c532053396 | ||
|
|
b8f97a5f4f | ||
|
|
2c350ca7fd | ||
|
|
8b3beac6bf | ||
|
|
ba433cc7b0 | ||
|
|
84ffdfc786 | ||
|
|
137425ab25 | ||
|
|
468bc5734d | ||
|
|
8e52c8cac2 | ||
|
|
8495309ea2 | ||
|
|
420ef78842 | ||
|
|
d254d5a6ca | ||
|
|
acfc0352c3 | ||
|
|
725e2c6913 | ||
|
|
e77a02993e | ||
|
|
88925c2f82 | ||
|
|
89174280d1 | ||
|
|
cc10c0de55 | ||
|
|
6b52bddc5b | ||
|
|
8aa5fd1a3b | ||
|
|
f84616702d | ||
|
|
af27ff7d2a | ||
|
|
17758458ef | ||
|
|
2efdeb593b | ||
|
|
96091494a5 | ||
|
|
ac4b98dbd7 | ||
|
|
ab1ae981f3 | ||
|
|
9417d13b26 | ||
|
|
c542af4efe | ||
|
|
23fbd8f935 | ||
|
|
adde034a7c | ||
|
|
64423c58bc | ||
|
|
6cdfe2ab8e | ||
|
|
2e3949f0ba | ||
|
|
fdd4eb5cad | ||
|
|
18b8bc11e9 | ||
|
|
b64ec66244 | ||
|
|
82ae9b1f59 | ||
|
|
1336d449d3 | ||
|
|
98e47487f4 | ||
|
|
28a23a9d1d | ||
|
|
2ebc496d07 | ||
|
|
6a76d4fa85 | ||
|
|
05ecbe8d49 | ||
|
|
b044038857 | ||
|
|
ac1a192a3a | ||
|
|
e1d9f24312 | ||
|
|
be3912f986 | ||
|
|
6d3e7aad20 | ||
|
|
5be9a8caef | ||
|
|
1ac0e475c1 | ||
|
|
f05eecdaa0 | ||
|
|
8cccc48516 | ||
|
|
c6a3f25224 | ||
|
|
b83a0de7a8 | ||
|
|
c8ba7b4a4b | ||
|
|
ce6726aad3 | ||
|
|
692d52c514 | ||
|
|
fd24a6b883 | ||
|
|
35005a571d | ||
|
|
97d09369d1 | ||
|
|
69154a03f1 | ||
|
|
9809caaa30 | ||
|
|
7a5885cde9 | ||
|
|
d0c1c12969 | ||
|
|
1e0641a0bd | ||
|
|
48b62a8b95 | ||
|
|
3d90d8edfe | ||
|
|
93a75a4720 | ||
|
|
39d0f2d872 | ||
|
|
7a8e5373b1 | ||
|
|
1ddc2c338b |
No files matched your search
Generated
+72
-26
@@ -856,6 +856,11 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
@@ -1408,7 +1413,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
|
||||
|
||||
[[package]]
|
||||
name = "figcraft"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"eframe",
|
||||
@@ -1422,17 +1427,20 @@ dependencies = [
|
||||
"fmv-macos-events",
|
||||
"image",
|
||||
"log",
|
||||
"raw-window-handle",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"wat",
|
||||
"wayland-client",
|
||||
"webbrowser",
|
||||
"windows",
|
||||
"winit",
|
||||
"winresource",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-cli"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-engine",
|
||||
@@ -1446,7 +1454,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-collab"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-color",
|
||||
@@ -1461,7 +1469,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-collab-net"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-collab",
|
||||
@@ -1484,7 +1492,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-color"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1493,7 +1501,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-doc"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-geom",
|
||||
@@ -1505,9 +1513,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-engine"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"craft-docpath",
|
||||
"figcraft-collab",
|
||||
"figcraft-color",
|
||||
"figcraft-doc",
|
||||
@@ -1532,7 +1541,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-format"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-pathops",
|
||||
@@ -1547,7 +1556,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-geom"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1555,7 +1564,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-layout"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1563,7 +1572,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-mcp"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-collab-net",
|
||||
@@ -1579,7 +1588,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-pathops"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1588,7 +1597,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-pdf"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-doc",
|
||||
@@ -1600,12 +1609,12 @@ dependencies = [
|
||||
"krilla",
|
||||
"log",
|
||||
"thiserror 2.0.21",
|
||||
"tiny-skia",
|
||||
"tiny-skia 0.12.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-plugin"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"serde",
|
||||
@@ -1618,7 +1627,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-render"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"figcraft-color",
|
||||
@@ -1631,12 +1640,12 @@ dependencies = [
|
||||
"moxcms",
|
||||
"png",
|
||||
"rayon",
|
||||
"tiny-skia",
|
||||
"tiny-skia 0.12.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-server"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"ctrlc",
|
||||
@@ -1660,7 +1669,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-svg"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-color",
|
||||
@@ -1676,7 +1685,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-text"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1690,7 +1699,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-ui-egui"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"egui",
|
||||
@@ -1715,7 +1724,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-web"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -2570,7 +2579,7 @@ dependencies = [
|
||||
"skrifa 0.42.1",
|
||||
"smallvec",
|
||||
"subsetter",
|
||||
"tiny-skia-path",
|
||||
"tiny-skia-path 0.12.0",
|
||||
"xmp-writer",
|
||||
"yoke",
|
||||
"zune-jpeg",
|
||||
@@ -4018,6 +4027,17 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "sctk-adwaita"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
|
||||
dependencies = [
|
||||
"log",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"tiny-skia 0.11.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "self_cell"
|
||||
version = "1.3.0"
|
||||
@@ -4477,6 +4497,20 @@ dependencies = [
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"bytemuck",
|
||||
"cfg-if",
|
||||
"log",
|
||||
"tiny-skia-path 0.11.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia"
|
||||
version = "0.12.0"
|
||||
@@ -4489,7 +4523,18 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"log",
|
||||
"png",
|
||||
"tiny-skia-path",
|
||||
"tiny-skia-path 0.12.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia-path"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"bytemuck",
|
||||
"strict-num",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4778,7 +4823,7 @@ dependencies = [
|
||||
"skrifa 0.44.0",
|
||||
"strict-num",
|
||||
"svgtypes",
|
||||
"tiny-skia-path",
|
||||
"tiny-skia-path 0.12.0",
|
||||
"unicode-bidi",
|
||||
"unicode-script",
|
||||
"unicode-vo",
|
||||
@@ -5731,6 +5776,7 @@ dependencies = [
|
||||
"raw-window-handle",
|
||||
"redox_syscall 0.4.1",
|
||||
"rustix 0.38.44",
|
||||
"sctk-adwaita",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"smol_str",
|
||||
"tracing",
|
||||
@@ -5888,7 +5934,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"figcraft-engine",
|
||||
"serde_json",
|
||||
|
||||
+4
-1
@@ -6,7 +6,7 @@ exclude = ["plugins"]
|
||||
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -32,6 +32,9 @@ figcraft-mcp = { path = "crates/mcp" }
|
||||
figcraft-collab-net = { path = "crates/collab-net" }
|
||||
figcraft-plugin = { path = "crates/plugin" }
|
||||
|
||||
# Which paths stored in a document may be followed (craft-libs).
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
|
||||
|
||||
# foundation
|
||||
kurbo = { version = "0.13", features = ["serde"] }
|
||||
thiserror = "2"
|
||||
|
||||
@@ -20,3 +20,34 @@ licence texts.
|
||||
Rust crate dependencies keep their own licenses (MIT, Apache-2.0, BSD, ISC, Zlib, OFL for egui's
|
||||
default fonts, CDLA-Permissive-2.0 for the root certificate list webpki-roots compiles in, and
|
||||
similar permissive licenses); see each crate's metadata.
|
||||
|
||||
Linux Wayland window decorations
|
||||
-------------------------------
|
||||
sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita,
|
||||
commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux
|
||||
for vector window controls. Its title-font features are disabled: no Cantarell
|
||||
font is embedded by this dependency and no font file is added to this repo.
|
||||
|
||||
sctk-adwaita's MIT licence:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Bartłomiej Maryńczak
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+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]
|
||||
|
||||
@@ -155,6 +155,7 @@ pub fn info(path: &str, as_json: bool) -> CliResult<ExitCode> {
|
||||
Some(json!({"id": p, "name": pn.name, "nodes": nodes, "topLevel": pn.children.len(), "frames": frames}))
|
||||
})
|
||||
.collect();
|
||||
let missing = figcraft_engine::fonts::missing(&doc);
|
||||
let v = json!({
|
||||
"file": path,
|
||||
"name": doc.name,
|
||||
@@ -164,6 +165,7 @@ pub fn info(path: &str, as_json: bool) -> CliResult<ExitCode> {
|
||||
"variables": doc.variables.variables.len(),
|
||||
"images": doc.images.len(),
|
||||
"notes": notes,
|
||||
"missingFonts": missing.iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect::<Vec<_>>(),
|
||||
});
|
||||
if as_json {
|
||||
println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
|
||||
@@ -187,6 +189,10 @@ pub fn info(path: &str, as_json: bool) -> CliResult<ExitCode> {
|
||||
println!(" {} {} \"{}\" {}×{}", f["id"], f["type"].as_str().unwrap_or(""), f["name"].as_str().unwrap_or(""), f["width"], f["height"]);
|
||||
}
|
||||
}
|
||||
for m in &missing {
|
||||
let layers = if m.layers == 1 { "text layer" } else { "text layers" };
|
||||
println!("missing font \"{}\": {} {layers} (drawn in a fallback font)", m.family, m.layers);
|
||||
}
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -45,3 +45,25 @@ fn headless_runs_resolve_installed_families_unless_opted_out() {
|
||||
assert_eq!(run_one("face", face.clone(), &[], false)["family"], json!(installed), "text uses the installed family");
|
||||
assert_ne!(run_one("face", face, &["--no-system-fonts"], false)["family"], json!(installed), "opted out, it falls back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_lists_the_fonts_a_file_uses_that_are_missing() {
|
||||
let d = std::env::temp_dir().join(format!("figcraft-cli-missing-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
let (script, file) = (d.join("make.json"), d.join("doc.figcraft"));
|
||||
let text =
|
||||
|family: &str| json!({"command": "text.create", "params": {"text": "Hamburgefonstiv", "x": 0, "y": 0, "props": {"fontFamily": family}}});
|
||||
std::fs::write(&script, json!([text("FigCraft Missing CLI"), text("FigCraft Missing CLI")]).to_string()).unwrap();
|
||||
let cli = || Command::new(env!("CARGO_BIN_EXE_figcraft-cli"));
|
||||
let made = cli().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap();
|
||||
assert!(made.status.success(), "{}", String::from_utf8_lossy(&made.stderr));
|
||||
|
||||
let out = cli().arg("info").arg(&file).arg("--json").output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let v: Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
assert_eq!(v["missingFonts"], json!([{"family": "FigCraft Missing CLI", "layers": 2}]));
|
||||
|
||||
let out = cli().arg("info").arg(&file).output().unwrap();
|
||||
let shown = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(shown.contains("missing font \"FigCraft Missing CLI\": 2 text layers"), "{shown}");
|
||||
}
|
||||
@@ -182,7 +182,7 @@ impl Config {
|
||||
return Err("`idle_days` must be from 0 to 3650".into());
|
||||
}
|
||||
let terms = self.terms_url.trim();
|
||||
if !terms.is_empty() && !(terms.starts_with("https://") || terms.starts_with("http://")) || terms.contains(char::is_whitespace) {
|
||||
if !(terms.is_empty() || terms.starts_with("https://") || terms.starts_with("http://")) || terms.contains(char::is_whitespace) {
|
||||
return Err("`terms_url` must be a web address (https://…)".into());
|
||||
}
|
||||
if self.operator_contact.chars().count() > 120 || self.operator_contact.chars().any(char::is_control) || terms.len() > 500 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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.
|
||||
@@ -143,6 +161,7 @@ fn main() -> eframe::Result {
|
||||
log::error!("{f}: {e}");
|
||||
}
|
||||
}
|
||||
let opened: Vec<u64> = session.docs.iter().map(|d| d.uid).collect();
|
||||
// Documents a crashed or killed run left unsaved come back; this run keeps its own copies.
|
||||
let recovery_root = services::config_dir().map(|d| d.join("Recovery"));
|
||||
// The preferences first: they hold the online files a recovered document may be one of.
|
||||
@@ -174,7 +193,10 @@ 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;
|
||||
// Files named on the command line are the user's too: their missing fonts are listed.
|
||||
fig.ui.font_checks = opened;
|
||||
fig.note_recovered(recovered.copies);
|
||||
if let Some(link) = &args.join {
|
||||
figcraft_ui_egui::live::open_invite(&mut fig, link);
|
||||
@@ -192,21 +214,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 +236,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 +257,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));
|
||||
}
|
||||
}
|
||||
@@ -203,7 +203,7 @@ fn pairs(query: &str) -> Result<Vec<(String, String)>, String> {
|
||||
fn with_port(authority: &str, default: u16) -> String {
|
||||
let has_port = match authority.rsplit_once(':') {
|
||||
// `[v6]` alone has colons inside the brackets only.
|
||||
Some((h, p)) => !(h.starts_with('[') && !h.ends_with(']')) && !p.contains(']'),
|
||||
Some((h, p)) => (!h.starts_with('[') || h.ends_with(']')) && !p.contains(']'),
|
||||
None => false,
|
||||
};
|
||||
if has_port { authority.to_string() } else { format!("{authority}:{default}") }
|
||||
|
||||
+92
-1
@@ -860,7 +860,35 @@ pub struct Device {
|
||||
|
||||
impl PrototypeSettings {
|
||||
/// The devices to pick from ("None" presents the frame at its own size).
|
||||
pub const DEVICES: [Device; 6] = [
|
||||
pub const DEVICES: [Device; 19] = [
|
||||
Device {
|
||||
name: "iPhone 17",
|
||||
width: 402.0,
|
||||
height: 874.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x38, 0x39, 0x3D), c("White", 0xE9, 0xE9, 0xE5)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 17 Pro",
|
||||
width: 402.0,
|
||||
height: 874.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x38, 0x39, 0x3D), c("Silver", 0xD7, 0xD7, 0xD5)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 17 Pro Max",
|
||||
width: 440.0,
|
||||
height: 956.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x38, 0x39, 0x3D), c("Silver", 0xD7, 0xD7, 0xD5)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone Air",
|
||||
width: 420.0,
|
||||
height: 912.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x38, 0x39, 0x3D), c("Silver", 0xD7, 0xD7, 0xD5)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 16",
|
||||
width: 393.0,
|
||||
@@ -874,6 +902,13 @@ impl PrototypeSettings {
|
||||
c("Blue", 0x72, 0x84, 0xD4),
|
||||
],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 16 Pro",
|
||||
width: 402.0,
|
||||
height: 874.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x3B, 0x3A, 0x38), c("White", 0xEC, 0xEB, 0xE6)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 16 Pro Max",
|
||||
width: 440.0,
|
||||
@@ -881,6 +916,55 @@ impl PrototypeSettings {
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x3B, 0x3A, 0x38), c("White", 0xEC, 0xEB, 0xE6), c("Natural", 0xBF, 0xB9, 0xAF), c("Sand", 0xC6, 0xAC, 0x8F)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 16 Plus",
|
||||
width: 430.0,
|
||||
height: 932.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x3A, 0x3B, 0x3F), c("White", 0xEE, 0xED, 0xE8)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 15 Pro",
|
||||
width: 393.0,
|
||||
height: 852.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x3B, 0x3A, 0x38), c("Silver", 0xD7, 0xD7, 0xD5)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 15 Pro Max",
|
||||
width: 430.0,
|
||||
height: 932.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x3B, 0x3A, 0x38), c("Silver", 0xD7, 0xD7, 0xD5)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 15 Plus",
|
||||
width: 430.0,
|
||||
height: 932.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x3A, 0x3B, 0x3F), c("White", 0xEE, 0xED, 0xE8)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 14 Pro",
|
||||
width: 393.0,
|
||||
height: 852.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x3B, 0x3A, 0x38), c("Silver", 0xD7, 0xD7, 0xD5)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 14 Pro Max",
|
||||
width: 430.0,
|
||||
height: 932.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x3B, 0x3A, 0x38), c("Silver", 0xD7, 0xD7, 0xD5)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 14",
|
||||
width: 390.0,
|
||||
height: 844.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x3A, 0x3B, 0x3F), c("White", 0xEE, 0xED, 0xE8)],
|
||||
},
|
||||
Device {
|
||||
name: "Android Compact",
|
||||
width: 412.0,
|
||||
@@ -888,6 +972,13 @@ impl PrototypeSettings {
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x2E, 0x2F, 0x33), c("Silver", 0xD6, 0xD8, 0xDB), c("Green", 0xA6, 0xC0, 0xA3)],
|
||||
},
|
||||
Device {
|
||||
name: "Android Medium",
|
||||
width: 700.0,
|
||||
height: 840.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x2E, 0x2F, 0x33), c("Silver", 0xD6, 0xD8, 0xDB)],
|
||||
},
|
||||
Device {
|
||||
name: "iPad mini",
|
||||
width: 744.0,
|
||||
|
||||
@@ -20,6 +20,7 @@ figcraft-render = { workspace = true }
|
||||
figcraft-format = { workspace = true }
|
||||
figcraft-svg = { workspace = true }
|
||||
figcraft-pdf = { workspace = true }
|
||||
craft-docpath = { workspace = true }
|
||||
figcraft-plugin = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# The workspace settings (../../clippy.toml; clippy reads only the nearest file), plus the engine's
|
||||
# own rule: shipped engine code reaches the file system only through `file_access`, which enforces
|
||||
# the automation roots (#715), so every command is confined without a check of its own.
|
||||
allow-unwrap-in-tests = true
|
||||
allow-expect-in-tests = true
|
||||
allow-panic-in-tests = true
|
||||
too-many-arguments-threshold = 10
|
||||
disallowed-methods = [
|
||||
{ path = "std::fs::read", reason = "use file_access::read (automation roots)" },
|
||||
{ path = "std::fs::read_to_string", reason = "use file_access::read (automation roots)" },
|
||||
{ path = "std::fs::write", reason = "use file_access::write (automation roots)" },
|
||||
{ path = "std::fs::metadata", reason = "use file_access::metadata (automation roots)" },
|
||||
{ path = "std::fs::symlink_metadata", reason = "use file_access (automation roots)" },
|
||||
{ path = "std::fs::read_dir", reason = "use file_access::read_dir (automation roots)" },
|
||||
{ path = "std::fs::canonicalize", reason = "use file_access (automation roots)" },
|
||||
{ path = "std::fs::copy", reason = "use file_access (automation roots)" },
|
||||
{ path = "std::fs::rename", reason = "use file_access (automation roots)" },
|
||||
{ path = "std::fs::create_dir", reason = "use file_access::create_dir_all (automation roots)" },
|
||||
{ path = "std::fs::create_dir_all", reason = "use file_access::create_dir_all (automation roots)" },
|
||||
{ path = "std::fs::remove_file", reason = "use file_access (automation roots)" },
|
||||
{ path = "std::fs::remove_dir", reason = "use file_access (automation roots)" },
|
||||
{ path = "std::fs::remove_dir_all", reason = "use file_access (automation roots)" },
|
||||
{ path = "std::fs::hard_link", reason = "use file_access (automation roots)" },
|
||||
{ path = "std::fs::File::open", reason = "use file_access::open (automation roots)" },
|
||||
{ path = "std::fs::File::create", reason = "use file_access::write (automation roots)" },
|
||||
{ path = "std::fs::File::create_new", reason = "use file_access::write (automation roots)" },
|
||||
{ path = "std::fs::OpenOptions::open", reason = "use file_access (automation roots)" },
|
||||
{ path = "std::path::Path::exists", reason = "use file_access::exists (automation roots)" },
|
||||
{ path = "std::path::Path::try_exists", reason = "use file_access::exists (automation roots)" },
|
||||
{ path = "std::path::Path::is_file", reason = "use file_access::metadata (automation roots)" },
|
||||
{ path = "std::path::Path::is_dir", reason = "use file_access::is_dir (automation roots)" },
|
||||
{ path = "std::path::Path::metadata", reason = "use file_access::metadata (automation roots)" },
|
||||
{ path = "std::path::Path::symlink_metadata", reason = "use file_access (automation roots)" },
|
||||
{ path = "std::path::Path::read_dir", reason = "use file_access::read_dir (automation roots)" },
|
||||
{ path = "std::path::Path::canonicalize", reason = "use file_access (automation roots)" },
|
||||
{ path = "figcraft_format::write_file_atomic", reason = "use file_access::write (automation roots)" },
|
||||
{ path = "figcraft_format::save_file", reason = "use file_access::write (automation roots)" },
|
||||
{ path = "figcraft_format::load_file", reason = "use file_access::read (automation roots)" },
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -213,6 +213,8 @@ pub static MENU: &[MenuItem] = &[
|
||||
m!("Text/Case", "Uppercase", "text.caseUpper"),
|
||||
m!("Text/Case", "Lowercase", "text.caseLower"),
|
||||
m!("Text/Case", "Title case", "text.caseTitle"),
|
||||
m!("Text", SEP, ""),
|
||||
m!("Text", "Missing fonts…", "text.missingFonts"),
|
||||
// Arrange
|
||||
m!("Arrange", "Align left", "align.left"),
|
||||
m!("Arrange", "Align horizontal centers", "align.hcenter"),
|
||||
@@ -287,7 +289,7 @@ pub fn items() -> impl Iterator<Item = &'static MenuItem> {
|
||||
}
|
||||
|
||||
/// Items of FigCraft's own that the parity count leaves out (as it does the `collab.` ones).
|
||||
const OWN: [&str; 2] = ["file.newFromTemplate", "help.gettingStarted"];
|
||||
const OWN: [&str; 3] = ["file.newFromTemplate", "help.gettingStarted", "text.missingFonts"];
|
||||
|
||||
/// Coverage: (implemented, total, missing ids). `extra` lists ids implemented by the UI layer.
|
||||
pub fn parity(extra: &[&str]) -> (usize, usize, Vec<&'static str>) {
|
||||
|
||||
@@ -19,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()
|
||||
}
|
||||
@@ -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,7 @@ fn push_changes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let groups: BTreeSet<String> = n
|
||||
.overrides
|
||||
.iter()
|
||||
.filter(|g| !matches!(g.as_str(), "size" | "position" | "component" | "props" | SLOT) && !(d == inst && g.as_str() == "name"))
|
||||
.filter(|g| !(matches!(g.as_str(), "size" | "resizing" | "position" | "component" | "props" | SLOT) || d == inst && g.as_str() == "name"))
|
||||
.cloned()
|
||||
.collect();
|
||||
for g in &groups {
|
||||
|
||||
@@ -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)] = &[
|
||||
@@ -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).
|
||||
|
||||
@@ -1156,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 {
|
||||
@@ -1168,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
|
||||
@@ -1295,7 +1356,7 @@ fn pattern_fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "fill.pattern";
|
||||
let src = req_id(CMD, p, "source")?;
|
||||
let doc = s.doc()?;
|
||||
if !doc.node(src).is_some_and(|n| !n.is_page()) {
|
||||
if doc.node(src).is_none_or(|n| n.is_page()) {
|
||||
return Err(bad(CMD, "`source` must be a layer"));
|
||||
}
|
||||
if req_targets(CMD, s, p)?.iter().any(|t| doc.is_ancestor(*t, src)) {
|
||||
|
||||
@@ -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());
|
||||
@@ -79,6 +80,7 @@ fn registry() -> &'static Registry {
|
||||
list.extend(page::specs());
|
||||
list.extend(view::specs());
|
||||
list.extend(text::specs());
|
||||
list.extend(crate::fonts::specs());
|
||||
list.extend(styles::specs());
|
||||
list.extend(image::specs());
|
||||
list.extend(variables::specs());
|
||||
|
||||
@@ -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}))
|
||||
|
||||
@@ -19,30 +19,58 @@ fn flag(s: &mut Session, p: &Value, f: fn(&mut ViewState) -> &mut bool) -> Resul
|
||||
Ok(json!({"on": *slot}))
|
||||
}
|
||||
|
||||
/// One of a view's on/off settings.
|
||||
type Slot = fn(&mut ViewState) -> &mut bool;
|
||||
|
||||
/// The View menu's on/off toggles (Pixel preview, with its scale, has its own command), and
|
||||
/// whether the preferences keep each: files opened or made later start with the kept ones. Outline
|
||||
/// modes and the sRGB soft-proof stay with their file, so a new file never opens in one.
|
||||
const TOGGLES: &[(&str, Slot, bool)] = &[
|
||||
("view.pixelGrid", |v| &mut v.pixel_grid, true),
|
||||
("view.layoutGrids", |v| &mut v.layout_grids, true),
|
||||
("view.rulers", |v| &mut v.rulers, true),
|
||||
("view.slices", |v| &mut v.show_slices, true),
|
||||
("view.comments", |v| &mut v.comments, true),
|
||||
("view.multiplayerCursors", |v| &mut v.multiplayer_cursors, true),
|
||||
("view.outlines", |v| &mut v.outlines, false),
|
||||
("view.outlinesSelection", |v| &mut v.outlines_selection, false),
|
||||
("view.outlinesHiddenLayers", |v| &mut v.outline_hidden, true),
|
||||
("view.outlinesObjectBounds", |v| &mut v.outline_bounds, true),
|
||||
("view.maskOutlines", |v| &mut v.mask_outlines, true),
|
||||
("view.frameOutlines", |v| &mut v.frame_outlines, true),
|
||||
("view.proofSrgb", |v| &mut v.proof_srgb, false),
|
||||
("view.snapGeometry", |v| &mut v.snap_geometry, true),
|
||||
("view.snapObjects", |v| &mut v.snap_objects, true),
|
||||
("view.snapPixel", |v| &mut v.snap_pixel, true),
|
||||
];
|
||||
|
||||
/// View toggle `id`: the file's view changes, and the preferences remember a kept one.
|
||||
fn toggle(s: &mut Session, p: &Value, id: &str) -> Result<Value> {
|
||||
let Some(&(_, f, keep)) = TOGGLES.iter().find(|(k, ..)| *k == id) else { return Err(bad(id, "not a View toggle")) };
|
||||
let r = flag(s, p, f)?;
|
||||
if keep && let Some(on) = r.get("on").and_then(Value::as_bool) {
|
||||
s.prefs.view.insert(id.to_string(), on);
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Set `v`'s View toggles to the ones the preferences keep (the others stay as they are).
|
||||
pub(crate) fn apply_kept(prefs: &crate::Prefs, v: &mut ViewState) {
|
||||
for (id, f, _) in TOGGLES.iter().filter(|t| t.2) {
|
||||
if let Some(on) = prefs.view.get(*id) {
|
||||
*f(v) = *on;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the View menu's toggle `id` is on (the menus show a check mark); `None` for a command
|
||||
/// that isn't one.
|
||||
pub fn checked(s: &Session, id: &str) -> Option<bool> {
|
||||
let v = &s.ds().ok()?.view;
|
||||
Some(match id {
|
||||
"view.pixelGrid" => v.pixel_grid,
|
||||
"view.layoutGrids" => v.layout_grids,
|
||||
"view.rulers" => v.rulers,
|
||||
"view.slices" => v.show_slices,
|
||||
"view.comments" => v.comments,
|
||||
"view.multiplayerCursors" => v.multiplayer_cursors,
|
||||
"view.outlines" => v.outlines,
|
||||
"view.outlinesSelection" => v.outlines_selection,
|
||||
"view.outlinesHiddenLayers" => v.outline_hidden,
|
||||
"view.outlinesObjectBounds" => v.outline_bounds,
|
||||
"view.maskOutlines" => v.mask_outlines,
|
||||
"view.pixelPreview" => v.pixel_preview,
|
||||
"view.frameOutlines" => v.frame_outlines,
|
||||
"view.proofSrgb" => v.proof_srgb,
|
||||
"view.snapGeometry" => v.snap_geometry,
|
||||
"view.snapObjects" => v.snap_objects,
|
||||
"view.snapPixel" => v.snap_pixel,
|
||||
_ => return None,
|
||||
})
|
||||
let mut v = s.ds().ok()?.view.clone();
|
||||
if id == "view.pixelPreview" {
|
||||
return Some(v.pixel_preview);
|
||||
}
|
||||
TOGGLES.iter().find(|(k, ..)| *k == id).map(|(_, f, _)| *f(&mut v))
|
||||
}
|
||||
|
||||
/// Pixel preview: toggles, or turns on at a resolution of 1x or 2x.
|
||||
@@ -235,46 +263,46 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("view.nextFrame", "Zoom to next frame", "{}", false, |s, _| zoom_frame(s, 1)),
|
||||
spec!("view.prevPage", "Previous page", "{}", false, |s, _| page_step(s, -1)),
|
||||
spec!("view.nextPage", "Next page", "{}", false, |s, _| page_step(s, 1)),
|
||||
spec!("view.pixelGrid", "Pixel grid", "{on?}", false, |s, p| flag(s, p, |v| &mut v.pixel_grid)),
|
||||
spec!("view.layoutGrids", "Layout grids", "{on?}", false, |s, p| flag(s, p, |v| &mut v.layout_grids)),
|
||||
spec!("view.rulers", "Rulers", "{on?}", false, |s, p| flag(s, p, |v| &mut v.rulers)),
|
||||
spec!("view.slices", "Slices", "{on?}", false, |s, p| flag(s, p, |v| &mut v.show_slices)),
|
||||
spec!("view.comments", "Comments", "{on?}", false, |s, p| flag(s, p, |v| &mut v.comments)),
|
||||
spec!("view.pixelGrid", "Pixel grid", "{on?}", false, |s, p| toggle(s, p, "view.pixelGrid")),
|
||||
spec!("view.layoutGrids", "Layout grids", "{on?}", false, |s, p| toggle(s, p, "view.layoutGrids")),
|
||||
spec!("view.rulers", "Rulers", "{on?}", false, |s, p| toggle(s, p, "view.rulers")),
|
||||
spec!("view.slices", "Slices", "{on?}", false, |s, p| toggle(s, p, "view.slices")),
|
||||
spec!("view.comments", "Comments", "{on?}", false, |s, p| toggle(s, p, "view.comments")),
|
||||
spec!("view.multiplayerCursors", "Multiplayer cursors", "{on?} others' cursors in a live session", false, |s, p| {
|
||||
flag(s, p, |v| &mut v.multiplayer_cursors)
|
||||
toggle(s, p, "view.multiplayerCursors")
|
||||
}),
|
||||
spec!("view.pan", "Pan the view", "{dx, dy (arrow-key steps, e.g. 1 or -1), big? (ten times as far, like Shift)}", false, pan),
|
||||
spec!("view.outlines", "Outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.outlines)),
|
||||
spec!("view.outlines", "Outlines", "{on?}", false, |s, p| toggle(s, p, "view.outlines")),
|
||||
spec!("view.outlinesSelection", "Outlines for selection", "{on?} outline mode for the selected layers only", false, |s, p| {
|
||||
flag(s, p, |v| &mut v.outlines_selection)
|
||||
toggle(s, p, "view.outlinesSelection")
|
||||
}),
|
||||
spec!(
|
||||
"view.outlinesHiddenLayers",
|
||||
"Include hidden layers in outlines",
|
||||
"{on?} outline mode (and outlines for the selection) outlines hidden layers too; they can be clicked there",
|
||||
false,
|
||||
|s, p| { flag(s, p, |v| &mut v.outline_hidden) }
|
||||
|s, p| { toggle(s, p, "view.outlinesHiddenLayers") }
|
||||
),
|
||||
spec!(
|
||||
"view.outlinesObjectBounds",
|
||||
"Include object bounds in outlines",
|
||||
"{on?} outline mode also draws each layer's box (groups, text, vectors); a click on a box's edge picks its layer",
|
||||
false,
|
||||
|s, p| { flag(s, p, |v| &mut v.outline_bounds) }
|
||||
|s, p| { toggle(s, p, "view.outlinesObjectBounds") }
|
||||
),
|
||||
spec!("view.maskOutlines", "Mask outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.mask_outlines)),
|
||||
spec!("view.maskOutlines", "Mask outlines", "{on?}", false, |s, p| toggle(s, p, "view.maskOutlines")),
|
||||
spec!("view.pixelPreview", "Pixel preview", "{on?, scale? (1|2: rendered pixels per page pixel; turns it on)}", false, pixel_preview),
|
||||
spec!("view.frameOutlines", "Frame outlines", "{on?}", false, |s, p| flag(s, p, |v| &mut v.frame_outlines)),
|
||||
spec!("view.frameOutlines", "Frame outlines", "{on?}", false, |s, p| toggle(s, p, "view.frameOutlines")),
|
||||
spec!(
|
||||
"view.proofSrgb",
|
||||
"Soft-proof sRGB",
|
||||
"{on?} mark the colours of a Display P3 file that sRGB screens and files can't show",
|
||||
false,
|
||||
|s, p| { flag(s, p, |v| &mut v.proof_srgb) }
|
||||
|s, p| { toggle(s, p, "view.proofSrgb") }
|
||||
),
|
||||
spec!("view.snapGeometry", "Snap to geometry", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_geometry)),
|
||||
spec!("view.snapObjects", "Snap to objects", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_objects)),
|
||||
spec!("view.snapPixel", "Snap to pixel grid", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_pixel)),
|
||||
spec!("view.snapGeometry", "Snap to geometry", "{on?}", false, |s, p| toggle(s, p, "view.snapGeometry")),
|
||||
spec!("view.snapObjects", "Snap to objects", "{on?}", false, |s, p| toggle(s, p, "view.snapObjects")),
|
||||
spec!("view.snapPixel", "Snap to pixel grid", "{on?}", false, |s, p| toggle(s, p, "view.snapPixel")),
|
||||
spec!("view.toggleUi", "Show/Hide UI", "{on?}", false, |s, p| flag(s, p, |v| &mut v.ui_hidden)),
|
||||
spec!("view.fullscreen", "Full screen", "{on?} the window fills the screen (the desktop app; a no-op headless)", false, |s, p| {
|
||||
s.request_ui("window.fullscreen", json!({"on": boolean(p, "on")}));
|
||||
|
||||
@@ -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)
|
||||
@@ -1475,7 +1498,7 @@ fn patch_copies(doc: &mut Document, seeds: &[(NodeId, Vec<&'static str>)]) -> bo
|
||||
let gs: Vec<&'static str> = groups
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|g| !cn.overrides.contains(*g) && !(root && matches!(*g, "visible" | "name" | "position" | "exports" | "interactions")))
|
||||
.filter(|g| !(cn.overrides.contains(*g) || root && matches!(*g, "visible" | "name" | "position" | "exports" | "interactions")))
|
||||
.collect();
|
||||
if gs.is_empty() {
|
||||
continue;
|
||||
@@ -1518,7 +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();
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
//! Which URLs stored in a document may be handed to the browser or the system opener.
|
||||
//!
|
||||
//! A document can carry URLs that the app opens for whoever views it: a prototype's Open link
|
||||
//! action ([`crate::proto_run`]) and Dev Mode's dev resources ([`crate::dev_resources`]). Both end
|
||||
//! up at the platform's opener, so a document that someone else made chooses what that opener is
|
||||
//! asked to do. Only the schemes a browser would follow on a plain link are allowed:
|
||||
//!
|
||||
//! - `javascript:` runs script. In the web build the opener is `window.open`, which executes it in
|
||||
//! the app's own origin, so a saved document becomes stored XSS.
|
||||
//! - `file:` and `smb:` reach the file system; on Windows a UNC host also sends credentials.
|
||||
//! - Windows hands `ms-…:` schemes to application handlers, and macOS and Linux hand unknown
|
||||
//! schemes to whichever application registered them.
|
||||
//!
|
||||
//! The check is lexical and does no I/O. It is deliberately an allow-list: a deny-list of known
|
||||
//! dangerous schemes is outgrown by every new handler a platform adds.
|
||||
|
||||
/// The schemes a URL stored in a document may use.
|
||||
const WEB_SCHEMES: [&str; 3] = ["http", "https", "mailto"];
|
||||
|
||||
/// Whether `url`, stored in a document, may be opened.
|
||||
///
|
||||
/// Requires a scheme from [`WEB_SCHEMES`], compared ignoring ASCII case. A URL with no scheme is
|
||||
/// refused: the opener would resolve it against the app's own page on the web. Control characters
|
||||
/// are refused anywhere, because browsers ignore some of them inside a scheme (`java\tscript:`
|
||||
/// reaches a `javascript:` handler), and `http`/`https` must name a host.
|
||||
pub fn openable(url: &str) -> bool {
|
||||
let url = url.trim();
|
||||
if url.is_empty() || url.chars().any(char::is_control) {
|
||||
return false;
|
||||
}
|
||||
let Some((scheme, rest)) = url.split_once(':') else { return false };
|
||||
// An RFC 3986 scheme, so a stray colon in text cannot look like one.
|
||||
let mut chars = scheme.chars();
|
||||
if !chars.next().is_some_and(|c| c.is_ascii_alphabetic()) || !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) {
|
||||
return false;
|
||||
}
|
||||
if !WEB_SCHEMES.iter().any(|s| scheme.eq_ignore_ascii_case(s)) {
|
||||
return false;
|
||||
}
|
||||
if scheme.eq_ignore_ascii_case("mailto") {
|
||||
// `mailto:` names an address.
|
||||
return !rest.is_empty();
|
||||
}
|
||||
// `http` and `https` name a host, so the authority is required: `http:x` is not a web link,
|
||||
// and `http:///p` has no host.
|
||||
let Some(after) = rest.strip_prefix("//") else { return false };
|
||||
!after.is_empty() && !after.starts_with('/')
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! A URL stored in a document reaches the browser or the system opener, so only the schemes a
|
||||
//! browser follows on a plain link are allowed ([`crate::doc_urls`]).
|
||||
|
||||
use crate::doc_urls::openable;
|
||||
|
||||
#[test]
|
||||
fn web_links_are_opened() {
|
||||
for url in [
|
||||
"http://example.com",
|
||||
"https://example.com",
|
||||
"https://example.com/a/b?c=d#e",
|
||||
"HTTPS://EXAMPLE.COM",
|
||||
"HtTp://example.com",
|
||||
"mailto:[email protected]",
|
||||
"mailto:[email protected]?subject=hi",
|
||||
" https://example.com ",
|
||||
"https://user:[email protected]:8443/p",
|
||||
] {
|
||||
assert!(openable(url), "{url:?} should open");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_and_handler_schemes_are_refused() {
|
||||
for url in [
|
||||
// Runs in the app's own origin on the web build: a saved document would be stored XSS.
|
||||
"javascript:alert(1)",
|
||||
"JavaScript:alert(1)",
|
||||
"jAvAsCrIpT:alert(document.cookie)",
|
||||
"vbscript:msgbox(1)",
|
||||
"data:text/html,<script>alert(1)</script>",
|
||||
"blob:https://example.com/x",
|
||||
// Reach the file system; a UNC host also sends Windows credentials.
|
||||
"file:///etc/passwd",
|
||||
"file://attacker.example/share/x",
|
||||
"smb://attacker.example/share/x",
|
||||
"webdav://attacker.example/x",
|
||||
// Windows application handlers.
|
||||
"ms-msdt:/id PCWDiagnostic",
|
||||
"ms-officecmd:x",
|
||||
"search-ms:query=x",
|
||||
"shell:startup",
|
||||
// Anything else a platform may have registered.
|
||||
"tel:+1234",
|
||||
"sms:+1234",
|
||||
"ssh://host",
|
||||
"ftp://host/x",
|
||||
"figcraft://join?host=127.0.0.1",
|
||||
"zoommtg://zoom.us/join?x",
|
||||
"view-source:https://example.com",
|
||||
"jar:https://example.com/x!/y",
|
||||
] {
|
||||
assert!(!openable(url), "{url:?} must be refused");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scheme_is_required_and_control_characters_are_refused() {
|
||||
for url in [
|
||||
// No scheme: the web opener would resolve these against the app's own page.
|
||||
"",
|
||||
" ",
|
||||
"example.com",
|
||||
"/etc/passwd",
|
||||
"//example.com",
|
||||
"\\\\host\\share\\x",
|
||||
"about",
|
||||
// Browsers ignore some control characters inside a scheme, so a smuggled one must not
|
||||
// leave `javascript` looking like an unknown scheme that we then refuse by luck.
|
||||
"java\tscript:alert(1)",
|
||||
"java\nscript:alert(1)",
|
||||
"java\rscript:alert(1)",
|
||||
"javascript\u{0}:alert(1)",
|
||||
"https://example.com/\u{0}",
|
||||
// A colon in ordinary text is not a scheme.
|
||||
"see page 2: the diagram",
|
||||
"1https://example.com",
|
||||
// The allowed schemes still need their own part.
|
||||
"mailto:",
|
||||
"http:",
|
||||
"http:example.com",
|
||||
"http:///p",
|
||||
"https://",
|
||||
] {
|
||||
assert!(!openable(url), "{url:?} must be refused");
|
||||
}
|
||||
}
|
||||
@@ -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,100 @@
|
||||
//! Fonts a document names that this machine doesn't have (#752): text layers, their styled
|
||||
//! ranges, text styles and the string variables bound to a font family. Text in such a family
|
||||
//! is drawn in a fallback font, so the user is told which ones are missing.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use figcraft_doc::{Document, NodeKind, StyleKind, VariableValue, Variables};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cmd::CommandSpec;
|
||||
use crate::{Result, Session, spec};
|
||||
|
||||
/// A family the document names that no installed or built-in font provides.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Missing {
|
||||
/// As the document spells it.
|
||||
pub family: String,
|
||||
/// Text layers that use it (0: only a text style or a variable names it).
|
||||
pub layers: usize,
|
||||
}
|
||||
|
||||
/// The families `doc` names that the font database doesn't have, by name. On the desktop the
|
||||
/// installed fonts are indexed in the background: ask once that's done
|
||||
/// (`figcraft_text::installed_fonts_known`), or every installed family counts as missing.
|
||||
pub fn missing(doc: &Document) -> Vec<Missing> {
|
||||
// Lower-case family → (spelling, layers).
|
||||
let mut used: BTreeMap<String, (String, usize)> = BTreeMap::new();
|
||||
let mut add = |family: &str, layer: bool| {
|
||||
let family = family.trim();
|
||||
if family.is_empty() {
|
||||
return;
|
||||
}
|
||||
let e = used.entry(family.to_lowercase()).or_insert_with(|| (family.to_string(), 0));
|
||||
e.1 += usize::from(layer);
|
||||
};
|
||||
for n in doc.nodes() {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
let mut families: Vec<&str> = t.spans().into_iter().map(|(_, _, st)| st.font_family.as_str()).collect();
|
||||
families.push(&t.style.font_family);
|
||||
families.sort_by_key(|f| f.trim().to_lowercase());
|
||||
families.dedup_by_key(|f| f.trim().to_lowercase());
|
||||
for f in families {
|
||||
add(f, true);
|
||||
}
|
||||
}
|
||||
if let Some(var) = n.bound.get("fontFamily") {
|
||||
for f in string_values(&doc.variables, var) {
|
||||
add(&f, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
for st in &doc.styles {
|
||||
if let StyleKind::Text { style } = &st.kind {
|
||||
add(&style.font_family, false);
|
||||
}
|
||||
if let Some(var) = st.bound.get("fontFamily") {
|
||||
for f in string_values(&doc.variables, var) {
|
||||
add(&f, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
used.into_values().filter(|(family, _)| !figcraft_text::has_family(family)).map(|(family, layers)| Missing { family, layers }).collect()
|
||||
}
|
||||
|
||||
/// Every string value of variable `id` in any mode, following aliases (at most 32 deep).
|
||||
fn string_values(vars: &Variables, id: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut todo = vec![(id.to_string(), 0)];
|
||||
while let Some((id, depth)) = todo.pop() {
|
||||
let Some(var) = vars.get(&id) else { continue };
|
||||
for v in var.values.values() {
|
||||
match v {
|
||||
VariableValue::String(s) => out.push(s.clone()),
|
||||
VariableValue::Alias(a) if depth < 32 => todo.push((a.clone(), depth + 1)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn missing_fonts(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let families: Vec<Value> = missing(s.doc()?).into_iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect();
|
||||
s.request_ui("dialog.missingFonts", json!({}));
|
||||
Ok(json!({"families": families}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![spec!(
|
||||
"text.missingFonts",
|
||||
"Missing fonts",
|
||||
"{} fonts the open document names that aren't installed (text layers, styled ranges, text styles, font family variables); the app shows them → {families: [{family, layers}]}",
|
||||
false,
|
||||
missing_fonts
|
||||
)]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "fonts_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,126 @@
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::params::id_of;
|
||||
use figcraft_doc::{Style, StyleRun, TextStyle};
|
||||
|
||||
fn text(s: &mut Session, family: &str) -> figcraft_doc::NodeId {
|
||||
let id = id_of(&s.execute("text.create", &json!({"text": "Hamburgefonstiv", "x": 0, "y": 0})).unwrap()["id"]).unwrap();
|
||||
if let NodeKind::Text(t) = &mut s.doc_mut().unwrap().get_mut(id).unwrap().kind {
|
||||
t.style.font_family = family.into();
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
fn families(s: &mut Session) -> Vec<(String, u64)> {
|
||||
let r = s.execute("text.missingFonts", &json!({})).unwrap();
|
||||
r["families"].as_array().unwrap().iter().map(|f| (f["family"].as_str().unwrap().to_string(), f["layers"].as_u64().unwrap())).collect()
|
||||
}
|
||||
|
||||
/// An installed family (built in, or found by the system scan), if this machine has any.
|
||||
fn installed() -> Option<String> {
|
||||
figcraft_text::scan_system_fonts();
|
||||
figcraft_text::family_names().into_iter().next()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_families_of_layers_ranges_styles_and_variables_once_each() {
|
||||
let mut s = Session::new();
|
||||
text(&mut s, "FigCraft Missing A");
|
||||
let b = text(&mut s, "figcraft missing a");
|
||||
if let NodeKind::Text(t) = &mut s.doc_mut().unwrap().get_mut(b).unwrap().kind {
|
||||
let style = TextStyle { font_family: "FigCraft Missing B".into(), ..t.style.clone() };
|
||||
t.runs.push(StyleRun { start: 0, end: 3, style });
|
||||
}
|
||||
let doc = s.doc_mut().unwrap();
|
||||
doc.styles.push(Style {
|
||||
id: "S:1".into(),
|
||||
name: "Heading".into(),
|
||||
description: String::new(),
|
||||
kind: StyleKind::Text { style: TextStyle { font_family: "FigCraft Missing C".into(), ..TextStyle::default() } },
|
||||
bound: Default::default(),
|
||||
});
|
||||
s.execute("variable.createCollection", &json!({"name": "Type", "modes": ["Light", "Dark"]})).unwrap();
|
||||
s.execute("variable.create", &json!({"collection": "Type", "name": "Family", "type": "string", "value": "FigCraft Missing D"})).unwrap();
|
||||
s.execute("variable.set", &json!({"variable": "Family", "mode": "Dark", "value": "FigCraft Missing H"})).unwrap();
|
||||
let var = s.doc().unwrap().variables.get("Family").unwrap().id.clone();
|
||||
s.doc_mut().unwrap().get_mut(b).unwrap().bound.insert("fontFamily".into(), var);
|
||||
|
||||
assert_eq!(
|
||||
families(&mut s),
|
||||
vec![
|
||||
("FigCraft Missing A".to_string(), 2),
|
||||
("FigCraft Missing B".to_string(), 1),
|
||||
("FigCraft Missing C".to_string(), 0),
|
||||
("FigCraft Missing D".to_string(), 0),
|
||||
("FigCraft Missing H".to_string(), 0),
|
||||
]
|
||||
);
|
||||
assert!(s.ui_requests.iter().any(|r| r.kind == "dialog.missingFonts"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_families_are_not_listed() {
|
||||
let Some(family) = installed() else {
|
||||
eprintln!("skipped: no fonts on this machine");
|
||||
return;
|
||||
};
|
||||
let mut s = Session::new();
|
||||
text(&mut s, &family);
|
||||
text(&mut s, &family.to_uppercase());
|
||||
text(&mut s, "FigCraft Missing E");
|
||||
assert_eq!(families(&mut s), vec![("FigCraft Missing E".to_string(), 1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_family_found_only_by_the_system_scan_is_not_missing() {
|
||||
figcraft_text::scan_system_fonts();
|
||||
let embedded: Vec<String> = figcraft_text::CRAFT_FONTS.iter().map(|f| f.family.to_lowercase()).collect();
|
||||
let Some(family) = figcraft_text::family_names().into_iter().find(|f| !embedded.contains(&f.to_lowercase())) else {
|
||||
eprintln!("skipped: no system fonts on this machine");
|
||||
return;
|
||||
};
|
||||
let mut s = Session::new();
|
||||
text(&mut s, &family);
|
||||
assert!(families(&mut s).is_empty(), "{family} is installed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_families_and_aliases_are_handled() {
|
||||
let mut s = Session::new();
|
||||
text(&mut s, " ");
|
||||
s.execute("variable.createCollection", &json!({"name": "Type"})).unwrap();
|
||||
s.execute("variable.create", &json!({"collection": "Type", "name": "Base", "type": "string", "value": "FigCraft Missing F"})).unwrap();
|
||||
let base = s.doc().unwrap().variables.get("Base").unwrap().id.clone();
|
||||
let t = text(&mut s, "FigCraft Missing G");
|
||||
let doc = s.doc_mut().unwrap();
|
||||
// A variable that only aliases another, and one aliasing itself.
|
||||
let mut alias = doc.variables.get(&base).unwrap().clone();
|
||||
alias.id = "V:alias".into();
|
||||
alias.name = "Alias".into();
|
||||
for v in alias.values.values_mut() {
|
||||
*v = VariableValue::Alias(base.clone());
|
||||
}
|
||||
let mut cycle = alias.clone();
|
||||
cycle.id = "V:cycle".into();
|
||||
cycle.name = "Cycle".into();
|
||||
for v in cycle.values.values_mut() {
|
||||
*v = VariableValue::Alias("V:cycle".into());
|
||||
}
|
||||
doc.variables.variables.push(alias);
|
||||
doc.variables.variables.push(cycle);
|
||||
doc.get_mut(t).unwrap().bound.insert("fontFamily".into(), "V:alias".into());
|
||||
let other = text(&mut s, "FigCraft Missing G");
|
||||
s.doc_mut().unwrap().get_mut(other).unwrap().bound.insert("fontFamily".into(), "V:cycle".into());
|
||||
// The bound layer takes the value its alias resolves to (bindings are applied after the next
|
||||
// command); the cycle resolves to nothing.
|
||||
let NodeKind::Text(bound) = &s.doc().unwrap().node(t).unwrap().kind else { panic!() };
|
||||
assert_eq!(bound.style.font_family, "FigCraft Missing F");
|
||||
assert_eq!(families(&mut s), vec![("FigCraft Missing F".to_string(), 1), ("FigCraft Missing G".to_string(), 1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_an_open_document() {
|
||||
let mut s = Session::empty();
|
||||
assert!(s.execute("text.missingFonts", &json!({})).is_err());
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Frame name labels as pointer targets: a press on a label (its area as the canvas drew it,
|
||||
//! turned with a rotated frame) selects the frame and drags it in one undo step, hovering it
|
||||
//! highlights the frame, and labels of locked, hidden, deleted or other pages' frames do nothing.
|
||||
//! highlights the frame, a double-click asks the UI to rename it there, and labels of locked,
|
||||
//! hidden, deleted or other pages' frames do nothing.
|
||||
|
||||
use figcraft_doc::NodeId;
|
||||
use figcraft_geom::Point;
|
||||
@@ -90,3 +91,24 @@ fn labels_of_frames_that_cant_be_picked_do_nothing() {
|
||||
let flat = [Point::new(1.0, 1.0); 4];
|
||||
assert_eq!(label_at(s.doc().unwrap(), page, &[(f, nan), (f, flat)], Point::new(1.0, 1.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_clicking_a_label_asks_to_rename_its_frame() {
|
||||
let (mut s, f, q) = setup();
|
||||
let c = center(&q);
|
||||
let before = s.doc().unwrap().world_bounds(f).unwrap();
|
||||
let undo = s.ds().unwrap().history.undo.len();
|
||||
for kind in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::DoubleClick] {
|
||||
s.pointer(&ev(kind, c)).unwrap();
|
||||
}
|
||||
assert_eq!(s.selection(), vec![f]);
|
||||
let asked = s.ui_requests.iter().find(|r| r.kind == "canvas.renameLabel").map(|r| id_of(&r.data["id"]));
|
||||
assert_eq!(asked, Some(Some(f)), "the UI renames the frame at its label");
|
||||
assert_eq!(s.doc().unwrap().world_bounds(f), Some(before), "not moved");
|
||||
assert_eq!(s.ds().unwrap().history.undo.len(), undo, "nothing to undo yet");
|
||||
// Other tools ignore the label.
|
||||
s.ui_requests.clear();
|
||||
s.set_tool(crate::tools::ToolId::Hand);
|
||||
s.pointer(&ev(PointerKind::DoubleClick, c)).unwrap();
|
||||
assert!(s.ui_requests.iter().all(|r| r.kind != "canvas.renameLabel"));
|
||||
}
|
||||
@@ -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;
|
||||
@@ -24,7 +26,10 @@ pub mod dev_resources;
|
||||
pub mod devcompare;
|
||||
pub mod devinspect;
|
||||
pub mod distance;
|
||||
pub mod doc_urls;
|
||||
pub mod drop;
|
||||
pub mod file_access;
|
||||
pub mod fonts;
|
||||
pub mod guard;
|
||||
pub mod guides;
|
||||
pub mod inspect;
|
||||
@@ -43,6 +48,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;
|
||||
@@ -369,6 +376,8 @@ pub struct DocState {
|
||||
pub guide: Option<(NodeId, usize)>,
|
||||
/// The selection's rotation origin, when moved or shown (see [`rotation_origin`]; not saved).
|
||||
pub(crate) rotation_origin: Option<rotation_origin::RotationOrigin>,
|
||||
/// The View toggles the preferences keep were applied ([`Session::apply_view_prefs`]).
|
||||
view_from_prefs: bool,
|
||||
/// The last duplicate while its copies are the selection: Duplicate repeats its offset (see
|
||||
/// [`cmd::edit::Duplicated`]; not saved).
|
||||
pub(crate) last_duplicate: Option<cmd::edit::Duplicated>,
|
||||
@@ -430,6 +439,7 @@ impl DocState {
|
||||
focus: None,
|
||||
guide: None,
|
||||
rotation_origin: None,
|
||||
view_from_prefs: false,
|
||||
last_duplicate: None,
|
||||
};
|
||||
st.zoom_to_fit();
|
||||
@@ -658,6 +668,10 @@ pub struct Prefs {
|
||||
pub home_sort: String,
|
||||
/// Which way: `"desc"` (newest first, Z to A) or `"asc"`.
|
||||
pub home_order: String,
|
||||
/// The View menu's toggles turned on or off (rulers, pixel grid, snapping…), by command id:
|
||||
/// files opened or made later start with them. Missing: the toggle's default.
|
||||
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub view: BTreeMap<String, bool>,
|
||||
/// Recently run actions (the Actions palette's Recents), newest first, at most
|
||||
/// [`MAX_RECENT_ACTIONS`]. Entries are `command:<id>`, `plugin:<plugin>/<command>` or
|
||||
/// `widget:<plugin>`.
|
||||
@@ -696,6 +710,7 @@ impl Default for Prefs {
|
||||
home_views: BTreeMap::new(),
|
||||
home_sort: "modified".into(),
|
||||
home_order: "desc".into(),
|
||||
view: BTreeMap::new(),
|
||||
recent_actions: Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -848,6 +863,14 @@ impl Session {
|
||||
s
|
||||
}
|
||||
|
||||
/// Files new to the session start with the View toggles the preferences keep.
|
||||
pub fn apply_view_prefs(&mut self) {
|
||||
for ds in self.docs.iter_mut().filter(|d| !d.view_from_prefs) {
|
||||
cmd::view::apply_kept(&self.prefs, &mut ds.view);
|
||||
ds.view_from_prefs = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ds(&self) -> Result<&DocState> {
|
||||
self.docs.get(self.active).ok_or(EngineError::NoDocument)
|
||||
}
|
||||
@@ -889,6 +912,7 @@ impl Session {
|
||||
return Err(EngineError::Disabled(id.into(), "leave the live session before reverting to the saved file".into()));
|
||||
}
|
||||
branches::sync(self);
|
||||
self.apply_view_prefs();
|
||||
if textpreview::COMMANDS.contains(&id) {
|
||||
// A live preview is no edit: no undo step, normalisation or rollback here.
|
||||
let r = guard::run(id, || (spec.run)(self, params));
|
||||
@@ -914,6 +938,7 @@ impl Session {
|
||||
match &result {
|
||||
Ok(_) => {
|
||||
self.after_command(spec, before);
|
||||
self.apply_view_prefs();
|
||||
if spec.history && id != "edit.repeat" {
|
||||
self.last_command = Some((id.to_string(), params.clone()));
|
||||
}
|
||||
@@ -1571,6 +1596,8 @@ mod devfocus_tests;
|
||||
#[cfg(test)]
|
||||
mod devinspect_tests;
|
||||
#[cfg(test)]
|
||||
mod doc_urls_tests;
|
||||
#[cfg(test)]
|
||||
mod double_click_tests;
|
||||
#[cfg(test)]
|
||||
mod draw_tools_tests;
|
||||
@@ -1623,12 +1650,16 @@ 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;
|
||||
#[cfg(test)]
|
||||
mod keymap_tests;
|
||||
#[cfg(test)]
|
||||
mod library_path_tests;
|
||||
#[cfg(test)]
|
||||
mod library_range_styles_tests;
|
||||
#[cfg(test)]
|
||||
mod library_variables_tests;
|
||||
@@ -1655,6 +1686,8 @@ mod menu_gaps_tests;
|
||||
#[cfg(test)]
|
||||
mod nested_override_tests;
|
||||
#[cfg(test)]
|
||||
mod node_set_refusals_tests;
|
||||
#[cfg(test)]
|
||||
mod online_tests;
|
||||
#[cfg(test)]
|
||||
mod open_issues_tests;
|
||||
@@ -1677,6 +1710,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;
|
||||
@@ -1796,3 +1831,6 @@ mod version_tests;
|
||||
mod view_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod widgets_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod selection_spacing_tests;
|
||||
@@ -541,7 +541,7 @@ fn now() -> Option<std::time::SystemTime> {
|
||||
|
||||
#[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"));
|
||||
}
|
||||
@@ -555,7 +555,7 @@ fn stat(_path: &str) -> std::result::Result<Stamp, String> {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_file(path: &str) -> std::result::Result<PublishedLibrary, String> {
|
||||
let f = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
let f = crate::file_access::open(path).map_err(|e| e.to_string())?;
|
||||
match figcraft_format::read_library(std::io::BufReader::new(f)) {
|
||||
Ok(Some(lib)) => Ok(lib),
|
||||
Ok(None) => Err(format!("{path} has no published library: open it and publish it first (Libraries ▸ Publish library…)")),
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
//! A library link is stored in the document, so it is followed only on this computer
|
||||
//! ([`crate::library_server`]): a path on another server would make opening someone else's file
|
||||
//! contact that server, and on Windows send the credentials of whoever opened it.
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn a_library_is_opened_only_from_this_computer() {
|
||||
let mut s = Session::new();
|
||||
for path in [
|
||||
r"\\attacker.example\share\lib.figcraft",
|
||||
"//attacker.example/share/lib.figcraft",
|
||||
"file://attacker.example/share/lib.figcraft",
|
||||
r"\\?\UNC\attacker.example\share\lib.figcraft",
|
||||
r"\\.\pipe\lib",
|
||||
"https://attacker.example/lib.figcraft",
|
||||
] {
|
||||
let e = s.fetch_library(path, false).expect_err(&format!("{path} must be refused"));
|
||||
assert!(e.to_string().contains("only from this computer"), "{path}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_local_library_path_is_still_followed() {
|
||||
let mut s = Session::new();
|
||||
// Refused for being absent, not for where it is: the gate let it through.
|
||||
let missing = std::env::temp_dir().join("figcraft-no-such-library.figcraft");
|
||||
let e = s.fetch_library(&missing.to_string_lossy(), false).expect_err("the file does not exist");
|
||||
assert!(!e.to_string().contains("only from this computer"), "a local path must not be refused as remote: {e}");
|
||||
}
|
||||
@@ -129,6 +129,13 @@ impl Session {
|
||||
/// document uses the library.
|
||||
pub fn fetch_library(&mut self, path: &str, fresh: bool) -> Result<Arc<PublishedLibrary>> {
|
||||
if !is_server_link(path) {
|
||||
// A library link is stored in the document, so opening someone else's file would
|
||||
// otherwise reach whatever path it names. Only a path on this computer is followed: a
|
||||
// network share would make opening a document contact that server, and on Windows
|
||||
// send the credentials of whoever opened it.
|
||||
if !craft_docpath::is_local(path) {
|
||||
return Err(EngineError::Other(format!("{path}: a library is opened only from this computer")));
|
||||
}
|
||||
return self.libraries.load(path);
|
||||
}
|
||||
let have = match self.libraries.cached(path) {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
//! `node.set` doesn't store what can't apply: auto layout keys go only to layers that can have auto
|
||||
//! layout (the others are named in `warnings`, and with none of them the call is refused), and a
|
||||
//! `fontStyle` the family lacks says which face draws instead.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
fn id(v: &Value) -> crate::NodeId {
|
||||
crate::params::id_of(v).unwrap()
|
||||
}
|
||||
|
||||
fn bad_msg(r: crate::Result<Value>) -> String {
|
||||
match r {
|
||||
Err(EngineError::BadParams { msg, .. }) => msg,
|
||||
other => panic!("expected BadParams, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_layout_keys_on_layers_without_auto_layout_are_refused() {
|
||||
let mut s = Session::new();
|
||||
let t = id(&ex(&mut s, "node.create", json!({"type": "text", "text": "Hi"}))["id"]);
|
||||
let r = id(&ex(&mut s, "node.create", json!({"type": "rectangle"}))["id"]);
|
||||
for (target, key, value) in [(t, "gap", json!(8)), (r, "padding", json!(4)), (t, "mainAlign", json!("center")), (r, "wrap", json!(true))] {
|
||||
let msg = bad_msg(s.execute("node.set", &json!({"ids": [target], "props": {key: value}})));
|
||||
assert!(msg.contains(key) && msg.contains("auto layout"), "{msg}");
|
||||
assert!(s.doc().unwrap().node(target).unwrap().layout.is_none(), "no hidden auto layout on {target}");
|
||||
}
|
||||
// Frames still take them, with or without a layout mode first.
|
||||
let f = id(&ex(&mut s, "node.create", json!({"type": "frame"}))["id"]);
|
||||
let out = ex(&mut s, "node.set", json!({"ids": [f], "props": {"gap": 8, "padding": 4}}));
|
||||
assert_eq!(s.doc().unwrap().node(f).unwrap().layout.as_ref().map(|l| l.gap), Some(8.0));
|
||||
assert!(out.get("warnings").is_none(), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_several_layers_the_frames_take_auto_layout_keys_and_the_others_are_named() {
|
||||
let mut s = Session::new();
|
||||
let f = id(&ex(&mut s, "node.create", json!({"type": "frame", "name": "Card"}))["id"]);
|
||||
let t = id(&ex(&mut s, "node.create", json!({"type": "text", "text": "Hi", "name": "Title"}))["id"]);
|
||||
let out = ex(&mut s, "node.set", json!({"ids": [f, t], "props": {"gap": 8, "opacity": 0.5}}));
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(doc.node(f).unwrap().layout.as_ref().map(|l| l.gap), Some(8.0));
|
||||
assert!(doc.node(t).unwrap().layout.is_none(), "no hidden auto layout on the text");
|
||||
assert_eq!(doc.node(t).unwrap().opacity, 0.5, "the text still gets the other keys");
|
||||
let w = out["warnings"][0].as_str().unwrap();
|
||||
assert!(w.contains("`gap`") && w.contains(&format!("{t} “Title”")) && w.contains("TEXT"), "{w}");
|
||||
// One undo step for all of it.
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc().unwrap().node(f).unwrap().layout.as_ref().is_none_or(|l| l.gap != 8.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_font_style_draws_in_the_nearest_face_and_says_so_when_it_differs() {
|
||||
figcraft_text::scan_system_fonts();
|
||||
// Any installed family with a regular and a bold face and nothing heavier (CI machines differ).
|
||||
let pick = figcraft_text::family_names().into_iter().find_map(|f| {
|
||||
let st = figcraft_text::family_styles(&f);
|
||||
let has = |w: u16| st.iter().any(|x| figcraft_doc::style_weight(x) == w && !x.to_lowercase().contains("italic"));
|
||||
(has(400) && has(700) && !st.iter().any(|x| figcraft_doc::style_weight(x) > 700)).then_some((f, st))
|
||||
});
|
||||
let Some((family, styles)) = pick else { return };
|
||||
let mut s = Session::new();
|
||||
let t = id(&ex(&mut s, "node.create", json!({"type": "text", "text": "Hi", "props": {"fontFamily": family}}))["id"]);
|
||||
let set = |s: &mut Session, style: &str| ex(s, "node.set", json!({"ids": [t], "props": {"fontStyle": style}}));
|
||||
// Any spelling of a style it has, and names that resolve to the weight they ask for, say nothing.
|
||||
let bold = styles.iter().find(|x| figcraft_doc::style_weight(x) == 700).unwrap().clone();
|
||||
for style in [bold.clone(), bold.to_uppercase(), bold.replace(' ', ""), "Roman".into(), "Normal".into()] {
|
||||
let out = set(&mut s, &style);
|
||||
assert!(out.get("warnings").is_none(), "{style}: {out}");
|
||||
}
|
||||
// A heavier weight than the family has applies (as a text layer resolves it) and says which face draws.
|
||||
let out = set(&mut s, "Black");
|
||||
let w = out["warnings"][0].as_str().unwrap();
|
||||
assert!(w.contains(&family) && w.contains("`Black`") && w.contains("nearest"), "{w}");
|
||||
assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().spans()[0].2.font_style, "Black");
|
||||
// A family that isn't installed keeps any style name without a word (files from elsewhere name theirs).
|
||||
let out = ex(&mut s, "node.set", json!({"ids": [t], "props": {"fontFamily": "No Such Family 7f3a", "fontStyle": "Hairline"}}));
|
||||
assert!(out.get("warnings").is_none(), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_call_changes_nothing() {
|
||||
let mut s = Session::new();
|
||||
let t = id(&ex(&mut s, "node.create", json!({"type": "text", "text": "Hi"}))["id"]);
|
||||
let before = s.doc().unwrap().node(t).unwrap().fills.clone();
|
||||
// `fill` comes before `gap` (keys apply in order), so this checks the rollback.
|
||||
assert!(s.execute("node.set", &json!({"ids": [t], "props": {"fill": "#FF0000", "gap": 8}})).is_err());
|
||||
assert_eq!(s.doc().unwrap().node(t).unwrap().fills, before);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//! Ending a pen path with Escape or Enter (#760): the path's vector stays in edit mode with the pen,
|
||||
//! so the next path is drawn into the same layer; another Escape leaves edit mode.
|
||||
|
||||
use crate::Session;
|
||||
use crate::tools::{PointerEvent, PointerKind, ToolId};
|
||||
use crate::vectoredit::VTool;
|
||||
use figcraft_doc::NodeKind;
|
||||
|
||||
fn click(s: &mut Session, x: f64, y: f64) {
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::at(kind, x, y, 1.0)).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn layers(s: &Session) -> usize {
|
||||
s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len()
|
||||
}
|
||||
|
||||
/// (points, segments) of vector `id`.
|
||||
fn shape(s: &Session, id: figcraft_doc::NodeId) -> (usize, usize) {
|
||||
match &s.doc().unwrap().node(id).unwrap().kind {
|
||||
NodeKind::Vector { network } => (network.vertices.len(), network.segments.len()),
|
||||
k => panic!("not a vector: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A two-point pen path, ended with `end`.
|
||||
fn drawn(end: fn(&mut Session)) -> (Session, figcraft_doc::NodeId) {
|
||||
let mut s = Session::new();
|
||||
s.set_tool(ToolId::Pen);
|
||||
click(&mut s, 100.0, 100.0);
|
||||
click(&mut s, 200.0, 100.0);
|
||||
let id = s.tool.pen.as_ref().and_then(|p| p.id).unwrap();
|
||||
let undo = s.undo_depth().0;
|
||||
end(&mut s);
|
||||
assert!(s.tool.pen.is_none(), "the path ended");
|
||||
assert_eq!(s.undo_depth().0, undo + 1, "the path is one undo step");
|
||||
let ve = s.ds().unwrap().vector_edit.clone().expect("still editing the vector");
|
||||
assert_eq!((ve.id, ve.tool, ve.anchor), (id, VTool::Pen, None), "the pen, with no open end");
|
||||
(s, id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_and_enter_keep_the_vector_in_edit_mode_with_the_pen() {
|
||||
// Escape, and Enter (its key runs `pen_end`).
|
||||
let ends: [fn(&mut Session); 2] = [|s| s.escape().unwrap(), |s| s.pen_end().unwrap()];
|
||||
for end in ends {
|
||||
let (mut s, id) = drawn(end);
|
||||
// The next path goes into the same layer, unconnected to the first.
|
||||
click(&mut s, 100.0, 300.0);
|
||||
click(&mut s, 200.0, 300.0);
|
||||
assert_eq!(layers(&s), 1, "no new layer");
|
||||
assert_eq!(shape(&s, id), (4, 2), "two separate paths in one vector network");
|
||||
// Escape ends that path, the next one leaves edit mode.
|
||||
s.escape().unwrap();
|
||||
assert!(s.ds().unwrap().vector_edit.is_some());
|
||||
s.escape().unwrap();
|
||||
assert!(s.ds().unwrap().vector_edit.is_none(), "the second Escape leaves edit mode");
|
||||
assert_eq!(s.ds().unwrap().selection, vec![id]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_before_a_segment_leaves_nothing_to_edit() {
|
||||
let mut s = Session::new();
|
||||
s.set_tool(ToolId::Pen);
|
||||
click(&mut s, 100.0, 100.0);
|
||||
s.escape().unwrap();
|
||||
assert!(s.tool.pen.is_none());
|
||||
assert!(s.ds().unwrap().vector_edit.is_none());
|
||||
assert_eq!(layers(&s), 0);
|
||||
}
|
||||
@@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
if let Some(path) = string(p, "path") {
|
||||
let scale = num(p, "scale").filter(|k| k.is_finite()).map_or(2.0, |k| k.clamp(0.01, 16.0));
|
||||
let png = crate::cmd::file::export_bytes(©, &[inst], ExportFormat::Png, scale)?;
|
||||
std::fs::write(&path, png).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
crate::file_access::write(&path, &png)?;
|
||||
out["path"] = json!(path);
|
||||
}
|
||||
Ok(out)
|
||||
|
||||
@@ -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"}),
|
||||
|
||||
@@ -1859,6 +1859,20 @@ impl Session {
|
||||
if self.tool.tool == ToolId::Pen {
|
||||
return self.pen_finish();
|
||||
}
|
||||
// A frame's or section's name label: rename it there (the canvas puts a field over it).
|
||||
// Labels come before widgets, as on a press; the second press began a move: undo it.
|
||||
if matches!(self.tool.tool, ToolId::Move | ToolId::Scale)
|
||||
&& !(ev.shift || ev.cmd || ev.alt)
|
||||
&& let Some(id) = label_at(&doc, page, &self.tool.labels, ev.p())
|
||||
{
|
||||
self.tool.gesture = Gesture::None;
|
||||
self.cancel_interaction()?;
|
||||
if sel != [id] {
|
||||
self.set_selection(vec![id]);
|
||||
}
|
||||
self.request_ui("canvas.renameLabel", serde_json::json!({"id": id}));
|
||||
return Ok(());
|
||||
}
|
||||
// Quick clicks on a widget's clickable part are clicks, one by one.
|
||||
if matches!(self.tool.tool, ToolId::Move | ToolId::Scale)
|
||||
&& !(ev.shift || ev.cmd || ev.alt)
|
||||
@@ -2051,13 +2065,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 +2183,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 +2351,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 {
|
||||
|
||||
@@ -742,3 +742,32 @@ fn alt_measures_between_layers() {
|
||||
assert_eq!(measured(&mut s, p, m), want, "{why}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_toggles_are_kept_for_the_next_launch() {
|
||||
let mut s = Session::new();
|
||||
// (command, turned to, ViewState field, derivation)
|
||||
let rows: Vec<(&str, bool, fn(&ViewState) -> bool, &str)> = vec![
|
||||
("view.rulers", true, |v| v.rulers, "rulers shown stay shown"),
|
||||
("view.pixelGrid", false, |v| v.pixel_grid, "a hidden pixel grid stays hidden"),
|
||||
("view.layoutGrids", false, |v| v.layout_grids, "hidden layout grids stay hidden"),
|
||||
("view.snapPixel", false, |v| v.snap_pixel, "snapping to the pixel grid off stays off"),
|
||||
];
|
||||
for (cmd, on, _, _) in &rows {
|
||||
ex(&mut s, cmd, json!({"on": on}));
|
||||
}
|
||||
// Modes stay with their file: a new one never opens in outline mode or soft-proofing.
|
||||
for cmd in ["view.outlines", "view.outlinesSelection", "view.proofSrgb"] {
|
||||
ex(&mut s, cmd, json!({"on": true}));
|
||||
}
|
||||
// The app writes its preferences as JSON and reads them back at the next launch.
|
||||
let saved = serde_json::to_value(&s.prefs).unwrap();
|
||||
let mut next = Session::empty();
|
||||
next.prefs = serde_json::from_value(saved).unwrap();
|
||||
ex(&mut next, "file.new", json!({}));
|
||||
let view = &next.ds().unwrap().view;
|
||||
for (cmd, on, get, why) in rows {
|
||||
assert_eq!(get(view), on, "{cmd}: {why}");
|
||||
}
|
||||
assert!(!view.outlines && !view.outlines_selection && !view.proof_srgb, "{view:?}");
|
||||
}
|
||||
@@ -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",
|
||||
@@ -1736,7 +1736,7 @@ impl<'d> Exporter<'d> {
|
||||
let sp = &n.stroke;
|
||||
let (w, h, weight) = (n.width, n.height, sp.weight);
|
||||
let plain = n.strokes.iter().any(|p| p.visible) && sp.dashes.is_empty() && sp.sides.is_none() && sp.profile().is_none() && sp.brush.is_none();
|
||||
if !plain || !(weight.is_finite() && weight > 0.0 && w.is_finite() && h.is_finite()) {
|
||||
if !(plain && weight.is_finite() && weight > 0.0 && w.is_finite() && h.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let whole = matches!(n.kind, NodeKind::Ellipse { sweep, inner, .. } if inner == 0.0 && sweep.abs() >= std::f64::consts::TAU - 1e-9);
|
||||
@@ -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));
|
||||
@@ -2216,12 +2217,13 @@ fn outlined(n: &Node) -> Option<(Node, String)> {
|
||||
NodeKind::Line | NodeKind::Vector { .. } | NodeKind::Ellipse { .. } | NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Rectangle
|
||||
);
|
||||
let shown = |paints: &[Paint]| paints.iter().any(|p| p.visible);
|
||||
if !shape
|
||||
|| sp.brush.is_some()
|
||||
|| !sp.profile().is_some_and(one_sided)
|
||||
|| !(sp.weight.is_finite() && sp.weight > 0.0)
|
||||
|| !shown(&n.strokes)
|
||||
|| shown(&n.fills)
|
||||
if !(shape
|
||||
&& sp.brush.is_none()
|
||||
&& sp.profile().is_some_and(one_sided)
|
||||
&& sp.weight.is_finite()
|
||||
&& sp.weight > 0.0
|
||||
&& shown(&n.strokes)
|
||||
&& !shown(&n.fills))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
@@ -2031,7 +2048,7 @@ impl<'a> Mapper<'a> {
|
||||
self.warn("slot content assignment uses the empty GUID sentinel and was skipped");
|
||||
continue;
|
||||
}
|
||||
if !self.nodes.get(&target).is_some_and(|n| n.get("isSlotContent").and_then(Kv::bool) == Some(true)) {
|
||||
if self.nodes.get(&target).is_none_or(|n| n.get("isSlotContent").and_then(Kv::bool) != Some(true)) {
|
||||
self.warn("slot content assignment target is missing or is not slot content");
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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")))]
|
||||
@@ -835,6 +852,11 @@ pub fn family_names() -> Vec<String> {
|
||||
db().read().unwrap_or_else(std::sync::PoisonError::into_inner).families()
|
||||
}
|
||||
|
||||
/// The global db has `family` (embedded, loaded or indexed; any case).
|
||||
pub fn has_family(family: &str) -> bool {
|
||||
db().read().unwrap_or_else(std::sync::PoisonError::into_inner).has_family(family)
|
||||
}
|
||||
|
||||
/// Style names of a family in the global db.
|
||||
pub fn family_styles(family: &str) -> Vec<String> {
|
||||
db().write().unwrap_or_else(std::sync::PoisonError::into_inner).styles(family)
|
||||
@@ -871,6 +893,16 @@ fn system_fonts_opted_out() -> bool {
|
||||
std::env::var_os("FIGCRAFT_NO_SYSTEM_FONTS").is_some_and(|v| !v.is_empty() && v != "0")
|
||||
}
|
||||
|
||||
/// Every font text can use is known: the system fonts were indexed, or there are none to index
|
||||
/// (opted out, or the web). Until then a family the scan would find counts as missing.
|
||||
pub fn installed_fonts_known() -> bool {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if !system_fonts_opted_out() {
|
||||
return system_fonts_scanned();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Index the system fonts the first time the font database is used (headless apps: a run that
|
||||
/// lays out no text never pays for the scan). The desktop app scans in the background instead.
|
||||
pub fn scan_system_fonts_on_demand() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -16,8 +16,8 @@ mod onpath;
|
||||
pub use breaks::grapheme_step;
|
||||
pub use color::{ColorBitmap, ColorInk, ColorLayer};
|
||||
pub use fontdb::{
|
||||
Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, axis_label, db, face_with_char, family_names, family_styles, find_face, no_coords,
|
||||
scan_system_fonts, scan_system_fonts_on_demand, system_fonts_scanned,
|
||||
Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, axis_label, db, face_with_char, family_names, family_styles, find_face, has_family,
|
||||
installed_fonts_known, no_coords, scan_system_fonts, scan_system_fonts_on_demand, system_fonts_scanned,
|
||||
};
|
||||
pub use layout::{Decoration, Glyph, Line, TextLayout, layout};
|
||||
pub use onpath::OnPath;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -91,6 +91,7 @@ name = "English"
|
||||
"All components" = "All components"
|
||||
"All fonts" = "All fonts"
|
||||
"All supported properties" = "All supported properties"
|
||||
"All the fonts this file uses are installed" = "All the fonts this file uses are installed"
|
||||
"All variables" = "All variables"
|
||||
"Alpha" = "Alpha"
|
||||
"Alphabetical" = "Alphabetical"
|
||||
@@ -197,6 +198,7 @@ name = "English"
|
||||
"C or ⇧C" = "C or ⇧C"
|
||||
"CMYK colour" = "CMYK colour"
|
||||
"Can view" = "Can view"
|
||||
"Checking the fonts installed on this computer…" = "Checking the fonts installed on this computer…"
|
||||
"Checking the server…" = "Checking the server…"
|
||||
"Clear from this list" = "Clear from this list"
|
||||
"Code or invite link" = "Code or invite link"
|
||||
@@ -341,6 +343,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 +654,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:"
|
||||
@@ -769,6 +773,7 @@ name = "English"
|
||||
"Mirror angle and length" = "Mirror angle and length"
|
||||
"Mirroring" = "Mirroring"
|
||||
"Missing" = "Missing"
|
||||
"Missing fonts" = "Missing fonts"
|
||||
"Missing layer" = "Missing layer"
|
||||
"Missing style" = "Missing style"
|
||||
"Missing variable" = "Missing variable"
|
||||
@@ -809,6 +814,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 +823,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 +831,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."
|
||||
@@ -1013,6 +1021,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"
|
||||
@@ -1107,6 +1116,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"
|
||||
@@ -1181,9 +1191,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"
|
||||
@@ -1365,6 +1377,7 @@ name = "English"
|
||||
"This condition is edited as an expression" = "This condition is edited as an expression"
|
||||
"This file" = "This file"
|
||||
"This file has unsaved changes" = "This file has unsaved changes"
|
||||
"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them."
|
||||
"This file is on {server}, open only to you. Share it to let others in with a new code." = "This file is on {server}, open only to you. Share it to let others in with a new code."
|
||||
"This frame is new on the branch." = "This frame is new on the branch."
|
||||
"This frame isn't on the branch." = "This frame isn't on the branch."
|
||||
@@ -1527,13 +1540,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"
|
||||
@@ -1542,14 +1567,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"
|
||||
@@ -1557,12 +1588,19 @@ 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"
|
||||
"only in a text style or variable" = "only in a text style or variable"
|
||||
"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"
|
||||
@@ -1580,8 +1618,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"
|
||||
@@ -1661,6 +1705,7 @@ name = "English"
|
||||
"{n} replies" = { one = "{n} reply", other = "{n} replies" }
|
||||
"{n} rows" = { one = "{n} row", other = "{n} rows" }
|
||||
"{n} styles" = { one = "{n} style", other = "{n} styles" }
|
||||
"{n} text layers" = { one = "{n} text layer", other = "{n} text layers" }
|
||||
"{n} variables" = { one = "{n} variable", other = "{n} variables" }
|
||||
"{n} weeks ago" = { one = "{n} week ago", other = "{n} weeks ago" }
|
||||
"{n} years ago" = { one = "{n} year ago", other = "{n} years ago" }
|
||||
@@ -1686,4 +1731,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" }
|
||||
|
||||
|
||||
@@ -93,6 +93,7 @@ status = "draft — needs native review"
|
||||
"All comments" = "Todos los comentarios"
|
||||
"All components" = "Todos los componentes"
|
||||
"All fonts" = "Todas las fuentes"
|
||||
"All the fonts this file uses are installed" = "Todas las fuentes que usa este archivo están instaladas"
|
||||
"All supported properties" = "Todas las propiedades compatibles"
|
||||
"All variables" = "Todas las variables"
|
||||
"Alpha" = "Alfa"
|
||||
@@ -200,6 +201,7 @@ status = "draft — needs native review"
|
||||
"C or ⇧C" = "C o ⇧C"
|
||||
"CMYK colour" = "Color CMYK"
|
||||
"Can view" = "Puede ver"
|
||||
"Checking the fonts installed on this computer…" = "Comprobando las fuentes instaladas en este equipo…"
|
||||
"Checking the server…" = "Comprobando el servidor…"
|
||||
"Clear from this list" = "Borrar de esta lista"
|
||||
"Code or invite link" = "Código o enlace de invitación"
|
||||
@@ -342,6 +344,7 @@ status = "draft — needs native review"
|
||||
"Current version" = "Versión actual"
|
||||
"Cursor chat" = "Chat del cursor"
|
||||
"Custom" = "Personalizado"
|
||||
"Custom size" = "Tamaño personalizado"
|
||||
"Cut" = "Cortar"
|
||||
"Dark" = "Oscuro"
|
||||
"Darken" = "Oscurecer"
|
||||
@@ -652,6 +655,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." = "Diseño de interfaces y prototipos. Una herramienta de diseño gratuita y de código abierto, de implementación independiente (clean-room), escrita desde cero en Rust, parte de las Crafting Apps de ArtCraft."
|
||||
"Intersect" = "Intersecar"
|
||||
"Intersects" = "Intersecciones"
|
||||
"Invalid color" = "Color no válido"
|
||||
"Invite link" = "Enlace de invitación"
|
||||
"Invite link copied" = "Enlace de invitación copiado"
|
||||
"It asks permission to:" = "Pide permiso para:"
|
||||
@@ -772,6 +776,7 @@ status = "draft — needs native review"
|
||||
"Missing" = "No encontrado"
|
||||
"Missing layer" = "Capa no encontrada"
|
||||
"Missing style" = "Estilo no encontrado"
|
||||
"Missing fonts" = "Fuentes que faltan"
|
||||
"Missing variable" = "Variable no encontrada"
|
||||
"Miter" = "Inglete"
|
||||
"Miter angle" = "Ángulo de inglete"
|
||||
@@ -810,6 +815,7 @@ status = "draft — needs native review"
|
||||
"New design file" = "Nuevo archivo de diseño"
|
||||
"Newest first" = "Más recientes primero"
|
||||
"Next or previous control in the focused region" = "Control siguiente o anterior de la región enfocada"
|
||||
"Next frame" = "Marco siguiente"
|
||||
"Next ready for dev" = "Siguiente listo para desarrollo"
|
||||
"Next size option" = "Siguiente opción de tamaño"
|
||||
"No assets: no export settings, icons or images. Add export settings in the Design tab." = "No hay recursos: ni ajustes de exportación, ni iconos, ni imágenes. Añade ajustes de exportación en la pestaña Diseño."
|
||||
@@ -818,6 +824,7 @@ status = "draft — needs native review"
|
||||
"No branches. A branch is a copy of the file to try changes in, then review and merge." = "No hay ramas. Una rama es una copia del archivo para probar cambios y luego revisarlos y fusionarlos."
|
||||
"No category" = "Sin categoría"
|
||||
"No changes" = "Sin cambios"
|
||||
"No color styles" = "No hay estilos de color"
|
||||
"No colour variables fit here." = "Ninguna variable de color encaja aquí."
|
||||
"No colour variables yet. Create them in Local variables." = "Aún no hay variables de color. Créalas en Variables locales."
|
||||
"No comments yet" = "Aún no hay comentarios"
|
||||
@@ -825,6 +832,7 @@ status = "draft — needs native review"
|
||||
"No components yet. Select layers and press {keys}." = "Aún no hay componentes. Selecciona capas y pulsa {keys}."
|
||||
"No conflicts." = "Sin conflictos."
|
||||
"No contributor data was built into this copy." = "Esta copia no incluye datos de colaboradores."
|
||||
"No device" = "Sin dispositivo"
|
||||
"No fonts match" = "Ninguna fuente coincide"
|
||||
"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Aquí no hay código para unirse (el servidor es anterior a los códigos, o esta dirección no cabe en uno): comparte el enlace."
|
||||
"No layer changes to show." = "No hay cambios de capas que mostrar."
|
||||
@@ -1014,6 +1022,7 @@ status = "draft — needs native review"
|
||||
"Preview" = "Vista previa"
|
||||
"Preview this flow" = "Vista previa de este flujo"
|
||||
"Preview this frame" = "Vista previa de este marco"
|
||||
"Previous frame" = "Marco anterior"
|
||||
"Previous ready for dev" = "Anterior listo para desarrollo"
|
||||
"Progressive" = "Progresivo"
|
||||
"Properties" = "Propiedades"
|
||||
@@ -1108,6 +1117,7 @@ status = "draft — needs native review"
|
||||
"Resize by the nudge amount" = "Redimensionar según el desplazamiento"
|
||||
"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Ajusta los marcos a la pantalla del dispositivo (o a la ventana sin dispositivo): sus restricciones y su diseño automático se adaptan"
|
||||
"Resize to fit" = "Ajustar tamaño"
|
||||
"Resizing" = "Cambio de tamaño"
|
||||
"Resolve" = "Resolver"
|
||||
"Resolve change" = "Resolver cambio"
|
||||
"Resolved" = "Resuelto"
|
||||
@@ -1182,9 +1192,11 @@ status = "draft — needs native review"
|
||||
"Select all with same" = "Seleccionar todo con el mismo"
|
||||
"Select layer" = "Seleccionar capa"
|
||||
"Select layers to apply the style to" = "Selecciona capas para aplicarles el estilo"
|
||||
"Select layers with this color" = "Seleccionar capas con este color"
|
||||
"Select overlapping shapes" = "Selecciona formas superpuestas"
|
||||
"Select points to edit their position, radius and mirroring" = "Selecciona puntos para editar su posición, radio y reflejo"
|
||||
"Select slot" = "Seleccionar ranura"
|
||||
"Selection colors" = "Colores de la selección"
|
||||
"Selection" = "Selección"
|
||||
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Cuadro de selección, {w} por {h} en {x}, {y}: las flechas lo mueven, Mayús+flechas o {cmd}+flechas cambian su tamaño, Intro selecciona, Esc cancela"
|
||||
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Cuadro de selección: las flechas lo mueven, Mayús+flechas o {cmd}+flechas cambian su tamaño, Intro selecciona, Esc cancela"
|
||||
@@ -1366,6 +1378,7 @@ status = "draft — needs native review"
|
||||
"This condition is edited as an expression" = "Esta condición se edita como expresión"
|
||||
"This file" = "Este archivo"
|
||||
"This file has unsaved changes" = "Este archivo tiene cambios sin guardar"
|
||||
"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Este archivo usa fuentes que no están instaladas en este equipo. Su texto se dibuja con una fuente de reserva hasta que las instales."
|
||||
"This file is on {server}, open only to you. Share it to let others in with a new code." = "Este archivo está en {server}, abierto solo para ti. Compártelo para dejar entrar a otros con un código nuevo."
|
||||
"This frame is new on the branch." = "Este marco es nuevo en la rama."
|
||||
"This frame isn't on the branch." = "Este marco no está en la rama."
|
||||
@@ -1528,13 +1541,25 @@ status = "draft — needs native review"
|
||||
"can edit the document" = "puede editar el documento"
|
||||
"conditional prototype actions nested too deeply were skipped" = "se omitieron las acciones condicionales de prototipo anidadas a demasiada profundidad"
|
||||
"conditional prototype actions with conditions that can't be evaluated were skipped" = "se omitieron las acciones condicionales de prototipo con condiciones que no se pueden evaluar"
|
||||
"connector label background was skipped: style data is missing" = "se omitió el fondo de la etiqueta del conector: faltan los datos de estilo"
|
||||
"connector label was skipped: background bounds are invalid" = "se omitió la etiqueta del conector: los límites del fondo no son válidos"
|
||||
"connector label was skipped: background transform is invalid" = "se omitió la etiqueta del conector: la transformación del fondo no es válida"
|
||||
"connector label was skipped: composed text transform is invalid" = "se omitió la etiqueta del conector: la transformación compuesta del texto no es válida"
|
||||
"connector label was skipped: derived background bounds are missing" = "se omitió la etiqueta del conector: faltan los límites derivados del fondo"
|
||||
"connector label was skipped: derived text bounds are missing" = "se omitió la etiqueta del conector: faltan los límites derivados del texto"
|
||||
"connector label was skipped: text bounds are invalid" = "se omitió la etiqueta del conector: los límites del texto no son válidos"
|
||||
"connector label was skipped: text style data is missing" = "se omitió la etiqueta del conector: faltan los datos del estilo de texto"
|
||||
"connector label was skipped: text transform is invalid" = "se omitió la etiqueta del conector: la transformación del texto no es válida"
|
||||
"connector labels were skipped" = "se omitieron las etiquetas de los conectores"
|
||||
"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "las capas de conector se importan como instantáneas estáticas de marco o vector, con sus etiquetas si las hay; no se conservan los vínculos dinámicos de los extremos"
|
||||
"connector snapshot vector data is missing; connector was left empty" = "faltan los datos vectoriales de la instantánea del conector; el conector quedó vacío"
|
||||
"connectors whose ends can't be placed are empty" = "los conectores cuyos extremos no se pueden colocar están vacíos"
|
||||
"crop marks" = "marcas de corte"
|
||||
"damaged vector geometry was skipped" = "se omitió la geometría vectorial dañada"
|
||||
"damaged vector networks import as outlines" = "las redes vectoriales dañadas se importan como contornos"
|
||||
"development: {path}" = "desarrollo: {path}"
|
||||
"document children other than pages were skipped" = "se omitieron los elementos del documento que no son páginas"
|
||||
"duplicate grid track sizing entries use equal tracks" = "las entradas duplicadas de tamaño de pista de cuadrícula usan pistas iguales"
|
||||
"e.g. count + 1" = "p. ej. count + 1"
|
||||
"e.g. count >= 3 and not done" = "p. ej. count >= 3 and not done"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "los conectores angulares y curvos sin trazado guardado se importan como líneas rectas"
|
||||
@@ -1543,14 +1568,20 @@ status = "draft — needs native review"
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "las áreas rellenas de un vector con relleno propio usan el relleno de la capa"
|
||||
"from {server}" = "de {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "se omitieron los desencadenantes de mando sin exactamente un botón de mando conocido"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "la lista de pistas de cuadrícula supera las 1000 entradas; las pistas usan el tamaño igual predeterminado"
|
||||
"grid track sizing is unsupported or malformed; affected tracks use equal sizing" = "el tamaño de las pistas de cuadrícula no es compatible o está mal formado; las pistas afectadas usan tamaños iguales"
|
||||
"grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "la lista de tamaños de pista de cuadrícula supera las 1000 entradas; las pistas usan el tamaño igual predeterminado"
|
||||
"hidden" = "oculta"
|
||||
"iOS" = "iOS"
|
||||
"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "los filtros de imagen son ambiguos porque están los dos contenedores de filtros; se omitió la conversión de filtros"
|
||||
"image filters use FigCraft's approximate renderer" = "los filtros de imagen usan el renderizado aproximado de FigCraft"
|
||||
"instance swap properties whose default component isn't in the file were skipped" = "se omitieron las propiedades de intercambio de instancia cuyo componente predeterminado no está en el archivo"
|
||||
"instances inside their own main component were imported as frames" = "las instancias dentro de su propio componente principal se importaron como marcos"
|
||||
"instances of components that aren't in the file show no layers" = "las instancias de componentes que no están en el archivo no muestran capas"
|
||||
"instances of components that aren't in the file were imported as frames" = "las instancias de componentes que no están en el archivo se importaron como marcos"
|
||||
"instances that would nest a component inside itself were imported as frames" = "las instancias que anidarían un componente dentro de sí mismo se importaron como marcos"
|
||||
"instances without a main component were imported as empty frames" = "las instancias sin componente principal se importaron como marcos vacíos"
|
||||
"invalid internal .fig text path data was ignored" = "se ignoraron datos internos no válidos de texto en trazado del .fig"
|
||||
"invalid or oversized vector networks import as outlines" = "las redes vectoriales no válidas o demasiado grandes se importan como contornos"
|
||||
"invalid or oversized vector path data was skipped" = "se omitieron los datos de trazados vectoriales no válidos o demasiado grandes"
|
||||
"just now" = "justo ahora"
|
||||
@@ -1558,12 +1589,19 @@ status = "draft — needs native review"
|
||||
"key up triggers import as key presses" = "los desencadenantes al soltar una tecla se importan como pulsaciones de tecla"
|
||||
"layers without a position were placed at their parent's origin" = "las capas sin posición se colocaron en el origen de su elemento padre"
|
||||
"layers without a type were skipped" = "se omitieron las capas sin tipo"
|
||||
"legacy image filter container is unsupported; filter conversion was skipped" = "el contenedor antiguo de filtros de imagen no es compatible; se omitió la conversión de filtros"
|
||||
"locked" = "bloqueada"
|
||||
"malformed image filters container was skipped" = "se omitió el contenedor de filtros de imagen mal formado"
|
||||
"malformed image paintFilter container was skipped" = "se omitió el contenedor paintFilter de imagen mal formado"
|
||||
"malformed slot content assignment was skipped" = "se omitió una asignación de contenido de ranura mal formada"
|
||||
"no bleed" = "sin sangrado"
|
||||
"nobody else here yet" = "aún no hay nadie más"
|
||||
"non-default slot settings are not represented and were skipped" = "los ajustes de ranura no predeterminados no se representan y se omitieron"
|
||||
"now" = "ahora"
|
||||
"only in a text style or variable" = "solo en un estilo de texto o una variable"
|
||||
"or" = "o"
|
||||
"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "se conservaron las uniones de trazo de los puntos; los trazos discontinuos o de ancho variable se dibujan con la unión de la capa"
|
||||
"preferred slot component keys cannot be resolved to file nodes and were skipped" = "las claves de componentes preferidos de las ranuras no corresponden a capas del archivo y se omitieron"
|
||||
"prototype actions of unknown types were skipped" = "se omitieron las acciones de prototipo de tipos desconocidos"
|
||||
"prototype actions without a destination in the file were skipped" = "se omitieron las acciones de prototipo sin destino en el archivo"
|
||||
"prototype expressions on variables that aren't in the variables JSON were skipped" = "se omitieron las expresiones de prototipo con variables que no están en el JSON de variables"
|
||||
@@ -1581,8 +1619,14 @@ status = "draft — needs native review"
|
||||
"set-variable actions with values that don't fit the variable were skipped" = "se omitieron las acciones «Establecer variable» con valores que no encajan con la variable"
|
||||
"set-variable actions without a value were skipped" = "se omitieron las acciones «Establecer variable» sin valor"
|
||||
"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "se omitieron las acciones «Establecer modo de variable» sobre colecciones o modos que no están en el JSON de variables"
|
||||
"slot content assignment target is missing or is not slot content" = "el destino de la asignación de contenido de ranura falta o no es contenido de ranura"
|
||||
"slot content assignment uses the empty GUID sentinel and was skipped" = "la asignación de contenido de ranura usa el GUID vacío como centinela y se omitió"
|
||||
"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "algunos puntos vectoriales hacen referencia a ajustes de punto que faltan en el archivo; usan las uniones de la capa y esquinas simples"
|
||||
"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "algunos segmentos vectoriales hacen referencia a ajustes de estilo que faltan en el archivo; usan el trazo de la capa"
|
||||
"stale or invalid grid track sizing entries were ignored" = "se ignoraron las entradas de tamaño de pista de cuadrícula obsoletas o no válidas"
|
||||
"stars and polygons without point counts or geometry use the default point count" = "las estrellas y los polígonos sin número de puntos ni geometría usan el número de puntos predeterminado"
|
||||
"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "los ajustes de estilo de algunos segmentos vectoriales no son compatibles; esos segmentos usan el trazo de la capa"
|
||||
"text path underline or strikethrough decoration is not rendered" = "el subrayado o tachado del texto en trazado no se dibuja"
|
||||
"text style overrides refer to missing table entries" = "las anulaciones de estilo de texto hacen referencia a entradas de tabla que faltan"
|
||||
"the file has no pages" = "el archivo no tiene páginas"
|
||||
"the plugin" = "el plugin"
|
||||
@@ -1662,6 +1706,7 @@ status = "draft — needs native review"
|
||||
"{n} replies" = { one = "{n} respuesta", other = "{n} respuestas" }
|
||||
"{n} rows" = { one = "{n} fila", other = "{n} filas" }
|
||||
"{n} styles" = { one = "{n} estilo", other = "{n} estilos" }
|
||||
"{n} text layers" = { one = "{n} capa de texto", other = "{n} capas de texto" }
|
||||
"{n} variables" = { one = "{n} variable", other = "{n} variables" }
|
||||
"{n} weeks ago" = { one = "hace {n} semana", other = "hace {n} semanas" }
|
||||
"{n} years ago" = { one = "hace {n} año", other = "hace {n} años" }
|
||||
@@ -1713,6 +1758,7 @@ status = "draft — needs native review"
|
||||
"arrange.packH" = "Agrupar en horizontal"
|
||||
"arrange.packV" = "Agrupar en vertical"
|
||||
"arrange.roundToPixels" = "Redondear a píxeles"
|
||||
"arrange.spacing" = "Espaciado"
|
||||
"arrange.tidy" = "Ordenar"
|
||||
"bool.exclude" = "Excluir selección"
|
||||
"bool.flatten" = "Acoplar booleana"
|
||||
@@ -2023,6 +2069,10 @@ status = "draft — needs native review"
|
||||
"select.sameStroke" = "Seleccionar mismo trazo"
|
||||
"select.sameText" = "Seleccionar mismas propiedades de texto"
|
||||
"select.set" = "Seleccionar"
|
||||
"selection.applyColorStyle" = "Aplicar estilo de color a la selección"
|
||||
"selection.colors" = "Colores de la selección"
|
||||
"selection.selectColor" = "Seleccionar capas con este color"
|
||||
"selection.setPaint" = "Cambiar color de la selección"
|
||||
"shape.arrow" = "Flecha"
|
||||
"shape.ellipse" = "Elipse"
|
||||
"shape.line" = "Línea"
|
||||
@@ -2082,6 +2132,7 @@ status = "draft — needs native review"
|
||||
"text.listOutdent" = "Reducir sangría de la lista"
|
||||
"text.listStyle" = "Estilo de lista"
|
||||
"text.maxLines" = "Máximo de líneas"
|
||||
"text.missingFonts" = "Fuentes que faltan"
|
||||
"text.numberList" = "Lista numerada"
|
||||
"text.onPath" = "Texto en trazado"
|
||||
"text.paragraphIndent" = "Sangría de párrafo"
|
||||
|
||||
@@ -328,6 +328,7 @@ status = "draft — needs native review"
|
||||
"Current version" = "Version actuelle"
|
||||
"Cursor chat" = "Chat au curseur"
|
||||
"Custom" = "Personnalisé"
|
||||
"Custom size" = "Taille personnalisée"
|
||||
"Cut" = "Couper"
|
||||
"Dark" = "Sombre"
|
||||
"Darken" = "Assombrir"
|
||||
@@ -619,6 +620,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." = "Design d’interfaces et prototypage. Un outil de design libre et gratuit, développé en salle blanche (clean-room) et écrit de zéro en Rust, membre des Crafting Apps d’ArtCraft."
|
||||
"Intersect" = "Intersection"
|
||||
"Intersects" = "Intersections"
|
||||
"Invalid color" = "Couleur non valide"
|
||||
"Invite link" = "Lien d’invitation"
|
||||
"Invite link copied" = "Lien d’invitation copié"
|
||||
"It asks permission to:" = "Il demande l’autorisation de :"
|
||||
@@ -764,6 +766,7 @@ status = "draft — needs native review"
|
||||
"Navigate to" = "Naviguer vers"
|
||||
"Nested instance" = "Instance imbriquée"
|
||||
"New design file" = "Nouveau fichier de design"
|
||||
"Next frame" = "Cadre suivant"
|
||||
"Next or previous control in the focused region" = "Contrôle suivant ou précédent dans la région active"
|
||||
"Next ready for dev" = "Prêt pour le dev suivant"
|
||||
"Next size option" = "Option de taille suivante"
|
||||
@@ -773,6 +776,7 @@ status = "draft — needs native review"
|
||||
"No branches. A branch is a copy of the file to try changes in, then review and merge." = "Aucune branche. Une branche est une copie du fichier pour essayer des modifications, puis les relire et les fusionner."
|
||||
"No category" = "Sans catégorie"
|
||||
"No changes" = "Aucune modification"
|
||||
"No color styles" = "Aucun style de couleur"
|
||||
"No colour variables fit here." = "Aucune variable de couleur ne convient ici."
|
||||
"No colour variables yet. Create them in Local variables." = "Aucune variable de couleur pour l’instant. Créez-en dans Variables locales."
|
||||
"No comments yet" = "Aucun commentaire pour l’instant"
|
||||
@@ -780,6 +784,7 @@ status = "draft — needs native review"
|
||||
"No components yet. Select layers and press {keys}." = "Aucun composant pour l’instant. Sélectionnez des calques et appuyez sur {keys}."
|
||||
"No conflicts." = "Aucun conflit."
|
||||
"No contributor data was built into this copy." = "Cette copie ne contient aucune donnée de contributeurs."
|
||||
"No device" = "Aucun appareil"
|
||||
"No fonts match" = "Aucune police ne correspond"
|
||||
"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Pas de code d’accès ici (le serveur est antérieur aux codes d’accès, ou cette adresse ne tient pas dans un code) : partagez plutôt le lien."
|
||||
"No layer changes to show." = "Aucune modification de calque à afficher."
|
||||
@@ -954,6 +959,7 @@ status = "draft — needs native review"
|
||||
"Preview" = "Aperçu"
|
||||
"Preview this flow" = "Prévisualiser ce flux"
|
||||
"Preview this frame" = "Prévisualiser ce cadre"
|
||||
"Previous frame" = "Cadre précédent"
|
||||
"Previous ready for dev" = "Prêt pour le dev précédent"
|
||||
"Progressive" = "Progressif"
|
||||
"Properties" = "Propriétés"
|
||||
@@ -1047,6 +1053,7 @@ status = "draft — needs native review"
|
||||
"Resize by the nudge amount" = "Redimensionner du pas de décalage"
|
||||
"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Redimensionne les cadres à l’écran de l’appareil (ou à la fenêtre sans appareil) : leurs contraintes et leur mise en page auto s’adaptent"
|
||||
"Resize to fit" = "Redimensionner au contenu"
|
||||
"Resizing" = "Redimensionnement"
|
||||
"Resolve" = "Résoudre"
|
||||
"Resolve change" = "Résoudre la modification"
|
||||
"Resolved" = "Résolu"
|
||||
@@ -1118,12 +1125,14 @@ status = "draft — needs native review"
|
||||
"Select all with same" = "Sélectionner tout ce qui a le même"
|
||||
"Select layer" = "Sélectionner le calque"
|
||||
"Select layers to apply the style to" = "Sélectionnez les calques auxquels appliquer le style"
|
||||
"Select layers with this color" = "Sélectionner les calques de cette couleur"
|
||||
"Select overlapping shapes" = "Sélectionnez des formes qui se chevauchent"
|
||||
"Select points to edit their position, radius and mirroring" = "Sélectionnez des points pour modifier leur position, leur rayon et leur symétrie"
|
||||
"Select slot" = "Sélectionner l’emplacement"
|
||||
"Selection" = "Sélection"
|
||||
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Zone de sélection, {w} sur {h} à {x}, {y} : les flèches la déplacent, Maj+flèches ou {cmd}+flèches la redimensionnent, Entrée sélectionne, Échap annule"
|
||||
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Zone de sélection : les flèches la déplacent, Maj+flèches ou {cmd}+flèches la redimensionnent, Entrée sélectionne, Échap annule"
|
||||
"Selection colors" = "Couleurs de la sélection"
|
||||
"Send backward" = "Reculer"
|
||||
"Send report" = "Envoyer le signalement"
|
||||
"Send to back" = "Mettre à l’arrière-plan"
|
||||
@@ -1440,13 +1449,25 @@ status = "draft — needs native review"
|
||||
"can edit the document" = "peut modifier le document"
|
||||
"conditional prototype actions nested too deeply were skipped" = "les actions de prototype conditionnelles trop profondément imbriquées ont été ignorées"
|
||||
"conditional prototype actions with conditions that can't be evaluated were skipped" = "les actions de prototype conditionnelles dont les conditions ne peuvent pas être évaluées ont été ignorées"
|
||||
"connector label background was skipped: style data is missing" = "l’arrière-plan du libellé du connecteur a été ignoré : les données de style sont manquantes"
|
||||
"connector label was skipped: background bounds are invalid" = "le libellé du connecteur a été ignoré : les limites de l’arrière-plan ne sont pas valides"
|
||||
"connector label was skipped: background transform is invalid" = "le libellé du connecteur a été ignoré : la transformation de l’arrière-plan n’est pas valide"
|
||||
"connector label was skipped: composed text transform is invalid" = "le libellé du connecteur a été ignoré : la transformation composée du texte n’est pas valide"
|
||||
"connector label was skipped: derived background bounds are missing" = "le libellé du connecteur a été ignoré : les limites dérivées de l’arrière-plan sont manquantes"
|
||||
"connector label was skipped: derived text bounds are missing" = "le libellé du connecteur a été ignoré : les limites dérivées du texte sont manquantes"
|
||||
"connector label was skipped: text bounds are invalid" = "le libellé du connecteur a été ignoré : les limites du texte ne sont pas valides"
|
||||
"connector label was skipped: text style data is missing" = "le libellé du connecteur a été ignoré : les données de style du texte sont manquantes"
|
||||
"connector label was skipped: text transform is invalid" = "le libellé du connecteur a été ignoré : la transformation du texte n’est pas valide"
|
||||
"connector labels were skipped" = "les libellés des connecteurs ont été ignorés"
|
||||
"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "les calques de connecteur sont importés comme des instantanés statiques de cadre ou de vecteur, avec leurs libellés s’il y en a ; les liaisons dynamiques des extrémités ne sont pas conservées"
|
||||
"connector snapshot vector data is missing; connector was left empty" = "les données vectorielles de l’instantané du connecteur sont manquantes ; le connecteur reste vide"
|
||||
"connectors whose ends can't be placed are empty" = "les connecteurs dont les extrémités ne peuvent pas être placées sont vides"
|
||||
"crop marks" = "traits de coupe"
|
||||
"damaged vector geometry was skipped" = "la géométrie vectorielle endommagée a été ignorée"
|
||||
"damaged vector networks import as outlines" = "les réseaux vectoriels endommagés sont importés comme tracés"
|
||||
"development: {path}" = "développement : {path}"
|
||||
"document children other than pages were skipped" = "les enfants du document autres que des pages ont été ignorés"
|
||||
"duplicate grid track sizing entries use equal tracks" = "les entrées de dimensionnement de piste de grille en double utilisent des pistes égales"
|
||||
"e.g. count + 1" = "p. ex. count + 1"
|
||||
"e.g. count >= 3 and not done" = "p. ex. count >= 3 and not done"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "les connecteurs coudés et courbes sans tracé enregistré sont importés comme des lignes droites"
|
||||
@@ -1455,26 +1476,38 @@ status = "draft — needs native review"
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "les zones remplies d’un vecteur avec leur propre remplissage utilisent le remplissage du calque"
|
||||
"from {server}" = "depuis {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "les déclencheurs de manette sans exactement un bouton de manette connu ont été ignorés"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "la liste des pistes de grille dépasse 1 000 entrées ; les pistes utilisent le dimensionnement égal par défaut"
|
||||
"grid track sizing is unsupported or malformed; affected tracks use equal sizing" = "le dimensionnement des pistes de grille n’est pas pris en charge ou est mal formé ; les pistes concernées ont un dimensionnement égal"
|
||||
"grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "la liste de dimensionnement des pistes de grille dépasse 1 000 entrées ; les pistes utilisent le dimensionnement égal par défaut"
|
||||
"hidden" = "masqué"
|
||||
"iOS" = "iOS"
|
||||
"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "les filtres d’image sont ambigus, car les deux conteneurs de filtres sont présents ; la conversion des filtres a été ignorée"
|
||||
"image filters use FigCraft's approximate renderer" = "les filtres d’image utilisent le rendu approximatif de FigCraft"
|
||||
"instance swap properties whose default component isn't in the file were skipped" = "les propriétés de permutation d’instance dont le composant par défaut n’est pas dans le fichier ont été ignorées"
|
||||
"instances inside their own main component were imported as frames" = "les instances placées dans leur propre composant principal ont été importées comme cadres"
|
||||
"instances of components that aren't in the file show no layers" = "les instances de composants absents du fichier n’affichent aucun calque"
|
||||
"instances of components that aren't in the file were imported as frames" = "les instances de composants absents du fichier ont été importées comme cadres"
|
||||
"instances that would nest a component inside itself were imported as frames" = "les instances qui imbriqueraient un composant dans lui-même ont été importées comme cadres"
|
||||
"instances without a main component were imported as empty frames" = "les instances sans composant principal ont été importées comme cadres vides"
|
||||
"invalid internal .fig text path data was ignored" = "les données internes non valides de texte sur tracé du .fig ont été ignorées"
|
||||
"invalid or oversized vector networks import as outlines" = "les réseaux vectoriels non valides ou trop grands sont importés comme tracés"
|
||||
"invalid or oversized vector path data was skipped" = "les données de tracé vectoriel non valides ou trop grandes ont été ignorées"
|
||||
"key triggers without a supported key were skipped" = "les déclencheurs de touche sans touche prise en charge ont été ignorés"
|
||||
"key up triggers import as key presses" = "les déclencheurs au relâchement d’une touche sont importés comme des appuis sur la touche"
|
||||
"layers without a position were placed at their parent's origin" = "les calques sans position ont été placés à l’origine de leur parent"
|
||||
"layers without a type were skipped" = "les calques sans type ont été ignorés"
|
||||
"legacy image filter container is unsupported; filter conversion was skipped" = "l’ancien conteneur de filtres d’image n’est pas pris en charge ; la conversion des filtres a été ignorée"
|
||||
"locked" = "verrouillé"
|
||||
"malformed image filters container was skipped" = "le conteneur de filtres d’image mal formé a été ignoré"
|
||||
"malformed image paintFilter container was skipped" = "le conteneur paintFilter d’image mal formé a été ignoré"
|
||||
"malformed slot content assignment was skipped" = "une affectation de contenu d’emplacement mal formée a été ignorée"
|
||||
"no bleed" = "sans fond perdu"
|
||||
"nobody else here yet" = "personne d’autre pour l’instant"
|
||||
"non-default slot settings are not represented and were skipped" = "les réglages d’emplacement autres que ceux par défaut ne sont pas représentés et ont été ignorés"
|
||||
"now" = "maintenant"
|
||||
"or" = "ou"
|
||||
"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "les jointures des points ont été conservées ; les contours en pointillés ou à largeur variable sont dessinés avec la jointure du calque"
|
||||
"preferred slot component keys cannot be resolved to file nodes and were skipped" = "les clés de composants préférés des emplacements ne correspondent à aucun calque du fichier et ont été ignorées"
|
||||
"prototype actions of unknown types were skipped" = "les actions de prototype de types inconnus ont été ignorées"
|
||||
"prototype actions without a destination in the file were skipped" = "les actions de prototype sans destination dans le fichier ont été ignorées"
|
||||
"prototype expressions on variables that aren't in the variables JSON were skipped" = "les expressions de prototype portant sur des variables absentes du JSON des variables ont été ignorées"
|
||||
@@ -1492,8 +1525,14 @@ status = "draft — needs native review"
|
||||
"set-variable actions with values that don't fit the variable were skipped" = "les actions de définition de variable dont les valeurs ne correspondent pas à la variable ont été ignorées"
|
||||
"set-variable actions without a value were skipped" = "les actions de définition de variable sans valeur ont été ignorées"
|
||||
"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "les actions de définition du mode de variable portant sur des collections ou des modes absents du JSON des variables ont été ignorées"
|
||||
"slot content assignment target is missing or is not slot content" = "la cible de l’affectation de contenu d’emplacement est absente ou n’est pas un contenu d’emplacement"
|
||||
"slot content assignment uses the empty GUID sentinel and was skipped" = "l’affectation de contenu d’emplacement utilise le GUID vide comme sentinelle et a été ignorée"
|
||||
"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "certains points vectoriels renvoient à des réglages de point absents du fichier ; ils utilisent les jointures du calque et des angles simples"
|
||||
"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "certains segments vectoriels renvoient à des réglages de style absents du fichier ; ils utilisent le contour du calque"
|
||||
"stale or invalid grid track sizing entries were ignored" = "les entrées de dimensionnement de piste de grille obsolètes ou non valides ont été ignorées"
|
||||
"stars and polygons without point counts or geometry use the default point count" = "les étoiles et polygones sans nombre de pointes ni géométrie utilisent le nombre de pointes par défaut"
|
||||
"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "les réglages de style de certains segments vectoriels ne sont pas pris en charge ; ces segments utilisent le contour du calque"
|
||||
"text path underline or strikethrough decoration is not rendered" = "le soulignement ou le barré du texte sur tracé n’est pas affiché"
|
||||
"text style overrides refer to missing table entries" = "les remplacements de style de texte renvoient à des entrées de table manquantes"
|
||||
"the file has no pages" = "le fichier n’a aucune page"
|
||||
"the plugin" = "le plugin"
|
||||
@@ -1622,6 +1661,7 @@ status = "draft — needs native review"
|
||||
"arrange.packH" = "Tasser horizontalement"
|
||||
"arrange.packV" = "Tasser verticalement"
|
||||
"arrange.roundToPixels" = "Arrondir aux pixels"
|
||||
"arrange.spacing" = "Espacement"
|
||||
"arrange.tidy" = "Ranger"
|
||||
"bool.exclude" = "Exclure la sélection"
|
||||
"bool.flatten" = "Aplatir le booléen"
|
||||
@@ -1925,6 +1965,10 @@ status = "draft — needs native review"
|
||||
"select.sameStroke" = "Sélectionner le même contour"
|
||||
"select.sameText" = "Sélectionner les mêmes propriétés de texte"
|
||||
"select.set" = "Sélectionner"
|
||||
"selection.applyColorStyle" = "Appliquer un style de couleur à la sélection"
|
||||
"selection.colors" = "Couleurs de la sélection"
|
||||
"selection.selectColor" = "Sélectionner les calques de cette couleur"
|
||||
"selection.setPaint" = "Changer la couleur de la sélection"
|
||||
"shape.arrow" = "Flèche"
|
||||
"shape.ellipse" = "Ellipse"
|
||||
"shape.line" = "Ligne"
|
||||
|
||||
@@ -337,6 +337,7 @@ status = "draft — needs native review"
|
||||
"Current version" = "Versi saat ini"
|
||||
"Cursor chat" = "Chat kursor"
|
||||
"Custom" = "Kustom"
|
||||
"Custom size" = "Ukuran kustom"
|
||||
"Cut" = "Potong"
|
||||
"Dark" = "Gelap"
|
||||
"Darken" = "Gelapkan"
|
||||
@@ -642,6 +643,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." = "Desain antarmuka dan prototyping. Alat desain clean-room gratis dan open-source yang ditulis dari nol dengan Rust, bagian dari ArtCraft Crafting Apps."
|
||||
"Intersect" = "Irisan"
|
||||
"Intersects" = "Beririsan"
|
||||
"Invalid color" = "Warna tidak valid"
|
||||
"Invite link" = "Link undangan"
|
||||
"Invite link copied" = "Link undangan disalin"
|
||||
"It asks permission to:" = "Plugin ini meminta izin untuk:"
|
||||
@@ -799,6 +801,7 @@ status = "draft — needs native review"
|
||||
"Nested instance" = "Instance bersarang"
|
||||
"New design file" = "File desain baru"
|
||||
"Newest first" = "Terbaru dulu"
|
||||
"Next frame" = "Frame berikutnya"
|
||||
"Next or previous control in the focused region" = "Kontrol berikutnya atau sebelumnya di wilayah yang difokuskan"
|
||||
"Next ready for dev" = "Siap dev berikutnya"
|
||||
"Next size option" = "Opsi ukuran berikutnya"
|
||||
@@ -808,6 +811,7 @@ status = "draft — needs native review"
|
||||
"No branches. A branch is a copy of the file to try changes in, then review and merge." = "Tidak ada branch. Branch adalah salinan file untuk mencoba perubahan, lalu ditinjau dan di-merge."
|
||||
"No category" = "Tanpa kategori"
|
||||
"No changes" = "Tidak ada perubahan"
|
||||
"No color styles" = "Tidak ada gaya warna"
|
||||
"No colour variables fit here." = "Tidak ada variabel warna yang cocok di sini."
|
||||
"No colour variables yet. Create them in Local variables." = "Belum ada variabel warna. Buat di Variabel lokal."
|
||||
"No comments yet" = "Belum ada komentar"
|
||||
@@ -815,6 +819,7 @@ status = "draft — needs native review"
|
||||
"No components yet. Select layers and press {keys}." = "Belum ada component. Pilih layer lalu tekan {keys}."
|
||||
"No conflicts." = "Tidak ada konflik."
|
||||
"No contributor data was built into this copy." = "Tidak ada data kontributor yang disertakan dalam salinan ini."
|
||||
"No device" = "Tanpa perangkat"
|
||||
"No fonts match" = "Tidak ada font yang cocok"
|
||||
"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Tidak ada kode gabung di sini (versi server lebih lama dari fitur kode gabung, atau alamat ini tidak dapat dimasukkan ke kode): bagikan link-nya saja."
|
||||
"No layer changes to show." = "Tidak ada perubahan layer untuk ditampilkan."
|
||||
@@ -998,6 +1003,7 @@ status = "draft — needs native review"
|
||||
"Preview" = "Pratinjau"
|
||||
"Preview this flow" = "Pratinjau alur ini"
|
||||
"Preview this frame" = "Pratinjau frame ini"
|
||||
"Previous frame" = "Frame sebelumnya"
|
||||
"Previous ready for dev" = "Versi siap dev sebelumnya"
|
||||
"Progressive" = "Progresif"
|
||||
"Properties" = "Properti"
|
||||
@@ -1091,6 +1097,7 @@ status = "draft — needs native review"
|
||||
"Resize by the nudge amount" = "Ubah ukuran sebesar jarak geser"
|
||||
"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Ubah ukuran frame ke layar perangkat (atau jendela tanpa perangkat): batasan dan auto layout-nya menyesuaikan"
|
||||
"Resize to fit" = "Ubah ukuran agar pas"
|
||||
"Resizing" = "Pengubahan ukuran"
|
||||
"Resolve" = "Selesaikan"
|
||||
"Resolve change" = "Selesaikan perubahan"
|
||||
"Resolved" = "Terselesaikan"
|
||||
@@ -1164,12 +1171,14 @@ status = "draft — needs native review"
|
||||
"Select all with same" = "Pilih semua yang sama"
|
||||
"Select layer" = "Pilih layer"
|
||||
"Select layers to apply the style to" = "Pilih layer untuk menerapkan gaya"
|
||||
"Select layers with this color" = "Pilih layer dengan warna ini"
|
||||
"Select overlapping shapes" = "Pilih bentuk yang tumpang tindih"
|
||||
"Select points to edit their position, radius and mirroring" = "Pilih titik untuk mengedit posisi, radius, dan pencerminannya"
|
||||
"Select slot" = "Pilih slot"
|
||||
"Selection" = "Pilihan"
|
||||
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Kotak pilihan, {w} x {h} di {x}, {y}: panah untuk memindahkan, Shift+panah atau {cmd}+panah untuk mengubah ukuran, Enter untuk memilih, Escape untuk membatalkan"
|
||||
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Kotak pilihan: panah untuk memindahkan, Shift+panah atau {cmd}+panah untuk mengubah ukuran, Enter untuk memilih, Escape untuk membatalkan"
|
||||
"Selection colors" = "Warna pilihan"
|
||||
"Send backward" = "Bawa mundur"
|
||||
"Send report" = "Kirim laporan"
|
||||
"Send to back" = "Bawa ke paling belakang"
|
||||
@@ -1503,13 +1512,25 @@ status = "draft — needs native review"
|
||||
"can edit the document" = "dapat mengedit dokumen"
|
||||
"conditional prototype actions nested too deeply were skipped" = "tindakan prototype kondisional yang bersarang terlalu dalam dilewati"
|
||||
"conditional prototype actions with conditions that can't be evaluated were skipped" = "tindakan prototype kondisional dengan kondisi yang tidak dapat dievaluasi dilewati"
|
||||
"connector label background was skipped: style data is missing" = "latar belakang label konektor dilewati: data gaya tidak ada"
|
||||
"connector label was skipped: background bounds are invalid" = "label konektor dilewati: batas latar belakang tidak valid"
|
||||
"connector label was skipped: background transform is invalid" = "label konektor dilewati: transformasi latar belakang tidak valid"
|
||||
"connector label was skipped: composed text transform is invalid" = "label konektor dilewati: transformasi gabungan teks tidak valid"
|
||||
"connector label was skipped: derived background bounds are missing" = "label konektor dilewati: batas turunan latar belakang tidak ada"
|
||||
"connector label was skipped: derived text bounds are missing" = "label konektor dilewati: batas turunan teks tidak ada"
|
||||
"connector label was skipped: text bounds are invalid" = "label konektor dilewati: batas teks tidak valid"
|
||||
"connector label was skipped: text style data is missing" = "label konektor dilewati: data gaya teks tidak ada"
|
||||
"connector label was skipped: text transform is invalid" = "label konektor dilewati: transformasi teks tidak valid"
|
||||
"connector labels were skipped" = "label konektor dilewati"
|
||||
"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "layer konektor diimpor sebagai snapshot frame/vector statis beserta labelnya jika ada; binding dinamis titik ujung tidak dipertahankan"
|
||||
"connector snapshot vector data is missing; connector was left empty" = "data vector snapshot konektor tidak ada; konektor dibiarkan kosong"
|
||||
"connectors whose ends can't be placed are empty" = "konektor yang ujungnya tidak dapat ditempatkan kosong"
|
||||
"crop marks" = "tanda potong"
|
||||
"damaged vector geometry was skipped" = "geometri vector yang rusak dilewati"
|
||||
"damaged vector networks import as outlines" = "jaringan vector yang rusak diimpor sebagai outline"
|
||||
"development: {path}" = "pengembangan: {path}"
|
||||
"document children other than pages were skipped" = "anak dokumen selain halaman dilewati"
|
||||
"duplicate grid track sizing entries use equal tracks" = "entri ukuran track grid yang duplikat memakai track yang sama besar"
|
||||
"e.g. count + 1" = "mis. count + 1"
|
||||
"e.g. count >= 3 and not done" = "mis. count >= 3 and not done"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "konektor siku dan lengkung tanpa jalur tersimpan diimpor sebagai garis lurus"
|
||||
@@ -1518,14 +1539,20 @@ status = "draft — needs native review"
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "area terisi pada vector dengan isian sendiri memakai isian layer"
|
||||
"from {server}" = "dari {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "pemicu gamepad yang tidak memiliki tepat satu tombol kontroler yang dikenal dilewati"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "daftar track grid melebihi 1000 entri; track memakai ukuran sama besar bawaan"
|
||||
"grid track sizing is unsupported or malformed; affected tracks use equal sizing" = "ukuran track grid tidak didukung atau rusak; track yang terdampak memakai ukuran sama besar"
|
||||
"grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "daftar ukuran track grid melebihi 1000 entri; track memakai ukuran sama besar bawaan"
|
||||
"hidden" = "tersembunyi"
|
||||
"iOS" = "iOS"
|
||||
"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "filter gambar ambigu karena kedua kontainer filter ada; konversi filter dilewati"
|
||||
"image filters use FigCraft's approximate renderer" = "filter gambar memakai perenderan perkiraan FigCraft"
|
||||
"instance swap properties whose default component isn't in the file were skipped" = "properti tukar instance yang component bawaannya tidak ada di file dilewati"
|
||||
"instances inside their own main component were imported as frames" = "instance di dalam main component-nya sendiri diimpor sebagai frame"
|
||||
"instances of components that aren't in the file show no layers" = "instance dari component yang tidak ada di file tidak menampilkan layer"
|
||||
"instances of components that aren't in the file were imported as frames" = "instance dari component yang tidak ada di file diimpor sebagai frame"
|
||||
"instances that would nest a component inside itself were imported as frames" = "instance yang akan menyarangkan component di dalam dirinya sendiri diimpor sebagai frame"
|
||||
"instances without a main component were imported as empty frames" = "instance tanpa main component diimpor sebagai frame kosong"
|
||||
"invalid internal .fig text path data was ignored" = "data teks di path internal .fig yang tidak valid diabaikan"
|
||||
"invalid or oversized vector networks import as outlines" = "jaringan vector yang tidak valid atau terlalu besar diimpor sebagai outline"
|
||||
"invalid or oversized vector path data was skipped" = "data jalur vector yang tidak valid atau terlalu besar dilewati"
|
||||
"just now" = "baru saja"
|
||||
@@ -1533,12 +1560,18 @@ status = "draft — needs native review"
|
||||
"key up triggers import as key presses" = "pemicu saat tombol dilepas diimpor sebagai penekanan tombol"
|
||||
"layers without a position were placed at their parent's origin" = "layer tanpa posisi ditempatkan di titik asal induknya"
|
||||
"layers without a type were skipped" = "layer tanpa tipe dilewati"
|
||||
"legacy image filter container is unsupported; filter conversion was skipped" = "kontainer filter gambar versi lama tidak didukung; konversi filter dilewati"
|
||||
"locked" = "terkunci"
|
||||
"malformed image filters container was skipped" = "kontainer filter gambar yang rusak dilewati"
|
||||
"malformed image paintFilter container was skipped" = "kontainer paintFilter gambar yang rusak dilewati"
|
||||
"malformed slot content assignment was skipped" = "penetapan konten slot yang rusak dilewati"
|
||||
"no bleed" = "tanpa bleed"
|
||||
"nobody else here yet" = "belum ada orang lain di sini"
|
||||
"non-default slot settings are not represented and were skipped" = "pengaturan slot non-default tidak dapat direpresentasikan dan dilewati"
|
||||
"now" = "sekarang"
|
||||
"or" = "atau"
|
||||
"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "sambungan stroke per titik dipertahankan; stroke putus-putus atau berlebar variabel digambar dengan sambungan layer"
|
||||
"preferred slot component keys cannot be resolved to file nodes and were skipped" = "kunci component pilihan slot tidak dapat dicocokkan dengan layer di file dan dilewati"
|
||||
"prototype actions of unknown types were skipped" = "tindakan prototype dengan tipe yang tidak dikenal dilewati"
|
||||
"prototype actions without a destination in the file were skipped" = "tindakan prototype tanpa tujuan di file dilewati"
|
||||
"prototype expressions on variables that aren't in the variables JSON were skipped" = "ekspresi prototype pada variabel yang tidak ada di JSON variabel dilewati"
|
||||
@@ -1556,8 +1589,14 @@ status = "draft — needs native review"
|
||||
"set-variable actions with values that don't fit the variable were skipped" = "tindakan atur variabel dengan nilai yang tidak cocok dengan variabelnya dilewati"
|
||||
"set-variable actions without a value were skipped" = "tindakan atur variabel tanpa nilai dilewati"
|
||||
"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "tindakan atur mode variabel pada koleksi atau mode yang tidak ada di JSON variabel dilewati"
|
||||
"slot content assignment target is missing or is not slot content" = "target penetapan konten slot tidak ada atau bukan konten slot"
|
||||
"slot content assignment uses the empty GUID sentinel and was skipped" = "penetapan konten slot memakai GUID kosong sebagai penanda dan dilewati"
|
||||
"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "beberapa titik vector merujuk ke pengaturan titik yang tidak ada di file; titik tersebut memakai sambungan layer dan sudut biasa"
|
||||
"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "beberapa segmen vector merujuk ke pengaturan gaya yang tidak ada di file; segmen tersebut memakai stroke layer"
|
||||
"stale or invalid grid track sizing entries were ignored" = "entri ukuran track grid yang usang atau tidak valid diabaikan"
|
||||
"stars and polygons without point counts or geometry use the default point count" = "bintang dan poligon tanpa jumlah titik atau geometri memakai jumlah titik bawaan"
|
||||
"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "pengaturan gaya beberapa segmen vector tidak didukung; segmen tersebut memakai stroke layer"
|
||||
"text path underline or strikethrough decoration is not rendered" = "garis bawah atau coretan pada teks di path tidak digambar"
|
||||
"text style overrides refer to missing table entries" = "penimpaan gaya teks merujuk ke entri tabel yang tidak ada"
|
||||
"the file has no pages" = "file tidak memiliki halaman"
|
||||
"the plugin" = "plugin"
|
||||
@@ -1688,6 +1727,7 @@ status = "draft — needs native review"
|
||||
"arrange.packH" = "Rapatkan horizontal"
|
||||
"arrange.packV" = "Rapatkan vertikal"
|
||||
"arrange.roundToPixels" = "Bulatkan ke piksel"
|
||||
"arrange.spacing" = "Spasi"
|
||||
"arrange.tidy" = "Rapikan"
|
||||
"bool.exclude" = "Kecualikan pilihan"
|
||||
"bool.flatten" = "Satukan boolean"
|
||||
@@ -1995,6 +2035,10 @@ status = "draft — needs native review"
|
||||
"select.sameStroke" = "Pilih stroke yang sama"
|
||||
"select.sameText" = "Pilih properti teks yang sama"
|
||||
"select.set" = "Pilih"
|
||||
"selection.applyColorStyle" = "Terapkan gaya warna ke pilihan"
|
||||
"selection.colors" = "Warna pilihan"
|
||||
"selection.selectColor" = "Pilih layer dengan warna ini"
|
||||
"selection.setPaint" = "Ubah warna pilihan"
|
||||
"shape.arrow" = "Panah"
|
||||
"shape.ellipse" = "Elips"
|
||||
"shape.line" = "Garis"
|
||||
|
||||
@@ -340,6 +340,7 @@ status = "draft — needs native review"
|
||||
"Current version" = "現在のバージョン"
|
||||
"Cursor chat" = "カーソルチャット"
|
||||
"Custom" = "カスタム"
|
||||
"Custom size" = "カスタムサイズ"
|
||||
"Cut" = "カット"
|
||||
"Dark" = "ダーク"
|
||||
"Darken" = "比較(暗)"
|
||||
@@ -647,6 +648,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." = "インターフェースデザインとプロトタイピング。Rust でゼロから書かれた、無料でオープンソースのクリーンルーム設計のデザインツール。ArtCraft Crafting Apps の一つ。"
|
||||
"Intersect" = "交差"
|
||||
"Intersects" = "交差"
|
||||
"Invalid color" = "無効なカラー"
|
||||
"Invite link" = "招待リンク"
|
||||
"Invite link copied" = "招待リンクをコピー済み"
|
||||
"It asks permission to:" = "次の権限を求めている:"
|
||||
@@ -804,6 +806,7 @@ status = "draft — needs native review"
|
||||
"Nested instance" = "ネストされたインスタンス"
|
||||
"New design file" = "新規デザインファイル"
|
||||
"Newest first" = "新しい順"
|
||||
"Next frame" = "次のフレーム"
|
||||
"Next or previous control in the focused region" = "フォーカス中の領域で次または前のコントロール"
|
||||
"Next ready for dev" = "次の開発準備完了"
|
||||
"Next size option" = "次のサイズオプション"
|
||||
@@ -813,6 +816,7 @@ status = "draft — needs native review"
|
||||
"No branches. A branch is a copy of the file to try changes in, then review and merge." = "ブランチはない。ブランチはファイルのコピーで、変更を試してからレビューしてマージできる。"
|
||||
"No category" = "カテゴリなし"
|
||||
"No changes" = "変更なし"
|
||||
"No color styles" = "カラースタイルがありません"
|
||||
"No colour variables fit here." = "ここに使えるカラー変数はない。"
|
||||
"No colour variables yet. Create them in Local variables." = "カラー変数はまだない。ローカル変数で作成。"
|
||||
"No comments yet" = "コメントはまだない"
|
||||
@@ -820,6 +824,7 @@ status = "draft — needs native review"
|
||||
"No components yet. Select layers and press {keys}." = "コンポーネントはまだない。レイヤーを選択して {keys} を押す。"
|
||||
"No conflicts." = "競合なし。"
|
||||
"No contributor data was built into this copy." = "このコピーには貢献者データが含まれていない。"
|
||||
"No device" = "デバイスなし"
|
||||
"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 layer changes to show." = "表示するレイヤーの変更なし。"
|
||||
@@ -1003,6 +1008,7 @@ status = "draft — needs native review"
|
||||
"Preview" = "プレビュー"
|
||||
"Preview this flow" = "このフローをプレビュー"
|
||||
"Preview this frame" = "このフレームをプレビュー"
|
||||
"Previous frame" = "前のフレーム"
|
||||
"Previous ready for dev" = "前の開発準備完了"
|
||||
"Progressive" = "プログレッシブ"
|
||||
"Properties" = "プロパティ"
|
||||
@@ -1097,6 +1103,7 @@ status = "draft — needs native review"
|
||||
"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 to fit" = "サイズを合わせる"
|
||||
"Resizing" = "サイズ変更"
|
||||
"Resolve" = "解決"
|
||||
"Resolve change" = "変更を解決"
|
||||
"Resolved" = "解決済み"
|
||||
@@ -1171,12 +1178,14 @@ status = "draft — needs native review"
|
||||
"Select layer" = "レイヤーを選択"
|
||||
"Select layers and they space themselves as their content changes" = "レイヤーを選択すると、内容の変化に合わせて間隔を自動調整"
|
||||
"Select layers to apply the style to" = "スタイルを適用するレイヤーを選択"
|
||||
"Select layers with this color" = "このカラーのレイヤーを選択"
|
||||
"Select overlapping shapes" = "重なったシェイプを選択"
|
||||
"Select points to edit their position, radius and mirroring" = "ポイントを選択すると位置、半径、対称を編集できる"
|
||||
"Select slot" = "スロットを選択"
|
||||
"Selection" = "選択範囲"
|
||||
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "選択ボックス、{w} × {h}、位置 {x}, {y}:矢印キーで移動、Shift+矢印キーまたは {cmd}+矢印キーでサイズ変更、Enter で選択、Escape でキャンセル"
|
||||
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "選択ボックス:矢印キーで移動、Shift+矢印キーまたは {cmd}+矢印キーでサイズ変更、Enter で選択、Escape でキャンセル"
|
||||
"Selection colors" = "選択範囲のカラー"
|
||||
"Send backward" = "背面へ"
|
||||
"Send report" = "報告を送信"
|
||||
"Send to back" = "最背面へ"
|
||||
@@ -1513,13 +1522,25 @@ status = "draft — needs native review"
|
||||
"can edit the document" = "ドキュメントを編集できる"
|
||||
"conditional prototype actions nested too deeply 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 was skipped: background bounds are invalid" = "コネクタラベルをスキップ:背景の境界が無効"
|
||||
"connector label was skipped: background 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 text bounds are missing" = "コネクタラベルをスキップ:算出したテキストの境界がない"
|
||||
"connector label was skipped: text bounds are invalid" = "コネクタラベルをスキップ:テキストの境界が無効"
|
||||
"connector label was skipped: text style data is missing" = "コネクタラベルをスキップ:テキストスタイルのデータがない"
|
||||
"connector label was skipped: text transform is invalid" = "コネクタラベルをスキップ:テキストの変形が無効"
|
||||
"connector labels were skipped" = "コネクタのラベルをスキップ"
|
||||
"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" = "コネクタのスナップショットのベクターデータがないため、コネクタは空のまま"
|
||||
"connectors whose ends can't be placed are empty" = "端点を配置できないコネクタは空"
|
||||
"crop marks" = "トンボ"
|
||||
"damaged vector geometry was skipped" = "破損したベクタージオメトリをスキップ"
|
||||
"damaged vector networks import as outlines" = "破損したベクターネットワークはアウトラインとしてインポート"
|
||||
"development: {path}" = "開発:{path}"
|
||||
"document children other than pages were skipped" = "ページ以外のドキュメントの子をスキップ"
|
||||
"duplicate grid track sizing entries use equal tracks" = "重複したグリッドトラックのサイズ指定は均等なトラックを使用"
|
||||
"e.g. count + 1" = "例:count + 1"
|
||||
"e.g. count >= 3 and not done" = "例:count >= 3 and not done"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "保存されたパスのない折れ線・曲線コネクタは直線としてインポート"
|
||||
@@ -1528,14 +1549,20 @@ status = "draft — needs native review"
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "独自の塗りを持つベクターの塗りつぶし領域にはレイヤーの塗りを使用"
|
||||
"from {server}" = "{server} から"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "既知のコントローラーボタンがちょうど 1 つではないゲームパッドトリガーをスキップ"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "グリッドトラックのリストが 1000 項目を超えるため、トラックはデフォルトの均等サイズを使用"
|
||||
"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" = "グリッドトラックのサイズ指定リストが 1000 項目を超えるため、トラックはデフォルトの均等サイズを使用"
|
||||
"hidden" = "非表示"
|
||||
"iOS" = "iOS"
|
||||
"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "両方のフィルターコンテナがあり画像フィルターが曖昧なため、フィルターの変換をスキップ"
|
||||
"image filters use FigCraft's approximate renderer" = "画像フィルターは FigCraft の近似レンダリングで描画"
|
||||
"instance swap properties whose default component isn't in the file were skipped" = "デフォルトのコンポーネントがファイルにないインスタンスの入れ替えプロパティをスキップ"
|
||||
"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 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" = "メインコンポーネントのないインスタンスは空のフレームとしてインポート"
|
||||
"invalid internal .fig text path data was ignored" = "無効な .fig 内部のパス上テキストのデータを無視した"
|
||||
"invalid or oversized vector networks import as outlines" = "無効または大きすぎるベクターネットワークはアウトラインとしてインポート"
|
||||
"invalid or oversized vector path data was skipped" = "無効または大きすぎるベクターパスデータをスキップ"
|
||||
"just now" = "たった今"
|
||||
@@ -1543,12 +1570,18 @@ status = "draft — needs native review"
|
||||
"key up triggers import as key presses" = "キーを離すトリガーはキー押下としてインポート"
|
||||
"layers without a position were placed at their parent's origin" = "位置のないレイヤーは親の原点に配置"
|
||||
"layers without a type were skipped" = "タイプのないレイヤーをスキップ"
|
||||
"legacy image filter container is unsupported; filter conversion was skipped" = "旧形式の画像フィルターコンテナは未対応のため、フィルターの変換をスキップ"
|
||||
"locked" = "ロック中"
|
||||
"malformed image filters container was skipped" = "不正な画像フィルターコンテナをスキップ"
|
||||
"malformed image paintFilter container was skipped" = "不正な画像の paintFilter コンテナをスキップ"
|
||||
"malformed slot content assignment was skipped" = "不正なスロットコンテンツの割り当てをスキップ"
|
||||
"no bleed" = "裁ち落としなし"
|
||||
"nobody else here yet" = "まだ他に誰もいない"
|
||||
"non-default slot settings are not represented and were skipped" = "デフォルト以外のスロット設定は再現できないためスキップ"
|
||||
"now" = "たった今"
|
||||
"or" = "または"
|
||||
"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" = "スロットの優先コンポーネントキーをファイル内のレイヤーに解決できないためスキップ"
|
||||
"prototype actions of unknown types 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" = "変数 JSON にない変数を使うプロトタイプの式をスキップ"
|
||||
@@ -1566,8 +1599,14 @@ status = "draft — needs native review"
|
||||
"set-variable actions with values that don't fit the variable 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" = "変数 JSON にないコレクションまたはモードに対する変数モード設定アクションをスキップ"
|
||||
"slot content assignment target is missing or is not slot content" = "スロットコンテンツの割り当て先がないか、スロットコンテンツではない"
|
||||
"slot content assignment uses the empty GUID sentinel and was skipped" = "スロットコンテンツの割り当てが空の GUID を番兵値として使用しているためスキップ"
|
||||
"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" = "一部のベクターセグメントがファイルにないスタイル設定を参照している。それらのセグメントはレイヤーの線を使用"
|
||||
"stale or invalid grid track sizing entries were ignored" = "古いまたは無効なグリッドトラックのサイズ指定を無視した"
|
||||
"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" = "一部のベクターセグメントのスタイル設定は未対応。それらのセグメントはレイヤーの線を使用"
|
||||
"text path underline or strikethrough decoration is not rendered" = "パス上テキストの下線や取り消し線は描画しない"
|
||||
"text style overrides refer to missing table entries" = "テキストスタイルのオーバーライドが存在しないテーブル項目を参照している"
|
||||
"the file has no pages" = "ファイルにページがない"
|
||||
"the plugin" = "このプラグイン"
|
||||
@@ -1698,6 +1737,7 @@ status = "draft — needs native review"
|
||||
"arrange.packH" = "水平方向に詰める"
|
||||
"arrange.packV" = "垂直方向に詰める"
|
||||
"arrange.roundToPixels" = "ピクセルに丸める"
|
||||
"arrange.spacing" = "間隔"
|
||||
"arrange.tidy" = "整列"
|
||||
"bool.exclude" = "選択範囲を除外"
|
||||
"bool.flatten" = "ブーリアンを平坦化"
|
||||
@@ -2007,6 +2047,10 @@ status = "draft — needs native review"
|
||||
"select.sameStroke" = "同じ線を選択"
|
||||
"select.sameText" = "同じテキストプロパティを選択"
|
||||
"select.set" = "選択"
|
||||
"selection.applyColorStyle" = "選択範囲にカラースタイルを適用"
|
||||
"selection.colors" = "選択範囲のカラー"
|
||||
"selection.selectColor" = "このカラーのレイヤーを選択"
|
||||
"selection.setPaint" = "選択範囲のカラーを変更"
|
||||
"shape.arrow" = "矢印"
|
||||
"shape.ellipse" = "楕円"
|
||||
"shape.line" = "線"
|
||||
|
||||
@@ -328,6 +328,7 @@ status = "draft — needs native review"
|
||||
"Current version" = "현재 버전"
|
||||
"Cursor chat" = "커서 채팅"
|
||||
"Custom" = "사용자 지정"
|
||||
"Custom size" = "사용자 지정 크기"
|
||||
"Cut" = "잘라내기"
|
||||
"Dark" = "다크"
|
||||
"Darken" = "어둡게 하기"
|
||||
@@ -619,6 +620,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." = "인터페이스 디자인과 프로토타이핑. Rust로 처음부터 작성한 무료 오픈 소스 클린룸 디자인 도구이며 ArtCraft Crafting Apps의 하나."
|
||||
"Intersect" = "교차"
|
||||
"Intersects" = "교차"
|
||||
"Invalid color" = "잘못된 색상"
|
||||
"Invite link" = "초대 링크"
|
||||
"Invite link copied" = "초대 링크 복사됨"
|
||||
"It asks permission to:" = "다음 권한을 요청함:"
|
||||
@@ -764,6 +766,7 @@ status = "draft — needs native review"
|
||||
"Navigate to" = "이동 대상"
|
||||
"Nested instance" = "중첩된 인스턴스"
|
||||
"New design file" = "새 디자인 파일"
|
||||
"Next frame" = "다음 프레임"
|
||||
"Next or previous control in the focused region" = "포커스된 영역의 다음 또는 이전 컨트롤"
|
||||
"Next ready for dev" = "다음 개발 준비 완료"
|
||||
"Next size option" = "다음 크기 옵션"
|
||||
@@ -773,6 +776,7 @@ status = "draft — needs native review"
|
||||
"No branches. A branch is a copy of the file to try changes in, then review and merge." = "브랜치 없음. 브랜치는 변경을 시험해 본 뒤 검토하고 병합하는 파일 사본임."
|
||||
"No category" = "카테고리 없음"
|
||||
"No changes" = "변경 사항 없음"
|
||||
"No color styles" = "색상 스타일 없음"
|
||||
"No colour variables fit here." = "여기에 맞는 색상 변수가 없음."
|
||||
"No colour variables yet. Create them in Local variables." = "아직 색상 변수가 없음. 로컬 변수에서 만드세요."
|
||||
"No comments yet" = "아직 댓글 없음"
|
||||
@@ -780,6 +784,7 @@ status = "draft — needs native review"
|
||||
"No components yet. Select layers and press {keys}." = "아직 컴포넌트가 없음. 레이어를 선택하고 {keys}를 누르세요."
|
||||
"No conflicts." = "충돌 없음."
|
||||
"No contributor data was built into this copy." = "이 사본에는 기여자 데이터가 포함되지 않음."
|
||||
"No device" = "기기 없음"
|
||||
"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 layer changes to show." = "표시할 레이어 변경 사항 없음."
|
||||
@@ -954,6 +959,7 @@ status = "draft — needs native review"
|
||||
"Preview" = "미리 보기"
|
||||
"Preview this flow" = "이 플로우 미리 보기"
|
||||
"Preview this frame" = "이 프레임 미리 보기"
|
||||
"Previous frame" = "이전 프레임"
|
||||
"Previous ready for dev" = "이전 개발 준비 완료"
|
||||
"Progressive" = "점진적"
|
||||
"Properties" = "속성"
|
||||
@@ -1047,6 +1053,7 @@ status = "draft — needs native review"
|
||||
"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 to fit" = "크기 맞춤"
|
||||
"Resizing" = "크기 조정"
|
||||
"Resolve" = "해결"
|
||||
"Resolve change" = "변경 사항 해결"
|
||||
"Resolved" = "해결됨"
|
||||
@@ -1118,12 +1125,14 @@ status = "draft — needs native review"
|
||||
"Select all with same" = "같은 항목 모두 선택"
|
||||
"Select layer" = "레이어 선택"
|
||||
"Select layers to apply the style to" = "스타일을 적용할 레이어를 선택하세요"
|
||||
"Select layers with this color" = "이 색상의 레이어 선택"
|
||||
"Select overlapping shapes" = "겹친 도형 선택"
|
||||
"Select points to edit their position, radius and mirroring" = "점을 선택해 위치, 반경, 대칭 편집"
|
||||
"Select slot" = "슬롯 선택"
|
||||
"Selection" = "선택 항목"
|
||||
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "선택 상자, {w} × {h}, 위치 {x}, {y}: 화살표로 이동, Shift+화살표 또는 {cmd}+화살표로 크기 조정, Enter로 선택, Escape로 취소"
|
||||
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "선택 상자: 화살표로 이동, Shift+화살표 또는 {cmd}+화살표로 크기 조정, Enter로 선택, Escape로 취소"
|
||||
"Selection colors" = "선택 항목 색상"
|
||||
"Send backward" = "뒤로 보내기"
|
||||
"Send report" = "신고 보내기"
|
||||
"Send to back" = "맨 뒤로 보내기"
|
||||
@@ -1440,13 +1449,25 @@ status = "draft — needs native review"
|
||||
"can edit the document" = "문서를 편집할 수 있음"
|
||||
"conditional prototype actions nested too deeply 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 was skipped: background bounds are invalid" = "커넥터 레이블을 건너뜀: 배경 경계가 잘못됨"
|
||||
"connector label was skipped: background 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 text bounds are missing" = "커넥터 레이블을 건너뜀: 계산된 텍스트 경계가 없음"
|
||||
"connector label was skipped: text bounds are invalid" = "커넥터 레이블을 건너뜀: 텍스트 경계가 잘못됨"
|
||||
"connector label was skipped: text style data is missing" = "커넥터 레이블을 건너뜀: 텍스트 스타일 데이터가 없음"
|
||||
"connector label was skipped: text transform is invalid" = "커넥터 레이블을 건너뜀: 텍스트 변형이 잘못됨"
|
||||
"connector labels were skipped" = "커넥터 레이블을 건너뜀"
|
||||
"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" = "커넥터 스냅샷의 벡터 데이터가 없어 커넥터를 비워 둠"
|
||||
"connectors whose ends can't be placed are empty" = "끝점을 배치할 수 없는 커넥터는 비어 있음"
|
||||
"crop marks" = "재단선 표시"
|
||||
"damaged vector geometry was skipped" = "손상된 벡터 지오메트리를 건너뜀"
|
||||
"damaged vector networks import as outlines" = "손상된 벡터 네트워크는 윤곽선으로 가져옴"
|
||||
"development: {path}" = "개발: {path}"
|
||||
"document children other than pages were skipped" = "페이지가 아닌 문서 하위 항목을 건너뜀"
|
||||
"duplicate grid track sizing entries use equal tracks" = "중복된 그리드 트랙 크기 항목은 균등한 트랙을 사용함"
|
||||
"e.g. count + 1" = "예: count + 1"
|
||||
"e.g. count >= 3 and not done" = "예: count >= 3 and not done"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "저장된 경로가 없는 꺾인 커넥터와 곡선 커넥터는 직선으로 가져옴"
|
||||
@@ -1455,26 +1476,38 @@ status = "draft — needs native review"
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "자체 채우기가 있는 벡터의 채워진 영역은 레이어의 채우기를 사용함"
|
||||
"from {server}" = "{server}에서"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "알려진 컨트롤러 버튼이 정확히 하나가 아닌 게임패드 트리거를 건너뜀"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "그리드 트랙 목록이 1000개 항목을 넘어 트랙은 기본 균등 크기를 사용함"
|
||||
"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" = "그리드 트랙 크기 목록이 1000개 항목을 넘어 트랙은 기본 균등 크기를 사용함"
|
||||
"hidden" = "숨김"
|
||||
"iOS" = "iOS"
|
||||
"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "필터 컨테이너가 둘 다 있어 이미지 필터가 모호하므로 필터 변환을 건너뜀"
|
||||
"image filters use FigCraft's approximate renderer" = "이미지 필터는 FigCraft의 근사 렌더링을 사용함"
|
||||
"instance swap properties whose default component isn't in the file were skipped" = "기본 컴포넌트가 파일에 없는 인스턴스 교체 속성을 건너뜀"
|
||||
"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 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" = "메인 컴포넌트가 없는 인스턴스는 빈 프레임으로 가져옴"
|
||||
"invalid internal .fig text path data was ignored" = "잘못된 .fig 내부 패스 위 텍스트 데이터를 무시함"
|
||||
"invalid or oversized vector networks import as outlines" = "잘못되었거나 너무 큰 벡터 네트워크는 윤곽선으로 가져옴"
|
||||
"invalid or oversized vector path data was skipped" = "잘못되었거나 너무 큰 벡터 경로 데이터를 건너뜀"
|
||||
"key triggers without a supported key were skipped" = "지원되는 키가 없는 키 트리거를 건너뜀"
|
||||
"key up triggers import as key presses" = "키를 뗄 때 트리거는 키 누름으로 가져옴"
|
||||
"layers without a position were placed at their parent's origin" = "위치가 없는 레이어는 상위 요소의 원점에 배치됨"
|
||||
"layers without a type were skipped" = "유형이 없는 레이어를 건너뜀"
|
||||
"legacy image filter container is unsupported; filter conversion was skipped" = "레거시 이미지 필터 컨테이너는 지원되지 않아 필터 변환을 건너뜀"
|
||||
"locked" = "잠김"
|
||||
"malformed image filters container was skipped" = "잘못된 형식의 이미지 필터 컨테이너를 건너뜀"
|
||||
"malformed image paintFilter container was skipped" = "잘못된 형식의 이미지 paintFilter 컨테이너를 건너뜀"
|
||||
"malformed slot content assignment was skipped" = "잘못된 형식의 슬롯 콘텐츠 할당을 건너뜀"
|
||||
"no bleed" = "도련 없음"
|
||||
"nobody else here yet" = "아직 다른 사람이 없음"
|
||||
"non-default slot settings are not represented and were skipped" = "기본값이 아닌 슬롯 설정은 표현할 수 없어 건너뜀"
|
||||
"now" = "방금"
|
||||
"or" = "또는"
|
||||
"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" = "슬롯의 기본 설정 컴포넌트 키를 파일의 레이어로 확인할 수 없어 건너뜀"
|
||||
"prototype actions of unknown types 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" = "변수 JSON에 없는 변수에 대한 프로토타입 수식을 건너뜀"
|
||||
@@ -1492,8 +1525,14 @@ status = "draft — needs native review"
|
||||
"set-variable actions with values that don't fit the variable 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" = "변수 JSON에 없는 컬렉션이나 모드에 대한 변수 모드 설정 액션을 건너뜀"
|
||||
"slot content assignment target is missing or is not slot content" = "슬롯 콘텐츠 할당 대상이 없거나 슬롯 콘텐츠가 아님"
|
||||
"slot content assignment uses the empty GUID sentinel and was skipped" = "슬롯 콘텐츠 할당이 빈 GUID를 센티넬 값으로 사용하여 건너뜀"
|
||||
"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" = "일부 벡터 세그먼트가 파일에 없는 스타일 설정을 참조함. 해당 세그먼트는 레이어의 선을 사용함"
|
||||
"stale or invalid grid track sizing entries were ignored" = "오래되었거나 잘못된 그리드 트랙 크기 항목을 무시함"
|
||||
"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" = "일부 벡터 세그먼트의 스타일 설정은 지원되지 않음. 해당 세그먼트는 레이어의 선을 사용함"
|
||||
"text path underline or strikethrough decoration is not rendered" = "패스 위 텍스트의 밑줄 또는 취소선은 그려지지 않음"
|
||||
"text style overrides refer to missing table entries" = "텍스트 스타일 재정의가 없는 테이블 항목을 참조함"
|
||||
"the file has no pages" = "파일에 페이지가 없음"
|
||||
"the plugin" = "이 플러그인"
|
||||
@@ -1622,6 +1661,7 @@ status = "draft — needs native review"
|
||||
"arrange.packH" = "가로로 붙이기"
|
||||
"arrange.packV" = "세로로 붙이기"
|
||||
"arrange.roundToPixels" = "픽셀에 맞춰 반올림"
|
||||
"arrange.spacing" = "간격"
|
||||
"arrange.tidy" = "정돈"
|
||||
"bool.exclude" = "선택 항목 제외"
|
||||
"bool.flatten" = "불리언 평면화"
|
||||
@@ -1925,6 +1965,10 @@ status = "draft — needs native review"
|
||||
"select.sameStroke" = "같은 선 선택"
|
||||
"select.sameText" = "같은 텍스트 속성 선택"
|
||||
"select.set" = "선택"
|
||||
"selection.applyColorStyle" = "선택 항목에 색상 스타일 적용"
|
||||
"selection.colors" = "선택 항목 색상"
|
||||
"selection.selectColor" = "이 색상의 레이어 선택"
|
||||
"selection.setPaint" = "선택 항목 색상 변경"
|
||||
"shape.arrow" = "화살표"
|
||||
"shape.ellipse" = "타원"
|
||||
"shape.line" = "선"
|
||||
|
||||
@@ -327,6 +327,7 @@ status = "draft — needs native review"
|
||||
"Current version" = "Versão atual"
|
||||
"Cursor chat" = "Chat do cursor"
|
||||
"Custom" = "Personalizado"
|
||||
"Custom size" = "Tamanho personalizado"
|
||||
"Cut" = "Recortar"
|
||||
"Dark" = "Escuro"
|
||||
"Darken" = "Escurecer"
|
||||
@@ -618,6 +619,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." = "Design de interfaces e prototipagem. Uma ferramenta de design gratuita e de código aberto, com implementação independente (clean-room), escrita do zero em Rust, parte dos Crafting Apps da ArtCraft."
|
||||
"Intersect" = "Interseção"
|
||||
"Intersects" = "Interseções"
|
||||
"Invalid color" = "Cor inválida"
|
||||
"Invite link" = "Link de convite"
|
||||
"Invite link copied" = "Link de convite copiado"
|
||||
"It asks permission to:" = "Ele pede permissão para:"
|
||||
@@ -763,6 +765,7 @@ status = "draft — needs native review"
|
||||
"Navigate to" = "Navegar para"
|
||||
"Nested instance" = "Instância aninhada"
|
||||
"New design file" = "Novo arquivo de design"
|
||||
"Next frame" = "Próximo quadro"
|
||||
"Next or previous control in the focused region" = "Controle seguinte ou anterior na região em foco"
|
||||
"Next ready for dev" = "Próximo pronto para dev"
|
||||
"Next size option" = "Próxima opção de tamanho"
|
||||
@@ -772,6 +775,7 @@ status = "draft — needs native review"
|
||||
"No branches. A branch is a copy of the file to try changes in, then review and merge." = "Nenhuma branch. Uma branch é uma cópia do arquivo para testar alterações e depois revisá-las e mesclá-las."
|
||||
"No category" = "Sem categoria"
|
||||
"No changes" = "Nenhuma alteração"
|
||||
"No color styles" = "Nenhum estilo de cor"
|
||||
"No colour variables fit here." = "Nenhuma variável de cor se encaixa aqui."
|
||||
"No colour variables yet. Create them in Local variables." = "Ainda não há variáveis de cor. Crie-as em Variáveis locais."
|
||||
"No comments yet" = "Ainda não há comentários"
|
||||
@@ -779,6 +783,7 @@ status = "draft — needs native review"
|
||||
"No components yet. Select layers and press {keys}." = "Ainda não há componentes. Selecione camadas e pressione {keys}."
|
||||
"No conflicts." = "Nenhum conflito."
|
||||
"No contributor data was built into this copy." = "Esta cópia não inclui dados de colaboradores."
|
||||
"No device" = "Nenhum dispositivo"
|
||||
"No fonts match" = "Nenhuma fonte corresponde"
|
||||
"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Não há código de acesso aqui (o servidor é anterior aos códigos de acesso, ou este endereço não cabe em um): compartilhe o link."
|
||||
"No layer changes to show." = "Nenhuma alteração de camada para mostrar."
|
||||
@@ -953,6 +958,7 @@ status = "draft — needs native review"
|
||||
"Preview" = "Visualização"
|
||||
"Preview this flow" = "Visualizar este fluxo"
|
||||
"Preview this frame" = "Visualizar este quadro"
|
||||
"Previous frame" = "Quadro anterior"
|
||||
"Previous ready for dev" = "Anterior pronto para dev"
|
||||
"Progressive" = "Progressivo"
|
||||
"Properties" = "Propriedades"
|
||||
@@ -1046,6 +1052,7 @@ status = "draft — needs native review"
|
||||
"Resize by the nudge amount" = "Redimensionar pelo valor do deslocamento"
|
||||
"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Redimensiona os quadros para a tela do dispositivo (ou para a janela, sem dispositivo): as restrições e o layout automático deles se adaptam"
|
||||
"Resize to fit" = "Redimensionar para caber"
|
||||
"Resizing" = "Redimensionamento"
|
||||
"Resolve" = "Resolver"
|
||||
"Resolve change" = "Resolver alteração"
|
||||
"Resolved" = "Resolvido"
|
||||
@@ -1117,12 +1124,14 @@ status = "draft — needs native review"
|
||||
"Select all with same" = "Selecionar tudo com o mesmo"
|
||||
"Select layer" = "Selecionar camada"
|
||||
"Select layers to apply the style to" = "Selecione as camadas às quais aplicar o estilo"
|
||||
"Select layers with this color" = "Selecionar camadas com esta cor"
|
||||
"Select overlapping shapes" = "Selecione formas sobrepostas"
|
||||
"Select points to edit their position, radius and mirroring" = "Selecione pontos para editar posição, raio e espelhamento"
|
||||
"Select slot" = "Selecionar slot"
|
||||
"Selection" = "Seleção"
|
||||
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Caixa de seleção, {w} por {h} em {x}, {y}: as setas a movem, Shift+setas ou {cmd}+setas a redimensionam, Enter seleciona, Esc cancela"
|
||||
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Caixa de seleção: as setas a movem, Shift+setas ou {cmd}+setas a redimensionam, Enter seleciona, Esc cancela"
|
||||
"Selection colors" = "Cores da seleção"
|
||||
"Send backward" = "Enviar para trás"
|
||||
"Send report" = "Enviar denúncia"
|
||||
"Send to back" = "Enviar para o plano de fundo"
|
||||
@@ -1439,13 +1448,25 @@ status = "draft — needs native review"
|
||||
"can edit the document" = "pode editar o documento"
|
||||
"conditional prototype actions nested too deeply were skipped" = "ações condicionais de protótipo aninhadas profundamente demais foram ignoradas"
|
||||
"conditional prototype actions with conditions that can't be evaluated were skipped" = "ações condicionais de protótipo com condições que não podem ser avaliadas foram ignoradas"
|
||||
"connector label background was skipped: style data is missing" = "o fundo do rótulo do conector foi ignorado: faltam os dados de estilo"
|
||||
"connector label was skipped: background bounds are invalid" = "o rótulo do conector foi ignorado: os limites do fundo são inválidos"
|
||||
"connector label was skipped: background transform is invalid" = "o rótulo do conector foi ignorado: a transformação do fundo é inválida"
|
||||
"connector label was skipped: composed text transform is invalid" = "o rótulo do conector foi ignorado: a transformação composta do texto é inválida"
|
||||
"connector label was skipped: derived background bounds are missing" = "o rótulo do conector foi ignorado: faltam os limites derivados do fundo"
|
||||
"connector label was skipped: derived text bounds are missing" = "o rótulo do conector foi ignorado: faltam os limites derivados do texto"
|
||||
"connector label was skipped: text bounds are invalid" = "o rótulo do conector foi ignorado: os limites do texto são inválidos"
|
||||
"connector label was skipped: text style data is missing" = "o rótulo do conector foi ignorado: faltam os dados de estilo do texto"
|
||||
"connector label was skipped: text transform is invalid" = "o rótulo do conector foi ignorado: a transformação do texto é inválida"
|
||||
"connector labels were skipped" = "rótulos de conectores foram ignorados"
|
||||
"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "camadas de conector são importadas como instantâneos estáticos de quadro ou vetor, com rótulos quando houver; os vínculos dinâmicos das pontas não são mantidos"
|
||||
"connector snapshot vector data is missing; connector was left empty" = "faltam os dados vetoriais do instantâneo do conector; o conector ficou vazio"
|
||||
"connectors whose ends can't be placed are empty" = "conectores cujas pontas não podem ser posicionadas ficam vazios"
|
||||
"crop marks" = "marcas de corte"
|
||||
"damaged vector geometry was skipped" = "geometria vetorial danificada foi ignorada"
|
||||
"damaged vector networks import as outlines" = "redes vetoriais danificadas são importadas como contornos"
|
||||
"development: {path}" = "desenvolvimento: {path}"
|
||||
"document children other than pages were skipped" = "filhos do documento que não são páginas foram ignorados"
|
||||
"duplicate grid track sizing entries use equal tracks" = "entradas duplicadas de dimensionamento de faixas da grade usam faixas iguais"
|
||||
"e.g. count + 1" = "por ex. count + 1"
|
||||
"e.g. count >= 3 and not done" = "por ex. count >= 3 and not done"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "conectores angulares e curvos sem um caminho salvo são importados como linhas retas"
|
||||
@@ -1454,26 +1475,38 @@ status = "draft — needs native review"
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "áreas preenchidas de um vetor com preenchimento próprio usam o preenchimento da camada"
|
||||
"from {server}" = "de {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "gatilhos de gamepad sem exatamente um botão de controle conhecido foram ignorados"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "a lista de faixas da grade passa de 1000 entradas; as faixas usam o dimensionamento igual padrão"
|
||||
"grid track sizing is unsupported or malformed; affected tracks use equal sizing" = "o dimensionamento de faixas da grade não é compatível ou está malformado; as faixas afetadas usam dimensionamento igual"
|
||||
"grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "a lista de dimensionamento de faixas da grade passa de 1000 entradas; as faixas usam o dimensionamento igual padrão"
|
||||
"hidden" = "oculta"
|
||||
"iOS" = "iOS"
|
||||
"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "os filtros de imagem são ambíguos porque os dois contêineres de filtro estão presentes; a conversão dos filtros foi ignorada"
|
||||
"image filters use FigCraft's approximate renderer" = "os filtros de imagem usam a renderização aproximada do FigCraft"
|
||||
"instance swap properties whose default component isn't in the file were skipped" = "propriedades de troca de instância cujo componente padrão não está no arquivo foram ignoradas"
|
||||
"instances inside their own main component were imported as frames" = "instâncias dentro do próprio componente principal foram importadas como quadros"
|
||||
"instances of components that aren't in the file show no layers" = "instâncias de componentes que não estão no arquivo não mostram camadas"
|
||||
"instances of components that aren't in the file were imported as frames" = "instâncias de componentes que não estão no arquivo foram importadas como quadros"
|
||||
"instances that would nest a component inside itself were imported as frames" = "instâncias que aninhariam um componente dentro dele mesmo foram importadas como quadros"
|
||||
"instances without a main component were imported as empty frames" = "instâncias sem componente principal foram importadas como quadros vazios"
|
||||
"invalid internal .fig text path data was ignored" = "dados internos inválidos de texto em caminho do .fig foram ignorados"
|
||||
"invalid or oversized vector networks import as outlines" = "redes vetoriais inválidas ou grandes demais são importadas como contornos"
|
||||
"invalid or oversized vector path data was skipped" = "dados de caminho vetorial inválidos ou grandes demais foram ignorados"
|
||||
"key triggers without a supported key were skipped" = "gatilhos de tecla sem uma tecla compatível foram ignorados"
|
||||
"key up triggers import as key presses" = "gatilhos ao soltar uma tecla são importados como pressionamentos de tecla"
|
||||
"layers without a position were placed at their parent's origin" = "camadas sem posição foram colocadas na origem do elemento pai"
|
||||
"layers without a type were skipped" = "camadas sem tipo foram ignoradas"
|
||||
"legacy image filter container is unsupported; filter conversion was skipped" = "o contêiner legado de filtros de imagem não é compatível; a conversão dos filtros foi ignorada"
|
||||
"locked" = "bloqueada"
|
||||
"malformed image filters container was skipped" = "o contêiner de filtros de imagem malformado foi ignorado"
|
||||
"malformed image paintFilter container was skipped" = "o contêiner paintFilter de imagem malformado foi ignorado"
|
||||
"malformed slot content assignment was skipped" = "uma atribuição de conteúdo de slot malformada foi ignorada"
|
||||
"no bleed" = "sem sangria"
|
||||
"nobody else here yet" = "ainda não há mais ninguém"
|
||||
"non-default slot settings are not represented and were skipped" = "configurações de slot diferentes do padrão não são representadas e foram ignoradas"
|
||||
"now" = "agora"
|
||||
"or" = "ou"
|
||||
"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "as junções de traço por ponto foram mantidas; traços tracejados ou de largura variável são desenhados com a junção da camada"
|
||||
"preferred slot component keys cannot be resolved to file nodes and were skipped" = "chaves de componentes preferidos de slots não correspondem a camadas do arquivo e foram ignoradas"
|
||||
"prototype actions of unknown types were skipped" = "ações de protótipo de tipos desconhecidos foram ignoradas"
|
||||
"prototype actions without a destination in the file were skipped" = "ações de protótipo sem destino no arquivo foram ignoradas"
|
||||
"prototype expressions on variables that aren't in the variables JSON were skipped" = "expressões de protótipo com variáveis que não estão no JSON de variáveis foram ignoradas"
|
||||
@@ -1491,8 +1524,14 @@ status = "draft — needs native review"
|
||||
"set-variable actions with values that don't fit the variable were skipped" = "ações de definir variável com valores que não cabem na variável foram ignoradas"
|
||||
"set-variable actions without a value were skipped" = "ações de definir variável sem valor foram ignoradas"
|
||||
"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "ações de definir modo de variável em coleções ou modos que não estão no JSON de variáveis foram ignoradas"
|
||||
"slot content assignment target is missing or is not slot content" = "o destino da atribuição de conteúdo de slot está ausente ou não é conteúdo de slot"
|
||||
"slot content assignment uses the empty GUID sentinel and was skipped" = "a atribuição de conteúdo de slot usa o GUID vazio como sentinela e foi ignorada"
|
||||
"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "alguns pontos vetoriais se referem a configurações de ponto ausentes no arquivo; eles usam as junções da camada e cantos simples"
|
||||
"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "alguns segmentos vetoriais se referem a configurações de estilo ausentes no arquivo; eles usam o traço da camada"
|
||||
"stale or invalid grid track sizing entries were ignored" = "entradas de dimensionamento de faixas da grade obsoletas ou inválidas foram ignoradas"
|
||||
"stars and polygons without point counts or geometry use the default point count" = "estrelas e polígonos sem número de pontas nem geometria usam o número de pontas padrão"
|
||||
"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "as configurações de estilo de alguns segmentos vetoriais não são compatíveis; esses segmentos usam o traço da camada"
|
||||
"text path underline or strikethrough decoration is not rendered" = "o sublinhado ou tachado do texto em caminho não é desenhado"
|
||||
"text style overrides refer to missing table entries" = "substituições de estilo de texto se referem a entradas de tabela ausentes"
|
||||
"the file has no pages" = "o arquivo não tem páginas"
|
||||
"the plugin" = "o plugin"
|
||||
@@ -1621,6 +1660,7 @@ status = "draft — needs native review"
|
||||
"arrange.packH" = "Compactar na horizontal"
|
||||
"arrange.packV" = "Compactar na vertical"
|
||||
"arrange.roundToPixels" = "Arredondar para pixels"
|
||||
"arrange.spacing" = "Espaçamento"
|
||||
"arrange.tidy" = "Organizar"
|
||||
"bool.exclude" = "Excluir sobreposição da seleção"
|
||||
"bool.flatten" = "Nivelar booleano"
|
||||
@@ -1924,6 +1964,10 @@ status = "draft — needs native review"
|
||||
"select.sameStroke" = "Selecionar mesmo traço"
|
||||
"select.sameText" = "Selecionar mesmas propriedades de texto"
|
||||
"select.set" = "Selecionar"
|
||||
"selection.applyColorStyle" = "Aplicar estilo de cor à seleção"
|
||||
"selection.colors" = "Cores da seleção"
|
||||
"selection.selectColor" = "Selecionar camadas com esta cor"
|
||||
"selection.setPaint" = "Alterar cor da seleção"
|
||||
"shape.arrow" = "Seta"
|
||||
"shape.ellipse" = "Elipse"
|
||||
"shape.line" = "Linha"
|
||||
|
||||
@@ -334,6 +334,7 @@ name = "Русский"
|
||||
"Current version" = "Текущая версия"
|
||||
"Cursor chat" = "Чат у курсора"
|
||||
"Custom" = "Пользовательский"
|
||||
"Custom size" = "Пользовательский размер"
|
||||
"Cut" = "Вырезать"
|
||||
"Dark" = "Тёмная"
|
||||
"Darken" = "Затемнение"
|
||||
@@ -639,6 +640,7 @@ name = "Русский"
|
||||
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Дизайн интерфейсов и прототипирование. Бесплатный инструмент с открытым исходным кодом, независимо созданный с нуля на Rust, из семейства ArtCraft Crafting Apps."
|
||||
"Intersect" = "Пересечение"
|
||||
"Intersects" = "Пересечения"
|
||||
"Invalid color" = "Недопустимый цвет"
|
||||
"Invite link" = "Ссылка приглашения"
|
||||
"Invite link copied" = "Ссылка приглашения скопирована"
|
||||
"It asks permission to:" = "Запрашивает разрешение на:"
|
||||
@@ -796,6 +798,7 @@ name = "Русский"
|
||||
"Nested instance" = "Вложенный экземпляр"
|
||||
"New design file" = "Новый файл дизайна"
|
||||
"Newest first" = "Сначала новые"
|
||||
"Next frame" = "Следующий Frame"
|
||||
"Next or previous control in the focused region" = "Следующий или предыдущий элемент в активной области"
|
||||
"Next ready for dev" = "Следующий готовый к разработке"
|
||||
"Next size option" = "Следующий вариант размера"
|
||||
@@ -805,6 +808,7 @@ name = "Русский"
|
||||
"No branches. A branch is a copy of the file to try changes in, then review and merge." = "Веток нет. Ветка — копия файла, в которой можно попробовать изменения, затем проверить и объединить их."
|
||||
"No category" = "Без категории"
|
||||
"No changes" = "Нет изменений"
|
||||
"No color styles" = "Нет цветовых стилей"
|
||||
"No colour variables fit here." = "Нет подходящих цветовых переменных."
|
||||
"No colour variables yet. Create them in Local variables." = "Цветовых переменных пока нет. Создайте их в разделе «Локальные переменные»."
|
||||
"No comments yet" = "Комментариев пока нет"
|
||||
@@ -812,6 +816,7 @@ name = "Русский"
|
||||
"No components yet. Select layers and press {keys}." = "Компонентов пока нет. Выделите слои и нажмите {keys}."
|
||||
"No conflicts." = "Конфликтов нет."
|
||||
"No contributor data was built into this copy." = "В этой сборке нет данных об участниках."
|
||||
"No device" = "Без устройства"
|
||||
"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 layer changes to show." = "Нет изменений слоёв для показа."
|
||||
@@ -996,6 +1001,7 @@ name = "Русский"
|
||||
"Preview" = "Предпросмотр"
|
||||
"Preview this flow" = "Предпросмотр этого сценария"
|
||||
"Preview this frame" = "Предпросмотр этого Frame"
|
||||
"Previous frame" = "Предыдущий Frame"
|
||||
"Previous ready for dev" = "Предыдущий готовый к разработке"
|
||||
"Progressive" = "Прогрессивный"
|
||||
"Properties" = "Свойства"
|
||||
@@ -1089,6 +1095,7 @@ name = "Русский"
|
||||
"Resize by the nudge amount" = "Изменить размер на шаг сдвига"
|
||||
"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Менять размер Frame под экран устройства (или окно, если устройство не выбрано): ограничения и автолейаут подстраиваются"
|
||||
"Resize to fit" = "Подогнать размер"
|
||||
"Resizing" = "Изменение размера"
|
||||
"Resolve" = "Решить"
|
||||
"Resolve change" = "Разрешить изменение"
|
||||
"Resolved" = "Решённые"
|
||||
@@ -1162,12 +1169,14 @@ name = "Русский"
|
||||
"Select all with same" = "Выделить всё с одинаковым"
|
||||
"Select layer" = "Выделить слой"
|
||||
"Select layers to apply the style to" = "Выделите слои для применения стиля"
|
||||
"Select layers with this color" = "Выделить слои с этим цветом"
|
||||
"Select overlapping shapes" = "Выделите пересекающиеся фигуры"
|
||||
"Select points to edit their position, radius and mirroring" = "Выделите точки для изменения положения, радиуса и зеркальности"
|
||||
"Select slot" = "Выделить слот"
|
||||
"Selection" = "Выделение"
|
||||
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Область выделения {w} на {h} в {x}, {y}: стрелки — перемещение, Shift+стрелки или {cmd}+стрелки — размер, Enter — выделить, Escape — отменить"
|
||||
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Область выделения: стрелки — перемещение, Shift+стрелки или {cmd}+стрелки — размер, Enter — выделить, Escape — отменить"
|
||||
"Selection colors" = "Цвета выделения"
|
||||
"Send backward" = "На уровень назад"
|
||||
"Send report" = "Отправить жалобу"
|
||||
"Send to back" = "На задний план"
|
||||
@@ -1501,13 +1510,25 @@ name = "Русский"
|
||||
"can edit the document" = "может редактировать документ"
|
||||
"conditional prototype actions nested too deeply 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 was skipped: background bounds are invalid" = "подпись соединительной линии пропущена: недопустимые границы фона"
|
||||
"connector label was skipped: background 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 text bounds are missing" = "подпись соединительной линии пропущена: нет вычисленных границ текста"
|
||||
"connector label was skipped: text bounds are invalid" = "подпись соединительной линии пропущена: недопустимые границы текста"
|
||||
"connector label was skipped: text style data is missing" = "подпись соединительной линии пропущена: нет данных стиля текста"
|
||||
"connector label was skipped: text transform is invalid" = "подпись соединительной линии пропущена: недопустимая трансформация текста"
|
||||
"connector labels were skipped" = "подписи соединительных линий пропущены"
|
||||
"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" = "нет векторных данных снимка соединительной линии; соединительная линия оставлена пустой"
|
||||
"connectors whose ends can't be placed are empty" = "соединительные линии, концы которых нельзя разместить, пусты"
|
||||
"crop marks" = "метки обреза"
|
||||
"damaged vector geometry was skipped" = "повреждённая векторная геометрия пропущена"
|
||||
"damaged vector networks import as outlines" = "повреждённые векторные сети импортируются как контуры"
|
||||
"development: {path}" = "разработка: {path}"
|
||||
"document children other than pages were skipped" = "дочерние элементы документа, не являющиеся страницами, пропущены"
|
||||
"duplicate grid track sizing entries use equal tracks" = "для повторяющихся записей размеров треков сетки используются равные треки"
|
||||
"e.g. count + 1" = "например, count + 1"
|
||||
"e.g. count >= 3 and not done" = "например, count >= 3 and not done"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "ломаные и изогнутые соединительные линии без сохранённого пути импортируются как прямые"
|
||||
@@ -1516,14 +1537,20 @@ name = "Русский"
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "залитые области вектора с собственной заливкой используют заливку слоя"
|
||||
"from {server}" = "с {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "триггеры геймпада без ровно одной известной кнопки контроллера пропущены"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "список треков сетки превышает 1000 записей; треки получают стандартный равный размер"
|
||||
"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" = "список размеров треков сетки превышает 1000 записей; треки получают стандартный равный размер"
|
||||
"hidden" = "скрыто"
|
||||
"iOS" = "iOS"
|
||||
"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "фильтры изображения неоднозначны, так как есть оба контейнера фильтров; преобразование фильтров пропущено"
|
||||
"image filters use FigCraft's approximate renderer" = "фильтры изображения отрисовываются приблизительно средствами FigCraft"
|
||||
"instance swap properties whose default component isn't in the file were skipped" = "свойства замены экземпляра, компонент по умолчанию которых отсутствует в файле, пропущены"
|
||||
"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 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" = "экземпляры без основного компонента импортированы как пустые фреймы"
|
||||
"invalid internal .fig text path data was ignored" = "недопустимые внутренние данные текста на пути из .fig проигнорированы"
|
||||
"invalid or oversized vector networks import as outlines" = "недопустимые или слишком большие векторные сети импортируются как контуры"
|
||||
"invalid or oversized vector path data was skipped" = "недопустимые или слишком большие данные векторных путей пропущены"
|
||||
"just now" = "только что"
|
||||
@@ -1531,12 +1558,18 @@ name = "Русский"
|
||||
"key up triggers import as key presses" = "триггеры отпускания клавиши импортируются как нажатия клавиши"
|
||||
"layers without a position were placed at their parent's origin" = "слои без положения размещены в начале координат родителя"
|
||||
"layers without a type were skipped" = "слои без типа пропущены"
|
||||
"legacy image filter container is unsupported; filter conversion was skipped" = "устаревший контейнер фильтров изображения не поддерживается; преобразование фильтров пропущено"
|
||||
"locked" = "заблокировано"
|
||||
"malformed image filters container was skipped" = "повреждённый контейнер фильтров изображения пропущен"
|
||||
"malformed image paintFilter container was skipped" = "повреждённый контейнер paintFilter изображения пропущен"
|
||||
"malformed slot content assignment was skipped" = "повреждённое назначение содержимого слота пропущено"
|
||||
"no bleed" = "без вылета"
|
||||
"nobody else here yet" = "здесь пока больше никого нет"
|
||||
"non-default slot settings are not represented and were skipped" = "нестандартные настройки слотов не передаются и пропущены"
|
||||
"now" = "сейчас"
|
||||
"or" = "или"
|
||||
"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" = "ключи предпочтительных компонентов слотов не удалось сопоставить со слоями файла, они пропущены"
|
||||
"prototype actions of unknown types 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" = "выражения прототипа с переменными, которых нет в JSON переменных, пропущены"
|
||||
@@ -1554,8 +1587,14 @@ name = "Русский"
|
||||
"set-variable actions with values that don't fit the variable 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" = "действия смены режима переменных для коллекций или режимов, которых нет в JSON переменных, пропущены"
|
||||
"slot content assignment target is missing or is not slot content" = "цель назначения содержимого слота отсутствует или не является содержимым слота"
|
||||
"slot content assignment uses the empty GUID sentinel and was skipped" = "назначение содержимого слота использует пустой GUID-маркер и пропущено"
|
||||
"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" = "некоторые векторные сегменты ссылаются на настройки стиля, которых нет в файле; они используют обводку слоя"
|
||||
"stale or invalid grid track sizing entries were ignored" = "устаревшие или недопустимые записи размеров треков сетки проигнорированы"
|
||||
"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" = "настройки стиля некоторых векторных сегментов не поддерживаются; эти сегменты используют обводку слоя"
|
||||
"text path underline or strikethrough decoration is not rendered" = "подчёркивание и зачёркивание текста на пути не отображаются"
|
||||
"text style overrides refer to missing table entries" = "переопределения текстовых стилей ссылаются на отсутствующие записи таблицы"
|
||||
"the file has no pages" = "в файле нет страниц"
|
||||
"the plugin" = "плагин"
|
||||
@@ -1686,6 +1725,7 @@ name = "Русский"
|
||||
"arrange.packH" = "Уплотнить по горизонтали"
|
||||
"arrange.packV" = "Уплотнить по вертикали"
|
||||
"arrange.roundToPixels" = "Округлить до пикселей"
|
||||
"arrange.spacing" = "Интервал"
|
||||
"arrange.tidy" = "Упорядочить"
|
||||
"bool.exclude" = "Исключить выделение"
|
||||
"bool.flatten" = "Свести булеву группу"
|
||||
@@ -1993,6 +2033,10 @@ name = "Русский"
|
||||
"select.sameStroke" = "Выделить с одинаковой обводкой"
|
||||
"select.sameText" = "Выделить с одинаковыми свойствами текста"
|
||||
"select.set" = "Выделить"
|
||||
"selection.applyColorStyle" = "Применить цветовой стиль к выделению"
|
||||
"selection.colors" = "Цвета выделения"
|
||||
"selection.selectColor" = "Выделить слои с этим цветом"
|
||||
"selection.setPaint" = "Изменить цвет выделения"
|
||||
"shape.arrow" = "Стрелка"
|
||||
"shape.ellipse" = "Эллипс"
|
||||
"shape.line" = "Линия"
|
||||
|
||||
@@ -339,6 +339,7 @@ name = "Svenska"
|
||||
"Current version" = "Aktuell version"
|
||||
"Cursor chat" = "Markörchatt"
|
||||
"Custom" = "Anpassad"
|
||||
"Custom size" = "Anpassad storlek"
|
||||
"Cut" = "Klipp ut"
|
||||
"Dark" = "Mörk"
|
||||
"Darken" = "Mörkare"
|
||||
@@ -649,6 +650,7 @@ name = "Svenska"
|
||||
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Gränssnittsdesign och prototyper. Ett fritt clean-room-designverktyg med öppen källkod, skrivet från grunden i Rust och en del av ArtCraft Crafting Apps."
|
||||
"Intersect" = "Skärning"
|
||||
"Intersects" = "Skär"
|
||||
"Invalid color" = "Ogiltig färg"
|
||||
"Invite link" = "Inbjudningslänk"
|
||||
"Invite link copied" = "Inbjudningslänken har kopierats"
|
||||
"It asks permission to:" = "Den ber om behörighet att:"
|
||||
@@ -806,6 +808,7 @@ name = "Svenska"
|
||||
"Nested instance" = "Kapslad instans"
|
||||
"New design file" = "Ny designfil"
|
||||
"Newest first" = "Nyaste först"
|
||||
"Next frame" = "Nästa ram"
|
||||
"Next or previous control in the focused region" = "Nästa eller föregående kontroll i den fokuserade regionen"
|
||||
"Next ready for dev" = "Nästa redo för utveckling"
|
||||
"Next size option" = "Nästa storleksalternativ"
|
||||
@@ -815,6 +818,7 @@ name = "Svenska"
|
||||
"No branches. A branch is a copy of the file to try changes in, then review and merge." = "Inga grenar. En gren är en kopia av filen där du kan prova ändringar och sedan granska och sammanfoga dem."
|
||||
"No category" = "Ingen kategori"
|
||||
"No changes" = "Inga ändringar"
|
||||
"No color styles" = "Inga färgformatmallar"
|
||||
"No colour variables fit here." = "Inga färgvariabler passar här."
|
||||
"No colour variables yet. Create them in Local variables." = "Inga färgvariabler än. Skapa dem i Lokala variabler."
|
||||
"No comments yet" = "Inga kommentarer än"
|
||||
@@ -822,6 +826,7 @@ name = "Svenska"
|
||||
"No components yet. Select layers and press {keys}." = "Inga komponenter än. Markera lager och tryck på {keys}."
|
||||
"No conflicts." = "Inga konflikter."
|
||||
"No contributor data was built into this copy." = "Inga uppgifter om bidragsgivare finns inbyggda i den här kopian."
|
||||
"No device" = "Ingen enhet"
|
||||
"No fonts match" = "Inga typsnitt matchar"
|
||||
"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Ingen anslutningskod här (servern är äldre än anslutningskoder, eller så kan den här adressen inte göras om till en): dela länken i stället."
|
||||
"No layer changes to show." = "Inga lagerändringar att visa."
|
||||
@@ -1007,6 +1012,7 @@ name = "Svenska"
|
||||
"Preview" = "Förhandsgranskning"
|
||||
"Preview this flow" = "Förhandsgranska det här flödet"
|
||||
"Preview this frame" = "Förhandsgranska den här ramen"
|
||||
"Previous frame" = "Föregående ram"
|
||||
"Previous ready for dev" = "Föregående redo för utveckling"
|
||||
"Progressive" = "Progressiv"
|
||||
"Properties" = "Egenskaper"
|
||||
@@ -1101,6 +1107,7 @@ name = "Svenska"
|
||||
"Resize by the nudge amount" = "Ändra storlek med knuffavståndet"
|
||||
"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Ändra storlek på ramar efter enhetens skärm (eller fönstret utan enhet): deras begränsningar och automatiska layout anpassas"
|
||||
"Resize to fit" = "Anpassa storlek"
|
||||
"Resizing" = "Storleksändring"
|
||||
"Resolve" = "Lös"
|
||||
"Resolve change" = "Lös ändring"
|
||||
"Resolved" = "Löst"
|
||||
@@ -1175,12 +1182,14 @@ name = "Svenska"
|
||||
"Select all with same" = "Markera alla med samma"
|
||||
"Select layer" = "Markera lager"
|
||||
"Select layers to apply the style to" = "Markera de lager som formatmallen ska användas på"
|
||||
"Select layers with this color" = "Markera lager med den här färgen"
|
||||
"Select overlapping shapes" = "Markera överlappande former"
|
||||
"Select points to edit their position, radius and mirroring" = "Markera punkter för att redigera deras position, radie och spegling"
|
||||
"Select slot" = "Markera plats"
|
||||
"Selection" = "Markering"
|
||||
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Markeringsruta, {w} gånger {h} vid {x}, {y}: piltangenterna flyttar den, Shift+pilar eller {cmd}+pilar ändrar storleken, Enter markerar, Escape avbryter"
|
||||
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Markeringsruta: piltangenterna flyttar den, Shift+pilar eller {cmd}+pilar ändrar storleken, Enter markerar, Escape avbryter"
|
||||
"Selection colors" = "Markeringsfärger"
|
||||
"Send backward" = "Flytta bakåt"
|
||||
"Send report" = "Skicka rapport"
|
||||
"Send to back" = "Flytta längst bak"
|
||||
@@ -1520,13 +1529,25 @@ name = "Svenska"
|
||||
"can edit the document" = "kan redigera dokumentet"
|
||||
"conditional prototype actions nested too deeply were skipped" = "villkorliga prototypåtgärder som var nästlade för djupt hoppades över"
|
||||
"conditional prototype actions with conditions that can't be evaluated were skipped" = "villkorliga prototypåtgärder med villkor som inte kan utvärderas hoppades över"
|
||||
"connector label background was skipped: style data is missing" = "bakgrunden till etiketten på en kopplingslinje hoppades över: stildata saknas"
|
||||
"connector label was skipped: background bounds are invalid" = "etiketten på en kopplingslinje hoppades över: bakgrundens gränser är ogiltiga"
|
||||
"connector label was skipped: background transform is invalid" = "etiketten på en kopplingslinje hoppades över: bakgrundens transformering är ogiltig"
|
||||
"connector label was skipped: composed text transform is invalid" = "etiketten på en kopplingslinje hoppades över: textens sammansatta transformering är ogiltig"
|
||||
"connector label was skipped: derived background bounds are missing" = "etiketten på en kopplingslinje hoppades över: bakgrundens härledda gränser saknas"
|
||||
"connector label was skipped: derived text bounds are missing" = "etiketten på en kopplingslinje hoppades över: textens härledda gränser saknas"
|
||||
"connector label was skipped: text bounds are invalid" = "etiketten på en kopplingslinje hoppades över: textens gränser är ogiltiga"
|
||||
"connector label was skipped: text style data is missing" = "etiketten på en kopplingslinje hoppades över: textens stildata saknas"
|
||||
"connector label was skipped: text transform is invalid" = "etiketten på en kopplingslinje hoppades över: textens transformering är ogiltig"
|
||||
"connector labels were skipped" = "etiketter på kopplingslinjer hoppades över"
|
||||
"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "kopplingslinjelager importeras som statiska ögonblicksbilder av ramar eller vektorer, med etiketter om de finns; dynamiska bindningar av ändpunkterna behålls inte"
|
||||
"connector snapshot vector data is missing; connector was left empty" = "vektordata för kopplingslinjens ögonblicksbild saknas; kopplingslinjen lämnades tom"
|
||||
"connectors whose ends can't be placed are empty" = "kopplingslinjer vars ändar inte kan placeras är tomma"
|
||||
"crop marks" = "skärmärken"
|
||||
"damaged vector geometry was skipped" = "skadad vektorgeometri hoppades över"
|
||||
"damaged vector networks import as outlines" = "skadade vektornätverk importeras som former"
|
||||
"development: {path}" = "utveckling: {path}"
|
||||
"document children other than pages were skipped" = "underordnade objekt i dokumentet som inte är sidor hoppades över"
|
||||
"duplicate grid track sizing entries use equal tracks" = "dubbla storleksposter för rutnätsspår använder lika stora spår"
|
||||
"e.g. count + 1" = "t.ex. count + 1"
|
||||
"e.g. count >= 3 and not done" = "t.ex. count >= 3 and not done"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "vinklade och böjda kopplingslinjer utan sparad bana importeras som raka linjer"
|
||||
@@ -1535,14 +1556,20 @@ name = "Svenska"
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "fyllda områden i en vektor med egen fyllning använder lagrets fyllning"
|
||||
"from {server}" = "från {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "spelkontrollsutlösare utan exakt en känd kontrollknapp hoppades över"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "listan över rutnätsspår har fler än 1000 poster; spåren får standardstorleken med lika stora spår"
|
||||
"grid track sizing is unsupported or malformed; affected tracks use equal sizing" = "storleken på rutnätsspår stöds inte eller är felaktig; berörda spår blir lika stora"
|
||||
"grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "listan över storlekar på rutnätsspår har fler än 1000 poster; spåren får standardstorleken med lika stora spår"
|
||||
"hidden" = "dold"
|
||||
"iOS" = "iOS"
|
||||
"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "bildfiltren är tvetydiga eftersom båda filterbehållarna finns; filterkonverteringen hoppades över"
|
||||
"image filters use FigCraft's approximate renderer" = "bildfilter använder FigCrafts ungefärliga rendering"
|
||||
"instance swap properties whose default component isn't in the file were skipped" = "egenskaper för instansbyte vars standardkomponent inte finns i filen hoppades över"
|
||||
"instances inside their own main component were imported as frames" = "instanser inuti sin egen huvudkomponent importerades som ramar"
|
||||
"instances of components that aren't in the file show no layers" = "instanser av komponenter som inte finns i filen visar inga lager"
|
||||
"instances of components that aren't in the file were imported as frames" = "instanser av komponenter som inte finns i filen importerades som ramar"
|
||||
"instances that would nest a component inside itself were imported as frames" = "instanser som skulle nästla en komponent i sig själv importerades som ramar"
|
||||
"instances without a main component were imported as empty frames" = "instanser utan huvudkomponent importerades som tomma ramar"
|
||||
"invalid internal .fig text path data was ignored" = "ogiltiga interna .fig-data för text på bana ignorerades"
|
||||
"invalid or oversized vector networks import as outlines" = "ogiltiga eller för stora vektornätverk importeras som former"
|
||||
"invalid or oversized vector path data was skipped" = "ogiltiga eller för stora vektorbanedata hoppades över"
|
||||
"just now" = "nyss"
|
||||
@@ -1550,12 +1577,18 @@ name = "Svenska"
|
||||
"key up triggers import as key presses" = "utlösare för släppt tangent importeras som tangenttryckningar"
|
||||
"layers without a position were placed at their parent's origin" = "lager utan position placerades i det överordnade objektets origo"
|
||||
"layers without a type were skipped" = "lager utan typ hoppades över"
|
||||
"legacy image filter container is unsupported; filter conversion was skipped" = "den äldre bildfilterbehållaren stöds inte; filterkonverteringen hoppades över"
|
||||
"locked" = "låst"
|
||||
"malformed image filters container was skipped" = "en felaktig bildfilterbehållare hoppades över"
|
||||
"malformed image paintFilter container was skipped" = "en felaktig paintFilter-behållare för bild hoppades över"
|
||||
"malformed slot content assignment was skipped" = "en felaktig tilldelning av fackinnehåll hoppades över"
|
||||
"no bleed" = "inget utfall"
|
||||
"nobody else here yet" = "ingen annan här än"
|
||||
"non-default slot settings are not represented and were skipped" = "fackinställningar som avviker från standard återges inte och hoppades över"
|
||||
"now" = "nu"
|
||||
"or" = "eller"
|
||||
"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "punkternas hörntyper behölls; streckade konturer och konturer med variabel bredd ritas med lagrets hörntyp"
|
||||
"preferred slot component keys cannot be resolved to file nodes and were skipped" = "nycklar för föredragna komponenter i fack kan inte kopplas till lager i filen och hoppades över"
|
||||
"prototype actions of unknown types were skipped" = "prototypåtgärder av okända typer hoppades över"
|
||||
"prototype actions without a destination in the file were skipped" = "prototypåtgärder utan mål i filen hoppades över"
|
||||
"prototype expressions on variables that aren't in the variables JSON were skipped" = "prototyputtryck med variabler som inte finns i variabel-JSON:en hoppades över"
|
||||
@@ -1573,8 +1606,14 @@ name = "Svenska"
|
||||
"set-variable actions with values that don't fit the variable were skipped" = "åtgärder för att ange variabel med värden som inte passar variabeln hoppades över"
|
||||
"set-variable actions without a value were skipped" = "åtgärder för att ange variabel utan värde hoppades över"
|
||||
"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "åtgärder för att ange variabelläge på samlingar eller lägen som inte finns i variabel-JSON:en hoppades över"
|
||||
"slot content assignment target is missing or is not slot content" = "målet för tilldelningen av fackinnehåll saknas eller är inte fackinnehåll"
|
||||
"slot content assignment uses the empty GUID sentinel and was skipped" = "tilldelningen av fackinnehåll använder det tomma GUID:t som markörvärde och hoppades över"
|
||||
"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "vissa vektorpunkter hänvisar till punktinställningar som saknas i filen; de använder lagrets hörntyper och vanliga hörn"
|
||||
"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "vissa vektorsegment hänvisar till stilinställningar som saknas i filen; de använder lagrets kontur"
|
||||
"stale or invalid grid track sizing entries were ignored" = "inaktuella eller ogiltiga storleksposter för rutnätsspår ignorerades"
|
||||
"stars and polygons without point counts or geometry use the default point count" = "stjärnor och polygoner utan antal punkter eller geometri använder standardantalet punkter"
|
||||
"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "stilinställningar för vissa vektorsegment stöds inte; de segmenten använder lagrets kontur"
|
||||
"text path underline or strikethrough decoration is not rendered" = "understrykning eller genomstrykning av text på bana ritas inte"
|
||||
"text style overrides refer to missing table entries" = "åsidosättningar av textstilar hänvisar till tabellposter som saknas"
|
||||
"the file has no pages" = "filen har inga sidor"
|
||||
"the plugin" = "pluginet"
|
||||
@@ -1706,6 +1745,7 @@ name = "Svenska"
|
||||
"arrange.packH" = "Packa horisontellt"
|
||||
"arrange.packV" = "Packa vertikalt"
|
||||
"arrange.roundToPixels" = "Avrunda till pixlar"
|
||||
"arrange.spacing" = "Avstånd"
|
||||
"arrange.tidy" = "Snygga till"
|
||||
"bool.exclude" = "Uteslut markering"
|
||||
"bool.flatten" = "Platta till boolesk form"
|
||||
@@ -2015,6 +2055,10 @@ name = "Svenska"
|
||||
"select.sameStroke" = "Markera samma kontur"
|
||||
"select.sameText" = "Markera samma textegenskaper"
|
||||
"select.set" = "Markera"
|
||||
"selection.applyColorStyle" = "Använd färgformatmall på markeringen"
|
||||
"selection.colors" = "Markeringsfärger"
|
||||
"selection.selectColor" = "Markera lager med den här färgen"
|
||||
"selection.setPaint" = "Ändra markeringens färg"
|
||||
"shape.arrow" = "Pil"
|
||||
"shape.ellipse" = "Ellips"
|
||||
"shape.line" = "Linje"
|
||||
|
||||
@@ -337,6 +337,7 @@ name = "Türkçe"
|
||||
"Current version" = "Geçerli sürüm"
|
||||
"Cursor chat" = "İmleç sohbeti"
|
||||
"Custom" = "Özel"
|
||||
"Custom size" = "Özel boyut"
|
||||
"Cut" = "Kes"
|
||||
"Dark" = "Koyu"
|
||||
"Darken" = "Koyulaştır"
|
||||
@@ -642,6 +643,7 @@ name = "Türkçe"
|
||||
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Arayüz tasarımı ve prototipleme. Rust ile sıfırdan yazılmış, ücretsiz, açık kaynaklı, temiz oda yöntemiyle geliştirilmiş bir tasarım aracı; ArtCraft Crafting Apps ailesinin bir parçası."
|
||||
"Intersect" = "Kesiştir"
|
||||
"Intersects" = "Kesişir"
|
||||
"Invalid color" = "Geçersiz renk"
|
||||
"Invite link" = "Davet bağlantısı"
|
||||
"Invite link copied" = "Davet bağlantısı kopyalandı"
|
||||
"It asks permission to:" = "Şunlar için izin istiyor:"
|
||||
@@ -799,6 +801,7 @@ name = "Türkçe"
|
||||
"Nested instance" = "İç içe örnek"
|
||||
"New design file" = "Yeni tasarım dosyası"
|
||||
"Newest first" = "Önce en yeni"
|
||||
"Next frame" = "Sonraki çerçeve"
|
||||
"Next or previous control in the focused region" = "Odaklanılan bölgedeki sonraki veya önceki denetim"
|
||||
"Next ready for dev" = "Sonraki geliştirmeye hazır"
|
||||
"Next size option" = "Sonraki boyut seçeneği"
|
||||
@@ -808,6 +811,7 @@ name = "Türkçe"
|
||||
"No branches. A branch is a copy of the file to try changes in, then review and merge." = "Dal yok. Dal, değişiklikleri denemek, sonra gözden geçirip birleştirmek için dosyanın bir kopyasıdır."
|
||||
"No category" = "Kategori yok"
|
||||
"No changes" = "Değişiklik yok"
|
||||
"No color styles" = "Renk stili yok"
|
||||
"No colour variables fit here." = "Buraya uyan renk değişkeni yok."
|
||||
"No colour variables yet. Create them in Local variables." = "Henüz renk değişkeni yok. Onları Yerel değişkenler'de oluşturun."
|
||||
"No comments yet" = "Henüz yorum yok"
|
||||
@@ -815,6 +819,7 @@ name = "Türkçe"
|
||||
"No components yet. Select layers and press {keys}." = "Henüz bileşen yok. Katmanları seçip {keys} tuşlarına basın."
|
||||
"No conflicts." = "Çakışma yok."
|
||||
"No contributor data was built into this copy." = "Bu kopyaya katkıda bulunan verisi eklenmemiş."
|
||||
"No device" = "Cihaz yok"
|
||||
"No fonts match" = "Eşleşen yazı tipi yok"
|
||||
"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Burada katılma kodu yok (sunucu katılma kodlarından eski veya bu adres bir koda sığmıyor): bunun yerine bağlantıyı paylaşın."
|
||||
"No layer changes to show." = "Gösterilecek katman değişikliği yok."
|
||||
@@ -998,6 +1003,7 @@ name = "Türkçe"
|
||||
"Preview" = "Önizleme"
|
||||
"Preview this flow" = "Bu akışı önizle"
|
||||
"Preview this frame" = "Bu çerçeveyi önizle"
|
||||
"Previous frame" = "Önceki çerçeve"
|
||||
"Previous ready for dev" = "Önceki geliştirmeye hazır"
|
||||
"Progressive" = "Aşamalı"
|
||||
"Properties" = "Özellikler"
|
||||
@@ -1091,6 +1097,7 @@ name = "Türkçe"
|
||||
"Resize by the nudge amount" = "Kaydırma miktarı kadar yeniden boyutlandır"
|
||||
"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Çerçeveleri cihazın ekranına (veya cihaz yoksa pencereye) göre yeniden boyutlandır: kısıtlamaları ve otomatik yerleşimleri tepki verir"
|
||||
"Resize to fit" = "Sığacak şekilde yeniden boyutlandır"
|
||||
"Resizing" = "Yeniden boyutlandırma"
|
||||
"Resolve" = "Çöz"
|
||||
"Resolve change" = "Değişikliği çöz"
|
||||
"Resolved" = "Çözüldü"
|
||||
@@ -1164,12 +1171,14 @@ name = "Türkçe"
|
||||
"Select all with same" = "Aynı olanların tümünü seç"
|
||||
"Select layer" = "Katman seç"
|
||||
"Select layers to apply the style to" = "Stilin uygulanacağı katmanları seçin"
|
||||
"Select layers with this color" = "Bu renkteki katmanları seç"
|
||||
"Select overlapping shapes" = "Çakışan şekilleri seç"
|
||||
"Select points to edit their position, radius and mirroring" = "Konumlarını, yarıçaplarını ve yansıtmalarını düzenlemek için noktaları seçin"
|
||||
"Select slot" = "Yuvayı seç"
|
||||
"Selection" = "Seçim"
|
||||
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Seçim kutusu, {x}, {y} konumunda {w} × {h}: oklar onu taşır, Shift+oklar veya {cmd}+oklar boyutlandırır, Enter seçer, Escape iptal eder"
|
||||
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Seçim kutusu: oklar onu taşır, Shift+oklar veya {cmd}+oklar boyutlandırır, Enter seçer, Escape iptal eder"
|
||||
"Selection colors" = "Seçim renkleri"
|
||||
"Send backward" = "Geriye gönder"
|
||||
"Send report" = "Raporu gönder"
|
||||
"Send to back" = "En arkaya gönder"
|
||||
@@ -1503,13 +1512,25 @@ name = "Türkçe"
|
||||
"can edit the document" = "belgeyi düzenleyebilir"
|
||||
"conditional prototype actions nested too deeply were skipped" = "çok derin iç içe geçmiş koşullu prototip eylemleri atlandı"
|
||||
"conditional prototype actions with conditions that can't be evaluated were skipped" = "değerlendirilemeyen koşullara sahip koşullu prototip eylemleri atlandı"
|
||||
"connector label background was skipped: style data is missing" = "bağlayıcı etiketinin arka planı atlandı: stil verileri eksik"
|
||||
"connector label was skipped: background bounds are invalid" = "bağlayıcı etiketi atlandı: arka plan sınırları geçersiz"
|
||||
"connector label was skipped: background transform is invalid" = "bağlayıcı etiketi atlandı: arka plan dönüşümü geçersiz"
|
||||
"connector label was skipped: composed text transform is invalid" = "bağlayıcı etiketi atlandı: birleştirilmiş metin dönüşümü geçersiz"
|
||||
"connector label was skipped: derived background bounds are missing" = "bağlayıcı etiketi atlandı: türetilmiş arka plan sınırları eksik"
|
||||
"connector label was skipped: derived text bounds are missing" = "bağlayıcı etiketi atlandı: türetilmiş metin sınırları eksik"
|
||||
"connector label was skipped: text bounds are invalid" = "bağlayıcı etiketi atlandı: metin sınırları geçersiz"
|
||||
"connector label was skipped: text style data is missing" = "bağlayıcı etiketi atlandı: metin stili verileri eksik"
|
||||
"connector label was skipped: text transform is invalid" = "bağlayıcı etiketi atlandı: metin dönüşümü geçersiz"
|
||||
"connector labels were skipped" = "bağlayıcı etiketleri atlandı"
|
||||
"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "bağlayıcı katmanları, varsa etiketleriyle birlikte statik çerçeve/vektör anlık görüntüleri olarak içe aktarılır; uç noktaların dinamik bağlantıları korunmaz"
|
||||
"connector snapshot vector data is missing; connector was left empty" = "bağlayıcı anlık görüntüsünün vektör verileri eksik; bağlayıcı boş bırakıldı"
|
||||
"connectors whose ends can't be placed are empty" = "uçları yerleştirilemeyen bağlayıcılar boş"
|
||||
"crop marks" = "kesim işaretleri"
|
||||
"damaged vector geometry was skipped" = "hasarlı vektör geometrisi atlandı"
|
||||
"damaged vector networks import as outlines" = "hasarlı vektör ağları ana hat olarak içe aktarılır"
|
||||
"development: {path}" = "geliştirme: {path}"
|
||||
"document children other than pages were skipped" = "belgenin sayfa olmayan alt öğeleri atlandı"
|
||||
"duplicate grid track sizing entries use equal tracks" = "yinelenen ızgara izi boyutlandırma girdileri eşit izler kullanır"
|
||||
"e.g. count + 1" = "ör. count + 1"
|
||||
"e.g. count >= 3 and not done" = "ör. count >= 3 and not done"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "kaydedilmiş yolu olmayan dirsekli ve eğri bağlayıcılar düz çizgi olarak içe aktarılır"
|
||||
@@ -1518,14 +1539,20 @@ name = "Türkçe"
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "kendi dolgusu olan vektör dolgu alanları katmanın dolgusunu kullanır"
|
||||
"from {server}" = "kaynak: {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "tam olarak bir bilinen kumanda düğmesi olmayan oyun kumandası tetikleyicileri atlandı"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "ızgara izi listesi 1000 girdiyi aşıyor; izler varsayılan eşit boyutlandırmayı kullanır"
|
||||
"grid track sizing is unsupported or malformed; affected tracks use equal sizing" = "ızgara izi boyutlandırması desteklenmiyor veya bozuk; etkilenen izler eşit boyutlandırma kullanır"
|
||||
"grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "ızgara izi boyutlandırma listesi 1000 girdiyi aşıyor; izler varsayılan eşit boyutlandırmayı kullanır"
|
||||
"hidden" = "gizli"
|
||||
"iOS" = "iOS"
|
||||
"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "iki filtre kapsayıcısı da bulunduğu için görüntü filtreleri belirsiz; filtre dönüştürme atlandı"
|
||||
"image filters use FigCraft's approximate renderer" = "görüntü filtreleri FigCraft'ın yaklaşık işlemesini kullanır"
|
||||
"instance swap properties whose default component isn't in the file were skipped" = "varsayılan bileşeni dosyada olmayan örnek değiştirme özellikleri atlandı"
|
||||
"instances inside their own main component were imported as frames" = "kendi ana bileşeninin içindeki örnekler çerçeve olarak içe aktarıldı"
|
||||
"instances of components that aren't in the file show no layers" = "dosyada olmayan bileşenlerin örnekleri katman göstermez"
|
||||
"instances of components that aren't in the file were imported as frames" = "dosyada olmayan bileşenlerin örnekleri çerçeve olarak içe aktarıldı"
|
||||
"instances that would nest a component inside itself were imported as frames" = "bir bileşeni kendi içine yerleştirecek örnekler çerçeve olarak içe aktarıldı"
|
||||
"instances without a main component were imported as empty frames" = "ana bileşeni olmayan örnekler boş çerçeve olarak içe aktarıldı"
|
||||
"invalid internal .fig text path data was ignored" = "yol üzerindeki metne ait geçersiz dahili .fig verileri yok sayıldı"
|
||||
"invalid or oversized vector networks import as outlines" = "geçersiz veya çok büyük vektör ağları ana hat olarak içe aktarılır"
|
||||
"invalid or oversized vector path data was skipped" = "geçersiz veya çok büyük vektör yolu verileri atlandı"
|
||||
"just now" = "az önce"
|
||||
@@ -1533,12 +1560,18 @@ name = "Türkçe"
|
||||
"key up triggers import as key presses" = "tuş bırakma tetikleyicileri tuşa basma olarak içe aktarılır"
|
||||
"layers without a position were placed at their parent's origin" = "konumu olmayan katmanlar üst öğelerinin başlangıç noktasına yerleştirildi"
|
||||
"layers without a type were skipped" = "türü olmayan katmanlar atlandı"
|
||||
"legacy image filter container is unsupported; filter conversion was skipped" = "eski görüntü filtresi kapsayıcısı desteklenmiyor; filtre dönüştürme atlandı"
|
||||
"locked" = "kilitli"
|
||||
"malformed image filters container was skipped" = "bozuk görüntü filtreleri kapsayıcısı atlandı"
|
||||
"malformed image paintFilter container was skipped" = "bozuk görüntü paintFilter kapsayıcısı atlandı"
|
||||
"malformed slot content assignment was skipped" = "bozuk yuva içeriği ataması atlandı"
|
||||
"no bleed" = "taşma payı yok"
|
||||
"nobody else here yet" = "burada henüz başka kimse yok"
|
||||
"non-default slot settings are not represented and were skipped" = "varsayılan dışındaki yuva ayarları aktarılamıyor ve atlandı"
|
||||
"now" = "şimdi"
|
||||
"or" = "veya"
|
||||
"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "nokta başına kontur birleşimleri korundu; kesikli veya değişken genişlikli konturlar katmanın birleşimiyle çizilir"
|
||||
"preferred slot component keys cannot be resolved to file nodes and were skipped" = "yuvaların tercih edilen bileşen anahtarları dosyadaki katmanlarla eşleştirilemedi ve atlandı"
|
||||
"prototype actions of unknown types were skipped" = "bilinmeyen türdeki prototip eylemleri atlandı"
|
||||
"prototype actions without a destination in the file were skipped" = "dosyada hedefi olmayan prototip eylemleri atlandı"
|
||||
"prototype expressions on variables that aren't in the variables JSON were skipped" = "değişken JSON'unda olmayan değişkenler üzerindeki prototip ifadeleri atlandı"
|
||||
@@ -1556,8 +1589,14 @@ name = "Türkçe"
|
||||
"set-variable actions with values that don't fit the variable were skipped" = "değişkene uymayan değerlere sahip değişken ayarlama eylemleri atlandı"
|
||||
"set-variable actions without a value were skipped" = "değeri olmayan değişken ayarlama eylemleri atlandı"
|
||||
"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "değişken JSON'unda olmayan koleksiyonlar veya modlar üzerindeki değişken modu ayarlama eylemleri atlandı"
|
||||
"slot content assignment target is missing or is not slot content" = "yuva içeriği atamasının hedefi eksik veya yuva içeriği değil"
|
||||
"slot content assignment uses the empty GUID sentinel and was skipped" = "yuva içeriği ataması boş GUID'i işaretçi olarak kullanıyor ve atlandı"
|
||||
"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "bazı vektör noktaları dosyada eksik olan nokta ayarlarına başvuruyor; bu noktalar katmanın birleşimlerini ve düz köşeleri kullanır"
|
||||
"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "bazı vektör segmentleri dosyada eksik olan stil ayarlarına başvuruyor; bu segmentler katmanın konturunu kullanır"
|
||||
"stale or invalid grid track sizing entries were ignored" = "eski veya geçersiz ızgara izi boyutlandırma girdileri yok sayıldı"
|
||||
"stars and polygons without point counts or geometry use the default point count" = "nokta sayısı veya geometrisi olmayan yıldızlar ve çokgenler varsayılan nokta sayısını kullanır"
|
||||
"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "bazı vektör segmentlerinin stil ayarları desteklenmiyor; bu segmentler katmanın konturunu kullanır"
|
||||
"text path underline or strikethrough decoration is not rendered" = "yol üzerindeki metinde altı çizili veya üstü çizili süsleme çizilmez"
|
||||
"text style overrides refer to missing table entries" = "metin stili geçersiz kılmaları eksik tablo girdilerine başvuruyor"
|
||||
"the file has no pages" = "dosyada sayfa yok"
|
||||
"the plugin" = "eklenti"
|
||||
@@ -1688,6 +1727,7 @@ name = "Türkçe"
|
||||
"arrange.packH" = "Yatay sıkıştır"
|
||||
"arrange.packV" = "Dikey sıkıştır"
|
||||
"arrange.roundToPixels" = "Piksellere yuvarla"
|
||||
"arrange.spacing" = "Aralık"
|
||||
"arrange.tidy" = "Toparla"
|
||||
"bool.exclude" = "Seçimi hariç tut"
|
||||
"bool.flatten" = "Boolean'ı düzleştir"
|
||||
@@ -1995,6 +2035,10 @@ name = "Türkçe"
|
||||
"select.sameStroke" = "Aynı konturu seç"
|
||||
"select.sameText" = "Aynı metin özelliklerini seç"
|
||||
"select.set" = "Seç"
|
||||
"selection.applyColorStyle" = "Seçime renk stili uygula"
|
||||
"selection.colors" = "Seçim renkleri"
|
||||
"selection.selectColor" = "Bu renkteki katmanları seç"
|
||||
"selection.setPaint" = "Seçimin rengini değiştir"
|
||||
"shape.arrow" = "Ok"
|
||||
"shape.ellipse" = "Elips"
|
||||
"shape.line" = "Çizgi"
|
||||
|
||||
@@ -329,6 +329,7 @@ status = "draft — needs native review"
|
||||
"Current version" = "当前版本"
|
||||
"Cursor chat" = "光标聊天"
|
||||
"Custom" = "自定义"
|
||||
"Custom size" = "自定义大小"
|
||||
"Cut" = "剪切"
|
||||
"Dark" = "深色"
|
||||
"Darken" = "变暗"
|
||||
@@ -620,6 +621,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." = "界面设计与原型制作。一款以 Rust 从零编写的免费、开源、净室设计工具,属于 ArtCraft Crafting Apps 系列。"
|
||||
"Intersect" = "相交"
|
||||
"Intersects" = "相交"
|
||||
"Invalid color" = "无效颜色"
|
||||
"Invite link" = "邀请链接"
|
||||
"Invite link copied" = "已复制邀请链接"
|
||||
"It asks permission to:" = "它请求以下权限:"
|
||||
@@ -765,6 +767,7 @@ status = "draft — needs native review"
|
||||
"Navigate to" = "导航到"
|
||||
"Nested instance" = "嵌套实例"
|
||||
"New design file" = "新建设计文件"
|
||||
"Next frame" = "下一个画框"
|
||||
"Next or previous control in the focused region" = "聚焦区域中的下一个或上一个控件"
|
||||
"Next ready for dev" = "下一个可供开发"
|
||||
"Next size option" = "下一个尺寸选项"
|
||||
@@ -774,6 +777,7 @@ status = "draft — needs native review"
|
||||
"No branches. A branch is a copy of the file to try changes in, then review and merge." = "没有分支。分支是文件的副本,可在其中尝试更改,再审阅并合并。"
|
||||
"No category" = "无类别"
|
||||
"No changes" = "无更改"
|
||||
"No color styles" = "没有颜色样式"
|
||||
"No colour variables fit here." = "没有适用于此处的颜色变量。"
|
||||
"No colour variables yet. Create them in Local variables." = "还没有颜色变量。请在“本地变量”中创建。"
|
||||
"No comments yet" = "还没有评论"
|
||||
@@ -781,6 +785,7 @@ status = "draft — needs native review"
|
||||
"No components yet. Select layers and press {keys}." = "还没有组件。选择图层并按 {keys}。"
|
||||
"No conflicts." = "没有冲突。"
|
||||
"No contributor data was built into this copy." = "此副本未内置贡献者数据。"
|
||||
"No device" = "无设备"
|
||||
"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 layer changes to show." = "没有可显示的图层更改。"
|
||||
@@ -955,6 +960,7 @@ status = "draft — needs native review"
|
||||
"Preview" = "预览"
|
||||
"Preview this flow" = "预览此流程"
|
||||
"Preview this frame" = "预览此画框"
|
||||
"Previous frame" = "上一个画框"
|
||||
"Previous ready for dev" = "上一个可供开发"
|
||||
"Progressive" = "渐进"
|
||||
"Properties" = "属性"
|
||||
@@ -1048,6 +1054,7 @@ status = "draft — needs native review"
|
||||
"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 to fit" = "调整大小以适应"
|
||||
"Resizing" = "调整大小"
|
||||
"Resolve" = "解决"
|
||||
"Resolve change" = "解决更改"
|
||||
"Resolved" = "已解决"
|
||||
@@ -1119,12 +1126,14 @@ status = "draft — needs native review"
|
||||
"Select all with same" = "选择所有相同"
|
||||
"Select layer" = "选择图层"
|
||||
"Select layers to apply the style to" = "选择要应用样式的图层"
|
||||
"Select layers with this color" = "选择使用此颜色的图层"
|
||||
"Select overlapping shapes" = "选择重叠的形状"
|
||||
"Select points to edit their position, radius and mirroring" = "选择点以编辑其位置、半径和镜像"
|
||||
"Select slot" = "选择插槽"
|
||||
"Selection" = "所选项"
|
||||
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "选框,{w} × {h},位于 {x}, {y}:方向键移动,Shift+方向键或 {cmd}+方向键调整大小,Enter 选择,Esc 取消"
|
||||
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "选框:方向键移动,Shift+方向键或 {cmd}+方向键调整大小,Enter 选择,Esc 取消"
|
||||
"Selection colors" = "所选项颜色"
|
||||
"Send backward" = "下移一层"
|
||||
"Send report" = "发送举报"
|
||||
"Send to back" = "置于底层"
|
||||
@@ -1441,13 +1450,25 @@ status = "draft — needs native review"
|
||||
"can edit the document" = "可编辑文档"
|
||||
"conditional prototype actions nested too deeply 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 was skipped: background bounds are invalid" = "已跳过连接线标签:背景边界无效"
|
||||
"connector label was skipped: background 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 text bounds are missing" = "已跳过连接线标签:缺少推算的文本边界"
|
||||
"connector label was skipped: text bounds are invalid" = "已跳过连接线标签:文本边界无效"
|
||||
"connector label was skipped: text style data is missing" = "已跳过连接线标签:缺少文本样式数据"
|
||||
"connector label was skipped: text transform is invalid" = "已跳过连接线标签:文本变换无效"
|
||||
"connector labels were skipped" = "已跳过连接线标签"
|
||||
"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" = "缺少连接线快照的矢量数据;连接线保留为空"
|
||||
"connectors whose ends can't be placed are empty" = "无法放置端点的连接线为空"
|
||||
"crop marks" = "裁切标记"
|
||||
"damaged vector geometry was skipped" = "已跳过损坏的矢量几何"
|
||||
"damaged vector networks import as outlines" = "损坏的矢量网络导入为轮廓"
|
||||
"development: {path}" = "开发:{path}"
|
||||
"document children other than pages were skipped" = "已跳过非页面的文档子项"
|
||||
"duplicate grid track sizing entries use equal tracks" = "重复的网格轨道尺寸条目使用等分轨道"
|
||||
"e.g. count + 1" = "例如 count + 1"
|
||||
"e.g. count >= 3 and not done" = "例如 count >= 3 and not done"
|
||||
"elbowed and curved connectors without a saved path import as straight lines" = "没有已保存路径的折线和曲线连接线导入为直线"
|
||||
@@ -1456,26 +1477,38 @@ status = "draft — needs native review"
|
||||
"filled areas of a vector with their own fill use the layer's fill" = "具有自身填充的矢量填充区域使用图层的填充"
|
||||
"from {server}" = "来自 {server}"
|
||||
"gamepad triggers without exactly one known controller button were skipped" = "已跳过没有恰好一个已知手柄按钮的游戏手柄触发器"
|
||||
"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "网格轨道列表超过 1000 个条目;轨道使用默认的等分尺寸"
|
||||
"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" = "网格轨道尺寸列表超过 1000 个条目;轨道使用默认的等分尺寸"
|
||||
"hidden" = "已隐藏"
|
||||
"iOS" = "iOS"
|
||||
"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "两个滤镜容器同时存在,图片滤镜有歧义;已跳过滤镜转换"
|
||||
"image filters use FigCraft's approximate renderer" = "图片滤镜使用 FigCraft 的近似渲染"
|
||||
"instance swap properties whose default component isn't in the file were skipped" = "已跳过默认组件不在文件中的实例替换属性"
|
||||
"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 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" = "没有主组件的实例已导入为空画框"
|
||||
"invalid internal .fig text path data was ignored" = "已忽略无效的 .fig 内部路径文字数据"
|
||||
"invalid or oversized vector networks import as outlines" = "无效或过大的矢量网络导入为轮廓"
|
||||
"invalid or oversized vector path data was skipped" = "已跳过无效或过大的矢量路径数据"
|
||||
"key triggers without a supported key were skipped" = "已跳过没有受支持按键的按键触发器"
|
||||
"key up triggers import as key presses" = "松开按键触发器导入为按下按键"
|
||||
"layers without a position were placed at their parent's origin" = "没有位置的图层已放在其父级的原点"
|
||||
"layers without a type were skipped" = "已跳过没有类型的图层"
|
||||
"legacy image filter container is unsupported; filter conversion was skipped" = "不支持旧版图片滤镜容器;已跳过滤镜转换"
|
||||
"locked" = "已锁定"
|
||||
"malformed image filters container was skipped" = "已跳过格式错误的图片滤镜容器"
|
||||
"malformed image paintFilter container was skipped" = "已跳过格式错误的图片 paintFilter 容器"
|
||||
"malformed slot content assignment was skipped" = "已跳过格式错误的插槽内容分配"
|
||||
"no bleed" = "无出血"
|
||||
"nobody else here yet" = "这里还没有其他人"
|
||||
"non-default slot settings are not represented and were skipped" = "非默认的插槽设置无法表示,已跳过"
|
||||
"now" = "刚刚"
|
||||
"or" = "或"
|
||||
"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" = "插槽的首选组件键无法对应到文件中的图层,已跳过"
|
||||
"prototype actions of unknown types 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" = "已跳过使用变量 JSON 中没有的变量的原型表达式"
|
||||
@@ -1493,8 +1526,14 @@ status = "draft — needs native review"
|
||||
"set-variable actions with values that don't fit the variable 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" = "已跳过针对变量 JSON 中没有的集合或模式的设置变量模式操作"
|
||||
"slot content assignment target is missing or is not slot content" = "插槽内容分配的目标缺失或不是插槽内容"
|
||||
"slot content assignment uses the empty GUID sentinel and was skipped" = "插槽内容分配使用空 GUID 作为哨兵值,已跳过"
|
||||
"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" = "部分矢量段引用了文件中缺失的样式设置;这些段使用图层的描边"
|
||||
"stale or invalid grid track sizing entries were ignored" = "已忽略过时或无效的网格轨道尺寸条目"
|
||||
"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" = "部分矢量段的样式设置不受支持;这些段使用图层的描边"
|
||||
"text path underline or strikethrough decoration is not rendered" = "不绘制路径文字的下划线或删除线"
|
||||
"text style overrides refer to missing table entries" = "文本样式覆盖引用了缺失的表条目"
|
||||
"the file has no pages" = "文件没有页面"
|
||||
"the plugin" = "插件"
|
||||
@@ -1623,6 +1662,7 @@ status = "draft — needs native review"
|
||||
"arrange.packH" = "水平紧凑排列"
|
||||
"arrange.packV" = "垂直紧凑排列"
|
||||
"arrange.roundToPixels" = "取整到像素"
|
||||
"arrange.spacing" = "间距"
|
||||
"arrange.tidy" = "整理"
|
||||
"bool.exclude" = "排除所选项"
|
||||
"bool.flatten" = "拼合布尔组"
|
||||
@@ -1926,6 +1966,10 @@ status = "draft — needs native review"
|
||||
"select.sameStroke" = "选择相同描边"
|
||||
"select.sameText" = "选择相同文本属性"
|
||||
"select.set" = "选择"
|
||||
"selection.applyColorStyle" = "将颜色样式应用于所选项"
|
||||
"selection.colors" = "所选项颜色"
|
||||
"selection.selectColor" = "选择使用此颜色的图层"
|
||||
"selection.setPaint" = "更改所选项颜色"
|
||||
"shape.arrow" = "箭头"
|
||||
"shape.ellipse" = "椭圆"
|
||||
"shape.line" = "直线"
|
||||
|
||||
@@ -942,10 +942,11 @@ pub fn menu_item(resp: &Response, label: &str, trail: &str, checked: Option<bool
|
||||
}
|
||||
|
||||
/// A menu item opening a submenu.
|
||||
pub fn submenu(resp: &Response, label: &str) {
|
||||
pub fn submenu(resp: &Response, label: &str, open: bool) {
|
||||
node(resp, label, |n| {
|
||||
n.set_role(Role::MenuItem);
|
||||
n.set_has_popup(accesskit::HasPopup::Menu);
|
||||
n.set_expanded(open);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -274,8 +274,9 @@ fn word_at(text: &str, i: usize) -> (usize, usize) {
|
||||
(a, b)
|
||||
}
|
||||
|
||||
/// Faces the UI adds for scripts its own fonts lack (desktop: from the system), as egui font data.
|
||||
pub fn script_fallbacks(have: &dyn Fn(char) -> bool) -> Vec<(String, egui::FontData)> {
|
||||
/// Faces the UI adds for scripts its own fonts lack (desktop: from the system), as egui font data;
|
||||
/// `taken` as for [`crate::theme::system_fallbacks`].
|
||||
pub fn script_fallbacks(have: &dyn Fn(char) -> bool, taken: &mut Vec<Arc<figcraft_text::Face>>) -> Vec<(String, egui::FontData)> {
|
||||
// Hebrew, Arabic letters, Arabic presentation forms (what joined names are drawn with).
|
||||
const SAMPLES: [char; 3] = ['\u{05D0}', '\u{0628}', '\u{FE91}'];
|
||||
const FAMILIES: [&str; 11] = [
|
||||
@@ -291,17 +292,7 @@ pub fn script_fallbacks(have: &dyn Fn(char) -> bool) -> Vec<(String, egui::FontD
|
||||
"FreeSans",
|
||||
"Noto Sans",
|
||||
];
|
||||
let mut out: Vec<(String, egui::FontData)> = Vec::new();
|
||||
let mut taken: Vec<Arc<figcraft_text::Face>> = Vec::new();
|
||||
for c in SAMPLES {
|
||||
if have(c) || taken.iter().any(|f| f.has_char(c)) {
|
||||
continue;
|
||||
}
|
||||
let Some(face) = figcraft_text::face_with_char(c, &FAMILIES) else { continue };
|
||||
out.push((format!("system-{}-{}", face.family, face.style), crate::theme::font_data(&face)));
|
||||
taken.push(face);
|
||||
}
|
||||
out
|
||||
crate::theme::system_fallbacks(SAMPLES, &FAMILIES, have, taken)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -237,11 +237,12 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
|
||||
// Re-project the last render onto the current view.
|
||||
let tl = app.to_screen(v.origin.0, v.origin.1);
|
||||
let zoom = app.session.ds().map(|d| d.view.zoom).unwrap_or(1.0);
|
||||
let w = (f64::from(v.width) / v.scale * zoom) as f32;
|
||||
let h = (f64::from(v.height) / v.scale * zoom) as f32;
|
||||
painter.image(tex.id(), Rect::from_min_size(tl, vec2(w, h)), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
let pixel_preview = app.session.ds().ok().and_then(|ds| ds.view.pixel_preview_scale()) == Some(v.scale);
|
||||
let image_rect = presented_image_rect(tl, v, zoom, ppp, pixel_preview);
|
||||
painter.image(tex.id(), image_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
app.session.tool.labels = overlays(app, &painter, &t);
|
||||
crate::frame_labels::rename_field(app, ui);
|
||||
// The zoom tool's area.
|
||||
if let (Some(start), Some(p)) = (app.canvas.zoom_drag, ctx.input(|i| i.pointer.hover_pos())) {
|
||||
let area = Rect::from_two_pos(start, p);
|
||||
@@ -252,6 +253,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
|
||||
}
|
||||
crate::dev_mode::draw(app, &painter);
|
||||
crate::handles::draw(app, &painter);
|
||||
crate::selection_spacing::draw(app, &painter);
|
||||
crate::comments::draw(app, &painter);
|
||||
crate::live_text::draw(app, &painter);
|
||||
crate::live::draw(app, &painter, &ctx);
|
||||
@@ -271,6 +273,22 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The image's screen rect; align native-density renders to device pixels to avoid linear filtering
|
||||
/// blending neighbouring texels. Stale and pixel-preview renders retain their original projection.
|
||||
pub(super) fn presented_image_rect(tl: Pos2, view: &figcraft_render::View, zoom: f64, ppp: f32, pixel_preview: bool) -> Rect {
|
||||
let w = (f64::from(view.width) / view.scale * zoom) as f32;
|
||||
let h = (f64::from(view.height) / view.scale * zoom) as f32;
|
||||
let native_density = !pixel_preview
|
||||
&& ppp.is_finite()
|
||||
&& ppp > 0.0
|
||||
&& zoom.is_finite()
|
||||
&& view.scale.is_finite()
|
||||
&& view.scale > 0.0
|
||||
&& (view.scale - zoom * f64::from(ppp)).abs() <= view.scale.abs().max(1.0) * 1e-9;
|
||||
let min = if native_density { pos2((tl.x * ppp).round() / ppp, (tl.y * ppp).round() / ppp) } else { tl };
|
||||
Rect::from_min_size(min, vec2(w, h))
|
||||
}
|
||||
|
||||
/// What the canvas says to screen readers: the selected layer ("Rectangle 1, Rectangle, 120 by 80,
|
||||
/// selected"), how many are selected, or the page with nothing selected.
|
||||
pub fn describe(s: &figcraft_engine::Session) -> String {
|
||||
@@ -480,6 +498,9 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool {
|
||||
let editing_text = app.session.ds().map(|d| d.text_edit.is_some()).unwrap_or(false);
|
||||
let typing_elsewhere = crate::a11y::field_focused(&ctx);
|
||||
app.canvas.space_pan = space && !editing_text && !typing_elsewhere;
|
||||
if !app.canvas.dragging && crate::selection_spacing::input(app, &ctx, hover_pos, resp.hovered(), app.canvas.space_pan) {
|
||||
return false;
|
||||
}
|
||||
// Dropped files import onto the canvas, behind a card while they do ([`crate::pending_open`]).
|
||||
let dropped = ctx.input(|i| i.raw.dropped_files.clone());
|
||||
for f in dropped {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
use super::canvas::presented_image_rect;
|
||||
use egui::{Pos2, Rect, pos2, vec2};
|
||||
use figcraft_render::View;
|
||||
|
||||
#[test]
|
||||
fn native_density_rect_aligns_origin_and_preserves_pixel_extent() {
|
||||
for ppp in [1.0_f32, 1.25, 1.5, 2.0] {
|
||||
for zoom in [0.37_f64, 0.67, 1.0, 1.3, 2.0] {
|
||||
let view = View { origin: (-31.25, 18.75), scale: zoom * f64::from(ppp), width: 375, height: 240 };
|
||||
let tl = pos2(149.5, 91.25);
|
||||
let rect = presented_image_rect(tl, &view, zoom, ppp, false);
|
||||
assert!(((rect.min.x * ppp) - (rect.min.x * ppp).round()).abs() < 1e-4, "left edge: ppp={ppp}, zoom={zoom}, rect={rect:?}");
|
||||
assert!(((rect.min.y * ppp) - (rect.min.y * ppp).round()).abs() < 1e-4, "top edge: ppp={ppp}, zoom={zoom}, rect={rect:?}");
|
||||
assert!((rect.min.x - tl.x).abs() * ppp <= 0.5001, "x shift exceeds half a device pixel: ppp={ppp}, rect={rect:?}");
|
||||
assert!((rect.min.y - tl.y).abs() * ppp <= 0.5001, "y shift exceeds half a device pixel: ppp={ppp}, rect={rect:?}");
|
||||
assert!((rect.width() * ppp - view.width as f32).abs() < 1e-3, "width changed: ppp={ppp}, rect={rect:?}");
|
||||
assert!((rect.height() * ppp - view.height as f32).abs() < 1e-3, "height changed: ppp={ppp}, rect={rect:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_pixel_preview_and_invalid_density_keep_unsnapped_origin() {
|
||||
let tl = Pos2::new(149.5, 91.25);
|
||||
let view = View { origin: (0.0, 0.0), scale: 1.95, width: 300, height: 180 };
|
||||
for (zoom, ppp, pixel_preview) in [(1.2, 1.5, false), (1.3, 1.5, true)] {
|
||||
let rect = presented_image_rect(tl, &view, zoom, ppp, pixel_preview);
|
||||
let original =
|
||||
Rect::from_min_size(tl, vec2((f64::from(view.width) / view.scale * zoom) as f32, (f64::from(view.height) / view.scale * zoom) as f32));
|
||||
assert_eq!(rect, original);
|
||||
}
|
||||
for ppp in [0.0, -1.0, f32::NAN, f32::INFINITY] {
|
||||
assert_eq!(presented_image_rect(tl, &view, 1.3, ppp, false).min, tl);
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,8 @@ const CHINESE: &[&str] = &[
|
||||
"Microsoft YaHei UI",
|
||||
"Microsoft YaHei",
|
||||
"PingFang SC",
|
||||
"Hiragino Sans GB",
|
||||
"Heiti SC",
|
||||
"Noto Sans SC",
|
||||
"Source Han Sans SC",
|
||||
"WenQuanYi Micro Hei",
|
||||
|
||||
@@ -18,6 +18,11 @@ use crate::widgets;
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum PickerTarget {
|
||||
PageBackground,
|
||||
SelectionColor {
|
||||
targets: Vec<figcraft_engine::selection_colors::PaintTarget>,
|
||||
selection: Vec<NodeId>,
|
||||
document: u64,
|
||||
},
|
||||
Paint {
|
||||
fills: bool,
|
||||
index: usize,
|
||||
@@ -72,6 +77,13 @@ pub fn range_fills(doc: &figcraft_doc::Document, id: NodeId, start: usize) -> Op
|
||||
fn current_paint(app: &FigApp, target: &PickerTarget) -> Option<Paint> {
|
||||
let doc = app.session.doc().ok()?;
|
||||
match target {
|
||||
PickerTarget::SelectionColor { targets, selection, document } => {
|
||||
let ds = app.session.ds().ok()?;
|
||||
if ds.uid != *document || ds.selection != *selection {
|
||||
return None;
|
||||
}
|
||||
targets.first()?.paint(doc)
|
||||
}
|
||||
PickerTarget::PageBackground => match &doc.node(app.session.page().ok()?)?.kind {
|
||||
NodeKind::Page { background, .. } => Some(Paint::solid(*background)),
|
||||
_ => None,
|
||||
@@ -121,6 +133,7 @@ fn write(app: &mut FigApp, target: &PickerTarget, paint: &Paint, live: bool) {
|
||||
let _ = app.session.begin_interaction("Color");
|
||||
}
|
||||
let r = match target {
|
||||
PickerTarget::SelectionColor { targets, .. } => app.session.execute("selection.setPaint", &json!({"targets": targets, "paint": paint})),
|
||||
PickerTarget::PageBackground => {
|
||||
let c = paint.solid_color().unwrap_or(Color::DEFAULT_PAGE);
|
||||
app.session.execute("page.setBackground", &json!({"color": c.to_css_hex()}))
|
||||
@@ -434,6 +447,9 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
||||
};
|
||||
let current = if stale { None } else { current_paint(app, &target) };
|
||||
let Some(mut paint) = current else {
|
||||
if matches!(target, PickerTarget::SelectionColor { .. }) {
|
||||
finish(app);
|
||||
}
|
||||
app.ui.picker = None;
|
||||
return;
|
||||
};
|
||||
@@ -500,7 +516,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
||||
image_pending = true;
|
||||
let target = target.clone();
|
||||
app.pick_open(tr("Choose image"), &["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"], move |app, p| {
|
||||
let Ok(bytes) = std::fs::read(&p) else { return };
|
||||
let Ok(bytes) = figcraft_engine::file_access::read(&p) else { return };
|
||||
let Some(hash) = app.session.doc_mut().ok().and_then(|d| figcraft_format_add(d, bytes)) else { return };
|
||||
if let Some(mut paint) = current_paint(app, &target) {
|
||||
paint.kind = Paint::image(hash, ImageScaleMode::Fill).kind;
|
||||
@@ -872,6 +888,7 @@ fn pattern_sources(app: &FigApp, target: &PickerTarget) -> Vec<(NodeId, String)>
|
||||
let Ok(doc) = app.session.doc() else { return Vec::new() };
|
||||
let painted = match target {
|
||||
PickerTarget::Paint { ids, .. } => ids.clone(),
|
||||
PickerTarget::SelectionColor { targets, .. } => targets.iter().map(|t| t.id).collect(),
|
||||
PickerTarget::TextRange { id, .. } => vec![*id],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
@@ -1129,6 +1146,54 @@ impl WithStops for Gradient {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod selection_color_tests {
|
||||
use super::*;
|
||||
use crate::NoServices;
|
||||
use figcraft_engine::{Session, selection_colors::collect};
|
||||
|
||||
fn setup() -> (FigApp, NodeId, NodeId) {
|
||||
let mut s = Session::new();
|
||||
let a = NodeId(s.execute("shape.rectangle", &json!({"width":40,"height":40,"props":{"fill":"#ff0000"}})).unwrap()["id"].as_u64().unwrap());
|
||||
let b = NodeId(s.execute("shape.rectangle", &json!({"width":40,"height":40,"props":{"fill":"#0000ff"}})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids":[a,b]})).unwrap();
|
||||
let mut app = FigApp::new(s, Box::new(NoServices));
|
||||
let targets = collect(app.session.doc().unwrap(), &[a, b])[0].targets.clone();
|
||||
crate::selection_colors::open_picker(&mut app, targets);
|
||||
(app, a, b)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_live_changes_keep_frozen_targets_and_commit_once() {
|
||||
let (mut app, a, b) = setup();
|
||||
let target = app.ui.picker.clone().unwrap();
|
||||
let before = app.session.undo_depth();
|
||||
for color in ["#0000ff", "#00ff00"] {
|
||||
write(&mut app, &target, &Paint::solid(Color::from_hex(color).unwrap()), true);
|
||||
}
|
||||
assert!(app.session.recent_colors.is_empty(), "previews must not be recorded");
|
||||
finish(&mut app);
|
||||
assert_eq!(app.session.recent_colors, vec![Color::from_hex("#00ff00").unwrap()]);
|
||||
assert_eq!(app.session.undo_depth().0, before.0 + 1);
|
||||
assert_eq!(current_paint(&app, &target).unwrap().solid_color().unwrap().to_hex(), "00FF00");
|
||||
assert_eq!(app.session.doc().unwrap().node(b).unwrap().fills[0].solid_color().unwrap().to_hex(), "0000FF");
|
||||
app.session.undo().unwrap();
|
||||
assert_eq!(app.session.doc().unwrap().node(a).unwrap().fills[0].solid_color().unwrap().to_hex(), "FF0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_does_not_follow_selection_or_document_switches() {
|
||||
let (mut app, _, _) = setup();
|
||||
let target = app.ui.picker.clone().unwrap();
|
||||
app.session.execute("select.none", &json!({})).unwrap();
|
||||
assert!(current_paint(&app, &target).is_none());
|
||||
let (mut app, _, _) = setup();
|
||||
let target = app.ui.picker.clone().unwrap();
|
||||
app.session.execute("file.new", &json!({})).unwrap();
|
||||
assert!(current_paint(&app, &target).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -213,7 +213,7 @@ fn the_presenter_shows_the_frames_comments() {
|
||||
key(&mut u, Key::C, Modifiers::SHIFT);
|
||||
assert_eq!(u.app.inspect()["presenter"]["comments"], true);
|
||||
// The frame is presented at 100% (Fit never scales up), centred under the bar.
|
||||
let screen = egui::Rect::from_min_size(pos2(0.0, 40.0), vec2(1440.0, 860.0));
|
||||
let screen = egui::Rect::from_min_size(pos2(0.0, 80.0), vec2(1440.0, 820.0));
|
||||
let frame_min = screen.center() - vec2(200.0, 150.0);
|
||||
let pin = frame_min + vec2(100.0, 100.0) + vec2(14.0, -14.0);
|
||||
click(&mut u, pin);
|
||||
|
||||
@@ -503,6 +503,7 @@ fn group_label(g: &str) -> String {
|
||||
"exports" => tr("Export settings"),
|
||||
"interactions" => tr("Interactions"),
|
||||
"size" => tr("Size"),
|
||||
"resizing" => tr("Resizing"),
|
||||
"position" => tr("Position"),
|
||||
"component" => tr("Swapped instance"),
|
||||
"props" => tr("Instance properties"),
|
||||
|
||||
Loaded 100 of 160 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user