mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-22 19:08:51 +00:00
Compare commits
129
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b7e0afd19 | ||
|
|
7fcb193f2a | ||
|
|
d8ff1441b1 | ||
|
|
018b8166b2 | ||
|
|
86e91d0b71 | ||
|
|
d1dad2e1c0 | ||
|
|
fec902b7d5 | ||
|
|
057bf9a289 | ||
|
|
f1d336249a | ||
|
|
55aa0165fd | ||
|
|
836d6f2fa6 | ||
|
|
1c3e175f66 | ||
|
|
aff3c4f18b | ||
|
|
adb23f0ff1 | ||
|
|
53c8308b66 | ||
|
|
a4c7e259ab | ||
|
|
de2915908c | ||
|
|
34b08ac8bf | ||
|
|
e9565813ac | ||
|
|
c87baa37cc | ||
|
|
911831fb89 | ||
|
|
be47f99534 | ||
|
|
0b08ea1127 | ||
|
|
61ccfe18a8 | ||
|
|
d1125130f6 | ||
|
|
ddd42b36bd | ||
|
|
6743c68203 | ||
|
|
caa3c5afce | ||
|
|
8d3a3e6171 | ||
|
|
d7a27c593e | ||
|
|
7d51d0515d | ||
|
|
13fdae3d44 | ||
|
|
ef535e49b0 | ||
|
|
e35f8eb1b7 | ||
|
|
55fbcd0d7e | ||
|
|
8629a6f586 | ||
|
|
99e5db399b | ||
|
|
247530b60e | ||
|
|
ae89635e00 | ||
|
|
633455c380 | ||
|
|
846a9f7214 | ||
|
|
1c158ed81b | ||
|
|
c3a21a6ab3 | ||
|
|
976bbb84ef | ||
|
|
78fd30ab5f | ||
|
|
4458eb8802 | ||
|
|
d1619f57bd | ||
|
|
eac260f160 | ||
|
|
a819e0a188 | ||
|
|
3a9610d480 | ||
|
|
ac606a2d43 | ||
|
|
104b80cd58 | ||
|
|
55a0712b11 | ||
|
|
bec13a4d07 | ||
|
|
f90becadab | ||
|
|
6a334fa354 | ||
|
|
2a3ea27605 | ||
|
|
d654a14720 | ||
|
|
d828e6379e | ||
|
|
e46dda1036 | ||
|
|
1768be2965 | ||
|
|
0f8e29bc9f | ||
|
|
47c2b228a5 | ||
|
|
d712f2ffd1 | ||
|
|
9b489a052e | ||
|
|
c8e93ddaaa | ||
|
|
52e3fe2967 | ||
|
|
78222e35d2 | ||
|
|
04e04f3275 | ||
|
|
67610bbba4 | ||
|
|
401af64bba | ||
|
|
27fedf0aa8 | ||
|
|
abd03d9f51 | ||
|
|
bcfaa3daef | ||
|
|
db43dc5a4a | ||
|
|
cf6bd4eeb2 | ||
|
|
e9be17427f | ||
|
|
47438dd4a2 | ||
|
|
84a86e1d84 | ||
|
|
c846f157dc | ||
|
|
1fe78aec66 | ||
|
|
1f0d9a9107 | ||
|
|
dd980c884f | ||
|
|
3f5f673ebc | ||
|
|
c7acd1a81d | ||
|
|
f7188de93d | ||
|
|
37419afe14 | ||
|
|
d574eaac1d | ||
|
|
154baa4467 | ||
|
|
1e7983a550 | ||
|
|
bc5a2b8fa7 | ||
|
|
e25c48cd56 | ||
|
|
42242e3c8b | ||
|
|
89c5d3dfe6 | ||
|
|
7fd936c8ba | ||
|
|
cbf6d2df41 | ||
|
|
cc12b8a3ec | ||
|
|
7ee2772da8 | ||
|
|
694c3114e8 | ||
|
|
a7352c1860 | ||
|
|
913634bec8 | ||
|
|
4a038a5f63 | ||
|
|
7b9ebaacbb | ||
|
|
76f0edce08 | ||
|
|
a7da760f9c | ||
|
|
f33ffb4367 | ||
|
|
e03c2a65f7 | ||
|
|
43bc7a3032 | ||
|
|
dc184083ef | ||
|
|
1c16aeae09 | ||
|
|
a672a6650f | ||
|
|
9265d554af | ||
|
|
c8b611a802 | ||
|
|
1d928a189c | ||
|
|
03bb164b5c | ||
|
|
91c80a19cc | ||
|
|
68b19422ad | ||
|
|
9a69f79861 | ||
|
|
0a01188c31 | ||
|
|
6afcfc2641 | ||
|
|
cae69a7ae8 | ||
|
|
6ce46b3532 | ||
|
|
a3c5025186 | ||
|
|
b84ee179d4 | ||
|
|
c6d6433473 | ||
|
|
825cb1e7af | ||
|
|
bb8b809d46 | ||
|
|
1e0641a0bd | ||
|
|
3d90d8edfe |
No files matched your search
@@ -0,0 +1,32 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Maintain Rust / Cargo dependencies
|
||||
- package-ecosystem: "cargo"
|
||||
directories:
|
||||
- "/"
|
||||
- "/plugins"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
# Consolidate updates into a single PR rather than one PR per crate
|
||||
groups:
|
||||
cargo-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
# Maintain GitHub Actions workflows
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "ci(deps)"
|
||||
Generated
+27
-21
@@ -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",
|
||||
@@ -1435,7 +1440,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-cli"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-engine",
|
||||
@@ -1449,7 +1454,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-collab"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-color",
|
||||
@@ -1464,7 +1469,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-collab-net"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-collab",
|
||||
@@ -1487,7 +1492,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-color"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1496,7 +1501,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-doc"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-geom",
|
||||
@@ -1508,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",
|
||||
@@ -1535,7 +1541,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-format"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-pathops",
|
||||
@@ -1550,7 +1556,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-geom"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1558,7 +1564,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-layout"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1566,7 +1572,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-mcp"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-collab-net",
|
||||
@@ -1582,7 +1588,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-pathops"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1591,7 +1597,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-pdf"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-doc",
|
||||
@@ -1608,7 +1614,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-plugin"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"serde",
|
||||
@@ -1621,7 +1627,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-render"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"figcraft-color",
|
||||
@@ -1639,7 +1645,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-server"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"ctrlc",
|
||||
@@ -1663,7 +1669,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-svg"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-color",
|
||||
@@ -1679,7 +1685,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-text"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1693,7 +1699,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-ui-egui"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"egui",
|
||||
@@ -1718,7 +1724,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-web"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5928,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"
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<h1 align="center">FigCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
<b>Interface design; an open-source, clean-room reimplementation of Figma, rebuilt in pure Rust.</b><br>
|
||||
<b>Interface design and prototyping: open source, collaborative and built in pure Rust.</b><br>
|
||||
Frames, auto layout, components and variants, vector networks, styles, variables and prototypes,<br>
|
||||
in a native app that opens instantly and works offline. Open source, and yours.
|
||||
</p>
|
||||
@@ -62,7 +62,7 @@
|
||||
<tr>
|
||||
<td width="25%" valign="top">
|
||||
<h3>🎛️ Familiar by design</h3>
|
||||
The toolbar, layers panel, Design panel, menus and shortcuts sit where your hands expect them. If you know Figma, you already know FigCraft.
|
||||
The toolbar, layers panel, Design panel, menus and shortcuts sit right where your hands expect them.
|
||||
</td>
|
||||
<td width="25%" valign="top">
|
||||
<h3>⚡ Native and fast</h3>
|
||||
@@ -262,7 +262,7 @@ New contributors and AI agents: start with [`AGENTS.md`](AGENTS.md), then [`docs
|
||||
Installers for macOS (universal, signed and notarized), Windows (x64, x86 and arm64), Linux (AppImage, `.deb`, `.rpm`, Flatpak and tarball), FreeBSD and the web are attached to each [GitHub release](https://github.com/storytold/figcraft/releases). Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Status:** FigCraft is in **alpha**: about 49% of the way to replacing Figma for real work, by our own estimate. Nearly all of Figma's main menu is wired ([`docs/parity-checklist.md`](docs/parity-checklist.md)) and the core editor works end to end, but FigCraft opens `.fig` files only best effort, has only a first preview of multiplayer (live sessions hosted from the app or a self-hosted server, with per-person undo and share links to edit or to view; no accounts, teams or cloud yet), runs its own WebAssembly plugins rather than Figma's, and hasn't been checked against real Figma documents. The honest, area-by-area picture is in the [roadmap](ROADMAP.md) and [`docs/target-app-parity.md`](docs/target-app-parity.md). Expect rough edges and please file issues, or tell us what broke on [Discord](https://discord.gg/artcraft).
|
||||
> **Status:** FigCraft is in **alpha**: about 49% of the way to replacing proprietary tools for real work, by our own estimate. Nearly all tracked interface design actions in the menu checklist are wired ([`docs/parity-checklist.md`](docs/parity-checklist.md)) and the core editor works end to end, but FigCraft opens `.fig` files only best effort, has only a first preview of multiplayer (live sessions hosted from the app or a self-hosted server, with per-person undo and share links to edit or to view; no accounts, teams or cloud yet), runs its own WebAssembly plugins, and hasn't been checked against real design documents. The honest, area-by-area picture is in the [roadmap](ROADMAP.md) and [`docs/target-app-parity.md`](docs/target-app-parity.md). Expect rough edges and please file issues, or tell us what broke on [Discord](https://discord.gg/artcraft).
|
||||
|
||||
## Privacy and online sharing
|
||||
|
||||
@@ -348,8 +348,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
|
||||
part of this repository and FigCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
|
||||
Forks and modified versions must remove them.
|
||||
|
||||
<sub>Figma is a trademark of Figma, Inc. FigCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Figma, Inc.; the name is used only to describe the workflows FigCraft is compatible with.</sub>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="https://raw.githubusercontent.com/storytold/vectorcraft/main/docs/brand/artcraft-mark.png" width="28"></a><br>
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
|
||||
+14
-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 (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)
|
||||
> **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
|
||||
|
||||
@@ -316,6 +316,7 @@ 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. |
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
//! [--automation-write-root <dir>]… [files…] [figcraft://join?…]`. An invite link
|
||||
//! (what the system passes when a `figcraft://` link is opened) joins that session once the window
|
||||
//! is up; everything else that isn't a flag is a file to open, as a path or a `file://` URL (the
|
||||
//! Linux desktop entry passes URLs, `%U`, so that it can also hand over invite links).
|
||||
//! Linux desktop entry passes URLs, `%U`, so that it can also hand over invite links). Any other
|
||||
//! `--flag` is unknown and stops the app at startup (#836): a mistyped root flag must never leave
|
||||
//! the app running unconfined. After a bare `--`, every argument is a file.
|
||||
|
||||
/// What the app was started with.
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
@@ -17,6 +19,8 @@ pub struct Args {
|
||||
/// which is refused at startup.
|
||||
pub read_roots: Vec<String>,
|
||||
pub write_roots: Vec<String>,
|
||||
/// `--flags` the app doesn't know, as given.
|
||||
pub unknown: Vec<String>,
|
||||
}
|
||||
|
||||
/// An invite link rather than a file (`figcraft:` links, in any case).
|
||||
@@ -55,6 +59,10 @@ pub fn parse(args: &[String]) -> Args {
|
||||
let mut out = Args::default();
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
if a == "--" {
|
||||
out.files.extend(it.by_ref().cloned());
|
||||
break;
|
||||
}
|
||||
let (flag, inline) = a.split_once('=').map_or((a.as_str(), None), |(f, v)| (f, Some(v.to_string())));
|
||||
let roots = match flag {
|
||||
"--automation-read-root" => Some(&mut out.read_roots),
|
||||
@@ -69,7 +77,9 @@ pub fn parse(args: &[String]) -> Args {
|
||||
out.join = Some(a.clone());
|
||||
} else if let Some(path) = file_url_path(a) {
|
||||
out.files.push(path);
|
||||
} else if !a.starts_with("--") {
|
||||
} else if a.starts_with("--") {
|
||||
out.unknown.push(a.clone());
|
||||
} else {
|
||||
out.files.push(a.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ fn args(a: &[&str]) -> Vec<String> {
|
||||
#[test]
|
||||
fn files_and_the_control_port() {
|
||||
assert_eq!(parse(&args(&[])), Args::default());
|
||||
let a = parse(&args(&["--control", "4242", "a.figcraft", "--verbose", "b.svg"]));
|
||||
let a = parse(&args(&["--control", "4242", "a.figcraft", "b.svg"]));
|
||||
assert_eq!(a, Args { control: Some(4242), files: args(&["a.figcraft", "b.svg"]), ..Args::default() });
|
||||
// A missing or bad port is ignored, not taken as a file.
|
||||
assert_eq!(parse(&args(&["--control"])), Args::default());
|
||||
@@ -26,6 +26,19 @@ fn automation_roots_repeat_and_are_never_files() {
|
||||
assert_eq!(parse(&args(&["--automation-write-root"])).write_roots, args(&[""]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_flags_are_refused_never_ignored() {
|
||||
// #836: a mistyped root flag (or one an older release lacks) must stop the app, not start it
|
||||
// unconfined. Both forms, with and without `=`.
|
||||
let a = parse(&args(&["--automation-write-roots", "out", "--automation-write-dir=out", "--verbose", "a.figcraft"]));
|
||||
assert_eq!(a.unknown, args(&["--automation-write-roots", "--automation-write-dir=out", "--verbose"]));
|
||||
assert!(a.write_roots.is_empty());
|
||||
// Single-dash arguments are left as they were (files); after `--` everything is a file.
|
||||
let a = parse(&args(&["--control", "1", "--", "--odd.figcraft", "--automation-write-root=x"]));
|
||||
assert_eq!(a.files, args(&["--odd.figcraft", "--automation-write-root=x"]));
|
||||
assert!(a.unknown.is_empty() && a.write_roots.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invite_links_join_instead_of_opening() {
|
||||
let link = "figcraft://join?host=192.168.1.20:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv";
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
//! `--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.
|
||||
//! the command line are not. A root that isn't an existing folder stops the app at startup, and so
|
||||
//! does any flag the app doesn't know (a mistyped root flag must not start the app unconfined).
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
|
||||
@@ -107,10 +108,13 @@ impl eframe::App for App {
|
||||
}
|
||||
|
||||
fn on_exit(&mut self) {
|
||||
with_app(|fig| services::save_state(fig));
|
||||
if let Some(r) = recovery::lock(&self.recovery).take() {
|
||||
r.finish();
|
||||
}
|
||||
// With the app out of reach (busy), its recovery copies stay for the next start.
|
||||
let copies = recovery::lock(&self.recovery).take();
|
||||
with_app(|fig| services::shutdown(fig, services::config_dir().as_deref(), copies));
|
||||
// Everything is kept: end before the windows' teardown, where AppKit's Touch Bar support
|
||||
// throws on the content view AccessKit restores (craft-libs#3, #781).
|
||||
#[cfg(target_os = "macos")]
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +130,12 @@ fn main() -> eframe::Result {
|
||||
return Ok(());
|
||||
}
|
||||
let args = args::parse(&args);
|
||||
if let Some(flag) = args.unknown.first() {
|
||||
eprintln!(
|
||||
"FigCraft: unknown option {flag}\nUsage: figcraft [--control PORT] [--automation-read-root DIR]... [--automation-write-root DIR]... [FILE | figcraft://join?...]..."
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
let roots = match (
|
||||
args.read_roots.is_empty() && args.write_roots.is_empty(),
|
||||
figcraft_engine::file_access::Roots::new(&args.read_roots, &args.write_roots),
|
||||
@@ -161,6 +171,7 @@ fn main() -> eframe::Result {
|
||||
log::error!("{f}: {e}");
|
||||
}
|
||||
}
|
||||
let opened: Vec<u64> = session.docs.iter().map(|d| d.uid).collect();
|
||||
// Documents a crashed or killed run left unsaved come back; this run keeps its own copies.
|
||||
let recovery_root = services::config_dir().map(|d| d.join("Recovery"));
|
||||
// The preferences first: they hold the online files a recovered document may be one of.
|
||||
@@ -194,6 +205,8 @@ fn main() -> eframe::Result {
|
||||
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);
|
||||
|
||||
@@ -120,8 +120,7 @@ impl Recovery {
|
||||
(0..10).find_map(|n| {
|
||||
let dir = root.join(if n == 0 { pid.to_string() } else { format!("{pid}-{n}") });
|
||||
std::fs::create_dir_all(&dir).ok()?;
|
||||
let lock = File::options().write(true).create(true).truncate(false).open(dir.join(LOCK)).ok()?;
|
||||
lock.try_lock().ok()?;
|
||||
let lock = lock_folder(&dir).ok()?;
|
||||
Some(Self { dir, _lock: lock, written: HashMap::new(), next: 0.0, waiting: Vec::new() })
|
||||
})
|
||||
}
|
||||
@@ -250,6 +249,17 @@ impl Recovery {
|
||||
self.written.insert(uid, Copy { done: None, flight: Some((vec![revision], writer)) });
|
||||
}
|
||||
|
||||
/// Quitting: a normal quit ([`finish`](Self::finish)) when the unsaved-changes question was
|
||||
/// `asked` or nothing is unsaved. Otherwise (macOS's Quit menu ends the app without asking) the
|
||||
/// unsaved documents are copied and the copies stay for the next start, as after a crash.
|
||||
pub fn quit(mut self, s: &Session, asked: bool) {
|
||||
if asked || !s.docs.iter().any(|d| needs_copy(s, d)) {
|
||||
return self.finish();
|
||||
}
|
||||
self.write(s, true);
|
||||
self.settle(s);
|
||||
}
|
||||
|
||||
/// A normal quit: the documents were saved or their changes discarded on purpose. (Earlier
|
||||
/// runs' copies still waiting for their outcome stay for the next start.)
|
||||
pub fn finish(self) {
|
||||
@@ -282,6 +292,17 @@ fn remove_kept(w: Waiting) {
|
||||
/// The extension of the note beside the copy of a document that is one of your online files.
|
||||
const ONLINE: &str = "online";
|
||||
|
||||
/// How long the startup waits, once, for folder locks that may free themselves (#334).
|
||||
#[cfg(unix)]
|
||||
const LOCK_WAIT: std::time::Duration = std::time::Duration::from_millis(100);
|
||||
|
||||
/// Lock the folder `dir` (`WouldBlock` while another process holds it).
|
||||
fn lock_folder(dir: &Path) -> Result<File, std::fs::TryLockError> {
|
||||
let lock = File::options().write(true).create(true).truncate(false).open(dir.join(LOCK)).map_err(std::fs::TryLockError::Error)?;
|
||||
lock.try_lock()?;
|
||||
Ok(lock)
|
||||
}
|
||||
|
||||
/// The copy at `file` is of one of your online files: join that file again as its owner, keeping
|
||||
/// the copy to compare with what arrives. The session, or `None` when the copy isn't one (or its
|
||||
/// file isn't recorded any more, or is open already): then it opens as any other copy.
|
||||
@@ -351,6 +372,7 @@ pub fn recover_all(root: &Path, s: &mut Session) -> Recovered {
|
||||
}
|
||||
}
|
||||
};
|
||||
let (mut free, mut held) = (Vec::new(), Vec::new());
|
||||
for e in entries.flatten() {
|
||||
let path = e.path();
|
||||
if !path.is_dir() {
|
||||
@@ -358,10 +380,22 @@ pub fn recover_all(root: &Path, s: &mut Session) -> Recovered {
|
||||
continue;
|
||||
}
|
||||
// Still running (another window of the app), or not ours to take.
|
||||
let Ok(lock) = File::options().write(true).create(true).truncate(false).open(path.join(LOCK)) else { continue };
|
||||
if lock.try_lock().is_err() {
|
||||
continue;
|
||||
match lock_folder(&path) {
|
||||
Ok(lock) => free.push((path, lock)),
|
||||
Err(std::fs::TryLockError::WouldBlock) => held.push(path),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
// A child between its `fork` and its `exec` shares the run's open descriptors, and a `flock`
|
||||
// lasts while any of them is open, so an ended run's folder can look held for a moment. Wait
|
||||
// once, however many folders are held, and try those again (#334). Windows doesn't pass the
|
||||
// lock to child processes.
|
||||
#[cfg(unix)]
|
||||
if !held.is_empty() {
|
||||
std::thread::sleep(LOCK_WAIT);
|
||||
free.extend(held.into_iter().filter_map(|path| lock_folder(&path).ok().map(|lock| (path, lock))));
|
||||
}
|
||||
for (path, lock) in free {
|
||||
let lock = Arc::new(lock);
|
||||
let files: Vec<PathBuf> = std::fs::read_dir(&path).into_iter().flatten().flatten().map(|f| f.path()).collect();
|
||||
for f in &files {
|
||||
|
||||
@@ -167,6 +167,23 @@ fn a_normal_quit_removes_the_recovered_copies_it_took_over() {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quitting_without_the_unsaved_question_keeps_copies_for_the_next_start() {
|
||||
let root = root("quitunasked");
|
||||
// macOS's Quit menu: nothing asked, so the unsaved document comes back next time.
|
||||
let s = edited();
|
||||
Recovery::start(&root).unwrap().quit(&s, false);
|
||||
let mut next = Session::empty();
|
||||
assert_eq!(recover(&root, &mut next), 1);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
// Asked (saved or discarded on purpose), or nothing unsaved: nothing is left.
|
||||
for (s, asked) in [(edited(), true), (Session::new(), false)] {
|
||||
Recovery::start(&root).unwrap().quit(&s, asked);
|
||||
assert!(std::fs::read_dir(&root).unwrap().next().is_none(), "asked: {asked}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_whose_folder_name_is_still_held_takes_another() {
|
||||
let root = root("pidreuse");
|
||||
@@ -186,6 +203,29 @@ fn a_run_whose_folder_name_is_still_held_takes_another() {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// #334: a lock that frees itself is not a run that is still going. A child between its `fork` and
|
||||
/// its `exec` inherits the run's open descriptors and a `flock` lasts while any descriptor for the
|
||||
/// file is open, so a crashed run's copies must still come back once that child has exec'd.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_lock_freed_a_moment_later_is_not_a_run_that_is_still_going() {
|
||||
let root = root("fork-window");
|
||||
crashed(&edited(), &root);
|
||||
// Hold the crashed run's folder lock on a second descriptor, the way a child forked before its
|
||||
// exec holds it, and free it shortly after.
|
||||
let lock = std::fs::File::options().write(true).create(true).truncate(false).open(root.join("crashed").join("lock")).unwrap();
|
||||
lock.try_lock().unwrap();
|
||||
let freed = std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
drop(lock);
|
||||
});
|
||||
// The next start waits for that lock rather than reading the run as still going.
|
||||
let mut next = Session::empty();
|
||||
assert_eq!(recover(&root, &mut next), 1, "the copies come back once the lock frees itself");
|
||||
freed.join().unwrap();
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// The rectangles in `doc`.
|
||||
fn rectangles(doc: &figcraft_engine::doc::Document) -> usize {
|
||||
doc.nodes().filter(|n| matches!(n.kind, figcraft_engine::doc::NodeKind::Rectangle)).count()
|
||||
|
||||
@@ -167,10 +167,18 @@ fn reveal(path: &Path) -> std::io::Result<()> {
|
||||
/// The preferences' file in the config folder.
|
||||
const STATE: &str = "state.json";
|
||||
|
||||
pub fn save_state(app: &FigApp) {
|
||||
if let Some(dir) = config_dir() {
|
||||
write_prefs(&dir, &app.session.prefs);
|
||||
/// Everything quitting keeps, all done when it returns (macOS then ends the process): the
|
||||
/// preferences in `dir`, this run's recovery copies ([`Recovery::quit`](crate::recovery::Recovery::quit))
|
||||
/// and the live sessions' ends (one this app hosts tells everyone and stops; a joined one leaves).
|
||||
pub fn shutdown(app: &mut FigApp, dir: Option<&Path>, recovery: Option<crate::recovery::Recovery>) {
|
||||
if let Some(dir) = dir {
|
||||
write_prefs(dir, &app.session.prefs);
|
||||
}
|
||||
// Before the sessions end: a recovered online file's copy waits for its session's outcome.
|
||||
if let Some(r) = recovery {
|
||||
r.quit(&app.session, app.ui.quit);
|
||||
}
|
||||
drop(std::mem::take(&mut app.session.live));
|
||||
}
|
||||
|
||||
/// Write `prefs` to `dir` atomically (a temporary file renamed over the old one: a crash leaves
|
||||
|
||||
@@ -82,3 +82,38 @@ fn preferences_are_readable_by_their_owner_only() {
|
||||
assert_eq!(mode(&dir.join("state.json")), 0o600);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A live link that only records the sessions it was told to leave.
|
||||
struct Leaves(Arc<Mutex<Vec<u64>>>);
|
||||
|
||||
impl figcraft_engine::live::LiveLink for Leaves {
|
||||
fn host(&mut self, _: u64, _: figcraft_engine::live::HostOptions) -> Result<figcraft_engine::live::SessionInfo, String> {
|
||||
Err("not in this test".into())
|
||||
}
|
||||
fn join(&mut self, _: u64, _: &str, _: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
fn leave(&mut self, session: u64) {
|
||||
self.0.lock().unwrap().push(session);
|
||||
}
|
||||
fn presence(&mut self, _: u64, _: &figcraft_engine::live::Presence) {}
|
||||
fn send(&mut self, _: u64, _: figcraft_engine::figcraft_collab::message::ClientMsg) {}
|
||||
fn poll(&mut self) -> Vec<(u64, figcraft_engine::live::Event)> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quitting_keeps_the_preferences_and_ends_the_live_sessions() {
|
||||
let dir = root("shutdown");
|
||||
let mut fig = start(&dir);
|
||||
let left = Arc::new(Mutex::new(Vec::new()));
|
||||
fig.session.live.install(Box::new(Leaves(left.clone())));
|
||||
let joined = fig.run("collab.join", json!({"link": "ws://127.0.0.1:9/?file=f1&token=EditSecretEditSecret00"})).unwrap();
|
||||
fig.session.prefs.user_name = "Ada".into();
|
||||
crate::services::shutdown(&mut fig, Some(&dir), None);
|
||||
assert_eq!(*left.lock().unwrap(), [joined["session"].as_u64().unwrap()], "the session was left");
|
||||
assert!(!fig.session.live.available(), "nothing is left to send on");
|
||||
assert_eq!(start(&dir).session.prefs.user_name, "Ada", "the preferences were kept");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -53,3 +53,6 @@ sync_github_name = false
|
||||
[people.krinry]
|
||||
display_name = "krinry"
|
||||
sync_github_name = true
|
||||
|
||||
[people.pechnikov2026-star]
|
||||
sync_github_name = true
|
||||
@@ -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}") }
|
||||
|
||||
@@ -87,8 +87,9 @@ macro_rules! node_props {
|
||||
($($variant:ident => $field:ident),* $(,)?) => {
|
||||
/// A node property: the tree position (`parent`, `position`) or one saved field of
|
||||
/// [`Node`]. List-valued fields (fills, text runs, vector networks…) are one property each,
|
||||
/// replaced whole (three-way merges line fills, strokes and effects up item by item, see
|
||||
/// [`merge`](crate::merge)). New properties go at the end (the binary form numbers them).
|
||||
/// replaced whole (three-way merges line the lists whose items stand on their own, such as
|
||||
/// fills, up item by item, see [`merge`](crate::merge)). New properties go at the end (the
|
||||
/// binary form numbers them).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Prop {
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
//!
|
||||
//! A conflict is the same property (or document-level entry) changed on both sides to different
|
||||
//! values, or a layer deleted on one side while the other side edited it or something inside it
|
||||
//! (including adding layers to it or moving layers into or out of it). Fills, strokes and effects
|
||||
//! merge item by item: both sides changing different items of the list (or one adding items at its
|
||||
//! end) is no conflict, only the same item changed both ways or the list shortened on a side
|
||||
//! while the other changed it. Nothing that conflicts is
|
||||
//! merged until it is resolved: each conflict is resolved for one side, and the other side's
|
||||
//! changes to it are left out. Everything else from both sides is kept.
|
||||
//! (including adding layers to it or moving layers into or out of it). The layer lists whose items
|
||||
//! stand on their own (fills, strokes, effects, layout grids, export settings, guides, prototype
|
||||
//! interactions, accessibility annotations and Dev Mode resources) merge item by item: both sides
|
||||
//! changing different items of the list (or one adding items at its end) is no conflict, only the
|
||||
//! same item changed both ways or the list shortened on a side while the other changed it. Nothing
|
||||
//! that conflicts is merged until it is resolved: each conflict is resolved for one side, and the
|
||||
//! other side's changes to it are left out. Everything else from both sides is kept.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt;
|
||||
@@ -159,8 +160,23 @@ fn by_key(changes: &[Change]) -> BTreeMap<Key, Vec<usize>> {
|
||||
out
|
||||
}
|
||||
|
||||
/// List properties whose items merge one by one.
|
||||
const PER_ITEM: &[Prop] = &[Prop::Fills, Prop::Strokes, Prop::Effects];
|
||||
/// List properties whose items merge one by one: the items stand on their own, so both sides
|
||||
/// changing different ones (or one side adding at its end) is no conflict. Sets and keyed maps
|
||||
/// (`bound`, `varModes`, `overrides`, `propRefs`, `pluginData`) are not here: their items line up
|
||||
/// by key, not by position.
|
||||
const PER_ITEM: &[Prop] = &[
|
||||
Prop::Fills,
|
||||
Prop::Strokes,
|
||||
Prop::Effects,
|
||||
Prop::Grids,
|
||||
Prop::Exports,
|
||||
Prop::Guides,
|
||||
Prop::Interactions,
|
||||
Prop::Annotations,
|
||||
// Not `CodeLinks`: there is one per language, and lining them up by position could leave two
|
||||
// for one language (the second hidden), so they merge whole.
|
||||
Prop::DevResources,
|
||||
];
|
||||
|
||||
/// Both sides' writes `b` and `m` of list property `key` merged item by item against the base
|
||||
/// (see [`merge_items`]); `None` when `key` isn't such a list or the writes conflict.
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
//! Fills, strokes and effects merge item by item: different items changed on each side merge,
|
||||
//! the same item changed both ways conflicts.
|
||||
//! The layer lists whose items stand on their own (fills, strokes, effects, layout grids, export
|
||||
//! settings, guides, prototype interactions, accessibility annotations and Dev Mode resources)
|
||||
//! merge item by item: different items changed on each side merge, the same item changed both ways
|
||||
//! conflicts.
|
||||
|
||||
use figcraft_color::{Color, Paint};
|
||||
use figcraft_doc::{Document, Effect, NodeId, NodeKind};
|
||||
use figcraft_doc::{Document, Effect, LayoutGrid, NodeId, NodeKind, proto};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::merge::{Key, Side, merge_items, plan};
|
||||
@@ -100,19 +102,113 @@ fn the_same_item_changed_both_ways_conflicts() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_lists_still_conflict_as_a_whole() {
|
||||
// Docs silent: lists other than fills, strokes and effects (export settings here) still
|
||||
// conflict whole when both sides change them.
|
||||
fn the_other_item_lists_merge_one_by_one_too() {
|
||||
// Layout grids, export settings, guides, accessibility annotations and Dev Mode resources are
|
||||
// item lists like fills: both sides changing different items merges.
|
||||
let (mut base, _, _, r) = files();
|
||||
let export = |suffix: &str| figcraft_doc::ExportSetting { suffix: suffix.into(), ..Default::default() };
|
||||
base.get_mut(r).unwrap().exports = vec![export("@1"), export("@2")];
|
||||
let guide = |vertical: bool, offset: f64| figcraft_doc::Guide { vertical, offset };
|
||||
let annot = |label: &str| figcraft_doc::Annotation { label: label.into(), properties: Vec::new(), category: None };
|
||||
let grid = |size: f64| LayoutGrid { size, ..Default::default() };
|
||||
let res = |url: &str| figcraft_doc::DevResource { url: url.into(), name: String::new() };
|
||||
let n = base.get_mut(r).unwrap();
|
||||
n.exports = vec![export("@1"), export("@2")];
|
||||
n.guides = vec![guide(true, 10.0), guide(false, 20.0)];
|
||||
n.annotations = vec![annot("a1"), annot("a2")];
|
||||
n.grids = vec![grid(10.0), grid(20.0)];
|
||||
n.dev_resources = vec![res("https://a.test"), res("https://b.test")];
|
||||
let (mut branch, mut main) = (base.clone(), base.clone());
|
||||
branch.set_client_id(base.highest_id_space() + 1);
|
||||
branch.get_mut(r).unwrap().exports[0] = export("@b");
|
||||
main.get_mut(r).unwrap().exports[1] = export("@m");
|
||||
// The branch changes the first item of each list; main the second.
|
||||
let b = branch.get_mut(r).unwrap();
|
||||
(b.exports[0].suffix, b.guides[0].offset, b.annotations[0].label, b.grids[0].size, b.dev_resources[0].url) =
|
||||
("@b".into(), 11.0, "b1".into(), 12.0, "https://branch.test".into());
|
||||
let m = main.get_mut(r).unwrap();
|
||||
(m.exports[1].suffix, m.guides[1].offset, m.annotations[1].label, m.grids[1].size, m.dev_resources[1].url) =
|
||||
("@m".into(), 22.0, "m2".into(), 24.0, "https://main.test".into());
|
||||
both_ways(&base, &branch, &main, |doc| {
|
||||
let n = doc.node(r).unwrap();
|
||||
let suffixes: Vec<&str> = n.exports.iter().map(|e| e.suffix.as_str()).collect();
|
||||
assert_eq!(suffixes, ["@b", "@m"], "export settings");
|
||||
let offsets: Vec<f64> = n.guides.iter().map(|g| g.offset).collect();
|
||||
assert_eq!(offsets, [11.0, 22.0], "guides");
|
||||
let labels: Vec<&str> = n.annotations.iter().map(|a| a.label.as_str()).collect();
|
||||
assert_eq!(labels, ["b1", "m2"], "annotations");
|
||||
let sizes: Vec<f64> = n.grids.iter().map(|g| g.size).collect();
|
||||
assert_eq!(sizes, [12.0, 24.0], "layout grids");
|
||||
let urls: Vec<&str> = n.dev_resources.iter().map(|d| d.url.as_str()).collect();
|
||||
assert_eq!(urls, ["https://branch.test", "https://main.test"], "dev resources");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_item_of_another_list_changed_both_ways_conflicts() {
|
||||
let (mut base, _, _, r) = files();
|
||||
let link = |language: &str| figcraft_doc::CodeLink { language: language.into(), ..Default::default() };
|
||||
let n = base.get_mut(r).unwrap();
|
||||
n.exports = vec![
|
||||
figcraft_doc::ExportSetting { suffix: "@1".into(), ..Default::default() },
|
||||
figcraft_doc::ExportSetting { suffix: "@2".into(), ..Default::default() },
|
||||
];
|
||||
n.code_links = vec![link("react"), link("vue")];
|
||||
let (mut branch, mut main) = (base.clone(), base.clone());
|
||||
branch.set_client_id(base.highest_id_space() + 1);
|
||||
branch.get_mut(r).unwrap().exports[0].suffix = "@b".into();
|
||||
main.get_mut(r).unwrap().exports[0].suffix = "@m".into();
|
||||
// Code links are one per language, not an item list: different links changed still conflict.
|
||||
branch.get_mut(r).unwrap().code_links[0].name = "B".into();
|
||||
main.get_mut(r).unwrap().code_links[1].name = "M".into();
|
||||
let p = plan(&base, &branch, &main).unwrap();
|
||||
let keys: Vec<String> = p.conflicts.iter().map(|c| c.key.to_string()).collect();
|
||||
assert_eq!(keys, [format!("{r}/exports")]);
|
||||
let mut keys: Vec<String> = p.conflicts.iter().map(|c| c.key.to_string()).collect();
|
||||
keys.sort();
|
||||
let mut want = [format!("{r}/codeLinks"), format!("{r}/exports")];
|
||||
want.sort();
|
||||
assert_eq!(keys, want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interactions_merge_item_by_item() {
|
||||
let ia = |duration_ms: u32| proto::Interaction {
|
||||
trigger: Default::default(),
|
||||
actions: vec![proto::Action::Back],
|
||||
transition: Default::default(),
|
||||
duration_ms,
|
||||
easing: Default::default(),
|
||||
delay_ms: 0,
|
||||
};
|
||||
let durations = |doc: &Document, r: NodeId| doc.node(r).unwrap().interactions.iter().map(|i| i.duration_ms).collect::<Vec<_>>();
|
||||
let (mut base, _, _, r) = files();
|
||||
base.get_mut(r).unwrap().interactions = vec![ia(100), ia(200)];
|
||||
let fork = || {
|
||||
let (mut branch, main) = (base.clone(), base.clone());
|
||||
branch.set_client_id(base.highest_id_space() + 1);
|
||||
(branch, main)
|
||||
};
|
||||
// The branch changes the first interaction and adds one; main changes the second.
|
||||
let (mut branch, mut main) = fork();
|
||||
branch.get_mut(r).unwrap().interactions[0].duration_ms = 150;
|
||||
branch.get_mut(r).unwrap().interactions.push(ia(300));
|
||||
main.get_mut(r).unwrap().interactions[1].duration_ms = 250;
|
||||
both_ways(&base, &branch, &main, |doc| assert_eq!(durations(doc, r), [150, 250, 300]));
|
||||
// The branch deletes one while main leaves them alone.
|
||||
let (mut branch, mut main) = fork();
|
||||
branch.get_mut(r).unwrap().interactions.remove(0);
|
||||
main.get_mut(r).unwrap().fills[0] = red();
|
||||
both_ways(&base, &branch, &main, |doc| assert_eq!(durations(doc, r), [200]));
|
||||
// The same interaction changed both ways, or one deleted while the other side changed the
|
||||
// list, conflicts.
|
||||
for delete in [false, true] {
|
||||
let (mut branch, mut main) = fork();
|
||||
if delete {
|
||||
branch.get_mut(r).unwrap().interactions.remove(0);
|
||||
} else {
|
||||
branch.get_mut(r).unwrap().interactions[1].duration_ms = 220;
|
||||
}
|
||||
main.get_mut(r).unwrap().interactions[1].duration_ms = 250;
|
||||
let p = plan(&base, &branch, &main).unwrap();
|
||||
let keys: Vec<String> = p.conflicts.iter().map(|c| c.key.to_string()).collect();
|
||||
assert_eq!(keys, [format!("{r}/interactions")], "delete: {delete}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -46,7 +46,7 @@ fn a_bound_fill_colour_keeps_the_other_fills() {
|
||||
let fills = s.doc().unwrap().node(r).unwrap().fills.clone();
|
||||
assert_eq!(fills.len(), 2, "the second fill stays");
|
||||
assert_eq!(fills[0].kind, PaintKind::Solid { color: Color::from_rgb8_const(0xFF, 0, 0) });
|
||||
assert_eq!(fills[0].opacity, 0.5, "the bound paint keeps its opacity");
|
||||
assert_eq!(fills[0].opacity, 1.0, "the variable brings the whole colour, alpha included");
|
||||
assert_eq!(fills[1].kind, PaintKind::Solid { color: Color::from_rgb8_const(0, 0xFF, 0) });
|
||||
// Removing the bound (first) fill takes the binding with it.
|
||||
ex(&mut s, "fill.remove", json!({"ids": [r], "index": 0}));
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
//! A colour variable's alpha applies once to the paint it is bound to (#802): a `#RRGGBBAA` fill keeps
|
||||
//! its alpha in the paint opacity, and binding a variable with alpha hands the variable the whole
|
||||
//! colour, so the two alphas don't multiply (canvas, SVG, `.fig` round trips, mode switches).
|
||||
|
||||
use figcraft_doc::NodeId;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// 0x80 / 255 and 0x40 / 255.
|
||||
const HALF: f32 = 128.0 / 255.0;
|
||||
const QUARTER: f32 = 64.0 / 255.0;
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: serde_json::Value) {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"));
|
||||
}
|
||||
|
||||
/// A rectangle filled `#0D6EFD80` and a colour variable `tint` (Light `light`, Dark `dark`).
|
||||
fn setup(light: &str, dark: &str) -> (Session, NodeId) {
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10, "props": {"fill": "#0D6EFD80"}})).unwrap();
|
||||
let r = NodeId(r["id"].as_u64().unwrap());
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "Tokens", "modes": ["Light", "Dark"]}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "Tokens", "name": "tint", "type": "color", "value": light}));
|
||||
ex(&mut s, "variable.set", json!({"variable": "tint", "mode": "Dark", "value": dark}));
|
||||
(s, r)
|
||||
}
|
||||
|
||||
/// The fill's effective alpha (colour alpha times paint opacity).
|
||||
fn alpha(s: &Session, r: NodeId) -> f32 {
|
||||
s.doc().unwrap().node(r).unwrap().fills[0].solid_color().unwrap().a
|
||||
}
|
||||
|
||||
/// The alpha byte of the rectangle rendered on its own (transparent backdrop).
|
||||
fn pixel_alpha(s: &Session, r: NodeId) -> u8 {
|
||||
let pix = figcraft_render::render_node(s.doc().unwrap(), r, 1.0).unwrap();
|
||||
let i = ((5 * pix.width() + 5) * 4 + 3) as usize;
|
||||
pix.data()[i]
|
||||
}
|
||||
|
||||
fn near(a: f32, b: f32) -> bool {
|
||||
(a - b).abs() < 2e-3
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hex_with_alpha_keeps_its_alpha_in_the_opacity() {
|
||||
let (s, r) = setup("#0D6EFD80", "#0D6EFD40");
|
||||
let p = &s.doc().unwrap().node(r).unwrap().fills[0];
|
||||
assert_eq!(p.kind, figcraft_color::PaintKind::Solid { color: figcraft_color::Color::from_rgb8_const(0x0D, 0x6E, 0xFD) });
|
||||
assert!(near(p.opacity, HALF));
|
||||
assert_eq!(pixel_alpha(&s, r), 128);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bound_variable_alpha_applies_once() {
|
||||
// The fill and the variable are both #0D6EFD80: the result is 50%, not 25%.
|
||||
let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40");
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
|
||||
assert!(near(alpha(&s, r), HALF), "{}", alpha(&s, r));
|
||||
assert_eq!(pixel_alpha(&s, r), 128);
|
||||
let svg = figcraft_svg::export_svg(s.doc().unwrap(), &[r], &figcraft_svg::SvgOptions::default()).unwrap();
|
||||
assert!(svg.contains("fill=\"#0D6EFD\" fill-opacity=\"0.502\""), "{svg}");
|
||||
// Binding the same variable again changes nothing.
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
|
||||
assert!(near(alpha(&s, r), HALF));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbinding_keeps_the_colour_with_its_alpha_in_the_opacity() {
|
||||
let (mut s, r) = setup("#0D6EFD40", "#0D6EFD");
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
|
||||
assert_eq!(pixel_alpha(&s, r), 64);
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": null}));
|
||||
let p = &s.doc().unwrap().node(r).unwrap().fills[0];
|
||||
assert_eq!(p.kind, figcraft_color::PaintKind::Solid { color: figcraft_color::Color::from_rgb8_const(0x0D, 0x6E, 0xFD) });
|
||||
assert!(near(p.opacity, QUARTER), "{}", p.opacity);
|
||||
assert_eq!(pixel_alpha(&s, r), 64);
|
||||
// Undo restores the binding and its paint.
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert!(!s.doc().unwrap().node(r).unwrap().bound.is_empty());
|
||||
assert_eq!(pixel_alpha(&s, r), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modes_switch_the_alpha_without_accumulating() {
|
||||
let (mut s, r) = setup("#0D6EFD40", "#0D6EFD80");
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
|
||||
for (mode, want) in [("Dark", HALF), ("Light", QUARTER), ("Dark", HALF), ("Light", QUARTER)] {
|
||||
ex(&mut s, "variable.setMode", json!({"collection": "Tokens", "mode": mode}));
|
||||
assert!(near(alpha(&s, r), want), "{mode}: {}", alpha(&s, r));
|
||||
}
|
||||
// An opacity set while bound multiplies on top and survives mode switches.
|
||||
ex(&mut s, "fill.set", json!({"ids": [r.0], "index": 0, "opacity": 0.5}));
|
||||
ex(&mut s, "variable.setMode", json!({"collection": "Tokens", "mode": "Dark"}));
|
||||
assert!(near(alpha(&s, r), HALF * 0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_set_and_style_bindings_apply_the_alpha_once() {
|
||||
let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40");
|
||||
let var = s.doc().unwrap().variables.get("tint").unwrap().id.clone();
|
||||
ex(&mut s, "node.set", json!({"id": r.0, "props": {"boundVariables": {"fill": var}}}));
|
||||
assert!(near(alpha(&s, r), HALF));
|
||||
ex(&mut s, "node.set", json!({"id": r.0, "props": {"boundVariables": {"fill": null}}}));
|
||||
assert!(near(s.doc().unwrap().node(r).unwrap().fills[0].opacity, HALF));
|
||||
// A paint style made from a `#RRGGBBAA` colour, bound to the variable.
|
||||
let st = s.execute("style.create", &json!({"type": "paint", "name": "Tint", "value": "#0D6EFD80"})).unwrap()["id"].clone();
|
||||
ex(&mut s, "style.bind", json!({"style": st, "field": "fills/0/color", "variable": "tint"}));
|
||||
ex(&mut s, "style.apply", json!({"ids": [r.0], "style": st}));
|
||||
assert!(near(alpha(&s, r), HALF), "{}", alpha(&s, r));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fig_round_trip_keeps_the_alpha_once() {
|
||||
let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40");
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
|
||||
ex(&mut s, "fill.set", json!({"ids": [r.0], "index": 0, "opacity": 0.5}));
|
||||
let (bytes, _) = figcraft_format::fig::export(s.doc().unwrap(), None).unwrap();
|
||||
let back = crate::cmd::file::load_fig(&bytes, "x").unwrap().unwrap().doc;
|
||||
let n = back.nodes().find(|n| !n.bound.is_empty()).unwrap();
|
||||
let a = n.fills[0].solid_color().unwrap().a;
|
||||
assert!(near(a, HALF * 0.5), "{a}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_gives_a_translucent_stroke_its_alpha_once() {
|
||||
let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40");
|
||||
ex(&mut s, "stroke.add", json!({"ids": [r.0], "color": "#FF000080"}));
|
||||
let code = s.execute("inspect.code", &json!({"id": r.0})).unwrap()["code"].as_str().unwrap_or_default().to_string();
|
||||
assert!(code.contains("solid rgba(255, 0, 0, 0.50)"), "{code}");
|
||||
}
|
||||
@@ -213,6 +213,8 @@ pub static MENU: &[MenuItem] = &[
|
||||
m!("Text/Case", "Uppercase", "text.caseUpper"),
|
||||
m!("Text/Case", "Lowercase", "text.caseLower"),
|
||||
m!("Text/Case", "Title case", "text.caseTitle"),
|
||||
m!("Text", SEP, ""),
|
||||
m!("Text", "Missing fonts…", "text.missingFonts"),
|
||||
// Arrange
|
||||
m!("Arrange", "Align left", "align.left"),
|
||||
m!("Arrange", "Align horizontal centers", "align.hcenter"),
|
||||
@@ -287,7 +289,7 @@ pub fn items() -> impl Iterator<Item = &'static MenuItem> {
|
||||
}
|
||||
|
||||
/// Items of FigCraft's own that the parity count leaves out (as it does the `collab.` ones).
|
||||
const OWN: [&str; 2] = ["file.newFromTemplate", "help.gettingStarted"];
|
||||
const OWN: [&str; 3] = ["file.newFromTemplate", "help.gettingStarted", "text.missingFonts"];
|
||||
|
||||
/// Coverage: (implemented, total, missing ids). `extra` lists ids implemented by the UI layer.
|
||||
pub fn parity(extra: &[&str]) -> (usize, usize, Vec<&'static str>) {
|
||||
|
||||
@@ -691,9 +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" | "resizing" | "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 {
|
||||
|
||||
@@ -73,9 +73,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("text.create", "Text", "{text, x, y, parent?, width? (fixed width wraps), props? (fontSize, fontFamily, …)}", true, |s, p| {
|
||||
create_kind(s, p, "text")
|
||||
}),
|
||||
spec!("vector.create", "Vector", "{path (SVG path data) | points [[x,y],..], closed?, x?, y?, parent?, props?}", true, |s, p| create_kind(
|
||||
s, p, "vector"
|
||||
)),
|
||||
spec!(
|
||||
"vector.create",
|
||||
"Vector",
|
||||
"{path (SVG path data) | points [[x,y],..], closed?, x?, y?, width?, height? (scale the path; one keeps its proportion), parent?, props?}",
|
||||
true,
|
||||
|s, p| create_kind(s, p, "vector")
|
||||
),
|
||||
spec!(
|
||||
"image.place",
|
||||
"Place image",
|
||||
@@ -162,6 +166,37 @@ pub fn parse_path_data(d: &str) -> Option<figcraft_geom::BezPath> {
|
||||
figcraft_geom::BezPath::from_svg(d).ok()
|
||||
}
|
||||
|
||||
/// How much a vector's path is scaled to take the size asked for: with both sides, each scales
|
||||
/// independently (as resizing a vector does); with one, the other keeps its proportion. A size on
|
||||
/// an axis the geometry has none of is an error unless it is 0 (a line's height), and a size of 0
|
||||
/// or less on an axis it has is refused rather than flattening it.
|
||||
fn vector_scale(width: Option<f64>, height: Option<f64>, bw: f64, bh: f64, what: &str) -> std::result::Result<(f64, f64), String> {
|
||||
let fit = |asked: Option<f64>, have: f64, axis: &str| match asked {
|
||||
None => Ok(1.0),
|
||||
Some(a) if have <= 1e-9 => {
|
||||
if a == 0.0 {
|
||||
Ok(1.0)
|
||||
} else {
|
||||
Err(format!("`{axis}` can't size {what} with no {} extent", if axis == "width" { "horizontal" } else { "vertical" }))
|
||||
}
|
||||
}
|
||||
Some(a) if a > 0.0 && (a / have).is_finite() => Ok(a / have),
|
||||
Some(_) => Err(format!("`{axis}` must be a positive, finite size: 0 or less would flatten {what}")),
|
||||
};
|
||||
Ok(match (width, height) {
|
||||
(Some(_), Some(_)) => (fit(width, bw, "width")?, fit(height, bh, "height")?),
|
||||
(Some(_), None) => {
|
||||
let s = fit(width, bw, "width")?;
|
||||
(s, s)
|
||||
}
|
||||
(None, Some(_)) => {
|
||||
let s = fit(height, bh, "height")?;
|
||||
(s, s)
|
||||
}
|
||||
(None, None) => (1.0, 1.0),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "node.create";
|
||||
let t = string(p, "type").ok_or_else(|| bad(cmd, "`type` is required"))?;
|
||||
@@ -187,20 +222,32 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"Section" => (num_or(p, "width", 400.0), num_or(p, "height", 300.0)),
|
||||
_ => (num_or(p, "width", 100.0), num_or(p, "height", 100.0)),
|
||||
};
|
||||
// Vectors: path data or points define the shape and its box.
|
||||
// Vectors: path data or points define the shape and its box. `width`/`height`, when asked
|
||||
// for, scale the geometry: both sides independently, one side proportionally.
|
||||
let mut offset = (0.0, 0.0);
|
||||
let asked = (num(p, "width"), num(p, "height"));
|
||||
if let NodeKind::Vector { network } = &mut kind {
|
||||
if let Some(d) = string(p, "path") {
|
||||
let mut path = parse_path_data(&d).ok_or_else(|| bad(cmd, "`path` is not valid SVG path data"))?;
|
||||
use figcraft_geom::Shape;
|
||||
let bb = path.bounding_box();
|
||||
if bb.width() <= 1e-9 && bb.height() <= 1e-9 {
|
||||
return Err(bad(cmd, "`path` draws nothing: a vector needs a visible line or area"));
|
||||
}
|
||||
path.apply_affine(figcraft_geom::Affine::translate((-bb.x0, -bb.y0)));
|
||||
let (sx, sy) = vector_scale(asked.0, asked.1, bb.width(), bb.height(), "a path").map_err(|e| bad(cmd, e))?;
|
||||
if sx != 1.0 || sy != 1.0 {
|
||||
path.apply_affine(figcraft_geom::Affine::scale_non_uniform(sx, sy));
|
||||
}
|
||||
*network = VectorNetwork::from_path(&path);
|
||||
if network.segments.is_empty() {
|
||||
return Err(bad(cmd, "`path` draws nothing: a vector needs a visible line or area"));
|
||||
}
|
||||
if boolean(p, "closed") == Some(false) {
|
||||
network.regions.clear();
|
||||
}
|
||||
w = bb.width();
|
||||
h = bb.height();
|
||||
w = bb.width() * sx;
|
||||
h = bb.height() * sy;
|
||||
offset = (bb.x0, bb.y0);
|
||||
} else if let Some(Value::Array(pts)) = get(p, "points") {
|
||||
let pts: Vec<Point> = pts.iter().filter_map(|v| Some(Point::new(f64_of(v.get(0)?)?, f64_of(v.get(1)?)?))).collect();
|
||||
@@ -208,10 +255,14 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, "`points` needs at least two [x, y] pairs"));
|
||||
}
|
||||
let bb = figcraft_geom::points_bounds(pts.iter().copied()).unwrap_or_default();
|
||||
let local: Vec<Point> = pts.iter().map(|q| Point::new(q.x - bb.x0, q.y - bb.y0)).collect();
|
||||
if bb.width() <= 1e-9 && bb.height() <= 1e-9 {
|
||||
return Err(bad(cmd, "`points` draws nothing: a vector needs a visible line or area"));
|
||||
}
|
||||
let (sx, sy) = vector_scale(asked.0, asked.1, bb.width(), bb.height(), "a point set").map_err(|e| bad(cmd, e))?;
|
||||
let local: Vec<Point> = pts.iter().map(|q| Point::new((q.x - bb.x0) * sx, (q.y - bb.y0) * sy)).collect();
|
||||
*network = VectorNetwork::from_points(&local, boolean(p, "closed").unwrap_or(false));
|
||||
w = bb.width();
|
||||
h = bb.height();
|
||||
w = bb.width() * sx;
|
||||
h = bb.height() * sy;
|
||||
offset = (bb.x0, bb.y0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1356,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)) {
|
||||
|
||||
@@ -548,6 +548,7 @@ pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option<Result<figcraft_forma
|
||||
/// Link imported instances to their mains (layer sources, overrides found by comparing values),
|
||||
/// innermost first so outer instances settle last.
|
||||
fn link_instances(doc: &mut Document) {
|
||||
super::styles::unfold_imported_bound_alpha(doc);
|
||||
crate::style_vars::after_load(doc);
|
||||
super::styles::apply_paint_bindings_on_load(doc);
|
||||
let mut insts: Vec<(usize, NodeId)> =
|
||||
|
||||
@@ -80,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());
|
||||
|
||||
@@ -775,10 +775,15 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
for (field, var) in o {
|
||||
match var.as_str() {
|
||||
Some(vid) => {
|
||||
if n.bound.get(field).map(String::as_str) != Some(vid) {
|
||||
crate::cmd::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, true);
|
||||
}
|
||||
n.bound.insert(field.clone(), vid.to_string());
|
||||
}
|
||||
None => {
|
||||
n.bound.remove(field);
|
||||
if n.bound.remove(field).is_some() {
|
||||
crate::cmd::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -458,10 +458,19 @@ fn bind_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match var {
|
||||
Some((_, kind)) if kind != want => return Err(bad(cmd, format!("`{field}` takes a {} variable, not a {} one", want.id(), kind.id()))),
|
||||
Some((id, _)) => {
|
||||
if style.bound.get(&key) != Some(&id)
|
||||
&& let StyleKind::Paint { paints } = &mut style.kind
|
||||
{
|
||||
rebind_paint_alpha(paints, &mut [], &key, true);
|
||||
}
|
||||
style.bound.insert(key, id);
|
||||
}
|
||||
None => {
|
||||
style.bound.remove(&key);
|
||||
if style.bound.remove(&key).is_some()
|
||||
&& let StyleKind::Paint { paints } = &mut style.kind
|
||||
{
|
||||
rebind_paint_alpha(paints, &mut [], &key, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The style takes its default-mode value, and each layer using it its own.
|
||||
@@ -770,16 +779,41 @@ pub fn bind(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let n = doc.get_mut(i)?;
|
||||
match &vid {
|
||||
Some(v) => {
|
||||
if n.bound.get(&field) != Some(v) {
|
||||
rebind_paint_alpha(&mut n.fills, &mut n.strokes, &field, true);
|
||||
}
|
||||
n.bound.insert(field.clone(), v.clone());
|
||||
}
|
||||
None => {
|
||||
n.bound.remove(&field);
|
||||
if n.bound.remove(&field).is_some() {
|
||||
rebind_paint_alpha(&mut n.fills, &mut n.strokes, &field, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
/// A colour variable bound to a solid paint owns its whole colour, alpha included. Binding restarts
|
||||
/// the opacity (which holds the alpha a typed colour had, see `Paint::solid`) at 1, so the
|
||||
/// variable's alpha applies once; unbinding moves the colour's alpha back into the opacity, where a
|
||||
/// typed colour keeps it. An opacity set while bound multiplies on top and stays.
|
||||
pub(crate) fn rebind_paint_alpha(fills: &mut [Paint], strokes: &mut [Paint], field: &str, bind: bool) {
|
||||
use super::lists::{BoundList, list_binding};
|
||||
let Some((list, i, "color")) = list_binding(field) else { return };
|
||||
let paints = match list {
|
||||
BoundList::Fills => fills,
|
||||
BoundList::Strokes => strokes,
|
||||
_ => return,
|
||||
};
|
||||
if let Some(p) = paints.get_mut(i)
|
||||
&& let PaintKind::Solid { color } = &mut p.kind
|
||||
{
|
||||
p.opacity = if bind { 1.0 } else { p.opacity * color.a };
|
||||
color.a = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// `variable.setMode`: choose a collection's mode on frames (the page when none are given).
|
||||
pub fn set_mode(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "variable.setMode";
|
||||
@@ -916,6 +950,49 @@ pub fn apply_bindings_to(doc: &mut Document, only: Option<&std::collections::BTr
|
||||
apply_bindings_matching(doc, only, |_| true);
|
||||
}
|
||||
|
||||
/// Imported files (`.fig`, REST) keep a solid paint's alpha in its opacity, a bound paint's too,
|
||||
/// while the bound colour brings its own alpha: take the variable's alpha back out of the opacity
|
||||
/// (styles' paints and layers') before the bindings are first resolved, so it applies once.
|
||||
pub(crate) fn unfold_imported_bound_alpha(doc: &mut Document) {
|
||||
use super::lists::{BoundList, list_binding};
|
||||
fn alphas(vars: &Variables, bound: &BTreeMap<String, VariableId>, modes: &BTreeMap<CollectionId, ModeId>) -> Vec<(BoundList, usize, f32)> {
|
||||
let found = bound.iter().filter_map(|(field, var)| match (list_binding(field)?, vars.resolve(var, modes)?) {
|
||||
((list @ (BoundList::Fills | BoundList::Strokes), i, "color"), VariableValue::Color(c)) if c.a > 0.0 && c.a < 1.0 => Some((list, i, c.a)),
|
||||
_ => None,
|
||||
});
|
||||
found.collect()
|
||||
}
|
||||
fn unfold(p: Option<&mut Paint>, a: f32) {
|
||||
if let Some(p) = p
|
||||
&& matches!(p.kind, PaintKind::Solid { .. })
|
||||
{
|
||||
p.opacity = (p.opacity / a).min(1.0);
|
||||
}
|
||||
}
|
||||
let pending = doc.take_dirty();
|
||||
for i in 0..doc.styles.len() {
|
||||
let found = doc.styles.get(i).map(|s| alphas(&doc.variables, &s.bound, &BTreeMap::new())).unwrap_or_default();
|
||||
if let Some(StyleKind::Paint { paints }) = doc.styles.get_mut(i).filter(|_| !found.is_empty()).map(|s| &mut s.kind) {
|
||||
for (_, j, a) in found {
|
||||
unfold(paints.get_mut(j), a);
|
||||
}
|
||||
}
|
||||
}
|
||||
let ids: Vec<NodeId> = doc.nodes().filter(|n| !n.bound.is_empty()).map(|n| n.id).collect();
|
||||
for id in ids {
|
||||
let found = doc.node(id).map(|n| alphas(&doc.variables, &n.bound, &doc.modes_for(id))).unwrap_or_default();
|
||||
if found.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Ok(n) = doc.get_mut(id) else { continue };
|
||||
for (list, j, a) in found {
|
||||
unfold(if list == BoundList::Fills { n.fills.get_mut(j) } else { n.strokes.get_mut(j) }, a);
|
||||
}
|
||||
}
|
||||
doc.take_dirty();
|
||||
doc.mark_dirty(pending);
|
||||
}
|
||||
|
||||
/// Settle imported paint colours before the document is first shown. Other bindings can affect
|
||||
/// layout or component structure, which must be resolved through the normal edit pipeline.
|
||||
pub(crate) fn apply_paint_bindings_on_load(doc: &mut Document) {
|
||||
@@ -1104,7 +1181,6 @@ pub fn document_colors(doc: &Document, page: NodeId) -> Vec<(Color, usize)> {
|
||||
stack.extend(n.children.iter().copied());
|
||||
for paint in n.fills.iter().chain(n.strokes.iter()).filter(|p| p.visible) {
|
||||
if let Some(c) = paint.solid_color() {
|
||||
let c = c.with_alpha(c.a * paint.opacity);
|
||||
counts.entry(c.to_rgba8()).or_insert((c, 0)).1 += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,7 +288,11 @@ pub(crate) fn forget_variables(doc: &mut Document, gone: &BTreeSet<VariableId>)
|
||||
doc.variables.variables.retain(|v| !gone.contains(&v.id));
|
||||
let users: Vec<NodeId> = doc.nodes().filter(|n| n.bound.values().any(|v| gone.contains(v))).map(|n| n.id).collect();
|
||||
for id in users {
|
||||
doc.get_mut(id)?.bound.retain(|_, v| !gone.contains(v));
|
||||
let n = doc.get_mut(id)?;
|
||||
for (field, _) in n.bound.iter().filter(|(_, v)| gone.contains(*v)) {
|
||||
super::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, false);
|
||||
}
|
||||
n.bound.retain(|_, v| !gone.contains(v));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"variable.import",
|
||||
"Import variables",
|
||||
"{variables (the local variables JSON, as variable.export writes it: an object, JSON text or a path)} collections join this file's collection of the same name (values by mode name; new modes and variables are added, a variable of another type is skipped), others are added → {collections (added), variables (added), updated, warnings}",
|
||||
"{variables (the local variables JSON, as variable.export writes it: an object, JSON text or a path; `variableCollections` and `variables` under `meta` or at the top level)} collections join this file's collection of the same name (values by mode name; new modes and variables are added, a variable of another type is skipped), others are added → {collections (added), variables (added), updated, warnings (also when it holds no variables, or fewer than its collections list)}",
|
||||
true,
|
||||
import
|
||||
),
|
||||
|
||||
@@ -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")}));
|
||||
|
||||
@@ -256,7 +256,7 @@ fn stroke_color(n: &Node) -> Option<Color> {
|
||||
if !n.has_visible_stroke() {
|
||||
return None;
|
||||
}
|
||||
n.strokes.iter().filter(|p| p.visible).find_map(|p| p.solid_color().map(|c| c.with_alpha(c.a * p.opacity)))
|
||||
n.strokes.iter().filter(|p| p.visible).find_map(|p| p.solid_color())
|
||||
}
|
||||
|
||||
fn drop_shadow(n: &Node) -> Option<&Shadow> {
|
||||
|
||||
@@ -1498,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;
|
||||
|
||||
@@ -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,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"));
|
||||
}
|
||||
@@ -26,8 +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;
|
||||
@@ -374,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>,
|
||||
@@ -435,6 +439,7 @@ impl DocState {
|
||||
focus: None,
|
||||
guide: None,
|
||||
rotation_origin: None,
|
||||
view_from_prefs: false,
|
||||
last_duplicate: None,
|
||||
};
|
||||
st.zoom_to_fit();
|
||||
@@ -663,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>`.
|
||||
@@ -701,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(),
|
||||
}
|
||||
}
|
||||
@@ -853,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)
|
||||
}
|
||||
@@ -894,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));
|
||||
@@ -919,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()));
|
||||
}
|
||||
@@ -1534,6 +1554,8 @@ mod binding_lists_tests;
|
||||
#[cfg(test)]
|
||||
mod booleans_tests;
|
||||
#[cfg(test)]
|
||||
mod bound_alpha_tests;
|
||||
#[cfg(test)]
|
||||
mod branch_tests;
|
||||
#[cfg(test)]
|
||||
mod branch_versions_tests;
|
||||
@@ -1576,6 +1598,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;
|
||||
@@ -1636,6 +1660,8 @@ 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;
|
||||
@@ -1686,6 +1712,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;
|
||||
@@ -1718,6 +1746,8 @@ mod proto_run_tests;
|
||||
#[cfg(test)]
|
||||
mod proto_tail_tests;
|
||||
#[cfg(test)]
|
||||
mod rest_slot_tests;
|
||||
#[cfg(test)]
|
||||
mod retained_walk_tests;
|
||||
#[cfg(test)]
|
||||
mod rotated_guides_tests;
|
||||
@@ -1790,6 +1820,8 @@ mod variables_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod variables_editor_tests;
|
||||
#[cfg(test)]
|
||||
mod variables_import_shapes_tests;
|
||||
#[cfg(test)]
|
||||
mod variables_io_tests;
|
||||
#[cfg(test)]
|
||||
mod vector_behaviour_tests;
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! Slots opened from REST file JSON: instances keep their own slot content through edits and
|
||||
//! moves of the mains, and through saving.
|
||||
|
||||
use crate::Session;
|
||||
use crate::components::tests::{ex, healthy, idv};
|
||||
use crate::components::*;
|
||||
use base64::Engine as _;
|
||||
use figcraft_doc::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn at(x: f64, y: f64) -> Value {
|
||||
json!([[1, 0, x], [0, 1, y]])
|
||||
}
|
||||
|
||||
fn text(id: &str, s: &str) -> Value {
|
||||
json!({"id": id, "name": s, "type": "TEXT", "characters": s, "relativeTransform": at(0.0, 0.0), "size": {"x": 100, "y": 20}})
|
||||
}
|
||||
|
||||
/// A Chip main, a Container main with an empty "Slot", and an instance of Container whose slot
|
||||
/// holds a text and a chip.
|
||||
fn file() -> Value {
|
||||
let slot = |id: &str, kids: Value| json!({"id": id, "name": "Slot", "type": "SLOT", "relativeTransform": at(10.0, 40.0), "size": {"x": 280, "y": 100}, "children": kids});
|
||||
let guid = |l: u32| json!({"guid": {"sessionID": 5, "localID": l}});
|
||||
let chip = json!({"id": "2:3", "name": "Chip", "type": "INSTANCE", "componentId": "1:9", "relativeTransform": at(0.0, 30.0), "size": {"x": 40, "y": 20},
|
||||
"children": [{"id": "I2:3;1:10", "name": "Dot", "type": "ELLIPSE", "relativeTransform": at(2.0, 2.0), "size": {"x": 16, "y": 16}}]});
|
||||
json!({"name": "Slots", "document": {"id": "0:0", "type": "DOCUMENT", "children": [{"id": "0:1", "name": "Page", "type": "CANVAS", "children": [
|
||||
{"id": "1:9", "name": "Chip", "type": "COMPONENT", "relativeTransform": at(0.0, 400.0), "size": {"x": 40, "y": 20}, "children": [
|
||||
{"id": "1:10", "name": "Dot", "type": "ELLIPSE", "relativeTransform": at(2.0, 2.0), "size": {"x": 16, "y": 16}}
|
||||
]},
|
||||
{"id": "1:2", "name": "Container", "type": "COMPONENT", "relativeTransform": at(0.0, 0.0), "size": {"x": 300, "y": 160},
|
||||
"componentPropertyDefinitions": {"Slot#1:3": {"type": "SLOT", "defaultValue": guid(1)}},
|
||||
"children": [text("1:4", "Title"), slot("1:5", json!([]))]},
|
||||
{"id": "2:1", "name": "Container", "type": "INSTANCE", "componentId": "1:2", "relativeTransform": at(400.0, 0.0), "size": {"x": 300, "y": 160},
|
||||
"componentProperties": {"Slot#1:3": {"type": "SLOT", "value": guid(7)}},
|
||||
"children": [text("I2:1;1:4", "Title"), slot("I2:1;1:5", json!([text("2:2", "Reports in progress"), chip]))]}
|
||||
]}]}})
|
||||
}
|
||||
|
||||
fn named(doc: &Document, kind: fn(&NodeKind) -> bool, name: &str) -> NodeId {
|
||||
doc.nodes().find(|n| n.name == name && kind(&n.kind)).map(|n| n.id).unwrap_or_else(|| panic!("no {name}"))
|
||||
}
|
||||
|
||||
/// The instance's slot and the names of what it holds.
|
||||
fn slot_content(s: &Session) -> (NodeId, Vec<String>) {
|
||||
let doc = s.doc().unwrap();
|
||||
let inst = named(doc, |k| matches!(k, NodeKind::Instance { .. }), "Container");
|
||||
let (slot, _) = slots_of(doc, inst).into_iter().next().expect("the instance has its slot");
|
||||
let names = doc.node(slot).unwrap().children.iter().map(|c| doc.node(*c).unwrap().name.clone()).collect();
|
||||
(slot, names)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rest_slot_content_survives_main_edits_moves_and_saving() {
|
||||
let mut s = Session::new();
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&file()).unwrap());
|
||||
ex(&mut s, "file.open", json!({"data": data, "name": "slots.json"}));
|
||||
healthy(&s);
|
||||
let want = vec!["Reports in progress".to_string(), "Chip".to_string()];
|
||||
let (slot, names) = slot_content(&s);
|
||||
assert_eq!(names, want);
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(slot_name(doc, slot).as_deref(), Some("Slot"));
|
||||
assert!(slot_edited(doc, slot), "the instance's own content");
|
||||
let chip = named(doc, |k| matches!(k, NodeKind::Instance { .. }), "Chip");
|
||||
assert!(!inside_instance(doc, chip), "content stands alone");
|
||||
|
||||
// Edit and move both mains (the container's and the one used in the content).
|
||||
let main = named(doc, |k| matches!(k, NodeKind::Component { .. }), "Container");
|
||||
let chip_main = named(doc, |k| matches!(k, NodeKind::Component { .. }), "Chip");
|
||||
let title = doc.node(main).unwrap().children[0];
|
||||
ex(&mut s, "node.set", json!({"id": title, "props": {"fill": "#ff0000"}}));
|
||||
ex(&mut s, "node.set", json!({"id": main, "props": {"width": 320}}));
|
||||
let holder = idv(&ex(&mut s, "frame.create", json!({"x": -1000, "y": 0, "width": 900, "height": 900, "name": "Design system"})));
|
||||
ex(&mut s, "node.reparent", json!({"ids": [main, chip_main], "parent": holder}));
|
||||
healthy(&s);
|
||||
assert_eq!(slot_content(&s).1, want, "kept across syncs");
|
||||
|
||||
// Saving and opening again keeps it.
|
||||
let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||
let mut again = Session::new();
|
||||
ex(&mut again, "file.open", json!({"data": data, "name": "slots.figcraft"}));
|
||||
healthy(&again);
|
||||
let (slot, names) = slot_content(&again);
|
||||
assert_eq!(names, want);
|
||||
assert!(slot_edited(again.doc().unwrap(), slot));
|
||||
|
||||
// Resetting the slot shows the main's (empty) default content.
|
||||
ex(&mut again, "instance.resetSlot", json!({"id": slot}));
|
||||
assert!(slot_content(&again).1.is_empty());
|
||||
}
|
||||
+45
-11
@@ -661,6 +661,9 @@ fn on_corner_square(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> bool
|
||||
/// handles: the canvas draws just its outline (the handles still answer just outside it).
|
||||
pub const SMALL_BOX_PX: f64 = 16.0;
|
||||
|
||||
/// The stroke the Pencil draws: its colour and weight (the canvas previews it the same).
|
||||
pub const PENCIL_STROKE: (figcraft_color::Color, f64) = (figcraft_color::Color::BLACK, 2.0);
|
||||
|
||||
/// The shorter and longer sides, in screen pixels, of the box with `corners` (tl, tr, br, bl).
|
||||
pub fn box_sides_px(corners: &[Point; 4], zoom: f64) -> (f64, f64) {
|
||||
let [a, b, _, d] = corners;
|
||||
@@ -1859,6 +1862,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 +2068,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 +2186,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();
|
||||
@@ -2210,8 +2244,8 @@ impl Session {
|
||||
let mut local = fitted.clone();
|
||||
local.apply_affine(pw.inverse());
|
||||
let _ = d;
|
||||
let mut props =
|
||||
json!({"fills": [], "strokes": ["#000000"], "strokeWeight": 2, "strokeCap": "round", "strokeJoin": "round", "strokeAlign": "center"});
|
||||
let (color, weight) = PENCIL_STROKE;
|
||||
let mut props = json!({"fills": [], "strokes": [color.to_css_hex()], "strokeWeight": weight, "strokeCap": "round", "strokeJoin": "round", "strokeAlign": "center"});
|
||||
// Pen pressure becomes the stroke's width profile (brushes follow it too).
|
||||
if let Some(points) = pressure_profile(points, pressure) {
|
||||
props["widthProfile"] = json!(points);
|
||||
@@ -2320,7 +2354,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;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
//! `variable.import` reads `variables` next to `variableCollections`, under `meta` or at the top
|
||||
//! level, and never imports nothing without saying so (#790).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn collections() -> Value {
|
||||
json!({"VC:1": {"id": "VC:1", "name": "Theme", "modes": [{"modeId": "1:0", "name": "Light"}], "defaultModeId": "1:0",
|
||||
"variableIds": ["VariableID:1"]}})
|
||||
}
|
||||
|
||||
fn variables() -> Value {
|
||||
json!({"VariableID:1": {"id": "VariableID:1", "name": "bg", "variableCollectionId": "VC:1", "resolvedType": "COLOR",
|
||||
"valuesByMode": {"1:0": {"r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0}}}})
|
||||
}
|
||||
|
||||
fn import(json: Value) -> Value {
|
||||
let mut s = Session::new();
|
||||
match s.execute("variable.import", &json!({"variables": json})) {
|
||||
Ok(r) => r,
|
||||
Err(e) => panic!("{e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variables_import_reads_every_shape() {
|
||||
for shape in [
|
||||
json!({"meta": {"variableCollections": collections(), "variables": variables()}}),
|
||||
json!({"variableCollections": collections(), "variables": variables()}),
|
||||
json!({"meta": {"variableCollections": collections()}, "variables": variables()}),
|
||||
json!({"variableCollections": collections(), "meta": {"variables": variables()}}),
|
||||
] {
|
||||
let r = import(shape.clone());
|
||||
assert_eq!((r["collections"].as_u64(), r["variables"].as_u64()), (Some(1), Some(1)), "{shape}: {r}");
|
||||
assert_eq!(r["warnings"], json!([]), "{shape}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variables_import_never_imports_nothing_silently() {
|
||||
// Collections without variables: they are added, with a warning saying where variables go.
|
||||
let r = import(json!({"meta": {"variableCollections": collections()}}));
|
||||
assert_eq!((r["collections"].as_u64(), r["variables"].as_u64()), (Some(1), Some(0)), "{r}");
|
||||
assert!(r["warnings"].to_string().contains("no variables found: expected `meta.variables`"), "{r}");
|
||||
// An empty file's export reads back with the same warning, not an error.
|
||||
let mut s = Session::new();
|
||||
let text = s.execute("variable.export", &json!({})).unwrap()["text"].as_str().unwrap().to_string();
|
||||
let r = s.execute("variable.import", &json!({"variables": text})).unwrap();
|
||||
assert!(r["warnings"].to_string().contains("no variables found"), "{r}");
|
||||
// Collections listing variables that aren't there.
|
||||
let mut colls = collections();
|
||||
colls["VC:1"]["variableIds"] = json!(["VariableID:1", "VariableID:2"]);
|
||||
let r = import(json!({"meta": {"variableCollections": colls, "variables": variables()}}));
|
||||
assert_eq!(r["variables"].as_u64(), Some(1), "{r}");
|
||||
assert!(r["warnings"].to_string().contains("1 of the 2 variables the collections list in `variableIds` weren't imported"), "{r}");
|
||||
// No collections anywhere: an error naming the expected shape.
|
||||
let e = Session::new().execute("variable.import", &json!({"variables": {"variables": variables()}})).unwrap_err().to_string();
|
||||
assert!(e.contains("(`meta.variableCollections` and `meta.variables`)"), "{e}");
|
||||
}
|
||||
@@ -439,3 +439,61 @@ fn bend_paint_bucket_cut_join_and_eraser_tools() {
|
||||
assert_eq!(net(&s, id).segments.len(), 2, "the eraser removes what it passes over");
|
||||
assert_eq!(s.doc().unwrap().node(page).unwrap().children, vec![id], "and the pieces stay one layer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_paths_take_the_requested_size_and_refuse_empty_geometry() {
|
||||
let mut s = Session::new();
|
||||
let path = "M0 0 L24 0 L24 24 L0 24 Z";
|
||||
let intrinsic = id_of(&ex(&mut s, "vector.create", json!({"path": path}))["id"]).unwrap();
|
||||
let n = s.doc().unwrap().node(intrinsic).unwrap();
|
||||
assert_eq!((n.width, n.height), (24.0, 24.0), "a path without a size keeps its intrinsic box");
|
||||
// Both sides: each scales independently, like resizing a vector.
|
||||
let both = id_of(&ex(&mut s, "vector.create", json!({"path": path, "width": 16, "height": 8}))["id"]).unwrap();
|
||||
let n = s.doc().unwrap().node(both).unwrap();
|
||||
assert_eq!((n.width, n.height), (16.0, 8.0));
|
||||
let n = net(&s, both);
|
||||
let max = n.vertices.iter().fold((0.0f64, 0.0f64), |(x, y), v| (x.max(v.x), y.max(v.y)));
|
||||
assert!((max.0 - 16.0).abs() < 1e-9 && (max.1 - 8.0).abs() < 1e-9, "{max:?}: the geometry itself is scaled");
|
||||
// One side: the other keeps its proportion.
|
||||
let width = id_of(&ex(&mut s, "vector.create", json!({"path": path, "width": 16}))["id"]).unwrap();
|
||||
let n = s.doc().unwrap().node(width).unwrap();
|
||||
assert_eq!((n.width, n.height), (16.0, 16.0));
|
||||
// A path or point set that draws nothing is an error, not an invisible zero-size layer.
|
||||
let e = s.execute("vector.create", &json!({"path": "M5 5"})).unwrap_err().to_string();
|
||||
assert!(e.contains("draws nothing"), "{e}");
|
||||
let e = s.execute("vector.create", &json!({"points": [[5, 5], [5, 5]]})).unwrap_err().to_string();
|
||||
assert!(e.contains("draws nothing"), "{e}");
|
||||
// A size on an axis the geometry has none of is refused, not silently ignored.
|
||||
let e = s.execute("vector.create", &json!({"path": "M0 0 L0 24", "width": 16})).unwrap_err().to_string();
|
||||
assert!(e.contains("horizontal extent"), "{e}");
|
||||
let e = s.execute("vector.create", &json!({"path": "M0 0 L24 0", "height": 16})).unwrap_err().to_string();
|
||||
assert!(e.contains("vertical extent"), "{e}");
|
||||
// But 0 on an axis it has none of is what it already is: a horizontal line with height 0.
|
||||
let line = id_of(&ex(&mut s, "vector.create", json!({"path": "M0 0 L24 0", "width": 16, "height": 0}))["id"]).unwrap();
|
||||
let n = s.doc().unwrap().node(line).unwrap();
|
||||
assert_eq!((n.width, n.height), (16.0, 0.0));
|
||||
// A size of 0 or less on an axis it has would flatten it, so it is refused, alone or with the other.
|
||||
for p in [json!({"path": path, "width": 0}), json!({"path": path, "width": 16, "height": 0}), json!({"path": path, "width": -4, "height": 8})] {
|
||||
let e = s.execute("vector.create", &p).unwrap_err().to_string();
|
||||
assert!(e.contains("positive"), "{p}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_points_take_the_requested_size() {
|
||||
let mut s = Session::new();
|
||||
let points = json!([[10, 20], [34, 20], [34, 32]]);
|
||||
let corners = |s: &Session, id: NodeId| net(s, id).vertices.iter().map(|v| (v.x, v.y)).collect::<Vec<_>>();
|
||||
// Both sides: each axis scales on its own; the vertices move with the box.
|
||||
let both = id_of(&ex(&mut s, "vector.create", json!({"points": points, "x": 10, "y": 20, "width": 48, "height": 6}))["id"]).unwrap();
|
||||
let n = s.doc().unwrap().node(both).unwrap();
|
||||
assert_eq!((n.width, n.height), (48.0, 6.0));
|
||||
assert_eq!(corners(&s, both), vec![(0.0, 0.0), (48.0, 0.0), (48.0, 6.0)]);
|
||||
// One side: the other keeps its proportion.
|
||||
let one = id_of(&ex(&mut s, "vector.create", json!({"points": points, "height": 24}))["id"]).unwrap();
|
||||
let n = s.doc().unwrap().node(one).unwrap();
|
||||
assert_eq!((n.width, n.height), (48.0, 24.0));
|
||||
assert_eq!(corners(&s, one), vec![(0.0, 0.0), (48.0, 0.0), (48.0, 24.0)]);
|
||||
let e = s.execute("vector.create", &json!({"points": points, "width": 16, "height": -1})).unwrap_err().to_string();
|
||||
assert!(e.contains("positive"), "{e}");
|
||||
}
|
||||
@@ -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,8 +1,8 @@
|
||||
//! 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.
|
||||
//! text that hugs by its resize mode. Also how distributed main-axis alignment reads.
|
||||
|
||||
use figcraft_doc::{Document, Sizing};
|
||||
use figcraft_doc::{Document, MainAlign, Sizing};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::kiwi::{Kv, Schema};
|
||||
@@ -11,6 +11,7 @@ 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
|
||||
@@ -18,7 +19,7 @@ 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, TextAutoResize textAutoResize, float fontSize, TextData textData, SymbolData symbolData
|
||||
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
|
||||
";
|
||||
|
||||
@@ -191,3 +192,27 @@ fn rest_files_read_left_out_sizing_modes_as_hug_and_text_by_its_resize_mode() {
|
||||
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);
|
||||
@@ -2217,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;
|
||||
}
|
||||
|
||||
@@ -630,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();
|
||||
|
||||
@@ -1404,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));
|
||||
@@ -2045,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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
+124
-13
@@ -6,8 +6,8 @@
|
||||
//! (`relativeTransform`, else `absoluteBoundingBox` + `rotation`), paints, strokes, effects,
|
||||
//! corners, constraints, auto layout, layout grids, export settings, text with styled runs,
|
||||
//! vectors from `fillGeometry` / `strokeGeometry`, components, component sets, instances and
|
||||
//! component properties are imported. Image fills keep their `imageRef` as the image key and
|
||||
//! show a grey placeholder unless an [`ImageResolver`] supplies the bytes (nothing is fetched).
|
||||
//! component properties (slots included) are imported. Image fills keep their `imageRef` as the
|
||||
//! image key and show a grey placeholder unless an [`ImageResolver`] supplies the bytes (nothing is fetched).
|
||||
//! Prototyping comes along: interactions (triggers, actions, transitions, easing), flows,
|
||||
//! scrolling and the prototype device. Variables come from the local variables response
|
||||
//! (`GET /v1/files/:key/variables/local`), embedded next to the file JSON or passed separately
|
||||
@@ -29,12 +29,16 @@ 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;
|
||||
#[cfg(test)]
|
||||
mod proto_variable_tests;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod slot_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod text;
|
||||
mod variables;
|
||||
@@ -104,12 +108,25 @@ pub fn is_variables_json(v: &Value) -> bool {
|
||||
}
|
||||
|
||||
/// The collections and variables of a local variables response (or its `meta`), checked like a
|
||||
/// file's, with the warnings about what was skipped; None when `v` isn't one.
|
||||
/// file's, with the warnings about what was skipped (and when it holds no variables at all, or
|
||||
/// fewer than its collections list); None when `v` isn't one.
|
||||
pub fn read_variables(v: &Value) -> Option<(figcraft_doc::Variables, Vec<String>)> {
|
||||
let src = variables::source(v)?;
|
||||
variables::source(v)?;
|
||||
let mut cx = Ctx::new(Document::new("Variables"), &no_images);
|
||||
cx.variables(src);
|
||||
Some((std::mem::take(&mut cx.doc.variables), cx.warnings.finish()))
|
||||
cx.variables(v);
|
||||
let vars = std::mem::take(&mut cx.doc.variables);
|
||||
let mut warnings = cx.warnings.finish();
|
||||
if vars.variables.is_empty() {
|
||||
warnings.push("no variables found: expected `meta.variables` (an object of variables by id, next to `meta.variableCollections`)".into());
|
||||
} else {
|
||||
let listed = variables::listed_ids(v);
|
||||
let read: BTreeSet<&str> = vars.variables.iter().map(|x| x.id.as_str()).collect();
|
||||
let missing = listed.difference(&read).count();
|
||||
if missing > 0 {
|
||||
warnings.push(format!("{missing} of the {} variables the collections list in `variableIds` weren't imported", listed.len()));
|
||||
}
|
||||
}
|
||||
Some((vars, warnings))
|
||||
}
|
||||
|
||||
/// Import `bytes` when they are REST API file JSON; `None` when they are something else (the
|
||||
@@ -189,7 +206,7 @@ fn import_parts(
|
||||
cx.collect_meta(e);
|
||||
}
|
||||
// Variables first: layers link to them as they are built.
|
||||
for src in [variables::source(v), variables.and_then(variables::source)].into_iter().flatten() {
|
||||
for src in [Some(v), variables].into_iter().flatten() {
|
||||
cx.variables(src);
|
||||
}
|
||||
if variables.is_some_and(|x| variables::source(x).is_none()) {
|
||||
@@ -366,6 +383,10 @@ pub(crate) struct Ctx<'a> {
|
||||
style_meta: BTreeMap<&'a str, &'a Value>,
|
||||
/// Instances (imported as frames until their main is resolved) with their JSON.
|
||||
instances: Vec<(NodeId, &'a Value)>,
|
||||
/// `SLOT` layers (imported as frames until their slot property is resolved) with their JSON.
|
||||
slots: Vec<(NodeId, &'a Value)>,
|
||||
/// Slot property defaults (the main's slot content id): (component or set, property) → value.
|
||||
slot_defaults: BTreeMap<(NodeId, String), &'a Value>,
|
||||
/// Instance-swap property defaults: (component or set, property, REST id of the default).
|
||||
swap_defaults: Vec<(NodeId, String, String)>,
|
||||
/// Property definitions of component sets, shared by their variants.
|
||||
@@ -400,6 +421,8 @@ impl<'a> Ctx<'a> {
|
||||
set_meta: BTreeMap::new(),
|
||||
style_meta: BTreeMap::new(),
|
||||
instances: Vec::new(),
|
||||
slots: Vec::new(),
|
||||
slot_defaults: BTreeMap::new(),
|
||||
swap_defaults: Vec::new(),
|
||||
set_defs: BTreeMap::new(),
|
||||
flows: Vec::new(),
|
||||
@@ -500,8 +523,10 @@ impl<'a> Ctx<'a> {
|
||||
sources.record(id, j, primary);
|
||||
}
|
||||
}
|
||||
if ty == "INSTANCE" {
|
||||
self.instances.push((id, j));
|
||||
match ty {
|
||||
"INSTANCE" => self.instances.push((id, j)),
|
||||
"SLOT" => self.slots.push((id, j)),
|
||||
_ => {}
|
||||
}
|
||||
let kids = children(j);
|
||||
if kids.is_empty() {
|
||||
@@ -524,8 +549,8 @@ impl<'a> Ctx<'a> {
|
||||
let point_count = || num(j, "pointCount").filter(|c| *c >= 3.0).map(|c| c.min(1000.0) as u32);
|
||||
let has_geometry = !list(j, "fillGeometry").is_empty() || !list(j, "strokeGeometry").is_empty();
|
||||
Some(match ty {
|
||||
// Instances start as frames; `finish` links them to their main.
|
||||
"FRAME" | "INSTANCE" => NodeKind::Frame,
|
||||
// Instances and slots start as frames; `finish` links them to their main and property.
|
||||
"FRAME" | "INSTANCE" | "SLOT" => NodeKind::Frame,
|
||||
"GROUP" => NodeKind::Group,
|
||||
"SECTION" => NodeKind::Section { contents_hidden: flag(j, "sectionContentsHidden").unwrap_or(false) },
|
||||
"RECTANGLE" => NodeKind::Rectangle,
|
||||
@@ -736,7 +761,7 @@ impl<'a> Ctx<'a> {
|
||||
}
|
||||
|
||||
/// Component property definitions (variant ones are implied by the variant names).
|
||||
fn prop_defs(&mut self, owner: NodeId, j: &Value) -> BTreeMap<String, PropDef> {
|
||||
fn prop_defs(&mut self, owner: NodeId, j: &'a Value) -> BTreeMap<String, PropDef> {
|
||||
let mut out = BTreeMap::new();
|
||||
for (name, d) in j.get("componentPropertyDefinitions").and_then(Value::as_object).into_iter().flatten().take(256) {
|
||||
let default = d.get("defaultValue");
|
||||
@@ -750,7 +775,13 @@ impl<'a> Ctx<'a> {
|
||||
continue;
|
||||
}
|
||||
Some("VARIANT") => continue,
|
||||
Some("SLOT") if self.fig_sources.is_some() => PropDef::Slot { preferred: Vec::new() },
|
||||
Some("SLOT") => {
|
||||
if let Some(d) = default {
|
||||
self.slot_defaults.insert((owner, name.clone()), d);
|
||||
}
|
||||
self.slot_notes(d);
|
||||
PropDef::Slot { preferred: Vec::new() }
|
||||
}
|
||||
_ => {
|
||||
self.warn("unknown component property types were skipped");
|
||||
continue;
|
||||
@@ -761,6 +792,20 @@ impl<'a> Ctx<'a> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Notes on what a slot property sets that the editor doesn't keep: preferred components (keys
|
||||
/// of library components, not layers of the file) and limits on its content.
|
||||
fn slot_notes(&mut self, d: &Value) {
|
||||
if !list(d, "preferredValues").is_empty() {
|
||||
self.warn("preferred slot component keys cannot be resolved to file nodes and were skipped");
|
||||
}
|
||||
// Insertion and empty-slot display only change editing aids, not what the file shows.
|
||||
let s = d.get("slotSettings");
|
||||
let limit = |k: &str| s.and_then(|s| num(s, k)).is_some_and(|n| n != 0.0);
|
||||
if limit("minChildren") || limit("maxChildren") || s.and_then(|s| flag(s, "allowPreferredValuesOnly")) == Some(true) {
|
||||
self.warn("non-default slot settings are not represented and were skipped");
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared styles with their values (`styleDefinitions`, written by the `.fig` import: a list of
|
||||
/// `{id, name, description, styleType, node}`, the node being a layer that shows the style), in
|
||||
/// the file's order. Layers then link to them; styles that no layer uses come across too.
|
||||
@@ -969,6 +1014,7 @@ impl<'a> Ctx<'a> {
|
||||
/// outside components, and instances that would nest a component inside itself (directly or
|
||||
/// through other components), stay frames.
|
||||
fn resolve_instances(&mut self) {
|
||||
let frames: BTreeSet<NodeId> = self.instances.iter().map(|(i, _)| *i).collect();
|
||||
let mut links: Vec<(NodeId, NodeId, &'a Value)> = Vec::new();
|
||||
for (inst, j) in std::mem::take(&mut self.instances) {
|
||||
match text(j, "componentId").and_then(|r| self.component(r)) {
|
||||
@@ -998,6 +1044,7 @@ impl<'a> Ctx<'a> {
|
||||
linked.push((inst, j));
|
||||
}
|
||||
}
|
||||
self.resolve_slots(&frames, &linked);
|
||||
// Overridden fields the file lists, on the instance and its layers.
|
||||
for (inst, j) in linked {
|
||||
for o in list(j, "overrides") {
|
||||
@@ -1013,6 +1060,70 @@ impl<'a> Ctx<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `SLOT` layers. In a main component a slot is bound to its slot property: the one the layer
|
||||
/// references, else the one named like it, else the main's only slot property left. In an
|
||||
/// instance it follows its main's slot, and holds the instance's own content (kept across syncs)
|
||||
/// unless the instance's value of the property is the main's default content (without the
|
||||
/// values to compare, a slot holding layers keeps them as its own). A slot without a property
|
||||
/// stays a frame. `frames`: every instance
|
||||
/// layer of the file, `linked`: those that became instances.
|
||||
fn resolve_slots(&mut self, frames: &BTreeSet<NodeId>, linked: &[(NodeId, &'a Value)]) {
|
||||
let linked: BTreeMap<NodeId, &Value> = linked.iter().copied().collect();
|
||||
let mut bound: BTreeSet<(NodeId, String)> = BTreeSet::new();
|
||||
for (id, j) in std::mem::take(&mut self.slots) {
|
||||
// The nearest main component or instance above the slot owns it; in an instance whose
|
||||
// main isn't in the file (a frame), the slot stays a frame too.
|
||||
let owner = self.doc.ancestors(id).into_iter().find_map(|a| match self.doc.node(a).map(|n| &n.kind) {
|
||||
Some(NodeKind::Instance { component, .. }) => Some(linked.get(&a).map(|j| (*component, Some(*j)))),
|
||||
Some(NodeKind::Component { .. }) => Some(Some((a, None))),
|
||||
_ => frames.contains(&a).then_some(None),
|
||||
});
|
||||
let Some(owner) = owner else {
|
||||
self.warn("slot layers without a matching slot property were imported as frames");
|
||||
continue;
|
||||
};
|
||||
let Some((main, inst)) = owner else { continue };
|
||||
let Some(NodeKind::Component { props, .. }) = self.doc.node(main).map(|n| &n.kind) else { continue };
|
||||
let defs: Vec<&String> = props.iter().filter(|(_, d)| matches!(d, PropDef::Slot { .. })).map(|(k, _)| k).collect();
|
||||
let layer = text(j, "name").unwrap_or_default();
|
||||
let refs: Vec<&str> =
|
||||
j.get("componentPropertyReferences").and_then(Value::as_object).into_iter().flatten().filter_map(|(_, v)| v.as_str()).collect();
|
||||
// An instance's slot is matched against all of its main's slot properties.
|
||||
let free: Vec<&&String> = defs.iter().filter(|d| inst.is_some() || !bound.contains(&(main, (**d).clone()))).collect();
|
||||
let name = defs
|
||||
.iter()
|
||||
.find(|d| refs.contains(&d.as_str()))
|
||||
.or_else(|| defs.iter().find(|d| short_prop_name(d) == layer))
|
||||
.or_else(|| if free.len() == 1 { free.first().copied() } else { None })
|
||||
.map(|d| (*d).clone());
|
||||
let Some(n) = self.doc.node(id) else { continue };
|
||||
let has_content = !n.children.is_empty();
|
||||
let Some(inst) = inst else {
|
||||
match name.filter(|p| bound.insert((main, p.clone()))) {
|
||||
Some(p) => {
|
||||
if let Some(n) = self.doc.node_mut(id) {
|
||||
n.prop_refs.insert("slot".into(), p);
|
||||
}
|
||||
}
|
||||
None => self.warn("slot layers without a matching slot property were imported as frames"),
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let set = self.doc.node(main).and_then(|n| n.parent);
|
||||
let default =
|
||||
name.as_ref().and_then(|p| [Some(main), set].into_iter().flatten().find_map(|o| self.slot_defaults.get(&(o, p.clone())).copied()));
|
||||
let value = name.as_ref().and_then(|p| inst.get("componentProperties")?.get(p)?.get("value"));
|
||||
let own = name.is_some()
|
||||
&& match (value, default) {
|
||||
(Some(v), Some(d)) => v != d,
|
||||
_ => has_content,
|
||||
};
|
||||
if own && let Some(n) = self.doc.node_mut(id) {
|
||||
n.overrides.insert("slot".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An instance's property values (variant values are implied by the main it shows).
|
||||
fn instance_props(&self, j: &Value) -> BTreeMap<String, Value> {
|
||||
let mut out = BTreeMap::new();
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
//! `SLOT` layers of the REST file JSON: bound to their slot property in a main component, and
|
||||
//! holding each instance's own content (synthetic files).
|
||||
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn at(x: f64, y: f64) -> Value {
|
||||
json!([[1, 0, x], [0, 1, y]])
|
||||
}
|
||||
|
||||
pub(crate) fn file(children: Value) -> Value {
|
||||
json!({"name": "Slots", "schemaVersion": 0, "document": {"id": "0:0", "name": "Document", "type": "DOCUMENT", "children": [
|
||||
{"id": "0:1", "name": "Page", "type": "CANVAS", "children": children}
|
||||
]}})
|
||||
}
|
||||
|
||||
fn content_id(local: u32) -> Value {
|
||||
json!({"guid": {"sessionID": 5, "localID": local}})
|
||||
}
|
||||
|
||||
fn slot(id: &str, name: &str, children: Value) -> Value {
|
||||
json!({"id": id, "name": name, "type": "SLOT", "relativeTransform": at(10.0, 40.0), "size": {"x": 280, "y": 100}, "children": children})
|
||||
}
|
||||
|
||||
fn label(id: &str, text: &str) -> Value {
|
||||
json!({"id": id, "name": text, "type": "TEXT", "characters": text, "relativeTransform": at(0.0, 0.0), "size": {"x": 100, "y": 20}})
|
||||
}
|
||||
|
||||
/// A "Chip" component (`1:9`), a "Container" component (`1:2`) with a "Title" text and an empty
|
||||
/// "Slot" (property `Slot#1:3`, default content 5:1), and the given layers after them.
|
||||
pub(crate) fn container_file(more: Vec<Value>) -> Value {
|
||||
let mut layers = vec![
|
||||
json!({"id": "1:9", "name": "Chip", "type": "COMPONENT", "relativeTransform": at(0.0, 400.0), "size": {"x": 40, "y": 20}, "children": [
|
||||
{"id": "1:10", "name": "Dot", "type": "ELLIPSE", "relativeTransform": at(2.0, 2.0), "size": {"x": 16, "y": 16}}
|
||||
]}),
|
||||
json!({"id": "1:2", "name": "Container", "type": "COMPONENT", "relativeTransform": at(0.0, 0.0), "size": {"x": 300, "y": 160},
|
||||
"componentPropertyDefinitions": {"Slot#1:3": {"type": "SLOT", "defaultValue": content_id(1)}},
|
||||
"children": [label("1:4", "Title"), slot("1:5", "Slot", json!([]))]}),
|
||||
];
|
||||
layers.extend(more);
|
||||
file(Value::Array(layers))
|
||||
}
|
||||
|
||||
/// An instance `id` of Container at `x` whose slot holds `content` (property value `value`).
|
||||
pub(crate) fn container(id: &str, x: f64, value: Value, content: Value) -> Value {
|
||||
json!({"id": id, "name": "Container", "type": "INSTANCE", "componentId": "1:2", "relativeTransform": at(x, 0.0), "size": {"x": 300, "y": 160},
|
||||
"componentProperties": {"Slot#1:3": {"type": "SLOT", "value": value}},
|
||||
"children": [label(&format!("I{id};1:4"), "Title"), slot(&format!("I{id};1:5"), "Slot", content)]})
|
||||
}
|
||||
|
||||
pub(crate) fn chip(id: &str) -> Value {
|
||||
json!({"id": id, "name": "Chip", "type": "INSTANCE", "componentId": "1:9", "relativeTransform": at(0.0, 30.0), "size": {"x": 40, "y": 20}, "children": [
|
||||
{"id": format!("I{id};1:10"), "name": "Dot", "type": "ELLIPSE", "relativeTransform": at(2.0, 2.0), "size": {"x": 16, "y": 16}}
|
||||
]})
|
||||
}
|
||||
|
||||
fn by_rest<'d>(doc: &'d Document, v: &Value, rest: &str) -> &'d Node {
|
||||
// Names repeat across copies: find by position in the file's tree instead.
|
||||
fn path(v: &Value, rest: &str, out: &mut Vec<usize>) -> bool {
|
||||
if v.get("id").and_then(Value::as_str) == Some(rest) {
|
||||
return true;
|
||||
}
|
||||
for (i, c) in v.get("children").and_then(Value::as_array).into_iter().flatten().enumerate() {
|
||||
out.push(i);
|
||||
if path(c, rest, out) {
|
||||
return true;
|
||||
}
|
||||
out.pop();
|
||||
}
|
||||
false
|
||||
}
|
||||
let mut p = Vec::new();
|
||||
assert!(path(&v["document"]["children"][0], rest, &mut p), "no {rest} in the file");
|
||||
let mut id = doc.pages[0];
|
||||
for i in p {
|
||||
id = doc.node(id).unwrap().children[i];
|
||||
}
|
||||
doc.node(id).unwrap()
|
||||
}
|
||||
|
||||
fn names(doc: &Document, n: &Node) -> Vec<String> {
|
||||
n.children.iter().map(|c| doc.node(*c).unwrap().name.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slot_layers_bind_to_their_property_and_keep_instance_content() {
|
||||
let v = container_file(vec![
|
||||
container("2:1", 400.0, content_id(7), json!([label("2:2", "Reports in progress"), chip("2:3")])),
|
||||
container("3:1", 800.0, content_id(1), json!([])),
|
||||
]);
|
||||
let (doc, warnings) = import(&v, &no_images).unwrap();
|
||||
assert!(warnings.iter().all(|w| !w.contains("SLOT") && !w.contains("slot")), "{warnings:?}");
|
||||
|
||||
let main = by_rest(&doc, &v, "1:2");
|
||||
let NodeKind::Component { props, .. } = &main.kind else { panic!("{:?}", main.kind) };
|
||||
assert_eq!(props.get("Slot"), Some(&PropDef::Slot { preferred: Vec::new() }), "named without its #id suffix");
|
||||
let main_slot = by_rest(&doc, &v, "1:5");
|
||||
assert!(matches!(main_slot.kind, NodeKind::Frame));
|
||||
assert_eq!(main_slot.prop_refs.get("slot").map(String::as_str), Some("Slot"));
|
||||
assert!(main_slot.children.is_empty(), "the main's slot stays empty");
|
||||
|
||||
let filled = by_rest(&doc, &v, "I2:1;1:5");
|
||||
assert!(filled.overrides.contains("slot"), "the instance's own content");
|
||||
assert!(filled.prop_refs.is_empty(), "an instance's slot follows its main's");
|
||||
assert_eq!(names(&doc, filled), ["Reports in progress", "Chip"]);
|
||||
let chip = by_rest(&doc, &v, "2:3");
|
||||
assert!(matches!(chip.kind, NodeKind::Instance { .. }), "instances in slot content link to their own main");
|
||||
|
||||
let default = by_rest(&doc, &v, "I3:1;1:5");
|
||||
assert!(!default.overrides.contains("slot"), "the main's default content follows the main");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slot_ownership_without_values_follows_the_content() {
|
||||
let mut owned = container("2:1", 400.0, json!(null), json!([label("2:2", "Own")]));
|
||||
owned["componentProperties"] = json!({});
|
||||
let mut empty = container("3:1", 800.0, json!(null), json!([]));
|
||||
empty["componentProperties"] = json!({});
|
||||
let v = container_file(vec![owned, empty]);
|
||||
let (doc, _) = import(&v, &no_images).unwrap();
|
||||
assert!(by_rest(&doc, &v, "I2:1;1:5").overrides.contains("slot"));
|
||||
assert!(!by_rest(&doc, &v, "I3:1;1:5").overrides.contains("slot"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slots_match_by_reference_then_name_then_the_one_left() {
|
||||
let comp = json!({"id": "1:2", "name": "Card", "type": "COMPONENT", "relativeTransform": at(0.0, 0.0), "size": {"x": 300, "y": 300},
|
||||
"componentPropertyDefinitions": {
|
||||
"Header#1:3": {"type": "SLOT", "defaultValue": content_id(1)},
|
||||
"Body#1:4": {"type": "SLOT", "defaultValue": content_id(2)},
|
||||
"Footer#1:5": {"type": "SLOT", "defaultValue": content_id(3)},
|
||||
"Visible#1:6": {"type": "BOOLEAN", "defaultValue": true}
|
||||
},
|
||||
"children": [
|
||||
{"id": "1:7", "name": "Top", "type": "SLOT", "componentPropertyReferences": {"visible": "Visible#1:6", "slot": "Header#1:3"},
|
||||
"relativeTransform": at(0.0, 0.0), "size": {"x": 300, "y": 50}},
|
||||
{"id": "1:8", "name": "Body", "type": "SLOT", "relativeTransform": at(0.0, 50.0), "size": {"x": 300, "y": 50}},
|
||||
{"id": "1:9", "name": "Bottom", "type": "SLOT", "relativeTransform": at(0.0, 100.0), "size": {"x": 300, "y": 50}}
|
||||
]});
|
||||
let v = file(json!([comp]));
|
||||
let (doc, warnings) = import(&v, &no_images).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
let bound = |rest: &str| by_rest(&doc, &v, rest).prop_refs.get("slot").cloned();
|
||||
assert_eq!(bound("1:7").as_deref(), Some("Header"));
|
||||
assert_eq!(by_rest(&doc, &v, "1:7").prop_refs.get("visible").map(String::as_str), Some("Visible"));
|
||||
assert_eq!(bound("1:8").as_deref(), Some("Body"));
|
||||
assert_eq!(bound("1:9").as_deref(), Some("Footer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variants_share_their_set_slot_property() {
|
||||
let variant = |id: &str, name: &str, x: f64| {
|
||||
json!({"id": id, "name": name, "type": "COMPONENT", "relativeTransform": at(x, 0.0), "size": {"x": 100, "y": 100},
|
||||
"children": [slot(&format!("{id}s"), "Content", json!([]))]})
|
||||
};
|
||||
let set = json!({"id": "1:1", "name": "Panel", "type": "COMPONENT_SET", "relativeTransform": at(0.0, 0.0), "size": {"x": 300, "y": 100},
|
||||
"componentPropertyDefinitions": {"Size": {"type": "VARIANT", "defaultValue": "Small"}, "Content#2:1": {"type": "SLOT", "defaultValue": content_id(1)}},
|
||||
"children": [variant("1:2", "Size=Small", 0.0), variant("1:3", "Size=Large", 150.0)]});
|
||||
let inst = json!({"id": "4:1", "name": "Panel", "type": "INSTANCE", "componentId": "1:3", "relativeTransform": at(0.0, 300.0), "size": {"x": 100, "y": 100},
|
||||
"componentProperties": {"Content#2:1": {"type": "SLOT", "value": content_id(9)}},
|
||||
"children": [slot("I4:1;1:3s", "Content", json!([label("4:2", "Mine")]))]});
|
||||
let v = file(json!([set, inst]));
|
||||
let (doc, warnings) = import(&v, &no_images).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
for rest in ["1:2s", "1:3s"] {
|
||||
assert_eq!(by_rest(&doc, &v, rest).prop_refs.get("slot").map(String::as_str), Some("Content"), "{rest}");
|
||||
}
|
||||
assert!(by_rest(&doc, &v, "I4:1;1:3s").overrides.contains("slot"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_instances_own_the_slots_of_their_main() {
|
||||
// A "Screen" main places a Container whose slot holds a chip; an instance of Screen places
|
||||
// other content in that nested slot.
|
||||
let screen = json!({"id": "6:1", "name": "Screen", "type": "COMPONENT", "relativeTransform": at(0.0, 600.0), "size": {"x": 300, "y": 160},
|
||||
"children": [container("6:2", 0.0, content_id(7), json!([chip("6:3")]))]});
|
||||
let nested = container("I7:1;6:2", 0.0, content_id(8), json!([label("7:2", "Other")]));
|
||||
let outer = json!({"id": "7:1", "name": "Screen", "type": "INSTANCE", "componentId": "6:1", "relativeTransform": at(400.0, 600.0), "size": {"x": 300, "y": 160},
|
||||
"children": [nested]});
|
||||
let v = container_file(vec![screen, outer]);
|
||||
let (doc, _) = import(&v, &no_images).unwrap();
|
||||
let in_main = by_rest(&doc, &v, "I6:2;1:5");
|
||||
assert!(in_main.overrides.contains("slot") && in_main.prop_refs.is_empty());
|
||||
assert!(matches!(by_rest(&doc, &v, "6:3").kind, NodeKind::Instance { .. }));
|
||||
let in_outer = by_rest(&doc, &v, "II7:1;6:2;1:5");
|
||||
assert!(in_outer.overrides.contains("slot"));
|
||||
assert_eq!(names(&doc, in_outer), ["Other"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slots_outside_components_or_without_a_property_stay_frames() {
|
||||
let mut no_def = container_file(Vec::new());
|
||||
no_def["document"]["children"][0]["children"][1].as_object_mut().unwrap().remove("componentPropertyDefinitions");
|
||||
let loose = slot("8:1", "Loose", json!([label("8:2", "Kept")]));
|
||||
no_def["document"]["children"][0]["children"].as_array_mut().unwrap().push(loose);
|
||||
let (doc, warnings) = import(&no_def, &no_images).unwrap();
|
||||
assert!(warnings.iter().any(|w| w.starts_with("slot layers without a matching slot property were imported as frames (×2)")), "{warnings:?}");
|
||||
let kept = by_rest(&doc, &no_def, "8:1");
|
||||
assert!(matches!(kept.kind, NodeKind::Frame) && kept.prop_refs.is_empty());
|
||||
assert_eq!(names(&doc, kept), ["Kept"]);
|
||||
assert!(by_rest(&doc, &no_def, "1:5").prop_refs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slot_settings_the_editor_cannot_keep_are_noted() {
|
||||
let mut v = container_file(Vec::new());
|
||||
v["document"]["children"][0]["children"][1]["componentPropertyDefinitions"]["Slot#1:3"]["slotSettings"] =
|
||||
json!({"stretchChildOnInsert": true, "displayEmptyByDefault": true, "minChildren": null, "maxChildren": 3});
|
||||
v["document"]["children"][0]["children"][1]["componentPropertyDefinitions"]["Slot#1:3"]["preferredValues"] =
|
||||
json!([{"type": "COMPONENT", "key": "abc"}]);
|
||||
let (_, warnings) = import(&v, &no_images).unwrap();
|
||||
assert!(warnings.iter().any(|w| w == "non-default slot settings are not represented and were skipped"), "{warnings:?}");
|
||||
assert!(warnings.iter().any(|w| w == "preferred slot component keys cannot be resolved to file nodes and were skipped"), "{warnings:?}");
|
||||
let mut plain = container_file(Vec::new());
|
||||
plain["document"]["children"][0]["children"][1]["componentPropertyDefinitions"]["Slot#1:3"]["slotSettings"] =
|
||||
json!({"stretchChildOnInsert": true, "minChildren": null, "maxChildren": null, "allowPreferredValuesOnly": false});
|
||||
assert!(import(&plain, &no_images).unwrap().1.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_slot_json_never_panics() {
|
||||
for junk in [json!(null), json!(1), json!("x"), json!([]), json!({"guid": null}), json!({"type": "SLOT"})] {
|
||||
let mut v = container_file(vec![container("2:1", 400.0, junk.clone(), json!([label("2:2", "A")]))]);
|
||||
v["document"]["children"][0]["children"][1]["componentPropertyDefinitions"]["Slot#1:3"] =
|
||||
json!({"type": "SLOT", "defaultValue": junk.clone(), "slotSettings": junk.clone(), "preferredValues": junk.clone()});
|
||||
v["document"]["children"][0]["children"][1]["children"][1]["componentPropertyReferences"] = json!({"slot": junk});
|
||||
assert!(import(&v, &no_images).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,16 @@ pub fn source(v: &Value) -> Option<&Value> {
|
||||
[v.get("meta"), Some(v)].into_iter().flatten().find(|m| m.get("variableCollections").is_some_and(Value::is_object))
|
||||
}
|
||||
|
||||
/// The variable ids the collections of a local variables response list in `variableIds`.
|
||||
pub(super) fn listed_ids(v: &Value) -> BTreeSet<&str> {
|
||||
source(v)
|
||||
.into_iter()
|
||||
.flat_map(|src| objects(src, "variableCollections", MAX_COLLECTIONS))
|
||||
.flat_map(|(_, c)| list(c, "variableIds").iter().filter_map(Value::as_str).map(clip_str))
|
||||
.take(MAX_VARIABLES)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn kind_of(t: &str) -> Option<VariableType> {
|
||||
Some(match t {
|
||||
"COLOR" => VariableType::Color,
|
||||
@@ -118,14 +128,18 @@ fn objects<'v>(v: &'v Value, key: &str, max: usize) -> impl Iterator<Item = (&'v
|
||||
}
|
||||
|
||||
impl Ctx<'_> {
|
||||
/// Add the collections and variables of a local variables response (or its `meta`).
|
||||
pub(super) fn variables(&mut self, src: &Value) {
|
||||
/// Add the collections and variables of a local variables response (or its `meta`); nothing
|
||||
/// when `v` isn't one. The `variables` map is read next to `variableCollections`, or from
|
||||
/// `meta` or the top level when it sits on the other side.
|
||||
pub(super) fn variables(&mut self, v: &Value) {
|
||||
let Some(src) = source(v) else { return };
|
||||
for (key, c) in objects(src, "variableCollections", MAX_COLLECTIONS) {
|
||||
self.collection(key, c);
|
||||
}
|
||||
let holder = [Some(src), v.get("meta"), Some(v)].into_iter().flatten().find(|m| m.get("variables").is_some_and(Value::is_object));
|
||||
let mut found: Vec<Variable> = Vec::new();
|
||||
let mut seen: BTreeSet<String> = BTreeSet::new();
|
||||
for (key, v) in objects(src, "variables", MAX_VARIABLES) {
|
||||
for (key, v) in holder.into_iter().flat_map(|h| objects(h, "variables", MAX_VARIABLES)) {
|
||||
if let Some(var) = self.variable(key, v)
|
||||
&& seen.insert(var.id.clone())
|
||||
{
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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() => {
|
||||
|
||||
@@ -147,7 +147,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
"type": {"type": "string", "enum": ["frame", "rectangle", "ellipse", "polygon", "star", "line", "arrow", "text", "vector", "section", "component", "slice"], "description": "Layer type"},
|
||||
"x": num("Left, relative to the parent frame (default 0)"),
|
||||
"y": num("Top, relative to the parent frame (default 0)"),
|
||||
"width": num("Width (default 100; text hugs its content unless width is set, which makes it wrap)"),
|
||||
"width": num("Width (default 100; text hugs its content unless width is set, which makes it wrap; for a vector with path, width/height scale the path, one alone keeping its proportion, and without them the path's own box is used)"),
|
||||
"height": num("Height (default 100; ignored for lines/arrows and auto-height text)"),
|
||||
"parent": id_schema("Parent frame/group/component id (default: the current page)"),
|
||||
"index": int("Z-index among the parent's children (default: on top)"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -634,6 +634,7 @@ status = "draft — needs native review"
|
||||
"Instance swap (no other component)" = "Výměna instance (žádná jiná komponenta)"
|
||||
"Instances" = "Instance"
|
||||
"Instant" = "Okamžitě"
|
||||
"Intensity" = "Intenzita"
|
||||
"Interaction" = "Interakce"
|
||||
"Interactions" = "Interakce"
|
||||
"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."
|
||||
@@ -1508,13 +1509,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"
|
||||
@@ -1523,14 +1536,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ď"
|
||||
@@ -1538,12 +1557,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"
|
||||
@@ -1561,8 +1586,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"
|
||||
|
||||
@@ -619,6 +619,7 @@ status = "draft — needs native review"
|
||||
"Instance swap (no other component)" = "Instanztausch (keine andere Komponente)"
|
||||
"Instances" = "Instanzen"
|
||||
"Instant" = "Sofort"
|
||||
"Intensity" = "Intensität"
|
||||
"Interaction" = "Interaktion"
|
||||
"Interactions" = "Interaktionen"
|
||||
"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."
|
||||
@@ -1458,13 +1459,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"
|
||||
@@ -1473,26 +1486,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"
|
||||
@@ -1510,8 +1535,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"
|
||||
|
||||
@@ -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"
|
||||
@@ -647,6 +649,7 @@ name = "English"
|
||||
"Instance swap (no other component)" = "Instance swap (no other component)"
|
||||
"Instances" = "Instances"
|
||||
"Instant" = "Instant"
|
||||
"Intensity" = "Intensity"
|
||||
"Interaction" = "Interaction"
|
||||
"Interactions" = "Interactions"
|
||||
"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."
|
||||
@@ -771,6 +774,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"
|
||||
@@ -830,6 +834,7 @@ name = "English"
|
||||
"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 files or templates match your search" = "No files or templates match your search"
|
||||
"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."
|
||||
"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link."
|
||||
@@ -1175,6 +1180,8 @@ name = "English"
|
||||
"Search actions, layers and tools" = "Search actions, layers and tools"
|
||||
"Search assets" = "Search assets"
|
||||
"Search comments" = "Search comments"
|
||||
"Search files and templates" = "Search files and templates"
|
||||
"Search results" = "Search results"
|
||||
"Search fonts" = "Search fonts"
|
||||
"Search layers" = "Search layers"
|
||||
"Search shortcuts" = "Search shortcuts"
|
||||
@@ -1374,6 +1381,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."
|
||||
@@ -1403,6 +1411,7 @@ name = "English"
|
||||
"Truncate with an ellipsis" = "Truncate with an ellipsis"
|
||||
"Try again" = "Try again"
|
||||
"Try another search or filter." = "Try another search or filter."
|
||||
"Try another search, or browse templates." = "Try another search, or browse templates."
|
||||
"Try another word, or look at every kind." = "Try another word, or look at every kind."
|
||||
"Turn a layer into a component, reuse it, and change every copy at once" = "Turn a layer into a component, reuse it, and change every copy at once"
|
||||
"Two digits" = "Two digits"
|
||||
@@ -1536,13 +1545,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"
|
||||
@@ -1551,14 +1572,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"
|
||||
@@ -1566,12 +1593,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"
|
||||
@@ -1589,8 +1623,15 @@ 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"
|
||||
"slot layers without a matching slot property were imported as frames" = "slot layers without a matching slot property were imported as frames"
|
||||
"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"
|
||||
@@ -1670,6 +1711,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" }
|
||||
|
||||
@@ -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"
|
||||
@@ -648,6 +650,7 @@ status = "draft — needs native review"
|
||||
"Instance swap (no other component)" = "Intercambio de instancia (no hay otro componente)"
|
||||
"Instances" = "Instancias"
|
||||
"Instant" = "Instantánea"
|
||||
"Intensity" = "Intensidad"
|
||||
"Interaction" = "Interacción"
|
||||
"Interactions" = "Interacciones"
|
||||
"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."
|
||||
@@ -774,6 +777,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"
|
||||
@@ -831,6 +835,7 @@ status = "draft — needs native review"
|
||||
"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 files or templates match your search" = "Ningún archivo ni plantilla coincide con tu búsqueda"
|
||||
"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."
|
||||
"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Aún no hay bibliotecas. Añade un archivo .figcraft publicado como biblioteca o pega el enlace de una biblioteca de equipo."
|
||||
@@ -1176,6 +1181,8 @@ status = "draft — needs native review"
|
||||
"Search actions, layers and tools" = "Buscar acciones, capas y herramientas"
|
||||
"Search assets" = "Buscar recursos"
|
||||
"Search comments" = "Buscar comentarios"
|
||||
"Search files and templates" = "Buscar archivos y plantillas"
|
||||
"Search results" = "Resultados de búsqueda"
|
||||
"Search fonts" = "Buscar fuentes"
|
||||
"Search layers" = "Buscar capas"
|
||||
"Search shortcuts" = "Buscar atajos"
|
||||
@@ -1375,6 +1382,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."
|
||||
@@ -1404,6 +1412,7 @@ status = "draft — needs native review"
|
||||
"Truncate with an ellipsis" = "Truncar con puntos suspensivos"
|
||||
"Try again" = "Reintentar"
|
||||
"Try another search or filter." = "Prueba otra búsqueda u otro filtro."
|
||||
"Try another search, or browse templates." = "Prueba otra búsqueda o explora las plantillas."
|
||||
"Try another word, or look at every kind." = "Prueba con otra palabra o mira todos los tipos."
|
||||
"Turn a layer into a component, reuse it, and change every copy at once" = "Convierte una capa en componente, reutilízalo y cambia todas las copias a la vez"
|
||||
"Two digits" = "Dos dígitos"
|
||||
@@ -1537,13 +1546,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"
|
||||
@@ -1552,14 +1573,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"
|
||||
@@ -1567,12 +1594,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"
|
||||
@@ -1590,8 +1624,15 @@ 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ó"
|
||||
"slot layers without a matching slot property were imported as frames" = "las capas de ranura sin una propiedad de ranura correspondiente se importaron como marcos"
|
||||
"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"
|
||||
@@ -1671,6 +1712,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" }
|
||||
@@ -2096,6 +2138,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"
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -19,7 +19,10 @@ status = "draft — needs native review"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Une collection de ce fichier avec ces modes et ces variables : remplacez leurs valeurs par mode et ajoutez vos propres variables"
|
||||
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Un fichier .figcraft portant ce nom existe déjà. Choisissez son nom complet pour confirmer le remplacement."
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Un calque seul s’aligne sur son cadre : sélectionnez d’autres calques, ou un calque dans un cadre"
|
||||
"A responsive product page: hero, features, figures, a quote and a footer" = "Une page produit responsive : héros, fonctionnalités, chiffres, une citation et un pied de page"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Un ressort joue jusqu’à ce qu’il se stabilise : sa masse, sa rigidité et son amortissement en fixent la durée"
|
||||
"A title, an agenda, a big figure, a chart and a closing slide" = "Un titre, un sommaire, un grand chiffre, un graphique et une diapositive de clôture"
|
||||
"A to Z" = "A à Z"
|
||||
"About" = "À propos"
|
||||
"About FigCraft" = "À propos de FigCraft"
|
||||
"About this server" = "À propos de ce serveur"
|
||||
@@ -65,6 +68,7 @@ status = "draft — needs native review"
|
||||
"Add variant with a new value" = "Ajouter une variante avec une nouvelle valeur"
|
||||
"Added" = "Ajouté"
|
||||
"Added the library \"{name}\"" = "Bibliothèque « {name} » ajoutée"
|
||||
"Adding to {name}" = "Ajout dans {name}"
|
||||
"Adjust" = "Réglages"
|
||||
"Adjustment presets" = "Préréglages"
|
||||
"Advanced" = "Avancé"
|
||||
@@ -90,7 +94,9 @@ status = "draft — needs native review"
|
||||
"All supported properties" = "Toutes les propriétés prises en charge"
|
||||
"All variables" = "Toutes les variables"
|
||||
"Alpha" = "Alpha"
|
||||
"Alphabetical" = "Alphabétique"
|
||||
"Alt+drag" = "Alt+glisser"
|
||||
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Un écran d’analyse sur une grille : chiffres, graphiques tracés en vectoriel et un tableau"
|
||||
"An invite link that opens FigCraft and joins" = "Un lien d’invitation qui ouvre FigCraft et rejoint la session"
|
||||
"Anchor" = "Ancre"
|
||||
"Android" = "Android"
|
||||
@@ -109,6 +115,8 @@ status = "draft — needs native review"
|
||||
"Apply variable" = "Appliquer une variable"
|
||||
"Apply variable to text" = "Appliquer une variable au texte"
|
||||
"Apply variable to visibility" = "Appliquer une variable à la visibilité"
|
||||
"Apps" = "Applications"
|
||||
"Apr" = "avr."
|
||||
"Arc" = "Arc"
|
||||
"Arc ratio" = "Rapport de l’arc"
|
||||
"Arc start" = "Début de l’arc"
|
||||
@@ -120,6 +128,7 @@ status = "draft — needs native review"
|
||||
"As typed" = "Tel que saisi"
|
||||
"Asking the server…" = "Interrogation du serveur…"
|
||||
"Assets" = "Ressources"
|
||||
"Aug" = "août"
|
||||
"Auto" = "Auto"
|
||||
"Auto (space around)" = "Auto (espace autour)"
|
||||
"Auto (space between)" = "Auto (espace entre)"
|
||||
@@ -164,6 +173,7 @@ status = "draft — needs native review"
|
||||
"Bottom" = "Bas"
|
||||
"Bottom left radius" = "Rayon inférieur gauche"
|
||||
"Bottom right radius" = "Rayon inférieur droit"
|
||||
"Bound to {name}. Click to detach the variable." = "Lié à {name}. Cliquez pour détacher la variable."
|
||||
"Branch" = "Branche"
|
||||
"Branch archived" = "Branche archivée"
|
||||
"Branch merged" = "Branche fusionnée"
|
||||
@@ -250,6 +260,7 @@ status = "draft — needs native review"
|
||||
"Color values in this file are in this color space." = "Les valeurs de couleur de ce fichier sont dans cet espace colorimétrique."
|
||||
"Color variables" = "Variables de couleur"
|
||||
"Colour" = "Couleur"
|
||||
"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Styles de couleur et de texte, variables Clair et Sombre, boutons, champs et cartes"
|
||||
"Column" = "Colonne"
|
||||
"Column gap" = "Espacement des colonnes"
|
||||
"Column sizes" = "Tailles des colonnes"
|
||||
@@ -286,6 +297,8 @@ status = "draft — needs native review"
|
||||
"Contributors" = "Contributeurs"
|
||||
"Contributors: {people} · Commits: {commits}" = "Contributeurs : {people} · Commits : {commits}"
|
||||
"Controller" = "Manette"
|
||||
"Convert to frame" = "Convertir en cadre"
|
||||
"Convert to section" = "Convertir en section"
|
||||
"Cool" = "Froid"
|
||||
"Copied as CSS" = "Copié en CSS"
|
||||
"Copied as PNG" = "Copié en PNG"
|
||||
@@ -319,6 +332,7 @@ status = "draft — needs native review"
|
||||
"Create property…" = "Créer une propriété…"
|
||||
"Create style" = "Créer un style"
|
||||
"Create style from the selected text" = "Créer un style à partir du texte sélectionné"
|
||||
"Created" = "Création"
|
||||
"Crop" = "Recadrer"
|
||||
"Crop marks" = "Traits de coupe"
|
||||
"Ctrl+arrows" = "Ctrl+flèches"
|
||||
@@ -333,6 +347,9 @@ status = "draft — needs native review"
|
||||
"Dark" = "Sombre"
|
||||
"Darken" = "Assombrir"
|
||||
"Dash" = "Tirets"
|
||||
"Dashboard" = "Tableau de bord"
|
||||
"Date created" = "Date de création"
|
||||
"Dec" = "déc."
|
||||
"Decoration style" = "Style de décoration"
|
||||
"Decoration" = "Décoration"
|
||||
"Deep select" = "Sélection en profondeur"
|
||||
@@ -359,6 +376,7 @@ status = "draft — needs native review"
|
||||
"Description" = "Description"
|
||||
"Description (optional)" = "Description (facultative)"
|
||||
"Design" = "Design"
|
||||
"Design system starter" = "Base de système de design"
|
||||
"Desktop" = "Ordinateur"
|
||||
"Destination" = "Destination"
|
||||
"Detach alias" = "Détacher l’alias"
|
||||
@@ -394,8 +412,12 @@ status = "draft — needs native review"
|
||||
"Drafts" = "Brouillons"
|
||||
"Drag the stroke to add a width point" = "Faites glisser le contour pour ajouter un point de largeur"
|
||||
"Draw" = "Dessin"
|
||||
"Draw a frame" = "Tracer un cadre"
|
||||
"Drawing tools" = "Outils de dessin"
|
||||
"Drop a .figcraft, .fig, .json, .svg or image file." = "Déposez un fichier .figcraft, .fig, .json, .svg ou une image."
|
||||
"Drop shadow" = "Ombre portée"
|
||||
"Drop to import" = "Déposer pour importer"
|
||||
"Drop to open" = "Déposer pour ouvrir"
|
||||
"Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "Double licence MIT OR Apache-2.0. Réalisé par l’équipe et la communauté ArtCraft."
|
||||
"Duo" = "Duo"
|
||||
"Duplicate" = "Dupliquer"
|
||||
@@ -412,6 +434,7 @@ status = "draft — needs native review"
|
||||
"Edit style" = "Modifier le style"
|
||||
"Edit variable" = "Modifier la variable"
|
||||
"Edit with the builder" = "Modifier avec l’assistant"
|
||||
"Edited {when}" = "Modifié {when}"
|
||||
"Effect settings" = "Réglages de l’effet"
|
||||
"Effect styles" = "Styles d’effet"
|
||||
"Effects" = "Effets"
|
||||
@@ -442,6 +465,7 @@ status = "draft — needs native review"
|
||||
"Expand" = "Développer"
|
||||
"Export" = "Exporter"
|
||||
"Export all" = "Tout exporter"
|
||||
"Export frames and layers as PNG, JPG, SVG or PDF" = "Exporter les cadres et les calques en PNG, JPG, SVG ou PDF"
|
||||
"Export page as PNG" = "Exporter la page en PNG"
|
||||
"Export settings" = "Réglages d’exportation"
|
||||
"Export variables" = "Exporter les variables"
|
||||
@@ -461,11 +485,13 @@ status = "draft — needs native review"
|
||||
"F" = "F"
|
||||
"Fade" = "Fondu"
|
||||
"False" = "Faux"
|
||||
"Feb" = "févr."
|
||||
"Fetching from the server…" = "Récupération depuis le serveur…"
|
||||
"FigCraft couldn't open its window: {error}" = "FigCraft n’a pas pu ouvrir sa fenêtre : {error}"
|
||||
"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft ne prend pas encore en charge toutes les fonctionnalités de ces fichiers. Les notes indiquent ce qui a été omis ou modifié dans la copie importée ; le fichier d’origine reste inchangé."
|
||||
"File" = "Fichier"
|
||||
"File name" = "Nom du fichier"
|
||||
"File not found" = "Fichier introuvable"
|
||||
"File published" = "Fichier publié"
|
||||
"Files aren't end-to-end encrypted: the server's operator can read them." = "Les fichiers ne sont pas chiffrés de bout en bout : l’opérateur du serveur peut les lire."
|
||||
"Files nobody opens for {n} days are deleted." = { one = "Les fichiers que personne n’ouvre pendant {n} jour sont supprimés.", other = "Les fichiers que personne n’ouvre pendant {n} jours sont supprimés." }
|
||||
@@ -512,6 +538,7 @@ status = "draft — needs native review"
|
||||
"Font size" = "Taille de police"
|
||||
"Font style" = "Style de police"
|
||||
"Forget on this computer" = "Oublier sur cet ordinateur"
|
||||
"Four screens of a trail app with components and a prototype flow" = "Quatre écrans d’une application de randonnée avec des composants et un flux de prototype"
|
||||
"Fractions" = "Fractions"
|
||||
"Frame" = "Cadre"
|
||||
"Frame selection" = "Encadrer la sélection"
|
||||
@@ -525,6 +552,7 @@ status = "draft — needs native review"
|
||||
"Gap between columns" = "Espacement entre les colonnes"
|
||||
"Gap between items" = "Espacement entre les éléments"
|
||||
"Gap between rows" = "Espacement entre les lignes"
|
||||
"Getting started" = "Premiers pas"
|
||||
"Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "Indiquez l’adresse d’un serveur ouvert au public, ou l’adresse d’un serveur avec sa clé (`figcraft-server key` l’affiche)."
|
||||
"Glass" = "Verre"
|
||||
"Go to main component" = "Aller au composant principal"
|
||||
@@ -533,6 +561,7 @@ status = "draft — needs native review"
|
||||
"Gradient" = "Dégradé"
|
||||
"Gray" = "Gris"
|
||||
"Green" = "Vert"
|
||||
"Greyscale components and two example screens to sketch a flow with" = "Composants en niveaux de gris et deux écrans d’exemple pour esquisser un flux"
|
||||
"Grid" = "Grille"
|
||||
"Grid ({size})" = "Grille ({size})"
|
||||
"Grid styles" = "Styles de grille"
|
||||
@@ -554,6 +583,8 @@ status = "draft — needs native review"
|
||||
"Hide contents" = "Masquer le contenu"
|
||||
"Hide from publishing" = "Exclure de la publication"
|
||||
"Hide section contents" = "Masquer le contenu de la section"
|
||||
"Hide these tips (Getting started in the Help menu shows them again)" = "Masquer ces astuces (Premiers pas dans le menu Aide les affiche de nouveau)"
|
||||
"Hide when publishing" = "Masquer à la publication"
|
||||
"Highlight layers on hover" = "Mettre en évidence les calques au survol"
|
||||
"Highlights" = "Hautes lumières"
|
||||
"Hold Alt" = "Maintenir Alt"
|
||||
@@ -575,6 +606,8 @@ status = "draft — needs native review"
|
||||
"Hue" = "Teinte"
|
||||
"Hug" = "Ajuster"
|
||||
"Hug contents" = "Ajuster au contenu"
|
||||
"Icon components on a 24 px grid with keylines and a size preview" = "Composants d’icônes sur une grille de 24 px avec repères et aperçu de la taille"
|
||||
"Icon set starter" = "Base de jeu d’icônes"
|
||||
"If it keeps happening, please report it at {link} with this message." = "Si cela se reproduit, signalez-le sur {link} avec ce message."
|
||||
"If" = "Si"
|
||||
"If {condition}" = "Si {condition}"
|
||||
@@ -615,6 +648,7 @@ status = "draft — needs native review"
|
||||
"Instance swap (no other component)" = "Permutation d’instance (aucun autre composant)"
|
||||
"Instances" = "Instances"
|
||||
"Instant" = "Instantané"
|
||||
"Intensity" = "Intensité"
|
||||
"Interaction" = "Interaction"
|
||||
"Interactions" = "Interactions"
|
||||
"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."
|
||||
@@ -624,6 +658,7 @@ status = "draft — needs native review"
|
||||
"Invite link" = "Lien d’invitation"
|
||||
"Invite link copied" = "Lien d’invitation copié"
|
||||
"It asks permission to:" = "Il demande l’autorisation de :"
|
||||
"Jan" = "janv."
|
||||
"Join" = "Rejoindre"
|
||||
"Join code copied" = "Code d’accès copié"
|
||||
"Join code or invite link" = "Code d’accès ou lien d’invitation"
|
||||
@@ -634,6 +669,8 @@ status = "draft — needs native review"
|
||||
"Joined {file}" = "Connecté à {file}"
|
||||
"Joining…" = "Connexion…"
|
||||
"Join|stroke" = "Jointure"
|
||||
"Jul" = "juil."
|
||||
"Jun" = "juin"
|
||||
"Just now" = "À l’instant"
|
||||
"Justify" = "Justifier"
|
||||
"Keep appearance" = "Conserver l’apparence"
|
||||
@@ -643,11 +680,14 @@ status = "draft — needs native review"
|
||||
"Key/Gamepad" = "Touche/manette"
|
||||
"Key/Gamepad {key}" = "Touche/manette {key}"
|
||||
"Keyboard shortcuts" = "Raccourcis clavier"
|
||||
"Kits" = "Kits"
|
||||
"Landing page" = "Page de destination"
|
||||
"Language" = "Langue"
|
||||
"Lasso" = "Lasso"
|
||||
"Last" = "Dernier"
|
||||
"Last change: {note}" = "Dernière modification : {note}"
|
||||
"Last commit" = "Dernier commit"
|
||||
"Last modified" = "Dernière modification"
|
||||
"Last on top" = "Dernier au-dessus"
|
||||
"Layer blur" = "Flou de calque"
|
||||
"Layer name" = "Nom du calque"
|
||||
@@ -686,6 +726,7 @@ status = "draft — needs native review"
|
||||
"Link" = "Lien"
|
||||
"Link copied" = "Lien copié"
|
||||
"Link docs, code or tickets for developers." = "Liez de la documentation, du code ou des tickets pour les développeurs."
|
||||
"Link frames on the Prototype tab, then present the flow" = "Reliez les cadres dans l’onglet Prototype, puis présentez le flux"
|
||||
"Link this component to its code component: instances then show your real snippet with their values." = "Reliez ce composant à son composant de code : les instances affichent alors votre véritable extrait avec leurs valeurs."
|
||||
"List spacing" = "Espacement de liste"
|
||||
"List style" = "Style de liste"
|
||||
@@ -703,7 +744,9 @@ status = "draft — needs native review"
|
||||
"Main" = "Principal"
|
||||
"Main component deleted" = "Composant principal supprimé"
|
||||
"Main menu" = "Menu principal"
|
||||
"Make a component" = "Créer un composant"
|
||||
"Make slot" = "Créer un emplacement"
|
||||
"Mar" = "mars"
|
||||
"Margin" = "Marge"
|
||||
"Mark all as read" = "Tout marquer comme lu"
|
||||
"Mark as resolved" = "Marquer comme résolu"
|
||||
@@ -716,6 +759,7 @@ status = "draft — needs native review"
|
||||
"Max W" = "L max."
|
||||
"Max lines" = "Lignes max."
|
||||
"Maximize" = "Agrandir"
|
||||
"May" = "mai"
|
||||
"Measure to the selection" = "Mesurer jusqu’à la sélection"
|
||||
"Merge into main" = "Fusionner dans le principal"
|
||||
"Merge regions" = "Fusionner les régions"
|
||||
@@ -737,6 +781,7 @@ status = "draft — needs native review"
|
||||
"Mixed" = "Mixte"
|
||||
"Mixed fonts" = "Polices mixtes"
|
||||
"Mixed interactions: click + to replace them" = "Interactions mixtes : cliquez sur + pour les remplacer"
|
||||
"Mobile app" = "Application mobile"
|
||||
"Mode" = "Mode"
|
||||
"Model" = "Modèle"
|
||||
"Models" = "Modèles"
|
||||
@@ -766,6 +811,7 @@ status = "draft — needs native review"
|
||||
"Navigate to" = "Naviguer vers"
|
||||
"Nested instance" = "Instance imbriquée"
|
||||
"New design file" = "Nouveau fichier de design"
|
||||
"Newest first" = "Plus récent d’abord"
|
||||
"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"
|
||||
@@ -799,6 +845,7 @@ status = "draft — needs native review"
|
||||
"No model credits were built into this copy." = "Cette copie ne contient aucun crédit de modèle."
|
||||
"No number variables fit here." = "Aucune variable numérique ne convient ici."
|
||||
"No number variables yet. Create them in Local variables." = "Aucune variable numérique pour l’instant. Créez-en dans Variables locales."
|
||||
"No other pages" = "Aucune autre page"
|
||||
"No paints" = "Aucune peinture"
|
||||
"No plugins on {server}." = "Aucun plugin sur {server}."
|
||||
"No plugins yet. Import a development plugin from its figcraft-plugin.json." = "Aucun plugin pour l’instant. Importez un plugin en développement depuis son figcraft-plugin.json."
|
||||
@@ -812,6 +859,7 @@ status = "draft — needs native review"
|
||||
"No string variables yet. Create them in Local variables." = "Aucune variable de chaîne pour l’instant. Créez-en dans Variables locales."
|
||||
"No styles yet." = "Aucun style pour l’instant."
|
||||
"No styles yet. Click + to create one from the selection." = "Aucun style pour l’instant. Cliquez sur + pour en créer un à partir de la sélection."
|
||||
"No templates match your search" = "Aucun modèle ne correspond à votre recherche"
|
||||
"No text styles yet. Click + to create one from the selected text." = "Aucun style de texte pour l’instant. Cliquez sur + pour en créer un à partir du texte sélectionné."
|
||||
"No variables fit here." = "Aucune variable ne convient ici."
|
||||
"No variables yet. Create them in Local variables." = "Aucune variable pour l’instant. Créez-en dans Variables locales."
|
||||
@@ -831,6 +879,7 @@ status = "draft — needs native review"
|
||||
"Nothing here yet. Set a frame's status to list it." = "Rien pour l’instant. Attribuez un statut à un cadre pour le lister ici."
|
||||
"Nothing online yet. Use Share on a file to put it online and it is listed here." = "Rien en ligne pour l’instant. Utilisez Partager sur un fichier pour le mettre en ligne et il apparaîtra ici."
|
||||
"Nothing to export" = "Rien à exporter"
|
||||
"Nov" = "nov."
|
||||
"Now" = "Maintenant"
|
||||
"Nudge (Shift: big nudge)" = "Décaler (Maj : grand décalage)"
|
||||
"Nudge amount" = "Pas de décalage"
|
||||
@@ -839,13 +888,16 @@ status = "draft — needs native review"
|
||||
"Number ↓" = "Numéro ↓"
|
||||
"Numbered list" = "Liste numérotée"
|
||||
"Object" = "Objet"
|
||||
"Oct" = "oct."
|
||||
"Off" = "Non"
|
||||
"Offered first when filling the slot (any content is allowed)" = "Proposés en premier pour remplir l’emplacement (tout contenu est autorisé)"
|
||||
"Offered first when swapping the instance (any component is allowed)" = "Proposés en premier pour permuter l’instance (tout composant est autorisé)"
|
||||
"Offset" = "Décalage"
|
||||
"Oldest first" = "Plus ancien d’abord"
|
||||
"On click" = "Au clic"
|
||||
"On drag" = "Au glissement"
|
||||
"On this page" = "Sur cette page"
|
||||
"One announcement in five common sizes: square, portrait, story, link and banner" = "Une annonce en cinq formats courants : carré, portrait, story, lien et bannière"
|
||||
"Online" = "En ligne"
|
||||
"Online files" = "Fichiers en ligne"
|
||||
"Online sharing is off: files are shared on the local network only." = "Le partage en ligne est désactivé : les fichiers ne sont partagés que sur le réseau local."
|
||||
@@ -870,9 +922,13 @@ status = "draft — needs native review"
|
||||
"Open variables ({count})" = "Ouvrir les variables ({count})"
|
||||
"Opened as a new document" = "Ouvert comme nouveau document"
|
||||
"Opened “{name}” in a new tab" = "« {name} » ouvert dans un nouvel onglet"
|
||||
"Opening the file…" = "Ouverture du fichier…"
|
||||
"Opening {n} files as new files" = { one = "Ouverture comme nouveau fichier", other = "Ouverture de {n} fichiers comme nouveaux fichiers" }
|
||||
"Open|comments" = "Ouverts"
|
||||
"Options" = "Options"
|
||||
"Orange" = "Orange"
|
||||
"Order" = "Ordre"
|
||||
"Other" = "Autre"
|
||||
"Other apps may not open every .fig file exported here." = "D’autres applications risquent de ne pas ouvrir tous les fichiers .fig exportés ici."
|
||||
"Others lose access and keep their copy; the file stays on the server for you" = "Les autres perdent l’accès et conservent leur copie ; le fichier reste sur le serveur pour vous"
|
||||
"Others type this under Share ▸ Join with a code" = "Les autres le saisissent dans Partager ▸ Rejoindre avec un code"
|
||||
@@ -893,6 +949,7 @@ status = "draft — needs native review"
|
||||
"Page" = "Page"
|
||||
"Pages" = "Pages"
|
||||
"Pages added to the document" = "Pages ajoutées au document"
|
||||
"Pick the Frame tool, then drag on the canvas or choose a device size" = "Choisissez l’outil Cadre, puis faites glisser sur le canevas ou choisissez une taille d’appareil"
|
||||
"Placed on the current page" = "Placé sur la page actuelle"
|
||||
"Pages: {pages}" = "Pages : {pages}"
|
||||
"Paint bucket" = "Pot de peinture"
|
||||
@@ -930,7 +987,10 @@ status = "draft — needs native review"
|
||||
"Place a shape or frame (its tool chosen)" = "Placer une forme ou un cadre (outil choisi)"
|
||||
"Place automatically" = "Placer automatiquement"
|
||||
"Place image" = "Placer une image"
|
||||
"Playground" = "Bac à sable"
|
||||
"Plugin" = "Plugin"
|
||||
"Plugins" = "Plugins"
|
||||
"Plugins & widgets" = "Plugins et widgets"
|
||||
"Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Les plugins s’exécutent dans le bac à sable de FigCraft, sans fichiers, réseau ni horloge. N’installez que des plugins de confiance."
|
||||
"Plus darker" = "Plus foncé"
|
||||
"Plus lighter" = "Plus clair"
|
||||
@@ -947,6 +1007,7 @@ status = "draft — needs native review"
|
||||
"Present or preview" = "Présenter ou prévisualiser"
|
||||
"Present this flow" = "Présenter ce flux"
|
||||
"Presentation" = "Présentation"
|
||||
"Presentation slides" = "Diapositives de présentation"
|
||||
"Presenting: comments" = "Présentation : commentaires"
|
||||
"Presenting: cycle the size options" = "Présentation : parcourir les options de taille"
|
||||
"Presenting: full screen" = "Présentation : plein écran"
|
||||
@@ -1003,6 +1064,7 @@ status = "draft — needs native review"
|
||||
"Remove auto layout" = "Retirer la mise en page auto"
|
||||
"Remove dev resource" = "Retirer la ressource de développement"
|
||||
"Remove export setting" = "Retirer le réglage d’exportation"
|
||||
"Remove from recents" = "Retirer des récents"
|
||||
"Remove from server" = "Retirer du serveur"
|
||||
"Remove from server…" = "Retirer du serveur…"
|
||||
"Remove guide" = "Retirer le repère"
|
||||
@@ -1115,15 +1177,18 @@ status = "draft — needs native review"
|
||||
"Search layers" = "Rechercher des calques"
|
||||
"Search plugins" = "Rechercher des plugins"
|
||||
"Search shortcuts" = "Rechercher des raccourcis"
|
||||
"Search templates" = "Rechercher des modèles"
|
||||
"Section" = "Section"
|
||||
"Section color" = "Couleur de la section"
|
||||
"Sections" = "Sections"
|
||||
"See all templates" = "Voir tous les modèles"
|
||||
"Select a frame and add a starting point to present it as a flow." = "Sélectionnez un cadre et ajoutez un point de départ pour le présenter comme un flux."
|
||||
"Select a frame to preview it" = "Sélectionnez un cadre pour le prévisualiser"
|
||||
"Select a layer to see its properties and code" = "Sélectionnez un calque pour voir ses propriétés et son code"
|
||||
"Select all" = "Tout sélectionner"
|
||||
"Select all with same" = "Sélectionner tout ce qui a le même"
|
||||
"Select layer" = "Sélectionner le calque"
|
||||
"Select layers and they space themselves as their content changes" = "Sélectionnez des calques et leur espacement s’ajuste quand leur contenu change"
|
||||
"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"
|
||||
@@ -1136,6 +1201,7 @@ status = "draft — needs native review"
|
||||
"Send backward" = "Reculer"
|
||||
"Send report" = "Envoyer le signalement"
|
||||
"Send to back" = "Mettre à l’arrière-plan"
|
||||
"Sep" = "sept."
|
||||
"Server address" = "Adresse du serveur"
|
||||
"Server address (ws://host:47900/)" = "Adresse du serveur (ws://host:47900/)"
|
||||
"Server for online sharing" = "Serveur pour le partage en ligne"
|
||||
@@ -1150,6 +1216,7 @@ status = "draft — needs native review"
|
||||
"Shape tools" = "Outils de forme"
|
||||
"Share" = "Partager"
|
||||
"Share this file" = "Partager ce fichier"
|
||||
"Share with a code" = "Partager avec un code"
|
||||
"Share: live session, export" = "Partager : session en direct, exportation"
|
||||
"Shared" = "Partagé"
|
||||
"Shared: copy its code from the file's menu" = "Partagé : copiez son code depuis le menu du fichier"
|
||||
@@ -1159,6 +1226,7 @@ status = "draft — needs native review"
|
||||
"Sharing online · {people}" = "Partage en ligne · {people}"
|
||||
"Sharing stopped: the file stays on the server, open only to you" = "Partage arrêté : le fichier reste sur le serveur, accessible à vous seul"
|
||||
"Show" = "Afficher"
|
||||
"Show all templates" = "Afficher tous les modèles"
|
||||
"Show as grid" = "Afficher en grille"
|
||||
"Show as list" = "Afficher en liste"
|
||||
"Show autosaves" = "Afficher les sauvegardes auto"
|
||||
@@ -1167,6 +1235,9 @@ status = "draft — needs native review"
|
||||
"Show device frame" = "Afficher le cadre de l’appareil"
|
||||
"Show dimensions on objects" = "Afficher les dimensions sur les objets"
|
||||
"Show hotspot hints" = "Afficher les zones interactives"
|
||||
"Show in Explorer" = "Afficher dans l’Explorateur"
|
||||
"Show in Finder" = "Afficher dans le Finder"
|
||||
"Show in folder" = "Afficher dans le dossier"
|
||||
"Show only this on the canvas" = "Afficher uniquement ceci sur le canevas"
|
||||
"Show open" = "Afficher les ouverts"
|
||||
"Show section contents" = "Afficher le contenu de la section"
|
||||
@@ -1191,13 +1262,17 @@ status = "draft — needs native review"
|
||||
"Small caps" = "Petites capitales"
|
||||
"Smart animate" = "Animation intelligente"
|
||||
"Snap to pixel grid" = "Aimanter à la grille de pixels"
|
||||
"Social and slides" = "Réseaux sociaux et diapositives"
|
||||
"Social media" = "Réseaux sociaux"
|
||||
"Social post sizes" = "Formats de publications sociales"
|
||||
"Soft light" = "Lumière douce"
|
||||
"Solid" = "Continu"
|
||||
"Some variants have the same property values: give each one a unique combination" = "Certaines variantes ont les mêmes valeurs de propriété : donnez à chacune une combinaison unique"
|
||||
"Sort" = "Trier"
|
||||
"Sort by" = "Trier par"
|
||||
"Sort by date" = "Trier par date"
|
||||
"Sort by page" = "Trier par page"
|
||||
"Sort by {column}" = "Trier par {column}"
|
||||
"Space around" = "Espace autour"
|
||||
"Space between" = "Espace entre"
|
||||
"Space drag" = "Espace et glisser"
|
||||
@@ -1216,9 +1291,11 @@ status = "draft — needs native review"
|
||||
"Start" = "Début"
|
||||
"Start ascending from / stop descending at" = "Compter vers le haut depuis / vers le bas jusqu’à"
|
||||
"Start from a blank canvas" = "Commencer avec un canevas vide"
|
||||
"Start from a template" = "Commencer avec un modèle"
|
||||
"Start on path" = "Début sur le tracé"
|
||||
"Start point" = "Point de départ"
|
||||
"Start sharing" = "Commencer le partage"
|
||||
"Start sharing with the Share button, then pass on the code it gives" = "Commencez à partager avec le bouton Partager, puis communiquez le code qu’il donne"
|
||||
"Starting to share…" = "Démarrage du partage…"
|
||||
"Status" = "Statut"
|
||||
"Sticky" = "Collant"
|
||||
@@ -1267,6 +1344,7 @@ status = "draft — needs native review"
|
||||
"Tell the server's operator what is wrong with it" = "Indiquez à l’opérateur du serveur ce qui ne va pas"
|
||||
"Temperature" = "Température"
|
||||
"Template" = "Modèle"
|
||||
"Templates" = "Modèles"
|
||||
"Terms" = "Conditions"
|
||||
"Text" = "Texte"
|
||||
"Text align" = "Alignement du texte"
|
||||
@@ -1276,6 +1354,9 @@ status = "draft — needs native review"
|
||||
"Texts" = "Textes"
|
||||
"Texture" = "Texture"
|
||||
"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "Le canevas affiche les couleurs en sRGB ; les fichiers PNG, JPG et WebP exportés sont balisés Display P3."
|
||||
"The destination document is unavailable. Nothing was imported." = "Le document de destination est indisponible. Rien n’a été importé."
|
||||
"The destination document was closed. Nothing was imported." = "Le document de destination a été fermé. Rien n’a été importé."
|
||||
"The destination page was removed. Nothing was imported." = "La page de destination a été supprimée. Rien n’a été importé."
|
||||
"The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "Le fichier est supprimé du serveur : plus personne ne pourra l’ouvrir ni le rejoindre depuis celui-ci, et c’est irréversible. Les copies ouvertes restent à ceux qui les ont."
|
||||
"The file now holds another library" = "Le fichier contient désormais une autre bibliothèque"
|
||||
"The file now holds another library (\"{name}\")" = "Le fichier contient désormais une autre bibliothèque (« {name} »)"
|
||||
@@ -1324,6 +1405,8 @@ status = "draft — needs native review"
|
||||
"Truncate with an ellipsis" = "Tronquer avec des points de suspension"
|
||||
"Try again" = "Réessayer"
|
||||
"Try another search or filter." = "Essayez une autre recherche ou un autre filtre."
|
||||
"Try another word, or look at every kind." = "Essayez un autre mot, ou regardez chaque type."
|
||||
"Turn a layer into a component, reuse it, and change every copy at once" = "Transformez un calque en composant, réutilisez-le et modifiez toutes les copies d’un coup"
|
||||
"Two digits" = "Deux chiffres"
|
||||
"Type" = "Type"
|
||||
"Type settings" = "Réglages typographiques"
|
||||
@@ -1362,6 +1445,7 @@ status = "draft — needs native review"
|
||||
"Use the layer's fill" = "Utiliser le remplissage du calque"
|
||||
"Used in {files} · {instances}" = "Utilisé dans {files} · {instances}"
|
||||
"Username" = "Nom d’utilisateur"
|
||||
"Uses variables: each layer shows them in its own mode" = "Utilise des variables : chaque calque les affiche dans son propre mode"
|
||||
"Value" = "Valeur"
|
||||
"Variable" = "Variable"
|
||||
"Variable collections" = "Collections de variables"
|
||||
@@ -1400,21 +1484,25 @@ status = "draft — needs native review"
|
||||
"Web" = "Web"
|
||||
"WebSocket URL" = "URL WebSocket"
|
||||
"WebSocket URL copied" = "URL WebSocket copiée"
|
||||
"Websites" = "Sites web"
|
||||
"Wedge" = "Coin"
|
||||
"Weight" = "Graisse"
|
||||
"What changed since it was marked ready for dev" = "Ce qui a changé depuis le marquage prêt pour le dev"
|
||||
"What is wrong with this file? The server's operator gets your report." = "Qu’est-ce qui ne va pas avec ce fichier ? L’opérateur du serveur reçoit votre signalement."
|
||||
"What it's for" = "À quoi il sert"
|
||||
"What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "L’état d’origine de cette modification est inconnu : elle date d’avant que FigCraft ne conserve une copie des cadres prêts pour le dev. Résolvez la modification (ou marquez-la à nouveau comme prête) pour comparer à partir de maintenant."
|
||||
"What you try here changes this preview and the code below, not the file" = "Ce que vous essayez ici change cet aperçu et le code ci-dessous, pas le fichier"
|
||||
"While hovering" = "Pendant le survol"
|
||||
"While pressing" = "Pendant l’appui"
|
||||
"White" = "Blanc"
|
||||
"Who runs it, its terms and how long it keeps files" = "Qui le gère, ses conditions et combien de temps il conserve les fichiers"
|
||||
"Why it changed" = "Pourquoi cela a changé"
|
||||
"Widget" = "Widget"
|
||||
"Widgets" = "Widgets"
|
||||
"Width" = "Largeur"
|
||||
"Width point" = "Point de largeur"
|
||||
"Width profile" = "Profil de largeur"
|
||||
"Wireframe kit" = "Kit de wireframes"
|
||||
"With the layers' export settings" = "Avec les réglages d’exportation des calques"
|
||||
"Wrap" = "Renvoi à la ligne"
|
||||
"Wrap in new section" = "Envelopper dans une nouvelle section"
|
||||
@@ -1429,6 +1517,7 @@ status = "draft — needs native review"
|
||||
"Your name in comments" = "Votre nom dans les commentaires"
|
||||
"Your system asks for reduced motion: animations stay off." = "Votre système demande de réduire les animations : elles restent désactivées."
|
||||
"Z" = "Z"
|
||||
"Z to A" = "Z à A"
|
||||
"Zoom" = "Zoom"
|
||||
"Zoom around the pointer" = "Zoomer autour du pointeur"
|
||||
"Zoom device to fill window" = "Zoomer l’appareil pour remplir la fenêtre"
|
||||
@@ -1449,13 +1538,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"
|
||||
@@ -1464,26 +1565,39 @@ 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"
|
||||
"just now" = "à l’instant"
|
||||
"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"
|
||||
@@ -1501,8 +1615,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"
|
||||
@@ -1529,9 +1649,11 @@ status = "draft — needs native review"
|
||||
"very long vector paths were skipped" = "les tracés vectoriels très longs ont été ignorés"
|
||||
"video actions are not supported and were skipped" = "les actions vidéo ne sont pas prises en charge et ont été ignorées"
|
||||
"video triggers are not supported and were skipped" = "les déclencheurs vidéo ne sont pas pris en charge et ont été ignorés"
|
||||
"yesterday" = "hier"
|
||||
"{instances} in {files}" = "{instances} dans {files}"
|
||||
"{language}: draft translation, waiting for a native speaker's review." = "{language} : traduction provisoire, en attente de relecture par un locuteur natif."
|
||||
"{length} bleed" = "fond perdu de {length}"
|
||||
"{month} {day}, {year}" = "{day} {month} {year}"
|
||||
"{name}\nClick to apply to the selection" = "{name}\nCliquez pour l’appliquer à la sélection"
|
||||
"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nFaites glisser sur le canevas, ou cliquez pour le placer au centre de la vue.\nMaintenez Alt (Option) en le déposant sur une instance pour la permuter."
|
||||
"{name} (from {library})" = "{name} (de {library})"
|
||||
@@ -1696,8 +1818,10 @@ status = "draft — needs native review"
|
||||
"component.editProperty" = "Modifier la propriété de composant"
|
||||
"component.exposeInstance" = "Exposer l’instance imbriquée"
|
||||
"component.goToMain" = "Aller au composant principal"
|
||||
"component.hideWhenPublishing" = "Masquer à la publication"
|
||||
"component.list" = "Lister les composants"
|
||||
"component.makeSlot" = "Créer un emplacement"
|
||||
"component.playground" = "Bac à sable de composant"
|
||||
"component.properties" = "Propriétés du composant"
|
||||
"component.pushChanges" = "Transmettre les modifications au composant principal"
|
||||
"component.removeSlot" = "Retirer l’emplacement"
|
||||
@@ -1756,6 +1880,7 @@ status = "draft — needs native review"
|
||||
"file.exportSvg" = "Exporter la sélection en SVG"
|
||||
"file.import" = "Importer"
|
||||
"file.new" = "Nouveau fichier de design"
|
||||
"file.newFromTemplate" = "Nouveau à partir d’un modèle"
|
||||
"file.open" = "Ouvrir"
|
||||
"file.openRecent" = "Ouvrir un fichier récent"
|
||||
"file.placeImage" = "Placer une image"
|
||||
@@ -1783,6 +1908,7 @@ status = "draft — needs native review"
|
||||
"guide.remove" = "Retirer le repère"
|
||||
"guide.select" = "Sélectionner le repère"
|
||||
"help.about" = "À propos de FigCraft"
|
||||
"help.gettingStarted" = "Premiers pas"
|
||||
"help.page" = "Page d’aide"
|
||||
"help.shortcuts" = "Raccourcis clavier"
|
||||
"image.adjust" = "Régler l’image"
|
||||
@@ -1836,6 +1962,9 @@ status = "draft — needs native review"
|
||||
"node.reparent" = "Déplacer le calque dans"
|
||||
"node.set" = "Définir les propriétés"
|
||||
"object.batchRename" = "Renommer par lot"
|
||||
"object.convertToFrame" = "Convertir en cadre"
|
||||
"object.convertToGroup" = "Convertir en groupe"
|
||||
"object.convertToSection" = "Convertir en section"
|
||||
"object.flipH" = "Retourner horizontalement"
|
||||
"object.flipV" = "Retourner verticalement"
|
||||
"object.frameSelection" = "Encadrer la sélection"
|
||||
@@ -1956,6 +2085,7 @@ status = "draft — needs native review"
|
||||
"stroke.set" = "Définir le contour"
|
||||
"stroke.widthProfile" = "Profil de largeur"
|
||||
"style.apply" = "Appliquer le style"
|
||||
"style.bind" = "Lier une variable dans un style"
|
||||
"style.create" = "Créer un style"
|
||||
"style.delete" = "Supprimer le style"
|
||||
"style.detach" = "Détacher le style"
|
||||
@@ -1964,6 +2094,7 @@ status = "draft — needs native review"
|
||||
"style.move" = "Déplacer les styles"
|
||||
"style.renameFolder" = "Renommer le dossier de styles"
|
||||
"style.update" = "Modifier le style"
|
||||
"template.list" = "Lister les modèles"
|
||||
"text.alignCenter" = "Centrer"
|
||||
"text.alignJustify" = "Justifier"
|
||||
"text.alignLeft" = "Aligner à gauche"
|
||||
@@ -2129,8 +2260,11 @@ status = "draft — needs native review"
|
||||
"view.nextPage" = "Page suivante"
|
||||
"view.nextRegion" = "Activer la région suivante"
|
||||
"view.outlines" = "Contours"
|
||||
"view.outlinesHiddenLayers" = "Inclure les calques masqués dans les contours"
|
||||
"view.outlinesObjectBounds" = "Inclure les limites des objets dans les contours"
|
||||
"view.outlinesSelection" = "Contours de la sélection"
|
||||
"view.pan" = "Faire défiler la vue"
|
||||
"view.panelWidths" = "Réinitialiser la largeur des panneaux"
|
||||
"view.pixelGrid" = "Grille de pixels"
|
||||
"view.pixelPreview" = "Aperçu des pixels"
|
||||
"view.prevFrame" = "Zoomer sur le cadre précédent"
|
||||
@@ -2188,6 +2322,8 @@ status = "draft — needs native review"
|
||||
"view.design" = "Design"
|
||||
"view.layers" = "Calques"
|
||||
"view.outlines" = "Afficher les contours"
|
||||
"view.outlinesHiddenLayers" = "Inclure les calques masqués"
|
||||
"view.outlinesObjectBounds" = "Inclure les limites des objets"
|
||||
"view.outlinesSelection" = "Afficher les contours de la sélection"
|
||||
"view.prototype" = "Prototype"
|
||||
"view.themeDark" = "Sombre"
|
||||
|
||||
@@ -638,6 +638,7 @@ status = "draft — needs native review"
|
||||
"Instance swap (no other component)" = "Tukar instance (tidak ada component lain)"
|
||||
"Instances" = "Instance"
|
||||
"Instant" = "Instan"
|
||||
"Intensity" = "Intensitas"
|
||||
"Interaction" = "Interaksi"
|
||||
"Interactions" = "Interaksi"
|
||||
"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."
|
||||
@@ -1512,13 +1513,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"
|
||||
@@ -1527,14 +1540,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"
|
||||
@@ -1542,12 +1561,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"
|
||||
@@ -1565,8 +1590,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"
|
||||
|
||||
@@ -643,6 +643,7 @@ status = "draft — needs native review"
|
||||
"Instance swap (no other component)" = "インスタンスの入れ替え(他のコンポーネントなし)"
|
||||
"Instances" = "インスタンス"
|
||||
"Instant" = "即時"
|
||||
"Intensity" = "強さ"
|
||||
"Interaction" = "インタラクション"
|
||||
"Interactions" = "インタラクション"
|
||||
"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 の一つ。"
|
||||
@@ -1522,13 +1523,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" = "保存されたパスのない折れ線・曲線コネクタは直線としてインポート"
|
||||
@@ -1537,14 +1550,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" = "たった今"
|
||||
@@ -1552,12 +1571,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 にない変数を使うプロトタイプの式をスキップ"
|
||||
@@ -1575,8 +1600,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" = "このプラグイン"
|
||||
|
||||
@@ -615,6 +615,7 @@ status = "draft — needs native review"
|
||||
"Instance swap (no other component)" = "인스턴스 교체(다른 컴포넌트 없음)"
|
||||
"Instances" = "인스턴스"
|
||||
"Instant" = "즉시"
|
||||
"Intensity" = "강도"
|
||||
"Interaction" = "인터랙션"
|
||||
"Interactions" = "인터랙션"
|
||||
"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의 하나."
|
||||
@@ -1449,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" = "저장된 경로가 없는 꺾인 커넥터와 곡선 커넥터는 직선으로 가져옴"
|
||||
@@ -1464,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에 없는 변수에 대한 프로토타입 수식을 건너뜀"
|
||||
@@ -1501,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" = "이 플러그인"
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -614,6 +614,7 @@ status = "draft — needs native review"
|
||||
"Instance swap (no other component)" = "Troca de instância (nenhum outro componente)"
|
||||
"Instances" = "Instâncias"
|
||||
"Instant" = "Instantâneo"
|
||||
"Intensity" = "Brilho"
|
||||
"Interaction" = "Interação"
|
||||
"Interactions" = "Interações"
|
||||
"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."
|
||||
@@ -1448,13 +1449,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"
|
||||
@@ -1463,26 +1476,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"
|
||||
@@ -1500,8 +1525,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"
|
||||
|
||||
@@ -635,6 +635,7 @@ name = "Русский"
|
||||
"Instance swap (no other component)" = "Замена экземпляра (других компонентов нет)"
|
||||
"Instances" = "Экземпляры"
|
||||
"Instant" = "Мгновенно"
|
||||
"Intensity" = "Яркость"
|
||||
"Interaction" = "Взаимодействие"
|
||||
"Interactions" = "Взаимодействия"
|
||||
"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."
|
||||
@@ -818,6 +819,7 @@ name = "Русский"
|
||||
"No contributor data was built into this copy." = "В этой сборке нет данных об участниках."
|
||||
"No device" = "Без устройства"
|
||||
"No fonts match" = "Подходящих шрифтов нет"
|
||||
"No files or templates match your search" = "Файлы и шаблоны по запросу не найдены"
|
||||
"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 libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Библиотек пока нет. Добавьте файл .figcraft, опубликованный как библиотека, или вставьте ссылку на командную библиотеку."
|
||||
@@ -1156,6 +1158,8 @@ name = "Русский"
|
||||
"Search actions, layers and tools" = "Поиск действий, слоёв и инструментов"
|
||||
"Search assets" = "Поиск ресурсов"
|
||||
"Search comments" = "Поиск комментариев"
|
||||
"Search files and templates" = "Поиск файлов и шаблонов"
|
||||
"Search results" = "Результаты поиска"
|
||||
"Search fonts" = "Поиск шрифтов"
|
||||
"Search layers" = "Поиск слоёв"
|
||||
"Search shortcuts" = "Поиск сочетаний клавиш"
|
||||
@@ -1381,6 +1385,7 @@ name = "Русский"
|
||||
"Truncate with an ellipsis" = "Обрезать с многоточием"
|
||||
"Try again" = "Повторить"
|
||||
"Try another search or filter." = "Попробуйте другой запрос или фильтр."
|
||||
"Try another search, or browse templates." = "Попробуйте другой запрос или просмотрите шаблоны."
|
||||
"Try another word, or look at every kind." = "Попробуйте другое слово или посмотрите все категории."
|
||||
"Turn a layer into a component, reuse it, and change every copy at once" = "Превратите слой в компонент, используйте повторно и изменяйте все копии сразу"
|
||||
"Two digits" = "Две цифры"
|
||||
@@ -1510,13 +1515,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" = "ломаные и изогнутые соединительные линии без сохранённого пути импортируются как прямые"
|
||||
@@ -1525,14 +1542,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" = "только что"
|
||||
@@ -1540,12 +1563,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 переменных, пропущены"
|
||||
@@ -1563,8 +1592,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" = "плагин"
|
||||
|
||||
@@ -645,6 +645,7 @@ name = "Svenska"
|
||||
"Instance swap (no other component)" = "Instansbyte (ingen annan komponent)"
|
||||
"Instances" = "Instanser"
|
||||
"Instant" = "Direkt"
|
||||
"Intensity" = "Intensitet"
|
||||
"Interaction" = "Interaktion"
|
||||
"Interactions" = "Interaktioner"
|
||||
"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."
|
||||
@@ -1529,13 +1530,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"
|
||||
@@ -1544,14 +1557,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"
|
||||
@@ -1559,12 +1578,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"
|
||||
@@ -1582,8 +1607,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"
|
||||
|
||||
@@ -638,6 +638,7 @@ name = "Türkçe"
|
||||
"Instance swap (no other component)" = "Örnek değiştirme (başka bileşen yok)"
|
||||
"Instances" = "Örnekler"
|
||||
"Instant" = "Anında"
|
||||
"Intensity" = "Yoğunluk"
|
||||
"Interaction" = "Etkileşim"
|
||||
"Interactions" = "Etkileşimler"
|
||||
"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ı."
|
||||
@@ -1512,13 +1513,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"
|
||||
@@ -1527,14 +1540,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"
|
||||
@@ -1542,12 +1561,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ı"
|
||||
@@ -1565,8 +1590,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"
|
||||
|
||||
@@ -616,6 +616,7 @@ status = "draft — needs native review"
|
||||
"Instance swap (no other component)" = "实例替换(无其他组件)"
|
||||
"Instances" = "实例"
|
||||
"Instant" = "即时"
|
||||
"Intensity" = "强度"
|
||||
"Interaction" = "交互"
|
||||
"Interactions" = "交互"
|
||||
"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 系列。"
|
||||
@@ -1450,13 +1451,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" = "没有已保存路径的折线和曲线连接线导入为直线"
|
||||
@@ -1465,26 +1478,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 中没有的变量的原型表达式"
|
||||
@@ -1502,8 +1527,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" = "插件"
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -242,6 +242,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
|
||||
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);
|
||||
@@ -1128,7 +1129,9 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
|
||||
if !ov.pen.is_empty() {
|
||||
let pts: Vec<Pos2> = ov.pen.iter().map(|[x, y]| app.to_screen(*x, *y)).collect();
|
||||
if app.session.tool.tool == ToolId::Pencil {
|
||||
painter.add(Shape::line(pts, stroke(t.text, 2.0)));
|
||||
// The stroke it will make, in its colour and weight (at least a point when zoomed out).
|
||||
let (color, weight) = figcraft_engine::tools::PENCIL_STROKE;
|
||||
painter.add(Shape::line(pts, stroke(doc_c32(painter.ctx(), color), ((weight * zoom) as f32).max(1.0))));
|
||||
} else {
|
||||
for [x0, y0, x1, y1] in &ov.pen_handles {
|
||||
bezier_handle(painter, app.to_screen(*x0, *y0), app.to_screen(*x1, *y1), 3.5, Color32::WHITE);
|
||||
|
||||
@@ -128,3 +128,36 @@ fn the_pen_draws_the_handles_of_the_point_being_dragged_and_a_curved_band() {
|
||||
let band = shapes.iter().any(|s| matches!(s, Shape::CubicBezier(c) if c.points[0].distance(a) < 0.5 && c.points[1].distance(at(&u, 140.0, 100.0)) < 0.5 && c.stroke.color == accent));
|
||||
assert!(band, "a curve leaving along the out handle");
|
||||
}
|
||||
|
||||
/// While drawing, the Pencil shows the stroke it will make, in its colour and weight (#256): in a
|
||||
/// dark theme the theme's text colour was white, so over a white shape nothing showed until the
|
||||
/// stroke was let go.
|
||||
#[test]
|
||||
fn the_pencil_previews_its_stroke_in_its_colour_and_weight() {
|
||||
let mut u = Ui::new(Session::new());
|
||||
u.app.run("tool.set", json!({"tool": "pencil"}));
|
||||
let (color, weight) = figcraft_engine::tools::PENCIL_STROKE;
|
||||
let preview = |shapes: &[Shape]| {
|
||||
shapes.iter().find_map(|s| match s {
|
||||
Shape::Path(p) if !p.closed && p.points.len() >= 3 && p.stroke.color == egui::epaint::ColorMode::Solid(crate::theme::c32(color)) => {
|
||||
Some(p.stroke.width)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
let at = |u: &Ui, x: f64, y: f64| u.app.to_screen(x, y);
|
||||
let start = at(&u, 10.0, 10.0);
|
||||
u.button(start, true);
|
||||
for i in 1..=6 {
|
||||
let p = at(&u, 10.0 + 12.0 * f64::from(i), 10.0 + 6.0 * f64::from(i % 2));
|
||||
u.frame(vec![egui::Event::PointerMoved(p)]);
|
||||
}
|
||||
let zoom = u.app.session.ds().unwrap().view.zoom;
|
||||
let w = preview(&flat(&u.frame_output(vec![]).shapes)).expect("a preview while drawing");
|
||||
assert!((w - (weight * zoom) as f32).abs() < 0.01, "{w} for {weight} at {zoom}");
|
||||
// Zoomed far out it stays visible.
|
||||
u.app.session.ds_mut().unwrap().view.zoom = 0.05;
|
||||
let p = at(&u, 120.0, 40.0);
|
||||
let w = preview(&flat(&u.frame_output(vec![egui::Event::PointerMoved(p)]).shapes)).expect("still previewing");
|
||||
assert!((w - 1.0).abs() < 0.01, "at least a point: {w}");
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -120,8 +120,7 @@ fn current_paint(app: &FigApp, target: &PickerTarget) -> Option<Paint> {
|
||||
/// Write `paint` to the target. `live` edits are part of an ongoing drag.
|
||||
fn write(app: &mut FigApp, target: &PickerTarget, paint: &Paint, live: bool) {
|
||||
if let PickerTarget::PluginField { plugin, control, .. } = target {
|
||||
let c = paint.solid_color().unwrap_or(Color::BLACK);
|
||||
let hex = c.with_alpha(c.a * paint.opacity).to_css_hex();
|
||||
let hex = paint.solid_color().unwrap_or(Color::BLACK).to_css_hex();
|
||||
let (plugin, control) = (plugin.clone(), control.clone());
|
||||
if !live {
|
||||
crate::plugins_ui::set_field(app, &plugin, &control, &hex);
|
||||
@@ -153,7 +152,7 @@ fn write(app: &mut FigApp, target: &PickerTarget, paint: &Paint, live: bool) {
|
||||
}
|
||||
PickerTarget::Variable { id, mode } => {
|
||||
let c = paint.solid_color().unwrap_or(Color::BLACK);
|
||||
app.session.execute("variable.set", &json!({"variable": id, "mode": mode, "value": c.with_alpha(c.a * paint.opacity).to_css_hex()}))
|
||||
app.session.execute("variable.set", &json!({"variable": id, "mode": mode, "value": c.to_css_hex()}))
|
||||
}
|
||||
PickerTarget::PluginField { .. } => return,
|
||||
PickerTarget::Style { id, index } => {
|
||||
@@ -1181,6 +1180,22 @@ mod selection_color_tests {
|
||||
assert_eq!(app.session.doc().unwrap().node(a).unwrap().fills[0].solid_color().unwrap().to_hex(), "FF0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writing_a_variable_keeps_its_alpha() {
|
||||
// The picker holds a colour variable's alpha as the paint opacity; writing it back applies it once.
|
||||
let (mut app, _, _) = setup();
|
||||
app.session.execute("variable.createCollection", &json!({"name":"T"})).unwrap();
|
||||
let v = app.session.execute("variable.create", &json!({"collection":"T","name":"tint","type":"color","value":"#0D6EFD80"})).unwrap();
|
||||
let var = app.session.doc().unwrap().variables.get(v["id"].as_str().unwrap()).unwrap().clone();
|
||||
let target = PickerTarget::Variable { id: var.id.clone(), mode: var.values.keys().next().unwrap().clone() };
|
||||
for _ in 0..2 {
|
||||
let paint = current_paint(&app, &target).unwrap();
|
||||
write(&mut app, &target, &paint, false);
|
||||
}
|
||||
let c = current_paint(&app, &target).unwrap().solid_color().unwrap();
|
||||
assert_eq!(c.to_css_hex().to_uppercase(), "#0D6EFD80");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_does_not_follow_selection_or_document_switches() {
|
||||
let (mut app, _, _) = setup();
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
//! for the frames that follow (all false: released); `gamepad` presses a controller button in the
|
||||
//! prototype playing (presenter or preview pane)
|
||||
//! - `ui.menu.invoke {id}` — a menu item exactly like clicking it (may open a dialog)
|
||||
//! - `ui.set {rightTab?, leftTab?, theme?, home?, homeSection?, homeRecentsView?, homeRecentsSort?, homeRecentsOrder?, palette?, focusRegion?, preview?, presentScroll?, …}`
|
||||
//! - `ui.set {rightTab?, leftTab?, theme?, home?, homeSection?, homeSearch?, homeRecentsView?, homeRecentsSort?, homeRecentsOrder?, palette?, focusRegion?, preview?, presentScroll?, …}`
|
||||
//! - `ui.screenshot {path?}` — PNG of the window (needs a visible window), written to `path` or
|
||||
//! returned as base64 `data`
|
||||
//! - `ui.render {path?, scale?, ids?}` — PNG of the document (no UI chrome; works without a
|
||||
@@ -429,6 +429,9 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
|
||||
if let Some(section) = p.get("homeSection").and_then(Value::as_str).and_then(crate::home::Section::parse) {
|
||||
app.ui.home_view.section = section;
|
||||
}
|
||||
if let Some(query) = p.get("homeSearch").and_then(Value::as_str) {
|
||||
app.ui.home_view.file_query = query.chars().take(256).collect();
|
||||
}
|
||||
if let Some(view) = p.get("homeRecentsView").and_then(Value::as_str).and_then(crate::home_recents::View::parse) {
|
||||
view.store(&mut app.session.prefs);
|
||||
}
|
||||
|
||||
@@ -57,6 +57,24 @@ fn a_confined_app_writes_only_inside_its_write_root() {
|
||||
assert!(app.session.execute("file.export", &json!({"format": "svg", "path": p("outside/own.svg")})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_confined_app_saves_only_inside_its_write_root() {
|
||||
// #836: saving over the control channel, both as a new file and to the current one.
|
||||
let (mut app, d) = confined_app("save");
|
||||
let p = |rel: &str| d.0.join(rel).to_string_lossy().into_owned();
|
||||
for cmd in ["file.saveAs", "file.save"] {
|
||||
let e = handle(&mut app, "engine.execute", json!({"command": cmd, "params": {"path": p("outside/a.figcraft")}})).unwrap_err();
|
||||
assert!(e.contains("automation path rejected"), "{cmd}: {e}");
|
||||
let e = handle(&mut app, "engine.execute", json!({"command": cmd, "params": {"path": p("out/../outside/b.figcraft")}})).unwrap_err();
|
||||
assert!(e.contains("automation path rejected"), "{cmd}: {e}");
|
||||
}
|
||||
assert_eq!(std::fs::read_dir(d.0.join("outside")).unwrap().count(), 0, "nothing written outside the root");
|
||||
handle(&mut app, "engine.execute", json!({"command": "file.saveAs", "params": {"path": p("out/in.figcraft")}})).unwrap();
|
||||
assert!(d.0.join("out/in.figcraft").is_file());
|
||||
// Saving again goes to the same file, inside the root.
|
||||
handle(&mut app, "engine.execute", json!({"command": "file.save", "params": {}})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_captures_are_written_through_the_roots_or_returned() {
|
||||
let (app, d) = confined_app("capture");
|
||||
|
||||
@@ -635,8 +635,9 @@ pub fn dev_resources_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id:
|
||||
};
|
||||
ui.painter().text(pos2(x, r.center().y + 7.0), Align2::LEFT_CENTER, widgets::elide(ui, &below, text_w), regular(10.0), t.text_secondary);
|
||||
crate::a11y::link(&resp, name);
|
||||
// Opened only on a click, never fetched.
|
||||
if resp.on_hover_text(url.as_str()).clicked() {
|
||||
// Opened only on a click, never fetched, and a dev resource's URL comes from the
|
||||
// document, so only a web link opens.
|
||||
if resp.on_hover_text(url.as_str()).clicked() && figcraft_engine::doc_urls::openable(url) {
|
||||
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
|
||||
}
|
||||
if let Some(i) = item.index {
|
||||
|
||||
@@ -151,6 +151,7 @@ pub fn keys(app: &mut FigApp, ctx: &Context) {
|
||||
|| u.find.is_some()
|
||||
|| u.batch_rename.is_some()
|
||||
|| u.import_report.is_some()
|
||||
|| u.missing_fonts.is_some()
|
||||
|| u.confirm_close
|
||||
|| u.export_preview
|
||||
|| u.shortcuts_panel
|
||||
@@ -164,6 +165,7 @@ pub fn keys(app: &mut FigApp, ctx: &Context) {
|
||||
|| u.find.take().is_some()
|
||||
|| u.batch_rename.take().is_some()
|
||||
|| u.import_report.take().is_some()
|
||||
|| u.missing_fonts.take().is_some()
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -178,6 +180,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
|
||||
// Dropped files waiting to open: their card, then (once it has shown) the files.
|
||||
crate::pending_open::pending_opens(app, ctx);
|
||||
crate::import_report::show(app, ctx);
|
||||
crate::missing_fonts::show(app, ctx);
|
||||
let t = Tokens::get(ctx);
|
||||
if app.ui.about {
|
||||
let mut open = true;
|
||||
|
||||
@@ -60,20 +60,23 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// One export setting: size (typed or picked), suffix, format, advanced options and remove; a
|
||||
/// PDF's print settings under it.
|
||||
/// One export setting: size (typed or picked; images only, SVG and PDF are vector), suffix,
|
||||
/// format, advanced options and remove; a PDF's print settings under it.
|
||||
fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &ExportSetting) {
|
||||
row(ui, 32.0, |ui| {
|
||||
// Tighter spacing so the advanced options and remove buttons fit beside the fields.
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
let (size_w, format_w, button, gap) = (52.0, 54.0, 22.0, ui.spacing().item_spacing.x);
|
||||
let suffix_w = (ui.available_width() - size_w - format_w - button * 2.0 - gap * 4.0).max(32.0);
|
||||
let presets: Vec<String> =
|
||||
ExportSize::SCALES.iter().map(|value| ExportSize::Scale { value: *value }.label()).chain(["512w".into(), "512h".into()]).collect();
|
||||
if let Some(s) = widgets::combo_field(ui, Id::new(("export_size", i)), &e.size.label(), size_w, &presets)
|
||||
&& ExportSize::parse(&s) != Some(e.size)
|
||||
{
|
||||
app.run("export.update", json!({"ids": ids, "index": i, "size": s}));
|
||||
let (format_w, button, gap) = (54.0, 22.0, ui.spacing().item_spacing.x);
|
||||
let size_w = e.format.is_raster().then_some(52.0);
|
||||
let suffix_w = (ui.available_width() - size_w.map_or(0.0, |w| w + gap) - format_w - button * 2.0 - gap * 3.0).max(32.0);
|
||||
if let Some(size_w) = size_w {
|
||||
let presets: Vec<String> =
|
||||
ExportSize::SCALES.iter().map(|value| ExportSize::Scale { value: *value }.label()).chain(["512w".into(), "512h".into()]).collect();
|
||||
if let Some(s) = widgets::combo_field(ui, Id::new(("export_size", i)), &e.size.label(), size_w, &presets)
|
||||
&& ExportSize::parse(&s) != Some(e.size)
|
||||
{
|
||||
app.run("export.update", json!({"ids": ids, "index": i, "size": s}));
|
||||
}
|
||||
}
|
||||
if let Some(sfx) = widgets::text_field(ui, Id::new(("export_suffix", i)), &e.suffix, suffix_w, tr("Suffix"))
|
||||
&& sfx != e.suffix
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! The Export section's Preview row: a slice paints nothing itself, so its preview must show the
|
||||
//! page content under it — the same thing exporting the slice writes — and not a blank
|
||||
//! transparency checkerboard next to a correct export.
|
||||
//! transparency checkerboard next to a correct export. And its setting rows: a size only for
|
||||
//! images.
|
||||
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use figcraft_engine::Session;
|
||||
@@ -50,3 +51,28 @@ fn other_layers_preview_themselves() {
|
||||
assert_eq!([pix.width(), pix.height()], [200, 100], "the layer's own area");
|
||||
assert!(paints(&pix));
|
||||
}
|
||||
|
||||
/// SVG and PDF are vector: their export rows offer no size, which their export ignores (#256).
|
||||
#[test]
|
||||
fn export_rows_offer_a_size_for_images_only() {
|
||||
let mut s = Session::new();
|
||||
let r = crate::present_tests::id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 80})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [r]})).unwrap();
|
||||
s.execute("export.add", &json!({"ids": [r]})).unwrap();
|
||||
let mut u = crate::layers_drag_tests::Ui::new(s);
|
||||
let size_menu = |u: &crate::layers_drag_tests::Ui| u.ctx.read_response(egui::Id::new(("export_size", 0)).with("menu")).is_some();
|
||||
u.frame(vec![]);
|
||||
assert!(size_menu(&u), "PNG has sizes");
|
||||
for format in ["svg", "pdf"] {
|
||||
u.app.run("export.update", json!({"ids": [r], "index": 0, "format": format}));
|
||||
u.frame(vec![]);
|
||||
u.frame(vec![]);
|
||||
assert!(!size_menu(&u), "{format} is vector: no size");
|
||||
}
|
||||
for format in ["jpg", "webp"] {
|
||||
u.app.run("export.update", json!({"ids": [r], "index": 0, "format": format}));
|
||||
u.frame(vec![]);
|
||||
u.frame(vec![]);
|
||||
assert!(size_menu(&u), "{format} has sizes again");
|
||||
}
|
||||
}
|
||||
@@ -473,3 +473,79 @@ fn scrubbing_the_scale_panel_width_scales_live_in_one_undo_step() {
|
||||
let (_, _, w, h) = geom(&u, a);
|
||||
assert_eq!((w, h, undo_steps(&u)), (60.0, 45.0, steps + 1), "40×30 scaled to a width of 60");
|
||||
}
|
||||
|
||||
/// The texts painted in the last frame: what they say, whether they end in an ellipsis, and where
|
||||
/// they show (within their clip).
|
||||
fn painted_texts(shapes: &[egui::epaint::ClippedShape]) -> Vec<(String, bool, egui::Rect)> {
|
||||
fn walk(shape: &egui::Shape, clip: egui::Rect, out: &mut Vec<(String, bool, egui::Rect)>) {
|
||||
match shape {
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, clip, out)),
|
||||
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.galley.elided, t.visual_bounding_rect().intersect(clip))),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
shapes.iter().for_each(|c| walk(&c.shape, c.clip_rect, &mut out));
|
||||
out
|
||||
}
|
||||
|
||||
/// The glass effect's light intensity field is labelled (#256): drawn in the Design panel with the
|
||||
/// effect's settings open.
|
||||
#[test]
|
||||
fn the_glass_light_intensity_field_has_a_label() {
|
||||
let mut s = Session::new();
|
||||
let r = NodeId(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 80})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [r]})).unwrap();
|
||||
s.execute("effect.add", &json!({"type": "glass"})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
u.ctx.data_mut(|d| d.insert_temp(Id::new(("fx_open", 0usize)), true));
|
||||
u.frame(vec![]);
|
||||
let shapes = u.frame_output(vec![]).shapes;
|
||||
let lead = u.ctx.read_response(Id::new(("fx_gi", 0usize)).with("lead")).expect("the intensity field is shown").rect;
|
||||
let labels: Vec<(String, bool)> =
|
||||
painted_texts(&shapes).into_iter().filter(|(_, _, at)| lead.contains(at.center())).map(|(text, cut, _)| (text, cut)).collect();
|
||||
assert_eq!(labels, [("Intensity".to_string(), false)], "labelled, and not cut short");
|
||||
let field = u.ctx.read_response(Id::new(("fx_gi", 0usize)).with("field")).expect("its value").rect;
|
||||
assert_eq!(field.max.x - lead.min.x, GLASS_FIELD_W, "the width the next test measures against");
|
||||
}
|
||||
|
||||
/// A glass settings field's width in the Design panel at its default width.
|
||||
const GLASS_FIELD_W: f32 = 98.5;
|
||||
|
||||
/// The light intensity label this fix adds fits whole beside its value in every language (#256).
|
||||
/// Other glass labels may end in "…" in some languages (es "Profundidad"); a longer
|
||||
/// "Intensity" translation fails here.
|
||||
#[test]
|
||||
fn the_intensity_label_fits_whole_in_every_language() {
|
||||
use crate::i18n::{self, Text};
|
||||
let ctx = egui::Context::default();
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| {
|
||||
let width = |s: &str| ui.painter().layout_no_wrap(s.to_string(), crate::theme::regular(11.0), egui::Color32::WHITE).size().x;
|
||||
for lang in i18n::languages() {
|
||||
let label = lang.ui.iter().find(|(k, _)| *k == "Intensity").map_or("Intensity", |(_, t)| match t {
|
||||
Text::Plain(s) | Text::Plural { other: s, .. } => s,
|
||||
});
|
||||
let lead = crate::widgets::text_lead_width(ui, label, GLASS_FIELD_W);
|
||||
assert!(lead >= 12.0 + width(label), "{}: {label:?} would end in \"…\"", lang.code);
|
||||
}
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_label_too_long_for_its_field_ends_in_an_ellipsis_before_the_value() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut field = egui::Rect::NOTHING;
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| {
|
||||
let lead = crate::widgets::Lead::Text("A label much wider than its field");
|
||||
let _ = crate::widgets::number_field(ui, Id::new("long"), lead, Some(80.0), "%", 60.0, 1.0);
|
||||
field = ui.min_rect();
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
let texts = painted_texts(&out.shapes);
|
||||
let [(_, label_cut, l), (value, value_cut, v)] = texts.as_slice() else { panic!("the label and the value: {texts:?}") };
|
||||
assert!(*label_cut, "the label ends in an ellipsis");
|
||||
assert_eq!((value.as_str(), *value_cut), ("80%", false), "the value shows whole");
|
||||
assert!(field.contains_rect(*l) && field.contains_rect(*v), "both inside the field {field:?}: {l:?} {v:?}");
|
||||
assert!(l.max.x < v.min.x, "the label stops before the value: {l:?} {v:?}");
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Frame name labels on the canvas, driven through egui headless: an upright frame's label sits
|
||||
//! just above its top-left corner, a rotated frame's runs along its turned top edge, and pressing
|
||||
//! either label selects and drags its frame.
|
||||
//! either label selects and drags its frame; double-clicking one renames the frame or section there.
|
||||
|
||||
use egui::{Event, Pos2, pos2, vec2};
|
||||
use figcraft_doc::NodeId;
|
||||
@@ -79,3 +79,80 @@ fn pressing_a_rotated_label_selects_and_drags_the_frame() {
|
||||
assert!(after.x0 > before.x0 + 1.0 && after.y0 > before.y0 + 1.0, "{before:?} → {after:?}");
|
||||
assert_eq!(u.app.session.ds().unwrap().history.undo.len(), undo + 1);
|
||||
}
|
||||
|
||||
fn key(k: egui::Key) -> Vec<Event> {
|
||||
vec![
|
||||
Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() },
|
||||
Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: Default::default() },
|
||||
]
|
||||
}
|
||||
|
||||
/// Double-click `f`'s label and wait for the field to take the keyboard.
|
||||
fn double_click_label(u: &mut Ui, f: NodeId) {
|
||||
let at = mid(&label(u, f));
|
||||
for _ in 0..2 {
|
||||
u.button(at, true);
|
||||
u.button(at, false);
|
||||
}
|
||||
u.frame(vec![]);
|
||||
u.frame(vec![]);
|
||||
}
|
||||
|
||||
fn name(u: &Ui, f: NodeId) -> String {
|
||||
u.app.session.doc().unwrap().node(f).unwrap().name.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_clicking_a_label_renames_the_frame_there() {
|
||||
let (mut u, a, b) = setup();
|
||||
let before = u.app.session.doc().unwrap().world_bounds(a).unwrap();
|
||||
double_click_label(&mut u, a);
|
||||
assert_eq!(u.app.session.selection(), vec![a]);
|
||||
assert_eq!(u.app.ui.rename_label.map(|r| r.id), Some(a), "a field over the label");
|
||||
// The name starts selected: typing replaces it, Enter keeps it.
|
||||
u.frame(vec![Event::Text("Sign in".into())]);
|
||||
u.frame(key(egui::Key::Enter));
|
||||
u.frame(vec![]);
|
||||
assert_eq!(name(&u, a), "Sign in");
|
||||
assert!(u.app.ui.rename_label.is_none());
|
||||
// The double-click didn't move the frame or rename the other one.
|
||||
assert_eq!(u.app.session.doc().unwrap().world_bounds(a).unwrap(), before);
|
||||
assert_eq!(name(&u, b), "Turned");
|
||||
// One undo step puts the old name back.
|
||||
u.app.session.undo().unwrap();
|
||||
assert_eq!(name(&u, a), "Upright");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_leaves_a_label_rename_with_the_old_name() {
|
||||
let (mut u, _, b) = setup();
|
||||
double_click_label(&mut u, b);
|
||||
assert_eq!(u.app.ui.rename_label.map(|r| r.id), Some(b), "rotated labels rename too");
|
||||
u.frame(vec![Event::Text("Nope".into())]);
|
||||
u.frame(key(egui::Key::Escape));
|
||||
u.frame(vec![]);
|
||||
assert_eq!(name(&u, b), "Turned");
|
||||
assert!(u.app.ui.rename_label.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sections_label_renames_it_and_a_click_elsewhere_commits() {
|
||||
let mut s = Session::new();
|
||||
let a = id(s.execute("node.create", &json!({"type": "section", "x": 0, "y": 0, "width": 300, "height": 200, "name": "Area"})).unwrap());
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
u.frame(vec![]);
|
||||
let undo = u.app.session.ds().unwrap().history.undo.len();
|
||||
double_click_label(&mut u, a);
|
||||
assert_eq!(u.app.ui.rename_label.map(|r| r.id), Some(a), "a field over the section's label");
|
||||
assert_eq!(u.app.session.selection(), vec![a]);
|
||||
u.frame(vec![Event::Text("Zone".into())]);
|
||||
let at = mid(&label(&u, a));
|
||||
let away = pos2(at.x + 200.0, at.y + 500.0);
|
||||
u.button(away, true);
|
||||
u.button(away, false);
|
||||
u.frame(vec![]);
|
||||
assert_eq!(name(&u, a), "Zone", "a click on the canvas commits");
|
||||
assert!(u.app.ui.rename_label.is_none());
|
||||
assert_eq!(u.app.session.ds().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
}
|
||||
@@ -151,6 +151,28 @@ pub fn draw(app: &FigApp, painter: &Painter, t: &Tokens, doc: &Document, id: Nod
|
||||
Some([area.left_top(), area.right_top(), area.right_bottom(), area.left_bottom()].map(|p| tl + rot * (p - tl)))
|
||||
}
|
||||
|
||||
/// The field over a frame's or section's name label being renamed (`UiState::rename_label`, from a
|
||||
/// double-click on the label): Enter or a click elsewhere renames the layer, Escape keeps the name.
|
||||
/// The rename ends when the field lets go of the keyboard or the label is gone.
|
||||
pub fn rename_field(app: &mut FigApp, ui: &mut egui::Ui) {
|
||||
let Some(r) = app.ui.rename_label else { return };
|
||||
let quad = app.session.tool.labels.iter().find(|(i, _)| *i == r.id).map(|(_, q)| q.map(|p| app.to_screen(p.x, p.y)));
|
||||
let name = app.session.doc().ok().and_then(|d| d.node(r.id)).map(|n| n.name.clone());
|
||||
let (Some(quad), Some(name)) = (quad, name) else {
|
||||
app.ui.rename_label = None;
|
||||
return;
|
||||
};
|
||||
let area = Rect::from_points(&quad);
|
||||
let font = regular(11.0);
|
||||
let text_w = ui.painter().layout_no_wrap(name.clone(), font.clone(), Color32::WHITE).size().x;
|
||||
let rect = Rect::from_min_size(pos2(area.min.x, area.max.y - 16.0), vec2((text_w + 24.0).max(area.width()).clamp(80.0, 480.0), 16.0));
|
||||
let (done, open) = crate::widgets::rename_field(ui, egui::Id::new(("frame_label_rename", r.id)), &name, rect, font, r.start);
|
||||
app.ui.rename_label = open.then_some(crate::layers::Rename { id: r.id, start: false });
|
||||
if let Some(new) = done {
|
||||
app.run("object.rename", serde_json::json!({"ids": [r.id], "name": new}));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+103
-22
@@ -51,6 +51,8 @@ impl Section {
|
||||
#[derive(Default)]
|
||||
pub struct Home {
|
||||
pub section: Section,
|
||||
/// Searches known files and starter templates together from the home sidebar.
|
||||
pub file_query: String,
|
||||
/// The Templates search text and kind filter.
|
||||
pub query: String,
|
||||
pub kind: Option<Kind>,
|
||||
@@ -143,7 +145,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
|
||||
let left = Rect::from_min_size(rect.min, vec2(SIDEBAR, rect.height()));
|
||||
ui.painter().line_segment([left.right_top(), left.right_bottom()], Stroke::new(1.0, t.border));
|
||||
sidebar(app, ui, &t, left);
|
||||
if app.ui.home_view.section == Section::Online {
|
||||
if app.ui.home_view.file_query.trim().is_empty() && app.ui.home_view.section == Section::Online {
|
||||
// Your online files lay themselves out in the area (and scroll their own list).
|
||||
let area = Rect::from_min_max(pos2(left.max.x + 40.0, rect.min.y + 24.0), rect.max - vec2(40.0, 24.0));
|
||||
crate::online::section(app, ui, area, &t);
|
||||
@@ -155,9 +157,14 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
|
||||
egui::ScrollArea::vertical().id_salt("home").auto_shrink([false, false]).show(ui, |ui| {
|
||||
egui::Frame::NONE.inner_margin(egui::Margin::symmetric(40, 24)).show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(GAP, 0.0);
|
||||
match app.ui.home_view.section {
|
||||
Section::Templates => templates(app, ui, &t),
|
||||
_ => recents(app, ui, &t),
|
||||
if app.ui.home_view.file_query.trim().is_empty() {
|
||||
match app.ui.home_view.section {
|
||||
Section::Templates => templates(app, ui, &t),
|
||||
_ => recents(app, ui, &t),
|
||||
}
|
||||
} else {
|
||||
search_results(app, ui, &t);
|
||||
opened_join(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -198,10 +205,20 @@ fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) {
|
||||
icons::paint(ui.painter(), Rect::from_min_size(pos2(left.min.x + 16.0, y), vec2(20.0, 20.0)), Icon::Logo, ACCENT);
|
||||
ui.painter().text(pos2(left.min.x + 44.0, y + 10.0), Align2::LEFT_CENTER, "FigCraft", semibold(14.0), t.text);
|
||||
y += 48.0;
|
||||
let search = Rect::from_min_size(pos2(left.min.x + 8.0, y), vec2(SIDEBAR - 16.0, widgets::FIELD_H));
|
||||
ui.scope_builder(egui::UiBuilder::new().max_rect(search), |ui| {
|
||||
let response =
|
||||
widgets::text_input(ui, Id::new("home_file_search"), &mut app.ui.home_view.file_query, search.width(), tr("Search files and templates"));
|
||||
a11y::text(&response, tr("Search files and templates"), &app.ui.home_view.file_query);
|
||||
if (response.has_focus() || response.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
app.ui.home_view.file_query.clear();
|
||||
}
|
||||
});
|
||||
y += widgets::FIELD_H + 12.0;
|
||||
for (section, icon, label) in Section::ALL {
|
||||
let r = Rect::from_min_size(pos2(left.min.x + 8.0, y), vec2(SIDEBAR - 16.0, 32.0));
|
||||
let resp = ui.interact(r, ui.id().with(("home_section", label)), Sense::click());
|
||||
let current = app.ui.home_view.section == section;
|
||||
let current = app.ui.home_view.file_query.trim().is_empty() && app.ui.home_view.section == section;
|
||||
a11y::tab(&resp, tr(label), current);
|
||||
if current || resp.hovered() {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(5), if current { t.selected_row } else { t.hover });
|
||||
@@ -211,11 +228,65 @@ fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) {
|
||||
ui.painter().text(pos2(r.min.x + 36.0, r.center().y), Align2::LEFT_CENTER, tr(label), regular(12.0), t.text);
|
||||
if resp.clicked() {
|
||||
app.ui.home_view.section = section;
|
||||
app.ui.home_view.file_query.clear();
|
||||
}
|
||||
y += 34.0;
|
||||
}
|
||||
}
|
||||
|
||||
fn file_matches(name: &str, location: &str, query: &str) -> bool {
|
||||
name.to_lowercase().contains(query) || location.to_lowercase().contains(query)
|
||||
}
|
||||
|
||||
/// One search over known files and starter templates. It never sends a query or owner token to a
|
||||
/// server; online files are opened through their existing owner-token command only on click.
|
||||
fn search_results(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
||||
heading(ui, t, tr("Search results"));
|
||||
let query = app.ui.home_view.file_query.trim().to_lowercase();
|
||||
let mut count = open_files(app, ui, t, &query);
|
||||
count += crate::home_recents::section(app, ui, t, &query);
|
||||
|
||||
let online: Vec<_> = app.session.prefs.online_files.iter().filter(|f| file_matches(&f.name, &f.server, &query)).cloned().collect();
|
||||
let mut open_online = None;
|
||||
if !online.is_empty() {
|
||||
subheading(ui, t, tr("Online files"));
|
||||
for f in &online {
|
||||
count += 1;
|
||||
let host = figcraft_engine::online::server_host(&f.server);
|
||||
let icon = crate::online::mark(f.shared.is_some());
|
||||
let row = FileRow {
|
||||
mark: Some((icon, if f.shared.is_some() { t.success } else { t.icon_secondary })),
|
||||
name: &f.name,
|
||||
ink: t.text,
|
||||
detail: host,
|
||||
detail_size: 11.0,
|
||||
detail_ink: t.text_secondary,
|
||||
};
|
||||
if listed(ui, t, ui.id().with(("search_online", &f.server, &f.file)), 36.0, &row) {
|
||||
open_online = Some(f.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let templates: Vec<&Template> = TEMPLATES.iter().filter(|template| matches(template, &query, None)).collect();
|
||||
if !templates.is_empty() {
|
||||
subheading(ui, t, tr("Templates"));
|
||||
template_grid(app, ui, t, &templates);
|
||||
}
|
||||
if count == 0 && templates.is_empty() {
|
||||
ui.label(egui::RichText::new(tr("No files or templates match your search")).font(semibold(13.0)).color(t.text));
|
||||
ui.add_space(6.0);
|
||||
widgets::label(ui, tr("Try another search, or browse templates."), t.text_secondary);
|
||||
ui.add_space(16.0);
|
||||
if widgets::button(ui, tr("Show all templates"), false, true).clicked() {
|
||||
app.ui.home_view.file_query.clear();
|
||||
app.ui.home_view.section = Section::Templates;
|
||||
}
|
||||
}
|
||||
if let Some(file) = open_online {
|
||||
crate::online::open(app, &file);
|
||||
}
|
||||
}
|
||||
|
||||
/// A ring around a card or row that has the keyboard.
|
||||
fn focus_ring(ui: &Ui, t: &Tokens, rect: Rect, resp: &Response, radius: u8) {
|
||||
if resp.has_focus() {
|
||||
@@ -290,22 +361,7 @@ fn recents(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
||||
ui.add_space(28.0);
|
||||
getting_started(app, ui, t);
|
||||
}
|
||||
if !app.session.docs.is_empty() {
|
||||
subheading(ui, t, tr("Open files"));
|
||||
let mut switch = None;
|
||||
for (i, d) in app.session.docs.iter().enumerate() {
|
||||
let detail = d.path.as_deref().unwrap_or_default();
|
||||
let row =
|
||||
FileRow { mark: Some((Icon::Logo, ACCENT)), name: &d.doc.name, ink: t.text, detail, detail_size: 11.0, detail_ink: t.text_secondary };
|
||||
if listed(ui, t, ui.id().with(("open", d.uid)), 36.0, &row) {
|
||||
switch = Some(i);
|
||||
}
|
||||
}
|
||||
if let Some(i) = switch {
|
||||
app.run("doc.switch", json!({"index": i}));
|
||||
app.ui.home = false;
|
||||
}
|
||||
}
|
||||
open_files(app, ui, t, "");
|
||||
if crate::home_recents::listed(&app.session.prefs.recent_files).next().is_none() {
|
||||
// A first run: nothing to reopen yet, so offer something to start from.
|
||||
subheading(ui, t, tr("Start from a template"));
|
||||
@@ -317,7 +373,32 @@ fn recents(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
crate::home_recents::section(app, ui, t);
|
||||
crate::home_recents::section(app, ui, t, "");
|
||||
}
|
||||
|
||||
/// The Open files list: the documents whose name or path has `query` (lower case; all for ""), a
|
||||
/// row each; choosing one shows its tab. How many are listed.
|
||||
fn open_files(app: &mut FigApp, ui: &mut Ui, t: &Tokens, query: &str) -> usize {
|
||||
let hit = |d: &&figcraft_engine::DocState| file_matches(&d.doc.name, d.path.as_deref().unwrap_or_default(), query);
|
||||
let count = app.session.docs.iter().filter(hit).count();
|
||||
if count == 0 {
|
||||
return 0;
|
||||
}
|
||||
subheading(ui, t, tr("Open files"));
|
||||
let mut switch = None;
|
||||
for (i, d) in app.session.docs.iter().enumerate().filter(|(_, d)| hit(d)) {
|
||||
let detail = d.path.as_deref().unwrap_or_default();
|
||||
let row =
|
||||
FileRow { mark: Some((Icon::Logo, ACCENT)), name: &d.doc.name, ink: t.text, detail, detail_size: 11.0, detail_ink: t.text_secondary };
|
||||
if listed(ui, t, ui.id().with(("open", d.uid)), 36.0, &row) {
|
||||
switch = Some(i);
|
||||
}
|
||||
}
|
||||
if let Some(i) = switch {
|
||||
app.run("doc.switch", json!({"index": i}));
|
||||
app.ui.home = false;
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/// The file joined from the home screen (a code, or one of your online files) opened in a tab of
|
||||
|
||||
@@ -554,16 +554,27 @@ enum Action {
|
||||
View(View),
|
||||
}
|
||||
|
||||
/// The Recent files heading, its toolbar and the files as cards or a table.
|
||||
pub fn section(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
||||
let paths: Vec<String> = listed(&app.session.prefs.recent_files).take(SHOWN).cloned().collect();
|
||||
/// The Recent files heading, its toolbar and the files as cards or a table. With a `query` (lower
|
||||
/// case, from the Home search) only the files not open whose path has it, from every recent file;
|
||||
/// nothing at all when none has it. How many files are shown.
|
||||
pub fn section(app: &mut FigApp, ui: &mut Ui, t: &Tokens, query: &str) -> usize {
|
||||
let searching = !query.is_empty();
|
||||
// A search reaches every recent file; the files shown without one stay known through it.
|
||||
let paths: Vec<String> = listed(&app.session.prefs.recent_files).take(if searching { usize::MAX } else { SHOWN }).cloned().collect();
|
||||
let time = ui.input(|i| i.time);
|
||||
let now = figcraft_engine::cmd::comment::now_secs();
|
||||
let reduce_motion = app.reduce_motion();
|
||||
let (view, sort) = (View::of(&app.session.prefs), Sort::of(&app.session.prefs));
|
||||
let state = &mut app.ui.home_view.recents;
|
||||
state.refresh(ui.ctx(), &paths, time);
|
||||
let files = sorted(state.shown(&paths), sort);
|
||||
let mut files = sorted(state.shown(&paths), sort);
|
||||
if searching {
|
||||
let docs = &app.session.docs;
|
||||
files.retain(|f| f.path.to_lowercase().contains(query) && !docs.iter().any(|d| d.path.as_deref() == Some(f.path.as_str())));
|
||||
if files.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
let with_created = files.iter().any(|f| f.stat.is_some_and(|s| s.created.is_some()));
|
||||
let mut action = toolbar(ui, t, view, sort, with_created);
|
||||
ui.add_space(16.0);
|
||||
@@ -593,6 +604,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
files.len()
|
||||
}
|
||||
|
||||
/// "Recent files", and at the right the sort menu and the grid and list buttons.
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
use egui::accesskit::Role;
|
||||
use egui::{Event, Key, Modifiers, PointerButton, Pos2, Rect, Shape};
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_engine::online::OnlineFile;
|
||||
use figcraft_engine::templates::TEMPLATES;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -184,6 +185,161 @@ fn typing_in_the_search_field_filters() {
|
||||
assert!(u.app.session.docs.is_empty(), "typing opens nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_search_finds_known_files_and_opens_an_existing_tab() {
|
||||
let mut session = Session::new();
|
||||
session.execute("file.rename", &json!({"name": "North Trail"})).unwrap();
|
||||
session.docs[0].path = Some("C:/designs/North Trail.figcraft".into());
|
||||
session.prefs.recent_files =
|
||||
vec!["C:/designs/North Trail.figcraft".into(), "C:/designs/South Trail.figcraft".into(), "C:/designs/Other.figcraft".into()];
|
||||
session.prefs.online_files.push(OnlineFile {
|
||||
server: "wss://share.example/".into(),
|
||||
file: "trail".into(),
|
||||
name: "Trail Live".into(),
|
||||
owner: "test-only-secret".into(),
|
||||
..OnlineFile::default()
|
||||
});
|
||||
let mut u = Ui::new(session);
|
||||
u.app.ui.home = true;
|
||||
u.app.ui.home_view.file_query = " TRAIL ".into();
|
||||
let s = settle(&mut u);
|
||||
assert!(has_text(&s, "Search results"));
|
||||
assert!(has_text(&s, "North Trail") && has_text(&s, "South Trail") && has_text(&s, "Trail Live"));
|
||||
assert!(!has_text(&s, "Other") && !has_text(&s, "test-only-secret"));
|
||||
assert_eq!(texts(&s).iter().filter(|(name, pos, _)| name == "North Trail" && pos.y > 90.0).count(), 1, "an open file is not repeated in recents");
|
||||
assert_eq!(u.app.inspect()["homeSearch"], " TRAIL ");
|
||||
|
||||
u.app.ui.home_view.file_query = "nothing matches".into();
|
||||
let s = shapes(&mut u);
|
||||
assert!(has_text(&s, "No files or templates match your search"));
|
||||
assert!(!has_text(&s, "Trail Live") && !has_text(&s, "South Trail"));
|
||||
assert!(!texts(&s).iter().any(|(name, pos, _)| name == "North Trail" && pos.y > 90.0));
|
||||
|
||||
u.app.ui.home_view.file_query = "north".into();
|
||||
let s = shapes(&mut u);
|
||||
click(&mut u, text_center(&s, "North Trail"));
|
||||
assert!(!u.app.ui.home, "choosing an open result shows its editor tab");
|
||||
assert_eq!(u.app.session.docs.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_search_shows_a_saved_files_preview() {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-home-search-preview-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("Search preview.figcraft");
|
||||
let image = image::RgbaImage::from_pixel(64, 32, image::Rgba([30, 140, 210, 255]));
|
||||
let mut png = Vec::new();
|
||||
image.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let doc = figcraft_doc::Document::new("Search preview");
|
||||
std::fs::write(&path, figcraft_format::save_bytes(&doc, Some(&png)).unwrap()).unwrap();
|
||||
|
||||
let mut session = Session::empty();
|
||||
session.prefs.recent_files.push(path.to_string_lossy().into_owned());
|
||||
let mut u = Ui::new(session);
|
||||
u.app.ui.home_view.file_query = "Search preview".into();
|
||||
for _ in 0..400 {
|
||||
u.frame(vec![]);
|
||||
if u.app.inspect()["homeRecents"]["files"][0]["picture"] == "picture" {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
let s = shapes(&mut u);
|
||||
assert!(has_text(&s, "Search preview"));
|
||||
assert!(s.iter().any(|s| matches!(s, Shape::Mesh(m) if m.texture_id != egui::TextureId::default())), "the saved preview is drawn");
|
||||
let _ = std::fs::remove_file(path);
|
||||
let _ = std::fs::remove_dir(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_search_field_filters_as_typed_and_a_section_click_clears_it() {
|
||||
let mut u = first_run();
|
||||
u.app.session.prefs.recent_files = vec!["C:/designs/Glacier.figcraft".into(), "C:/designs/Forest.figcraft".into()];
|
||||
let s = shapes(&mut u);
|
||||
click(&mut u, text_center(&s, "Search files and templates"));
|
||||
u.frame(vec![Event::Text("glacier".into())]);
|
||||
let s = settle(&mut u);
|
||||
assert_eq!(u.app.ui.home_view.file_query, "glacier");
|
||||
assert!(has_text(&s, "Glacier") && !has_text(&s, "Forest"));
|
||||
click(&mut u, text_center(&s, "Recents"));
|
||||
assert!(u.app.ui.home_view.file_query.is_empty());
|
||||
let s = shapes(&mut u);
|
||||
assert!(has_text(&s, "Forest"));
|
||||
// Escape in the field clears the search too.
|
||||
click(&mut u, text_center(&s, "Search files and templates"));
|
||||
u.frame(vec![Event::Text("forest".into())]);
|
||||
assert_eq!(u.app.ui.home_view.file_query, "forest");
|
||||
u.frame(vec![Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]);
|
||||
assert!(u.app.ui.home_view.file_query.is_empty(), "Escape clears the search");
|
||||
}
|
||||
|
||||
/// A search keeps what the recent files it doesn't match know: back from it, their pictures and
|
||||
/// the selection are still there (nothing is read again).
|
||||
#[test]
|
||||
fn a_search_keeps_the_other_recent_files_pictures_and_the_selection() {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-home-search-cache-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_pixel(8, 8, image::Rgba([30, 140, 210, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let paths: Vec<String> = ["Alpine", "Basin"]
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
let path = dir.join(format!("{name}.figcraft"));
|
||||
std::fs::write(&path, figcraft_format::save_bytes(&figcraft_doc::Document::new(name), Some(&png)).unwrap()).unwrap();
|
||||
path.to_string_lossy().into_owned()
|
||||
})
|
||||
.collect();
|
||||
let mut session = Session::empty();
|
||||
session.prefs.recent_files = paths.clone();
|
||||
let mut u = Ui::new(session);
|
||||
let pictures = |u: &mut Ui| -> Vec<serde_json::Value> {
|
||||
u.app.inspect()["homeRecents"]["files"].as_array().unwrap().iter().map(|f| f["picture"].clone()).collect()
|
||||
};
|
||||
for _ in 0..400 {
|
||||
u.frame(vec![]);
|
||||
if pictures(&mut u).iter().all(|p| p == "picture") {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
assert!(pictures(&mut u).iter().all(|p| p == "picture"), "both pictures are in");
|
||||
u.app.ui.home_view.recents.selected = Some(paths[1].clone());
|
||||
u.app.ui.home_view.file_query = "alpine".into();
|
||||
let s = shapes(&mut u);
|
||||
assert!(has_text(&s, "Alpine") && !has_text(&s, "Basin"));
|
||||
u.app.ui.home_view.file_query.clear();
|
||||
u.frame(vec![]);
|
||||
assert!(pictures(&mut u).iter().all(|p| p == "picture"), "no picture is read again");
|
||||
assert_eq!(u.app.ui.home_view.recents.selected.as_deref(), Some(paths[1].as_str()), "the selection stays");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_search_includes_templates_and_opens_a_matching_card() {
|
||||
let mut u = first_run();
|
||||
u.app.ui.home_view.file_query = "MOBILE".into();
|
||||
let s = settle(&mut u);
|
||||
assert!(has_text(&s, "Templates") && has_text(&s, "Mobile app"));
|
||||
assert!(!has_text(&s, "Dashboard"));
|
||||
click(&mut u, text_center(&s, "Mobile app"));
|
||||
assert_eq!(u.app.session.doc().unwrap().name, "Mobile app");
|
||||
assert!(!u.app.ui.home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_home_search_can_browse_all_templates() {
|
||||
let mut u = first_run();
|
||||
u.app.ui.home_view.file_query = "no such design".into();
|
||||
let s = shapes(&mut u);
|
||||
assert!(has_text(&s, "No files or templates match your search"));
|
||||
click(&mut u, text_center(&s, "Show all templates"));
|
||||
assert!(u.app.ui.home_view.file_query.is_empty());
|
||||
assert_eq!(u.app.ui.home_view.section, Section::Templates);
|
||||
assert!(has_text(&settle(&mut u), "Dashboard"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thumbnails_are_cached_per_theme() {
|
||||
let mut u = first_run();
|
||||
|
||||
@@ -166,8 +166,9 @@ fn used_keys() -> BTreeSet<String> {
|
||||
}
|
||||
|
||||
/// The importers' fixed notes: the string literals passed to `warn` in the `.fig` and REST import
|
||||
/// sources. The import report shows a note translated when the catalog has it, so an entry for one
|
||||
/// counts as used, and an entry for a note the importers no longer give shows up as unused.
|
||||
/// sources. The import report shows a note translated when the catalog has it, so each one is a key
|
||||
/// `en.toml` (and every complete language) must have, and an entry for a note the importers no
|
||||
/// longer give shows up as unused.
|
||||
fn importer_notes() -> BTreeSet<String> {
|
||||
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../format/src");
|
||||
let mut keys = BTreeSet::new();
|
||||
@@ -249,11 +250,10 @@ fn english() -> &'static Lang {
|
||||
|
||||
#[test]
|
||||
fn every_key_in_the_code_is_in_the_english_catalog_and_no_other() {
|
||||
let used: BTreeSet<String> = used_keys().into_iter().chain(dynamic_keys()).collect();
|
||||
let used: BTreeSet<String> = used_keys().into_iter().chain(dynamic_keys()).chain(importer_notes()).collect();
|
||||
let en: BTreeSet<String> = english().ui.iter().map(|(k, _)| k.to_string()).collect();
|
||||
let missing: Vec<_> = used.difference(&en).collect();
|
||||
let notes = importer_notes();
|
||||
let unused: Vec<_> = en.difference(&used).filter(|k| !notes.contains(*k)).collect();
|
||||
let unused: Vec<_> = en.difference(&used).collect();
|
||||
assert!(
|
||||
missing.is_empty(),
|
||||
"keys missing from locales/en.toml:\n{}",
|
||||
@@ -343,11 +343,12 @@ fn system_languages_pick_the_first_one_with_a_catalog() {
|
||||
// Portugal's Portuguese and a bare "pt" read the Brazilian catalog.
|
||||
assert_eq!(code("system", Some("pt-PT")), "pt-BR");
|
||||
assert_eq!(code("pt", None), "pt-BR");
|
||||
// An OS list: the first language there is a catalog for (English counts).
|
||||
assert_eq!(code("system", Some("nl-NL,de-DE,en-US")), "de");
|
||||
assert_eq!(code("system", Some("nl-NL, en-US,fr-FR")), "en");
|
||||
assert_eq!(code("system", Some("nl,da")), "en");
|
||||
assert_eq!(code("system", Some("nl,sv")), "sv");
|
||||
// An OS list: the first language there is a catalog for (English counts). Hindi has no
|
||||
// catalog (Dutch gained one; `hi` keeps exercising the no-catalog path).
|
||||
assert_eq!(code("system", Some("hi-IN,de-DE,en-US")), "de");
|
||||
assert_eq!(code("system", Some("hi-IN, en-US,fr-FR")), "en");
|
||||
assert_eq!(code("system", Some("hi,da")), "en");
|
||||
assert_eq!(code("system", Some("hi,sv")), "sv");
|
||||
assert_eq!(code("fr", Some("de-DE")), "fr", "a chosen language beats the system's");
|
||||
}
|
||||
|
||||
@@ -472,7 +473,7 @@ fn russian_language_tags_and_integer_plurals() {
|
||||
assert_eq!(resolve(tag, None), ru, "{tag}");
|
||||
assert_eq!(resolve("system", Some(tag)), ru, "{tag}");
|
||||
}
|
||||
assert_eq!(resolve("system", Some("nl,ru-RU,en-US")), ru);
|
||||
assert_eq!(resolve("system", Some("hi,ru-RU,en-US")), ru);
|
||||
for n in [1, 21, 31, 101, 1_001] {
|
||||
assert_eq!(plural("ru", n), Plural::One, "{n}");
|
||||
assert_eq!(fill(counted(ru, "{n} layers", n), &[("n", &n)]), format!("{n} слой"));
|
||||
|
||||
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user