mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-13 01:11:11 +00:00
Merge main into #1655
This commit is contained in:
commit
60cb106eb6
187 files changed
+7598
-941
No files matched your search
Generated
+28
-1
@@ -768,6 +768,15 @@ dependencies = [
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_complete"
|
||||
version = "4.5.67"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1248e420506b523e97ebd7b610656114abadbe73b04279a86768a12ce1860837"
|
||||
dependencies = [
|
||||
"clap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.6.7"
|
||||
@@ -786,6 +795,16 @@ version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
|
||||
|
||||
[[package]]
|
||||
name = "clap_mangen"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "211d617eaa4b735c96c9e0228fcbdb5120ef623f2b8cb67ffb84c3e02dbc28a4"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"roff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.3"
|
||||
@@ -1115,7 +1134,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1065653e75854e9c17e1ff563acdd344c1b185f0#1065653e75854e9c17e1ff563acdd344c1b185f0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -1779,6 +1798,8 @@ name = "figcraft-cli"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
"clap_mangen",
|
||||
"figcraft-doc",
|
||||
"figcraft-engine",
|
||||
"figcraft-format",
|
||||
@@ -4468,6 +4489,12 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "roff"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189"
|
||||
|
||||
[[package]]
|
||||
name = "roxmltree"
|
||||
version = "0.20.0"
|
||||
|
||||
+4
-3
@@ -33,7 +33,8 @@ figcraft-mcp = { path = "crates/mcp" }
|
||||
figcraft-collab-net = { path = "crates/collab-net" }
|
||||
figcraft-plugin = { path = "crates/plugin" }
|
||||
|
||||
# craft-libs crates all pin one rev (craftrules standards/shared-libraries.md): bump them together.
|
||||
# Keep coupled craft-libs crates on one rev. craft-update has no sibling-crate
|
||||
# dependencies and is pinned separately for the suite updater rollout.
|
||||
# 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 = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
# Who draws the window frame on Linux — the compositor or the app — so every Craft app shows the
|
||||
@@ -41,8 +42,8 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l
|
||||
# to its own `TitleBar`, so it does not enable craft-window's optional `egui` helpers (which would
|
||||
# pull a second egui family version).
|
||||
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
# craft-update with storytold/craft-libs#434: the update prompt shows the app's icon, accent and developer.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1065653e75854e9c17e1ff563acdd344c1b185f0", features = ["egui"] }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] }
|
||||
# The suite's font store: exact lookup of a missing font in the pinned Google Fonts and Noto
|
||||
# indexes, and checksum-verified installs into the one folder every Craft app reads (craft-libs).
|
||||
craft-font-store = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
|
||||
+20
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join coLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` listLine truncated
|
||||
|
||||
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
|
||||
@@ -58,7 +58,7 @@ native speakers' reviews of the nine language drafts need people.
|
||||
|---|---|---|---|
|
||||
| Features (depth) | 63% (core editor 72%) | 28–67 | [target-app-parity](docs/target-app-parity.md) |
|
||||
| UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) |
|
||||
| File formats | 60% (`.fig` read best effort with editable vectors; experimental write, round trips through our own importer and written files opened in other software) | 5–10 | [file-format-parity](docs/file-format-parity.md) |
|
||||
| File formats | 60% (`.fig` read best effort with editable vectors and a total ZIP image byte budget; experimental write, round trips through our own importer and written files opened in other software) | 5–10 | [file-format-parity](docs/file-format-parity.md) |
|
||||
| Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, cursor chat and spotlight, the browser joins and publishes to a server, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 46–92 | [collaboration-parity](docs/collaboration-parity.md) |
|
||||
| Stability | 55% | 20–40 | [gaps](docs/gaps.md) |
|
||||
| Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) |
|
||||
@@ -133,6 +133,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
## Progress log
|
||||
|
||||
- **2026-10-30** · Text style dedupe is O(spans) (M8, #1102): `shape_text` deduped spans into styles by a scan over the styles already collected — O(spans × styles), so a styled text's dedupe grew with the square of its runs (a synthetic 50k-run text with 100 alternating styles spent 7.7 ms in it). Now the last two styles again (spans run in blocks), then buckets keyed by a cheap fingerprint (family and style bytes, size bits with −0.0 folded, the enums, fill count and solid colours), each bucket resolved with the full equality: the result is the scan's, never worse than it, and O(spans) when names and sizes differ (the same synthetic text: 2.6 ms). The font-db write lock is untouched: `find()` loads faces, which needs the write. `layout_dedupe_tests`. Numbers unchanged.
|
||||
- **2026-10-29** · M10: correct nine German labels in `de.toml`: gradient stops use Verlaufsstopp, OpenType ss01–ss05 use Stilsatz, and the Home-screen preference names the Startbildschirm consistently in the UI and Actions palette. This is a partial terminology pass; German remains a draft awaiting a full native-speaker review and maintainer approval. Numbers unchanged.
|
||||
- **2026-10-28** · M3, #922: an instance scaled with the Scale tool (or `transform.scale`) keeps its layers' scaled size and points when its main is rebuilt. The scaled layers' new size and place were read as what constraints give (an ancestor resized in the same edit), so no override was kept, and adding a layer to the main put a 14 px icon's 24 px triangle back. The Scale tool now records them as `size` and `position` overrides on layers inside instances; strokes already kept theirs. The report's own sequence (clearing effects on the component holding the icon) no longer reproduces on main. Regressions for a scaled instance, one scaled with the tool (with undo) and a scaled icon nested in a component and its instances. Numbers unchanged.
|
||||
- **2026-10-28** · M6, #1059: `.fig` import copies each image carried inside the message once per blob, shared by every hash that names it, instead of once per hash: a file pointing many hashes at one large blob no longer multiplies it in memory (100 hashes on a 250 MB blob were 25 GB). Regression test. Numbers unchanged.
|
||||
- **2026-10-28** · M1, #1476: page ruler guides survive save and reopen. A page kept its guides in the page kind while the layer's own `guides` field (frame guides, #70) took that key when a file was read, so a reopened page's guides vanished, and saving it wrote `guides` twice, which then refused to open. Every layer now keeps its guides in the one field, and a page with two `guides` lists opens with both. Regressions in the doc and format crates. Numbers unchanged.
|
||||
@@ -357,6 +358,22 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | Text style dedupe is O(spans) (M8, #1102): `shape_text`'s scan over collected styles replaced by a last-two cache and fingerprint buckets resolved with the full equality — same result, O(spans) for the styles that usually differ, never worse than the scan; the font-db write lock untouched (face loading needs it). `layout_dedupe_tests`. Numbers unchanged |
|
||||
| 2026-10-30 | minor | M6, #1646: a layer pasted from another design app whose image the clipboard didn't carry came in flat grey, like a grey the design chose, and the paste said only that some kinds of content were left out. Now the placeholder an import stores for an image it doesn't have is light grey crossed by darker diagonal stripes (REST JSON, `.fig` and paste alike), the fill keeps the image's own name, the `.fig` and paste note is a fixed, translated one per image ("image fills whose image isn't in the file show a missing-image placeholder and keep its name"), `edit.pasteDesignLayers` returns `missingImages` (the names of the pasted layers concerned), the paste's message names them, and a paste with notes opens the import report like an import. Tests: `fig/missing_image_tests.rs`, `paste_missing_image_tests.rs`, `import_report_tests.rs`. |
|
||||
| 2026-10-30 | minor | M6, #1638: layers pasted from another design app lost the colours of their shared paint styles: a component's glyphs, and the same glyphs in its instances, came in black. The clipboard links each style by the guid it has in the library it came from, which the clipboard doesn't hold, as well as by its library asset, whose copy it does hold; the `.fig` mapping took the guid and found nothing. Now a guid names a style, variable collection or other resource only when the file holds a node by it, and otherwise the asset it also names finds the file's copy, so `.fig` import and paste agree. A link to a style the file really doesn't hold keeps the colours stored with the layer, as before, now with a note. Tests: `paste_style_tests.rs` (design data built in code, read as paste reads it). |
|
||||
| 2026-10-30 | minor | Docs (found while landing #1649): merges that kept both sides of a conflict had left rows and status items twice. One row each now for `.fig` in `docs/file-format-parity.md` (four copies: #1234, #1236 and the #1076 ZIP budget kept, with the newer four-values-per-byte budget wording and #1649's connector routing), Real-time co-editing (three: #1069, #1065, #1063) and Live cursors (two: #1380, #1416) in `docs/collaboration-parity.md`, Windows x64 / arm64 in `docs/hardware-parity.md` (#1320, #965) and M0.9 in `docs/roadmap.md` (#1396, #996). The status lines of this file, `docs/roadmap.md`, `docs/gaps.md`, `docs/target-app-parity.md`, `docs/ui-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md` and `docs/file-format-parity.md` drop items listed more than once (302 in all, the first kept), and an unclosed parenthesis in target-app-parity's status line is closed. No wording changed otherwise. New `cargo xtask docs`, run by `ci`: a table row that repeats an earlier one (header rows apart) or a status item listed twice fails it. Numbers unchanged. Test: `docs_check::tests`. |
|
||||
| 2026-10-30 | minor | M3, #1613 (after #1618): the rest of the stored-id work. `Variables::get` and `Variables::collection` take the variable or collection with that exact id before one named like it (as `find_mode` does), so typed ids in `variable.bind`, `variable.update`, aliases and the prototype action and expression input can't be taken by a renamed one. Stored mode ids go through the new `Collection::mode_by_id` (expression evaluation, display and type checks, the prototype's mode switch), font family variables read their stored ids exactly, and the colour picker, prototype editor and panels' labels (variables, prototype, selection colours, typography, style picker, assets, variable fields) read exact ids. Tests: `stored_ids_tests.rs` (exported aliases, deletion, Dev Inspect, expressions, mode switch, library import, interaction edits) and the doc crate's lookup test; #1618's expression test now expects the exact id to win. |
|
||||
| 2026-10-30 | minor | M6, gap 6 (first slice): plugin widgets have hover styles, as the reference tool's widgets do. Any layer of a widget description, the widget itself too, may have `hover: {fill?, stroke?, opacity?, color? (text)}`, checked like the rest; on the canvas, while the pointer is over such a layer, it shows that style as a live preview (the text-preview mechanism: no undo step, never saved or sent to a live session), and moving away, leaving the canvas or any command (a click on the part acts on the document itself) puts it back; another live preview showing isn't replaced. `widget.hover {ids}` does the same for scripts and agents. No new plugin API: older FigCraft draws the layer as it is. The Counter example's buttons darken under the pointer. Timers and panels from widgets are still missing. Numbers unchanged. Tests: `widget_hover_tests`, `widget_tests::hover_styles_are_checked_and_normalised`. |
|
||||
| 2026-10-30 | minor | M6, gap 1: `.fig` connectors saved without their path that are elbowed or curved were drawn as straight lines. They now run as FigCraft's board connectors do: in right angles (around the layers they join when the short way would cross them) or along one curve, leaving each layer from the side the file's `magnet` names, else the edge its point lies on, else the side facing the other end. The router (`route_points`) moved from the engine's boards into `figcraft-doc`, unchanged, so the importer and the board connectors share it. Straight connectors are unchanged. Checked by rendering the synthetic fixture with figcraft-cli; not on real files. Numbers unchanged. Tests: `fig::tests::tail::connectors_without_a_saved_path_follow_their_line_style`, the elbowed connector in `other_layer_kinds_import_as_the_closest_layers`, the engine's board connector tests. |
|
||||
| 2026-10-30 | minor | M6, gap 6: plugin panels (and plugin command forms) take a `slider` control: a number between a required `min` and `max`, drawn as a slider with the value field beside it, named for screen readers as a slider with its range. Dragging shows the value live and calls the plugin back once, when let go (one undo step); typing in the field or double-clicking the slider (back to the panel's value) do the same. The slider is the one the Type settings' variable axes used, moved into the shared widgets, which also gives those axis sliders a screen-reader name. Numbers unchanged. Tests: `v2_tests::panels_are_validated_and_keep_entered_values`, `plugin_panel_tests::a_slider_calls_the_plugin_back_once_when_let_go`. |
|
||||
| 2026-10-30 | minor | M6, gap 16: `figcraft-cli connect publish <file.figcraft> [--mappings FILE] [--dry-run]` publishes a code repository's code connect mappings (`figcraft.connect.json`, as Export code connections writes it) to a design file: each link connects the components or sets of that name to their code for its language; links that don't fit are warnings and are skipped; the file is saved in place through the engine's `file.save` (whole or not at all, version history kept); a dry run saves nothing; nothing linked is exit code 1. Numbers unchanged. Tests: `files::tests::connect_publish_links_components_and_saves_the_file`, `args` tests. |
|
||||
| 2026-10-30 | minor | M8, #1571 (part 3): the first frame after each move of a drag rebuilt the selection's outlines and box, its slots, the selection spacing and the Design panel's values from the new document. Now the engine notes when a drag has only moved the selected layers, each by the same offset from where it started (`Interaction::moved_by`: not for a layer in an auto layout flow, nor an Alt copy), and the canvas and panel work those out once from the document the drag started from and shift what depends on position (the outlines on screen, the box); the Design panel shows that document's values, all but the position fields, which follow the move. The Export section's values are kept too. Nothing looks different. Dragging 648 of about 1,900 synthetic layers (release build, two runs each): the UI pass 2.10 to 1.44 ms a frame, CPU 135% to 117% of a core; the Design panel 0.9 to 0.5 ms, the overlays 0.5 to 0.3 ms. Not yet: the engine's preview of each move (copying and normalising the document) and the canvas texture's whole upload. Tests: `moved_by_tests.rs`, `canvas/selection_cache_tests.rs`. |
|
||||
| 2026-10-30 | minor | M12, #1597 (the third point): Shift+Cmd/Ctrl+Enter (`board.nextSticky`) now works on a selected or edited shape too: it adds the next shape of the same kind, size and colour on its right (or the `side` given), joined to it by a connector and with typing going on in it, as the "+" beside it does (`board.addConnected`, #1631). On a sticky it adds a sticky beside it without a connector, as before. Numbers unchanged. Tests: `board_connected_tests::the_next_sticky_shortcut_joins_the_next_shape_and_leaves_stickies_as_they_were`, `board_ui_tests::the_next_shape_from_the_keyboard_comes_connected_and_goes_on_typing`. |
|
||||
| 2026-10-30 | minor | M12, #1597: boards create the next shape already connected. A selected sticky or shape shows a "+" beside each of its four sides (clear of the contextual bar, the size label and the toolbar); hovering one shows where the next goes and the connector to it; clicking makes a sticky, or a shape of the same kind, size and colour, 80 units away on that side, joined to it by a connector the way the Connector tool makes one, and starts typing in it. One undo step takes both away. `board.addConnected {id?, side?, edit?}` does it from commands, MCP and plugins. Shift+Cmd/Ctrl+Enter still adds a sticky without a connector. Tests: `board_connected_tests.rs` (engine), `board_connect_tests.rs` (the handles, headless). |
|
||||
| 2026-10-30 | minor | M4, #1292: a `.fig` import said a family was not installed and drew it in a fallback font although the font picker listed it. Fonts name a family two ways: a typographic name ("Economia", style "Medium") and a legacy name per weight ("Economia Medium", style "Regular"); fonts without typographic names are installed under the legacy ones only, while a file may use either. The font database now resolves a family + style either way (`FontDb::resolve`, used by every lookup: import, paste, REST import, rendering, the missing-fonts check and the font report): a family named with weight words finds the family without them, a style the family lacks finds the family named with it, and the Style list offers those weights. Family names also match across case, spaces, hyphens and underscores, and a weight number style ("600") goes by that weight. `.fig` and REST imports wait for the installed fonts to be indexed before their text is laid out. Widths aren't taken for weights (a missing "Roboto Condensed" stays missing). Tests: `fontdb_names_tests.rs` (synthetic names), `fig_font_names_tests.rs` (a `.fig` round trip). |
|
||||
| 2026-10-30 | minor | M0.9, part of #1396: a development example generates completions for Bash, Elvish, Fish, PowerShell and Zsh and a man page for the CLI and each subcommand, nested ones included, from figcraft-cli's clap definitions, without running commands. Generating these files doesn't install them; package installation and the other RFC-0012 exceptions remain outstanding. Regression tests in `apps/figcraft-cli/tests/it/generated_docs.rs` cover artifacts, command names and options, matching Bash dispatch states, repeat generation and filesystem errors. Numbers unchanged |
|
||||
| 2026-10-30 | minor | M8, #1076: bound total `.fig` ZIP image reads to 4 GiB (1 GiB on 32-bit targets) of decompressed ZIP-member bytes containing encoded images, keeping the 256 MiB member cap. Members that do not fit are skipped before allocation and counted in the import warnings; failed reads still consume their bytes, and repeated references keep their cached resolution. Embedded message images, decoded pixel memory and native `.figcraft` reads are unchanged. Regression tests in `fig::tests::image_budget` (format). Numbers unchanged. |
|
||||
| 2026-10-30 | minor | M8, #1114 (the rest): `file.open` and `file.import` (and recovery copies), `image.place {path}`, the image fill's file picker and `figcraft-cli run`'s script read the path they were given whole, so a device (`/dev/zero`), a pipe or an absurd file could fill memory or wait forever. Every whole-file read of a path now goes through `file_access::read_capped`, which looks at the path before opening it and refuses a folder, a device or pipe, or a file over its cap, with a message saying which, and reads at most the cap even if the file grows; the uncapped `file_access::read` is gone. Caps: documents 16 GiB (a `.figcraft` keeps its images as they are: #1313's file is 7.4 GB on disk), placed images 256 MiB (what a document takes from outside, `ImageData::MAX_BYTES`), scripts 1 GiB; fonts (256 MiB), plugin files and variables files already had theirs. Numbers unchanged. Tests: `path_read_caps_tests`, `file_access` tests, the CLI's `a_script_that_isnt_a_regular_file_is_refused`. |
|
||||
| 2026-10-30 | minor | M8 (found while retaking the README screenshots): at the default (and narrowest) 240 px right panel, a component set's variant property rows ran off the panel: values past the second ("Outline", "Disabled") were cut off, and the + after them and the − of each property sat past the edge; a shared boolean property's − was pushed out too. Values now wrap as many to a line as fit (two at 240 px, three from about 320 px as before), the property name field gives way first so the default and − stay in view, and a boolean's checkbox no longer takes the whole row. Numbers unchanged. Tests: `component_set_narrow_tests`. |
|
||||
| 2026-10-30 | minor | M9, #1130: the server key is compared by its hash. `Store::manage` hashes the presented and the kept key with SHA-256 and compares the two digests; the key on disk, `figcraft-server key` and rotation are unchanged. Regression tests in `store_tests` (`operator_key_*`). Numbers unchanged |
|
||||
| 2026-10-30 | minor | M8, #1097: while the Assets panel showed a file, the app asked the server of every team library the file named for updates, with the link's token and a usage report, so a file someone sent could make the app contact any server. Now only team libraries you added (`library.add`) or checked on this computer are checked by themselves (kept with the preferences as hashes of their links); another one waits in **Assets ▸ Libraries** as "Not checked yet" until you click **Check for updates** (`library.check`, which plugins can't call). Placing or applying something from it is your own action and asks its server as before. Numbers unchanged. Tests: `library_beacon_tests` (engine and UI). |
|
||||
| 2026-10-30 | minor | M8, #1121: client-side maps were unbounded. A hostile host or server could mint people without end: the presence waiting for the UI, the people a connection had seen and the session's list of who is here each grew with every new id; and a session server's set of deleted layer ids (kept so undo may create them again) only grew. Now a client keeps track of at most `MAX_PEERS` (4,096) others: past it, someone new is left out with a logged warning (in a welcome too), someone already here can still be renamed, and someone leaving makes room; a server's `max_people` above 4,096 is lowered to it with a startup warning (the server still starts). A session remembers at most 262,144 deleted ids, forgetting the lowest first (on restore too). Threat model in `collab-protocol.md`. Tests: `client_cap_tests.rs` (collab-net), `live_peer_cap_tests.rs` (engine), `deleted_cap_tests.rs` (collab), the server's `max_people` clamp. |
|
||||
@@ -392,6 +409,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
| 2026-10-30 | minor | M8, #1550: SVG import reads CSS colour functions its SVG parser doesn't (it painted them black, or left strokes out, without a word): `color(srgb …)` and `color(display-p3 …)`, with alpha, converted to the document's profile, and `color-mix(in srgb|oklab|oklch, …)`, evaluated at import (percentages, alpha and nested mixes as CSS Color 5 has them), in colour attributes, `style` attributes and `<style>` sheets. Other colour spaces are taken as sRGB values and other mixes mixed in sRGB, each with an import note; a call that can't be read becomes a mid grey with a note. Bounded: calls over 2,048 characters, mixes nested more than 8 deep and more than 100,000 calls are not read. Tests: `css_color_tests` (svg). Numbers unchanged |
|
||||
| 2026-10-30 | minor | M1, #1320: settings in portable mode. The settings folder now comes from craft-settings, the suite's rules: `FIGCRAFT_CONFIG_DIR` names a folder (set but empty: none, nothing read or written); a `portable` file next to the executable (next to the `.app` on macOS) puts the preferences, recovery copies and installed plugins in `config/` beside it; otherwise they stay in `FigCraft` in the platform folder, where every earlier version kept them, so nothing moves for an installed app. The Windows portable zip now ships the `portable` file. A portable folder without preferences yet starts with a copy of the platform folder's `state.json` (owner-only, the original left for an installed app), so a portable copy that kept its settings in `%APPDATA%` before keeps them after the update. Checked on Windows 11 with a portable copy beside an installed copy's settings: the first start took them (its name and start screen), a name changed there was kept in `config\` only, and without the marker the app read the installed settings again. Tests: `settings_dir_tests`. Numbers unchanged |
|
||||
| 2026-10-30 | minor | M1, #965, #1438: "Parent device is lost" at start. Both reports lost the device while wgpu compiled its indirect-call validation shaders, which it builds with every device: on Windows Direct3D 12 took another program's `dxcompiler.dll` from the search path and failed without its `dxil.dll`; Intel Macs' Metal compiler aborts on them. Direct3D 12 now compiles with FXC (part of Windows; `WGPU_DX12_COMPILER` still chooses), and the validation shaders are left out (FigCraft draws nothing indirectly; `WGPU_VALIDATION_INDIRECT_CALL` turns them back on). When the graphics still fail before the window's first frame, the app starts itself again with the next attempt, from a fixed list: on Windows Direct3D 12/OpenGL, Vulkan, OpenGL, then the first again on the power-saving GPU with WebGL2-level device limits; on Linux and FreeBSD the defaults, OpenGL, then lower requirements; on macOS the defaults, then lower requirements. The last failure's message names what was tried; the log names the backend and GPU that drew. `WGPU_BACKEND` is still the one attempt. A restarted app waits up to 5 s for its `--control` port. Checked on Windows 11 with a local build that made the first one, two, three and then all four attempts fail: each fallback opened the window (Vulkan, OpenGL, Direct3D 12 on the integrated GPU) and four failures ended with the message. Not run on the reporters' machines or on macOS. Tests: `graphics_tests`, `startup_tests`. Numbers unchanged |
|
||||
| 2026-10-29 | minor | German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft. Nine existing translations corrected; keys, placeholders, coverage, grade and estimates unchanged |
|
||||
| 2026-10-29 | minor | M4, #1219: Thai, Lao, Khmer and Burmese text, written without spaces between words, wraps between words instead of where a line overflows (`พัฒนาซอ|ฟต์แวร์` → `พัฒนา|ซอฟต์แวร์`). `figcraft_text::breaks::word_breaks` finds the word starts inside each run of these scripts with ICU4X's line segmenter (`icu_segmenter` 2.3): its dictionaries on desktop, its LSTM models in the browser (about 0.39 MB of wasm instead of 1.86 MB, which the 24 MiB gate can't take; they split a few compounds the dictionaries keep whole). Where a run meets spaces, Latin or CJK the UAX #14 rules decide as before, and a word wider than its box still takes #1400's emergency break. Text without these scripts does no extra work. Grade unchanged. Evidence: `breaks::tests::thai_lines_break_between_words`, `lao_khmer_and_burmese_lines_break_between_words`, `word_breaks_survive_junk`, `behaviour_tests::thai_lines_break_between_words` (fails on main). |
|
||||
| 2026-10-30 | minor | M8, #1416: an AI agent attached over MCP shows where it works, as a person in a live session does: its avatar in its own colour in the people row, named as its comments are signed ("Claude (agent)"), and an outline with its name around the layers its last edit changed or it last selected. It goes 8 seconds after its last command and shows only in the file it works in. Display only (no undo step, not saved, never synced to a live session); the engine records it in `Session::execute_as` (`figcraft_engine::agent`). Tests: `agent_presence_tests` (engine), `live::agent` (ui-egui) |
|
||||
| 2026-10-30 | minor | M5, #405: a manual overlay can be placed on the canvas. While the interaction editor has an Open overlay action with Manual position open, the overlay frame shows dimmed and outlined where it opens, from the top-left of the layer that opens it (right below it without an offset), and dragging it moves it: the X and Y fields follow while dragging, and the drag is one undo step. Tests: `overlay_ghost` (ui-egui) |
|
||||
|
||||
@@ -24,3 +24,8 @@ figcraft-mcp = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# 4.6 breaks Bash subcommand completion for hyphenated binary names (clap-rs/clap#6421).
|
||||
clap_complete = "~4.5.67"
|
||||
clap_mangen = "0.3"
|
||||
@@ -0,0 +1,44 @@
|
||||
//! Writes figcraft-cli's shell completions and recursive man pages into a directory:
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo run -p figcraft-cli --example man-and-completions -- <out-dir>
|
||||
//! ```
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[path = "../src/args.rs"]
|
||||
#[expect(dead_code, reason = "Generation uses clap's command metadata, not the parsed fields or runtime parser.")]
|
||||
mod args;
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use clap::{CommandFactory, ValueEnum};
|
||||
use clap_complete::Shell;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let mut args = std::env::args_os().skip(1);
|
||||
let (Some(dir), None) = (args.next(), args.next()) else {
|
||||
eprintln!("usage: man-and-completions <out-dir>");
|
||||
return ExitCode::from(2);
|
||||
};
|
||||
let dir = Path::new(&dir);
|
||||
match generate(dir) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("error: {}: {e}", dir.display());
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn generate(dir: &Path) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let mut command = args::Cli::command();
|
||||
let name = command.get_name().to_owned();
|
||||
for &shell in Shell::value_variants() {
|
||||
clap_complete::generate_to(shell, &mut command, &name, dir)?;
|
||||
}
|
||||
// Completions build the command and add synthetic help subcommands. A fresh command keeps
|
||||
// those out of the man pages (such as figcraft-cli-help-export.1).
|
||||
clap_mangen::generate_to(args::Cli::command(), dir)
|
||||
}
|
||||
@@ -41,6 +41,11 @@ pub enum Command {
|
||||
#[command(subcommand)]
|
||||
command: PluginCommand,
|
||||
},
|
||||
/// Publish a code repository's component mappings (code connect) to a design file
|
||||
Connect {
|
||||
#[command(subcommand)]
|
||||
command: ConnectCommand,
|
||||
},
|
||||
Perf(PerfArgs),
|
||||
}
|
||||
|
||||
@@ -228,6 +233,31 @@ pub struct PublishArgs {
|
||||
pub server: String,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum ConnectCommand {
|
||||
Publish(ConnectPublishArgs),
|
||||
}
|
||||
|
||||
/// Link a design file's components to their code as a figcraft.connect.json says, and save it
|
||||
///
|
||||
/// The mappings are what Dev Mode's Export code connections writes, kept in the code repository:
|
||||
/// each links a component (or component set) of that name to its code component for a language.
|
||||
/// Entries that don't fit (an unknown component, a bad template) are reported and skipped.
|
||||
#[derive(Args)]
|
||||
pub struct ConnectPublishArgs {
|
||||
/// The design file (.figcraft), saved in place
|
||||
#[arg(value_name = "FILE")]
|
||||
pub file: String,
|
||||
/// The mappings
|
||||
#[arg(long, value_name = "FILE", default_value = "figcraft.connect.json")]
|
||||
pub mappings: String,
|
||||
/// Report what would be linked without saving the file
|
||||
#[arg(long)]
|
||||
pub dry_run: bool,
|
||||
#[command(flatten)]
|
||||
pub roots: RootArgs,
|
||||
}
|
||||
|
||||
/// Measure the hot paths on synthetic documents and print Markdown tables
|
||||
#[derive(Args)]
|
||||
pub struct PerfArgs {
|
||||
@@ -326,7 +356,7 @@ mod tests {
|
||||
use clap::CommandFactory;
|
||||
use clap::error::ErrorKind;
|
||||
|
||||
use super::{Cli, Command, PluginCommand, parse};
|
||||
use super::{Cli, Command, ConnectCommand, PluginCommand, parse};
|
||||
|
||||
fn try_parse(args: &[&str]) -> Result<Cli, clap::Error> {
|
||||
parse(std::iter::once("figcraft-cli").chain(args.iter().copied()).map(Into::into).collect())
|
||||
@@ -434,6 +464,14 @@ mod tests {
|
||||
panic!("plugin")
|
||||
};
|
||||
assert_eq!((a.folder.as_str(), a.server.as_str()), ("dist/counter", "ws://h:47900/"));
|
||||
let Command::Connect { command: ConnectCommand::Publish(a) } = ok(&["connect", "publish", "ui.figcraft"]) else { panic!("connect") };
|
||||
assert_eq!((a.file.as_str(), a.mappings.as_str(), a.dry_run), ("ui.figcraft", "figcraft.connect.json", false));
|
||||
let Command::Connect { command: ConnectCommand::Publish(a) } =
|
||||
ok(&["connect", "publish", "ui.figcraft", "--mappings", "code/map.json", "--dry-run"])
|
||||
else {
|
||||
panic!("connect")
|
||||
};
|
||||
assert_eq!((a.mappings.as_str(), a.dry_run), ("code/map.json", true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -402,8 +402,12 @@ pub fn parse_script(text: &str) -> Result<Vec<BatchStep>, String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The largest script `run` reads (one may carry images as base64): a device, a pipe or a larger
|
||||
/// file is refused before it is read (#1114).
|
||||
pub const MAX_SCRIPT: u64 = 1 << 30;
|
||||
|
||||
pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliResult<ExitCode> {
|
||||
let bytes = figcraft_engine::file_access::read(script).map_err(|e| CliError::Failed(e.to_string()))?;
|
||||
let bytes = figcraft_engine::file_access::read_capped(script, MAX_SCRIPT).map_err(|e| CliError::Failed(e.to_string()))?;
|
||||
let text = String::from_utf8(bytes).map_err(|_| CliError::Failed(format!("{script}: not UTF-8")))?;
|
||||
let steps = parse_script(&text).map_err(|e| CliError::Failed(format!("{script}: {e}")))?;
|
||||
let (mut s, input_images_left_out) = match input {
|
||||
@@ -455,6 +459,34 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
|
||||
/// Link the components of `file` (a `.figcraft`) to their code as `mappings` (a
|
||||
/// `figcraft.connect.json`) says, and save it in place unless `dry_run`. Prints what was linked;
|
||||
/// entries that were skipped are warnings on stderr, and linking nothing fails (exit code 1).
|
||||
pub fn connect_publish(file: &str, mappings: &str, dry_run: bool) -> CliResult<ExitCode> {
|
||||
if ext(file) != "figcraft" {
|
||||
return Err(CliError::Usage(format!("{file}: connect publish saves code links in a .figcraft file")));
|
||||
}
|
||||
let mut s = Session::empty();
|
||||
s.execute("file.open", &json!({"path": file})).map_err(|e| CliError::Failed(format!("can't open {file}: {e}")))?;
|
||||
let r = s.execute("codeConnect.import", &json!({"path": mappings})).map_err(|e| CliError::Failed(format!("{mappings}: {e}")))?;
|
||||
for w in r["warnings"].as_array().into_iter().flatten().filter_map(Value::as_str) {
|
||||
eprintln!("warning: {w}");
|
||||
}
|
||||
let linked = r["linked"].as_u64().unwrap_or(0);
|
||||
if linked == 0 {
|
||||
eprintln!("error: no component of {file} matches a link in {mappings}: nothing published");
|
||||
return Ok(ExitCode::from(1));
|
||||
}
|
||||
if dry_run {
|
||||
println!("{linked} component link(s) from {mappings} would be published to {file} (dry run: not saved)");
|
||||
return Ok(ExitCode::SUCCESS);
|
||||
}
|
||||
// The engine's save: written whole or not at all, with the file's version history.
|
||||
s.execute("file.save", &json!({})).map_err(|e| CliError::Failed(format!("can't save {file}: {e}")))?;
|
||||
println!("published {linked} component link(s) from {mappings} to {file}");
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -538,6 +570,57 @@ mod tests {
|
||||
dir
|
||||
}
|
||||
|
||||
/// A script path that is a folder or a device is refused before anything is read (#1114).
|
||||
#[test]
|
||||
fn a_script_that_isnt_a_regular_file_is_refused() {
|
||||
let dir = temp_dir("script-caps");
|
||||
let mut bad = vec![dir.to_string_lossy().into_owned()];
|
||||
if cfg!(unix) {
|
||||
bad.push("/dev/zero".into());
|
||||
}
|
||||
for path in bad {
|
||||
let Err(CliError::Failed(e)) = run_script(&path, None, None) else { panic!("{path} ran") };
|
||||
assert!(e.contains("is a folder, not a file") || e.contains("isn't a regular file"), "{path}: {e}");
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// `connect publish` links the file's components as the mappings say and saves the file in
|
||||
/// place; a dry run saves nothing, and mappings that link nothing fail.
|
||||
#[test]
|
||||
fn connect_publish_links_components_and_saves_the_file() {
|
||||
let dir = temp_dir("connect");
|
||||
let file = dir.join("ui.figcraft");
|
||||
let file = file.to_str().unwrap();
|
||||
let mut s = Session::new();
|
||||
let frame = s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 40, "name": "Card"})).unwrap();
|
||||
s.execute("component.create", &json!({"ids": [frame["id"]]})).unwrap();
|
||||
s.execute("file.saveAs", &json!({"path": file})).unwrap();
|
||||
let mappings = dir.join("figcraft.connect.json");
|
||||
let link =
|
||||
|component: &str| json!({"component": component, "language": "react", "importPath": "@acme/ui", "name": "Card", "template": "<Card />"});
|
||||
std::fs::write(&mappings, json!({"version": 1, "links": [link("Card"), link("Nope")]}).to_string()).unwrap();
|
||||
let mappings = mappings.to_str().unwrap();
|
||||
let links = || {
|
||||
let mut s = Session::empty();
|
||||
s.execute("file.open", &json!({"path": file})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
doc.nodes().filter(|n| n.name == "Card").map(|n| n.code_links.len()).sum::<usize>()
|
||||
};
|
||||
|
||||
assert_eq!(connect_publish(file, mappings, true).unwrap(), ExitCode::SUCCESS);
|
||||
assert_eq!(links(), 0, "a dry run saves nothing");
|
||||
assert_eq!(connect_publish(file, mappings, false).unwrap(), ExitCode::SUCCESS, "an unknown component is only a warning");
|
||||
assert_eq!(links(), 1, "Card is linked to its code in the saved file");
|
||||
|
||||
let none = dir.join("none.json");
|
||||
std::fs::write(&none, json!({"version": 1, "links": [link("Nope")]}).to_string()).unwrap();
|
||||
assert_eq!(connect_publish(file, none.to_str().unwrap(), false).unwrap(), ExitCode::from(1));
|
||||
assert!(matches!(connect_publish(dir.join("ui.svg").to_str().unwrap(), mappings, false), Err(CliError::Usage(_))));
|
||||
assert!(matches!(connect_publish(file, dir.join("missing.json").to_str().unwrap(), false), Err(CliError::Failed(_))));
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// A script resolves `{"$step": n}` like the same commands through `command.batch`. Each step
|
||||
/// used to run on its own, so the reference reached `parent` unresolved, `node.create` read it
|
||||
/// as "no parent" and the layer landed on the page — with every step reporting success.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! `figcraft-cli`: FigCraft without a window. Inspect, export, convert and render documents, run
|
||||
//! command scripts, list commands, serve MCP on stdio, publish plugins to a server's plugin
|
||||
//! directory and measure performance.
|
||||
//! directory, publish code connect mappings to a design file and measure performance.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod args;
|
||||
@@ -10,7 +10,7 @@ mod perf;
|
||||
use std::io::Write;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use args::{Cli, Command, PluginCommand, RootArgs};
|
||||
use args::{Cli, Command, ConnectCommand, PluginCommand, RootArgs};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// The automation roots a file subcommand was given, if any. Omitting both leaves the run
|
||||
@@ -93,6 +93,7 @@ fn run(cli: Cli) -> CliResult<ExitCode> {
|
||||
// `--attach` conflicts with the roots: the app runs the commands, only its own roots can confine them.
|
||||
Command::Mcp(a) => mcp(a.attach.as_deref(), roots_of(&a.roots)?),
|
||||
Command::Plugin { command: PluginCommand::Publish(a) } => publish_plugin(&a.folder, &a.server),
|
||||
Command::Connect { command: ConnectCommand::Publish(a) } => confined(&a.roots, || files::connect_publish(&a.file, &a.mappings, a.dry_run)),
|
||||
Command::Perf(a) => {
|
||||
let sizes: Vec<usize> = a.layers.into_iter().map(|n| n.clamp(10, 2_000_000)).collect();
|
||||
let instances: Vec<usize> = a.instances.into_iter().filter(|n| *n > 0).map(|n| n.min(100_000)).collect();
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
//! Generated CLI documentation uses the parser's command definition, without running commands.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[path = "../../examples/man-and-completions.rs"]
|
||||
#[expect(dead_code, reason = "These tests call generate directly; the example's main is smoke-tested separately.")]
|
||||
mod generator;
|
||||
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
fn path(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> std::io::Result<TempDir> {
|
||||
static NEXT: AtomicUsize = AtomicUsize::new(0);
|
||||
loop {
|
||||
let n = NEXT.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-cli-test-{}-{n}-{name}", std::process::id()));
|
||||
match fs::create_dir(&dir) {
|
||||
Ok(()) => return Ok(TempDir(dir)),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completions_name_the_cli_its_subcommands_and_options() -> std::io::Result<()> {
|
||||
let temp = tmp("completions")?;
|
||||
let dir = temp.path();
|
||||
generator::generate(dir)?;
|
||||
for name in ["figcraft-cli.bash", "figcraft-cli.elv", "figcraft-cli.fish", "_figcraft-cli.ps1", "_figcraft-cli"] {
|
||||
let text = fs::read_to_string(dir.join(name))?;
|
||||
for word in [
|
||||
"figcraft-cli",
|
||||
"info",
|
||||
"export",
|
||||
"convert",
|
||||
"render",
|
||||
"run",
|
||||
"commands",
|
||||
"mcp",
|
||||
"plugin",
|
||||
"perf",
|
||||
"publish",
|
||||
"no-system-fonts",
|
||||
"format",
|
||||
"automation-read-root",
|
||||
"server",
|
||||
] {
|
||||
assert!(text.contains(word), "{name} does not contain {word}");
|
||||
}
|
||||
assert!(!text.contains("man-and-completions"), "{name} names the example instead of the CLI");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bash_dispatch_states_have_matching_option_blocks() -> std::io::Result<()> {
|
||||
let temp = tmp("bash-completion-states")?;
|
||||
let dir = temp.path();
|
||||
generator::generate(dir)?;
|
||||
let text = fs::read_to_string(dir.join("figcraft-cli.bash"))?;
|
||||
let states: Vec<_> =
|
||||
text.lines().filter_map(|line| line.trim().strip_prefix("cmd=\"")?.strip_suffix('"')).filter(|state| !state.is_empty()).collect();
|
||||
assert!(states.len() > 1, "the completion has no subcommand dispatch states");
|
||||
for state in states {
|
||||
let block = format!("{state})");
|
||||
assert!(text.lines().any(|line| line.trim() == block), "Bash dispatch state {state} has no option block");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn man_pages_cover_every_subcommand_without_synthetic_help_pages() -> std::io::Result<()> {
|
||||
let temp = tmp("man-pages")?;
|
||||
let dir = temp.path();
|
||||
generator::generate(dir)?;
|
||||
let mut pages = Vec::new();
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let path = entry?.path();
|
||||
if path.extension().is_some_and(|ext| ext == "1") {
|
||||
pages.push(path.file_name().unwrap_or_default().to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
pages.sort();
|
||||
assert_eq!(
|
||||
pages,
|
||||
[
|
||||
"figcraft-cli-commands.1",
|
||||
"figcraft-cli-connect-publish.1",
|
||||
"figcraft-cli-connect.1",
|
||||
"figcraft-cli-convert.1",
|
||||
"figcraft-cli-export.1",
|
||||
"figcraft-cli-info.1",
|
||||
"figcraft-cli-mcp.1",
|
||||
"figcraft-cli-perf.1",
|
||||
"figcraft-cli-plugin-publish.1",
|
||||
"figcraft-cli-plugin.1",
|
||||
"figcraft-cli-render.1",
|
||||
"figcraft-cli-run.1",
|
||||
"figcraft-cli.1",
|
||||
]
|
||||
);
|
||||
for name in &pages {
|
||||
let text = fs::read_to_string(dir.join(name))?.replace("\\-", "-");
|
||||
assert!(text.contains(".SH NAME"), "{name} has no NAME section");
|
||||
assert!(text.contains(name.trim_end_matches(".1")), "{name} has the wrong command name");
|
||||
assert!(!text.contains("man-and-completions"), "{name} names the example instead of the CLI");
|
||||
}
|
||||
for (page, option) in [
|
||||
("figcraft-cli.1", "--no-system-fonts"),
|
||||
("figcraft-cli-export.1", "--format"),
|
||||
("figcraft-cli-export.1", "--automation-read-root"),
|
||||
("figcraft-cli-plugin-publish.1", "--server"),
|
||||
("figcraft-cli-connect-publish.1", "--dry-run"),
|
||||
] {
|
||||
let text = fs::read_to_string(dir.join(page))?.replace("\\-", "-");
|
||||
assert!(text.contains(option), "{page} does not contain {option}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generation_creates_the_output_directory_and_can_run_again() -> std::io::Result<()> {
|
||||
let temp = tmp("generated-docs-repeat")?;
|
||||
let dir = temp.path();
|
||||
let output = dir.join("new").join("docs");
|
||||
assert!(!output.exists());
|
||||
generator::generate(&output)?;
|
||||
let page = output.join("figcraft-cli.1");
|
||||
let first = fs::read(&page)?;
|
||||
assert!(!first.is_empty());
|
||||
fs::write(&page, "old page")?;
|
||||
generator::generate(&output)?;
|
||||
assert_eq!(fs::read(page)?, first);
|
||||
assert_eq!(fs::read_dir(output)?.count(), 18);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generation_returns_filesystem_errors() -> std::io::Result<()> {
|
||||
let temp = tmp("generated-docs-errors")?;
|
||||
let dir = temp.path();
|
||||
let file = dir.join("not-a-directory");
|
||||
fs::write(&file, "keep me")?;
|
||||
assert!(generator::generate(&file).is_err());
|
||||
assert_eq!(fs::read_to_string(&file)?, "keep me");
|
||||
// A valid directory can still fail when writing one of the generated pages.
|
||||
fs::create_dir(dir.join("figcraft-cli.1"))?;
|
||||
assert!(generator::generate(dir).is_err());
|
||||
Ok(())
|
||||
}
|
||||
@@ -6,6 +6,7 @@ mod cli;
|
||||
mod export_depth;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod fonts;
|
||||
mod generated_docs;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod pdf;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
@@ -98,3 +98,6 @@ display_name = "Filip Strajnar"
|
||||
|
||||
[people.Carbocode]
|
||||
sync_github_name = true
|
||||
|
||||
[people.dylanmtaylor]
|
||||
sync_github_name = true
|
||||
@@ -0,0 +1,144 @@
|
||||
//! Where a connector runs between its ends: straight, in right angles or along one curve. Pure
|
||||
//! geometry, shared by the engine (board connectors that follow their layers) and the importers
|
||||
//! (connectors saved without their path).
|
||||
|
||||
use figcraft_geom::{Point, Rect, Vec2};
|
||||
|
||||
use crate::{Routing, Side};
|
||||
|
||||
/// One end of a route: a free point, or an object's box and the side the route leaves it by
|
||||
/// ([`Side::Auto`]: the side facing the other end).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum RouteEnd {
|
||||
Point(Point),
|
||||
Box(Rect, Side),
|
||||
}
|
||||
|
||||
impl RouteEnd {
|
||||
fn centre(self) -> Point {
|
||||
match self {
|
||||
RouteEnd::Point(p) => p,
|
||||
RouteEnd::Box(b, _) => b.center(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How far an elbow connector going around the objects it joins keeps from them.
|
||||
const ROOM: f64 = 24.0;
|
||||
|
||||
/// The points a connector runs through from `from` to `to`: from the middle of the side of each
|
||||
/// box facing the other end (or the side it names), straight, in right angles, or (curved) its two
|
||||
/// ends with the curve's two control points between them. `None` when a point isn't finite.
|
||||
pub fn route_points(from: RouteEnd, to: RouteEnd, routing: Routing) -> Option<Vec<Point>> {
|
||||
let (p0, s0) = attach(from, to.centre());
|
||||
let (p1, s1) = attach(to, from.centre());
|
||||
let boxes: Vec<Rect> = [from, to].into_iter().filter_map(|e| if let RouteEnd::Box(b, _) = e { Some(b) } else { None }).collect();
|
||||
let pts = match routing {
|
||||
Routing::Straight => tidy(vec![p0, p1]),
|
||||
Routing::Elbow => elbow(p0, s0, p1, s1, &boxes),
|
||||
Routing::Curved => curve(p0, s0, p1, s1),
|
||||
};
|
||||
pts.iter().all(|p| p.x.is_finite() && p.y.is_finite()).then_some(pts)
|
||||
}
|
||||
|
||||
/// `pts` without repeats or turns that don't turn (three in a line across or down).
|
||||
fn tidy(pts: Vec<Point>) -> Vec<Point> {
|
||||
let mut out: Vec<Point> = Vec::with_capacity(pts.len());
|
||||
for p in pts {
|
||||
if out.last() == Some(&p) {
|
||||
continue;
|
||||
}
|
||||
if let [.., a, b] = out.as_slice()
|
||||
&& ((a.x == b.x && b.x == p.x) || (a.y == b.y && b.y == p.y))
|
||||
{
|
||||
out.pop();
|
||||
}
|
||||
out.push(p);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Where end `e` attaches (its point, or the middle of its box's side facing `other`) and that
|
||||
/// side (`None`: a free point).
|
||||
fn attach(e: RouteEnd, other: Point) -> (Point, Option<Side>) {
|
||||
match e {
|
||||
RouteEnd::Point(p) => (p, None),
|
||||
RouteEnd::Box(b, side) => {
|
||||
let side = match side {
|
||||
Side::Auto => {
|
||||
let d = other - b.center();
|
||||
// Compare against the box's proportions: a wide box takes the sides more.
|
||||
if d.x.abs() * b.height().max(1e-9) >= d.y.abs() * b.width().max(1e-9) {
|
||||
if d.x >= 0.0 { Side::Right } else { Side::Left }
|
||||
} else if d.y >= 0.0 {
|
||||
Side::Bottom
|
||||
} else {
|
||||
Side::Top
|
||||
}
|
||||
}
|
||||
s => s,
|
||||
};
|
||||
(side.point(b), Some(side))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A curve from `p0` to `p1` leaving and arriving along the sides' normals (a free end pulls no
|
||||
/// way): the ends and the two control points between them.
|
||||
fn curve(p0: Point, s0: Option<Side>, p1: Point, s1: Option<Side>) -> Vec<Point> {
|
||||
let reach = (p1 - p0).hypot() / 2.0;
|
||||
let pull = |p: Point, s: Option<Side>| p + s.map_or(Vec2::ZERO, |s| s.normal() * reach);
|
||||
vec![p0, pull(p0, s0), pull(p1, s1), p1]
|
||||
}
|
||||
|
||||
/// A right-angled route from `p0` to `p1`, leaving and arriving along the sides' normals (`s`; a
|
||||
/// free end follows the other): the short way when it keeps out of the attached objects' `boxes`,
|
||||
/// else the cheapest of a few ways around them (by length, and turns).
|
||||
fn elbow(p0: Point, s0: Option<Side>, p1: Point, s1: Option<Side>, boxes: &[Rect]) -> Vec<Point> {
|
||||
let direct = tidy(short_elbow(p0, s0.map(Side::horizontal), p1, s1.map(Side::horizontal)));
|
||||
let Some(all) = boxes.iter().copied().reduce(|a, b| a.union(b)) else { return direct };
|
||||
if !crosses(&direct, boxes) {
|
||||
return direct;
|
||||
}
|
||||
// Out of each end by ROOM, then across on one of a few lines: halfway, or clear of the objects.
|
||||
let out = |p: Point, s: Option<Side>| p + s.map_or(Vec2::ZERO, |s| s.normal() * ROOM);
|
||||
let (a, b) = (out(p0, s0), out(p1, s1));
|
||||
let mut ways = vec![vec![p0, a, Point::new(b.x, a.y), b, p1], vec![p0, a, Point::new(a.x, b.y), b, p1]];
|
||||
for x in [(a.x + b.x) / 2.0, all.x0 - ROOM, all.x1 + ROOM] {
|
||||
ways.push(vec![p0, a, Point::new(x, a.y), Point::new(x, b.y), b, p1]);
|
||||
}
|
||||
for y in [(a.y + b.y) / 2.0, all.y0 - ROOM, all.y1 + ROOM] {
|
||||
ways.push(vec![p0, a, Point::new(a.x, y), Point::new(b.x, y), b, p1]);
|
||||
}
|
||||
let cost = |r: &Vec<Point>| r.windows(2).map(|s| (s[1] - s[0]).hypot()).sum::<f64>() + ROOM * r.len() as f64;
|
||||
ways.into_iter().map(tidy).filter(|r| !crosses(r, boxes)).min_by(|x, y| cost(x).total_cmp(&cost(y))).unwrap_or(direct)
|
||||
}
|
||||
|
||||
/// Whether the right-angled route `pts` runs through the inside of any of `boxes` (along an edge,
|
||||
/// or out of one, is outside).
|
||||
fn crosses(pts: &[Point], boxes: &[Rect]) -> bool {
|
||||
const EDGE: f64 = 0.5;
|
||||
pts.windows(2).any(|s| {
|
||||
let (a, b) = (s[0], s[1]);
|
||||
boxes.iter().any(|r| a.x.min(b.x) < r.x1 - EDGE && a.x.max(b.x) > r.x0 + EDGE && a.y.min(b.y) < r.y1 - EDGE && a.y.max(b.y) > r.y0 + EDGE)
|
||||
})
|
||||
}
|
||||
|
||||
/// The short right-angled route from `p0` to `p1` (`h`: the side faces sideways; a free end
|
||||
/// follows the other): halfway across between two sides facing the same way, one turn otherwise.
|
||||
fn short_elbow(p0: Point, h0: Option<bool>, p1: Point, h1: Option<bool>) -> Vec<Point> {
|
||||
let h0 = h0.or(h1).unwrap_or((p1.x - p0.x).abs() >= (p1.y - p0.y).abs());
|
||||
let h1 = h1.unwrap_or(h0);
|
||||
match (h0, h1) {
|
||||
(true, true) => {
|
||||
let mx = (p0.x + p1.x) / 2.0;
|
||||
vec![p0, Point::new(mx, p0.y), Point::new(mx, p1.y), p1]
|
||||
}
|
||||
(false, false) => {
|
||||
let my = (p0.y + p1.y) / 2.0;
|
||||
vec![p0, Point::new(p0.x, my), Point::new(p1.x, my), p1]
|
||||
}
|
||||
(true, false) => vec![p0, Point::new(p1.x, p0.y), p1],
|
||||
(false, true) => vec![p0, Point::new(p0.x, p1.y), p1],
|
||||
}
|
||||
}
|
||||
@@ -89,8 +89,20 @@ impl Style {
|
||||
impl Variables {
|
||||
pub fn for_each_color_mut(&mut self, f: &mut ColorVisitor) {
|
||||
for v in self.variables.iter_mut().flat_map(|v| v.values.values_mut()) {
|
||||
if let VariableValue::Color(c) = v {
|
||||
f(c);
|
||||
match v {
|
||||
VariableValue::Color(c) => f(c),
|
||||
// A mix's literals are stored colours too (a calculation's alpha is just a
|
||||
// number): a profile conversion that left them behind would blend converted
|
||||
// operands with unconverted ones, and the bound layers would silently shift
|
||||
// relative to the rest of the document.
|
||||
VariableValue::ColorMix { from, to, .. } => {
|
||||
for op in [from, to] {
|
||||
if let crate::ColorOperand::Literal { literal } = op {
|
||||
f(literal);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod board;
|
||||
mod board_route;
|
||||
pub mod colors;
|
||||
pub mod comments;
|
||||
pub mod facilitation;
|
||||
@@ -26,6 +27,7 @@ pub mod vector_faces;
|
||||
pub mod vector_regions;
|
||||
|
||||
pub use board::*;
|
||||
pub use board_route::{RouteEnd, route_points};
|
||||
pub use colors::*;
|
||||
pub use comments::*;
|
||||
pub use facilitation::*;
|
||||
|
||||
@@ -103,12 +103,14 @@ impl Expr {
|
||||
Expr::Value(VariableValue::Alias(id)) => return vars.resolve(id, modes),
|
||||
Expr::Value(VariableValue::Number(n)) => return n.is_finite().then_some(VariableValue::Number(*n)),
|
||||
Expr::Value(v) => return Some(v.clone()),
|
||||
Expr::Mode { collection } => return vars.collection(collection)?.current_mode(modes).map(|m| VariableValue::String(m.name.clone())),
|
||||
Expr::Mode { collection } => {
|
||||
return vars.collection_by_id(collection)?.current_mode(modes).map(|m| VariableValue::String(m.name.clone()));
|
||||
}
|
||||
Expr::InMode { variable, mode } => {
|
||||
let v = vars.get(variable)?;
|
||||
let c = vars.collection(&v.collection)?;
|
||||
let v = vars.get_by_id(variable)?;
|
||||
let c = vars.collection_by_id(&v.collection)?;
|
||||
let mut at = modes.clone();
|
||||
at.insert(c.id.clone(), c.find_mode(mode)?.id.clone());
|
||||
at.insert(c.id.clone(), c.mode_by_id(mode)?.id.clone());
|
||||
return vars.resolve(&v.id, &at);
|
||||
}
|
||||
Expr::Op { op, args } => (*op, args),
|
||||
@@ -143,20 +145,22 @@ impl Expr {
|
||||
return "…".into();
|
||||
}
|
||||
match self {
|
||||
Expr::Value(VariableValue::Alias(id)) => vars.get(id).map_or_else(|| "(missing variable)".into(), |v| var_ref(vars, v)),
|
||||
Expr::Value(VariableValue::Alias(id)) => vars.get_by_id(id).map_or_else(|| "(missing variable)".into(), |v| var_ref(vars, v)),
|
||||
Expr::Value(VariableValue::String(s)) => quote(s),
|
||||
// Exact numbers and `#` colours, so the text reads back as the same expression.
|
||||
Expr::Value(VariableValue::Number(n)) => format!("{n}"),
|
||||
Expr::Value(VariableValue::Color(c)) => c.to_css_hex(),
|
||||
Expr::Value(v) => v.display(),
|
||||
Expr::Mode { collection } => {
|
||||
let name = vars.collection(collection).map_or("(missing collection)", |c| c.name.as_str());
|
||||
let name = vars.collection_by_id(collection).map_or("(missing collection)", |c| c.name.as_str());
|
||||
format!("mode({})", name_arg(name))
|
||||
}
|
||||
Expr::InMode { variable, mode } => {
|
||||
let var = vars.get(variable);
|
||||
let mode =
|
||||
var.and_then(|v| vars.collection(&v.collection)).and_then(|c| c.find_mode(mode)).map_or("(missing mode)", |m| m.name.as_str());
|
||||
let var = vars.get_by_id(variable);
|
||||
let mode = var
|
||||
.and_then(|v| vars.collection_by_id(&v.collection))
|
||||
.and_then(|c| c.mode_by_id(mode))
|
||||
.map_or("(missing mode)", |m| m.name.as_str());
|
||||
format!("{}[{}]", Expr::var(variable.clone()).display_at(vars, depth + 1), name_arg(mode))
|
||||
}
|
||||
Expr::Op { op, args } => {
|
||||
@@ -230,7 +234,7 @@ fn as_text(v: &VariableValue) -> Option<String> {
|
||||
match v {
|
||||
VariableValue::String(s) => Some(s.clone()),
|
||||
VariableValue::Number(_) | VariableValue::Boolean(_) => Some(v.display()),
|
||||
VariableValue::Color(_) | VariableValue::Alias(_) => None,
|
||||
VariableValue::Color(_) | VariableValue::Alias(_) | VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -138,3 +138,37 @@ fn lookups_evaluate_in_their_mode_and_modes_follow_the_layer() {
|
||||
assert_eq!(mode_of("gone").eval(&vars, &light), None);
|
||||
assert_eq!(in_mode("n", "dark").variables(), ["n"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_keep_expression_evaluation_display_and_types() {
|
||||
let mut vars = more_vars();
|
||||
let mut shadow = vars.get_by_id("s").unwrap().clone();
|
||||
shadow.id = "shadow".into();
|
||||
shadow.name = "n".into();
|
||||
vars.variables.insert(0, shadow);
|
||||
let mut shadow = vars.collection("cs").unwrap().clone();
|
||||
shadow.id = "shadow".into();
|
||||
shadow.name = "c".into();
|
||||
vars.collections.insert(0, shadow);
|
||||
let modes = BTreeMap::new();
|
||||
for e in [Expr::var("n"), in_mode("n", "dark")] {
|
||||
assert_eq!(e.type_of(&vars), Ok(VariableType::Number));
|
||||
assert!(e.display(&vars).starts_with("n-name"));
|
||||
}
|
||||
assert_eq!(Expr::var("n").eval(&vars, &modes), Some(VariableValue::Number(3.0)));
|
||||
assert_eq!(in_mode("n", "dark").eval(&vars, &modes), Some(VariableValue::Number(7.0)));
|
||||
assert_eq!(Expr::parse("n-name[Dark]", &vars), Ok(in_mode("n", "dark")));
|
||||
assert_eq!(mode_of("c").eval(&vars, &modes), Some(VariableValue::String("Light".into())));
|
||||
assert_eq!(mode_of("c").display(&vars), "mode(Theme)");
|
||||
|
||||
vars.variables.retain(|v| v.id != "n");
|
||||
for e in [Expr::var("n"), in_mode("n", "dark")] {
|
||||
assert_eq!(e.eval(&vars, &modes), None);
|
||||
assert_eq!(e.type_of(&vars), Err("No variable `n`".into()));
|
||||
assert!(e.display(&vars).contains("missing variable"));
|
||||
}
|
||||
vars.collections.retain(|c| c.id != "c");
|
||||
assert_eq!(mode_of("c").eval(&vars, &modes), None);
|
||||
assert_eq!(mode_of("c").type_of(&vars), Err("No variable collection `c`".into()));
|
||||
assert_eq!(mode_of("c").display(&vars), "mode(\"(missing collection)\")");
|
||||
}
|
||||
@@ -88,16 +88,16 @@ impl Expr {
|
||||
return Err("The expression is nested too deeply".into());
|
||||
}
|
||||
let (op, args) = match self {
|
||||
Expr::Value(VariableValue::Alias(id)) => return vars.get(id).map(|v| v.kind).ok_or_else(|| format!("No variable `{id}`")),
|
||||
Expr::Value(VariableValue::Alias(id)) => return vars.get_by_id(id).map(|v| v.kind).ok_or_else(|| format!("No variable `{id}`")),
|
||||
Expr::Value(VariableValue::Number(n)) if !n.is_finite() => return Err("Numbers must be finite".into()),
|
||||
Expr::Value(v) => return v.type_of().ok_or_else(|| "Not a value".to_string()),
|
||||
Expr::Mode { collection } => {
|
||||
return vars.collection(collection).map(|_| T::String).ok_or_else(|| format!("No variable collection `{collection}`"));
|
||||
return vars.collection_by_id(collection).map(|_| T::String).ok_or_else(|| format!("No variable collection `{collection}`"));
|
||||
}
|
||||
Expr::InMode { variable, mode } => {
|
||||
let v = vars.get(variable).ok_or_else(|| format!("No variable `{variable}`"))?;
|
||||
let c = vars.collection(&v.collection).ok_or_else(|| format!("No variable collection `{}`", v.collection))?;
|
||||
return c.find_mode(mode).map(|_| v.kind).ok_or_else(|| format!("{} has no mode `{mode}`", c.name));
|
||||
let v = vars.get_by_id(variable).ok_or_else(|| format!("No variable `{variable}`"))?;
|
||||
let c = vars.collection_by_id(&v.collection).ok_or_else(|| format!("No variable collection `{}`", v.collection))?;
|
||||
return c.mode_by_id(mode).map(|_| v.kind).ok_or_else(|| format!("{} has no mode `{mode}`", c.name));
|
||||
}
|
||||
Expr::Op { op, args } => (*op, args),
|
||||
};
|
||||
@@ -501,7 +501,7 @@ impl Parser<'_> {
|
||||
self.i += 1;
|
||||
let start = self.i;
|
||||
let name = self.argument(']', "mode")?;
|
||||
let coll = self.vars.get(&var).and_then(|v| self.vars.collection(&v.collection));
|
||||
let coll = self.vars.get_by_id(&var).and_then(|v| self.vars.collection_by_id(&v.collection));
|
||||
match coll.and_then(|c| c.find_mode(&name)) {
|
||||
Some(m) => Ok(Expr::InMode { variable: var, mode: m.id.clone() }),
|
||||
None => {
|
||||
|
||||
+275
-18
@@ -82,6 +82,51 @@ pub enum VariableValue {
|
||||
String(String),
|
||||
Boolean(bool),
|
||||
Alias(VariableId),
|
||||
/// The referenced colour with its alpha channel replaced (a "50% variant"). Replace rather
|
||||
/// than scale: applying it twice is the same value (stable under editing the opacity or the
|
||||
/// chain it reads), and "at 50%" reads as the design ask.
|
||||
ColorAlpha {
|
||||
variable: VariableId,
|
||||
opacity: f32,
|
||||
},
|
||||
/// A linear blend of two colour operands at `t` (0 = `from`, 1 = `to`). The space is the stored
|
||||
/// one — non-premultiplied components in the document's colour profile, alpha blended like the
|
||||
/// channels — deliberately not perceptual: the values a hex panel can show blend exactly, and
|
||||
/// 8-bit rounding stays where it already was (display and export) rather than being done twice.
|
||||
/// The trade-off: converting the profile keeps the ends exact but moves the colours in between.
|
||||
ColorMix {
|
||||
from: ColorOperand,
|
||||
to: ColorOperand,
|
||||
t: f32,
|
||||
},
|
||||
}
|
||||
|
||||
/// One operand of a colour calculation: a variable (resolved when read, so it follows its own modes,
|
||||
/// aliases and calculations) or a fixed colour. Tagged by key, not by type: a bare string is a
|
||||
/// colour name just as easily as a variable name.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum ColorOperand {
|
||||
Variable { variable: VariableId },
|
||||
Literal { literal: Color },
|
||||
}
|
||||
|
||||
impl ColorOperand {
|
||||
/// The variable it reads, if it reads one.
|
||||
pub fn variable(&self) -> Option<&VariableId> {
|
||||
match self {
|
||||
ColorOperand::Variable { variable } => Some(variable),
|
||||
ColorOperand::Literal { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The variable id it reads, for a remap (library import and swap re-point ids).
|
||||
pub fn variable_mut(&mut self) -> Option<&mut VariableId> {
|
||||
match self {
|
||||
ColorOperand::Variable { variable } => Some(variable),
|
||||
ColorOperand::Literal { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl VariableValue {
|
||||
@@ -95,22 +140,63 @@ impl VariableValue {
|
||||
}
|
||||
}
|
||||
|
||||
/// The type this value has on its own, which a typed literal needs. None for references
|
||||
/// (aliases and colour calculations): what they read may be missing or loop, so only
|
||||
/// resolving them gives a type, and an expression can't take one as a literal.
|
||||
pub fn type_of(&self) -> Option<VariableType> {
|
||||
match self {
|
||||
VariableValue::Color(_) => Some(VariableType::Color),
|
||||
VariableValue::Number(_) => Some(VariableType::Number),
|
||||
VariableValue::String(_) => Some(VariableType::String),
|
||||
VariableValue::Boolean(_) => Some(VariableType::Boolean),
|
||||
VariableValue::Alias(_) => None,
|
||||
VariableValue::Alias(_) | VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. } => None,
|
||||
}
|
||||
}
|
||||
/// The value on one line, as the panel shows it: `→ id` for an alias, `α 50% · id` and
|
||||
/// `from + to @ 40%` for colour calculations, variables by id ([`Self::display_with`] names
|
||||
/// them).
|
||||
pub fn display(&self) -> String {
|
||||
self.display_with(&|id| id.to_string())
|
||||
}
|
||||
|
||||
/// As [`Self::display`], with the variables it reads shown by `name` (the panel shows their
|
||||
/// names where ids would mean nothing to a reader).
|
||||
pub fn display_with(&self, name: &dyn Fn(&str) -> String) -> String {
|
||||
let percent = |x: f32| (x * 100.0).round();
|
||||
let operand = |op: &ColorOperand| match op {
|
||||
ColorOperand::Variable { variable } => name(variable),
|
||||
// As the panel shows a colour (no `#`), with alpha digits when it isn't opaque.
|
||||
ColorOperand::Literal { literal } => literal.to_css_hex().trim_start_matches('#').to_string(),
|
||||
};
|
||||
match self {
|
||||
VariableValue::Color(c) => c.to_hex(),
|
||||
VariableValue::Number(n) => crate::text::trim_num(*n),
|
||||
VariableValue::String(s) => s.clone(),
|
||||
VariableValue::Boolean(b) => b.to_string(),
|
||||
VariableValue::Alias(a) => format!("→ {a}"),
|
||||
VariableValue::Alias(a) => format!("→ {}", name(a)),
|
||||
VariableValue::ColorAlpha { variable, opacity } => format!("α {}% · {}", percent(*opacity), name(variable)),
|
||||
VariableValue::ColorMix { from, to, t } => format!("{} + {} @ {}%", operand(from), operand(to), percent(*t)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The variable ids this value reads (its alias target, or its calculation's variable operands;
|
||||
/// literal operands read nothing). Used for reference walks: cycles, deletion, library remaps.
|
||||
pub fn references(&self) -> Vec<&VariableId> {
|
||||
match self {
|
||||
VariableValue::Alias(a) => vec![a],
|
||||
VariableValue::ColorAlpha { variable, .. } => vec![variable],
|
||||
VariableValue::ColorMix { from, to, .. } => from.variable().into_iter().chain(to.variable()).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::references`], to re-point: library import and swap move them to other ids.
|
||||
pub fn references_mut(&mut self) -> Vec<&mut VariableId> {
|
||||
match self {
|
||||
VariableValue::Alias(a) => vec![a],
|
||||
VariableValue::ColorAlpha { variable, .. } => vec![variable],
|
||||
VariableValue::ColorMix { from, to, .. } => from.variable_mut().into_iter().chain(to.variable_mut()).collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,9 +223,14 @@ impl Collection {
|
||||
self.modes.first().map(|m| &m.id)
|
||||
}
|
||||
|
||||
/// The mode with id `key`, else the one named `key`.
|
||||
/// The mode with id `key`, else the one named `key` (what someone typed).
|
||||
pub fn find_mode(&self, key: &str) -> Option<&Mode> {
|
||||
self.modes.iter().find(|m| m.id == key).or_else(|| self.modes.iter().find(|m| m.name == key))
|
||||
self.mode_by_id(key).or_else(|| self.modes.iter().find(|m| m.name == key))
|
||||
}
|
||||
|
||||
/// The mode with id `id` exactly: a stored reference, which no mode's name stands in for (#1613).
|
||||
pub fn mode_by_id(&self, id: &str) -> Option<&Mode> {
|
||||
self.modes.iter().find(|m| m.id == id)
|
||||
}
|
||||
|
||||
/// The mode the collection is in under `modes` (collection → chosen mode): the chosen one if it
|
||||
@@ -185,6 +276,12 @@ pub struct Variable {
|
||||
pub const CODE_PLATFORMS: [&str; 3] = ["WEB", "iOS", "ANDROID"];
|
||||
|
||||
impl Variable {
|
||||
/// Its value in `mode`, or its first value when it has none there: an export writes every
|
||||
/// mode of the collection, given a value in each or not.
|
||||
pub fn value_or_first(&self, mode: &str) -> Option<&VariableValue> {
|
||||
self.values.get(mode).or_else(|| self.values.values().next())
|
||||
}
|
||||
|
||||
/// Whether pickers offer this variable for properties of `scope`.
|
||||
pub fn in_scope(&self, scope: &str) -> bool {
|
||||
self.scopes.as_ref().is_none_or(|list| list.iter().any(|s| s == scope || s == ALL_SCOPES || (s == "ALL_FILLS" && scope.ends_with("_FILL"))))
|
||||
@@ -264,8 +361,10 @@ pub struct Variables {
|
||||
}
|
||||
|
||||
impl Variables {
|
||||
pub fn get(&self, id: &str) -> Option<&Variable> {
|
||||
self.variables.iter().find(|v| v.id == id || v.name == id)
|
||||
/// The variable a person named: the one with id `key`, else the one named `key`. A stored
|
||||
/// reference goes through [`get_by_id`](Self::get_by_id) instead.
|
||||
pub fn get(&self, key: &str) -> Option<&Variable> {
|
||||
self.get_by_id(key).or_else(|| self.variables.iter().find(|v| v.name == key))
|
||||
}
|
||||
|
||||
/// Look up an already resolved variable id (a stored binding or alias): exact ids only, so a
|
||||
@@ -274,23 +373,181 @@ impl Variables {
|
||||
self.variables.iter().find(|v| v.id == id)
|
||||
}
|
||||
|
||||
pub fn collection(&self, id: &str) -> Option<&Collection> {
|
||||
self.collections.iter().find(|c| c.id == id || c.name == id)
|
||||
/// The collection a person named: the one with id `key`, else the one named `key`. A stored
|
||||
/// reference goes through [`collection_by_id`](Self::collection_by_id) instead.
|
||||
pub fn collection(&self, key: &str) -> Option<&Collection> {
|
||||
self.collection_by_id(key).or_else(|| self.collections.iter().find(|c| c.name == key))
|
||||
}
|
||||
|
||||
/// [`get_by_id`](Self::get_by_id) for collections: a stored collection id (a variable's own, a
|
||||
/// prototype action's, a library record's) never matches another collection's name (#1613).
|
||||
pub fn collection_by_id(&self, id: &str) -> Option<&Collection> {
|
||||
self.collections.iter().find(|c| c.id == id)
|
||||
}
|
||||
|
||||
/// The distinct variables one resolve may consult. Calculations read trees, not chains, so the
|
||||
/// cap counts variables rather than hops; with the memo each one is resolved at most once per
|
||||
/// call, so the work is bounded by this however the graph is shaped (a diamond re-sharing a
|
||||
/// subtree does not walk it twice).
|
||||
pub const RESOLVE_LIMIT: usize = 32;
|
||||
|
||||
/// Resolve `id` in `modes` (collection → chosen mode; default mode otherwise), following aliases
|
||||
/// (bounded, cycles return None).
|
||||
/// and evaluating colour calculations (bounded, cycles and over-deep graphs return None).
|
||||
pub fn resolve(&self, id: &str, modes: &BTreeMap<CollectionId, ModeId>) -> Option<VariableValue> {
|
||||
let mut cur = id.to_string();
|
||||
for _ in 0..32 {
|
||||
let var = self.get_by_id(&cur)?;
|
||||
let coll = self.collection(&var.collection)?;
|
||||
let mode = modes.get(&coll.id).filter(|m| var.values.contains_key(*m)).or_else(|| coll.default_mode())?;
|
||||
match var.values.get(mode).or_else(|| var.values.values().next())? {
|
||||
VariableValue::Alias(next) => cur = next.clone(),
|
||||
v => return Some(v.clone()),
|
||||
self.resolve_memo(id, modes, &mut BTreeMap::new())
|
||||
}
|
||||
|
||||
/// `val`, a value of `var` shown in `mode`, as a format without colour calculations writes it:
|
||||
/// a calculation becomes the colour it evaluates to there (other collections in their default
|
||||
/// modes), anything else stays as given. None: the calculation doesn't resolve.
|
||||
pub fn flattened(&self, var: &Variable, mode: &str, val: &VariableValue) -> Option<VariableValue> {
|
||||
self.flattened_as(&var.id, &var.collection, mode, val)
|
||||
}
|
||||
|
||||
/// [`Self::flattened`] for a value of variable `id` in `collection`, which need not exist yet.
|
||||
pub fn flattened_as(&self, id: &str, collection: &str, mode: &str, val: &VariableValue) -> Option<VariableValue> {
|
||||
match val {
|
||||
VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. } => {
|
||||
// `id` is in flight from the start, so a calculation that reads it back resolves to
|
||||
// nothing, as it would through `resolve`.
|
||||
let mut done = BTreeMap::from([(id.to_string(), None)]);
|
||||
self.eval(val, &BTreeMap::from([(collection.to_string(), mode.to_string())]), &mut done)
|
||||
}
|
||||
other => Some(other.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Values as an export without colour calculations writes them, references included: see
|
||||
/// [`Flattener::value`].
|
||||
pub fn flattener(&self) -> Flattener<'_> {
|
||||
let mut out = BTreeSet::new();
|
||||
for var in &self.variables {
|
||||
let Some(coll) = self.collection_by_id(&var.collection) else { continue };
|
||||
if coll.modes.iter().any(|m| var.value_or_first(&m.id).is_some_and(|v| self.flattened(var, &m.id, v).is_none())) {
|
||||
out.insert(var.id.as_str());
|
||||
}
|
||||
}
|
||||
None
|
||||
// Then every alias that leads to one, back along alias chains.
|
||||
let mut readers: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
|
||||
for var in &self.variables {
|
||||
for value in var.values.values() {
|
||||
if let VariableValue::Alias(target) = value {
|
||||
readers.entry(target.as_str()).or_default().push(var.id.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut stack: Vec<&str> = out.iter().copied().collect();
|
||||
while let Some(id) = stack.pop() {
|
||||
for reader in readers.get(id).into_iter().flatten() {
|
||||
if out.insert(reader) {
|
||||
stack.push(reader);
|
||||
}
|
||||
}
|
||||
}
|
||||
Flattener { vars: self, unreferenceable: out }
|
||||
}
|
||||
|
||||
/// The stored value of `id` in its collection's current mode (the mode chosen when it has a
|
||||
/// value for it, else the default mode, else any value).
|
||||
fn value_in(&self, id: &str, modes: &BTreeMap<CollectionId, ModeId>) -> Option<&VariableValue> {
|
||||
let var = self.get_by_id(id)?;
|
||||
let coll = self.collection_by_id(&var.collection)?;
|
||||
let mode = modes.get(&coll.id).filter(|m| var.values.contains_key(*m)).or_else(|| coll.default_mode())?;
|
||||
var.values.get(mode).or_else(|| var.values.values().next())
|
||||
}
|
||||
|
||||
/// [`Self::resolve`] with `done` shared by every variable one call reads: each is resolved once
|
||||
/// and its outcome kept, one met again while still in flight is a cycle (None), and the call
|
||||
/// reads at most [`Self::RESOLVE_LIMIT`] variables.
|
||||
fn resolve_memo(
|
||||
&self,
|
||||
id: &str,
|
||||
modes: &BTreeMap<CollectionId, ModeId>,
|
||||
done: &mut BTreeMap<String, Option<VariableValue>>,
|
||||
) -> Option<VariableValue> {
|
||||
if let Some(hit) = done.get(id) {
|
||||
return hit.clone();
|
||||
}
|
||||
if done.len() >= Self::RESOLVE_LIMIT {
|
||||
return None;
|
||||
}
|
||||
// Mark as being resolved before recursing: meeting it again mid-flight is a cycle, and the
|
||||
// stored None unwinds it. Overwritten with the outcome below (a cached failure stays cached:
|
||||
// a variable resolves the same however deep it is reached from — the limit only bails, it
|
||||
// does not cache).
|
||||
done.insert(id.to_string(), None);
|
||||
let out = self.value_in(id, modes).and_then(|val| self.eval(val, modes, done));
|
||||
done.insert(id.to_string(), out.clone());
|
||||
out
|
||||
}
|
||||
|
||||
/// A stored value reduced to a plain one (aliases walked, calculations evaluated).
|
||||
fn eval(
|
||||
&self,
|
||||
val: &VariableValue,
|
||||
modes: &BTreeMap<CollectionId, ModeId>,
|
||||
done: &mut BTreeMap<String, Option<VariableValue>>,
|
||||
) -> Option<VariableValue> {
|
||||
match val {
|
||||
VariableValue::Alias(next) => self.resolve_memo(next, modes, done),
|
||||
VariableValue::ColorAlpha { variable, opacity } => match self.resolve_memo(variable, modes, done)? {
|
||||
VariableValue::Color(c) => Some(VariableValue::Color(c.with_alpha(*opacity))),
|
||||
_ => None,
|
||||
},
|
||||
VariableValue::ColorMix { from, to, t } => {
|
||||
let a = self.operand_color(from, modes, done)?;
|
||||
let b = self.operand_color(to, modes, done)?;
|
||||
Some(VariableValue::Color(mix_colors(&a, &b, *t)))
|
||||
}
|
||||
other => Some(other.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A colour operand: a literal as stored, or a variable that must itself resolve to a colour.
|
||||
fn operand_color(
|
||||
&self,
|
||||
op: &ColorOperand,
|
||||
modes: &BTreeMap<CollectionId, ModeId>,
|
||||
done: &mut BTreeMap<String, Option<VariableValue>>,
|
||||
) -> Option<Color> {
|
||||
match op {
|
||||
ColorOperand::Literal { literal } => Some(literal.sanitized()),
|
||||
ColorOperand::Variable { variable } => match self.resolve_memo(variable, modes, done)? {
|
||||
VariableValue::Color(c) => Some(c),
|
||||
_ => None,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Linear per-channel interpolation (`t` clamped and NaN-guarded: a command validates, a crafted
|
||||
/// file must still resolve to a sane colour).
|
||||
/// Values as an export that leaves out what resolves to nothing writes them ([`Variables::flattener`]).
|
||||
pub struct Flattener<'v> {
|
||||
vars: &'v Variables,
|
||||
/// Colour calculations that resolve to nothing in some mode of their collection, and every
|
||||
/// alias that leads to one.
|
||||
unreferenceable: BTreeSet<&'v str>,
|
||||
}
|
||||
|
||||
impl Flattener<'_> {
|
||||
/// `val`, a value of `var` in `mode`, as [`Variables::flattened`] writes it, and an alias that
|
||||
/// leads to a calculation some mode leaves out also as the value it shows here: a reference to
|
||||
/// it would read nothing whenever that mode applies. None: it shows nothing in this mode.
|
||||
pub fn value(&self, var: &Variable, mode: &str, val: &VariableValue) -> Option<VariableValue> {
|
||||
match self.vars.flattened(var, mode, val)? {
|
||||
VariableValue::Alias(target) if self.unreferenceable.contains(target.as_str()) => {
|
||||
self.vars.resolve(&var.id, &BTreeMap::from([(var.collection.clone(), mode.to_string())]))
|
||||
}
|
||||
other => Some(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mix_colors(a: &Color, b: &Color, t: f32) -> Color {
|
||||
let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 };
|
||||
// Weighted at both ends so t = 0 and t = 1 give each operand exactly: `x + (y - x) * t` can
|
||||
// miss `y` by a rounding step (from 1.0 to 1e-8 it lands on 0).
|
||||
let lerp = |x: f32, y: f32| x * (1.0 - t) + y * t;
|
||||
Color::new(lerp(a.r, b.r), lerp(a.g, b.g), lerp(a.b, b.b), lerp(a.a, b.a))
|
||||
}
|
||||
+204
-66
@@ -249,64 +249,14 @@ fn vector_network_paths() {
|
||||
|
||||
#[test]
|
||||
fn variables_resolve_modes_and_aliases() {
|
||||
let mut v = Variables::default();
|
||||
v.collections.push(Collection {
|
||||
id: "c1".into(),
|
||||
name: "Theme".into(),
|
||||
modes: vec![Mode { id: "light".into(), name: "Light".into() }, Mode { id: "dark".into(), name: "Dark".into() }],
|
||||
extends: None,
|
||||
});
|
||||
let mut vals = std::collections::BTreeMap::new();
|
||||
vals.insert("light".to_string(), VariableValue::Color(Color::WHITE));
|
||||
vals.insert("dark".to_string(), VariableValue::Color(Color::BLACK));
|
||||
v.variables.push(Variable {
|
||||
id: "bg".into(),
|
||||
name: "bg".into(),
|
||||
collection: "c1".into(),
|
||||
kind: VariableType::Color,
|
||||
values: vals,
|
||||
description: String::new(),
|
||||
scopes: None,
|
||||
hidden_from_publishing: false,
|
||||
extends: None,
|
||||
overrides: Default::default(),
|
||||
code_syntax: Default::default(),
|
||||
});
|
||||
let mut alias = std::collections::BTreeMap::new();
|
||||
alias.insert("light".to_string(), VariableValue::Alias("bg".into()));
|
||||
v.variables.push(Variable {
|
||||
id: "surface".into(),
|
||||
name: "surface".into(),
|
||||
collection: "c1".into(),
|
||||
kind: VariableType::Color,
|
||||
values: alias,
|
||||
description: String::new(),
|
||||
scopes: None,
|
||||
hidden_from_publishing: false,
|
||||
extends: None,
|
||||
overrides: Default::default(),
|
||||
code_syntax: Default::default(),
|
||||
});
|
||||
let mut v = vars_with_colours(&[("bg", VariableValue::Color(Color::WHITE), VariableValue::Color(Color::BLACK))]);
|
||||
add(&mut v, "surface", VariableValue::Alias("bg".into()));
|
||||
let mut modes = std::collections::BTreeMap::new();
|
||||
assert_eq!(v.resolve("surface", &modes), Some(VariableValue::Color(Color::WHITE)));
|
||||
modes.insert("c1".to_string(), "dark".to_string());
|
||||
assert_eq!(v.resolve("bg", &modes), Some(VariableValue::Color(Color::BLACK)));
|
||||
// Cycle.
|
||||
let mut cyc = std::collections::BTreeMap::new();
|
||||
cyc.insert("light".to_string(), VariableValue::Alias("loop".into()));
|
||||
v.variables.push(Variable {
|
||||
id: "loop".into(),
|
||||
name: "loop".into(),
|
||||
collection: "c1".into(),
|
||||
kind: VariableType::Color,
|
||||
values: cyc,
|
||||
description: String::new(),
|
||||
scopes: None,
|
||||
hidden_from_publishing: false,
|
||||
extends: None,
|
||||
overrides: Default::default(),
|
||||
code_syntax: Default::default(),
|
||||
});
|
||||
add(&mut v, "loop", VariableValue::Alias("loop".into()));
|
||||
assert_eq!(v.resolve("loop", &std::collections::BTreeMap::new()), None);
|
||||
}
|
||||
|
||||
@@ -319,19 +269,7 @@ fn variables_resolve_stored_ids_never_by_name() {
|
||||
modes: vec![Mode { id: "m".into(), name: "Mode 1".into() }],
|
||||
extends: None,
|
||||
});
|
||||
let var = |id: &str, name: &str, value: VariableValue| Variable {
|
||||
id: id.into(),
|
||||
name: name.into(),
|
||||
collection: "c1".into(),
|
||||
kind: VariableType::Color,
|
||||
values: std::collections::BTreeMap::from([("m".to_string(), value)]),
|
||||
description: String::new(),
|
||||
scopes: None,
|
||||
hidden_from_publishing: false,
|
||||
extends: None,
|
||||
overrides: Default::default(),
|
||||
code_syntax: Default::default(),
|
||||
};
|
||||
let var = |id: &str, name: &str, value: VariableValue| colour_var(id, name, &[("m", value)]);
|
||||
let red = Color::from_hex("#FF0000").unwrap();
|
||||
let blue = Color::from_hex("#0D99FF").unwrap();
|
||||
// Named like the next variable's id, and like an id that no longer exists.
|
||||
@@ -344,6 +282,26 @@ fn variables_resolve_stored_ids_never_by_name() {
|
||||
// A deleted id stays unresolved instead of picking up the variable named like it.
|
||||
assert_eq!(v.resolve("VariableID:9", &modes), None);
|
||||
assert_eq!(v.resolve("VariableID:4", &modes), None);
|
||||
// What a person types: an id wins over a name, and a name still finds its variable (#1613).
|
||||
assert_eq!(v.get("VariableID:2").map(|x| x.name.as_str()), Some("surface"));
|
||||
assert_eq!(v.get("surface").map(|x| x.id.as_str()), Some("VariableID:2"));
|
||||
assert!(v.get_by_id("surface").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collections_and_modes_resolve_stored_ids_never_by_name() {
|
||||
let mut v = Variables::default();
|
||||
let coll = |id: &str, name: &str, modes: Vec<Mode>| Collection { id: id.into(), name: name.into(), modes, extends: None };
|
||||
let mode = |id: &str, name: &str| Mode { id: id.into(), name: name.into() };
|
||||
v.collections.push(coll("c1", "c2", vec![mode("m1", "m2")]));
|
||||
v.collections.push(coll("c2", "Theme", vec![mode("m2", "Dark"), mode("m3", "m9")]));
|
||||
assert_eq!(v.collection("c2").map(|c| c.name.as_str()), Some("Theme"), "an id wins over a name");
|
||||
assert_eq!(v.collection("Theme").map(|c| c.id.as_str()), Some("c2"));
|
||||
assert!(v.collection_by_id("Theme").is_none() && v.collection_by_id("c9").is_none());
|
||||
let theme = v.collection_by_id("c2").unwrap();
|
||||
assert_eq!(theme.mode_by_id("m2").map(|m| m.name.as_str()), Some("Dark"));
|
||||
assert!(theme.mode_by_id("m9").is_none(), "a deleted mode id isn't the mode named like it");
|
||||
assert_eq!(theme.find_mode("Dark").map(|m| m.id.as_str()), Some("m2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -708,3 +666,183 @@ fn two_segment_closed_loop_rounded_corners() {
|
||||
let path = net.fill_path();
|
||||
assert!(!path.elements().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_resolve_only_their_exact_collection() {
|
||||
let mut vars = crate::proto_expr_tests::vars();
|
||||
// The wrong collection's modes force the old resolver's first-value fallback (dark).
|
||||
let mut shadow = vars.collections[0].clone();
|
||||
shadow.id = "shadow".into();
|
||||
shadow.name = "c".into();
|
||||
shadow.modes = vec![Mode { id: "other".into(), name: "Other".into() }];
|
||||
vars.collections.insert(0, shadow);
|
||||
assert_eq!(vars.resolve("n", &Default::default()), Some(VariableValue::Number(3.0)));
|
||||
vars.collections.retain(|c| c.id != "c");
|
||||
assert_eq!(vars.resolve("n", &Default::default()), None);
|
||||
}
|
||||
|
||||
// ——— colour calculations ———
|
||||
|
||||
/// A colour variable of collection `c1` with `values` by mode id.
|
||||
fn colour_var(id: &str, name: &str, values: &[(&str, VariableValue)]) -> Variable {
|
||||
Variable {
|
||||
id: id.into(),
|
||||
name: name.into(),
|
||||
collection: "c1".into(),
|
||||
kind: VariableType::Color,
|
||||
values: values.iter().map(|(m, v)| (m.to_string(), v.clone())).collect(),
|
||||
description: String::new(),
|
||||
scopes: None,
|
||||
hidden_from_publishing: false,
|
||||
extends: None,
|
||||
overrides: Default::default(),
|
||||
code_syntax: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `c1` with Light/Dark (Light is the default mode) and colour variables `("id", light, dark)`.
|
||||
fn vars_with_colours(cols: &[(&str, VariableValue, VariableValue)]) -> Variables {
|
||||
let mut v = Variables::default();
|
||||
v.collections.push(Collection {
|
||||
id: "c1".into(),
|
||||
name: "C".into(),
|
||||
modes: vec![Mode { id: "light".into(), name: "Light".into() }, Mode { id: "dark".into(), name: "Dark".into() }],
|
||||
extends: None,
|
||||
});
|
||||
for (id, light, dark) in cols {
|
||||
v.variables.push(colour_var(id, id, &[("light", light.clone()), ("dark", dark.clone())]));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Adds `id` with a value in the Light mode only.
|
||||
fn add(v: &mut Variables, id: &str, light: VariableValue) {
|
||||
v.variables.push(colour_var(id, id, &[("light", light)]));
|
||||
}
|
||||
|
||||
fn fade(source: &str, opacity: f32) -> VariableValue {
|
||||
VariableValue::ColorAlpha { variable: source.into(), opacity }
|
||||
}
|
||||
fn mix(from: ColorOperand, to: ColorOperand, t: f32) -> VariableValue {
|
||||
VariableValue::ColorMix { from, to, t }
|
||||
}
|
||||
fn lit(hex: &str) -> ColorOperand {
|
||||
ColorOperand::Literal { literal: Color::from_hex_or_name(hex).unwrap() }
|
||||
}
|
||||
fn src(id: &str) -> ColorOperand {
|
||||
ColorOperand::Variable { variable: id.into() }
|
||||
}
|
||||
fn red() -> VariableValue {
|
||||
VariableValue::Color(Color::from_rgb8_const(255, 0, 0))
|
||||
}
|
||||
fn resolved(v: &Variables, id: &str, mode: Option<&str>) -> Color {
|
||||
let modes: std::collections::BTreeMap<String, String> =
|
||||
mode.map(|m| std::collections::BTreeMap::from([("c1".to_string(), m.to_string())])).unwrap_or_default();
|
||||
match v.resolve(id, &modes) {
|
||||
Some(VariableValue::Color(c)) => c,
|
||||
other => panic!("{id} did not resolve to a colour: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_alpha_calculation_replaces_alpha_and_follows_its_source() {
|
||||
let mut v = vars_with_colours(&[("base", red(), VariableValue::Color(Color::from_rgb8_const(0, 0, 255)))]);
|
||||
add(&mut v, "fade", fade("base", 0.5));
|
||||
assert_eq!(resolved(&v, "fade", None).to_rgba8(), [255, 0, 0, 128], "default mode: red at half alpha (replace)");
|
||||
assert_eq!(resolved(&v, "fade", Some("dark")).to_rgba8(), [0, 0, 255, 128], "the source's value in the chosen mode is what fades");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_alpha_calculation_replaces_even_a_faded_sources_alpha() {
|
||||
// Replace (not scale): a fade of a source that already has alpha lands on exactly the requested
|
||||
// opacity, so re-editing the source's alpha cannot compound with the fade's.
|
||||
let quarter = VariableValue::Color(Color::new(1.0, 0.0, 0.0, 0.25));
|
||||
let mut v = vars_with_colours(&[("base", quarter.clone(), quarter)]);
|
||||
add(&mut v, "fade", fade("base", 0.5));
|
||||
assert_eq!(resolved(&v, "fade", None).a, 0.5, "not 0.25 * 0.5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_alpha_calculation_reads_through_an_alias() {
|
||||
let mut v = vars_with_colours(&[("base", red(), red())]);
|
||||
add(&mut v, "mirror", VariableValue::Alias("base".into()));
|
||||
add(&mut v, "faded", fade("mirror", 0.5));
|
||||
assert_eq!(resolved(&v, "faded", None).to_rgba8(), [255, 0, 0, 128]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mix_blends_stored_components_with_exact_edges() {
|
||||
let mut v = vars_with_colours(&[("base", red(), VariableValue::Color(Color::from_rgb8_const(0, 0, 255)))]);
|
||||
add(&mut v, "t0", mix(src("base"), lit("#FFFFFF"), 0.0));
|
||||
add(&mut v, "t1", mix(src("base"), lit("#FFFFFF"), 1.0));
|
||||
add(&mut v, "grey", mix(lit("#000000"), lit("#FFFFFF"), 0.4));
|
||||
add(&mut v, "afade", mix(lit("#FF0000"), lit("#FF000000"), 0.25));
|
||||
assert_eq!(resolved(&v, "t0", None).to_rgba8(), [255, 0, 0, 255], "t=0 is exactly the first operand");
|
||||
assert_eq!(resolved(&v, "t1", None).to_rgba8(), [255, 255, 255, 255], "t=1 is exactly the second");
|
||||
assert_eq!(resolved(&v, "grey", None).to_rgba8(), [102, 102, 102, 255], "0.4 of white rounds to 102 (0x66)");
|
||||
assert_eq!(resolved(&v, "afade", None).to_rgba8(), [255, 0, 0, 191], "alpha blends like the channels (0.75 -> 0xBF)");
|
||||
assert_eq!(resolved(&v, "t0", Some("dark")).to_rgba8(), [0, 0, 255, 255], "the variable operand follows the mode");
|
||||
// Exact in the stored components too, not only after 8-bit rounding.
|
||||
let faint = ColorOperand::Literal { literal: Color::new(1e-8, 0.0, 0.0, 1.0) };
|
||||
add(&mut v, "to_faint", mix(lit("#FF0000"), faint.clone(), 1.0));
|
||||
add(&mut v, "from_faint", mix(faint, lit("#FF0000"), 0.0));
|
||||
assert_eq!(resolved(&v, "to_faint", None).r, 1e-8, "t=1 is the second operand to the last bit");
|
||||
assert_eq!(resolved(&v, "from_faint", None).r, 1e-8, "t=0 is the first operand to the last bit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calculations_compose_and_a_crafted_cycle_terminates() {
|
||||
let mut v = vars_with_colours(&[("base", red(), red())]);
|
||||
add(&mut v, "fade", fade("base", 0.5));
|
||||
add(&mut v, "toast", mix(src("fade"), lit("#FFFFFF"), 0.0));
|
||||
assert_eq!(resolved(&v, "toast", None).to_rgba8(), [255, 0, 0, 128], "a calculation may read a calculation");
|
||||
// A hand-crafted cycle (commands refuse these) must unwind, never spin.
|
||||
add(&mut v, "loop", mix(src("loop2"), lit("#FFFFFF"), 0.5));
|
||||
add(&mut v, "loop2", fade("loop", 0.5));
|
||||
assert_eq!(v.resolve("loop", &std::collections::BTreeMap::new()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calculation_resolution_is_memoised_and_bounded() {
|
||||
// A doubling tree: each level reads the one below twice. Memoised per resolve, that is
|
||||
// one pass over the levels; unmemoised it is 2^23 walks (the time bound catches a regression).
|
||||
let mut v = vars_with_colours(&[("leaf", red(), red())]);
|
||||
let mut prev = "leaf".to_string();
|
||||
for i in 0..23 {
|
||||
let id = format!("f{i}");
|
||||
add(&mut v, &id, mix(src(&prev), src(&prev), 0.5));
|
||||
prev = id;
|
||||
}
|
||||
let started = std::time::Instant::now();
|
||||
assert_eq!(resolved(&v, &prev, None).to_rgba8(), [255, 0, 0, 255], "the tree reads the leaf");
|
||||
assert!(started.elapsed().as_millis() < 200, "memoised: {:?}", started.elapsed());
|
||||
// A diamond: two fades of one base under one mix (shared subtrees each resolved once).
|
||||
let quarter = VariableValue::Color(Color::new(1.0, 0.0, 0.0, 0.25));
|
||||
let mut d = vars_with_colours(&[("base", quarter.clone(), quarter)]);
|
||||
add(&mut d, "fa", fade("base", 0.25));
|
||||
add(&mut d, "fb", fade("base", 0.75));
|
||||
add(&mut d, "dia", mix(src("fa"), src("fb"), 0.5));
|
||||
assert_eq!(resolved(&d, "dia", None).a, 0.5, "0.25 + (0.75 - 0.25) * 0.5");
|
||||
// One resolve reads at most 32 variables, however they are chained.
|
||||
let mut long = vars_with_colours(&[("v0", red(), red())]);
|
||||
for i in 1..=40 {
|
||||
add(&mut long, &format!("v{i}"), fade(&format!("v{}", i - 1), 0.99));
|
||||
}
|
||||
assert_eq!(resolved(&long, "v31", None).to_rgba8(), [255, 0, 0, 252], "32 variables still resolve");
|
||||
assert_eq!(long.resolve("v32", &std::collections::BTreeMap::new()), None, "a 33rd reads nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_colour_walker_visits_calculation_literals() {
|
||||
// Document::map_colors (profile conversion) must reach the colours stored inside a mix:
|
||||
// leaving them behind would blend converted operands with unconverted ones.
|
||||
let mut v = vars_with_colours(&[("base", red(), red())]);
|
||||
add(&mut v, "mx", mix(lit("#804020"), src("base"), 0.5));
|
||||
let mut doc = Document::new("x");
|
||||
doc.variables = v;
|
||||
doc.map_colors(|c| c.with_alpha(0.5));
|
||||
let mx = doc.variables.variables.iter().find(|x| x.id == "mx").unwrap();
|
||||
let VariableValue::ColorMix { from, .. } = &mx.values["light"] else { panic!("still the mix") };
|
||||
let ColorOperand::Literal { literal } = from else { panic!("literal operand") };
|
||||
assert_eq!(literal.a, 0.5, "the mix's stored literal was converted like every other colour");
|
||||
}
|
||||
@@ -379,6 +379,13 @@ impl TextStyle {
|
||||
/// Weight from a style name ("Semi Bold Italic" → 600).
|
||||
pub fn style_weight(style: &str) -> u16 {
|
||||
let s = style.to_ascii_lowercase().replace([' ', '-'], "");
|
||||
// A weight number ("600", "600 Italic"), as some apps name styles.
|
||||
let digits: String = s.chars().take_while(char::is_ascii_digit).collect();
|
||||
if let Ok(n) = digits.parse::<u16>()
|
||||
&& (1..=1000).contains(&n)
|
||||
{
|
||||
return n;
|
||||
}
|
||||
let table: [(&str, u16); 10] = [
|
||||
("thin", 100),
|
||||
("extralight", 200),
|
||||
|
||||
@@ -6,8 +6,8 @@ allow-expect-in-tests = true
|
||||
allow-panic-in-tests = true
|
||||
too-many-arguments-threshold = 10
|
||||
disallowed-methods = [
|
||||
{ path = "std::fs::read", reason = "use file_access::read (automation roots)" },
|
||||
{ path = "std::fs::read_to_string", reason = "use file_access::read (automation roots)" },
|
||||
{ path = "std::fs::read", reason = "use file_access::read_capped (automation roots, size caps)" },
|
||||
{ path = "std::fs::read_to_string", reason = "use file_access::read_capped (automation roots, size caps)" },
|
||||
{ path = "std::fs::write", reason = "use file_access::write (automation roots)" },
|
||||
{ path = "std::fs::metadata", reason = "use file_access::metadata (automation roots)" },
|
||||
{ path = "std::fs::symlink_metadata", reason = "use file_access (automation roots)" },
|
||||
@@ -35,5 +35,5 @@ disallowed-methods = [
|
||||
{ path = "std::path::Path::canonicalize", reason = "use file_access (automation roots)" },
|
||||
{ path = "figcraft_format::write_file_atomic", reason = "use file_access::write (automation roots)" },
|
||||
{ path = "figcraft_format::save_file", reason = "use file_access::write (automation roots)" },
|
||||
{ path = "figcraft_format::load_file", reason = "use file_access::read (automation roots)" },
|
||||
{ path = "figcraft_format::load_file", reason = "use file_access::read_capped (automation roots, size caps)" },
|
||||
]
|
||||
@@ -18,7 +18,7 @@ use figcraft_doc::proto::Action;
|
||||
use figcraft_doc::{
|
||||
Collection, CollectionId, Document, ImportedComponent, ImportedRecord, ImportedStyle, LibraryAsset, LibraryCollection, LibraryLink,
|
||||
LibraryManifest, LibraryStyle, LibraryVariable, Node, NodeId, NodeKind, PropDef, PublishedLibrary, Style, StyleKind, Variable, VariableId,
|
||||
VariableValue, Variables, node_images, paint_images, used_images,
|
||||
Variables, node_images, paint_images, used_images,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -240,24 +240,27 @@ pub fn default_selection(doc: &Document, prev: Option<&LibraryManifest>) -> Sele
|
||||
.collections
|
||||
.into_iter()
|
||||
.filter(|c| {
|
||||
pick(prev.and_then(|m| m.collection(c)).map(|x| (x.published, x.name.as_str())), doc.variables.collection(c).map(|x| x.name.as_str()))
|
||||
pick(
|
||||
prev.and_then(|m| m.collection(c)).map(|x| (x.published, x.name.as_str())),
|
||||
doc.variables.collection_by_id(c).map(|x| x.name.as_str()),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `seeds` (variable ids) and every variable they alias, transitively, by id.
|
||||
/// `seeds` (variable ids) and every variable they alias or calculate from, transitively, by id.
|
||||
fn alias_closure(vars: &Variables, seeds: impl IntoIterator<Item = VariableId>) -> BTreeSet<VariableId> {
|
||||
let mut out = BTreeSet::new();
|
||||
let mut stack: Vec<VariableId> = seeds.into_iter().collect();
|
||||
while let Some(cur) = stack.pop() {
|
||||
let Some(v) = vars.get(&cur) else { continue };
|
||||
let Some(v) = vars.get_by_id(&cur) else { continue };
|
||||
if out.len() >= MAX_ASSETS || !out.insert(v.id.clone()) {
|
||||
continue;
|
||||
}
|
||||
for val in v.values.values() {
|
||||
if let VariableValue::Alias(a) = val {
|
||||
stack.push(a.clone());
|
||||
for r in val.references() {
|
||||
stack.push(r.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -702,7 +705,7 @@ pub fn import_variables(
|
||||
) -> VariableMap {
|
||||
let (lid, m, src) = (lib.manifest.id.as_str(), &lib.manifest, &lib.doc.variables);
|
||||
let vars = alias_closure(src, keys.iter().cloned());
|
||||
let colls: BTreeSet<&CollectionId> = collections.iter().chain(vars.iter().filter_map(|v| src.get(v)).map(|v| &v.collection)).collect();
|
||||
let colls: BTreeSet<&CollectionId> = collections.iter().chain(vars.iter().filter_map(|v| src.get_by_id(v)).map(|v| &v.collection)).collect();
|
||||
let mut out = VariableMap::default();
|
||||
// Collections first: the variables name theirs.
|
||||
for key in colls {
|
||||
@@ -755,15 +758,19 @@ pub fn import_variables(
|
||||
let Some(collection) = out.collections.get(&v.collection).cloned() else { continue };
|
||||
let mut values = BTreeMap::new();
|
||||
for (mode, val) in &v.values {
|
||||
let val = match val {
|
||||
// An alias to a variable the library doesn't have resolves to nothing: dropped
|
||||
// (never pointed at a variable of this file).
|
||||
VariableValue::Alias(a) => match src.get(a).and_then(|t| out.variables.get(&t.id)) {
|
||||
Some(l) => VariableValue::Alias(l.clone()),
|
||||
None => continue,
|
||||
},
|
||||
other => other.clone(),
|
||||
};
|
||||
// A value reading a variable the library doesn't have resolves to nothing: dropped
|
||||
// (never pointed at a variable of this file), for aliases and calculations alike.
|
||||
let mut val = val.clone();
|
||||
let remapped = val.references_mut().into_iter().all(|r| match src.get_by_id(r.as_str()).and_then(|t| out.variables.get(&t.id)) {
|
||||
Some(here) => {
|
||||
*r = here.clone();
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
});
|
||||
if !remapped {
|
||||
continue;
|
||||
}
|
||||
values.insert(mode.clone(), val);
|
||||
}
|
||||
let copy = Variable { id: local.clone(), collection, values, extends: None, overrides: Default::default(), ..v.clone() };
|
||||
@@ -955,7 +962,7 @@ pub fn pending(doc: &Document, lib: &PublishedLibrary) -> Pending {
|
||||
.filter(|r| r.library == m.id)
|
||||
.map(|r| r.key.as_str())
|
||||
.collect();
|
||||
let copied = |key: &str| libs.variables.iter().any(|r| r.library == m.id && r.key == key && vars.get(&r.local).is_some());
|
||||
let copied = |key: &str| libs.variables.iter().any(|r| r.library == m.id && r.key == key && vars.get_by_id(&r.local).is_some());
|
||||
variables.extend(published_variables(lib).filter(|v| extended.contains(v.collection.as_str()) && !copied(&v.id)).map(|v| ImportedRecord {
|
||||
library: m.id.clone(),
|
||||
key: v.id.clone(),
|
||||
@@ -1041,10 +1048,14 @@ pub fn prune(doc: &mut Document, library: &str) -> Result<()> {
|
||||
.flat_map(|n| n.bound.values().map(String::as_str).chain(action_variables(n)))
|
||||
.chain(doc.variables.variables.iter().filter_map(|v| v.extends.as_deref()))
|
||||
.map(str::to_string)
|
||||
.chain(doc.variables.variables.iter().filter(|v| !is_remote_variable(doc, &v.id)).flat_map(|v| v.values.values()).filter_map(|x| match x {
|
||||
VariableValue::Alias(a) => Some(a.clone()),
|
||||
_ => None,
|
||||
}))
|
||||
.chain(
|
||||
doc.variables
|
||||
.variables
|
||||
.iter()
|
||||
.filter(|v| !is_remote_variable(doc, &v.id))
|
||||
.flat_map(|v| v.values.values())
|
||||
.flat_map(|x| x.references().into_iter().cloned()),
|
||||
)
|
||||
.collect::<Vec<_>>();
|
||||
let used = alias_closure(&doc.variables, seeds);
|
||||
let unused: BTreeSet<String> =
|
||||
|
||||
@@ -149,19 +149,22 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re
|
||||
out.styles = styles.len();
|
||||
|
||||
// Variables: by collection and variable name and type; collections and modes by name.
|
||||
let coll_name = |d: &Document, id: &str| d.variables.collection(id).map(|c| c.name.clone()).unwrap_or_default();
|
||||
// Variables the document's own layers bind or its own variables alias.
|
||||
let coll_name = |d: &Document, id: &str| d.variables.collection_by_id(id).map(|c| c.name.clone()).unwrap_or_default();
|
||||
// Variables the document's own layers bind or its own variables alias or calculate from.
|
||||
let remote: BTreeSet<String> = doc.libraries.variables.iter().map(|r| r.local.clone()).collect();
|
||||
let aliases = doc.variables.variables.iter().filter(|v| !remote.contains(&v.id)).flat_map(|v| v.values.values()).filter_map(|x| match x {
|
||||
VariableValue::Alias(a) => Some(a.clone()),
|
||||
_ => None,
|
||||
});
|
||||
let aliases = doc
|
||||
.variables
|
||||
.variables
|
||||
.iter()
|
||||
.filter(|v| !remote.contains(&v.id))
|
||||
.flat_map(|v| v.values.values())
|
||||
.flat_map(|x| x.references().into_iter().cloned());
|
||||
let used: BTreeSet<String> = doc.nodes().filter(|n| outside(doc, n.id)).flat_map(|n| n.bound.values().cloned()).chain(aliases).collect();
|
||||
let var_recs: Vec<ImportedRecord> = doc.libraries.variables.iter().filter(|r| r.library == from && used.contains(&r.local)).cloned().collect();
|
||||
let lib_coll = |name: &str| to.doc.variables.collections.iter().find(|c| c.name == name && m.collection(&c.id).is_some_and(|x| x.published));
|
||||
let mut var_keys: BTreeMap<String, String> = BTreeMap::new();
|
||||
for r in &var_recs {
|
||||
let Some(v) = doc.variables.get(&r.local) else { continue };
|
||||
let Some(v) = doc.variables.get_by_id(&r.local) else { continue };
|
||||
let cname = coll_name(doc, &v.collection);
|
||||
let found = lib_coll(&cname).and_then(|c| {
|
||||
to.doc
|
||||
@@ -189,10 +192,10 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re
|
||||
// A's collection → (B's, A's mode id → B's mode of the same name).
|
||||
let mut colls: BTreeMap<String, (String, BTreeMap<String, String>)> = BTreeMap::new();
|
||||
for (a, k) in &coll_keys {
|
||||
let (Some(b), Some(ac)) = (vmap.collections.get(k), doc.variables.collection(a)) else { continue };
|
||||
let (Some(b), Some(ac)) = (vmap.collections.get(k), doc.variables.collection_by_id(a)) else { continue };
|
||||
let modes = doc
|
||||
.variables
|
||||
.collection(b)
|
||||
.collection_by_id(b)
|
||||
.map(|bc| {
|
||||
ac.modes.iter().filter_map(|am| bc.modes.iter().find(|bm| bm.name == am.name).map(|bm| (am.id.clone(), bm.id.clone()))).collect()
|
||||
})
|
||||
@@ -233,11 +236,9 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re
|
||||
.collect();
|
||||
}
|
||||
for v in doc.variables.variables.iter_mut().filter(|v| !remote.contains(&v.id)) {
|
||||
for val in v.values.values_mut() {
|
||||
if let VariableValue::Alias(a) = val
|
||||
&& let Some(b) = vars.get(a)
|
||||
{
|
||||
*a = b.clone();
|
||||
for r in v.values.values_mut().flat_map(VariableValue::references_mut) {
|
||||
if let Some(b) = vars.get(r.as_str()) {
|
||||
*r = b.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,17 +57,23 @@ fn main_of(s: &Session, inst: NodeId) -> NodeId {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swap_moves_instances_styles_and_variables_by_name() {
|
||||
let dir = Scratch(std::env::temp_dir().join(format!("figcraft-libswap-{}", std::process::id())));
|
||||
/// A session with libraries A (blue, plus `extra_a` components) and B (green) enabled, files in a
|
||||
/// scratch directory named by `tag`; returns the directory guard, the session and both library ids.
|
||||
fn two_libraries(tag: &str, extra_a: &[&str]) -> (Scratch, Session, String, String) {
|
||||
let dir = Scratch(std::env::temp_dir().join(format!("figcraft-libswap-{tag}-{}", std::process::id())));
|
||||
std::fs::create_dir_all(&dir.0).unwrap();
|
||||
let (pa, pb) = (dir.0.join("a.figcraft").to_string_lossy().into_owned(), dir.0.join("b.figcraft").to_string_lossy().into_owned());
|
||||
let a = library(&pa, "#0000ff", &["Only in A"]);
|
||||
let a = library(&pa, "#0000ff", extra_a);
|
||||
let b = library(&pb, "#00ff00", &[]);
|
||||
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "library.add", json!({"path": pa}));
|
||||
ex(&mut s, "library.add", json!({"path": pb}));
|
||||
(dir, s, a, b)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swap_moves_instances_styles_and_variables_by_name() {
|
||||
let (_dir, mut s, a, b) = two_libraries("main", &["Only in A"]);
|
||||
let inst = idv(&ex(&mut s, "library.place", json!({"library": a, "component": "Button", "x": 0, "y": 0})));
|
||||
let label = s.doc().unwrap().node(inst).unwrap().children[0];
|
||||
ex(&mut s, "text.set", json!({"id": label, "characters": "Buy"}));
|
||||
@@ -114,3 +120,63 @@ fn swap_moves_instances_styles_and_variables_by_name() {
|
||||
assert!(s.execute("library.swap", &json!({"from": b, "to": b})).is_err());
|
||||
assert!(s.execute("library.swap", &json!({"from": a, "to": b})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_keep_library_swap_matches_on_the_real_copies() {
|
||||
let dir = Scratch(std::env::temp_dir().join(format!("figcraft-libswap-ids-{}", std::process::id())));
|
||||
std::fs::create_dir_all(&dir.0).unwrap();
|
||||
let (pa, pb) = (dir.0.join("a.figcraft").to_string_lossy().into_owned(), dir.0.join("b.figcraft").to_string_lossy().into_owned());
|
||||
let a = library(&pa, "#0000ff", &[]);
|
||||
let b = library(&pb, "#00ff00", &[]);
|
||||
let mut s = Session::new();
|
||||
let shadow_collection = ex(&mut s, "variable.createCollection", json!({"name": "Shadow"}));
|
||||
let shadow = ex(&mut s, "variable.create", json!({"collection": "Shadow", "name": "shadow", "type": "color", "value": "#ff0000"}));
|
||||
ex(&mut s, "library.add", json!({"path": pa}));
|
||||
ex(&mut s, "library.add", json!({"path": pb}));
|
||||
let dot = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})));
|
||||
ex(&mut s, "library.bindVariable", json!({"library": a, "variable": "accent", "field": "fill", "ids": [dot]}));
|
||||
ex(&mut s, "library.setVariableMode", json!({"library": a, "collection": "Theme", "mode": "Dark", "ids": [dot]}));
|
||||
let doc = s.doc().unwrap();
|
||||
let target = doc.libraries.variables[0].local.clone();
|
||||
let collection = doc.libraries.collections[0].local.clone();
|
||||
ex(&mut s, "variable.update", json!({"variable": shadow["id"], "name": target}));
|
||||
ex(&mut s, "variable.renameCollection", json!({"collection": shadow_collection["id"], "name": collection}));
|
||||
let result = ex(&mut s, "library.swap", json!({"from": a, "to": b}));
|
||||
assert_eq!(result["variables"], 1);
|
||||
assert_eq!(result["unmatched"], json!([]));
|
||||
let doc = s.doc().unwrap();
|
||||
let node = doc.node(dot).unwrap();
|
||||
assert_eq!(node.fills[0].solid_color().unwrap().to_hex(), "00FF00");
|
||||
assert!(!node.bound.is_empty());
|
||||
assert!(!node.var_modes.is_empty());
|
||||
assert!(node.bound.values().all(|v| doc.libraries.variable_import(v).is_some_and(|r| r.library == b)));
|
||||
assert!(node.var_modes.keys().all(|c| doc.libraries.collection_import(c).is_some_and(|r| r.library == b)));
|
||||
}
|
||||
|
||||
/// A local colour calculation reading a library variable moves to the other library's variable of
|
||||
/// the same name, as a binding does.
|
||||
#[test]
|
||||
fn swap_repoints_colour_calculations() {
|
||||
let (_dir, mut s, a, b) = two_libraries("calc", &[]);
|
||||
let accent = ex(&mut s, "library.importVariable", json!({"library": a, "variable": "accent"}))["variable"].clone();
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "App"}));
|
||||
ex(
|
||||
&mut s,
|
||||
"variable.create",
|
||||
json!({"collection": "App", "name": "veil", "type": "color", "value": {"alpha": {"variable": accent, "opacity": 0.5}}}),
|
||||
);
|
||||
let wash = json!({"mix": {"from": {"variable": accent}, "to": {"literal": "#ffffff"}, "t": 0.5}});
|
||||
ex(&mut s, "variable.create", json!({"collection": "App", "name": "wash", "type": "color", "value": wash}));
|
||||
|
||||
let r = ex(&mut s, "library.swap", json!({"from": a, "to": b}));
|
||||
assert_eq!(r["variables"].as_u64(), Some(1), "{r}");
|
||||
let doc = s.doc().unwrap();
|
||||
let vars = &doc.variables;
|
||||
let shown = |name: &str| match vars.resolve(&vars.get(name).unwrap().id, &Default::default()) {
|
||||
Some(VariableValue::Color(c)) => c.to_css_hex(),
|
||||
other => panic!("{name}: {other:?}"),
|
||||
};
|
||||
assert_eq!((shown("veil"), shown("wash")), ("#00FF0080".to_string(), "#80FF80".to_string()));
|
||||
let reads: Vec<&VariableId> = ["veil", "wash"].iter().flat_map(|n| vars.get(n).unwrap().values.values()).flat_map(|v| v.references()).collect();
|
||||
assert!(reads.iter().all(|r| doc.libraries.variable_import(r).is_some_and(|i| i.library == b)), "{reads:?}");
|
||||
}
|
||||
@@ -236,21 +236,42 @@ fn io(path: &Path) -> impl Fn(std::io::Error) -> EngineError + '_ {
|
||||
move |e| EngineError::Other(format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// The bytes of file `path`.
|
||||
pub fn read(path: impl AsRef<Path>) -> Result<Vec<u8>> {
|
||||
let path = path.as_ref();
|
||||
std::fs::read(allow(path, Access::Read)?).map_err(io(path))
|
||||
/// `bytes` for a person: "300 bytes", "12 MB", "6.5 GB".
|
||||
fn size(bytes: u64) -> String {
|
||||
match bytes {
|
||||
b if b < 1 << 20 => format!("{b} bytes"),
|
||||
b if b < 1 << 30 => format!("{} MB", b.div_ceil(1 << 20)),
|
||||
b => format!("{:.1} GB", b as f64 / f64::from(1u32 << 30)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The bytes of file `path`, refused when it isn't a file or is larger than `max`.
|
||||
/// The bytes of file `path`, at most `max` of them. A folder, a device, a pipe or a file larger
|
||||
/// than `max` is refused before anything is read, so a path a caller gives can't fill memory or
|
||||
/// wait forever (#1114). Every whole-file read of a path goes through here.
|
||||
pub fn read_capped(path: impl AsRef<Path>, max: u64) -> Result<Vec<u8>> {
|
||||
use std::io::Read as _;
|
||||
let path = path.as_ref();
|
||||
let p = allow(path, Access::Read)?;
|
||||
let refuse = |why: String| EngineError::Other(format!("{}: {why}", path.display()));
|
||||
// Looked at before opening: opening a pipe waits for whoever writes it.
|
||||
let meta = std::fs::metadata(&p).map_err(io(path))?;
|
||||
if !meta.is_file() || meta.len() > max {
|
||||
return Err(EngineError::Other(format!("{}: not a file of at most {max} bytes", path.display())));
|
||||
if meta.is_dir() {
|
||||
return Err(refuse("is a folder, not a file".into()));
|
||||
}
|
||||
std::fs::read(p).map_err(io(path))
|
||||
if !meta.is_file() {
|
||||
return Err(refuse("isn't a regular file (a device or a pipe?), so it isn't read".into()));
|
||||
}
|
||||
if meta.len() > max {
|
||||
return Err(refuse(format!("is {}, more than the {} allowed here", size(meta.len()), size(max))));
|
||||
}
|
||||
let f = std::fs::File::open(p).map_err(io(path))?;
|
||||
let mut bytes = Vec::with_capacity(usize::try_from(meta.len()).unwrap_or(0));
|
||||
f.take(max.saturating_add(1)).read_to_end(&mut bytes).map_err(io(path))?;
|
||||
// It grew after it was looked at.
|
||||
if bytes.len() as u64 > max {
|
||||
return Err(refuse(format!("grew past the {} allowed here while it was read", size(max))));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// File `path` opened for reading.
|
||||
|
||||
@@ -198,10 +198,10 @@ fn a_scope_confines_the_thread_and_ends_with_it() {
|
||||
assert!(current().is_none());
|
||||
confined(Some(&roots), || {
|
||||
assert!(allow(f.p("outside/secret.txt"), Access::Read).is_err());
|
||||
assert!(super::read(f.p("outside/secret.txt")).is_err());
|
||||
assert_eq!(super::read(f.p("root/in.txt")).unwrap(), b"in");
|
||||
assert!(super::read_capped(f.p("outside/secret.txt"), 64).is_err());
|
||||
assert_eq!(super::read_capped(f.p("root/in.txt"), 64).unwrap(), b"in");
|
||||
// Not confined: the app's own files.
|
||||
assert_eq!(super::app_owned(|| super::read(f.p("outside/secret.txt")).unwrap()), b"secret");
|
||||
assert_eq!(super::app_owned(|| super::read_capped(f.p("outside/secret.txt"), 64).unwrap()), b"secret");
|
||||
assert!(current().is_some(), "back in the scope after app_owned");
|
||||
// Another thread isn't confined by this one's scope.
|
||||
let p = f.p("outside/secret.txt");
|
||||
@@ -386,3 +386,8 @@ fn only_resolved_paths_count_as_the_apps_own_folder() {
|
||||
assert!(!super::within_app_folder(&install.join("missing.wasm"), &install));
|
||||
assert!(!super::within_app_folder(&install.join("in.txt"), &f.0.join("no-such-folder")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sizes_read_as_a_person_says_them() {
|
||||
assert_eq!((super::size(300), super::size((12 << 20) - 5), super::size(13 << 29)), ("300 bytes".into(), "12 MB".into(), "6.5 GB".into()));
|
||||
}
|
||||
@@ -5,32 +5,188 @@ use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Top-level node keys accepted by `fields`, including the structure kept in every tree.
|
||||
pub const NODE_KEYS: &[&str] = &[
|
||||
"absolute",
|
||||
"absoluteBounds",
|
||||
"annotations",
|
||||
"arc",
|
||||
"background",
|
||||
"blend",
|
||||
"board",
|
||||
"booleanOperation",
|
||||
"boundVariables",
|
||||
"brush",
|
||||
"characters",
|
||||
"childCount",
|
||||
"children",
|
||||
"clip",
|
||||
"codeLinks",
|
||||
"componentProperties",
|
||||
"componentPropertyDefinitions",
|
||||
"constraints",
|
||||
"cornerRadius",
|
||||
"cornerSmoothing",
|
||||
"dashes",
|
||||
"description",
|
||||
"devChanged",
|
||||
"devNote",
|
||||
"devResources",
|
||||
"devStatus",
|
||||
"effects",
|
||||
"exports",
|
||||
"fills",
|
||||
"flows",
|
||||
"fontFamily",
|
||||
"fontSize",
|
||||
"fontStyle",
|
||||
"fontVariations",
|
||||
"gridCell",
|
||||
"grids",
|
||||
"hangingList",
|
||||
"hangingPunctuation",
|
||||
"height",
|
||||
"id",
|
||||
"innerRadius",
|
||||
"interactions",
|
||||
"isMask",
|
||||
"layout",
|
||||
"leadingTrim",
|
||||
"letterSpacing",
|
||||
"lineHeight",
|
||||
"lineIndentations",
|
||||
"lineTypes",
|
||||
"listSpacing",
|
||||
"listStartOffsets",
|
||||
"listStyle",
|
||||
"locked",
|
||||
"mainComponent",
|
||||
"mainComponentName",
|
||||
"maskType",
|
||||
"maxHeight",
|
||||
"maxLines",
|
||||
"maxWidth",
|
||||
"measurements",
|
||||
"minHeight",
|
||||
"minWidth",
|
||||
"miterAngle",
|
||||
"name",
|
||||
"opacity",
|
||||
"operandOperation",
|
||||
"overrides",
|
||||
"pluginData",
|
||||
"pointCount",
|
||||
"rotation",
|
||||
"sectionContentsHidden",
|
||||
"segments",
|
||||
"sizing",
|
||||
"slot",
|
||||
"strokeAlign",
|
||||
"strokeJoin",
|
||||
"strokeSides",
|
||||
"strokeWeight",
|
||||
"strokes",
|
||||
"styledRuns",
|
||||
"styles",
|
||||
"textAlign",
|
||||
"textAutoResize",
|
||||
"textDecoration",
|
||||
"textDecorationColor",
|
||||
"textDecorationOffset",
|
||||
"textDecorationSkipInk",
|
||||
"textDecorationStyle",
|
||||
"textDecorationThickness",
|
||||
"textPath",
|
||||
"textStyleRanges",
|
||||
"type",
|
||||
"vertices",
|
||||
"visible",
|
||||
"width",
|
||||
"widthProfile",
|
||||
"x",
|
||||
"y",
|
||||
];
|
||||
|
||||
/// Which layer properties to build. Missing options keep the full, sparse inspection output.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct InspectOptions {
|
||||
fields: Option<std::collections::BTreeSet<&'static str>>,
|
||||
outline: bool,
|
||||
}
|
||||
|
||||
impl InspectOptions {
|
||||
/// Parse the same options for engine commands, control methods and MCP tools.
|
||||
pub fn parse(p: &Value) -> Result<Self, String> {
|
||||
let outline = match p.get("outline") {
|
||||
None => false,
|
||||
Some(Value::Bool(v)) => *v,
|
||||
Some(_) => return Err("`outline` must be a boolean".into()),
|
||||
};
|
||||
if outline && p.get("fields").is_some() {
|
||||
return Err("`outline: true` cannot be combined with `fields`".into());
|
||||
}
|
||||
let fields = match p.get("fields") {
|
||||
None if outline => Some(["id", "name", "type", "characters", "mainComponentName", "visible"].into_iter().collect()),
|
||||
None => None,
|
||||
Some(Value::Array(values)) => {
|
||||
let mut fields = std::collections::BTreeSet::new();
|
||||
for value in values {
|
||||
let key = value.as_str().ok_or("`fields` must be an array of strings")?;
|
||||
let key = NODE_KEYS
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|k| *k == key)
|
||||
.ok_or_else(|| format!("unknown field `{key}`; valid keys: {}", NODE_KEYS.join(", ")))?;
|
||||
fields.insert(key);
|
||||
}
|
||||
Some(fields)
|
||||
}
|
||||
Some(_) => return Err("`fields` must be an array of strings".into()),
|
||||
};
|
||||
Ok(Self { fields, outline })
|
||||
}
|
||||
|
||||
fn includes(&self, key: &str) -> bool {
|
||||
self.fields.as_ref().is_none_or(|fields| fields.contains(key))
|
||||
}
|
||||
|
||||
fn explicit(&self, key: &str) -> bool {
|
||||
self.fields.as_ref().is_some_and(|fields| fields.contains(key))
|
||||
}
|
||||
}
|
||||
|
||||
/// A node and (to `depth`) its children, with the properties a designer would check.
|
||||
pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
|
||||
node_json_with_options(doc, id, depth, &InspectOptions::default())
|
||||
}
|
||||
|
||||
/// A node's selected properties, applied at every level of its subtree.
|
||||
pub fn node_json_with_options(doc: &Document, id: NodeId, depth: usize, options: &InspectOptions) -> Value {
|
||||
let Some(n) = doc.node(id) else { return Value::Null };
|
||||
let mut v = json!({
|
||||
"id": n.id,
|
||||
"name": n.name,
|
||||
"type": n.kind.type_name(),
|
||||
});
|
||||
let mut v = json!({"id": n.id});
|
||||
let Some(o) = v.as_object_mut() else { return v };
|
||||
insert_prop(o, options, "name", || json!(n.name));
|
||||
insert_prop(o, options, "type", || json!(n.kind.type_name()));
|
||||
if options.explicit("visible") {
|
||||
insert_prop(o, options, "visible", || json!(n.visible));
|
||||
}
|
||||
if !n.is_page() {
|
||||
base_props(doc, n, id, o);
|
||||
paint_props(n, o);
|
||||
layout_props(doc, n, o);
|
||||
dev_props(doc, n, o);
|
||||
base_props(doc, n, id, o, options);
|
||||
paint_props(n, o, options);
|
||||
layout_props(doc, n, o, options);
|
||||
dev_props(doc, n, o, options);
|
||||
}
|
||||
// Layer data written with `node.set {props: {pluginData}}` (pages too).
|
||||
if !n.plugin_data.is_empty() {
|
||||
o.insert("pluginData".into(), json!(n.plugin_data));
|
||||
insert_prop(o, options, "pluginData", || json!(n.plugin_data));
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Text(t) => text_props(t, o),
|
||||
_ => kind_props(doc, n, o),
|
||||
NodeKind::Text(t) => text_props(t, o, options),
|
||||
_ => kind_props(doc, n, o, options),
|
||||
}
|
||||
if !n.children.is_empty() {
|
||||
if depth > 0 {
|
||||
o.insert("children".into(), Value::Array(n.children.iter().map(|c| node_json(doc, *c, depth - 1)).collect()));
|
||||
o.insert("children".into(), Value::Array(n.children.iter().map(|c| node_json_with_options(doc, *c, depth - 1, options)).collect()));
|
||||
} else {
|
||||
o.insert("childCount".into(), json!(n.children.len()));
|
||||
}
|
||||
@@ -38,272 +194,301 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
|
||||
v
|
||||
}
|
||||
|
||||
fn base_props(doc: &Document, n: &Node, id: NodeId, o: &mut serde_json::Map<String, Value>) {
|
||||
let c = doc.frame_relative(id).as_coeffs();
|
||||
o.insert("x".into(), json!(round(c[4])));
|
||||
o.insert("y".into(), json!(round(c[5])));
|
||||
o.insert("width".into(), json!(round(n.width)));
|
||||
o.insert("height".into(), json!(round(n.height)));
|
||||
let rot = figcraft_geom::rotation_degrees(n.transform);
|
||||
if rot.abs() > 1e-6 {
|
||||
o.insert("rotation".into(), json!(round(rot)));
|
||||
}
|
||||
if let Some(b) = doc.world_bounds(id) {
|
||||
o.insert("absoluteBounds".into(), json!({"x": round(b.x0), "y": round(b.y0), "width": round(b.width()), "height": round(b.height())}));
|
||||
}
|
||||
if !n.visible {
|
||||
o.insert("visible".into(), json!(false));
|
||||
}
|
||||
if n.locked {
|
||||
o.insert("locked".into(), json!(true));
|
||||
}
|
||||
if n.opacity < 1.0 {
|
||||
o.insert("opacity".into(), json!(n.opacity));
|
||||
}
|
||||
if !matches!(n.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) {
|
||||
o.insert("blend".into(), json!(n.blend));
|
||||
/// Build a property only when it was requested (or no field list was given).
|
||||
fn insert_prop(o: &mut serde_json::Map<String, Value>, options: &InspectOptions, key: &str, value: impl FnOnce() -> Value) {
|
||||
if options.includes(key) {
|
||||
o.insert(key.into(), value());
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_props(n: &Node, o: &mut serde_json::Map<String, Value>) {
|
||||
fn base_props(doc: &Document, n: &Node, id: NodeId, o: &mut serde_json::Map<String, Value>, options: &InspectOptions) {
|
||||
if options.includes("x") || options.includes("y") {
|
||||
let c = doc.frame_relative(id).as_coeffs();
|
||||
insert_prop(o, options, "x", || json!(round(c[4])));
|
||||
insert_prop(o, options, "y", || json!(round(c[5])));
|
||||
}
|
||||
insert_prop(o, options, "width", || json!(round(n.width)));
|
||||
insert_prop(o, options, "height", || json!(round(n.height)));
|
||||
if options.includes("rotation") {
|
||||
let rot = figcraft_geom::rotation_degrees(n.transform);
|
||||
if rot.abs() > 1e-6 {
|
||||
insert_prop(o, options, "rotation", || json!(round(rot)));
|
||||
}
|
||||
}
|
||||
if let Some(b) = options.includes("absoluteBounds").then(|| doc.world_bounds(id)).flatten() {
|
||||
insert_prop(
|
||||
o,
|
||||
options,
|
||||
"absoluteBounds",
|
||||
|| json!({"x": round(b.x0), "y": round(b.y0), "width": round(b.width()), "height": round(b.height())}),
|
||||
);
|
||||
}
|
||||
if !n.visible {
|
||||
insert_prop(o, options, "visible", || json!(false));
|
||||
}
|
||||
if n.locked {
|
||||
insert_prop(o, options, "locked", || json!(true));
|
||||
}
|
||||
if n.opacity < 1.0 {
|
||||
insert_prop(o, options, "opacity", || json!(n.opacity));
|
||||
}
|
||||
if !matches!(n.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) {
|
||||
insert_prop(o, options, "blend", || json!(n.blend));
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_props(n: &Node, o: &mut serde_json::Map<String, Value>, options: &InspectOptions) {
|
||||
if !n.fills.is_empty() {
|
||||
o.insert("fills".into(), paints(&n.fills));
|
||||
insert_prop(o, options, "fills", || paints(&n.fills));
|
||||
}
|
||||
if !n.strokes.is_empty() {
|
||||
o.insert("strokes".into(), paints(&n.strokes));
|
||||
o.insert("strokeWeight".into(), json!(n.stroke.weight));
|
||||
o.insert("strokeAlign".into(), json!(n.stroke.align));
|
||||
insert_prop(o, options, "strokes", || paints(&n.strokes));
|
||||
insert_prop(o, options, "strokeWeight", || json!(n.stroke.weight));
|
||||
insert_prop(o, options, "strokeAlign", || json!(n.stroke.align));
|
||||
if n.stroke.sides.is_some() {
|
||||
o.insert("strokeSides".into(), json!(n.stroke.sides));
|
||||
insert_prop(o, options, "strokeSides", || json!(n.stroke.sides));
|
||||
}
|
||||
if !n.stroke.dashes.is_empty() {
|
||||
o.insert("dashes".into(), json!(n.stroke.dashes));
|
||||
insert_prop(o, options, "dashes", || json!(n.stroke.dashes));
|
||||
}
|
||||
if n.stroke.join != StrokeJoin::Miter {
|
||||
o.insert("strokeJoin".into(), json!(n.stroke.join));
|
||||
insert_prop(o, options, "strokeJoin", || json!(n.stroke.join));
|
||||
} else if n.stroke.miter_limit != 4.0 {
|
||||
o.insert("miterAngle".into(), json!((n.stroke.miter_angle() * 100.0).round() / 100.0));
|
||||
insert_prop(o, options, "miterAngle", || json!((n.stroke.miter_angle() * 100.0).round() / 100.0));
|
||||
}
|
||||
if let Some(p) = n.stroke.profile() {
|
||||
let points = crate::cmd::props::width_points_json(Some(p));
|
||||
o.insert("widthProfile".into(), json!({"preset": p.preset().map(|x| x.id()), "points": points}));
|
||||
insert_prop(o, options, "widthProfile", || {
|
||||
let points = crate::cmd::props::width_points_json(Some(p));
|
||||
json!({"preset": p.preset().map(|x| x.id()), "points": points})
|
||||
});
|
||||
}
|
||||
if let Some(b) = &n.stroke.brush {
|
||||
o.insert("brush".into(), camel(serde_json::to_value(b).unwrap_or(Value::Null)));
|
||||
insert_prop(o, options, "brush", || camel(serde_json::to_value(b).unwrap_or(Value::Null)));
|
||||
}
|
||||
}
|
||||
if !n.effects.is_empty() {
|
||||
o.insert("effects".into(), camel(serde_json::to_value(&n.effects).unwrap_or(Value::Null)));
|
||||
insert_prop(o, options, "effects", || camel(serde_json::to_value(&n.effects).unwrap_or(Value::Null)));
|
||||
}
|
||||
if n.corners.radii.iter().any(|r| *r > 0.0) {
|
||||
o.insert(
|
||||
"cornerRadius".into(),
|
||||
match n.corners.uniform() {
|
||||
Some(r) => json!(r),
|
||||
None => json!(n.corners.radii),
|
||||
},
|
||||
);
|
||||
insert_prop(o, options, "cornerRadius", || match n.corners.uniform() {
|
||||
Some(r) => json!(r),
|
||||
None => json!(n.corners.radii),
|
||||
});
|
||||
if n.corners.smoothing > 0.0 {
|
||||
o.insert("cornerSmoothing".into(), json!(n.corners.smoothing));
|
||||
insert_prop(o, options, "cornerSmoothing", || json!(n.corners.smoothing));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn layout_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>) {
|
||||
fn layout_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>, options: &InspectOptions) {
|
||||
if let Some(l) = &n.layout {
|
||||
o.insert("layout".into(), camel(serde_json::to_value(l).unwrap_or(Value::Null)));
|
||||
insert_prop(o, options, "layout", || camel(serde_json::to_value(l).unwrap_or(Value::Null)));
|
||||
}
|
||||
let parent_al = n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some());
|
||||
if n.layout.is_some() || parent_al || matches!(n.kind, NodeKind::Text(_)) {
|
||||
o.insert("sizing".into(), json!({"horizontal": n.child_layout.sizing_h, "vertical": n.child_layout.sizing_v}));
|
||||
insert_prop(o, options, "sizing", || json!({"horizontal": n.child_layout.sizing_h, "vertical": n.child_layout.sizing_v}));
|
||||
}
|
||||
let cl = &n.child_layout;
|
||||
if cl.absolute {
|
||||
o.insert("absolute".into(), json!(true));
|
||||
insert_prop(o, options, "absolute", || json!(true));
|
||||
}
|
||||
for (k, v) in [("minWidth", cl.min_w), ("maxWidth", cl.max_w), ("minHeight", cl.min_h), ("maxHeight", cl.max_h)] {
|
||||
if let Some(v) = v {
|
||||
o.insert(k.into(), json!(v));
|
||||
insert_prop(o, options, k, || json!(v));
|
||||
}
|
||||
}
|
||||
if let Some(cell) = parent_al
|
||||
if let Some(cell) = (parent_al && options.includes("gridCell"))
|
||||
.then(|| n.parent.and_then(|p| figcraft_layout::grid_geometry(doc, p)))
|
||||
.flatten()
|
||||
.and_then(|g| g.cells.into_iter().find(|c| c.id == n.id))
|
||||
{
|
||||
o.insert(
|
||||
"gridCell".into(),
|
||||
insert_prop(o, options, "gridCell", || {
|
||||
json!({"column": cell.column, "row": cell.row, "columnSpan": cell.column_span, "rowSpan": cell.row_span,
|
||||
"anchored": cl.grid_column.is_some() || cl.grid_row.is_some(), "alignH": cl.grid_align_h, "alignV": cl.grid_align_v}),
|
||||
);
|
||||
"anchored": cl.grid_column.is_some() || cl.grid_row.is_some(), "alignH": cl.grid_align_h, "alignV": cl.grid_align_v})
|
||||
});
|
||||
}
|
||||
if (!parent_al || cl.absolute) && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.kind.is_frame_like()) {
|
||||
o.insert("constraints".into(), serde_json::to_value(n.constraints).unwrap_or(Value::Null));
|
||||
insert_prop(o, options, "constraints", || serde_json::to_value(n.constraints).unwrap_or(Value::Null));
|
||||
}
|
||||
if n.kind.is_frame_like() {
|
||||
o.insert("clip".into(), json!(n.clip));
|
||||
insert_prop(o, options, "clip", || json!(n.clip));
|
||||
}
|
||||
}
|
||||
|
||||
fn dev_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>) {
|
||||
fn dev_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>, options: &InspectOptions) {
|
||||
if !n.dev_status.is_none() {
|
||||
o.insert("devStatus".into(), json!(n.dev_status.id()));
|
||||
insert_prop(o, options, "devStatus", || json!(n.dev_status.id()));
|
||||
}
|
||||
if n.dev_changed {
|
||||
o.insert("devChanged".into(), json!(true));
|
||||
insert_prop(o, options, "devChanged", || json!(true));
|
||||
}
|
||||
if !n.dev_note.is_empty() {
|
||||
o.insert("devNote".into(), json!(n.dev_note));
|
||||
insert_prop(o, options, "devNote", || json!(n.dev_note));
|
||||
}
|
||||
if let NodeKind::Section { contents_hidden } = n.kind {
|
||||
o.insert("sectionContentsHidden".into(), json!(contents_hidden));
|
||||
insert_prop(o, options, "sectionContentsHidden", || json!(contents_hidden));
|
||||
}
|
||||
if n.is_mask {
|
||||
o.insert("isMask".into(), json!(true));
|
||||
o.insert("maskType".into(), json!(n.mask_type.id()));
|
||||
insert_prop(o, options, "isMask", || json!(true));
|
||||
insert_prop(o, options, "maskType", || json!(n.mask_type.id()));
|
||||
}
|
||||
if !n.grids.is_empty() {
|
||||
o.insert("grids".into(), camel(serde_json::to_value(&n.grids).unwrap_or(Value::Null)));
|
||||
insert_prop(o, options, "grids", || camel(serde_json::to_value(&n.grids).unwrap_or(Value::Null)));
|
||||
}
|
||||
if !n.exports.is_empty() {
|
||||
o.insert("exports".into(), serde_json::to_value(&n.exports).unwrap_or(Value::Null));
|
||||
insert_prop(o, options, "exports", || serde_json::to_value(&n.exports).unwrap_or(Value::Null));
|
||||
}
|
||||
if let Some(b) = &n.board {
|
||||
o.insert("board".into(), serde_json::to_value(b).unwrap_or(Value::Null));
|
||||
insert_prop(o, options, "board", || serde_json::to_value(b).unwrap_or(Value::Null));
|
||||
}
|
||||
if !n.annotations.is_empty() {
|
||||
o.insert("annotations".into(), serde_json::to_value(&n.annotations).unwrap_or(Value::Null));
|
||||
insert_prop(o, options, "annotations", || serde_json::to_value(&n.annotations).unwrap_or(Value::Null));
|
||||
}
|
||||
if !n.code_links.is_empty() {
|
||||
o.insert("codeLinks".into(), camel(serde_json::to_value(&n.code_links).unwrap_or(Value::Null)));
|
||||
insert_prop(o, options, "codeLinks", || camel(serde_json::to_value(&n.code_links).unwrap_or(Value::Null)));
|
||||
}
|
||||
if !n.measurements.is_empty() {
|
||||
o.insert("measurements".into(), serde_json::to_value(&n.measurements).unwrap_or(Value::Null));
|
||||
insert_prop(o, options, "measurements", || serde_json::to_value(&n.measurements).unwrap_or(Value::Null));
|
||||
}
|
||||
let dev = crate::dev_resources::list_json(doc, n.id);
|
||||
if dev.as_array().is_some_and(|a| !a.is_empty()) {
|
||||
o.insert("devResources".into(), dev);
|
||||
if options.includes("devResources") {
|
||||
let dev = crate::dev_resources::list_json(doc, n.id);
|
||||
if dev.as_array().is_some_and(|a| !a.is_empty()) {
|
||||
insert_prop(o, options, "devResources", || dev);
|
||||
}
|
||||
}
|
||||
if !n.interactions.is_empty() {
|
||||
o.insert("interactions".into(), serde_json::to_value(&n.interactions).unwrap_or(Value::Null));
|
||||
insert_prop(o, options, "interactions", || serde_json::to_value(&n.interactions).unwrap_or(Value::Null));
|
||||
}
|
||||
if !n.styles.is_empty() {
|
||||
o.insert("styles".into(), serde_json::to_value(&n.styles).unwrap_or(Value::Null));
|
||||
insert_prop(o, options, "styles", || serde_json::to_value(&n.styles).unwrap_or(Value::Null));
|
||||
}
|
||||
if !n.bound.is_empty() {
|
||||
o.insert("boundVariables".into(), json!(n.bound));
|
||||
insert_prop(o, options, "boundVariables", || json!(n.bound));
|
||||
}
|
||||
if !n.overrides.is_empty() {
|
||||
o.insert("overrides".into(), json!(n.overrides));
|
||||
insert_prop(o, options, "overrides", || json!(n.overrides));
|
||||
}
|
||||
if let Some(slot) = crate::components::slot_name(doc, n.id) {
|
||||
o.insert("slot".into(), json!(slot));
|
||||
if let Some(slot) = options.includes("slot").then(|| crate::components::slot_name(doc, n.id)).flatten() {
|
||||
insert_prop(o, options, "slot", || json!(slot));
|
||||
}
|
||||
// An operand combining by an operation of its own.
|
||||
if let Some(op) = n.bool_op.filter(|_| crate::booleans::group_of(doc, n.id).is_some()) {
|
||||
o.insert("operandOperation".into(), json!(op));
|
||||
if let Some(op) = n.bool_op.filter(|_| options.includes("operandOperation") && crate::booleans::group_of(doc, n.id).is_some()) {
|
||||
insert_prop(o, options, "operandOperation", || json!(op));
|
||||
}
|
||||
}
|
||||
|
||||
fn text_props(t: &TextData, o: &mut serde_json::Map<String, Value>) {
|
||||
o.insert("characters".into(), json!(t.characters));
|
||||
o.insert("fontFamily".into(), json!(t.style.font_family));
|
||||
o.insert("fontStyle".into(), json!(t.style.font_style));
|
||||
o.insert("fontSize".into(), json!(t.style.font_size));
|
||||
fn text_props(t: &TextData, o: &mut serde_json::Map<String, Value>, options: &InspectOptions) {
|
||||
insert_prop(o, options, "characters", || {
|
||||
if options.outline {
|
||||
let mut chars = t.characters.chars();
|
||||
let mut text: String = chars.by_ref().take(120).collect();
|
||||
if chars.next().is_some() {
|
||||
text.push_str("...");
|
||||
}
|
||||
json!(text)
|
||||
} else {
|
||||
json!(t.characters)
|
||||
}
|
||||
});
|
||||
insert_prop(o, options, "fontFamily", || json!(t.style.font_family));
|
||||
insert_prop(o, options, "fontStyle", || json!(t.style.font_style));
|
||||
insert_prop(o, options, "fontSize", || json!(t.style.font_size));
|
||||
if !t.style.variations.is_empty() {
|
||||
o.insert("fontVariations".into(), json!(t.style.variations));
|
||||
insert_prop(o, options, "fontVariations", || json!(t.style.variations));
|
||||
}
|
||||
o.insert("lineHeight".into(), json!(t.style.line_height.label()));
|
||||
o.insert("letterSpacing".into(), json!(t.style.letter_spacing.label()));
|
||||
o.insert("textAlign".into(), json!(t.align_h));
|
||||
o.insert("textAutoResize".into(), json!(t.auto_resize));
|
||||
insert_prop(o, options, "lineHeight", || json!(t.style.line_height.label()));
|
||||
insert_prop(o, options, "letterSpacing", || json!(t.style.letter_spacing.label()));
|
||||
insert_prop(o, options, "textAlign", || json!(t.align_h));
|
||||
insert_prop(o, options, "textAutoResize", || json!(t.auto_resize));
|
||||
if t.max_lines > 0 {
|
||||
o.insert("maxLines".into(), json!(t.max_lines));
|
||||
insert_prop(o, options, "maxLines", || json!(t.max_lines));
|
||||
}
|
||||
if let Some(tp) = &t.path {
|
||||
o.insert("textPath".into(), json!(tp));
|
||||
insert_prop(o, options, "textPath", || json!(tp));
|
||||
}
|
||||
if t.style.decoration != TextDecoration::None {
|
||||
o.insert("textDecoration".into(), json!(t.style.decoration));
|
||||
insert_prop(o, options, "textDecoration", || json!(t.style.decoration));
|
||||
}
|
||||
if let Some(l) = t.style.decoration_look.as_deref() {
|
||||
o.insert("textDecorationStyle".into(), json!(l.style));
|
||||
o.insert("textDecorationThickness".into(), json!(l.thickness.label()));
|
||||
o.insert("textDecorationOffset".into(), json!(l.offset.label()));
|
||||
o.insert("textDecorationSkipInk".into(), json!(l.skip_ink));
|
||||
o.insert("textDecorationColor".into(), l.color.map_or(json!("auto"), |c| json!(format!("#{}", c.to_hex()))));
|
||||
insert_prop(o, options, "textDecorationStyle", || json!(l.style));
|
||||
insert_prop(o, options, "textDecorationThickness", || json!(l.thickness.label()));
|
||||
insert_prop(o, options, "textDecorationOffset", || json!(l.offset.label()));
|
||||
insert_prop(o, options, "textDecorationSkipInk", || json!(l.skip_ink));
|
||||
insert_prop(o, options, "textDecorationColor", || l.color.map_or(json!("auto"), |c| json!(format!("#{}", c.to_hex()))));
|
||||
}
|
||||
// Lists: one style for the layer, or each paragraph's type, nesting level and start.
|
||||
if t.paragraphs.is_empty() {
|
||||
if t.list != figcraft_doc::ListStyle::None {
|
||||
o.insert("listStyle".into(), json!(t.list));
|
||||
insert_prop(o, options, "listStyle", || json!(t.list));
|
||||
}
|
||||
} else {
|
||||
o.insert("lineTypes".into(), t.paragraphs.iter().map(|p| json!(p.list)).collect());
|
||||
o.insert("lineIndentations".into(), t.paragraphs.iter().map(|p| json!(p.indent)).collect());
|
||||
insert_prop(o, options, "lineTypes", || t.paragraphs.iter().map(|p| json!(p.list)).collect());
|
||||
insert_prop(o, options, "lineIndentations", || t.paragraphs.iter().map(|p| json!(p.indent)).collect());
|
||||
if t.paragraphs.iter().any(|p| p.start_offset > 0) {
|
||||
o.insert("listStartOffsets".into(), t.paragraphs.iter().map(|p| json!(p.start_offset)).collect());
|
||||
insert_prop(o, options, "listStartOffsets", || t.paragraphs.iter().map(|p| json!(p.start_offset)).collect());
|
||||
}
|
||||
}
|
||||
if t.list_spacing != 0.0 {
|
||||
o.insert("listSpacing".into(), json!(t.list_spacing));
|
||||
insert_prop(o, options, "listSpacing", || json!(t.list_spacing));
|
||||
}
|
||||
for (k, on) in [("hangingPunctuation", t.hanging_punctuation), ("hangingList", t.hanging_list)] {
|
||||
if on {
|
||||
o.insert(k.into(), json!(true));
|
||||
insert_prop(o, options, k, || json!(true));
|
||||
}
|
||||
}
|
||||
if t.leading_trim != figcraft_doc::LeadingTrim::None {
|
||||
o.insert("leadingTrim".into(), json!(t.leading_trim));
|
||||
insert_prop(o, options, "leadingTrim", || json!(t.leading_trim));
|
||||
}
|
||||
if !t.runs.is_empty() {
|
||||
o.insert("styledRuns".into(), serde_json::to_value(&t.runs).unwrap_or(Value::Null));
|
||||
insert_prop(o, options, "styledRuns", || serde_json::to_value(&t.runs).unwrap_or(Value::Null));
|
||||
}
|
||||
// Chars linked to text styles: [start, end, style id] (the layer as a whole: `styles.text`).
|
||||
let linked: Vec<Value> = t.spans().into_iter().filter_map(|(a, b, s)| s.style_id.as_ref().map(|id| json!([a, b, id]))).collect();
|
||||
if !linked.is_empty() {
|
||||
o.insert("textStyleRanges".into(), Value::Array(linked));
|
||||
if options.includes("textStyleRanges") {
|
||||
let linked: Vec<Value> = t.spans().into_iter().filter_map(|(a, b, s)| s.style_id.as_ref().map(|id| json!([a, b, id]))).collect();
|
||||
if !linked.is_empty() {
|
||||
insert_prop(o, options, "textStyleRanges", || Value::Array(linked));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>) {
|
||||
fn kind_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>, options: &InspectOptions) {
|
||||
match &n.kind {
|
||||
NodeKind::Ellipse { start, sweep, inner } if (*sweep).abs() < std::f64::consts::TAU - 1e-9 || *inner > 0.0 => {
|
||||
o.insert("arc".into(), json!({"start": start.to_degrees(), "sweep": sweep.to_degrees(), "inner": inner}));
|
||||
insert_prop(o, options, "arc", || json!({"start": start.to_degrees(), "sweep": sweep.to_degrees(), "inner": inner}));
|
||||
}
|
||||
NodeKind::Polygon { count } => {
|
||||
o.insert("pointCount".into(), json!(count));
|
||||
insert_prop(o, options, "pointCount", || json!(count));
|
||||
}
|
||||
NodeKind::Star { count, inner_ratio } => {
|
||||
o.insert("pointCount".into(), json!(count));
|
||||
o.insert("innerRadius".into(), json!(inner_ratio));
|
||||
insert_prop(o, options, "pointCount", || json!(count));
|
||||
insert_prop(o, options, "innerRadius", || json!(inner_ratio));
|
||||
}
|
||||
NodeKind::BooleanOp { op } => {
|
||||
o.insert("booleanOperation".into(), json!(op));
|
||||
insert_prop(o, options, "booleanOperation", || json!(op));
|
||||
}
|
||||
NodeKind::Instance { component, props } => {
|
||||
o.insert("mainComponent".into(), json!(component));
|
||||
o.insert("mainComponentName".into(), json!(doc.node(*component).map(|c| c.name.clone())));
|
||||
insert_prop(o, options, "mainComponent", || json!(component));
|
||||
insert_prop(o, options, "mainComponentName", || json!(doc.node(*component).map(|c| c.name.clone())));
|
||||
if !props.is_empty() {
|
||||
o.insert("componentProperties".into(), json!(props));
|
||||
insert_prop(o, options, "componentProperties", || json!(props));
|
||||
}
|
||||
}
|
||||
NodeKind::Component { description, props, .. } => {
|
||||
if !description.is_empty() {
|
||||
o.insert("description".into(), json!(description));
|
||||
insert_prop(o, options, "description", || json!(description));
|
||||
}
|
||||
if !props.is_empty() {
|
||||
o.insert("componentPropertyDefinitions".into(), serde_json::to_value(props).unwrap_or(Value::Null));
|
||||
insert_prop(o, options, "componentPropertyDefinitions", || serde_json::to_value(props).unwrap_or(Value::Null));
|
||||
}
|
||||
}
|
||||
NodeKind::Vector { network } => {
|
||||
o.insert("vertices".into(), json!(network.vertices.len()));
|
||||
o.insert("segments".into(), json!(network.segments.len()));
|
||||
insert_prop(o, options, "vertices", || json!(network.vertices.len()));
|
||||
insert_prop(o, options, "segments", || json!(network.segments.len()));
|
||||
}
|
||||
NodeKind::Page { background, flows, .. } => {
|
||||
o.insert("background".into(), json!(background.to_css_hex()));
|
||||
insert_prop(o, options, "background", || json!(background.to_css_hex()));
|
||||
if !flows.is_empty() {
|
||||
o.insert("flows".into(), serde_json::to_value(flows).unwrap_or(Value::Null));
|
||||
insert_prop(o, options, "flows", || serde_json::to_value(flows).unwrap_or(Value::Null));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -339,10 +524,20 @@ fn paints(p: &[figcraft_color::Paint]) -> Value {
|
||||
|
||||
/// The whole document (current page in depth, other pages summarised).
|
||||
pub fn document(s: &Session, depth: usize, all_pages: bool) -> Value {
|
||||
document_with_options(s, depth, all_pages, &InspectOptions::default())
|
||||
}
|
||||
|
||||
/// The document metadata and a tree built with only the selected layer properties.
|
||||
pub fn document_with_options(s: &Session, depth: usize, all_pages: bool, options: &InspectOptions) -> Value {
|
||||
let Ok(ds) = s.ds() else { return json!({"documents": 0}) };
|
||||
let doc = &ds.doc;
|
||||
let pages: Vec<Value> =
|
||||
doc.pages.iter().map(|p| if all_pages || *p == ds.page { node_json(doc, *p, depth) } else { node_json(doc, *p, 0) }).collect();
|
||||
doc.pages
|
||||
.iter()
|
||||
.map(|p| {
|
||||
if all_pages || *p == ds.page { node_json_with_options(doc, *p, depth, options) } else { node_json_with_options(doc, *p, 0, options) }
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
"id": ds.uid,
|
||||
"name": doc.name,
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
//! `board.addConnected` (#1597): the next sticky or shape on a side of the selected one, of the
|
||||
//! same kind, size and colour, joined to it by a connector, in one undo step.
|
||||
|
||||
use figcraft_doc::{BoardItem, BoardShape, Connector, End, NodeId, Side};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::board::CONNECTED_GAP;
|
||||
use crate::board_tests::{board, id, node};
|
||||
|
||||
fn connector(s: &Session, c: NodeId) -> Connector {
|
||||
match &node(s, c).board {
|
||||
Some(BoardItem::Connector(k)) => *k,
|
||||
other => panic!("not a connector: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn at(s: &Session, n: NodeId) -> (f64, f64) {
|
||||
let t = node(s, n).transform.translation();
|
||||
(t.x, t.y)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shape_gets_the_next_one_joined_on_each_side() {
|
||||
let mut s = board();
|
||||
let a = id(&s.execute("board.shape", &json!({"shape": "diamond", "x": 0, "y": 0, "width": 200, "height": 120, "color": "blue"})).unwrap(), "id");
|
||||
let colour = |s: &Session, n: NodeId| node(s, node(s, n).children[0]).fills[0].solid_color();
|
||||
let g = CONNECTED_GAP;
|
||||
for (side, want, ends) in [
|
||||
("right", (200.0 + g, 0.0), (Side::Right, Side::Left)),
|
||||
("below", (0.0, 120.0 + g), (Side::Bottom, Side::Top)),
|
||||
("left", (-200.0 - g, 0.0), (Side::Left, Side::Right)),
|
||||
("above", (0.0, -120.0 - g), (Side::Top, Side::Bottom)),
|
||||
] {
|
||||
let r = s.execute("board.addConnected", &json!({"id": a, "side": side})).unwrap();
|
||||
let (b, c) = (id(&r, "id"), id(&r, "connector"));
|
||||
assert_eq!(at(&s, b), want, "{side}");
|
||||
let nb = node(&s, b);
|
||||
assert_eq!((nb.width, nb.height), (200.0, 120.0), "{side}: the same size");
|
||||
assert!(matches!(nb.board, Some(BoardItem::Shape { shape: BoardShape::Diamond })), "{side}: the same shape");
|
||||
assert_eq!(colour(&s, b), colour(&s, a), "{side}: the same colour");
|
||||
let k = connector(&s, c);
|
||||
assert_eq!((k.from, k.to), (End::Node { node: a, side: ends.0 }, End::Node { node: b, side: ends.1 }), "{side}");
|
||||
assert_eq!(k.routing, s.tool.board.routing, "{side}: as the connector tool makes one");
|
||||
assert_eq!(s.selection(), vec![b], "{side}: the new one is selected");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sticky_gets_a_sticky_of_its_colour_and_typing_goes_on_in_it() {
|
||||
let mut s = board();
|
||||
let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0, "color": "pink", "text": "First"})).unwrap(), "id");
|
||||
s.ui_requests.clear();
|
||||
// The selection is the default, and editing is asked for.
|
||||
s.execute("select.set", &json!({"ids": [a]})).unwrap();
|
||||
let r = s.execute("board.addConnected", &json!({"side": "below", "edit": true})).unwrap();
|
||||
let b = id(&r, "id");
|
||||
assert!(matches!(node(&s, b).board, Some(BoardItem::Sticky { .. })));
|
||||
assert_eq!(node(&s, b).fills[0].solid_color(), node(&s, a).fills[0].solid_color());
|
||||
assert_eq!(at(&s, b).1, node(&s, a).height + CONNECTED_GAP);
|
||||
assert!(s.ui_requests.iter().any(|q| q.kind == "board.editText" && q.data["id"] == json!(b)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_is_one_undo_step_and_refuses_what_it_cant_join() {
|
||||
let mut s = board();
|
||||
let a = id(&s.execute("board.shape", &json!({"shape": "ellipse", "x": 0, "y": 0})).unwrap(), "id");
|
||||
let before = s.doc().unwrap().ids().count();
|
||||
let r = s.execute("board.addConnected", &json!({"id": a})).unwrap();
|
||||
assert_eq!(at(&s, id(&r, "id")).0, 160.0 + CONNECTED_GAP, "right by default");
|
||||
assert!(s.doc().unwrap().ids().count() > before);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().ids().count(), before, "the shape and its connector go together");
|
||||
// Not a sticky or shape, not a side, nothing selected.
|
||||
let c = id(&s.execute("board.stamp", &json!({"x": 400, "y": 0, "stamp": "star"})).unwrap(), "id");
|
||||
assert!(s.execute("board.addConnected", &json!({"id": c})).is_err());
|
||||
assert!(s.execute("board.addConnected", &json!({"id": a, "side": "diagonal"})).is_err());
|
||||
s.execute("select.set", &json!({"ids": []})).unwrap();
|
||||
assert!(s.execute("board.addConnected", &json!({})).is_err());
|
||||
// Not on a design file.
|
||||
let mut d = Session::new();
|
||||
assert!(d.execute("board.addConnected", &json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_next_sticky_shortcut_joins_the_next_shape_and_leaves_stickies_as_they_were() {
|
||||
let mut s = board();
|
||||
let a = id(&s.execute("board.shape", &json!({"shape": "rectangle", "x": 0, "y": 0, "width": 200, "height": 120})).unwrap(), "id");
|
||||
s.execute("select.set", &json!({"ids": [a]})).unwrap();
|
||||
let undo = s.ds().unwrap().history.undo.len();
|
||||
// Shift+Cmd/Ctrl+Enter on a shape: the next shape on the right, joined by a connector (#1597).
|
||||
let r = s.execute("board.nextSticky", &json!({})).unwrap();
|
||||
let (b, c) = (id(&r, "id"), id(&r, "connector"));
|
||||
assert!(matches!(node(&s, b).board, Some(BoardItem::Shape { shape: BoardShape::Rectangle })));
|
||||
assert_eq!(at(&s, b), (200.0 + CONNECTED_GAP, 0.0));
|
||||
assert_eq!(connector(&s, c).from, End::Node { node: a, side: Side::Right });
|
||||
assert_eq!(s.ds().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
// Another side, as the command takes one.
|
||||
let r = s.execute("board.nextSticky", &json!({"id": a, "side": "below"})).unwrap();
|
||||
assert_eq!(at(&s, id(&r, "id")), (0.0, 120.0 + CONNECTED_GAP));
|
||||
// A sticky still gets a sticky beside it, without a connector.
|
||||
let st = id(&s.execute("board.sticky", &json!({"x": 1000, "y": 0})).unwrap(), "id");
|
||||
let r = s.execute("board.nextSticky", &json!({"id": st})).unwrap();
|
||||
assert!(r.get("connector").is_none(), "{r}");
|
||||
assert!(matches!(node(&s, id(&r, "id")).board, Some(BoardItem::Sticky { .. })));
|
||||
}
|
||||
@@ -13,7 +13,7 @@ use figcraft_doc::{
|
||||
TextAlignH, TextAutoResize, TextData, VConstraint, VectorNetwork, board_color,
|
||||
};
|
||||
use figcraft_geom::kurbo::PathEl;
|
||||
use figcraft_geom::{Affine, BezPath, Point, Rect, Shape, Vec2};
|
||||
use figcraft_geom::{Affine, BezPath, Point, Rect, Shape};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cmd::CommandSpec;
|
||||
@@ -36,8 +36,9 @@ pub use edit::label_of;
|
||||
pub(crate) use edit::{copied_items, copy_map, rejoin};
|
||||
use edit::{label_cmd, set_color, set_style};
|
||||
pub(crate) use items::add_text;
|
||||
pub use items::{CONNECTED_GAP, connected_place};
|
||||
use items::{add_connected, ink_cmd, next_sticky, shape_cmd, stamp_cmd, sticky_cmd};
|
||||
pub use items::{edit_text, owner_of_text, sticky_font_size, text_of};
|
||||
use items::{ink_cmd, next_sticky, shape_cmd, stamp_cmd, sticky_cmd};
|
||||
|
||||
/// Why a board command refuses on a design file.
|
||||
pub const NEEDS_BOARD: &str = "this is for boards: make one with File ▸ New board";
|
||||
@@ -103,10 +104,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"board.nextSticky",
|
||||
"Next sticky note",
|
||||
"{id? (a sticky; default the selected or edited one), side?: right|below|left|above (default right), edit? (start typing in it; default false)} → {id, text} a sticky of the same colour beside it",
|
||||
"{id? (a sticky or shape; default the selected or edited one), side?: right|below|left|above (default right), edit? (start typing in it; default false)} → {id, text, connector?} a sticky of the same colour beside a sticky; after a shape, the next shape joined to it by a connector, as board.addConnected",
|
||||
true,
|
||||
next_sticky
|
||||
),
|
||||
spec!(
|
||||
"board.addConnected",
|
||||
"Add connected shape",
|
||||
"{id? (a sticky or shape; default the selected or edited one), side?: right|below|left|above (default right), edit? (start typing in it; default false)} → {id, text, connector} the next one of the same kind, size and colour on that side, joined by a connector",
|
||||
true,
|
||||
add_connected
|
||||
),
|
||||
spec!(
|
||||
"board.shape",
|
||||
"Shape with text",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use figcraft_doc::RouteEnd;
|
||||
|
||||
use super::edit::set_font_size;
|
||||
use super::items::{owner_of_text, shape_padding, sticky_font_size, text_of};
|
||||
use super::{
|
||||
Affine, BTreeSet, BezPath, BoardItem, Connector, Document, End, NodeId, NodeKind, PathEl, Point, Rect, Routing, Shape, Side, Vec2, VectorNetwork,
|
||||
is_board,
|
||||
Affine, BTreeSet, BezPath, BoardItem, Connector, Document, End, NodeId, NodeKind, PathEl, Point, Rect, Routing, Shape, VectorNetwork, is_board,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@@ -214,129 +215,12 @@ pub(super) fn place_route(doc: &mut Document, id: NodeId, pts: &[Point], routing
|
||||
}
|
||||
}
|
||||
|
||||
/// How far an elbow connector going around the objects it joins keeps from them.
|
||||
const ROOM: f64 = 24.0;
|
||||
|
||||
/// The page points connector `c` runs through: from the middle of the side of each attached layer
|
||||
/// facing the other end (or the side it names), straight, in right angles, or (curved) its two
|
||||
/// ends with the curve's two control points between them.
|
||||
/// The page points connector `c` runs through (see [`figcraft_doc::route_points`]): from the
|
||||
/// middle of the side of each attached layer facing the other end (or the side it names).
|
||||
pub fn route(doc: &Document, c: &Connector) -> Option<Vec<Point>> {
|
||||
let bounds = |e: &End| e.node().and_then(|n| doc.world_bounds(n));
|
||||
let centre = |e: &End, b: Option<Rect>| match e {
|
||||
End::Point { x, y } => Some(Point::new(*x, *y)),
|
||||
End::Node { .. } => b.map(|b| b.center()),
|
||||
let end = |e: &End| match e {
|
||||
End::Point { x, y } => Some(RouteEnd::Point(Point::new(*x, *y))),
|
||||
End::Node { node, side } => doc.world_bounds(*node).map(|b| RouteEnd::Box(b, *side)),
|
||||
};
|
||||
let (fb, tb) = (bounds(&c.from), bounds(&c.to));
|
||||
let (fc, tc) = (centre(&c.from, fb)?, centre(&c.to, tb)?);
|
||||
let (p0, s0) = attach(&c.from, fb, tc)?;
|
||||
let (p1, s1) = attach(&c.to, tb, fc)?;
|
||||
let pts = match c.routing {
|
||||
Routing::Straight => tidy(vec![p0, p1]),
|
||||
Routing::Elbow => elbow(p0, s0, p1, s1, &[fb, tb].into_iter().flatten().collect::<Vec<_>>()),
|
||||
Routing::Curved => curve(p0, s0, p1, s1),
|
||||
};
|
||||
pts.iter().all(|p| p.x.is_finite() && p.y.is_finite()).then_some(pts)
|
||||
}
|
||||
|
||||
/// `pts` without repeats or turns that don't turn (three in a line across or down).
|
||||
fn tidy(pts: Vec<Point>) -> Vec<Point> {
|
||||
let mut out: Vec<Point> = Vec::with_capacity(pts.len());
|
||||
for p in pts {
|
||||
if out.last() == Some(&p) {
|
||||
continue;
|
||||
}
|
||||
if let [.., a, b] = out.as_slice()
|
||||
&& ((a.x == b.x && b.x == p.x) || (a.y == b.y && b.y == p.y))
|
||||
{
|
||||
out.pop();
|
||||
}
|
||||
out.push(p);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Where end `e` attaches (its point, or the middle of its layer's side facing `other`) and that
|
||||
/// side (`None`: a free point).
|
||||
fn attach(e: &End, b: Option<Rect>, other: Point) -> Option<(Point, Option<Side>)> {
|
||||
match e {
|
||||
End::Point { x, y } => Some((Point::new(*x, *y), None)),
|
||||
End::Node { side, .. } => {
|
||||
let b = b?;
|
||||
let side = match side {
|
||||
Side::Auto => {
|
||||
let d = other - b.center();
|
||||
// Compare against the box's proportions: a wide box takes the sides more.
|
||||
if d.x.abs() * b.height().max(1e-9) >= d.y.abs() * b.width().max(1e-9) {
|
||||
if d.x >= 0.0 { Side::Right } else { Side::Left }
|
||||
} else if d.y >= 0.0 {
|
||||
Side::Bottom
|
||||
} else {
|
||||
Side::Top
|
||||
}
|
||||
}
|
||||
s => *s,
|
||||
};
|
||||
Some((side.point(b), Some(side)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A curve from `p0` to `p1` leaving and arriving along the sides' normals (a free end pulls no
|
||||
/// way): the ends and the two control points between them.
|
||||
fn curve(p0: Point, s0: Option<Side>, p1: Point, s1: Option<Side>) -> Vec<Point> {
|
||||
let reach = (p1 - p0).hypot() / 2.0;
|
||||
let pull = |p: Point, s: Option<Side>| p + s.map_or(Vec2::ZERO, |s| s.normal() * reach);
|
||||
vec![p0, pull(p0, s0), pull(p1, s1), p1]
|
||||
}
|
||||
|
||||
/// A right-angled route from `p0` to `p1`, leaving and arriving along the sides' normals (`s`; a
|
||||
/// free end follows the other): the short way when it keeps out of the attached objects' `boxes`,
|
||||
/// else the cheapest of a few ways around them (by length, and turns).
|
||||
fn elbow(p0: Point, s0: Option<Side>, p1: Point, s1: Option<Side>, boxes: &[Rect]) -> Vec<Point> {
|
||||
let direct = tidy(short_elbow(p0, s0.map(Side::horizontal), p1, s1.map(Side::horizontal)));
|
||||
let Some(all) = boxes.iter().copied().reduce(|a, b| a.union(b)) else { return direct };
|
||||
if !crosses(&direct, boxes) {
|
||||
return direct;
|
||||
}
|
||||
// Out of each end by ROOM, then across on one of a few lines: halfway, or clear of the objects.
|
||||
let out = |p: Point, s: Option<Side>| p + s.map_or(Vec2::ZERO, |s| s.normal() * ROOM);
|
||||
let (a, b) = (out(p0, s0), out(p1, s1));
|
||||
let mut ways = vec![vec![p0, a, Point::new(b.x, a.y), b, p1], vec![p0, a, Point::new(a.x, b.y), b, p1]];
|
||||
for x in [(a.x + b.x) / 2.0, all.x0 - ROOM, all.x1 + ROOM] {
|
||||
ways.push(vec![p0, a, Point::new(x, a.y), Point::new(x, b.y), b, p1]);
|
||||
}
|
||||
for y in [(a.y + b.y) / 2.0, all.y0 - ROOM, all.y1 + ROOM] {
|
||||
ways.push(vec![p0, a, Point::new(a.x, y), Point::new(b.x, y), b, p1]);
|
||||
}
|
||||
let cost = |r: &Vec<Point>| r.windows(2).map(|s| (s[1] - s[0]).hypot()).sum::<f64>() + ROOM * r.len() as f64;
|
||||
ways.into_iter().map(tidy).filter(|r| !crosses(r, boxes)).min_by(|x, y| cost(x).total_cmp(&cost(y))).unwrap_or(direct)
|
||||
}
|
||||
|
||||
/// Whether the right-angled route `pts` runs through the inside of any of `boxes` (along an edge,
|
||||
/// or out of one, is outside).
|
||||
fn crosses(pts: &[Point], boxes: &[Rect]) -> bool {
|
||||
const EDGE: f64 = 0.5;
|
||||
pts.windows(2).any(|s| {
|
||||
let (a, b) = (s[0], s[1]);
|
||||
boxes.iter().any(|r| a.x.min(b.x) < r.x1 - EDGE && a.x.max(b.x) > r.x0 + EDGE && a.y.min(b.y) < r.y1 - EDGE && a.y.max(b.y) > r.y0 + EDGE)
|
||||
})
|
||||
}
|
||||
|
||||
/// The short right-angled route from `p0` to `p1` (`h`: the side faces sideways; a free end
|
||||
/// follows the other): halfway across between two sides facing the same way, one turn otherwise.
|
||||
fn short_elbow(p0: Point, h0: Option<bool>, p1: Point, h1: Option<bool>) -> Vec<Point> {
|
||||
let h0 = h0.or(h1).unwrap_or((p1.x - p0.x).abs() >= (p1.y - p0.y).abs());
|
||||
let h1 = h1.unwrap_or(h0);
|
||||
match (h0, h1) {
|
||||
(true, true) => {
|
||||
let mx = (p0.x + p1.x) / 2.0;
|
||||
vec![p0, Point::new(mx, p0.y), Point::new(mx, p1.y), p1]
|
||||
}
|
||||
(false, false) => {
|
||||
let my = (p0.y + p1.y) / 2.0;
|
||||
vec![p0, Point::new(p0.x, my), Point::new(p1.x, my), p1]
|
||||
}
|
||||
(true, false) => vec![p0, Point::new(p1.x, p0.y), p1],
|
||||
(false, true) => vec![p0, Point::new(p0.x, p1.y), p1],
|
||||
}
|
||||
figcraft_doc::route_points(end(&c.from)?, end(&c.to)?, c.routing)
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
use super::connectors::{CAPS, make};
|
||||
use super::{
|
||||
AUTHOR_INK, Affine, AutoLayout, BlendMode, BoardItem, BoardShape, Color, Constraints, CrossAlign, Document, Effect, EffectKind, EngineError,
|
||||
HConstraint, HIGHLIGHTER, INK, LayoutMode, MARKER, MainAlign, Node, NodeId, NodeKind, Paint, Result, SHAPE, STAMP_SIZE, STICKY, STICKY_GAP,
|
||||
STICKY_PAD, Session, Shadow, Sizing, StrokeAlign, StrokeCap, StrokeJoin, TextAlignH, TextAutoResize, TextData, VConstraint, Value, VectorNetwork,
|
||||
author, bad, boolean, color_param, get, id, id_of, json, line_shade, num, num_or, parent_of, req_num, require, shape_of, stamp_of, string,
|
||||
textedit,
|
||||
AUTHOR_INK, Affine, AutoLayout, BlendMode, BoardItem, BoardShape, Color, Connector, Constraints, CrossAlign, Document, Effect, EffectKind, End,
|
||||
EngineError, HConstraint, HIGHLIGHTER, INK, LayoutMode, MARKER, MainAlign, Node, NodeId, NodeKind, Paint, Result, SHAPE, STAMP_SIZE, STICKY,
|
||||
STICKY_GAP, STICKY_PAD, Session, Shadow, Side, Sizing, StrokeAlign, StrokeCap, StrokeJoin, TextAlignH, TextAutoResize, TextData, VConstraint,
|
||||
Value, VectorNetwork, author, bad, boolean, color_param, get, id, id_of, json, line_shade, num, num_or, parent_of, req_num, require, shape_of,
|
||||
stamp_of, string, textedit,
|
||||
};
|
||||
|
||||
/// A new layer at the top of `parent`, at `at` in it, made by `f`.
|
||||
@@ -144,10 +145,14 @@ pub(super) fn next_sticky(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let from = id(p, "id")
|
||||
.or_else(|| editing.and_then(|t| owner_of_text(doc, t)))
|
||||
.or_else(|| s.selection().first().copied().map(|i| owner_of_text(doc, i).unwrap_or(i)))
|
||||
.ok_or_else(|| EngineError::Disabled(cmd.into(), "select a sticky first".into()))?;
|
||||
.ok_or_else(|| EngineError::Disabled(cmd.into(), "select a sticky or a shape first".into()))?;
|
||||
let n = doc.get(from)?;
|
||||
if matches!(n.board, Some(BoardItem::Shape { .. })) {
|
||||
// A shape gets the next one joined to it, as the "+" beside it makes (#1597).
|
||||
return add_connected(s, p);
|
||||
}
|
||||
if !matches!(n.board, Some(BoardItem::Sticky { .. })) {
|
||||
return Err(bad(cmd, "that isn't a sticky"));
|
||||
return Err(bad(cmd, "that isn't a sticky or a shape"));
|
||||
}
|
||||
let color = n.fills.iter().find_map(Paint::solid_color).unwrap_or(s.tool.board.sticky);
|
||||
let parent = n.parent.ok_or_else(|| bad(cmd, "that sticky has no parent"))?;
|
||||
@@ -172,6 +177,70 @@ pub(super) fn next_sticky(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"id": id, "text": note}))
|
||||
}
|
||||
|
||||
/// The room between a board object and the next one `board.addConnected` makes, joined by a
|
||||
/// connector: enough for the arrow to read.
|
||||
pub const CONNECTED_GAP: f64 = 80.0;
|
||||
|
||||
/// Where `board.addConnected` puts the next object on `side` of one at `at` sized `size`, `next`
|
||||
/// big (its top-left), and the sides the connector joins (from, to). `None`: not a side.
|
||||
pub fn connected_place(at: (f64, f64), size: (f64, f64), next: (f64, f64), side: &str) -> Option<((f64, f64), Side, Side)> {
|
||||
let ((x, y), (w, h), (nw, nh)) = (at, size, next);
|
||||
let (cx, cy) = (x + (w - nw) / 2.0, y + (h - nh) / 2.0);
|
||||
Some(match side {
|
||||
"right" => ((x + w + CONNECTED_GAP, cy), Side::Right, Side::Left),
|
||||
"left" => ((x - CONNECTED_GAP - nw, cy), Side::Left, Side::Right),
|
||||
"below" => ((cx, y + h + CONNECTED_GAP), Side::Bottom, Side::Top),
|
||||
"above" => ((cx, y - CONNECTED_GAP - nh), Side::Top, Side::Bottom),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `board.addConnected`: the next sticky or shape on a side of one, of the same kind, size and
|
||||
/// colour, joined to it by a connector the way the connector tool makes one; one undo step.
|
||||
pub(super) fn add_connected(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "board.addConnected";
|
||||
require(s, cmd)?;
|
||||
let editing = s.ds()?.text_edit.as_ref().map(|t| t.id);
|
||||
let doc = s.doc()?;
|
||||
let from = id(p, "id")
|
||||
.or_else(|| editing.and_then(|t| owner_of_text(doc, t)))
|
||||
.or_else(|| s.selection().first().copied().map(|i| owner_of_text(doc, i).unwrap_or(i)))
|
||||
.ok_or_else(|| EngineError::Disabled(cmd.into(), "select a sticky or a shape first".into()))?;
|
||||
let n = doc.get(from)?;
|
||||
let shape = match &n.board {
|
||||
Some(BoardItem::Sticky { .. }) => None,
|
||||
Some(BoardItem::Shape { shape }) => Some(*shape),
|
||||
_ => return Err(bad(cmd, "that isn't a sticky or a shape")),
|
||||
};
|
||||
let side = string(p, "side").unwrap_or_else(|| "right".into());
|
||||
let parent = n.parent.ok_or_else(|| bad(cmd, "that object has no parent"))?;
|
||||
let at = n.transform.translation();
|
||||
let colour_of = |n: &Node| n.fills.iter().find_map(Paint::solid_color);
|
||||
let (next, color) = match shape {
|
||||
None => ((STICKY, STICKY), colour_of(n)),
|
||||
Some(_) => ((n.width, n.height), n.children.first().and_then(|c| doc.node(*c)).and_then(colour_of)),
|
||||
};
|
||||
let color = color.unwrap_or(s.tool.board.sticky);
|
||||
let (to_at, from_side, to_side) = connected_place((at.x, at.y), (n.width, n.height), next, &side)
|
||||
.ok_or_else(|| bad(cmd, format!("`side` is right, below, left or above, not `{side}`")))?;
|
||||
let (routing, author, page) = (s.tool.board.routing, author(s), s.page()?);
|
||||
if editing.is_some() {
|
||||
textedit::end(s)?;
|
||||
}
|
||||
let doc = s.doc_mut()?;
|
||||
let (id, text) = match shape {
|
||||
None => make_sticky(doc, parent, to_at, "", color, &author)?,
|
||||
Some(shape) => make_shape(doc, parent, to_at, next, shape, "", color)?,
|
||||
};
|
||||
let connector = Connector { from: End::Node { node: from, side: from_side }, to: End::Node { node: id, side: to_side }, routing };
|
||||
let line = make(doc, page, connector, CAPS)?;
|
||||
s.set_selection(vec![id]);
|
||||
if boolean(p, "edit") == Some(true) {
|
||||
s.request_ui("board.editText", json!({"id": id}));
|
||||
}
|
||||
Ok(json!({"id": id, "text": text, "connector": line}))
|
||||
}
|
||||
|
||||
/// A shape with text's layers: the frame, its outline and its text.
|
||||
fn make_shape(
|
||||
doc: &mut Document,
|
||||
|
||||
@@ -12,7 +12,7 @@ use serde_json::Value;
|
||||
use crate::{EngineError, Result, Session, bad, find_command, guard, normalize};
|
||||
|
||||
/// The commands that show or end a preview (they don't end the one showing).
|
||||
pub const COMMANDS: [&str; 2] = ["text.preview", "text.endPreview"];
|
||||
pub const COMMANDS: [&str; 3] = ["text.preview", "text.endPreview", "widget.hover"];
|
||||
|
||||
/// What can be previewed: text styles, font styles and axis values, layers' properties and the
|
||||
/// blend of a paint on a layer, a selection or a style.
|
||||
@@ -70,6 +70,27 @@ pub fn show(s: &mut Session, command: &str, params: &Value) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Show what `change` does to the document as a live preview (in place of an earlier one), like
|
||||
/// [`show`] does with a command's: no undo step, and the next command puts the document back first.
|
||||
pub(crate) fn show_change(s: &mut Session, cmd: &str, change: impl FnOnce(&mut figcraft_doc::Document) -> Result<()>) -> Result<()> {
|
||||
end(s);
|
||||
let ds = s.ds_mut()?;
|
||||
if ds.preview.is_some() {
|
||||
return Err(EngineError::Disabled(cmd.into(), crate::PREVIEW_READ_ONLY.into()));
|
||||
}
|
||||
let base = ds.doc.clone();
|
||||
let mut doc = (*base).clone();
|
||||
guard::run(cmd, || {
|
||||
change(&mut doc)?;
|
||||
normalize(&mut doc, &base);
|
||||
Ok(())
|
||||
})?;
|
||||
ds.doc = Arc::new(doc);
|
||||
ds.text_preview = Some(base);
|
||||
ds.revision += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// End the live preview, if any: the document is back as it was. Returns whether one was showing.
|
||||
pub fn end(s: &mut Session) -> bool {
|
||||
let Ok(ds) = s.ds_mut() else { return false };
|
||||
|
||||
@@ -578,6 +578,8 @@ pub struct ToolState {
|
||||
pub pick_only: bool,
|
||||
/// The colour the eyedropper sampled for a colour picker, until the picker takes it.
|
||||
pub picked: Option<figcraft_color::Color>,
|
||||
/// The widget layers whose hover styles show while the pointer is over them (a live preview).
|
||||
pub(crate) widget_hover: Vec<NodeId>,
|
||||
}
|
||||
|
||||
impl ToolState {
|
||||
|
||||
@@ -232,6 +232,8 @@ impl Session {
|
||||
let mut in_flow = false;
|
||||
// Placed in a row or column: (is it a column, its gap) for the insertion line.
|
||||
let mut slot_axis: Option<(bool, f64)> = None;
|
||||
// Only the selected layers moved, each by `d` (see `Interaction::moved_by`).
|
||||
let mut shifted = !dup;
|
||||
self.preview(|doc, sel| {
|
||||
let mut moving = ids.clone();
|
||||
if dup {
|
||||
@@ -288,6 +290,7 @@ impl Session {
|
||||
}
|
||||
// A free move. Layers dragged out of an auto layout flow leave it (onto the page
|
||||
// while dragging; the drop puts them in the frame under the pointer).
|
||||
shifted &= only_shifted(doc, &moving);
|
||||
for m in &moving {
|
||||
let in_al = doc.node(*m).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some());
|
||||
if flow && in_al {
|
||||
@@ -298,6 +301,9 @@ impl Session {
|
||||
drop_target = (flow && target != page).then_some(target);
|
||||
Ok(())
|
||||
})?;
|
||||
if let Some(it) = self.ds_mut()?.interaction.as_mut() {
|
||||
it.moved_by = (shifted && !in_flow).then_some((d.x, d.y));
|
||||
}
|
||||
let placed = if dup { self.selection() } else { ids };
|
||||
// Snapping guides mean nothing while the layout places the layers; with Alt held,
|
||||
// live distances to the neighbours replace them.
|
||||
@@ -628,3 +634,21 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Moving `moving` by the same offset moves nothing else and changes nothing about them but where
|
||||
/// they are: none is inside another (it would move twice) or placed by its parent's auto layout
|
||||
/// (the layout would put it back, or close the gap it leaves).
|
||||
fn only_shifted(doc: &Document, moving: &[NodeId]) -> bool {
|
||||
let set: std::collections::HashSet<NodeId> = moving.iter().copied().collect();
|
||||
moving.iter().all(|m| {
|
||||
let Some(n) = doc.node(*m) else { return false };
|
||||
let in_flow = n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()) && !n.child_layout.absolute;
|
||||
let mut up = n.parent;
|
||||
let mut nested = false;
|
||||
while let Some(p) = up {
|
||||
nested |= set.contains(&p);
|
||||
up = doc.node(p).and_then(|x| x.parent);
|
||||
}
|
||||
!in_flow && !nested
|
||||
})
|
||||
}
|
||||
@@ -13,6 +13,24 @@ impl Session {
|
||||
self.tool.overlay.measure_box = None;
|
||||
}
|
||||
self.vector_pointer_left();
|
||||
self.follow_widget_hover(Vec::new());
|
||||
}
|
||||
|
||||
/// Show the hover styles of the widget layers `ids` the pointer is over (none: end them), unless
|
||||
/// another live preview (a hovered font, say) is showing.
|
||||
fn follow_widget_hover(&mut self, ids: Vec<NodeId>) {
|
||||
let ours = !self.tool.widget_hover.is_empty() && crate::textpreview::active(self);
|
||||
if ids.is_empty() {
|
||||
if ours {
|
||||
crate::textpreview::end(self);
|
||||
}
|
||||
self.tool.widget_hover.clear();
|
||||
return;
|
||||
}
|
||||
if ours && self.tool.widget_hover == ids || !ours && crate::textpreview::active(self) {
|
||||
return;
|
||||
}
|
||||
self.tool.widget_hover = if self.widget_hover(&ids).unwrap_or(false) { ids } else { Vec::new() };
|
||||
}
|
||||
|
||||
/// What a marquee starting at page point `p` selects inside: the top-level frame it starts in
|
||||
@@ -194,6 +212,7 @@ impl Session {
|
||||
cursor = Cursor::Text;
|
||||
}
|
||||
let mut hover = None;
|
||||
let mut widget_hover = Vec::new();
|
||||
let mut guides = Vec::new();
|
||||
let mut measure_box = None;
|
||||
let arc_hover = if self.arc_handles_shown() { crate::arc_handles::hit(&doc, &sel, ev.p(), ev.zoom).map(|(_, h)| h) } else { None };
|
||||
@@ -214,6 +233,7 @@ impl Session {
|
||||
if leaf.and_then(|l| crate::widgets::target_at(&doc, l, ev.p())).is_some() {
|
||||
cursor = Cursor::Pointer;
|
||||
}
|
||||
widget_hover = leaf.map(|l| crate::widgets::hovered_at(&doc, l, ev.p())).unwrap_or_default();
|
||||
hover = label_at(&doc, page, &self.tool.labels, ev.p()).or_else(|| leaf.map(|leaf| select_target(&doc, leaf, &sel, ev.cmd)));
|
||||
// Alt over a selected layer: a drag from here duplicates it.
|
||||
if ev.alt && !self.tool.dev_mode && tool == ToolId::Move && hover.is_some_and(|h| sel.contains(&h) && !locked_in(&doc, h)) {
|
||||
@@ -260,6 +280,7 @@ impl Session {
|
||||
if tool == ToolId::Connector {
|
||||
self.connector_hover(ev);
|
||||
}
|
||||
self.follow_widget_hover(widget_hover);
|
||||
let ds = self.ds_mut()?;
|
||||
if ds.hover != hover {
|
||||
ds.hover = hover;
|
||||
|
||||
@@ -410,9 +410,10 @@ fn free_spot(s: &Session, w: f64, h: f64) -> Result<(f64, f64)> {
|
||||
})
|
||||
}
|
||||
|
||||
/// An image file to place: no larger than a document takes an image from outside (#1114).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_path(path: &str) -> Result<Vec<u8>> {
|
||||
crate::file_access::read(path)
|
||||
crate::file_access::read_capped(path, figcraft_doc::ImageData::MAX_BYTES)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -127,7 +127,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"edit.pasteDesignLayers",
|
||||
"Paste design layers",
|
||||
"{html (the clipboard's HTML) | data (base64 of the clipboard's design data, at most 256 MB), parent?, x?, y?} layers copied from another design app (a .fig canvas in the clipboard's HTML), read like a .fig file (best effort, with `warnings`), pasted as edit.paste pastes",
|
||||
"{html (the clipboard's HTML) | data (base64 of the clipboard's design data, at most 256 MB), parent?, x?, y?} layers copied from another design app (a .fig canvas in the clipboard's HTML), read like a .fig file (best effort, with `warnings`), pasted as edit.paste pastes; `missingImages`: the names of pasted layers whose image the clipboard didn't carry (they show the missing-image placeholder)",
|
||||
true,
|
||||
paste_design_layers
|
||||
),
|
||||
@@ -305,9 +305,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
ok()
|
||||
}),
|
||||
spec!("node.set", "Set properties", super::props::KEYS, true, set),
|
||||
spec!("node.get", "Get layer", "{id}", false, |s, p| {
|
||||
spec!("node.get", "Get layer", "{id, fields?: [node keys], outline?: bool (not with fields)}", false, |s, p| {
|
||||
let i = req_id("node.get", p, "id")?;
|
||||
Ok(crate::inspect::node_json(s.doc()?, i, 1))
|
||||
let options = crate::inspect::InspectOptions::parse(p).map_err(|e| bad("node.get", e))?;
|
||||
Ok(crate::inspect::node_json_with_options(s.doc()?, i, 1, &options))
|
||||
}),
|
||||
spec!(
|
||||
"node.find",
|
||||
|
||||
@@ -134,12 +134,39 @@ pub(super) fn paste_design_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
layers_generation,
|
||||
};
|
||||
let mut out = paste(s, p)?;
|
||||
let pasted: Vec<NodeId> = out["ids"].as_array().map(|a| a.iter().filter_map(crate::params::id_of).collect()).unwrap_or_default();
|
||||
let missing = layers_missing_images(s.doc()?, &pasted);
|
||||
if !missing.is_empty() {
|
||||
out["missingImages"] = json!(missing);
|
||||
}
|
||||
if !other.issues.is_empty() {
|
||||
out["warnings"] = json!(other.issues);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The most layers [`layers_missing_images`] names.
|
||||
const MAX_MISSING_NAMED: usize = 20;
|
||||
|
||||
/// The names of the layers under `ids` with an image fill or stroke whose image the paste didn't
|
||||
/// carry (#1646): it shows the importer's missing-image placeholder, or nothing at all.
|
||||
fn layers_missing_images(doc: &figcraft_doc::Document, ids: &[NodeId]) -> Vec<String> {
|
||||
let missing = |p: &figcraft_color::Paint| match &p.kind {
|
||||
figcraft_color::PaintKind::Image(img) => doc.images.get(&img.image).is_none_or(figcraft_format::rest::is_missing_image),
|
||||
_ => false,
|
||||
};
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
for n in ids.iter().flat_map(|i| doc.subtree(*i)).filter_map(|i| doc.node(i)) {
|
||||
if n.fills.iter().chain(&n.strokes).any(missing) && !names.contains(&n.name) {
|
||||
names.push(n.name.clone());
|
||||
if names.len() == MAX_MISSING_NAMED {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
pub(super) fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("edit.copy", s, p)?;
|
||||
copy_to_clipboard(s, "edit.copy", &ids)?;
|
||||
|
||||
@@ -30,9 +30,9 @@ pub use export::{
|
||||
};
|
||||
use export::{export, export_frames_pdf, export_layers, pdf_bytes};
|
||||
use load::font_report;
|
||||
pub use load::{MAX_DOCUMENT_FILE, load, load_bytes, load_path};
|
||||
#[cfg(test)]
|
||||
pub(crate) use load::{installed_family, sketch_line};
|
||||
pub use load::{load, load_bytes, load_path};
|
||||
pub(crate) use load::{load_fig, read_capped, recovered, recovered_from, variables_param, write};
|
||||
pub use open::MAX_RECENT;
|
||||
#[cfg(test)]
|
||||
@@ -203,7 +203,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"file.colorProfile",
|
||||
"Color profile",
|
||||
"{profile?: srgb|displayP3, convert? (default true: convert colour values so colours keep their appearance; false: keep the values and reinterpret them)} without `profile`, returns the current one (and the app opens the dialog). Returns {profile, changed}",
|
||||
"{profile?: srgb|displayP3, convert? (default true: convert colour values so colours keep their appearance — a colour calculation's mix keeps its ends exact and its colours in between move slightly; false: keep the values and reinterpret them)} without `profile`, returns the current one (and the app opens the dialog). Returns {profile, changed}",
|
||||
true,
|
||||
color_profile
|
||||
),
|
||||
|
||||
@@ -14,9 +14,14 @@ pub(crate) fn recovered_from(bytes: &[u8]) -> Result<DocState> {
|
||||
Ok(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches).unsaved())
|
||||
}
|
||||
|
||||
/// The largest file opened or imported as a document. A `.figcraft` file keeps its images as
|
||||
/// they are, so a real one can be far larger than it unpacks to (#1313's took 7.4 GB); twice that
|
||||
/// opens, while a device, a pipe or an absurd file is refused before it is read (#1114).
|
||||
pub const MAX_DOCUMENT_FILE: u64 = 16 << 30;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn read(path: &str) -> Result<Vec<u8>> {
|
||||
crate::file_access::read(path)
|
||||
crate::file_access::read_capped(path, MAX_DOCUMENT_FILE)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -135,6 +140,7 @@ fn load_any(
|
||||
if let Some(r) = figcraft_format::rest::try_import_with(bytes, variables, images) {
|
||||
let (mut doc, warnings) = r.map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
link_instances(&mut doc);
|
||||
installed_fonts_first();
|
||||
return Ok((figcraft_format::Loaded { issues: warnings, ..doc.into() }, true));
|
||||
}
|
||||
let mut loaded = figcraft_format::load_bytes_with_versions(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
@@ -376,11 +382,19 @@ pub(crate) fn installed_family(family: &str, installed: &[String]) -> Option<Str
|
||||
installed.iter().find(|f| key(f) == want).cloned()
|
||||
}
|
||||
|
||||
/// An imported file names its fonts by family: the installed ones must be known before its text is
|
||||
/// laid out, or it is measured (and reported missing) in a fallback font while the desktop's
|
||||
/// background scan is still running (#1292).
|
||||
fn installed_fonts_first() {
|
||||
figcraft_text::scan_system_fonts();
|
||||
}
|
||||
|
||||
/// Gives `None` when `bytes` are not a `.fig` file. Then other readers can try.
|
||||
pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option<Result<figcraft_format::Loaded>> {
|
||||
let r = figcraft_format::fig::try_import(bytes, name)?;
|
||||
Some(r.map_err(|e| EngineError::Other(e.to_string())).map(|(mut doc, warnings)| {
|
||||
link_instances(&mut doc);
|
||||
installed_fonts_first();
|
||||
figcraft_format::Loaded { issues: warnings, ..doc.into() }
|
||||
}))
|
||||
}
|
||||
@@ -557,7 +571,8 @@ fn font_report_with_families(doc: &Document, installed: &[String]) -> Value {
|
||||
} else if known.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(known.contains(&lower))
|
||||
// Also under the other name its fonts give the family ("Economia Medium").
|
||||
Some(known.contains(&lower) || figcraft_text::has_family(&family))
|
||||
};
|
||||
json!({"family":family,"available":available,"layers":layers.into_iter().collect::<Vec<_>>()})
|
||||
})
|
||||
|
||||
@@ -245,7 +245,7 @@ fn alias(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let kind = doc.variables.get(&own).filter(|v| !is_remote_variable(doc, &v.id)).map(|v| v.kind);
|
||||
let kind = kind.ok_or_else(|| bad(cmd, format!("no local variable `{own}`")))?;
|
||||
let (lib, key) = variable_arg(s, cmd, p, "alias")?;
|
||||
if lib.doc.variables.get(&key).is_some_and(|v| v.kind != kind) {
|
||||
if lib.doc.variables.get_by_id(&key).is_some_and(|v| v.kind != kind) {
|
||||
return Err(bad(cmd, "the library variable has another type"));
|
||||
}
|
||||
let local = import_variable(s, &lib, &key)?;
|
||||
@@ -262,7 +262,7 @@ fn set_mode(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.or_else(|| published_collections(&lib).find(|c| c.name == key))
|
||||
.ok_or_else(|| bad(cmd, format!("\"{}\" publishes no variable collection `{key}`", link.name)))?;
|
||||
let m = req_string(cmd, p, "mode")?;
|
||||
let mode = coll.modes.iter().find(|x| x.id == m || x.name == m).map(|x| x.id.clone()).ok_or_else(|| bad(cmd, "no such mode"))?;
|
||||
let mode = coll.find_mode(&m).map(|x| x.id.clone()).ok_or_else(|| bad(cmd, "no such mode"))?;
|
||||
let key = coll.id.clone();
|
||||
let map = import_variables(s.doc_mut()?, &lib, &BTreeSet::new(), &BTreeSet::from([key.clone()]), false);
|
||||
let local = map.collections.get(&key).cloned().ok_or_else(|| EngineError::Other("the collection didn't import".into()))?;
|
||||
@@ -474,7 +474,8 @@ fn swap_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn assets_json(lib: &PublishedLibrary) -> Value {
|
||||
let m = &lib.manifest;
|
||||
let modes = |c: &str| lib.doc.variables.collection(c).map(|c| c.modes.iter().map(|x| json!({"id": x.id, "name": x.name})).collect::<Vec<_>>());
|
||||
let modes =
|
||||
|c: &str| lib.doc.variables.collection_by_id(c).map(|c| c.modes.iter().map(|x| json!({"id": x.id, "name": x.name})).collect::<Vec<_>>());
|
||||
json!({
|
||||
"components": m.components.iter().filter(|a| a.published).map(|a| json!({"id": a.id, "name": a.name, "set": a.set, "version": a.version})).collect::<Vec<_>>(),
|
||||
"styles": m.styles.iter().filter(|x| x.published).map(|x| json!({"id": x.id, "name": x.name, "type": x.kind, "version": x.version})).collect::<Vec<_>>(),
|
||||
@@ -520,7 +521,7 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
res["publishable"] = json!({
|
||||
"components": all.components.iter().filter_map(|c| doc.node(*c).map(|n| json!({"id": c, "name": n.name, "selected": def.components.contains(c)}))).collect::<Vec<_>>(),
|
||||
"styles": all.styles.iter().filter_map(|x| doc.style(x).map(|st| json!({"id": st.id, "name": st.name, "selected": def.styles.contains(x)}))).collect::<Vec<_>>(),
|
||||
"collections": all.collections.iter().filter_map(|x| doc.variables.collection(x).map(|c| json!({"id": c.id, "name": c.name, "selected": def.collections.contains(x)}))).collect::<Vec<_>>(),
|
||||
"collections": all.collections.iter().filter_map(|x| doc.variables.collection_by_id(x).map(|c| json!({"id": c.id, "name": c.name, "selected": def.collections.contains(x)}))).collect::<Vec<_>>(),
|
||||
});
|
||||
}
|
||||
Ok(res)
|
||||
|
||||
@@ -308,40 +308,6 @@ pub(crate) fn check_action_dests(cmd: &str, doc: &Document, actions: &[Action])
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Variables, collections and modes that actions name by name, stored by id.
|
||||
fn canonical_ids(doc: &Document, it: &mut Interaction) {
|
||||
canonical_actions(&doc.variables, &mut it.actions);
|
||||
}
|
||||
|
||||
/// Variables (also those expressions read), collections and modes that `actions` name by name,
|
||||
/// stored by id.
|
||||
pub(crate) fn canonical_actions(vars: &Variables, actions: &mut [Action]) {
|
||||
walk_mut(actions, &mut |a| match a {
|
||||
Action::SetVariable { variable, expr, .. } => {
|
||||
if let Some(v) = vars.get(variable) {
|
||||
*variable = v.id.clone();
|
||||
}
|
||||
if let Some(e) = expr {
|
||||
canonical_expr(vars, e, 0);
|
||||
}
|
||||
}
|
||||
Action::SetVariableMode { collection, mode } => {
|
||||
if let Some(c) = vars.collection(collection) {
|
||||
if let Some(m) = c.find_mode(mode) {
|
||||
*mode = m.id.clone();
|
||||
}
|
||||
*collection = c.id.clone();
|
||||
}
|
||||
}
|
||||
Action::Conditional { blocks } => {
|
||||
for e in blocks.iter_mut().filter_map(|b| b.condition.as_mut()) {
|
||||
canonical_expr(vars, e, 0);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
}
|
||||
|
||||
/// Variables, collections and modes an expression names by name, stored by id.
|
||||
pub(crate) fn canonical_expr(vars: &Variables, e: &mut Expr, depth: usize) {
|
||||
match e {
|
||||
@@ -357,7 +323,7 @@ pub(crate) fn canonical_expr(vars: &Variables, e: &mut Expr, depth: usize) {
|
||||
}
|
||||
Expr::InMode { variable, mode } => {
|
||||
if let Some(v) = vars.get(variable) {
|
||||
if let Some(m) = vars.collection(&v.collection).and_then(|c| c.find_mode(mode)) {
|
||||
if let Some(m) = vars.collection_by_id(&v.collection).and_then(|c| c.find_mode(mode)) {
|
||||
*mode = m.id.clone();
|
||||
}
|
||||
*variable = v.id.clone();
|
||||
@@ -425,7 +391,6 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut it = it;
|
||||
it.delay_ms = delay(cmd, p)?.unwrap_or(0);
|
||||
settle(&mut it);
|
||||
canonical_ids(s.doc()?, &mut it);
|
||||
check_dests(cmd, s, &it)?;
|
||||
let mut index = 0;
|
||||
for i in req_targets(cmd, s, p)? {
|
||||
@@ -484,7 +449,6 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if it.actions.is_empty() {
|
||||
return Err(bad(cmd, "an interaction needs an action"));
|
||||
}
|
||||
canonical_ids(s.doc()?, &mut it);
|
||||
check_dests(cmd, s, &it)?;
|
||||
if let Some(slot) = s.doc_mut()?.get_mut(i)?.interactions.get_mut(k) {
|
||||
*slot = it;
|
||||
|
||||
@@ -159,15 +159,73 @@ pub fn default_condition(vars: &Variables) -> Expr {
|
||||
}
|
||||
}
|
||||
|
||||
/// `p` with every `expression` text in it read: on a set variable action it becomes `expr`
|
||||
/// (checked against the variable), elsewhere (conditional blocks) the `condition` (checked to give
|
||||
/// true or false).
|
||||
/// `p` with incoming names stored as ids and every `expression` text read: on a set variable
|
||||
/// action it becomes `expr` (checked against the variable), elsewhere (conditional blocks) the
|
||||
/// `condition` (checked to give true or false). Parsed text already holds ids, so it is never
|
||||
/// canonicalized again. Existing actions that a command leaves alone never pass through here.
|
||||
pub fn with_expressions(cmd: &str, vars: &Variables, p: &Value) -> Result<Value> {
|
||||
let mut v = p.clone();
|
||||
canonical_inputs(cmd, vars, &mut v, 0)?;
|
||||
resolve_expressions(cmd, vars, &mut v, 0)?;
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Visit input actions and conditional blocks, never literal values or parsed expressions.
|
||||
fn canonical_inputs(cmd: &str, vars: &Variables, v: &mut Value, depth: usize) -> Result<()> {
|
||||
if depth > 4 * MAX_ACTION_DEPTH {
|
||||
return Err(bad(cmd, "actions are nested too deeply"));
|
||||
}
|
||||
match v {
|
||||
Value::Array(a) => a.iter_mut().try_for_each(|x| canonical_inputs(cmd, vars, x, depth + 1)),
|
||||
Value::Object(o) => {
|
||||
canonical_action_names(vars, o);
|
||||
canonical_json_exprs(cmd, vars, o)?;
|
||||
for key in ["action", "actions", "interaction", "blocks"] {
|
||||
if let Some(child) = o.get_mut(key) {
|
||||
canonical_inputs(cmd, vars, child, depth + 1)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Only command input accepts names: settle action targets before reading text expressions.
|
||||
fn canonical_action_names(vars: &Variables, o: &mut serde_json::Map<String, Value>) {
|
||||
match o.get("type").and_then(Value::as_str) {
|
||||
Some("setVariable") => {
|
||||
if let Some(v) = o.get("variable").and_then(Value::as_str).and_then(|n| vars.get(n)) {
|
||||
o.insert("variable".into(), json!(v.id));
|
||||
}
|
||||
}
|
||||
Some("setVariableMode") => {
|
||||
if let Some(c) = o.get("collection").and_then(Value::as_str).and_then(|n| vars.collection(n)) {
|
||||
if let Some(m) = o.get("mode").and_then(Value::as_str).and_then(|n| c.find_mode(n)) {
|
||||
o.insert("mode".into(), json!(m.id));
|
||||
}
|
||||
o.insert("collection".into(), json!(c.id));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON expressions accept names too; text replaces them and is parsed separately.
|
||||
fn canonical_json_exprs(cmd: &str, vars: &Variables, o: &mut serde_json::Map<String, Value>) -> Result<()> {
|
||||
if o.get("expression").is_some_and(Value::is_string) {
|
||||
return Ok(());
|
||||
}
|
||||
for key in ["expr", "condition"] {
|
||||
if let Some(value) = o.get_mut(key).filter(|v| !v.is_null()) {
|
||||
let mut e: Expr = serde_json::from_value(value.clone()).map_err(|e| bad(cmd, format!("{key}: {e}")))?;
|
||||
super::proto::canonical_expr(vars, &mut e, 0);
|
||||
*value = expr_json(cmd, &e)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_expressions(cmd: &str, vars: &Variables, v: &mut Value, depth: usize) -> Result<()> {
|
||||
if depth > 4 * MAX_ACTION_DEPTH {
|
||||
return Err(bad(cmd, "actions are nested too deeply"));
|
||||
@@ -181,7 +239,7 @@ fn resolve_expressions(cmd: &str, vars: &Variables, v: &mut Value, depth: usize)
|
||||
let var = o
|
||||
.get("variable")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|n| vars.get(n))
|
||||
.and_then(|n| vars.get_by_id(n))
|
||||
.ok_or_else(|| bad(cmd, "setVariable: no such `variable`"))?;
|
||||
e.check_value(vars, var.kind).map_err(|m| bad(cmd, m))?;
|
||||
o.insert("expr".into(), expr_json(cmd, &e)?);
|
||||
@@ -208,7 +266,7 @@ fn check_action(cmd: &str, vars: &Variables, a: &Action) -> Result<()> {
|
||||
for x in walk(std::slice::from_ref(a)) {
|
||||
match x {
|
||||
Action::SetVariable { variable, expr, .. } => {
|
||||
let v = vars.get(variable).ok_or_else(|| bad(cmd, format!("no variable `{variable}`")))?;
|
||||
let v = vars.get_by_id(variable).ok_or_else(|| bad(cmd, format!("no variable `{variable}`")))?;
|
||||
if let Some(e) = expr {
|
||||
e.check_value(vars, v.kind).map_err(|m| bad(cmd, m))?;
|
||||
}
|
||||
@@ -231,8 +289,7 @@ fn check_action(cmd: &str, vars: &Variables, a: &Action) -> Result<()> {
|
||||
fn action_param(cmd: &str, s: &Session, p: &Value) -> Result<Action> {
|
||||
let doc = s.doc()?;
|
||||
let p = with_expressions(cmd, &doc.variables, p)?;
|
||||
let mut a: Action = parse(cmd, &p, "action")?.ok_or_else(|| bad(cmd, "`action` required"))?;
|
||||
super::proto::canonical_actions(&doc.variables, std::slice::from_mut(&mut a));
|
||||
let a: Action = parse(cmd, &p, "action")?.ok_or_else(|| bad(cmd, "`action` required"))?;
|
||||
check_action(cmd, &doc.variables, &a)?;
|
||||
super::proto::check_action_dests(cmd, doc, std::slice::from_ref(&a))?;
|
||||
Ok(a)
|
||||
|
||||
@@ -164,21 +164,21 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"variable.create",
|
||||
"Create variable",
|
||||
"{collection, name, type: color|number|string|boolean, value (default mode) | values {modeName: value}, description?}",
|
||||
"{collection, name, type: color|number|string|boolean, value (default mode) | values {modeName: value}, description?} a value may also be {alias: variable} (same type); a colour value may also be {alpha: {variable, opacity 0..1}} (that variable's colour with its alpha replaced by the opacity) or {mix: {from, to, t}} (operands {variable: variable} or {literal: colour}; t is 0..1, 0 gives from and 1 to; blended per channel over the stored values, in the document's colour profile, including alpha; converting the profile keeps a mix's ends exact and moves its colours in between slightly), each alone in its object",
|
||||
true,
|
||||
var_create
|
||||
),
|
||||
spec!(
|
||||
"variable.set",
|
||||
"Set variable value",
|
||||
"{variable, mode? (name or id; default first), value | {alias: variable} (same type, no loops)}",
|
||||
"{variable, mode? (name or id; default first), value | {alias: variable} (same type, no loops) | {alpha} | {mix} (colour variables; shapes as in variable.create; no loops)}",
|
||||
true,
|
||||
var_set
|
||||
),
|
||||
spec!(
|
||||
"variable.delete",
|
||||
"Delete variable",
|
||||
"{variable} (bound layers keep their values; variables aliasing it take its values)",
|
||||
"{variable} (bound layers keep their values; variables aliasing it take its values; colour calculations reading it keep the colour they show)",
|
||||
true,
|
||||
|s, p| {
|
||||
let v = req_string("variable.delete", p, "variable")?;
|
||||
@@ -881,18 +881,31 @@ pub fn set_mode(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
fn var_value(kind: VariableType, v: &Value) -> Option<VariableValue> {
|
||||
if let Some(a) = v.get("alias").and_then(Value::as_str) {
|
||||
return Some(VariableValue::Alias(a.to_string()));
|
||||
/// A `value`/`values` entry of `kind`: a plain value of that type, `{alias: variable}`, or (colour
|
||||
/// variables) an `{alpha}`/`{mix}` calculation. An alias stays a name here — the caller binds it to
|
||||
/// an id; a calculation binds its own operands as they are checked. `for_id` is the variable being
|
||||
/// set when it already exists (a calculation may not lead back to it).
|
||||
fn var_value(cmd: &str, vars: &Variables, kind: VariableType, for_id: Option<&VariableId>, v: &Value) -> Result<VariableValue> {
|
||||
// Calculations first: they refuse sibling keys, so `{alias, alpha}` is an error rather than
|
||||
// quietly one of the two.
|
||||
if v.get("alpha").is_some() || v.get("mix").is_some() {
|
||||
return super::variables::colour_calc(cmd, vars, kind, for_id, v);
|
||||
}
|
||||
Some(match kind {
|
||||
VariableType::Color => VariableValue::Color(color_of(v)?),
|
||||
VariableType::Number => VariableValue::Number(f64_of(v)?),
|
||||
if let Some(a) = v.get("alias").and_then(Value::as_str) {
|
||||
return Ok(VariableValue::Alias(a.to_string()));
|
||||
}
|
||||
Ok(match kind {
|
||||
VariableType::Color => VariableValue::Color(color_of(v).ok_or_else(|| bad(cmd, "invalid value"))?),
|
||||
VariableType::Number => VariableValue::Number(f64_of(v).ok_or_else(|| bad(cmd, "invalid value"))?),
|
||||
VariableType::String => VariableValue::String(v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())),
|
||||
VariableType::Boolean => VariableValue::Boolean(v.as_bool()?),
|
||||
VariableType::Boolean => VariableValue::Boolean(v.as_bool().ok_or_else(|| bad(cmd, "invalid value"))?),
|
||||
})
|
||||
}
|
||||
|
||||
/// `variable.create`: a variable with a value for every mode of its collection (`values` by mode,
|
||||
/// then `value` for the modes left, then its type's default). Every value is checked before the
|
||||
/// variable exists (alias targets, calculation operands, the 32-variable limit), so a refused one
|
||||
/// leaves nothing behind.
|
||||
fn var_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "variable.create";
|
||||
let c = req_string(cmd, p, "collection")?;
|
||||
@@ -915,11 +928,11 @@ fn var_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.find(|x| x.id == *mode || x.name == *mode)
|
||||
.map(|x| x.id.clone())
|
||||
.ok_or_else(|| bad(cmd, format!("no mode `{mode}`")))?;
|
||||
values.insert(mid, var_value(kind, v).ok_or_else(|| bad(cmd, "invalid value"))?);
|
||||
values.insert(mid, var_value(cmd, &doc.variables, kind, None, v)?);
|
||||
}
|
||||
}
|
||||
if let Some(v) = get(p, "value") {
|
||||
let val = var_value(kind, v).ok_or_else(|| bad(cmd, "invalid value"))?;
|
||||
let val = var_value(cmd, &doc.variables, kind, None, v)?;
|
||||
for m in &coll.modes {
|
||||
values.entry(m.id.clone()).or_insert_with(|| val.clone());
|
||||
}
|
||||
@@ -936,6 +949,10 @@ fn var_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
*a = t.id.clone();
|
||||
}
|
||||
}
|
||||
// The new variable has no id yet and nothing reads it, so any stand-in id will do.
|
||||
for (m, val) in &values {
|
||||
super::variables::check_resolves(cmd, &doc.variables, "", &coll.id, m, val)?;
|
||||
}
|
||||
let id = doc.new_variable_id();
|
||||
doc.variables.variables.push(Variable {
|
||||
id: id.clone(),
|
||||
@@ -953,20 +970,26 @@ fn var_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"id": id}))
|
||||
}
|
||||
|
||||
/// `variable.set`: one mode's value (the default mode when none is named), checked against the
|
||||
/// variable as it stands before anything changes: an alias or calculation may not lead back to it,
|
||||
/// and a calculation must fit the 32-variable limit. On a variable an extended collection inherits,
|
||||
/// the value becomes that mode's override.
|
||||
pub fn var_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "variable.set";
|
||||
let v = req_string(cmd, p, "variable")?;
|
||||
let doc = s.doc_mut()?;
|
||||
let var = doc.variables.get(&v).cloned().ok_or_else(|| bad(cmd, "no such variable"))?;
|
||||
let coll = doc.variables.collection(&var.collection).cloned().ok_or_else(|| bad(cmd, "variable has no collection"))?;
|
||||
let coll = doc.variables.collection_by_id(&var.collection).cloned().ok_or_else(|| bad(cmd, "variable has no collection"))?;
|
||||
let mode = match string(p, "mode") {
|
||||
Some(m) => coll.modes.iter().find(|x| x.id == m || x.name == m).map(|x| x.id.clone()).ok_or_else(|| bad(cmd, "no such mode"))?,
|
||||
Some(m) => coll.find_mode(&m).map(|x| x.id.clone()).ok_or_else(|| bad(cmd, "no such mode"))?,
|
||||
None => coll.default_mode().cloned().ok_or_else(|| bad(cmd, "collection has no modes"))?,
|
||||
};
|
||||
let val = match get(p, "value").and_then(|x| var_value(var.kind, x)).ok_or_else(|| bad(cmd, "invalid value"))? {
|
||||
let raw = get(p, "value").ok_or_else(|| bad(cmd, "invalid value"))?;
|
||||
let val = match var_value(cmd, &doc.variables, var.kind, Some(&var.id), raw)? {
|
||||
VariableValue::Alias(a) => VariableValue::Alias(super::variables::alias_target(&doc.variables, &var, &a).map_err(|e| bad(cmd, e))?),
|
||||
v => v,
|
||||
};
|
||||
super::variables::check_resolves(cmd, &doc.variables, &var.id, &var.collection, &mode, &val)?;
|
||||
if let Some(slot) = doc.variables.variables.iter_mut().find(|x| x.id == var.id) {
|
||||
// An inherited variable's value of its own overrides the inherited one in that mode.
|
||||
if slot.extends.is_some() {
|
||||
@@ -1206,7 +1229,8 @@ fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::B
|
||||
VariableValue::Number(x) => json!(x),
|
||||
VariableValue::String(x) => json!(x),
|
||||
VariableValue::Boolean(b) => json!(b),
|
||||
VariableValue::Alias(_) => continue,
|
||||
// Resolved values are plain, so these never occur here.
|
||||
VariableValue::Alias(_) | VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. } => continue,
|
||||
};
|
||||
sets.insert(field, jv);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use figcraft_doc::{
|
||||
CODE_PLATFORMS, Collection, CollectionId, Document, ModeId, NodeId, NodeKind, Variable, VariableId, VariableType, VariableValue, Variables,
|
||||
normalize_scopes, scope_known,
|
||||
CODE_PLATFORMS, Collection, CollectionId, ColorOperand, Document, ModeId, NodeId, NodeKind, Variable, VariableId, VariableType, VariableValue,
|
||||
Variables, normalize_scopes, scope_known,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -32,7 +32,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"variable.deleteCollection",
|
||||
"Delete collection",
|
||||
"{collection} (with its variables; bound layers keep their values)",
|
||||
"{collection} (with its variables; bound layers keep their values; variables elsewhere aliasing them take their values and colour calculations reading them keep the colour they show)",
|
||||
true,
|
||||
delete_collection
|
||||
),
|
||||
@@ -89,7 +89,7 @@ fn inherited(cmd: &str, what: &str) -> crate::EngineError {
|
||||
|
||||
/// Refuse mode edits of extended collections (they have the modes of the collection they extend).
|
||||
pub(crate) fn check_modes_editable(doc: &Document, cmd: &str, collection: &str) -> Result<()> {
|
||||
match doc.variables.collection(collection) {
|
||||
match doc.variables.collection_by_id(collection) {
|
||||
Some(c) if c.extends.is_some() => Err(inherited(cmd, "an extended collection's modes")),
|
||||
_ => Ok(()),
|
||||
}
|
||||
@@ -296,17 +296,21 @@ fn delete_collection(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// Delete variables `gone`: layers bound to them keep the values they show but no longer follow
|
||||
/// them, and variables aliasing them take their values (per mode: the aliased variable's value in
|
||||
/// that mode when it's in the same collection, else in its default mode).
|
||||
/// them; a variable aliasing one takes its value and a colour calculation reading one keeps the
|
||||
/// colour it shows (per mode: what the reader resolves to in that mode, other collections in their
|
||||
/// default modes).
|
||||
pub(crate) fn forget_variables(doc: &mut Document, gone: &BTreeSet<VariableId>) -> Result<()> {
|
||||
// Every reader is resolved before anything is written, so a reader of a reader resolves
|
||||
// through the values as they were.
|
||||
let vars = &doc.variables;
|
||||
let mut detached: Vec<(VariableId, ModeId, VariableValue)> = Vec::new();
|
||||
for v in vars.variables.iter().filter(|v| !gone.contains(&v.id)) {
|
||||
for (m, val) in &v.values {
|
||||
let VariableValue::Alias(a) = val else { continue };
|
||||
let Some(t) = vars.get(a).filter(|t| gone.contains(&t.id)) else { continue };
|
||||
let modes = std::collections::BTreeMap::from([(t.collection.clone(), m.clone())]);
|
||||
if let Some(r) = vars.resolve(&t.id, &modes) {
|
||||
if !val.references().iter().any(|r| gone.contains(*r)) {
|
||||
continue;
|
||||
}
|
||||
let modes = std::collections::BTreeMap::from([(v.collection.clone(), m.clone())]);
|
||||
if let Some(r) = vars.resolve(&v.id, &modes) {
|
||||
detached.push((v.id.clone(), m.clone(), r));
|
||||
}
|
||||
}
|
||||
@@ -333,11 +337,11 @@ fn reset_override(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = req_string(cmd, p, "variable")?;
|
||||
let doc = s.doc_mut()?;
|
||||
let var = doc.variables.get(&v).cloned().ok_or_else(|| bad(cmd, "no such variable"))?;
|
||||
let parent = var.extends.as_deref().and_then(|x| doc.variables.get(x)).cloned();
|
||||
let parent = var.extends.as_deref().and_then(|x| doc.variables.get_by_id(x)).cloned();
|
||||
let parent = parent.ok_or_else(|| bad(cmd, "only variables an extended collection inherits have overrides"))?;
|
||||
let modes: Vec<ModeId> = match string(p, "mode") {
|
||||
Some(m) => {
|
||||
let coll = doc.variables.collection(&var.collection).ok_or_else(|| bad(cmd, "variable has no collection"))?;
|
||||
let coll = doc.variables.collection_by_id(&var.collection).ok_or_else(|| bad(cmd, "variable has no collection"))?;
|
||||
vec![coll.find_mode(&m).ok_or_else(|| bad(cmd, "no such mode"))?.id.clone()]
|
||||
}
|
||||
None => var.overrides.iter().cloned().collect(),
|
||||
@@ -429,31 +433,151 @@ pub fn sync_extensions(doc: &mut Document) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a variable can't be read by an alias or a calculation.
|
||||
enum TargetError {
|
||||
Missing,
|
||||
Kind(VariableType),
|
||||
Loop,
|
||||
}
|
||||
|
||||
/// The id of `target` (the variable a value would read, None when there is none) as something a
|
||||
/// value may read: it is of `kind` and doesn't lead back to `from` (the variable being set, once it
|
||||
/// exists).
|
||||
fn reference_target(
|
||||
vars: &Variables,
|
||||
target: Option<&Variable>,
|
||||
kind: VariableType,
|
||||
from: Option<&VariableId>,
|
||||
) -> std::result::Result<VariableId, TargetError> {
|
||||
let t = target.ok_or(TargetError::Missing)?;
|
||||
if t.kind != kind {
|
||||
return Err(TargetError::Kind(t.kind));
|
||||
}
|
||||
if reaches_back(vars, &t.id, from) {
|
||||
return Err(TargetError::Loop);
|
||||
}
|
||||
Ok(t.id.clone())
|
||||
}
|
||||
|
||||
/// Check that `var` may alias `target` (id or name): it exists, has the same type and doesn't
|
||||
/// lead back to `var`. Returns the target's id.
|
||||
pub fn alias_target(vars: &Variables, var: &Variable, target: &str) -> std::result::Result<VariableId, String> {
|
||||
let t = vars.get(target).ok_or_else(|| format!("no variable `{target}` to alias"))?;
|
||||
if t.kind != var.kind {
|
||||
return Err(format!("can't alias a {} variable to a {} one", var.kind.id(), t.kind.id()));
|
||||
}
|
||||
let mut stack = vec![t.id.clone()];
|
||||
alias_target_by_id(vars, var, &t.id)
|
||||
}
|
||||
|
||||
/// Check an alias whose target is already a stored or remapped id, without matching names.
|
||||
pub(crate) fn alias_target_by_id(vars: &Variables, var: &Variable, target: &str) -> std::result::Result<VariableId, String> {
|
||||
reference_target(vars, vars.get_by_id(target), var.kind, Some(&var.id)).map_err(|e| match e {
|
||||
TargetError::Missing => format!("no variable `{target}` to alias"),
|
||||
TargetError::Kind(k) => format!("can't alias a {} variable to a {} one", var.kind.id(), k.id()),
|
||||
TargetError::Loop => "the alias would refer back to itself".into(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether what `seed` references (aliases and colour-calculation operands, in every mode) can
|
||||
/// lead back to `only`. Bounded at 10,000 variables for hand-crafted files with bigger graphs.
|
||||
fn reaches_back(vars: &Variables, seed: &VariableId, only: Option<&VariableId>) -> bool {
|
||||
let Some(only) = only else { return false };
|
||||
let mut stack = vec![seed.clone()];
|
||||
let mut seen = BTreeSet::new();
|
||||
while let Some(cur) = stack.pop() {
|
||||
if cur == var.id {
|
||||
return Err("the alias would refer back to itself".into());
|
||||
if cur == *only {
|
||||
return true;
|
||||
}
|
||||
if !seen.insert(cur.clone()) || seen.len() > 10_000 {
|
||||
continue;
|
||||
}
|
||||
for v in vars.get(&cur).map(|x| x.values.values()).into_iter().flatten() {
|
||||
if let VariableValue::Alias(a) = v
|
||||
&& let Some(next) = vars.get(a)
|
||||
{
|
||||
stack.push(next.id.clone());
|
||||
}
|
||||
for v in vars.get_by_id(&cur).map(|x| x.values.values()).into_iter().flatten() {
|
||||
stack.extend(v.references().into_iter().cloned());
|
||||
}
|
||||
}
|
||||
Ok(t.id.clone())
|
||||
false
|
||||
}
|
||||
|
||||
/// `{"alpha": …}` / `{"mix": …}` values: a colour derived from what it reads, checked the moment it
|
||||
/// is set (unknown operand, non-colour operand, bad parameter, a reference back to the variable
|
||||
/// being defined). Names bind to ids here, the way aliases bind at the call sites.
|
||||
pub(crate) fn colour_calc(cmd: &str, vars: &Variables, kind: VariableType, for_id: Option<&VariableId>, v: &Value) -> Result<VariableValue> {
|
||||
if kind != VariableType::Color {
|
||||
return Err(bad(cmd, "colour calculations only fit colour variables"));
|
||||
}
|
||||
// One operation per value: a second key beside it (another operation, `alias`, a typo) would
|
||||
// otherwise be dropped without a word.
|
||||
let obj = v.as_object().filter(|o| o.len() == 1).ok_or_else(|| bad(cmd, "a colour calculation is {alpha: …} or {mix: …} on its own"))?;
|
||||
match obj.get("alpha") {
|
||||
Some(a) => {
|
||||
// Only these two keys: an extra one (a typo beside `opacity`, an option this doesn't take)
|
||||
// must not be dropped without a word.
|
||||
let a = a
|
||||
.as_object()
|
||||
.filter(|a| a.keys().all(|k| k == "variable" || k == "opacity"))
|
||||
.ok_or_else(|| bad(cmd, "alpha: {variable, opacity}"))?;
|
||||
let name = a.get("variable").and_then(Value::as_str).ok_or_else(|| bad(cmd, "alpha needs the variable to fade"))?;
|
||||
// Parameters before operands: their errors don't depend on what the operands are.
|
||||
let opacity = unit(cmd, a, "opacity")?;
|
||||
Ok(VariableValue::ColorAlpha { variable: calc_operand(cmd, vars, for_id, name)?, opacity })
|
||||
}
|
||||
None => match obj.get("mix") {
|
||||
Some(m) => {
|
||||
let m = m
|
||||
.as_object()
|
||||
.filter(|m| m.keys().all(|k| matches!(k.as_str(), "from" | "to" | "t")))
|
||||
.ok_or_else(|| bad(cmd, "mix: {from, to, t}"))?;
|
||||
let t = unit(cmd, m, "t")?;
|
||||
Ok(VariableValue::ColorMix {
|
||||
from: mix_operand(cmd, vars, for_id, m.get("from"))?,
|
||||
to: mix_operand(cmd, vars, for_id, m.get("to"))?,
|
||||
t,
|
||||
})
|
||||
}
|
||||
None => Err(bad(cmd, "not a colour calculation")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// A calculation whose operands all exist can still read more variables than one resolve may (a
|
||||
/// mix of two long alias chains): refused when it is set, rather than left showing nothing. It is
|
||||
/// checked in `mode` with every other collection in its default mode, the modes exports write it
|
||||
/// in; another mode of a collection it reads, or a later change to what it reads, can still take
|
||||
/// it past the limit, as an alias chain can.
|
||||
pub(crate) fn check_resolves(cmd: &str, vars: &Variables, id: &str, collection: &str, mode: &str, val: &VariableValue) -> Result<()> {
|
||||
let calc = matches!(val, VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. });
|
||||
if calc && vars.flattened_as(id, collection, mode, val).is_none() {
|
||||
return Err(bad(cmd, format!("the colour calculation reads more than {} variables", Variables::RESOLVE_LIMIT)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `obj[key]` as a number from 0 to 1. Strict, unlike `fraction_of` (which reads numbers past 1 as
|
||||
/// percentages): a mistyped 1.5 is refused instead of being stored as 1.5%. -0.0 is stored as 0.0
|
||||
/// so saved files read cleanly.
|
||||
fn unit(cmd: &str, obj: &serde_json::Map<String, Value>, key: &str) -> Result<f32> {
|
||||
let x = obj.get(key).and_then(Value::as_f64).filter(|x| (0.0..=1.0).contains(x));
|
||||
let x = x.ok_or_else(|| bad(cmd, format!("`{key}` must be a number from 0 to 1")))?;
|
||||
Ok(if x == 0.0 { 0.0 } else { x as f32 })
|
||||
}
|
||||
|
||||
/// A calculation operand given by name: an existing colour variable, never one that leads back to
|
||||
/// the variable being set. Returns its id.
|
||||
fn calc_operand(cmd: &str, vars: &Variables, for_id: Option<&VariableId>, name: &str) -> Result<VariableId> {
|
||||
reference_target(vars, vars.get(name), VariableType::Color, for_id).map_err(|e| match e {
|
||||
TargetError::Missing => bad(cmd, format!("no variable `{name}` to calculate from")),
|
||||
TargetError::Kind(k) => bad(cmd, format!("colour calculations take colour variables: `{name}` is a {} one", k.id())),
|
||||
TargetError::Loop => bad(cmd, "the colour calculation would refer back to itself"),
|
||||
})
|
||||
}
|
||||
|
||||
/// A mix operand: `{variable: name-or-id}` or `{literal: colour}` — a bare string is a colour name
|
||||
/// as easily as a variable name, so the kind is always spelled out.
|
||||
fn mix_operand(cmd: &str, vars: &Variables, for_id: Option<&VariableId>, v: Option<&Value>) -> Result<ColorOperand> {
|
||||
let shape = || bad(cmd, "mix operands are {variable: …} or {literal: colour}");
|
||||
let o = v.and_then(Value::as_object).filter(|o| o.len() == 1).ok_or_else(shape)?;
|
||||
if let Some(name) = o.get("variable").and_then(Value::as_str) {
|
||||
return Ok(ColorOperand::Variable { variable: calc_operand(cmd, vars, for_id, name)? });
|
||||
}
|
||||
let literal = crate::params::color_of(o.get("literal").ok_or_else(shape)?).ok_or_else(|| bad(cmd, "mix literal must be a colour"))?;
|
||||
Ok(ColorOperand::Literal { literal })
|
||||
}
|
||||
|
||||
/// The variable scope of bindable property `field` on `node` (None: any variable of the type fits).
|
||||
|
||||
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"variable.export",
|
||||
"Export variables",
|
||||
"{collections? [ids or names; default every collection of this file, not library copies], format? (json (default): the local variables JSON that variable.import reads | css: custom properties, the default mode on :root and each other mode on [data-<collection>=\"<mode>\"]; numbers in px unless layers or scopes use them as opacity or font weight | tailwind: a Tailwind CSS v4 stylesheet, the default mode as an @theme block named the way inspect.code's tailwind utilities read them: --color-*, --radius-*, --text-*, --spacing-*, --font-*, unique per file) | dart: a Dart file for Flutter, a class of constants per collection and mode (Color, double, String, bool), aliases resolved), path? (write it there)} → {text}, or {path} when written",
|
||||
"{collections? [ids or names; default every collection of this file, not library copies], format? (json (default): the local variables JSON that variable.import reads, colour calculations as the colour they show in each mode with other collections in their default modes | css: custom properties, the default mode on :root and each other mode on [data-<collection>=\"<mode>\"], colour calculations as the colour they show in that mode with other collections in their default modes; numbers in px unless layers or scopes use them as opacity or font weight | tailwind: a Tailwind CSS v4 stylesheet, the default mode as an @theme block named the way inspect.code's tailwind utilities read them: --color-*, --radius-*, --text-*, --spacing-*, --font-*, unique per file; each other mode as in css; colour calculations as the colour they show in that mode, other collections in their default modes) | dart: a Dart file for Flutter, a class of constants per collection and mode (Color, double, String, bool), aliases and colour calculations resolved), path? (write it there)} → {text}, or {path} when written",
|
||||
false,
|
||||
export
|
||||
),
|
||||
@@ -67,9 +67,10 @@ fn css_safe(text: &str) -> String {
|
||||
|
||||
/// Each collection's variables as custom properties (named as generated code names them): the
|
||||
/// default mode's values on `:root`, every other mode's on `[data-<collection>="<mode>"]`;
|
||||
/// aliases are `var(--name)`.
|
||||
/// aliases are `var(--name)` and colour calculations resolve to their colour in the mode.
|
||||
fn css(doc: &Document, colls: &[&Collection], tailwind: bool) -> String {
|
||||
let vars = &doc.variables;
|
||||
let flattener = vars.flattener();
|
||||
// Tailwind theme names, unique per file and the same ones page code reads.
|
||||
let theme = crate::tailwind::Theme::new(doc);
|
||||
let name = |v: &Variable| if tailwind { theme.name(v) } else { css_var_of(v) };
|
||||
@@ -89,17 +90,21 @@ fn css(doc: &Document, colls: &[&Collection], tailwind: bool) -> String {
|
||||
};
|
||||
out.push_str(&format!("/* {} · {} */\n{sel} {{\n", css_safe(&c.name), css_safe(&m.name)));
|
||||
for v in vars.variables.iter().filter(|v| v.collection == c.id) {
|
||||
let value = match v.values.get(&m.id) {
|
||||
// A calculation is written as the colour it resolves to in this mode (no color-mix()
|
||||
// or relative-colour output), and so is an alias to one some mode leaves out:
|
||||
// `var()` of a property no block defines would invalidate whatever reads it.
|
||||
let flat = v.values.get(&m.id).and_then(|x| flattener.value(v, &m.id, x));
|
||||
let value = match flat.as_ref() {
|
||||
Some(VariableValue::Color(col)) => col.to_css(),
|
||||
Some(VariableValue::Number(x)) if roles.role(v) == crate::token_roles::NumberRole::Unitless => trim_num(*x),
|
||||
Some(VariableValue::Number(x)) => format!("{}px", trim_num(*x)),
|
||||
Some(VariableValue::String(t)) => format!("\"{}\"", css_safe(t)),
|
||||
Some(VariableValue::Boolean(b)) => b.to_string(),
|
||||
Some(VariableValue::Alias(a)) => match vars.get(a) {
|
||||
Some(VariableValue::Alias(a)) => match vars.get_by_id(a) {
|
||||
Some(t) => format!("var({})", name(t)),
|
||||
None => continue,
|
||||
},
|
||||
None => continue,
|
||||
Some(VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. }) | None => continue,
|
||||
};
|
||||
out.push_str(&format!(" {}: {value};\n", name(v)));
|
||||
}
|
||||
@@ -330,7 +335,7 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
VariableValue::Alias(a) => {
|
||||
let Some(var) = doc.variables.variables.iter().find(|x| x.id == *local) else { continue };
|
||||
let target = ids.get(a).ok_or_else(|| "its alias target wasn't imported".to_string());
|
||||
match target.and_then(|t| super::variables::alias_target(&doc.variables, var, t)) {
|
||||
match target.and_then(|t| super::variables::alias_target_by_id(&doc.variables, var, t)) {
|
||||
Ok(t) => VariableValue::Alias(t),
|
||||
Err(e) => {
|
||||
warnings.push(format!("`{}`: {e}", v.name));
|
||||
|
||||
@@ -372,7 +372,7 @@ pub fn bound_variable<'d>(doc: &'d Document, n: &Node, field: &str) -> Option<&'
|
||||
_ => &[],
|
||||
};
|
||||
let found = if forms.is_empty() { n.bound.get(field) } else { forms.iter().find_map(|k| n.bound.get(*k)) };
|
||||
found.and_then(|v| doc.variables.get(v))
|
||||
found.and_then(|v| doc.variables.get_by_id(v))
|
||||
}
|
||||
|
||||
/// The variable bound to `field` on `n`, by name.
|
||||
|
||||
@@ -58,6 +58,25 @@ fn properties_group_layout_auto_layout_paints_and_tokens() {
|
||||
assert!(s.execute("inspect.properties", &json!({"id": 999_999})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_keep_their_dev_inspect_token() {
|
||||
let mut s = Session::new();
|
||||
s.execute("variable.createCollection", &json!({"name": "Theme"})).unwrap();
|
||||
let shadow = s.execute("variable.create", &json!({"collection": "Theme", "name": "shadow", "type": "color", "value": "#FF0000"})).unwrap();
|
||||
let surface = s.execute("variable.create", &json!({"collection": "Theme", "name": "surface", "type": "color", "value": "#0D99FF"})).unwrap();
|
||||
let r = id(s.execute("node.create", &json!({"type": "rectangle", "props": {"fill": {"$var": surface["id"]}}})).unwrap());
|
||||
s.execute("variable.update", &json!({"variable": "surface", "codeSyntax": {"WEB": "--real-surface"}})).unwrap();
|
||||
s.execute("variable.update", &json!({"variable": shadow["id"], "name": surface["id"], "codeSyntax": {"WEB": "--shadow"}})).unwrap();
|
||||
|
||||
let code = s.execute("inspect.code", &json!({"id": r, "language": "css"})).unwrap();
|
||||
assert!(code["code"].as_str().unwrap().contains("var(--real-surface"));
|
||||
assert!(!code["code"].as_str().unwrap().contains("--shadow"));
|
||||
let out = s.execute("inspect.properties", &json!({"id": r})).unwrap();
|
||||
let fills = out["sections"].as_array().unwrap().iter().find(|s| s["title"] == "Fills").unwrap();
|
||||
assert_eq!(fills["rows"][0]["color"], "#0D99FF");
|
||||
assert_eq!(fills["rows"][0]["token"], "surface");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typography_section_for_text() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -26,6 +26,10 @@ pub struct Interaction {
|
||||
pub label: String,
|
||||
pub doc: Arc<Document>,
|
||||
pub selection: Vec<NodeId>,
|
||||
/// The gesture so far only moved the selected layers by this much (page units) from `doc`:
|
||||
/// their outlines, box and everything else that doesn't depend on where they are can follow
|
||||
/// by shifting what was worked out from `doc` (#1571). `None` for any other change.
|
||||
pub moved_by: Option<(f64, f64)>,
|
||||
}
|
||||
|
||||
/// The text being edited on the canvas.
|
||||
|
||||
@@ -324,11 +324,7 @@ pub(crate) fn installed<'a>(s: &'a Session, cmd: &str, id: &str) -> Result<&'a I
|
||||
/// Read a file of at most `max` bytes.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn read_file(path: &std::path::Path, max: usize) -> Result<Vec<u8>> {
|
||||
let meta = crate::file_access::metadata(path)?;
|
||||
if !meta.is_file() || meta.len() > max as u64 {
|
||||
return Err(EngineError::Other(format!("{}: not a file of at most {} KB", path.display(), max / 1024)));
|
||||
}
|
||||
crate::file_access::read(path)
|
||||
crate::file_access::read_capped(path, max as u64)
|
||||
}
|
||||
|
||||
/// The manifest at `path` (the file, or the folder holding `figcraft-plugin.json`), its absolute
|
||||
|
||||
@@ -16,7 +16,7 @@ use figcraft_color::{ImageScaleMode, Paint};
|
||||
use figcraft_doc::{Document, Node, NodeId, NodeKind, TextData};
|
||||
use figcraft_geom::{Affine, Point};
|
||||
use figcraft_plugin::widget::{MAX_IMAGE_BYTES, MAX_STATE_BYTES, check_menu, check_state, clean_field};
|
||||
use figcraft_plugin::{Instance, Manifest, RunOutput, WidgetKind, WidgetNode, WidgetRender};
|
||||
use figcraft_plugin::{Instance, Manifest, RunOutput, WidgetHover, WidgetKind, WidgetNode, WidgetRender};
|
||||
pub use figcraft_plugin::{MenuItem, MenuKind};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
@@ -36,6 +36,8 @@ const MENU: &str = "figcraft.widget.menu";
|
||||
const TARGET: &str = "figcraft.widget.target";
|
||||
/// On a text layer inside a widget: the name of the editable field it is.
|
||||
const FIELD: &str = "figcraft.widget.field";
|
||||
/// On a widget or a layer inside one: how it looks while the pointer is over it (JSON).
|
||||
const HOVER: &str = "figcraft.widget.hover";
|
||||
/// Images in a widget are at most this many pixels on a side.
|
||||
const MAX_IMAGE_SIDE: u32 = 4096;
|
||||
|
||||
@@ -79,6 +81,54 @@ pub fn field_of(doc: &Document, id: NodeId) -> Option<(NodeId, String)> {
|
||||
Some((widget_of(doc, id)?, field))
|
||||
}
|
||||
|
||||
/// The layers of a widget under page point `p` that change while the pointer is over them (the
|
||||
/// widget itself among them), given the layer the pointer hits, outermost first.
|
||||
pub fn hovered_at(doc: &Document, hit: NodeId, p: Point) -> Vec<NodeId> {
|
||||
let Some(w) = widget_of(doc, hit) else { return Vec::new() };
|
||||
doc.subtree(w)
|
||||
.into_iter()
|
||||
.filter(|i| doc.node(*i).is_some_and(|n| n.visible && n.plugin_data.contains_key(HOVER)))
|
||||
.filter(|i| doc.world_bounds(*i).is_some_and(|b| b.contains(p)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// What layer `n` sets while the pointer is over it, as `node.set` properties (none when it has no
|
||||
/// usable hover style: the layer data may come from a file).
|
||||
fn hover_props(n: &Node) -> Option<Map<String, Value>> {
|
||||
let text = n.plugin_data.get(HOVER).filter(|s| s.len() <= MAX_STATE_BYTES)?;
|
||||
let mut h: WidgetHover = serde_json::from_str(text).ok()?;
|
||||
h.check(if matches!(n.kind, NodeKind::Text(_)) { WidgetKind::Text } else { WidgetKind::Rectangle }).ok()?;
|
||||
let mut p = Map::new();
|
||||
if let Some(c) = h.fill.or(h.color) {
|
||||
p.insert("fills".into(), json!([c]));
|
||||
}
|
||||
if let Some(c) = h.stroke {
|
||||
p.insert("strokes".into(), json!([c]));
|
||||
}
|
||||
if let Some(o) = h.opacity {
|
||||
p.insert("opacity".into(), json!(o));
|
||||
}
|
||||
Some(p)
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Show the hover styles of widget layers `ids` as a live preview (none: end the one showing):
|
||||
/// nothing is undone, saved or sent to a live session, and the next command puts the document
|
||||
/// back first. Layers without a hover style are left as they are.
|
||||
pub fn widget_hover(&mut self, ids: &[NodeId]) -> Result<bool> {
|
||||
let cmd = "widget.hover";
|
||||
crate::textpreview::end(self);
|
||||
let doc = self.doc()?;
|
||||
let changes: Vec<(NodeId, Map<String, Value>)> =
|
||||
ids.iter().filter(|i| widget_of(doc, **i).is_some()).filter_map(|i| Some((*i, hover_props(doc.node(*i)?)?))).collect();
|
||||
if changes.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
crate::textpreview::show_change(self, cmd, |doc| changes.iter().try_for_each(|(id, p)| crate::cmd::props::apply(cmd, doc, *id, p)))?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget `w`'s property menu (empty when it has none or it's unusable: it may come from a file).
|
||||
pub fn menu_of(doc: &Document, w: NodeId) -> Vec<MenuItem> {
|
||||
doc.node(w)
|
||||
@@ -172,6 +222,7 @@ fn build(s: &mut Session, plugin: &str, w: NodeId, r: &WidgetRender) -> Result<(
|
||||
Ok(menu) if !r.menu.is_empty() => n.plugin_data.insert(MENU.into(), menu),
|
||||
_ => n.plugin_data.remove(MENU),
|
||||
};
|
||||
set_hover(n, r.root.hover.as_ref());
|
||||
for c in &r.root.children {
|
||||
add(doc, w, c, cmd)?;
|
||||
}
|
||||
@@ -199,6 +250,7 @@ fn add(doc: &mut Document, parent: NodeId, d: &WidgetNode, cmd: &str) -> Result<
|
||||
if !d.field.is_empty() {
|
||||
n.plugin_data.insert(FIELD.into(), d.field.clone());
|
||||
}
|
||||
set_hover(&mut n, d.hover.as_ref());
|
||||
if d.kind == WidgetKind::Image {
|
||||
use base64::Engine as _;
|
||||
let bytes = base64::engine::general_purpose::STANDARD.decode(d.data.trim()).map_err(|e| bad(cmd, format!("an image's `data`: {e}")))?;
|
||||
@@ -218,6 +270,14 @@ fn add(doc: &mut Document, parent: NodeId, d: &WidgetNode, cmd: &str) -> Result<
|
||||
d.children.iter().try_for_each(|c| add(doc, id, c, cmd))
|
||||
}
|
||||
|
||||
/// Keep layer `n`'s hover style (validated already) with it, or drop the one it had.
|
||||
fn set_hover(n: &mut Node, hover: Option<&WidgetHover>) {
|
||||
match hover.and_then(|h| serde_json::to_string(h).ok()) {
|
||||
Some(h) => n.plugin_data.insert(HOVER.into(), h),
|
||||
None => n.plugin_data.remove(HOVER),
|
||||
};
|
||||
}
|
||||
|
||||
/// `node.set` properties for described layer `d` (validated already); `root`: the widget itself,
|
||||
/// which keeps its place and size unless the description gives one.
|
||||
fn props(d: &WidgetNode, root: bool) -> Map<String, Value> {
|
||||
@@ -462,6 +522,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
false,
|
||||
menu
|
||||
),
|
||||
spec!(
|
||||
"widget.hover",
|
||||
"Hover widget",
|
||||
"{ids? [layers of widgets]} shows their hover styles, as the canvas does while the pointer is over a widget: a live preview without an undo step, which the next command (or no ids) ends → {hovering}",
|
||||
false,
|
||||
|s, p| {
|
||||
let ids: Vec<NodeId> =
|
||||
p.get("ids").and_then(Value::as_array).map(|a| a.iter().filter_map(crate::params::id_of).collect()).unwrap_or_default();
|
||||
Ok(json!({"hovering": s.widget_hover(&ids)?}))
|
||||
}
|
||||
),
|
||||
spec!("widget.list", "List widgets", "{} → {widgets: [{id, plugin, name, page, state, targets, fields, menu}]}", false, list),
|
||||
]
|
||||
}
|
||||
@@ -206,3 +206,26 @@ fn nested_instances_of_variants_link_to_their_mains_layers() {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert_eq!(outline(s.doc().unwrap()), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_keep_exported_binding_identity() {
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "variable.createCollection", json!({"name": "Theme"}));
|
||||
let shadow = run(&mut s, "variable.create", json!({"collection": "Theme", "name": "shadow", "type": "color", "value": "#FF0000"}));
|
||||
let surface = run(&mut s, "variable.create", json!({"collection": "Theme", "name": "surface", "type": "color", "value": "#0D99FF"}));
|
||||
let r = id(&run(&mut s, "node.create", json!({"type": "rectangle", "props": {"name": "Bound", "fill": {"$var": surface["id"]}}})));
|
||||
run(&mut s, "select.set", json!({"ids": [r]}));
|
||||
run(&mut s, "style.create", json!({"type": "paint", "name": "Surface"}));
|
||||
run(&mut s, "node.set", json!({"id": r, "props": {"name": "Styled"}}));
|
||||
run(&mut s, "node.create", json!({"type": "rectangle", "props": {"name": "Bound", "fill": {"$var": surface["id"]}}}));
|
||||
run(&mut s, "variable.update", json!({"variable": shadow["id"], "name": surface["id"]}));
|
||||
let (bytes, _) = figcraft_format::fig::export(s.doc().unwrap(), None).unwrap();
|
||||
let (back, _) = figcraft_format::fig::import(&bytes, "identity").unwrap();
|
||||
let bound = back.nodes().find(|n| n.name == "Bound").unwrap().bound.values().next().unwrap();
|
||||
assert_eq!(back.variables.get_by_id(bound).unwrap().name, "surface");
|
||||
let style = back.styles.iter().find(|s| s.name == "Surface").unwrap();
|
||||
assert!(!style.bound.is_empty());
|
||||
for bound in style.bound.values() {
|
||||
assert_eq!(back.variables.get_by_id(bound).unwrap().name, "surface");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! A `.fig` file whose text names an installed family another way than the font list does (#1292)
|
||||
//! isn't reported missing and draws in that family: by the legacy name of one of its weights
|
||||
//! ("<Family> Bold", style Regular) or spelt with other case and spaces. Through the real import
|
||||
//! (a file the app exported), with an embedded family standing in for an installed one.
|
||||
|
||||
use base64::Engine as _;
|
||||
use figcraft_doc::NodeKind;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn text(s: &mut Session, family: &str) {
|
||||
let id = s.execute("text.create", &json!({"text": "Hamburgefonstiv", "x": 0, "y": 0})).unwrap()["id"].as_u64().unwrap();
|
||||
if let NodeKind::Text(t) = &mut s.doc_mut().unwrap().get_mut(figcraft_doc::NodeId(id)).unwrap().kind {
|
||||
t.style.font_family = family.into();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_imported_file_finds_installed_families_by_their_other_names() {
|
||||
let Some(family) = figcraft_text::family_names().into_iter().find(|f| figcraft_text::family_styles(f).iter().any(|s| s == "Bold")) else {
|
||||
return;
|
||||
};
|
||||
let legacy = format!("{family} Bold");
|
||||
let squashed = family.to_uppercase().replace(' ', "");
|
||||
let mut s = Session::new();
|
||||
text(&mut s, &legacy);
|
||||
text(&mut s, &squashed);
|
||||
text(&mut s, "FigCraft Surely Missing");
|
||||
let r = s.execute("file.export", &json!({"format": "FIG"})).unwrap();
|
||||
let bytes = base64::engine::general_purpose::STANDARD.decode(r["data"].as_str().unwrap()).unwrap();
|
||||
let (doc, _) = crate::cmd::file::load_bytes(&bytes, "names.fig").unwrap();
|
||||
let missing: Vec<String> = crate::fonts::missing(&doc).into_iter().map(|m| m.family).collect();
|
||||
assert_eq!(missing, ["FigCraft Surely Missing"], "{family}");
|
||||
let bold = figcraft_text::find_face(&legacy, "Regular").unwrap();
|
||||
assert_eq!((bold.family.to_lowercase(), bold.weight), (family.to_lowercase(), 700));
|
||||
assert_eq!(figcraft_text::find_face(&squashed, "Bold").unwrap().family.to_lowercase(), family.to_lowercase());
|
||||
}
|
||||
@@ -111,7 +111,7 @@ fn string_values(vars: &Variables, id: &str) -> Vec<String> {
|
||||
if !seen.insert(id.clone()) {
|
||||
continue;
|
||||
}
|
||||
let Some(var) = vars.get(&id) else { continue };
|
||||
let Some(var) = vars.get_by_id(&id) else { continue };
|
||||
for v in var.values.values() {
|
||||
match v {
|
||||
VariableValue::String(s) => out.push(s.clone()),
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
//! A panic in a grouped closure, outside any command, ends the group like a failure (#1085): the
|
||||
//! documents, selection, undo and redo, history limit and journal are as they were, and the panic
|
||||
//! goes on to the caller.
|
||||
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{HistoryEntry, NodeId, Session};
|
||||
|
||||
fn rect(s: &mut Session, x: i64) -> NodeId {
|
||||
s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().map(NodeId).unwrap()
|
||||
}
|
||||
|
||||
fn labels(entries: &[HistoryEntry]) -> Vec<String> {
|
||||
entries.iter().map(|e| e.label.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grouped_closure_that_panics_leaves_the_session_as_before() {
|
||||
let mut s = Session::new();
|
||||
let first = rect(&mut s, 0);
|
||||
let second = rect(&mut s, 20);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [first]})).unwrap();
|
||||
let ds = s.ds().unwrap();
|
||||
let (doc, selection, page) = (ds.doc.clone(), ds.selection.clone(), ds.page);
|
||||
let (undo, redo, limit) = (labels(&ds.history.undo), labels(&ds.history.redo), ds.history.limit);
|
||||
assert_eq!(redo.len(), 1);
|
||||
let journal = s.journal.clone();
|
||||
let depth = s.undo_depth();
|
||||
|
||||
let unwound = catch_unwind(AssertUnwindSafe(|| {
|
||||
s.grouped("Edit then panic", |s| -> crate::Result<()> {
|
||||
s.execute("node.set", &json!({"id": first, "props": {"name": "Renamed"}}))?;
|
||||
rect(s, 40);
|
||||
s.execute("select.set", &json!({"ids": []}))?;
|
||||
panic!("a bug in the closure");
|
||||
})
|
||||
}));
|
||||
|
||||
let payload = unwound.unwrap_err();
|
||||
assert_eq!(payload.downcast_ref::<&str>(), Some(&"a bug in the closure"), "the original panic goes on to the caller");
|
||||
let ds = s.ds().unwrap();
|
||||
assert!(Arc::ptr_eq(&ds.doc, &doc));
|
||||
assert_eq!(*ds.doc, *doc);
|
||||
assert_eq!(ds.selection, selection);
|
||||
assert_eq!(ds.page, page);
|
||||
assert_eq!(labels(&ds.history.undo), undo);
|
||||
assert_eq!(labels(&ds.history.redo), redo);
|
||||
assert_eq!(ds.history.limit, limit);
|
||||
assert_eq!(s.journal, journal);
|
||||
assert_eq!(s.undo_depth(), depth);
|
||||
assert_eq!(s.group_depth, 0);
|
||||
assert!(s.closed_in_group.is_empty());
|
||||
|
||||
// The redo step survived, and the next edit pushes one step of its own.
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().contains(second));
|
||||
rect(&mut s, 60);
|
||||
assert_eq!(s.undo_depth(), (depth.0 + 2, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grouped_closure_that_panics_releases_the_live_hold_so_later_edits_sync() {
|
||||
use crate::live_tests::{push, relay, serve, session};
|
||||
|
||||
let (mut s, script) = session();
|
||||
let first = rect(&mut s, 0);
|
||||
s.execute("collab.host", &json!({})).unwrap();
|
||||
let (mut server, id, welcome) = serve(s.doc().unwrap());
|
||||
push(&script, vec![welcome]);
|
||||
s.live_poll();
|
||||
assert!(s.live_doc(s.ds().unwrap().uid));
|
||||
let before = s.doc().unwrap().clone();
|
||||
script.lock().unwrap().msgs.clear();
|
||||
|
||||
let unwound = catch_unwind(AssertUnwindSafe(|| {
|
||||
s.grouped("Rename then panic", |s| -> crate::Result<()> {
|
||||
s.execute("node.set", &json!({"id": first, "props": {"name": "Rolled back"}}))?;
|
||||
s.live_sync();
|
||||
assert!(script.lock().unwrap().msgs.is_empty(), "the edit waits for the group to end");
|
||||
panic!("a bug in the live closure");
|
||||
})
|
||||
}));
|
||||
|
||||
assert!(unwound.is_err());
|
||||
assert_eq!(s.doc().unwrap(), &before);
|
||||
s.live_sync();
|
||||
assert!(script.lock().unwrap().msgs.is_empty(), "the rolled-back edit never goes out");
|
||||
|
||||
s.execute("node.set", &json!({"id": first, "props": {"name": "Sent afterwards"}})).unwrap();
|
||||
s.live_sync();
|
||||
assert_eq!(script.lock().unwrap().msgs.len(), 1, "the later edit goes out");
|
||||
let replies = relay(&script, &mut server, id);
|
||||
push(&script, replies);
|
||||
s.live_poll();
|
||||
assert_eq!(server.document().node(first).unwrap().name, "Sent afterwards");
|
||||
assert_eq!(s.doc().unwrap(), server.document());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panic_in_an_inner_group_rolls_back_through_the_outer_one() {
|
||||
let mut s = Session::new();
|
||||
let unwound = catch_unwind(AssertUnwindSafe(|| {
|
||||
s.grouped("outer", |s| {
|
||||
s.execute("comment.add", &json!({"x": 1, "y": 2, "text": "Rolled back"}))?;
|
||||
s.grouped("inner", |s| -> crate::Result<()> {
|
||||
s.execute("comment.add", &json!({"x": 3, "y": 4, "text": "Also rolled back"}))?;
|
||||
panic!("a bug in the inner closure");
|
||||
})
|
||||
})
|
||||
}));
|
||||
|
||||
assert!(unwound.is_err());
|
||||
assert!(s.doc().unwrap().comments.is_empty());
|
||||
assert!(s.pending_events.is_empty());
|
||||
assert_eq!(s.events.latest(), 0);
|
||||
assert_eq!(s.group_depth, 0);
|
||||
assert_eq!(s.undo_depth(), (0, 0));
|
||||
|
||||
// The session still groups normally afterwards.
|
||||
s.grouped("again", |s| s.execute("comment.add", &json!({"x": 5, "y": 6, "text": "Kept"})).map(|_| ())).unwrap();
|
||||
assert_eq!(s.doc().unwrap().comments.len(), 1);
|
||||
assert_eq!(s.undo_depth(), (1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_that_closes_and_opens_tabs_then_panics_brings_the_tabs_back() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"name": "Second"})).unwrap();
|
||||
let uids: Vec<_> = s.docs.iter().map(|ds| ds.uid).collect();
|
||||
let active = s.active;
|
||||
|
||||
let unwound = catch_unwind(AssertUnwindSafe(|| {
|
||||
s.grouped("Close, open, panic", |s| -> crate::Result<()> {
|
||||
s.execute("file.close", &json!({"index": 1, "force": true}))?;
|
||||
s.execute("file.new", &json!({"name": "Temporary"}))?;
|
||||
panic!("a bug after switching tabs");
|
||||
})
|
||||
}));
|
||||
|
||||
assert!(unwound.is_err());
|
||||
assert_eq!(s.docs.iter().map(|ds| ds.uid).collect::<Vec<_>>(), uids);
|
||||
assert_eq!(s.active, active);
|
||||
assert!(s.closed_in_group.is_empty());
|
||||
assert!(s.docs.iter().all(|ds| ds.history.limit != usize::MAX));
|
||||
}
|
||||
@@ -128,7 +128,10 @@ fn an_explicit_variables_path_is_capped_like_the_sibling_file() {
|
||||
for cmd in ["file.open", "file.import"] {
|
||||
let before = state(&s);
|
||||
let e = s.execute(cmd, &json!({"path": path, "variables": bad})).unwrap_err().to_string();
|
||||
assert!(e.contains("not a file of at most 268435456 bytes"), "{cmd} {bad}: {e}");
|
||||
assert!(
|
||||
["more than the 256 MB allowed here", "is a folder, not a file", "isn't a regular file"].iter().any(|w| e.contains(w)),
|
||||
"{cmd} {bad}: {e}"
|
||||
);
|
||||
assert_eq!(state(&s), before, "{cmd} {bad}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
//! Compact inspection keeps the tree useful without building every layer property.
|
||||
|
||||
use figcraft_doc::{NodeId, NodeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::inspect::{InspectOptions, document, document_with_options, node_json, node_json_with_options};
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
||||
s.execute(command, ¶ms).unwrap_or_else(|e| panic!("{command}: {e}"))
|
||||
}
|
||||
|
||||
fn create(s: &mut Session, params: Value) -> NodeId {
|
||||
crate::params::id_of(&run(s, "node.create", params)["id"]).unwrap()
|
||||
}
|
||||
|
||||
fn tree() -> (Session, NodeId, NodeId) {
|
||||
let mut s = Session::new();
|
||||
let frame = create(&mut s, json!({"type": "frame", "name": "Card", "x": 100, "y": 200, "width": 300, "height": 200}));
|
||||
let text = create(&mut s, json!({"type": "text", "parent": frame, "name": "Title", "text": "Hello", "x": 10, "y": 20}));
|
||||
create(&mut s, json!({"type": "rectangle", "parent": frame, "name": "Hidden", "props": {"visible": false, "fill": "#123456"}}));
|
||||
(s, frame, text)
|
||||
}
|
||||
|
||||
fn node(s: &Session, id: NodeId, depth: usize, params: Value) -> Value {
|
||||
node_json_with_options(s.doc().unwrap(), id, depth, &InspectOptions::parse(¶ms).unwrap())
|
||||
}
|
||||
|
||||
fn keys(v: &Value, allowed: &[&str]) {
|
||||
for (key, value) in v.as_object().unwrap() {
|
||||
assert!(allowed.contains(&key.as_str()), "unexpected {key}: {v}");
|
||||
if key == "children" {
|
||||
for child in value.as_array().unwrap() {
|
||||
keys(child, allowed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn golden_tree() -> (Session, NodeId) {
|
||||
let mut s = Session::new();
|
||||
let frame = create(
|
||||
&mut s,
|
||||
json!({"type": "frame", "name": "Card", "x": 100, "y": 200, "width": 300, "height": 200,
|
||||
"props": {"layout": "horizontal", "padding": 8, "gap": 4, "fill": "#123456", "stroke": "#abcdef", "strokeWeight": 2,
|
||||
"effects": [{"type": "dropShadow", "offset": [2, 3], "radius": 4}], "cornerRadius": 12}}),
|
||||
);
|
||||
create(
|
||||
&mut s,
|
||||
json!({"type": "text", "parent": frame, "name": "Title", "text": "Hello", "width": 80, "height": 24,
|
||||
"props": {"fontFamily": "Synthetic", "fontSize": 18, "fontStyle": "Bold", "letterSpacing": 1, "lineHeight": 24,
|
||||
"textAlign": "center", "textDecoration": "underline", "fill": "#fedcba"}}),
|
||||
);
|
||||
create(
|
||||
&mut s,
|
||||
json!({"type": "rectangle", "parent": frame, "name": "Hidden", "width": 20, "height": 30,
|
||||
"props": {"visible": false, "locked": true, "fill": "#654321"}}),
|
||||
);
|
||||
let main = create(&mut s, json!({"type": "component", "name": "Button", "x": 400, "y": 200, "width": 80, "height": 40}));
|
||||
run(&mut s, "instance.create", json!({"component": main, "x": 500, "y": 200}));
|
||||
run(&mut s, "page.add", json!({"name": "Second"}));
|
||||
(s, frame)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_options_keep_the_main_tree_and_document_output() {
|
||||
let (s, frame) = golden_tree();
|
||||
// Captured with this builder on origin/main (43af145a12eacd1e87f5f5e6984c49b7da040288), plus the
|
||||
// document `id` that #1533 added. That id comes from a process-wide counter, so it is the
|
||||
// session's own.
|
||||
let expected = json!({
|
||||
"colorProfile": "srgb",
|
||||
"currentPage": 7,
|
||||
"dirty": true,
|
||||
"history": {
|
||||
"redo": 0,
|
||||
"undo": [
|
||||
"Add page",
|
||||
"Create instance",
|
||||
"Create layer",
|
||||
"Create layer",
|
||||
"Create layer",
|
||||
"Create layer"
|
||||
]
|
||||
},
|
||||
"id": s.ds().unwrap().uid,
|
||||
"images": 0,
|
||||
"kind": "design",
|
||||
"name": "Untitled",
|
||||
"pages": [
|
||||
{
|
||||
"background": "#F5F5F5",
|
||||
"children": [
|
||||
{
|
||||
"absoluteBounds": {
|
||||
"height": 200.0,
|
||||
"width": 300.0,
|
||||
"x": 100.0,
|
||||
"y": 200.0
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"absoluteBounds": {
|
||||
"height": 24.0,
|
||||
"width": 80.0,
|
||||
"x": 110.0,
|
||||
"y": 210.0
|
||||
},
|
||||
"characters": "Hello",
|
||||
"fills": [
|
||||
{
|
||||
"color": "#FEDCBA",
|
||||
"type": "solid"
|
||||
}
|
||||
],
|
||||
"fontFamily": "Synthetic",
|
||||
"fontSize": 18.0,
|
||||
"fontStyle": "Bold",
|
||||
"height": 24.0,
|
||||
"id": 3,
|
||||
"letterSpacing": "1",
|
||||
"lineHeight": "24",
|
||||
"name": "Title",
|
||||
"sizing": {
|
||||
"horizontal": "fixed",
|
||||
"vertical": "hug"
|
||||
},
|
||||
"textAlign": "center",
|
||||
"textAutoResize": "height",
|
||||
"textDecoration": "underline",
|
||||
"type": "TEXT",
|
||||
"width": 80.0,
|
||||
"x": 10.0,
|
||||
"y": 10.0
|
||||
},
|
||||
{
|
||||
"absoluteBounds": {
|
||||
"height": 30.0,
|
||||
"width": 20.0,
|
||||
"x": 100.0,
|
||||
"y": 200.0
|
||||
},
|
||||
"fills": [
|
||||
{
|
||||
"color": "#654321",
|
||||
"type": "solid"
|
||||
}
|
||||
],
|
||||
"height": 30.0,
|
||||
"id": 4,
|
||||
"locked": true,
|
||||
"name": "Hidden",
|
||||
"sizing": {
|
||||
"horizontal": "fixed",
|
||||
"vertical": "fixed"
|
||||
},
|
||||
"type": "RECTANGLE",
|
||||
"visible": false,
|
||||
"width": 20.0,
|
||||
"x": 0.0,
|
||||
"y": 0.0
|
||||
}
|
||||
],
|
||||
"clip": true,
|
||||
"cornerRadius": 12.0,
|
||||
"effects": [
|
||||
{
|
||||
"blend": "normal",
|
||||
"color": {
|
||||
"a": 0.25,
|
||||
"b": 0.0,
|
||||
"g": 0.0,
|
||||
"r": 0.0
|
||||
},
|
||||
"offset": [
|
||||
2.0,
|
||||
3.0
|
||||
],
|
||||
"radius": 4.0,
|
||||
"showBehind": false,
|
||||
"spread": 0.0,
|
||||
"type": "dropShadow",
|
||||
"visible": true
|
||||
}
|
||||
],
|
||||
"fills": [
|
||||
{
|
||||
"color": "#123456",
|
||||
"type": "solid"
|
||||
}
|
||||
],
|
||||
"height": 200.0,
|
||||
"id": 2,
|
||||
"layout": {
|
||||
"columnTracks": [],
|
||||
"contentAlign": "auto",
|
||||
"crossAlign": "min",
|
||||
"crossGap": 10.0,
|
||||
"gap": 4.0,
|
||||
"gridColumns": 2,
|
||||
"gridRows": 2,
|
||||
"mainAlign": "min",
|
||||
"mode": "horizontal",
|
||||
"padding": [
|
||||
8.0,
|
||||
8.0,
|
||||
8.0,
|
||||
8.0
|
||||
],
|
||||
"reverseZ": false,
|
||||
"rowTracks": [],
|
||||
"strokesIncluded": true,
|
||||
"wrap": false
|
||||
},
|
||||
"name": "Card",
|
||||
"sizing": {
|
||||
"horizontal": "fixed",
|
||||
"vertical": "fixed"
|
||||
},
|
||||
"strokeAlign": "inside",
|
||||
"strokeWeight": 2.0,
|
||||
"strokes": [
|
||||
{
|
||||
"color": "#ABCDEF",
|
||||
"type": "solid"
|
||||
}
|
||||
],
|
||||
"type": "FRAME",
|
||||
"width": 300.0,
|
||||
"x": 100.0,
|
||||
"y": 200.0
|
||||
},
|
||||
{
|
||||
"absoluteBounds": {
|
||||
"height": 40.0,
|
||||
"width": 80.0,
|
||||
"x": 400.0,
|
||||
"y": 200.0
|
||||
},
|
||||
"clip": true,
|
||||
"fills": [
|
||||
{
|
||||
"color": "#FFFFFF",
|
||||
"type": "solid"
|
||||
}
|
||||
],
|
||||
"height": 40.0,
|
||||
"id": 5,
|
||||
"name": "Button",
|
||||
"type": "COMPONENT",
|
||||
"width": 80.0,
|
||||
"x": 400.0,
|
||||
"y": 200.0
|
||||
},
|
||||
{
|
||||
"absoluteBounds": {
|
||||
"height": 40.0,
|
||||
"width": 80.0,
|
||||
"x": 500.0,
|
||||
"y": 200.0
|
||||
},
|
||||
"clip": true,
|
||||
"fills": [
|
||||
{
|
||||
"color": "#FFFFFF",
|
||||
"type": "solid"
|
||||
}
|
||||
],
|
||||
"height": 40.0,
|
||||
"id": 6,
|
||||
"mainComponent": 5,
|
||||
"mainComponentName": "Button",
|
||||
"name": "Button",
|
||||
"type": "INSTANCE",
|
||||
"width": 80.0,
|
||||
"x": 500.0,
|
||||
"y": 200.0
|
||||
}
|
||||
],
|
||||
"id": 1,
|
||||
"name": "Page 1",
|
||||
"type": "PAGE"
|
||||
},
|
||||
{
|
||||
"background": "#F5F5F5",
|
||||
"id": 7,
|
||||
"name": "Second",
|
||||
"type": "PAGE"
|
||||
}
|
||||
],
|
||||
"path": null,
|
||||
"selection": [],
|
||||
"styles": [],
|
||||
"textEdit": null,
|
||||
"variables": 0
|
||||
});
|
||||
let expected_node = &expected["pages"][0]["children"][0];
|
||||
assert_eq!(node_json(s.doc().unwrap(), frame, 4).to_string(), expected_node.to_string());
|
||||
assert_eq!(document(&s, 4, true).to_string(), expected.to_string());
|
||||
for params in [json!({}), json!({"outline": false})] {
|
||||
let options = InspectOptions::parse(¶ms).unwrap();
|
||||
assert_eq!(node_json_with_options(s.doc().unwrap(), frame, 4, &options).to_string(), expected_node.to_string());
|
||||
assert_eq!(document_with_options(&s, 4, true, &options).to_string(), expected.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fields_filter_recursively_including_pages_without_changing_metadata_or_history() {
|
||||
let (s, _, _) = tree();
|
||||
let before = document(&s, 4, true);
|
||||
let selection = s.selection();
|
||||
let history = s.undo_depth();
|
||||
let options = InspectOptions::parse(&json!({"fields": ["name", "characters", "name"], "outline": false})).unwrap();
|
||||
let compact = document_with_options(&s, 4, true, &options);
|
||||
let mut metadata = before.clone();
|
||||
metadata["pages"] = compact["pages"].clone();
|
||||
assert_eq!(compact, metadata);
|
||||
keys(&compact["pages"][0], &["id", "name", "characters", "children", "childCount"]);
|
||||
assert_eq!(compact["pages"][0]["children"][0]["children"][0]["characters"], "Hello");
|
||||
assert_eq!(s.selection(), selection);
|
||||
assert_eq!(s.undo_depth(), history);
|
||||
assert_eq!(document(&s, 4, true), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_field_list_keeps_ids_and_structure_at_depth_boundaries() {
|
||||
let (s, frame, _) = tree();
|
||||
let compact = node(&s, frame, 4, json!({"fields": []}));
|
||||
keys(&compact, &["id", "children", "childCount"]);
|
||||
assert_eq!(compact["children"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(node(&s, frame, 0, json!({"fields": []})), json!({"id": frame, "childCount": 2}));
|
||||
let options = InspectOptions::parse(&json!({"fields": []})).unwrap();
|
||||
let summary = document_with_options(&s, 0, false, &options);
|
||||
assert_eq!(summary["pages"][0], json!({"id": s.page().unwrap(), "childCount": 1}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn geometry_keys_are_independently_selectable() {
|
||||
let (s, frame, text) = tree();
|
||||
let fields = ["x", "y", "width", "height", "absoluteBounds"];
|
||||
let compact = node(&s, frame, 2, json!({"fields": fields}));
|
||||
keys(&compact, &["id", "children", "childCount", "x", "y", "width", "height", "absoluteBounds"]);
|
||||
assert_eq!((compact["x"].as_f64(), compact["y"].as_f64()), (Some(100.0), Some(200.0)));
|
||||
assert_eq!(compact["children"][0]["x"], 10.0);
|
||||
assert_eq!(compact["children"][0]["absoluteBounds"]["x"], 110.0);
|
||||
for key in fields {
|
||||
let one = node(&s, text, 0, json!({"fields": [key]}));
|
||||
assert_eq!(one.as_object().unwrap().len(), 2, "{one}");
|
||||
assert_eq!(one[key], compact["children"][0][key]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_truncates_only_after_120_unicode_scalars() {
|
||||
let (mut s, _, text) = tree();
|
||||
for unit in ["a", "界", "\u{1f980}"] {
|
||||
for len in [0, 119, 120, 121] {
|
||||
let content = unit.repeat(len);
|
||||
s.doc_mut().unwrap().get_mut(text).unwrap().kind.text_mut().unwrap().characters = content.clone();
|
||||
let outline = node(&s, text, 0, json!({"outline": true}));
|
||||
let want = if len > 120 { format!("{}...", unit.repeat(120)) } else { content.clone() };
|
||||
assert_eq!(outline["characters"], want);
|
||||
assert_eq!(node(&s, text, 0, json!({"fields": ["characters"]}))["characters"], content);
|
||||
keys(&outline, &["id", "name", "type", "characters", "mainComponentName", "visible", "children", "childCount"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_visible_makes_it_explicit_for_every_node_including_pages() {
|
||||
let (s, frame, _) = tree();
|
||||
for params in [json!({"fields": ["visible"]}), json!({"outline": true})] {
|
||||
let options = InspectOptions::parse(¶ms).unwrap();
|
||||
let tree = document_with_options(&s, 4, true, &options);
|
||||
assert_eq!(tree["pages"][0]["visible"], true);
|
||||
let card = &tree["pages"][0]["children"][0];
|
||||
assert_eq!(card["visible"], true);
|
||||
assert_eq!(card["children"][0]["visible"], true);
|
||||
assert_eq!(card["children"][1]["visible"], false);
|
||||
}
|
||||
let no_visible = node(&s, frame, 4, json!({"fields": ["name"]}));
|
||||
assert!(no_visible["children"][1].get("visible").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_names_are_selected_without_other_instance_properties_even_with_a_missing_main() {
|
||||
let mut s = Session::new();
|
||||
let main = create(&mut s, json!({"type": "component", "name": "Button"}));
|
||||
let instance = crate::params::id_of(&run(&mut s, "instance.create", json!({"component": main}))["id"]).unwrap();
|
||||
for params in [json!({"fields": ["mainComponentName"]}), json!({"outline": true})] {
|
||||
let compact = node(&s, instance, 0, params);
|
||||
assert_eq!(compact["mainComponentName"], "Button");
|
||||
assert!(compact.get("mainComponent").is_none());
|
||||
}
|
||||
if let NodeKind::Instance { component, .. } = &mut s.doc_mut().unwrap().get_mut(instance).unwrap().kind {
|
||||
*component = NodeId(u64::MAX);
|
||||
}
|
||||
let compact = node(&s, instance, 0, json!({"outline": true}));
|
||||
assert_eq!(compact.get("mainComponentName"), Some(&Value::Null));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_properties_and_other_page_summaries_respect_fields() {
|
||||
let (mut s, _, _) = tree();
|
||||
let first = s.page().unwrap();
|
||||
run(&mut s, "node.set", json!({"ids": [first], "props": {"pluginData": {"source": "synthetic"}}}));
|
||||
run(&mut s, "page.add", json!({"name": "Second"}));
|
||||
let options = InspectOptions::parse(&json!({"fields": ["pluginData", "background"]})).unwrap();
|
||||
let compact = document_with_options(&s, 4, false, &options);
|
||||
assert_eq!(compact["pages"].as_array().unwrap().len(), 2);
|
||||
let page = &compact["pages"][0];
|
||||
keys(page, &["id", "pluginData", "background", "childCount", "children"]);
|
||||
assert_eq!(page["pluginData"]["source"], "synthetic");
|
||||
assert_eq!(page["childCount"], 1);
|
||||
assert!(page["background"].is_string());
|
||||
assert!(page.get("name").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_catalogue_accepts_emitted_keys_and_structure_keys() {
|
||||
let (mut s, frame, text) = tree();
|
||||
run(&mut s, "node.set", json!({"ids": [frame], "props": {"layout": "grid", "fill": "#123456", "stroke": "#abcdef"}}));
|
||||
s.doc_mut().unwrap().get_mut(frame).unwrap().dev_note = "Ready for review".into();
|
||||
s.doc_mut().unwrap().get_mut(frame).unwrap().dev_changed = true;
|
||||
let main = create(&mut s, json!({"type": "component", "name": "Button"}));
|
||||
let instance = crate::params::id_of(&run(&mut s, "instance.create", json!({"component": main}))["id"]).unwrap();
|
||||
s.doc_mut().unwrap().get_mut(text).unwrap().kind.text_mut().unwrap().style.style_id = Some("synthetic-style".into());
|
||||
// Exercise each of the six property helpers, including their more expensive values.
|
||||
for (id, key, sibling) in [
|
||||
(frame, "absoluteBounds", "width"),
|
||||
(frame, "fills", "strokes"),
|
||||
(text, "gridCell", "sizing"),
|
||||
(frame, "devNote", "devChanged"),
|
||||
(text, "textStyleRanges", "fontFamily"),
|
||||
(instance, "mainComponentName", "mainComponent"),
|
||||
] {
|
||||
let full = node_json(s.doc().unwrap(), id, 0);
|
||||
assert!(full.get(key).is_some(), "fixture must emit {key}: {full}");
|
||||
assert!(full.get(sibling).is_some(), "fixture must emit {sibling}: {full}");
|
||||
let selected = node(&s, id, 0, json!({"fields": [key]}));
|
||||
assert_eq!(selected[key], full[key]);
|
||||
assert!(selected.get(sibling).is_none(), "unrequested {sibling}: {selected}");
|
||||
keys(&selected, &["id", "children", "childCount", key]);
|
||||
}
|
||||
let full = node_json(s.doc().unwrap(), s.page().unwrap(), 3);
|
||||
fn check(s: &Session, full: &Value) {
|
||||
let id = crate::params::id_of(&full["id"]).unwrap();
|
||||
for (key, value) in full.as_object().unwrap() {
|
||||
let selected = node(s, id, 2, json!({"fields": [key]}));
|
||||
if key == "children" {
|
||||
assert_eq!(selected[key].as_array().map(Vec::len), value.as_array().map(Vec::len));
|
||||
} else {
|
||||
assert_eq!(&selected[key], value, "{key}");
|
||||
}
|
||||
}
|
||||
for child in full["children"].as_array().into_iter().flatten() {
|
||||
check(s, child);
|
||||
}
|
||||
}
|
||||
check(&s, &full);
|
||||
let structural = node(&s, frame, 0, json!({"fields": ["id", "children", "childCount"]}));
|
||||
assert_eq!(structural, json!({"id": frame, "childCount": 2}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_options_are_errors_and_unknown_names_list_the_catalogue() {
|
||||
for params in [
|
||||
json!({"fields": null}),
|
||||
json!({"fields": "name"}),
|
||||
json!({"fields": {"name": true}}),
|
||||
json!({"fields": [false]}),
|
||||
json!({"fields": [null]}),
|
||||
json!({"fields": ["name", 1]}),
|
||||
json!({"outline": null}),
|
||||
json!({"outline": "true"}),
|
||||
json!({"outline": 1}),
|
||||
json!({"outline": []}),
|
||||
json!({"outline": true, "fields": []}),
|
||||
json!({"outline": true, "fields": ["name"]}),
|
||||
] {
|
||||
assert!(InspectOptions::parse(¶ms).is_err(), "{params}");
|
||||
}
|
||||
for key in ["Name", "absolute_bounds", "noSuchField", "truncated"] {
|
||||
let error = InspectOptions::parse(&json!({"fields": [key]})).unwrap_err();
|
||||
assert!(error.contains(key), "{error}");
|
||||
for valid in crate::inspect::NODE_KEYS {
|
||||
assert!(error.contains(valid), "missing {valid}: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_get_uses_options_but_keeps_its_existing_depth_and_selection() {
|
||||
let (mut s, frame, _) = tree();
|
||||
let selection = s.selection();
|
||||
let history = s.undo_depth();
|
||||
let default = node_json(s.doc().unwrap(), frame, 1);
|
||||
assert_eq!(run(&mut s, "node.get", json!({"id": frame})), default);
|
||||
let compact = run(&mut s, "node.get", json!({"id": frame, "fields": []}));
|
||||
assert!(compact["children"].is_array(), "the engine command still inspects to depth 1");
|
||||
let error = s.execute("node.get", &json!({"id": frame, "fields": [], "depth": 0})).unwrap_err();
|
||||
assert!(error.to_string().contains("unknown parameter `depth`"), "{error}");
|
||||
keys(&compact, &["id", "children", "childCount"]);
|
||||
assert!(s.execute("node.get", &json!({"id": frame, "outline": true, "fields": []})).is_err());
|
||||
assert_eq!(s.selection(), selection);
|
||||
assert_eq!(s.undo_depth(), history);
|
||||
}
|
||||
@@ -201,7 +201,7 @@ pub static KEYS: &[Binding] = &[
|
||||
k(Tools, "tool.highlighter", &["U"]),
|
||||
k(Tools, "tool.eraser", &["E"]),
|
||||
k(Tools, "tool.stamp", &["W"]),
|
||||
k(Tools, "board.nextSticky", &["Shift+Cmd+Enter"]).label("Board: a new sticky beside the selected one").both(),
|
||||
k(Tools, "board.nextSticky", &["Shift+Cmd+Enter"]).label("Board: a new sticky beside the selected one, or the next shape joined to it").both(),
|
||||
// View
|
||||
// The documentation gives both keys for the pixel grid in different places.
|
||||
k(View, "view.pixelGrid", &["Shift+'", "Cmd+'"]),
|
||||
|
||||
@@ -201,6 +201,8 @@ mod batch_undo_snapshot_tests;
|
||||
#[cfg(test)]
|
||||
mod binding_lists_tests;
|
||||
#[cfg(test)]
|
||||
mod board_connected_tests;
|
||||
#[cfg(test)]
|
||||
mod board_connector_tests;
|
||||
#[cfg(test)]
|
||||
mod board_grid_tests;
|
||||
@@ -307,6 +309,8 @@ mod fig_export_assets_tests;
|
||||
#[cfg(test)]
|
||||
mod fig_export_tests;
|
||||
#[cfg(test)]
|
||||
mod fig_font_names_tests;
|
||||
#[cfg(test)]
|
||||
mod fig_import_tests;
|
||||
#[cfg(test)]
|
||||
mod fill_none_tests;
|
||||
@@ -327,6 +331,8 @@ mod gesture_preview_tests;
|
||||
#[cfg(test)]
|
||||
mod gradient_shorthand_tests;
|
||||
#[cfg(test)]
|
||||
mod group_panic_tests;
|
||||
#[cfg(test)]
|
||||
mod guide_snap_tests;
|
||||
#[cfg(test)]
|
||||
mod hide_publishing_tests;
|
||||
@@ -349,6 +355,8 @@ mod import_journal_tests;
|
||||
#[cfg(test)]
|
||||
mod inspect_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod inspect_fields_tests;
|
||||
#[cfg(test)]
|
||||
mod instance_modes_tests;
|
||||
#[cfg(test)]
|
||||
mod instance_resizing_tests;
|
||||
@@ -391,6 +399,8 @@ mod measure_tests;
|
||||
#[cfg(test)]
|
||||
mod menu_gaps_tests;
|
||||
#[cfg(test)]
|
||||
mod moved_by_tests;
|
||||
#[cfg(test)]
|
||||
mod nested_override_tests;
|
||||
#[cfg(test)]
|
||||
mod node_find_tests;
|
||||
@@ -419,6 +429,10 @@ mod paint_tests;
|
||||
#[cfg(test)]
|
||||
mod panels_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod paste_missing_image_tests;
|
||||
#[cfg(test)]
|
||||
mod path_read_caps_tests;
|
||||
#[cfg(test)]
|
||||
mod pdf_print_tests;
|
||||
#[cfg(test)]
|
||||
mod pen_end_tests;
|
||||
@@ -511,6 +525,8 @@ mod sketch_import_tests;
|
||||
#[cfg(test)]
|
||||
mod slot_tests;
|
||||
#[cfg(test)]
|
||||
mod stored_ids_tests;
|
||||
#[cfg(test)]
|
||||
mod stroke_style_tests;
|
||||
#[cfg(test)]
|
||||
mod style_tests;
|
||||
@@ -555,6 +571,8 @@ mod variable_tests;
|
||||
#[cfg(test)]
|
||||
mod variables_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod variables_colour_tests;
|
||||
#[cfg(test)]
|
||||
mod variables_editor_tests;
|
||||
#[cfg(test)]
|
||||
mod variables_import_shapes_tests;
|
||||
@@ -583,6 +601,8 @@ mod view_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod vue_codegen_tests;
|
||||
#[cfg(test)]
|
||||
mod widget_hover_tests;
|
||||
#[cfg(test)]
|
||||
mod widget_input_tests;
|
||||
#[cfg(test)]
|
||||
mod widgets_tests;
|
||||
|
||||
@@ -264,6 +264,67 @@ fn bind_alias_mode_read_only_and_updates() {
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// A library's colour calculations come in reading this file's copies of their operands (the
|
||||
/// library's ids mean nothing here), keep following the library's updates, and keep the copies they
|
||||
/// read when the library is removed.
|
||||
#[test]
|
||||
fn library_colour_calculations_read_the_copies() {
|
||||
let d = dir("calc");
|
||||
let lib_path = path_str(&d.join("theme.figcraft"));
|
||||
let mut lib = Session::new();
|
||||
theme(&mut lib);
|
||||
ex(
|
||||
&mut lib,
|
||||
"variable.create",
|
||||
json!({"collection": "Theme", "name": "hover", "type": "color", "value": {"alpha": {"variable": "base", "opacity": 0.5}}}),
|
||||
);
|
||||
let tint = json!({"mix": {"from": {"variable": "primary"}, "to": {"literal": "#ffffff"}, "t": 0.5}});
|
||||
ex(&mut lib, "variable.create", json!({"collection": "Theme", "name": "tint", "type": "color", "value": tint}));
|
||||
let lid = publish_to(&mut lib, &lib_path);
|
||||
let lib_ids: Vec<String> = lib.doc().unwrap().variables.variables.iter().map(|v| v.id.clone()).collect();
|
||||
|
||||
let mut s = Session::new();
|
||||
// Local variables first, so the copies get ids the library's don't share.
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "App"}));
|
||||
for name in ["one", "two", "three", "four", "five", "six", "seven"] {
|
||||
ex(&mut s, "variable.create", json!({"collection": "App", "name": name, "type": "number", "value": 1}));
|
||||
}
|
||||
ex(&mut s, "library.add", json!({"path": lib_path}));
|
||||
let a = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})));
|
||||
let b = idv(&ex(&mut s, "shape.rectangle", json!({"x": 20, "y": 0, "width": 10, "height": 10})));
|
||||
ex(&mut s, "library.bindVariable", json!({"library": lid, "variable": "hover", "field": "fill", "ids": [a]}));
|
||||
ex(&mut s, "library.bindVariable", json!({"library": lid, "variable": "tint", "field": "fill", "ids": [b]}));
|
||||
let shown = |s: &Session, id: NodeId| Some(crate::paint_tests::fill(s, id).to_css_hex());
|
||||
assert_eq!((shown(&s, a), shown(&s, b)), (Some("#00FF0080".into()), Some("#8080FF".into())));
|
||||
let (base, primary) = (copy_of(&s, "base").id, copy_of(&s, "primary").id);
|
||||
assert!(!lib_ids.contains(&base) && !lib_ids.contains(&primary), "copies have ids of their own");
|
||||
let reads = |name: &str| copy_of(&s, name).values.values().flat_map(|v| v.references().into_iter().cloned()).collect::<Vec<_>>();
|
||||
assert_eq!(reads("hover"), [base.clone(), base]);
|
||||
assert_eq!(reads("tint"), [primary.clone(), primary.clone()]);
|
||||
|
||||
ex(&mut lib, "variable.set", json!({"variable": "base", "value": "#ff0000"}));
|
||||
ex(&mut lib, "variable.set", json!({"variable": "primary", "mode": "Light", "value": "#000000"}));
|
||||
republish(&mut lib);
|
||||
ex(&mut s, "library.acceptUpdates", json!({}));
|
||||
assert_eq!((shown(&s, a), shown(&s, b)), (Some("#FF000080".into()), Some("#808080".into())));
|
||||
|
||||
// A local calculation is the only reader of `primary` once the layers let go of the library.
|
||||
ex(
|
||||
&mut s,
|
||||
"variable.create",
|
||||
json!({"collection": "App", "name": "wash", "type": "color", "value": {"mix": {"from": {"variable": primary}, "to": {"literal": "#ffffff"}, "t": 0.5}}}),
|
||||
);
|
||||
ex(&mut s, "variable.bind", json!({"ids": [a, b], "field": "fill", "variable": null}));
|
||||
ex(&mut s, "library.remove", json!({"id": lid}));
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(doc.variables.get_by_id(&primary).is_some(), "the copy a local calculation reads stays");
|
||||
let wash = doc.variables.get("wash").unwrap();
|
||||
let Some(VariableValue::Color(c)) = doc.variables.resolve(&wash.id, &Default::default()) else { panic!("wash resolves to a colour") };
|
||||
assert_eq!(c.to_rgba8(), [128, 128, 128, 255]);
|
||||
healthy(&s);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_components_keep_their_variable_bindings() {
|
||||
let d = dir("components");
|
||||
@@ -428,3 +489,36 @@ fn a_library_published_before_modes_were_overrides_keeps_the_modes_set_inside_ne
|
||||
healthy(&s);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_keep_library_alias_dependencies() {
|
||||
let mut s = Session::new();
|
||||
for name in ["Shadow", "Base", "Theme"] {
|
||||
ex(&mut s, "variable.createCollection", json!({"name": name}));
|
||||
}
|
||||
let shadow = ex(&mut s, "variable.create", json!({"collection": "Shadow", "name": "shadow", "type": "color", "value": "#FF0000"}));
|
||||
let surface = ex(&mut s, "variable.create", json!({"collection": "Base", "name": "surface", "type": "color", "value": "#0D99FF"}));
|
||||
let alias = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "alias", "type": "color", "value": {"alias": surface["id"]}}));
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"name": "Chip", "x": 0, "y": 0, "width": 100, "height": 40})));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "fill", "variable": alias["id"]}));
|
||||
let chip = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
|
||||
ex(&mut s, "variable.update", json!({"variable": shadow["id"], "name": surface["id"]}));
|
||||
let doc = s.doc().unwrap();
|
||||
let selection = Selection { components: vec![chip], ..Default::default() };
|
||||
let lib = publish(doc, None, &selection, None, 0).unwrap();
|
||||
let target = surface["id"].as_str().unwrap();
|
||||
assert!(lib.doc.variables.get_by_id(target).is_some());
|
||||
assert!(lib.doc.variables.get_by_id(shadow["id"].as_str().unwrap()).is_none());
|
||||
assert!(lib.doc.variables.collections.iter().any(|c| c.name == "Base"));
|
||||
|
||||
// Import from a snapshot that retains both the dependency and its namesake.
|
||||
let lib = publish(doc, None, &publishable(doc), None, 1).unwrap();
|
||||
let mut dest = Document::new("consumer");
|
||||
let key = alias["id"].as_str().unwrap().to_string();
|
||||
let map = import_variables(&mut dest, &lib, &std::collections::BTreeSet::from([key.clone()]), &Default::default(), false);
|
||||
assert!(map.variables.contains_key(target));
|
||||
let copy = dest.variables.get_by_id(&map.variables[&key]).unwrap();
|
||||
assert!(!copy.values.is_empty());
|
||||
assert!(copy.values.values().all(|v| *v == VariableValue::Alias(map.variables[target].clone())));
|
||||
assert!(dest.variables.collections.iter().any(|c| c.name == "Base"));
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! `Interaction::moved_by` (#1571): set while a drag has only moved the selected layers, each by the
|
||||
//! same offset from the document the gesture started from, so the UI can shift what it worked out
|
||||
//! from that document; `None` for anything else (a layer in an auto layout flow, a copy made with
|
||||
//! Alt), and gone with the gesture.
|
||||
|
||||
use figcraft_doc::NodeId;
|
||||
use figcraft_geom::Point;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
use crate::tools::{PointerEvent, PointerKind};
|
||||
|
||||
fn rect(s: &mut Session, parent: Option<NodeId>, x: f64, y: f64) -> NodeId {
|
||||
let mut p = json!({"x": x, "y": y, "width": 40, "height": 40});
|
||||
if let Some(parent) = parent {
|
||||
p["parent"] = json!(parent);
|
||||
}
|
||||
id_of(&s.execute("shape.rectangle", &p).unwrap()["id"]).unwrap()
|
||||
}
|
||||
|
||||
fn ev(kind: PointerKind, x: f64, y: f64, alt: bool) -> PointerEvent {
|
||||
PointerEvent { alt, ..PointerEvent::at(kind, x, y, 1.0) }
|
||||
}
|
||||
|
||||
/// Press at `from` and move to `to`, holding the button: what the interaction says of it.
|
||||
fn moved_by(s: &mut Session, from: (f64, f64), to: (f64, f64), alt: bool) -> Option<(f64, f64)> {
|
||||
s.pointer(&ev(PointerKind::Down, from.0, from.1, alt)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, (from.0 + to.0) / 2.0, (from.1 + to.1) / 2.0, alt)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, to.0, to.1, alt)).unwrap();
|
||||
s.ds().unwrap().interaction.as_ref().and_then(|it| it.moved_by)
|
||||
}
|
||||
|
||||
fn release(s: &mut Session, at: (f64, f64)) {
|
||||
s.pointer(&ev(PointerKind::Up, at.0, at.1, false)).unwrap();
|
||||
}
|
||||
|
||||
fn world(s: &Session, id: NodeId) -> Point {
|
||||
let t = s.doc().unwrap().world_transform(id).translation();
|
||||
Point::new(t.x, t.y)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plain_move_says_how_far_and_matches_the_document() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, None, 0.0, 0.0);
|
||||
let b = rect(&mut s, None, 100.0, 0.0);
|
||||
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
|
||||
let (before_a, before_b) = (world(&s, a), world(&s, b));
|
||||
let d = moved_by(&mut s, (20.0, 20.0), (57.0, 81.0), false).expect("a plain move");
|
||||
assert_eq!(d, (37.0, 61.0));
|
||||
for (id, was) in [(a, before_a), (b, before_b)] {
|
||||
assert_eq!(world(&s, id), Point::new(was.x + d.0, was.y + d.1), "each layer moved by it");
|
||||
}
|
||||
release(&mut s, (57.0, 81.0));
|
||||
assert!(s.ds().unwrap().interaction.is_none(), "gone with the gesture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_selected_with_its_frame_moves_with_it_once() {
|
||||
let mut s = Session::new();
|
||||
let g = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 200})).unwrap()["id"]).unwrap();
|
||||
let inner = rect(&mut s, Some(g), 20.0, 20.0);
|
||||
s.execute("select.set", &json!({"ids": [g, inner]})).unwrap();
|
||||
let was = world(&s, inner);
|
||||
let d = moved_by(&mut s, (25.0, 25.0), (80.0, 90.0), false).expect("the frame moves, and the layer in it with it");
|
||||
assert_eq!(world(&s, inner), Point::new(was.x + d.0, was.y + d.1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_moves_say_nothing() {
|
||||
// A layer placed by its frame's auto layout.
|
||||
let mut s = Session::new();
|
||||
let f = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 300})).unwrap()["id"]).unwrap();
|
||||
let a = rect(&mut s, Some(f), 10.0, 10.0);
|
||||
rect(&mut s, Some(f), 60.0, 10.0);
|
||||
s.execute("layout.add", &json!({"ids": [f]})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a]})).unwrap();
|
||||
let at = world(&s, a);
|
||||
assert_eq!(moved_by(&mut s, (at.x + 5.0, at.y + 5.0), (at.x + 9.0, at.y + 200.0), false), None, "in an auto layout flow");
|
||||
release(&mut s, (at.x + 9.0, at.y + 200.0));
|
||||
// A copy dragged out with Alt.
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, None, 0.0, 0.0);
|
||||
s.execute("select.set", &json!({"ids": [a]})).unwrap();
|
||||
assert_eq!(moved_by(&mut s, (20.0, 20.0), (90.0, 20.0), true), None, "a copy");
|
||||
}
|
||||
@@ -13,7 +13,7 @@ fn red_rect(s: &mut Session) -> figcraft_doc::NodeId {
|
||||
r
|
||||
}
|
||||
|
||||
fn fill(s: &Session, id: figcraft_doc::NodeId) -> Color {
|
||||
pub(crate) fn fill(s: &Session, id: figcraft_doc::NodeId) -> Color {
|
||||
s.doc().unwrap().node(id).unwrap().fills[0].solid_color().unwrap()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
//! Layers pasted from another design app whose image the clipboard doesn't carry (#1646): the
|
||||
//! paste names them (`missingImages`), and they keep their image's name. Design data made by our
|
||||
//! own `.fig` export: its canvas alone, as a clipboard carries it, and the whole file with images.
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
use base64::Engine as _;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
|
||||
/// A `.fig` export of a page with "Photo" (an image fill) and "Plain" (a solid fill).
|
||||
fn exported() -> Vec<u8> {
|
||||
let mut s = Session::new();
|
||||
let photo = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 80, "height": 60})).unwrap()["id"]).unwrap();
|
||||
let plain = id_of(&s.execute("shape.rectangle", &json!({"x": 100, "y": 0, "width": 80, "height": 60})).unwrap()["id"]).unwrap();
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_pixel(4, 4, image::Rgba([200, 40, 40, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let (hash, _, _) = figcraft_format::add_image(doc, png).unwrap();
|
||||
let n = doc.get_mut(photo).unwrap();
|
||||
n.name = "Photo".into();
|
||||
n.fills = vec![figcraft_color::Paint::image(hash, figcraft_color::ImageScaleMode::Fill)];
|
||||
doc.get_mut(plain).unwrap().name = "Plain".into();
|
||||
let r = s.execute("file.export", &json!({"format": "fig"})).unwrap();
|
||||
base64::engine::general_purpose::STANDARD.decode(r["data"].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// Just the canvas of a `.fig` file: the design data a clipboard carries, without the images.
|
||||
fn canvas_only(fig: &[u8]) -> Vec<u8> {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(fig)).unwrap();
|
||||
let mut out = Vec::new();
|
||||
z.by_name("canvas.fig").unwrap().read_to_end(&mut out).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
fn paste(data: &[u8]) -> Value {
|
||||
let mut s = Session::new();
|
||||
s.execute("edit.pasteDesignLayers", &json!({"data": base64::engine::general_purpose::STANDARD.encode(data)})).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_whose_image_the_clipboard_lacks_are_named() {
|
||||
let fig = exported();
|
||||
let r = paste(&canvas_only(&fig));
|
||||
assert_eq!(r["missingImages"], json!(["Photo"]), "{r}");
|
||||
assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("missing-image placeholder")), "{r}");
|
||||
// With its images, nothing is missing.
|
||||
let r = paste(&fig);
|
||||
assert!(r.get("missingImages").is_none(), "{r}");
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//! Paths callers give are read only when they are regular files within a cap that fits their
|
||||
//! kind (#1114): a folder, a device or an oversized file is refused with a clear error before
|
||||
//! anything is read, and the session is left as it was.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::file_access::read_capped;
|
||||
|
||||
struct Dir(PathBuf);
|
||||
|
||||
impl Dir {
|
||||
fn new(name: &str) -> Self {
|
||||
let d = std::env::temp_dir().join(format!("figcraft-caps-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(d.join("folder")).unwrap();
|
||||
Self(d)
|
||||
}
|
||||
fn p(&self, rel: &str) -> String {
|
||||
self.0.join(rel).to_string_lossy().into_owned()
|
||||
}
|
||||
/// A sparse file of `len` bytes (nothing is written).
|
||||
fn sized(&self, rel: &str, len: u64) -> String {
|
||||
std::fs::File::create(self.0.join(rel)).unwrap().set_len(len).unwrap();
|
||||
self.p(rel)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Dir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// `cmd` with `params` fails with a message containing `why`, and changes no document.
|
||||
fn refused(s: &mut Session, cmd: &str, params: Value, why: &str) {
|
||||
let before = (format!("{:?}", s.docs.iter().map(|d| &d.doc).collect::<Vec<_>>()), s.active);
|
||||
let e = s.execute(cmd, ¶ms).unwrap_err().to_string();
|
||||
assert!(e.contains(why), "{cmd} {params}: {e}");
|
||||
assert_eq!((format!("{:?}", s.docs.iter().map(|d| &d.doc).collect::<Vec<_>>()), s.active), before, "{cmd} {params}");
|
||||
}
|
||||
|
||||
const FOLDER: &str = "is a folder, not a file";
|
||||
const DEVICE: &str = "isn't a regular file";
|
||||
|
||||
#[test]
|
||||
fn the_capped_read_refuses_folders_devices_and_oversized_files() {
|
||||
let d = Dir::new("read");
|
||||
std::fs::write(d.0.join("four.txt"), b"four").unwrap();
|
||||
assert_eq!(read_capped(d.p("four.txt"), 4).unwrap(), b"four");
|
||||
let e = read_capped(d.p("four.txt"), 3).unwrap_err().to_string();
|
||||
assert!(e.contains("is 4 bytes, more than the 3 bytes allowed here"), "{e}");
|
||||
assert!(read_capped(d.p("folder"), u64::MAX).unwrap_err().to_string().contains(FOLDER));
|
||||
if cfg!(unix) {
|
||||
// An endless source: refused, not read until memory runs out.
|
||||
assert!(read_capped("/dev/zero", u64::MAX).unwrap_err().to_string().contains(DEVICE));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documents_are_refused_as_folders_and_devices() {
|
||||
let d = Dir::new("docs");
|
||||
let mut s = Session::new();
|
||||
for cmd in ["file.open", "file.import"] {
|
||||
refused(&mut s, cmd, json!({"path": d.p("folder")}), FOLDER);
|
||||
if cfg!(unix) {
|
||||
refused(&mut s, cmd, json!({"path": "/dev/zero"}), DEVICE);
|
||||
}
|
||||
}
|
||||
const { assert!(crate::cmd::file::MAX_DOCUMENT_FILE > 7_400_000_000, "a real 7.4 GB file still opens") };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placed_images_are_capped_at_what_a_document_takes() {
|
||||
let d = Dir::new("images");
|
||||
let mut s = Session::new();
|
||||
let big = d.sized("huge.png", figcraft_doc::ImageData::MAX_BYTES + 1);
|
||||
refused(&mut s, "image.place", json!({"path": big}), "more than the 256 MB allowed here");
|
||||
refused(&mut s, "image.place", json!({"path": d.p("folder")}), FOLDER);
|
||||
if cfg!(unix) {
|
||||
refused(&mut s, "image.place", json!({"path": "/dev/zero"}), DEVICE);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fonts_and_plugins_are_refused_as_folders() {
|
||||
let d = Dir::new("plugins");
|
||||
let mut s = Session::new();
|
||||
refused(&mut s, "text.loadFont", json!({"path": d.p("folder")}), FOLDER);
|
||||
// A plugin folder whose manifest is a folder.
|
||||
std::fs::create_dir_all(d.0.join("plugin").join("figcraft-plugin.json")).unwrap();
|
||||
refused(&mut s, "plugin.import", json!({"path": d.p("plugin")}), FOLDER);
|
||||
assert!(s.prefs.plugins.is_empty());
|
||||
}
|
||||
@@ -42,10 +42,11 @@ fn fit(kind: VariableType, v: VariableValue) -> Option<VariableValue> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set `variable` to `value`, or to `expr` evaluated in the modes of `node` (the layer whose
|
||||
/// interaction runs), in every mode, and re-apply bindings. False when nothing could be set.
|
||||
/// Set stored variable id `variable` to `value`, or to `expr` evaluated in the modes of `node`
|
||||
/// (the layer whose interaction runs), in every mode, and re-apply bindings. False when nothing
|
||||
/// could be set.
|
||||
pub fn set_variable(doc: &mut Document, node: NodeId, variable: &str, value: &Value, expr: Option<&Expr>) -> bool {
|
||||
let Some(var) = doc.variables.get(variable) else { return false };
|
||||
let Some(var) = doc.variables.get_by_id(variable) else { return false };
|
||||
let (id, kind) = (var.id.clone(), var.kind);
|
||||
let v = match expr {
|
||||
Some(e) => e.eval(&doc.variables, &doc.modes_for(node)).and_then(|v| fit(kind, v)),
|
||||
@@ -66,7 +67,16 @@ pub fn set_variable(doc: &mut Document, node: NodeId, variable: &str, value: &Va
|
||||
/// win), then re-apply bindings. False for an unknown collection or mode.
|
||||
pub fn set_mode(doc: &mut Document, frame: NodeId, collection: &str, mode: &str) -> bool {
|
||||
let Some(c) = doc.variables.collection(collection) else { return false };
|
||||
let Some(mode) = c.find_mode(mode).map(|m| m.id.clone()) else {
|
||||
let Some(m) = c.find_mode(mode) else { return false };
|
||||
let (collection, mode) = (c.id.clone(), m.id.clone());
|
||||
set_mode_by_id(doc, frame, &collection, &mode)
|
||||
}
|
||||
|
||||
/// Switch a stored collection id to a stored mode id (a prototype action's), without matching
|
||||
/// another collection's or mode's name (#1613). Otherwise behaves like [`set_mode`].
|
||||
pub fn set_mode_by_id(doc: &mut Document, frame: NodeId, collection: &str, mode: &str) -> bool {
|
||||
let Some(c) = doc.variables.collection_by_id(collection) else { return false };
|
||||
let Some(mode) = c.mode_by_id(mode).map(|m| m.id.clone()) else {
|
||||
return false;
|
||||
};
|
||||
let cid = c.id.clone();
|
||||
|
||||
@@ -207,3 +207,145 @@ fn junk_conditional_actions_never_panic() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_set_only_the_prototype_action_target() {
|
||||
let (mut s, frame, _, _, _) = setup();
|
||||
let shadow = var_id(s.doc().unwrap(), "count");
|
||||
let target = s.execute("variable.create", &json!({"collection": "Theme", "name": "target", "type": "number", "value": 5})).unwrap();
|
||||
let target = target["id"].as_str().unwrap().to_string();
|
||||
s.execute("variable.update", &json!({"variable": shadow, "name": target})).unwrap();
|
||||
let mut doc = s.doc().unwrap().clone();
|
||||
assert!(set_variable(&mut doc, frame, &target, &json!(9), None));
|
||||
assert_eq!(doc.variables.resolve(&target, &Default::default()), Some(VariableValue::Number(9.0)));
|
||||
assert_eq!(doc.variables.resolve(&shadow, &Default::default()), Some(VariableValue::Number(0.0)));
|
||||
doc.variables.variables.retain(|v| v.id != target);
|
||||
let before = doc.clone();
|
||||
assert!(!set_variable(&mut doc, frame, &target, &json!(11), None));
|
||||
assert_eq!(doc, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_survive_unrelated_interaction_edits() {
|
||||
let (mut s, frame, _, _, _) = setup();
|
||||
let target = s.execute("variable.create", &json!({"collection": "Theme", "name": "target", "type": "number", "value": 5})).unwrap()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
s.execute(
|
||||
"proto.interaction.add",
|
||||
&json!({"id": frame, "actions": [
|
||||
{"type": "back"},
|
||||
{"type": "setVariable", "variable": "target", "expression": "target + 1"},
|
||||
{"type": "setVariableMode", "collection": "Theme", "mode": "Dark"}
|
||||
]}),
|
||||
)
|
||||
.unwrap();
|
||||
let theme = s.doc().unwrap().variables.collections[0].id.clone();
|
||||
s.execute("variable.createCollection", &json!({"name": "shadow", "modes": ["Light", "Dark"]})).unwrap();
|
||||
s.execute("variable.renameCollection", &json!({"collection": "shadow", "name": theme})).unwrap();
|
||||
// Put the namesake before the referenced collection, as variable lookup already does.
|
||||
s.doc_mut().unwrap().variables.collections.swap(0, 1);
|
||||
s.execute("variable.update", &json!({"variable": "count", "name": target})).unwrap();
|
||||
let actions = s.doc().unwrap().node(frame).unwrap().interactions[0].actions.clone();
|
||||
for patch in [json!({"durationMs": 450}), json!({"action": {"type": "closeOverlay"}})] {
|
||||
let mut p = json!({"id": frame, "index": 0});
|
||||
p.as_object_mut().unwrap().extend(patch.as_object().unwrap().clone());
|
||||
s.execute("proto.interaction.set", &p).unwrap();
|
||||
let it = &s.doc().unwrap().node(frame).unwrap().interactions[0];
|
||||
assert_eq!(it.duration_ms, 450);
|
||||
assert_eq!(it.actions[1..], actions[1..], "only the supplied fields change");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_from_text_are_not_resolved_twice() {
|
||||
let (mut s, frame, _, _, _) = setup();
|
||||
let target = s.execute("variable.create", &json!({"collection": "Theme", "name": "target", "type": "number", "value": 5})).unwrap()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
s.execute("variable.set", &json!({"variable": "count", "value": 100})).unwrap();
|
||||
s.execute("variable.update", &json!({"variable": "count", "name": target})).unwrap();
|
||||
let action = json!({"type": "setVariable", "variable": "target", "expression": "target + 1"});
|
||||
s.execute("proto.interaction.add", &json!({"id": frame, "action": action})).unwrap();
|
||||
for (cmd, patch) in [
|
||||
("proto.interaction.add", json!({"action": action})),
|
||||
("proto.interaction.set", json!({"action": action})),
|
||||
("proto.interaction.set", json!({"interaction": {"actions": [action]}})),
|
||||
("proto.action.add", json!({"path": [0], "action": action})),
|
||||
("proto.action.set", json!({"path": [0], "action": action})),
|
||||
] {
|
||||
let mut p = json!({"id": frame});
|
||||
if cmd != "proto.interaction.add" {
|
||||
p["index"] = json!(0);
|
||||
}
|
||||
p.as_object_mut().unwrap().extend(patch.as_object().unwrap().clone());
|
||||
let r = s.execute(cmd, &p).unwrap();
|
||||
let index = if cmd == "proto.interaction.add" { r["index"].as_u64().unwrap() as usize } else { 0 };
|
||||
let Action::SetVariable { variable, expr: Some(expr), .. } = &s.doc().unwrap().node(frame).unwrap().interactions[index].actions[0] else {
|
||||
panic!("{cmd}: no set variable expression")
|
||||
};
|
||||
assert_eq!(variable, &target, "{cmd}");
|
||||
assert_eq!(expr.eval(&s.doc().unwrap().variables, &Default::default()), Some(VariableValue::Number(6.0)), "{cmd}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_resolve_only_incoming_action_and_expression_names() {
|
||||
let (mut s, frame, _, _, _) = setup();
|
||||
let target = s.execute("variable.create", &json!({"collection": "Theme", "name": "target", "type": "number", "value": 5})).unwrap()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
s.execute("variable.set", &json!({"variable": "count", "value": 100})).unwrap();
|
||||
s.execute("variable.update", &json!({"variable": "count", "name": target})).unwrap();
|
||||
let value = json!({"type": "setVariable", "variable": "target"});
|
||||
s.execute(
|
||||
"proto.interaction.add",
|
||||
&json!({"id": frame, "actions": [
|
||||
{"type": "setVariable", "variable": "target", "value": null, "expr": Expr::var(&target)},
|
||||
{"type": "conditional", "blocks": [{"expression": "target == 5", "actions": [
|
||||
{"type": "setVariable", "variable": "label", "value": value}
|
||||
]}]}
|
||||
]}),
|
||||
)
|
||||
.unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let actions = &doc.node(frame).unwrap().interactions[0].actions;
|
||||
let Action::SetVariable { variable, expr: Some(expr), .. } = &actions[0] else { panic!("no set variable") };
|
||||
assert_eq!(variable, &target);
|
||||
// A JSON expression is command input: it accepts names, but an exact id comes first, so the
|
||||
// variable renamed to target's id doesn't take its place (#1613).
|
||||
assert_eq!(expr.eval(&doc.variables, &Default::default()), Some(VariableValue::Number(5.0)));
|
||||
let Action::Conditional { blocks } = &actions[1] else { panic!("no conditional") };
|
||||
assert_eq!(blocks[0].condition.as_ref().unwrap().eval(&doc.variables, &Default::default()), Some(VariableValue::Boolean(true)));
|
||||
let Action::SetVariable { value: stored, .. } = &blocks[0].actions[0] else { panic!("no nested set variable") };
|
||||
assert_eq!(stored, &value, "a literal value is not an action to canonicalize");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_switch_only_the_prototype_collection() {
|
||||
let (s, frame, _, _, r) = setup();
|
||||
let mut doc = s.doc().unwrap().clone();
|
||||
let theme = doc.variables.collections[0].clone();
|
||||
let mut shadow = theme.clone();
|
||||
shadow.id = "shadow".into();
|
||||
shadow.name = theme.id.clone();
|
||||
doc.variables.collections.insert(0, shadow);
|
||||
let dark = theme.find_mode("Dark").unwrap().id.clone();
|
||||
assert!(!crate::proto_run::set_mode_by_id(&mut doc, frame, &theme.id, "Dark"), "a stored mode is an id, not a name");
|
||||
assert!(crate::proto_run::set_mode_by_id(&mut doc, frame, &theme.id, &dark));
|
||||
assert_eq!(fill_hex(&doc, r).as_deref(), Some("000000"));
|
||||
let page = doc.page_of(frame).unwrap();
|
||||
assert!(!doc.node(page).unwrap().var_modes.contains_key("shadow"));
|
||||
// The name-accepting wrapper takes an exact id before a name (#1613).
|
||||
assert!(set_mode(&mut doc, frame, &theme.id, "Light"));
|
||||
assert!(!doc.node(page).unwrap().var_modes.contains_key("shadow"));
|
||||
assert!(set_mode(&mut doc, frame, "shadow", "Light"), "the other collection by its id");
|
||||
assert!(doc.node(page).unwrap().var_modes.contains_key("shadow"));
|
||||
doc.variables.collections.retain(|c| c.id != theme.id);
|
||||
let before = doc.clone();
|
||||
assert!(!crate::proto_run::set_mode_by_id(&mut doc, frame, &theme.id, &dark));
|
||||
assert_eq!(doc, before);
|
||||
}
|
||||
@@ -57,7 +57,7 @@ impl Session {
|
||||
return Err(EngineError::Disabled(label.to_string(), PREVIEW_READ_ONLY.into()));
|
||||
}
|
||||
if ds.interaction.is_none() {
|
||||
ds.interaction = Some(Interaction { label: label.to_string(), doc: ds.doc.clone(), selection: ds.selection.clone() });
|
||||
ds.interaction = Some(Interaction { label: label.to_string(), doc: ds.doc.clone(), selection: ds.selection.clone(), moved_by: None });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -251,7 +251,8 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Run `f` as one undo step called `label`: the commands it executes push no steps of their
|
||||
/// own, and when it fails the documents stay at their group-start state.
|
||||
/// own, and when it fails or panics the documents stay at their group-start state (a panic
|
||||
/// then carries on unwinding).
|
||||
pub fn grouped<T>(&mut self, label: &str, f: impl FnOnce(&mut Self) -> Result<T>) -> Result<T> {
|
||||
self.ds()?;
|
||||
self.checkpointed(Some(label), f)
|
||||
@@ -261,7 +262,12 @@ impl Session {
|
||||
/// A checkpoint also works between closing the last tab and opening another one.
|
||||
fn checkpointed<T>(&mut self, label: Option<&str>, f: impl FnOnce(&mut Self) -> Result<T>) -> Result<T> {
|
||||
let before = self.group_checkpoint(label.is_some());
|
||||
let result = f(self);
|
||||
// A panic in `f` ends the group like a failure, then goes on to the caller's guard (#1085);
|
||||
// the stand-in error only drives the rollback and is never returned.
|
||||
let (result, panic) = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(self))) {
|
||||
Ok(result) => (result, None),
|
||||
Err(payload) => (Err(EngineError::Other("the group panicked".into())), Some(payload)),
|
||||
};
|
||||
self.group_depth -= 1;
|
||||
let at_root = self.group_depth == 0;
|
||||
for ds in &before.docs {
|
||||
@@ -297,6 +303,9 @@ impl Session {
|
||||
self.closed_in_group.clear();
|
||||
self.flush_pending_events();
|
||||
}
|
||||
if let Some(payload) = panic {
|
||||
std::panic::resume_unwind(payload);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
//! A variable renamed to another one's id never takes its place (#1613), beside #1618's tests:
|
||||
//! an id a person types (`variable.bind`, `variable.set`) finds the variable with that exact id
|
||||
//! before one named like it, and the Tailwind export and expressions read the stored id. Names still
|
||||
//! work where a person types them.
|
||||
|
||||
use figcraft_doc::VariableValue;
|
||||
use figcraft_doc::proto::Expr;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::components::tests::ex;
|
||||
|
||||
/// The issue's document: Theme with `shadow`, `surface` and `alias` (→ surface), then `shadow`
|
||||
/// renamed to surface's id with Web code syntax `--shadow`. Ids of shadow, surface and alias.
|
||||
fn renamed_like_an_id() -> (Session, String, String, String) {
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "Theme"}));
|
||||
let id = |v: Value| v["id"].as_str().unwrap().to_string();
|
||||
let shadow = id(ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "shadow", "type": "color", "value": "#FF0000"})));
|
||||
let surface = id(ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "surface", "type": "color", "value": "#0D99FF"})));
|
||||
let alias = id(ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "alias", "type": "color", "value": {"alias": "surface"}})));
|
||||
ex(&mut s, "variable.update", json!({"variable": shadow, "name": surface, "codeSyntax": {"WEB": "--shadow"}}));
|
||||
(s, shadow, surface, alias)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exported_aliases_point_at_the_stored_id() {
|
||||
let (mut s, ..) = renamed_like_an_id();
|
||||
let css = ex(&mut s, "variable.export", json!({"format": "css"}))["text"].as_str().unwrap().to_string();
|
||||
assert!(css.contains("--alias: var(--surface);"), "{css}");
|
||||
let tw = ex(&mut s, "variable.export", json!({"format": "tailwind"}))["text"].as_str().unwrap().to_string();
|
||||
assert!(tw.contains("var(--color-surface)") && !tw.contains("var(--shadow)"), "{tw}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dev_inspect_names_the_bound_variable() {
|
||||
let (mut s, _, surface, _) = renamed_like_an_id();
|
||||
let r = ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}));
|
||||
let r = crate::params::id_of(&r["id"]).unwrap();
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": surface}));
|
||||
let doc = s.doc().unwrap();
|
||||
let found = crate::devinspect::bound_variable(doc, doc.node(r).unwrap(), "fill").unwrap();
|
||||
assert_eq!(found.name, "surface");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expressions_read_and_show_the_stored_variable() {
|
||||
let (mut s, _, surface, _) = renamed_like_an_id();
|
||||
ex(&mut s, "variable.addMode", json!({"collection": "Theme", "name": "Dark"}));
|
||||
ex(&mut s, "variable.set", json!({"variable": surface, "mode": "Dark", "value": "#00FF00"}));
|
||||
let vars = &s.doc().unwrap().variables;
|
||||
let theme = vars.collection("Theme").unwrap().clone();
|
||||
let dark = theme.find_mode("Dark").unwrap().id.clone();
|
||||
let e = Expr::InMode { variable: surface.clone(), mode: dark };
|
||||
assert!(matches!(e.eval(vars, &Default::default()), Some(VariableValue::Color(c)) if c.to_hex() == "00FF00"));
|
||||
assert_eq!(e.display(vars), "surface[Dark]");
|
||||
assert_eq!(Expr::var(surface).display(vars), "surface");
|
||||
}
|
||||
@@ -459,6 +459,12 @@ fn every_command_survives_junk_params() {
|
||||
// would write to it.)
|
||||
json!({"ids": [2, 3], "type": "vector", "path": [5], "width": -1e308, "props": {"path": "M0 0 L1e308 0 L-1e308 1e308", "height": "NaN", "x": 1e308}}),
|
||||
json!({"id": 2, "type": "vector", "path": {"d": 1}, "props": {"path": "M NaN 0 Z", "width": 1e-308}}),
|
||||
// Compact inspection: fields and outline with malformed types and unknown keys.
|
||||
json!({"id": 2, "fields": "name", "outline": true}),
|
||||
json!({"id": 2, "fields": ["name", 5]}),
|
||||
json!({"id": 2, "fields": ["Name", "noSuchField"]}),
|
||||
json!({"id": 2, "fields": null, "outline": []}),
|
||||
json!({"id": 2, "outline": "yes"}),
|
||||
// Layer search roots, filters and limits as junk.
|
||||
json!({"root": {"id": 2}, "name": [1], "type": "nope", "text": true, "component": [], "limit": -1}),
|
||||
json!({"root": 999999, "limit": 1e308}),
|
||||
|
||||
@@ -15,7 +15,7 @@ fn id(v: Value) -> NodeId {
|
||||
id_of(&v["id"]).unwrap()
|
||||
}
|
||||
|
||||
fn vid(v: Value) -> String {
|
||||
pub(crate) fn vid(v: Value) -> String {
|
||||
v["id"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
@@ -498,3 +498,24 @@ fn a_translucent_variable_bound_to_an_empty_fill_applies_its_alpha_once() {
|
||||
assert_eq!(node(&s, r).fills[0].solid_color().unwrap().to_css_hex(), "#00FF0040");
|
||||
assert_eq!(node(&s, r).fills[0].opacity, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_cannot_hide_an_alias_cycle() {
|
||||
for link in ["a", "b"] {
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "variable.createCollection", json!({"name": "Theme"}));
|
||||
let mut ids = std::collections::BTreeMap::new();
|
||||
for name in ["shadow", "a", "b"] {
|
||||
let v = run(&mut s, "variable.create", json!({"collection": "Theme", "name": name, "type": "number", "value": 1}));
|
||||
ids.insert(name, v["id"].clone());
|
||||
}
|
||||
run(&mut s, "variable.set", json!({"variable": "b", "value": {"alias": "a"}}));
|
||||
run(&mut s, "variable.update", json!({"variable": ids["shadow"], "name": ids[link]}));
|
||||
let before = s.doc().unwrap().clone();
|
||||
let undo = s.ds().unwrap().history.undo.len();
|
||||
let error = s.execute("variable.set", &json!({"variable": "a", "value": {"alias": "b"}})).unwrap_err();
|
||||
assert!(error.to_string().contains("refer back to itself"), "{link}: {error}");
|
||||
assert_eq!(s.doc().unwrap(), &before);
|
||||
assert_eq!(s.ds().unwrap().history.undo.len(), undo);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
//! Colour calculations: `{alpha: …}` (a referenced colour with its alpha replaced) and `{mix: …}`
|
||||
//! (a blend of two colour operands) as per-mode variable values — set and create shapes, lazy
|
||||
//! propagation to bound layers and mode switches, composition over aliases and other
|
||||
//! calculations, set-time refusal of cycles and bad operands (leaving the variable untouched),
|
||||
//! deletion flattening, undo/redo exactness, save/load round trip, exports and junk input.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::components::tests::{ex, idv};
|
||||
use crate::error::EngineError;
|
||||
use crate::paint_tests::fill;
|
||||
use crate::plugins_tests::rect;
|
||||
use crate::variables_behaviour_tests::vid;
|
||||
use crate::widgets_tests::err;
|
||||
use figcraft_color::Color;
|
||||
|
||||
/// A "C" collection (modes M) with `ink` #FF0000 and `edge` #00FF00 (the error-case set's seeds).
|
||||
fn seeds() -> Session {
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "C", "modes": ["M"]}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "C", "name": "ink", "type": "color", "value": "#FF0000"}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "C", "name": "edge", "type": "color", "value": "#00FF00"}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "C", "name": "size", "type": "number", "value": 5}));
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_accepts_alpha_and_mix_and_they_propagate_to_bound_layers() {
|
||||
let mut s = seeds();
|
||||
let hover = vid(ex(
|
||||
&mut s,
|
||||
"variable.create",
|
||||
json!({"collection": "C", "name": "hover", "type": "color", "value": {"alpha": {"variable": "ink", "opacity": 0.5}}}),
|
||||
));
|
||||
let box_id = rect(&mut s, 0.0);
|
||||
ex(&mut s, "variable.bind", json!({"ids": [box_id], "field": "fill", "variable": "hover"}));
|
||||
assert_eq!(fill(&s, box_id).to_rgba8(), [255, 0, 0, 128], "alpha replaces the source's alpha");
|
||||
ex(&mut s, "variable.set", json!({"variable": "ink", "value": "#00AA00"}));
|
||||
assert_eq!(fill(&s, box_id).to_rgba8(), [0, 170, 0, 128], "the bound layer follows the source, no re-set needed");
|
||||
// A mix of two variables, one literal, and the t edges.
|
||||
ex(&mut s, "variable.set", json!({"variable": hover, "value": {"mix": {"from": {"variable": "ink"}, "to": {"variable": "edge"}, "t": 0.0}}}));
|
||||
assert_eq!(fill(&s, box_id).to_rgba8(), [0, 170, 0, 255], "t=0 gives from exactly");
|
||||
ex(
|
||||
&mut s,
|
||||
"variable.set",
|
||||
json!({"variable": hover, "value": {"mix": {"from": {"literal": "#000000"}, "to": {"literal": "#FFFFFF"}, "t": 0.4}}}),
|
||||
);
|
||||
assert_eq!(fill(&s, box_id).to_rgba8(), [102, 102, 102, 255], "literal operands blend (0.4 -> 0x66)");
|
||||
// Compose: a mix reading the calculation, and alpha over an alias.
|
||||
ex(&mut s, "variable.create", json!({"collection": "C", "name": "mirror", "type": "color", "value": {"alias": "ink"}}));
|
||||
ex(
|
||||
&mut s,
|
||||
"variable.create",
|
||||
json!({"collection": "C", "name": "faded-mirror", "type": "color", "value": {"alpha": {"variable": "mirror", "opacity": 0.5}}}),
|
||||
);
|
||||
let b2 = rect(&mut s, 0.0);
|
||||
ex(&mut s, "variable.bind", json!({"ids": [b2], "field": "fill", "variable": "faded-mirror"}));
|
||||
assert_eq!(fill(&s, b2).to_rgba8(), [0, 170, 0, 128], "alpha over an alias reads the aliased value");
|
||||
let toast = vid(ex(
|
||||
&mut s,
|
||||
"variable.create",
|
||||
json!({"collection": "C", "name": "toast", "type": "color", "value": {"alpha": {"variable": "hover", "opacity": 0.25}}}),
|
||||
));
|
||||
let b3 = rect(&mut s, 0.0);
|
||||
ex(&mut s, "variable.bind", json!({"ids": [b3], "field": "fill", "variable": toast}));
|
||||
assert_eq!(fill(&s, b3).to_rgba8(), [102, 102, 102, 64], "a calculation may read a calculation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_mode_operands_follow_each_collection_chosen_mode() {
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "Core", "modes": ["Light", "Dark"]}));
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "Extra", "modes": ["Light", "Dark"]}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "Core", "name": "p", "type": "color", "values": {"Light": "#FF0000", "Dark": "#0000FF"}}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "Extra", "name": "s", "type": "color", "values": {"Light": "#000000", "Dark": "#FFFFFF"}}));
|
||||
ex(
|
||||
&mut s,
|
||||
"variable.create",
|
||||
json!({"collection": "Core", "name": "panel", "type": "color",
|
||||
"values": {"Light": {"mix": {"from": {"variable": "p"}, "to": {"variable": "s"}, "t": 0.5}},
|
||||
"Dark": {"mix": {"from": {"variable": "p"}, "to": {"variable": "s"}, "t": 0.5}}}}),
|
||||
);
|
||||
let b = rect(&mut s, 0.0);
|
||||
ex(&mut s, "variable.bind", json!({"ids": [b], "field": "fill", "variable": "panel"}));
|
||||
assert_eq!(fill(&s, b).to_rgba8(), [128, 0, 0, 255], "Light mixes Light values of BOTH collections");
|
||||
ex(&mut s, "variable.setMode", json!({"ids": [b], "collection": "Core", "mode": "Dark"}));
|
||||
assert_eq!(fill(&s, b).to_rgba8(), [0, 0, 128, 255], "a Core switch moves only the Core operand (Extra stays Light)");
|
||||
ex(&mut s, "variable.setMode", json!({"ids": [b], "collection": "Extra", "mode": "Dark"}));
|
||||
assert_eq!(fill(&s, b).to_rgba8(), [128, 128, 255, 255], "the Extra switch then moves its operand, independently");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_shapes_and_operands_are_refused_without_touching_the_variable() {
|
||||
let mut s = seeds();
|
||||
let before = json!(s.doc().unwrap().variables);
|
||||
let want_t = "`t` must be a number from 0 to 1";
|
||||
let want_opacity = "`opacity` must be a number from 0 to 1";
|
||||
let want_operand = "mix operands are {variable: …} or {literal: colour}";
|
||||
let cases: Vec<(&str, Value, &str)> = vec![
|
||||
(
|
||||
"self alpha",
|
||||
json!({"variable": "ink", "value": {"alpha": {"variable": "ink", "opacity": 0.5}}}),
|
||||
"the colour calculation would refer back to itself",
|
||||
),
|
||||
(
|
||||
"unknown operand",
|
||||
json!({"variable": "ink", "value": {"alpha": {"variable": "ghost", "opacity": 0.5}}}),
|
||||
"no variable `ghost` to calculate from",
|
||||
),
|
||||
(
|
||||
"number operand",
|
||||
json!({"variable": "ink", "value": {"alpha": {"variable": "size", "opacity": 0.5}}}),
|
||||
"colour calculations take colour variables: `size` is a number one",
|
||||
),
|
||||
(
|
||||
"number mix operand",
|
||||
json!({"variable": "ink", "value": {"mix": {"from": {"variable": "size"}, "to": {"variable": "edge"}, "t": 0.5}}}),
|
||||
"colour calculations take colour variables: `size` is a number one",
|
||||
),
|
||||
(
|
||||
"calculation on a number variable",
|
||||
json!({"variable": "size", "value": {"alpha": {"variable": "ink", "opacity": 0.5}}}),
|
||||
"colour calculations only fit colour variables",
|
||||
),
|
||||
("t too big", json!({"variable": "ink", "value": {"mix": {"from": {"variable": "edge"}, "to": {"literal": "#FFFFFF"}, "t": 1.5}}}), want_t),
|
||||
("t missing", json!({"variable": "ink", "value": {"mix": {"from": {"variable": "edge"}, "to": {"literal": "#FFFFFF"}}}}), want_t),
|
||||
(
|
||||
"t as text",
|
||||
json!({"variable": "ink", "value": {"mix": {"from": {"variable": "edge"}, "to": {"literal": "#FFFFFF"}, "t": "half"}}}),
|
||||
want_t,
|
||||
),
|
||||
("opacity above 1", json!({"variable": "ink", "value": {"alpha": {"variable": "edge", "opacity": 2}}}), want_opacity),
|
||||
("opacity missing", json!({"variable": "ink", "value": {"alpha": {"variable": "edge"}}}), want_opacity),
|
||||
("alpha on a literal", json!({"variable": "ink", "value": {"alpha": {"literal": "#FF0000", "opacity": 0.5}}}), "alpha: {variable, opacity}"),
|
||||
("mix with a bare string", json!({"variable": "ink", "value": {"mix": {"from": "ink", "to": {"variable": "edge"}, "t": 0.5}}}), want_operand),
|
||||
(
|
||||
"mix operand with two keys",
|
||||
json!({"variable": "ink", "value": {"mix": {"from": {"variable": "ink", "literal": "#000000"}, "to": {"variable": "edge"}, "t": 0.5}}}),
|
||||
want_operand,
|
||||
),
|
||||
("unknown key in alpha", json!({"variable": "ink", "value": {"alpha": {"variable": "edge", "opactiy": 0.5}}}), "alpha: {variable, opacity}"),
|
||||
// A bad parameter is named even beside an operand that loops: it is checked first.
|
||||
(
|
||||
"bad t beside a loop",
|
||||
json!({"variable": "ink", "value": {"mix": {"from": {"variable": "ink"}, "to": {"variable": "edge"}, "t": 2}}}),
|
||||
want_t,
|
||||
),
|
||||
("bad opacity beside a loop", json!({"variable": "ink", "value": {"alpha": {"variable": "ink", "opacity": -1}}}), want_opacity),
|
||||
];
|
||||
for (label, p, want) in cases {
|
||||
let msg = err(&mut s, "variable.set", p.clone());
|
||||
assert!(msg.contains(want), "{label}: {msg}");
|
||||
}
|
||||
// A second key beside the operation is refused, not dropped.
|
||||
let alpha = json!({"variable": "edge", "opacity": 0.5});
|
||||
let mix = json!({"from": {"variable": "edge"}, "to": {"literal": "#FFFFFF"}, "t": 0.5});
|
||||
for (label, value) in [
|
||||
("alpha and mix", json!({"alpha": alpha.clone(), "mix": mix.clone()})),
|
||||
("alias beside alpha", json!({"alias": "edge", "alpha": alpha})),
|
||||
("unknown key beside mix", json!({"mix": mix, "space": "oklch"})),
|
||||
] {
|
||||
let msg = err(&mut s, "variable.set", json!({"variable": "ink", "value": value}));
|
||||
assert!(msg.contains("on its own"), "{label}: {msg}");
|
||||
let msg = err(&mut s, "variable.create", json!({"collection": "C", "name": "extra", "type": "color", "value": value}));
|
||||
assert!(msg.contains("on its own"), "{label} on create: {msg}");
|
||||
}
|
||||
assert_eq!(json!(s.doc().unwrap().variables), before, "the refusals left every variable as it was");
|
||||
// A cycle reached through binary operands too: ink mixes edge; then edge must not read ink.
|
||||
ex(&mut s, "variable.set", json!({"variable": "ink", "value": {"mix": {"from": {"variable": "edge"}, "to": {"literal": "#FFFFFF"}, "t": 0.5}}}));
|
||||
let msg = err(&mut s, "variable.set", json!({"variable": "edge", "value": {"alpha": {"variable": "ink", "opacity": 0.5}}}));
|
||||
assert!(msg.contains("the colour calculation would refer back to itself"), "cycle named as such: {msg}");
|
||||
let msg = err(
|
||||
&mut s,
|
||||
"variable.set",
|
||||
json!({"variable": "edge", "value": {"mix": {"from": {"variable": "ink"}, "to": {"literal": "#FFFFFF"}, "t": 0.5}}}),
|
||||
);
|
||||
assert!(msg.contains("the colour calculation would refer back to itself"), "mix-cycle named as such: {msg}");
|
||||
// None of the refusals touched the variables: ink still holds its last accepted value, and a
|
||||
// fresh calculation on it works.
|
||||
let b = rect(&mut s, 0.0);
|
||||
ex(&mut s, "variable.bind", json!({"ids": [b], "field": "fill", "variable": "ink"}));
|
||||
assert_eq!(fill(&s, b).to_rgba8(), [128, 255, 128, 255], "ink keeps the accepted mix (edge + white at 0.5)");
|
||||
ex(&mut s, "variable.set", json!({"variable": "ink", "value": {"alpha": {"variable": "edge", "opacity": 0.5}}}));
|
||||
assert_eq!(fill(&s, b).to_rgba8(), [0, 255, 0, 128], "and calculations still set afterwards");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_and_redo_are_exact_through_calculations() {
|
||||
let mut s = seeds();
|
||||
let hover = vid(ex(
|
||||
&mut s,
|
||||
"variable.create",
|
||||
json!({"collection": "C", "name": "hover", "type": "color", "value": {"alpha": {"variable": "ink", "opacity": 0.5}}}),
|
||||
));
|
||||
let b = rect(&mut s, 0.0);
|
||||
ex(&mut s, "variable.bind", json!({"ids": [b], "field": "fill", "variable": hover}));
|
||||
let first = fill(&s, b).to_rgba8();
|
||||
ex(&mut s, "variable.set", json!({"variable": "hover", "value": {"alpha": {"variable": "edge", "opacity": 0.25}}}));
|
||||
let second = fill(&s, b).to_rgba8();
|
||||
assert_ne!(first, second);
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(fill(&s, b).to_rgba8(), first, "undo restores the stored alpha calculation exactly");
|
||||
ex(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(fill(&s, b).to_rgba8(), second, "redo replays the new one exactly");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_source_flattens_its_calculations() {
|
||||
let mut s = seeds();
|
||||
ex(
|
||||
&mut s,
|
||||
"variable.create",
|
||||
json!({"collection": "C", "name": "fade", "type": "color", "value": {"alpha": {"variable": "ink", "opacity": 0.5}}}),
|
||||
);
|
||||
let b = rect(&mut s, 0.0);
|
||||
ex(&mut s, "variable.bind", json!({"ids": [b], "field": "fill", "variable": "fade"}));
|
||||
let before = json!(s.doc().unwrap().variables);
|
||||
ex(&mut s, "variable.delete", json!({"variable": "ink"}));
|
||||
assert_eq!(fill(&s, b).to_rgba8(), [255, 0, 0, 128], "the bound layer keeps the colour it showed");
|
||||
let fade: Vec<_> = s.doc().unwrap().variables.get("fade").unwrap().values.values().cloned().collect();
|
||||
assert_eq!(fade, [figcraft_doc::VariableValue::Color(Color::new(1.0, 0.0, 0.0, 0.5))], "the calculation became the colour it showed");
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(json!(s.doc().unwrap().variables), before, "undo restores every variable exactly");
|
||||
}
|
||||
|
||||
/// Deleting a collection at once flattens every reader outside it — an alias of a calculation, a
|
||||
/// calculation of two deleted variables — and deleting a calculation flattens a calculation that
|
||||
/// reads it; undo restores the variables exactly each time.
|
||||
#[test]
|
||||
fn deleting_sources_together_flattens_every_reader() {
|
||||
let mut s = seeds();
|
||||
let calc = |name: &str, value: Value| json!({"collection": "C", "name": name, "type": "color", "value": value});
|
||||
ex(&mut s, "variable.create", calc("fade", json!({"alpha": {"variable": "ink", "opacity": 0.5}})));
|
||||
ex(&mut s, "variable.create", calc("wash", json!({"mix": {"from": {"variable": "fade"}, "to": {"literal": "#FFFFFF"}, "t": 0.5}})));
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "App"}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "App", "name": "echo", "type": "color", "value": {"alias": "wash"}}));
|
||||
let tint = json!({"mix": {"from": {"variable": "edge"}, "to": {"variable": "ink"}, "t": 0.5}});
|
||||
ex(&mut s, "variable.create", json!({"collection": "App", "name": "tint", "type": "color", "value": tint}));
|
||||
let stored = |s: &Session, name: &str| s.doc().unwrap().variables.get(name).unwrap().values.values().next().cloned().unwrap();
|
||||
let plain = |s: &Session, name: &str| match stored(s, name) {
|
||||
figcraft_doc::VariableValue::Color(c) => c.to_rgba8(),
|
||||
other => panic!("{name} is not a plain colour: {other:?}"),
|
||||
};
|
||||
let before = json!(s.doc().unwrap().variables);
|
||||
|
||||
ex(&mut s, "variable.deleteCollection", json!({"collection": "C"}));
|
||||
assert_eq!(plain(&s, "echo"), [255, 128, 128, 191], "the alias of a calculation keeps what it showed");
|
||||
assert_eq!(plain(&s, "tint"), [128, 128, 0, 255], "a calculation of two deleted variables keeps its colour");
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(json!(s.doc().unwrap().variables), before, "undo restores the collection and its readers exactly");
|
||||
|
||||
ex(&mut s, "variable.delete", json!({"variable": "fade"}));
|
||||
assert_eq!(plain(&s, "wash"), [255, 128, 128, 191], "a calculation of the deleted calculation keeps its colour");
|
||||
assert!(matches!(stored(&s, "echo"), figcraft_doc::VariableValue::Alias(_)), "an alias of a surviving variable stays an alias");
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(json!(s.doc().unwrap().variables), before);
|
||||
}
|
||||
|
||||
/// One resolve reads at most `Variables::RESOLVE_LIMIT` variables: a mix of two alias chains that
|
||||
/// would read more is refused when it is set rather than left showing nothing.
|
||||
#[test]
|
||||
fn a_calculation_reading_too_many_variables_is_refused() {
|
||||
let mut s = seeds();
|
||||
for side in ["a", "b"] {
|
||||
ex(&mut s, "variable.create", json!({"collection": "C", "name": format!("{side}0"), "type": "color", "value": {"alias": "ink"}}));
|
||||
for i in 1..16 {
|
||||
let value = json!({"alias": format!("{side}{}", i - 1)});
|
||||
ex(&mut s, "variable.create", json!({"collection": "C", "name": format!("{side}{i}"), "type": "color", "value": value}));
|
||||
}
|
||||
}
|
||||
// Reading `a{k}` and `b{k}` takes 2k + 4 variables: itself, both chains and the `ink` they share.
|
||||
let mix = |k: usize| json!({"mix": {"from": {"variable": format!("a{k}")}, "to": {"variable": format!("b{k}")}, "t": 0.5}});
|
||||
ex(&mut s, "variable.create", json!({"collection": "C", "name": "fits", "type": "color", "value": mix(14)}));
|
||||
let b = rect(&mut s, 0.0);
|
||||
ex(&mut s, "variable.bind", json!({"ids": [b], "field": "fill", "variable": "fits"}));
|
||||
assert_eq!(fill(&s, b).to_rgba8(), [255, 0, 0, 255], "32 variables still resolve");
|
||||
let too_many = "the colour calculation reads more than 32 variables";
|
||||
let msg = err(&mut s, "variable.create", json!({"collection": "C", "name": "over", "type": "color", "value": mix(15)}));
|
||||
assert!(msg.contains(too_many), "create: {msg}");
|
||||
let msg = err(&mut s, "variable.set", json!({"variable": "fits", "value": mix(15)}));
|
||||
assert!(msg.contains(too_many), "set: {msg}");
|
||||
}
|
||||
|
||||
/// Junk anywhere inside a calculation is refused cleanly: the variables exist, so it reaches the
|
||||
/// calculation parsers (the command-wide sweep starts from a document without variables).
|
||||
#[test]
|
||||
fn calculation_parsers_survive_junk() {
|
||||
let junk = [
|
||||
json!(null),
|
||||
json!(1e308),
|
||||
json!(-0.0),
|
||||
json!("NaN"),
|
||||
json!("\u{0}"),
|
||||
json!([]),
|
||||
json!({}),
|
||||
json!([1e308, -1e308, "x"]),
|
||||
json!({"variable": [1]}),
|
||||
json!({"variable": "ink", "literal": "#000"}),
|
||||
json!({"literal": {"r": 1e308, "g": "x"}}),
|
||||
json!({"mix": {"from": {"literal": {"mix": 1}}}}),
|
||||
json!({"alpha": {"variable": "ink", "opacity": 0.5}}),
|
||||
];
|
||||
for j in &junk {
|
||||
for value in [
|
||||
json!({"alpha": j}),
|
||||
json!({"mix": j}),
|
||||
json!({"mix": {"from": j, "to": j, "t": j}}),
|
||||
json!({"alpha": {"variable": j, "opacity": j}}),
|
||||
json!({"alpha": {"variable": "ink", "opacity": j}}),
|
||||
json!({"mix": {"from": {"variable": "ink"}, "to": {"literal": j}, "t": 0.5}}),
|
||||
] {
|
||||
for (cmd, p) in [
|
||||
("variable.set", json!({"variable": "edge", "value": value})),
|
||||
("variable.set", json!({"variable": "size", "value": value})),
|
||||
("variable.create", json!({"collection": "C", "name": "junk", "type": "color", "values": {"M": value}})),
|
||||
] {
|
||||
let mut s = seeds();
|
||||
if let Err(EngineError::Internal { cmd, msg }) = s.execute(cmd, &p) {
|
||||
panic!("{cmd} panicked on {p}: {msg}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calculations_survive_save_and_load() {
|
||||
let mut s = seeds();
|
||||
ex(
|
||||
&mut s,
|
||||
"variable.create",
|
||||
json!({"collection": "C", "name": "mixv", "type": "color", "value": {"mix": {"from": {"variable": "ink"}, "to": {"variable": "edge"}, "t": 0.4}}}),
|
||||
);
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-colour-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("d.figcraft");
|
||||
ex(&mut s, "file.save", json!({"path": path.to_string_lossy()}));
|
||||
let mut t = Session::new();
|
||||
ex(&mut t, "file.open", json!({"path": path.to_string_lossy()}));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let list = ex(&mut t, "variable.list", json!({}));
|
||||
let mixv = list["variables"].as_array().unwrap().iter().find(|v| v["name"] == "mixv").unwrap();
|
||||
assert_eq!(mixv["values"].as_object().unwrap().values().next().unwrap()["type"], "colorMix", "still a calculation after a round trip");
|
||||
// And it still propagates in the reloaded document.
|
||||
let b = idv(&ex(&mut t, "shape.rectangle", json!({"x": 0, "y": 0, "width": 4, "height": 4})));
|
||||
ex(&mut t, "variable.bind", json!({"ids": [b], "field": "fill", "variable": "mixv"}));
|
||||
assert_eq!(fill(&t, b).to_rgba8(), [153, 102, 0, 255], "ink + edge at 0.4: r=255*(1-0.4), g=255*0.4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rest_and_css_exports_write_calculations_as_their_colours() {
|
||||
let mut s = seeds();
|
||||
// The exported REST variable `name` of `collections`, and the CSS text for them.
|
||||
let json_variable = |s: &mut Session, collections: Value, name: &str| -> Value {
|
||||
let text = ex(s, "variable.export", json!({"format": "json", "collections": collections}));
|
||||
let doc: Value = serde_json::from_str(text["text"].as_str().unwrap()).unwrap();
|
||||
let meta = doc.get("meta").unwrap_or(&doc);
|
||||
meta["variables"].as_object().unwrap().values().find(|v| v["name"] == name).unwrap().clone()
|
||||
};
|
||||
let css = |s: &mut Session, collections: Value| {
|
||||
ex(s, "variable.export", json!({"format": "css", "collections": collections}))["text"].as_str().unwrap().to_string()
|
||||
};
|
||||
ex(
|
||||
&mut s,
|
||||
"variable.create",
|
||||
json!({"collection": "C", "name": "fade", "type": "color", "value": {"alpha": {"variable": "ink", "opacity": 0.5}}}),
|
||||
);
|
||||
let fade = json_variable(&mut s, json!(["C"]), "fade");
|
||||
let value = fade["valuesByMode"].as_object().unwrap().values().next().unwrap();
|
||||
assert_eq!(value["r"], 1.0);
|
||||
assert_eq!(value["a"], 0.5);
|
||||
assert!(value.get("type").is_none(), "no calculation shape leaks into the REST JSON: {value}");
|
||||
let text = css(&mut s, json!(["C"]));
|
||||
assert!(text.contains("rgba(255, 0, 0, 0.5"), "the CSS export writes the colour it resolves to: {text}");
|
||||
// Each mode is written as the colour it shows in that mode.
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "Theme", "modes": ["Light", "Dark"]}));
|
||||
ex(
|
||||
&mut s,
|
||||
"variable.create",
|
||||
json!({"collection": "Theme", "name": "brand", "type": "color", "values": {"Light": "#FF0000", "Dark": "#0000FF"}}),
|
||||
);
|
||||
let veil = json!({"alpha": {"variable": "brand", "opacity": 0.5}});
|
||||
ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "veil", "type": "color", "value": veil}));
|
||||
let veil = json_variable(&mut s, json!(["Theme"]), "veil");
|
||||
let mut per_mode: Vec<(f64, f64, f64)> = veil["valuesByMode"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.values()
|
||||
.map(|v| (v["r"].as_f64().unwrap(), v["b"].as_f64().unwrap(), v["a"].as_f64().unwrap()))
|
||||
.collect();
|
||||
per_mode.sort_by(|x, y| x.partial_cmp(y).unwrap());
|
||||
assert_eq!(per_mode, [(0.0, 1.0, 0.5), (1.0, 0.0, 0.5)], "Light and Dark each written as their own colour");
|
||||
let text = css(&mut s, json!(["Theme"]));
|
||||
assert!(text.contains("rgba(255, 0, 0, 0.5") && text.contains("rgba(0, 0, 255, 0.5"), "both modes in CSS: {text}");
|
||||
}
|
||||
|
||||
/// A hand-edited file can hold a calculation that resolves to nothing (one reading a variable
|
||||
/// that isn't there). The CSS export leaves it out, so no property may name it: an alias to it
|
||||
/// goes out as the colour it shows, or is left out when that is nothing.
|
||||
#[test]
|
||||
fn css_never_names_a_calculation_it_leaves_out() {
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "T", "modes": ["Light", "Dark"]}));
|
||||
let ink = json!({"collection": "T", "name": "ink", "type": "color", "values": {"Light": "#FF0000", "Dark": "#0000FF"}});
|
||||
ex(&mut s, "variable.create", ink);
|
||||
for name in ["lost", "half"] {
|
||||
ex(&mut s, "variable.create", json!({"collection": "T", "name": name, "type": "color", "value": "#000000"}));
|
||||
}
|
||||
for (name, to) in [("lost-too", "lost"), ("half-too", "half"), ("ink-too", "ink")] {
|
||||
ex(&mut s, "variable.create", json!({"collection": "T", "name": name, "type": "color", "value": {"alias": to}}));
|
||||
}
|
||||
// As a hand-edited file could hold them: `lost` reads a variable that isn't there in both
|
||||
// modes, `half` only in Dark.
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let ink = doc.variables.get("ink").unwrap().id.clone();
|
||||
let modes: Vec<String> = doc.variables.collection("T").unwrap().modes.iter().map(|m| m.id.clone()).collect();
|
||||
let gone = figcraft_doc::VariableValue::ColorAlpha { variable: "gone".into(), opacity: 0.5 };
|
||||
let half = figcraft_doc::VariableValue::ColorAlpha { variable: ink, opacity: 0.5 };
|
||||
for v in &mut doc.variables.variables {
|
||||
match v.name.as_str() {
|
||||
"lost" => v.values = modes.iter().map(|m| (m.clone(), gone.clone())).collect(),
|
||||
"half" => v.values = [(modes[0].clone(), half.clone()), (modes[1].clone(), gone.clone())].into_iter().collect(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let css = ex(&mut s, "variable.export", json!({"format": "css"}))["text"].as_str().unwrap().to_string();
|
||||
let (light, dark) = css.split_once("[data-").unwrap();
|
||||
assert!(!css.contains("var(--lost)") && !css.contains("var(--half)"), "no var() of a property a block leaves out: {css}");
|
||||
assert!(!css.contains("--lost-too"), "an alias to what shows nothing is left out: {css}");
|
||||
assert!(light.contains("--half-too: rgba(255, 0, 0, 0.5") && !dark.contains("--half-too"), "the colour it shows, where it shows one: {css}");
|
||||
assert!(css.contains("--ink-too: var(--ink);"), "an alias that always resolves stays a reference: {css}");
|
||||
}
|
||||
|
||||
/// A calculation is a variable's value, not a literal an expression may hold: nothing would check
|
||||
/// what it reads. Every way an expression arrives refuses one.
|
||||
#[test]
|
||||
fn prototype_expressions_refuse_a_calculation_literal() {
|
||||
let mut s = seeds();
|
||||
let btn = rect(&mut s, 0.0);
|
||||
let dest = idv(&ex(&mut s, "frame.create", json!({"x": 200, "y": 0, "width": 100, "height": 100})));
|
||||
ex(&mut s, "proto.interaction.add", json!({"id": btn, "dest": dest}));
|
||||
let calc = json!({"value": {"type": "colorAlpha", "value": {"variable": "ink", "opacity": 9}}});
|
||||
let msg = err(&mut s, "proto.evaluate", json!({"expr": calc}));
|
||||
assert!(msg.contains("Not a value"), "evaluate: {msg}");
|
||||
let set = json!({"type": "setVariable", "variable": "edge", "value": null, "expr": calc});
|
||||
let msg = err(&mut s, "proto.action.add", json!({"id": btn, "index": 0, "action": set}));
|
||||
assert!(msg.contains("Not a value"), "set variable action: {msg}");
|
||||
let added = ex(
|
||||
&mut s,
|
||||
"proto.action.add",
|
||||
json!({"id": btn, "index": 0, "action": {"type": "conditional", "blocks": [{"expression": "size > 1", "actions": []}]}}),
|
||||
);
|
||||
let same = json!({"op": {"op": "equals", "args": [{"value": {"type": "alias", "value": "ink"}}, calc]}});
|
||||
let msg = err(&mut s, "proto.conditional.setCondition", json!({"id": btn, "index": 0, "path": added["path"], "block": 0, "condition": same}));
|
||||
assert!(msg.contains("Not a value"), "condition: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rest_import_skips_a_hand_written_calculation_with_a_warning() {
|
||||
// The variables JSON has no shape for calculations; a hand-written one must not import or corrupt.
|
||||
let mut s = seeds();
|
||||
let text = json!({
|
||||
"variableCollections": {"COL:X": {"id": "COL:X", "name": "Imp", "modes": [{"modeId": "M:1", "name": "M"}], "defaultModeId": "M:1", "variableIds": ["V:9"]}},
|
||||
"variables": {"V:9": {"id": "V:9", "name": "ghost", "variableCollectionId": "COL:X", "resolvedType": "COLOR",
|
||||
"valuesByMode": {"M:1": {"type": "colorAlpha", "value": {"variable": "V:1", "opacity": 0.5}}}}}
|
||||
});
|
||||
let out = ex(&mut s, "variable.import", json!({"variables": text}));
|
||||
assert!(out["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap_or("").contains("composed colours")), "skip warning present: {out}");
|
||||
let list = ex(&mut s, "variable.list", json!({}));
|
||||
assert!(list["variables"].as_array().unwrap().iter().all(|v| v["name"] != "ghost"), "the calculation was skipped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_conversion_reaches_the_colours_stored_in_a_calculation() {
|
||||
// A mix of identical literals shows that colour; after a converting profile change the
|
||||
// mix-bound layer must match a layer bound to the plain colour (the literal converted too).
|
||||
let mut s = seeds();
|
||||
ex(&mut s, "variable.create", json!({"collection": "C", "name": "p", "type": "color", "value": "#804020"}));
|
||||
ex(
|
||||
&mut s,
|
||||
"variable.create",
|
||||
json!({"collection": "C", "name": "dl", "type": "color", "value": {"mix": {"from": {"literal": "#804020"}, "to": {"literal": "#804020"}, "t": 0.5}}}),
|
||||
);
|
||||
let a = rect(&mut s, 0.0);
|
||||
let b = rect(&mut s, 0.0);
|
||||
ex(&mut s, "variable.bind", json!({"ids": [a], "field": "fill", "variable": "p"}));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [b], "field": "fill", "variable": "dl"}));
|
||||
ex(&mut s, "file.colorProfile", json!({"profile": "displayP3"}));
|
||||
let (fa, fb) = (fill(&s, a), fill(&s, b));
|
||||
assert_ne!(fa.to_rgba8(), [128, 64, 32, 255], "the profile change converted the plain colour");
|
||||
assert_eq!(fa.to_rgba8(), fb.to_rgba8(), "the colour stored inside the calculation converted the same way");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_zero_bounds_are_stored_as_plain_zero() {
|
||||
let mut s = seeds();
|
||||
ex(
|
||||
&mut s,
|
||||
"variable.create",
|
||||
json!({"collection": "C", "name": "n0", "type": "color", "value": {"alpha": {"variable": "ink", "opacity": -0.0}}}),
|
||||
);
|
||||
ex(
|
||||
&mut s,
|
||||
"variable.create",
|
||||
json!({"collection": "C", "name": "m0", "type": "color", "value": {"mix": {"from": {"variable": "ink"}, "to": {"variable": "edge"}, "t": -0.0}}}),
|
||||
);
|
||||
let list = serde_json::to_string(&ex(&mut s, "variable.list", json!({}))).unwrap();
|
||||
assert!(!list.contains("-0"), "no negative zeros persisted: {list}");
|
||||
}
|
||||
@@ -125,3 +125,66 @@ fn create_collection_refuses_a_taken_name() {
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "Theme 2"}));
|
||||
assert_eq!(s.doc().unwrap().variables.collections.len(), before + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_detach_deleted_aliases_per_mode() {
|
||||
for collection in [false, true] {
|
||||
let mut s = Session::new();
|
||||
for name in ["Base", "Theme"] {
|
||||
ex(&mut s, "variable.createCollection", json!({"name": name, "modes": ["Light", "Dark"]}));
|
||||
}
|
||||
let shadow = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "shadow", "type": "color", "value": "#FF0000"}));
|
||||
let surface = ex(
|
||||
&mut s,
|
||||
"variable.create",
|
||||
json!({"collection": "Base", "name": "surface", "type": "color", "values": {"Light": "#0D99FF", "Dark": "#000000"}}),
|
||||
);
|
||||
// Use a different collection only when deleting the whole target collection.
|
||||
let home = if collection { "Theme" } else { "Base" };
|
||||
let alias = ex(&mut s, "variable.create", json!({"collection": home, "name": "alias", "type": "color", "value": {"alias": surface["id"]}}));
|
||||
ex(&mut s, "variable.update", json!({"variable": shadow["id"], "name": surface["id"]}));
|
||||
let aid = alias["id"].as_str().unwrap();
|
||||
let before = s.doc().unwrap().variables.get_by_id(aid).unwrap().clone();
|
||||
let expected: std::collections::BTreeMap<_, _> = before
|
||||
.values
|
||||
.keys()
|
||||
.map(|m| {
|
||||
let modes = std::collections::BTreeMap::from([(before.collection.clone(), m.clone())]);
|
||||
(m.clone(), s.doc().unwrap().variables.resolve(aid, &modes).unwrap())
|
||||
})
|
||||
.collect();
|
||||
if collection {
|
||||
ex(&mut s, "variable.deleteCollection", json!({"collection": "Base"}));
|
||||
} else {
|
||||
ex(&mut s, "variable.delete", json!({"variable": "surface"}));
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().variables.get_by_id(aid).unwrap().values, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_keep_collection_edits_on_the_resolved_collection() {
|
||||
let mut s = Session::new();
|
||||
let shadow = ex(&mut s, "variable.createCollection", json!({"name": "Shadow", "modes": ["Only"]}));
|
||||
let theme = ex(&mut s, "variable.createCollection", json!({"name": "Theme", "modes": ["Light", "Dark"]}));
|
||||
let surface = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "surface", "type": "number", "value": 1}));
|
||||
ex(&mut s, "variable.renameCollection", json!({"collection": shadow["id"], "name": theme["id"]}));
|
||||
ex(&mut s, "variable.set", json!({"variable": "surface", "mode": "Dark", "value": 7}));
|
||||
let doc = s.doc().unwrap();
|
||||
let coll = doc.variables.collection_by_id(theme["id"].as_str().unwrap()).unwrap();
|
||||
let mode = coll.find_mode("Dark").unwrap().id.clone();
|
||||
let modes = std::collections::BTreeMap::from([(coll.id.clone(), mode)]);
|
||||
assert_eq!(doc.variables.resolve(surface["id"].as_str().unwrap(), &modes), Some(figcraft_doc::VariableValue::Number(7.0)));
|
||||
|
||||
// Mode commands resolve the user's collection argument before checking its stored id.
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let mut extended = doc.variables.collection_by_id(theme["id"].as_str().unwrap()).unwrap().clone();
|
||||
extended.id = "extended".into();
|
||||
extended.name = "Extended".into();
|
||||
extended.extends = Some(theme["id"].as_str().unwrap().into());
|
||||
doc.variables.collections.push(extended);
|
||||
doc.variables.collections[0].name = "extended".into();
|
||||
let before = s.doc().unwrap().clone();
|
||||
assert!(s.execute("variable.removeMode", &json!({"collection": "Extended", "mode": "Dark"})).is_err());
|
||||
assert_eq!(s.doc().unwrap(), &before);
|
||||
}
|
||||
@@ -231,6 +231,40 @@ fn variables_export_and_import_round_trip() {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_keep_remapped_import_aliases() {
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "Theme"}));
|
||||
let shadow = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "shadow", "type": "number", "value": 100}))["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let target = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "target", "type": "number", "value": 5}))["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
ex(&mut s, "variable.update", json!({"variable": shadow, "name": target}));
|
||||
let imported = json!({"meta": {
|
||||
"variableCollections": {"c": {"id": "c", "name": "Theme", "modes": [{"modeId": "m", "name": "Mode 1"}]}},
|
||||
"variables": {
|
||||
"t": {"id": "t", "name": "target", "variableCollectionId": "c", "resolvedType": "FLOAT", "valuesByMode": {"m": 5}},
|
||||
"a": {"id": "a", "name": "alias", "variableCollectionId": "c", "resolvedType": "FLOAT",
|
||||
"valuesByMode": {"m": {"type": "VARIABLE_ALIAS", "id": "t"}}}
|
||||
}
|
||||
}});
|
||||
let result = ex(&mut s, "variable.import", json!({"variables": imported}));
|
||||
assert_eq!(result["warnings"], json!([]));
|
||||
let vars = &s.doc().unwrap().variables;
|
||||
let alias = vars.get("alias").unwrap();
|
||||
assert_eq!(alias.values.values().next(), Some(&VariableValue::Alias(target.clone())));
|
||||
assert_eq!(vars.resolve(&alias.id, &Default::default()), Some(VariableValue::Number(5.0)));
|
||||
// The command's alias argument accepts names, but an exact id comes first (#1613): the
|
||||
// variable renamed to target's id doesn't take its place.
|
||||
ex(&mut s, "variable.set", json!({"variable": "alias", "value": {"alias": target}}));
|
||||
let vars = &s.doc().unwrap().variables;
|
||||
assert_eq!(vars.resolve(&vars.get("alias").unwrap().id, &Default::default()), Some(VariableValue::Number(5.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variables_import_checks_what_it_reads() {
|
||||
let mut s = Session::new();
|
||||
@@ -291,7 +325,7 @@ fn variables_import_caps_the_file_it_reads() {
|
||||
for bad in &refused {
|
||||
let before = (format!("{:?}", t.docs), t.active);
|
||||
let e = t.execute("variable.import", &json!({"variables": bad})).unwrap_err().to_string();
|
||||
assert!(e.contains("not a file of at most 268435456 bytes"), "{bad}: {e}");
|
||||
assert!(["more than the 256 MB allowed here", "is a folder, not a file", "isn't a regular file"].iter().any(|w| e.contains(w)), "{bad}: {e}");
|
||||
assert_eq!((format!("{:?}", t.docs), t.active), before, "{bad}");
|
||||
}
|
||||
// A file under the limit still imports.
|
||||
@@ -555,3 +589,53 @@ fn css_export_neutralises_every_css_newline_in_strings() {
|
||||
assert!(css.contains("\"a b c\""), "{css:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_keep_exported_css_alias_names() {
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "Theme"}));
|
||||
let shadow = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "shadow", "type": "color", "value": "#FF0000"}));
|
||||
let surface = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "surface", "type": "color", "value": "#0D99FF"}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "alias", "type": "color", "value": {"alias": surface["id"]}}));
|
||||
ex(&mut s, "variable.update", json!({"variable": shadow["id"], "name": surface["id"], "codeSyntax": {"WEB": "--shadow"}}));
|
||||
let css = ex(&mut s, "variable.export", json!({"format": "css"}));
|
||||
assert!(css["text"].as_str().unwrap().contains("--alias: var(--surface);"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_reset_only_the_inherited_parent_and_collection() {
|
||||
for collection_shadow in [false, true] {
|
||||
let mut s = Session::new();
|
||||
let shadow_coll = ex(&mut s, "variable.createCollection", json!({"name": "Shadow"}));
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "Base", "modes": ["Light", "Dark"]}));
|
||||
let shadow = ex(&mut s, "variable.create", json!({"collection": "Shadow", "name": "shadow", "type": "number", "value": 0}));
|
||||
let surface =
|
||||
ex(&mut s, "variable.create", json!({"collection": "Base", "name": "surface", "type": "number", "values": {"Light": 4, "Dark": 8}}));
|
||||
if collection_shadow {
|
||||
ex(&mut s, "variable.renameCollection", json!({"collection": shadow_coll["id"], "name": "extended"}));
|
||||
} else {
|
||||
ex(&mut s, "variable.update", json!({"variable": shadow["id"], "name": surface["id"]}));
|
||||
}
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let parent = doc.variables.get_by_id(surface["id"].as_str().unwrap()).unwrap().clone();
|
||||
let mut coll = doc.variables.collection_by_id(&parent.collection).unwrap().clone();
|
||||
let (light, dark) = (coll.find_mode("Light").unwrap().id.clone(), coll.find_mode("Dark").unwrap().id.clone());
|
||||
coll.extends = Some(coll.id.clone());
|
||||
coll.id = "extended".into();
|
||||
coll.name = "Extended".into();
|
||||
let mut inherited = parent.clone();
|
||||
inherited.id = "inherited".into();
|
||||
inherited.name = "inherited".into();
|
||||
inherited.collection = coll.id.clone();
|
||||
inherited.extends = Some(parent.id);
|
||||
inherited.overrides = inherited.values.keys().cloned().collect();
|
||||
inherited.values.values_mut().for_each(|v| *v = VariableValue::Number(2.0));
|
||||
doc.variables.collections.push(coll);
|
||||
doc.variables.variables.push(inherited);
|
||||
ex(&mut s, "variable.resetOverride", json!({"variable": "inherited", "mode": "Dark"}));
|
||||
let result = s.doc().unwrap().variables.get_by_id("inherited").unwrap();
|
||||
assert_eq!(result.values[&light], VariableValue::Number(2.0));
|
||||
assert_eq!(result.values[&dark], VariableValue::Number(8.0));
|
||||
assert_eq!(result.overrides, std::collections::BTreeSet::from([light]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
//! Widgets' hover styles: while the pointer is over a widget layer that has one, the canvas shows
|
||||
//! it as a live preview (no undo step, nothing saved or synced); moving away, leaving the canvas or
|
||||
//! any command puts the layer back, and a click on a hovered part acts on the document itself.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::components::tests::{ex, idv, node};
|
||||
use crate::plugins_tests::{install, undo_len};
|
||||
use crate::tools::{PointerEvent, PointerKind};
|
||||
use crate::widgets_tests::{W, widget_manifest};
|
||||
use crate::{Session, textpreview};
|
||||
use figcraft_doc::NodeId;
|
||||
use figcraft_geom::Point;
|
||||
|
||||
const BLUE: &str = "0D99FF";
|
||||
const DARK: &str = "0B7FD6";
|
||||
|
||||
/// A widget whose button darkens and whose label turns white while hovered; the widget fades a
|
||||
/// little. A click on the button counts.
|
||||
fn button() -> Value {
|
||||
json!({"root": {"type": "frame", "name": "Hover", "layout": "horizontal", "padding": 10, "fill": "#FFFFFF", "hover": {"opacity": 0.9}, "children": [
|
||||
{"type": "rectangle", "name": "Button", "target": "go", "width": 40, "height": 20, "fill": format!("#{BLUE}"), "hover": {"fill": format!("#{DARK}"), "stroke": "#000000"}},
|
||||
{"type": "text", "name": "Label", "text": "Go", "color": "#111111", "hover": {"color": "#FFFFFF"}}]}})
|
||||
}
|
||||
|
||||
fn session() -> (Session, NodeId) {
|
||||
let mut s = Session::new();
|
||||
let mut w = W::default();
|
||||
let body = w.render(&button());
|
||||
install(&mut s, widget_manifest("org.test.hover", "read"), &w.build(&body));
|
||||
let id = idv(&ex(&mut s, "widget.insert", json!({"plugin": "org.test.hover", "x": 50, "y": 60})));
|
||||
ex(&mut s, "select.set", json!({"ids": []}));
|
||||
(s, id)
|
||||
}
|
||||
|
||||
fn fill(s: &Session, id: NodeId) -> String {
|
||||
node(s, id).fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn hover(s: &mut Session, p: Point) {
|
||||
s.pointer(&PointerEvent::at(PointerKind::Hover, p.x, p.y, 1.0)).unwrap();
|
||||
}
|
||||
|
||||
fn hover_over(s: &mut Session, id: NodeId) {
|
||||
let p = centre(s, id);
|
||||
hover(s, p);
|
||||
}
|
||||
|
||||
fn centre(s: &Session, id: NodeId) -> Point {
|
||||
s.doc().unwrap().world_bounds(id).unwrap().center()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hovering_a_widget_shows_its_hover_styles_without_an_edit() {
|
||||
let (mut s, w) = session();
|
||||
let kids = node(&s, w).children.clone();
|
||||
let (btn, label) = (kids[0], kids[1]);
|
||||
let undo = undo_len(&s);
|
||||
hover_over(&mut s, btn);
|
||||
assert_eq!(fill(&s, btn), DARK, "the hovered button");
|
||||
assert!(!node(&s, btn).strokes.is_empty());
|
||||
assert!((node(&s, w).opacity - 0.9).abs() < 1e-6, "the widget the pointer is in");
|
||||
assert_eq!(fill(&s, label), "111111", "the label isn't under the pointer");
|
||||
let itself = s.ds().unwrap().document_itself().clone();
|
||||
assert_eq!(itself.node(btn).unwrap().fills[0].solid_color().unwrap().to_hex(), BLUE, "the document itself is unchanged");
|
||||
assert_eq!(undo_len(&s), undo, "no undo step");
|
||||
// Over the label: the label's style, not the button's.
|
||||
hover_over(&mut s, label);
|
||||
assert_eq!((fill(&s, btn), fill(&s, label)), (BLUE.into(), "FFFFFF".into()));
|
||||
// Away from the widget, and leaving the canvas: as it was.
|
||||
hover(&mut s, Point::new(-400.0, -400.0));
|
||||
assert_eq!((fill(&s, btn), fill(&s, label)), (BLUE.into(), "111111".into()));
|
||||
assert!((node(&s, w).opacity - 1.0).abs() < 1e-6);
|
||||
hover_over(&mut s, btn);
|
||||
s.pointer_left();
|
||||
assert_eq!(fill(&s, btn), BLUE);
|
||||
assert!(!textpreview::active(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_on_a_hovered_part_edits_the_document_itself() {
|
||||
let (mut s, w) = session();
|
||||
let btn = node(&s, w).children[0];
|
||||
let at = centre(&s, btn);
|
||||
hover(&mut s, at);
|
||||
assert_eq!(fill(&s, btn), DARK);
|
||||
let undo = undo_len(&s);
|
||||
s.pointer(&PointerEvent::at(PointerKind::Down, at.x, at.y, 1.0)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Up, at.x, at.y, 1.0)).unwrap();
|
||||
assert_eq!(undo_len(&s), undo + 1, "the click is one undo step");
|
||||
// The widget was redrawn from the document itself: no hover style was kept in it.
|
||||
let btn = node(&s, w).children[0];
|
||||
assert_eq!(fill(&s, btn), BLUE);
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
let btn = node(&s, w).children[0];
|
||||
assert_eq!(fill(&s, btn), BLUE, "undo brings back the document, not a hovered one");
|
||||
// Hovering again shows it again.
|
||||
hover(&mut s, at);
|
||||
assert_eq!(fill(&s, btn), DARK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn widget_hover_is_a_command_any_other_command_ends() {
|
||||
let (mut s, w) = session();
|
||||
let btn = node(&s, w).children[0];
|
||||
assert_eq!(ex(&mut s, "widget.hover", json!({"ids": [btn]}))["hovering"], true);
|
||||
assert_eq!(fill(&s, btn), DARK);
|
||||
ex(&mut s, "select.set", json!({"ids": [w]}));
|
||||
assert_eq!(fill(&s, btn), BLUE, "another command puts the document back first");
|
||||
// Layers without a hover style, and layers outside widgets, show nothing.
|
||||
let r = idv(&ex(&mut s, "shape.rectangle", json!({"x": 400, "y": 0, "width": 10, "height": 10})));
|
||||
assert_eq!(ex(&mut s, "widget.hover", json!({"ids": [r]}))["hovering"], false);
|
||||
assert_eq!(ex(&mut s, "widget.hover", json!({}))["hovering"], false);
|
||||
// A hover style damaged in a file is ignored.
|
||||
let doc = s.ds_mut().unwrap().doc_mut();
|
||||
doc.get_mut(btn).unwrap().plugin_data.insert("figcraft.widget.hover".into(), "{\"fill\": \"not a colour\"}".into());
|
||||
assert_eq!(ex(&mut s, "widget.hover", json!({"ids": [btn]}))["hovering"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_widget_doesnt_replace_another_live_preview() {
|
||||
let (mut s, w) = session();
|
||||
let btn = node(&s, w).children[0];
|
||||
let other = idv(&ex(&mut s, "shape.rectangle", json!({"x": 400, "y": 0, "width": 10, "height": 10})));
|
||||
ex(&mut s, "text.preview", json!({"command": "node.set", "params": {"ids": [other], "props": {"opacity": 0.5}}}));
|
||||
hover_over(&mut s, btn);
|
||||
assert!((node(&s, other).opacity - 0.5).abs() < 1e-6, "the preview showing stays");
|
||||
assert_eq!(fill(&s, btn), BLUE);
|
||||
}
|
||||
@@ -30,8 +30,12 @@ pub mod kiwi;
|
||||
#[cfg(test)]
|
||||
mod layer_cap_tests;
|
||||
mod map;
|
||||
#[cfg(test)]
|
||||
mod missing_image_tests;
|
||||
mod network;
|
||||
#[cfg(test)]
|
||||
mod paste_style_tests;
|
||||
#[cfg(test)]
|
||||
mod prop_value_tests;
|
||||
#[cfg(test)]
|
||||
mod slot_tests;
|
||||
@@ -50,7 +54,10 @@ mod text_skip_ink_tests;
|
||||
mod text_style_tests;
|
||||
#[cfg(test)]
|
||||
mod text_wrap_tests;
|
||||
#[cfg(test)]
|
||||
mod value_budget_tests;
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Read;
|
||||
use std::sync::Arc;
|
||||
@@ -67,6 +74,13 @@ const MAX_CANVAS: u64 = 256 * 1024 * 1024;
|
||||
const MAX_CHUNK: u64 = 512 * 1024 * 1024;
|
||||
/// Largest image member read.
|
||||
const MAX_IMAGE: u64 = figcraft_doc::ImageData::MAX_BYTES;
|
||||
/// Total decompressed ZIP image bytes read per import: the native image budget (`IMAGE_BUDGET`) on
|
||||
/// 64-bit targets, lower on 32-bit targets to leave address space for the canvas, layers and
|
||||
/// decoded pixels.
|
||||
#[cfg(not(target_pointer_width = "32"))]
|
||||
const MAX_IMAGE_BYTES: u64 = crate::IMAGE_BUDGET.bytes;
|
||||
#[cfg(target_pointer_width = "32")]
|
||||
const MAX_IMAGE_BYTES: u64 = 1024 * 1024 * 1024;
|
||||
/// Most chunks read from `canvas.fig`.
|
||||
const MAX_CHUNKS: usize = 16;
|
||||
/// Most layers imported from a `.fig` file: one per [`PACKED_BYTES_PER_LAYER`] bytes of its
|
||||
@@ -105,7 +119,12 @@ pub fn try_import(bytes: &[u8], name: &str) -> Option<Result<(Document, Vec<Stri
|
||||
/// Build a document from a `.fig` file. Returns it with the warnings about what was skipped or
|
||||
/// approximated. `name`: the document name when the file doesn't carry one.
|
||||
pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec<String>), FormatError> {
|
||||
import_with_image_limit(bytes, name, MAX_IMAGE_BYTES)
|
||||
}
|
||||
|
||||
fn import_with_image_limit(bytes: &[u8], name: &str, image_limit: u64) -> Result<(Document, Vec<String>), FormatError> {
|
||||
let mut archive = Archive::open(bytes)?;
|
||||
archive.image_budget.remaining.set(image_limit);
|
||||
// The budgets follow the compressed canvas, which the file itself bounds (its zip member may
|
||||
// inflate too).
|
||||
let packed = archive.canvas.len().min(bytes.len());
|
||||
@@ -125,6 +144,10 @@ pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec<String>), Forma
|
||||
drop(msg);
|
||||
let images = |r: &str| archive.image(r).or_else(|| embedded.get(r).map(|b| b.to_vec()));
|
||||
let (mut doc, more) = crate::rest::import_with_networks(&json, &images, networks, max_nodes)?;
|
||||
let skipped = archive.image_budget.skipped.get();
|
||||
if skipped > 0 {
|
||||
warnings.push(format!("{skipped} ZIP image(s) skipped by the total ZIP image byte budget ({image_limit} bytes)"));
|
||||
}
|
||||
if board {
|
||||
doc.kind = FileKind::Board;
|
||||
}
|
||||
@@ -218,18 +241,27 @@ fn blob<'k>(blobs: &'k [Kv], index: usize) -> Option<&'k [u8]> {
|
||||
blobs.get(index)?.get("bytes").and_then(Kv::bytes)
|
||||
}
|
||||
|
||||
/// Bytes left for ZIP image reads, including damaged images. The REST resolver caches references,
|
||||
/// including failures, so each referenced member is read or counted as skipped only once.
|
||||
struct ZipImageBudget {
|
||||
remaining: Cell<u64>,
|
||||
skipped: Cell<usize>,
|
||||
}
|
||||
|
||||
/// The parts of a `.fig` archive the import reads.
|
||||
struct Archive<'b> {
|
||||
canvas: Vec<u8>,
|
||||
file_name: Option<String>,
|
||||
/// `images/<hash>` members (or nothing for a bare canvas).
|
||||
zip: Option<zip::ZipArchive<std::io::Cursor<&'b [u8]>>>,
|
||||
image_budget: ZipImageBudget,
|
||||
}
|
||||
|
||||
impl<'b> Archive<'b> {
|
||||
fn open(bytes: &'b [u8]) -> Result<Self, FormatError> {
|
||||
let image_budget = ZipImageBudget { remaining: Cell::new(MAX_IMAGE_BYTES), skipped: Cell::new(0) };
|
||||
if bytes.starts_with(b"fig-") {
|
||||
return Ok(Archive { canvas: bytes.to_vec(), file_name: None, zip: None });
|
||||
return Ok(Archive { canvas: bytes.to_vec(), file_name: None, zip: None, image_budget });
|
||||
}
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| corrupt(e.to_string()))?;
|
||||
let canvas = crate::read_member(&mut z, "canvas.fig", MAX_CANVAS)?;
|
||||
@@ -237,17 +269,51 @@ impl<'b> Archive<'b> {
|
||||
.ok()
|
||||
.and_then(|m| serde_json::from_slice::<serde_json::Value>(&m).ok())
|
||||
.and_then(|m| m.get("file_name").and_then(serde_json::Value::as_str).map(str::to_string));
|
||||
Ok(Archive { canvas, file_name, zip: Some(z) })
|
||||
Ok(Archive { canvas, file_name, zip: Some(z), image_budget })
|
||||
}
|
||||
|
||||
/// Bytes of image `hash` (lowercase hex) from `images/`.
|
||||
fn image(&self, hash: &str) -> Option<Vec<u8>> {
|
||||
let budget = &self.image_budget;
|
||||
if hash.is_empty() || hash.len() > 128 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
// A clone shares the parsed directory and the bytes; the resolver only has shared access.
|
||||
let mut z = self.zip.clone()?;
|
||||
crate::read_member(&mut z, &format!("images/{hash}"), MAX_IMAGE).ok()
|
||||
let f = z.by_name(&format!("images/{hash}")).ok()?;
|
||||
let size = f.size();
|
||||
if size > MAX_IMAGE {
|
||||
return None;
|
||||
}
|
||||
let remaining = budget.remaining.get();
|
||||
if size > remaining {
|
||||
budget.skipped.set(budget.skipped.get() + 1);
|
||||
return None;
|
||||
}
|
||||
let checksum = f.crc32();
|
||||
let mut data = Vec::new();
|
||||
// Probe past the per-image cap only when the total budget has room. Even a dishonest
|
||||
// size cannot make the read exceed the remaining total; failed reads keep their charge.
|
||||
let limit = remaining.min(MAX_IMAGE + 1);
|
||||
let read = f.take(limit).read_to_end(&mut data);
|
||||
let used = data.len() as u64;
|
||||
budget.remaining.set(remaining - used);
|
||||
if used > size && used == remaining {
|
||||
budget.skipped.set(budget.skipped.get() + 1);
|
||||
}
|
||||
if read.is_err() || used != size {
|
||||
return None;
|
||||
}
|
||||
// `take` can stop exactly at the budget without asking ZIP for EOF (where it checks CRC).
|
||||
// Check it here too, without spending an extra byte on an overflow probe.
|
||||
if used == limit {
|
||||
let mut crc = flate2::Crc::new();
|
||||
crc.update(&data);
|
||||
if crc.sum() != checksum {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(data)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! laid out for it. Everything written reads back; what the format can't say is written as its
|
||||
//! value with a warning; damaged files never panic.
|
||||
|
||||
use figcraft_doc::{Collection, Mode, Style, StyleKind, Variable, VariableType, VariableValue};
|
||||
use figcraft_doc::{Collection, ColorOperand, Mode, Style, StyleKind, Variable, VariableType, VariableValue};
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
@@ -383,6 +383,36 @@ fn bindings_the_format_cant_say_keep_their_value_with_a_warning() {
|
||||
assert!(back.variables.get("lost").is_none());
|
||||
}
|
||||
|
||||
/// A colour calculation has no encoding here: it is written as the colour it shows in each mode,
|
||||
/// and one that can't resolve is left out with a warning rather than written as something else.
|
||||
#[test]
|
||||
fn colour_calculations_are_written_as_the_colour_they_show() {
|
||||
let mut doc = themed();
|
||||
let fade = |source: &str| VariableValue::ColorAlpha { variable: source.into(), opacity: 0.5 };
|
||||
let mix = VariableValue::ColorMix {
|
||||
from: ColorOperand::Variable { variable: "v-fg".into() },
|
||||
to: ColorOperand::Literal { literal: Color::new(1.0, 0.0, 0.0, 1.0) },
|
||||
t: 0.5,
|
||||
};
|
||||
doc.variables.variables.push(var("v-veil", "color/veil", "c-theme", VariableType::Color, &[("m-light", fade("v-bg")), ("m-dark", mix)]));
|
||||
doc.variables.variables.push(var("v-broken", "color/broken", "c-theme", VariableType::Color, &[("m-light", fade("v-gone"))]));
|
||||
let (bytes, warnings) = export(&doc, None).unwrap();
|
||||
assert!(warnings.iter().any(|w| w == crate::CALCULATIONS_LEFT_OUT), "{warnings:?}");
|
||||
let (back, _) = import(&bytes, "x").unwrap();
|
||||
assert!(back.variables.get("color/broken").is_none_or(|v| v.values.is_empty()), "left out, not written as something else");
|
||||
let theme = back.variables.collections.iter().find(|c| c.name == "Theme").unwrap();
|
||||
let veil = back.variables.get("color/veil").unwrap();
|
||||
let shown: Vec<String> = theme
|
||||
.modes
|
||||
.iter()
|
||||
.map(|m| match veil.values.get(&m.id) {
|
||||
Some(VariableValue::Color(c)) => c.to_css_hex(),
|
||||
other => panic!("{}: {other:?}", m.name),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(shown, ["#FFFFFF80", "#DF6060"]);
|
||||
}
|
||||
|
||||
/// A style of each kind linked from a layer, and one that nothing uses.
|
||||
fn styled() -> Document {
|
||||
let mut doc = Document::new("Styled");
|
||||
|
||||
@@ -216,7 +216,7 @@ fn too_large() -> FormatError {
|
||||
}
|
||||
|
||||
/// `canvas.fig`: the magic, the version and the two deflated chunks, each after its length.
|
||||
fn canvas(schema: &[u8], message: &[u8]) -> Result<Vec<u8>, FormatError> {
|
||||
pub(super) fn canvas(schema: &[u8], message: &[u8]) -> Result<Vec<u8>, FormatError> {
|
||||
let mut out = b"fig-kiwi".to_vec();
|
||||
out.extend_from_slice(&VERSION.to_le_bytes());
|
||||
for chunk in [schema, message] {
|
||||
|
||||
@@ -211,7 +211,13 @@ impl<'d> Exporter<'d> {
|
||||
let mut values = Vec::new();
|
||||
for m in &c.modes {
|
||||
let (Some(mode), Some(val)) = (self.mode_ids.get(&(c.id.as_str(), m.id.as_str())), v.values.get(&m.id)) else { continue };
|
||||
let (ty, value) = match val {
|
||||
// The Figma binary has no encoding FigCraft writes for calculations: the colour
|
||||
// they show in this mode goes out as a plain colour value.
|
||||
let Some(val) = doc.variables.flattened(v, &m.id, val) else {
|
||||
self.warn(crate::CALCULATIONS_LEFT_OUT);
|
||||
continue;
|
||||
};
|
||||
let (ty, value) = match &val {
|
||||
VariableValue::Color(x) => ("COLOR", ("colorValue", color(*x))),
|
||||
VariableValue::Number(x) => ("FLOAT", ("floatValue", fl(*x))),
|
||||
// NULs can't be stored in a string.
|
||||
@@ -224,6 +230,8 @@ impl<'d> Exporter<'d> {
|
||||
continue;
|
||||
}
|
||||
},
|
||||
// `flattened` leaves none: this format has no shape for a calculation.
|
||||
VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. } => continue,
|
||||
};
|
||||
let data = o(vec![("value", o(vec![value])), ("dataType", e(ty)), ("resolvedDataType", e(resolved))]);
|
||||
values.push(o(vec![("modeID", guid(AUX, *mode)), ("variableData", data)]));
|
||||
@@ -251,9 +259,9 @@ impl<'d> Exporter<'d> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A reference to variable `var` (its id or name) as variable data.
|
||||
/// A reference to variable `var` (a stored id: no name stands in for it, #1613) as variable data.
|
||||
fn alias(&mut self, var: &str) -> Option<Kv<'static>> {
|
||||
let found = self.doc.variables.get(var).and_then(|v| Some((v.kind, *self.var_ids.get(v.id.as_str())?)));
|
||||
let found = self.doc.variables.get_by_id(var).and_then(|v| Some((v.kind, *self.var_ids.get(v.id.as_str())?)));
|
||||
let Some((kind, local)) = found else {
|
||||
self.warn("bindings to variables that aren't in the document were written as their values");
|
||||
return None;
|
||||
|
||||
@@ -11,11 +11,20 @@ use std::fmt;
|
||||
/// Deepest struct/message nesting decoded.
|
||||
const MAX_DEPTH: usize = 64;
|
||||
/// Most values decoded from one message (every scalar, array element and field counts): at least
|
||||
/// [`MIN_VALUES`], and one per byte of the file's compressed canvas up to [`MAX_VALUES`], so a
|
||||
/// large real file decodes while a small one can't expand into a huge tree, however far its
|
||||
/// chunks inflate. 32-bit targets (wasm32) have a 4 GB address space in all, so their ceiling is
|
||||
/// lower: past it the file is refused cleanly instead of running out of memory.
|
||||
/// [`MIN_VALUES`], and [`VALUES_PER_PACKED_BYTE`] per byte of the file's compressed canvas up to
|
||||
/// [`MAX_VALUES`], so a large real file decodes while a small one can't expand into a huge tree,
|
||||
/// however far its chunks inflate. 32-bit targets (wasm32) have a 4 GB address space in all, so
|
||||
/// their ceiling is lower: past it the file is refused cleanly instead of running out of memory.
|
||||
const MIN_VALUES: usize = 20_000_000;
|
||||
/// Value budget per compressed canvas byte. The real files measured in
|
||||
/// `large_files_get_a_larger_value_budget` hold up to 0.75 values per compressed byte without their
|
||||
/// single scalar fields; counting those too adds 15–43% on the generated files in
|
||||
/// `value_budget_tests` and up to 3.2 times on a rectangle carrying only geometry and paint, so a
|
||||
/// dense file may reach about 2.4 per byte. Four leaves two-thirds more room than that. The
|
||||
/// trade-off: from 32 MiB of canvas (8 MiB on 32-bit targets) a file gets all of [`MAX_VALUES`],
|
||||
/// and above the floor it may decode four values for every byte it carries; [`MIN_VALUES`] and
|
||||
/// [`MAX_VALUES`] still bound every file.
|
||||
const VALUES_PER_PACKED_BYTE: usize = 4;
|
||||
#[cfg(not(target_pointer_width = "32"))]
|
||||
const MAX_VALUES: usize = 128 << 20;
|
||||
#[cfg(target_pointer_width = "32")]
|
||||
@@ -306,7 +315,7 @@ impl Schema {
|
||||
|
||||
/// Most values decoded from a file whose compressed canvas is `packed` bytes.
|
||||
pub(crate) fn value_budget(packed: usize) -> usize {
|
||||
packed.clamp(MIN_VALUES, MAX_VALUES)
|
||||
packed.saturating_mul(VALUES_PER_PACKED_BYTE).clamp(MIN_VALUES, MAX_VALUES)
|
||||
}
|
||||
|
||||
struct Decoder<'s, 'b> {
|
||||
|
||||
@@ -91,10 +91,6 @@ pub(super) fn adapt_warning(w: String) -> Option<String> {
|
||||
if w.contains("geometry=paths") {
|
||||
return None;
|
||||
}
|
||||
if w.contains("without image data") {
|
||||
let count = w.split_whitespace().next().unwrap_or("Some");
|
||||
return Some(format!("{count} image fill(s) whose image isn't in the file show a grey placeholder"));
|
||||
}
|
||||
Some(w.replace("the variables JSON", "the file").replace("the file JSON", "the file"))
|
||||
}
|
||||
|
||||
@@ -351,7 +347,10 @@ impl<'a> Mapper<'a> {
|
||||
/// the reference has neither or the asset names nothing (or ambiguously) in the file.
|
||||
/// Original code for this importer: the field names are those of the schema each file embeds.
|
||||
fn resource_id(&self, v: &Kv) -> Option<String> {
|
||||
if let Some(g) = v.get("guid").and_then(guid).filter(|g| *g != NO_STYLE) {
|
||||
// A guid names the resource when this file holds a node by it. One from another file (the
|
||||
// library a style or collection came from, as a paste carries) names nothing here: the
|
||||
// asset it also names finds the file's copy (#1638).
|
||||
if let Some(g) = v.get("guid").and_then(guid).filter(|g| *g != NO_STYLE && self.nodes.contains_key(g)) {
|
||||
return Some(g);
|
||||
}
|
||||
let asset = v.get("assetRef")?;
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
use super::layer::{cap_name, own_text, svg_path, valid_size};
|
||||
use super::{Affine, Kv, Mapper, NO_STYLE, Obj, Point, Value, View, generated_record, guid, json, matrix, network, num, rows};
|
||||
use figcraft_doc::geom::kurbo::{BezPath, PathEl, Rect, Shape};
|
||||
use figcraft_doc::{RouteEnd, Routing, Side, route_points};
|
||||
|
||||
impl<'a> Mapper<'a> {
|
||||
/// A connector's end caps, and when it has no saved outline or network a straight line
|
||||
/// between its ends: free points, or a point (the centre unless given) of a sibling layer.
|
||||
/// A connector's end caps, and when it has no saved outline or network its line between its
|
||||
/// ends (free points, or a sibling layer): straight to the point given on the layer (its
|
||||
/// centre unless given), or elbowed or curved from a side of it, as a board connector runs.
|
||||
pub(super) fn connector(&mut self, o: &mut Obj, v: &View<'a>, space: Affine) {
|
||||
for (from, to) in [("connectorStartCap", "strokeCapStart"), ("connectorEndCap", "strokeCapEnd")] {
|
||||
if let Some(c) = v.str(from) {
|
||||
@@ -16,10 +19,15 @@ impl<'a> Mapper<'a> {
|
||||
if ["vectorNetwork", "vectorNetworkRef", "fillGeometry", "strokeGeometry"].iter().any(|k| o.contains_key(*k)) {
|
||||
return;
|
||||
}
|
||||
let routing = match v.str("connectorLineStyle") {
|
||||
Some("ELBOWED") => Routing::Elbow,
|
||||
Some("CURVED") => Routing::Curved,
|
||||
_ => Routing::Straight,
|
||||
};
|
||||
let parent = v.get("parentIndex").and_then(|p| p.get("guid")).and_then(guid);
|
||||
let end = |k: &str| -> Option<Point> {
|
||||
let end = |k: &str| -> Option<RouteEnd> {
|
||||
let e = v.get(k)?;
|
||||
let p = match e.get("endpointNodeID").and_then(guid).filter(|g| g != NO_STYLE) {
|
||||
let end = match e.get("endpointNodeID").and_then(guid).filter(|g| g != NO_STYLE) {
|
||||
Some(id) => {
|
||||
let n = *self.nodes.get(&id)?;
|
||||
// Only siblings share the connector's coordinates.
|
||||
@@ -27,27 +35,52 @@ impl<'a> Mapper<'a> {
|
||||
return None;
|
||||
}
|
||||
let (w, h) = n.get("size").map_or((0.0, 0.0), |s| (num(s, "x").unwrap_or(0.0), num(s, "y").unwrap_or(0.0)));
|
||||
let (rx, ry) = e.get("relativePosition").and_then(|r| Some((num(r, "x")?, num(r, "y")?))).unwrap_or((0.5, 0.5));
|
||||
n.get("transform").and_then(matrix).unwrap_or_default() * Point::new(w * rx, h * ry)
|
||||
let at = e.get("relativePosition").and_then(|r| Some((num(r, "x")?, num(r, "y")?)));
|
||||
let t = n.get("transform").and_then(matrix).unwrap_or_default();
|
||||
match routing {
|
||||
Routing::Straight => {
|
||||
let (rx, ry) = at.unwrap_or((0.5, 0.5));
|
||||
RouteEnd::Point(t * Point::new(w * rx, h * ry))
|
||||
}
|
||||
_ => RouteEnd::Box(t.transform_rect_bbox(Rect::new(0.0, 0.0, w, h)), side(e, at)),
|
||||
}
|
||||
}
|
||||
None => e.get("position").and_then(|p| Some(Point::new(num(p, "x")?, num(p, "y")?)))?,
|
||||
None => RouteEnd::Point(e.get("position").and_then(|p| Some(Point::new(num(p, "x")?, num(p, "y")?)))?),
|
||||
};
|
||||
(p.x.is_finite() && p.y.is_finite()).then_some(p)
|
||||
let finite = |p: Point| p.x.is_finite() && p.y.is_finite();
|
||||
let ok = match end {
|
||||
RouteEnd::Point(p) => finite(p),
|
||||
RouteEnd::Box(b, _) => finite(b.origin()) && finite(Point::new(b.x1, b.y1)),
|
||||
};
|
||||
ok.then_some(end)
|
||||
};
|
||||
let (Some(a), Some(b)) = (end("connectorStart"), end("connectorEnd")) else {
|
||||
let Some(pts) = end("connectorStart").zip(end("connectorEnd")).and_then(|(a, b)| route_points(a, b, routing)) else {
|
||||
self.warn("connectors whose ends can't be placed are empty");
|
||||
return;
|
||||
};
|
||||
if v.str("connectorLineStyle").is_some_and(|s| s != "STRAIGHT") {
|
||||
self.warn("elbowed and curved connectors without a saved path import as straight lines");
|
||||
if routing != Routing::Straight {
|
||||
self.warn("elbowed and curved connectors without a saved path follow FigCraft's own routing");
|
||||
}
|
||||
let (x0, y0) = (a.x.min(b.x), a.y.min(b.y));
|
||||
// A curved route is one curve: its ends and the two control points between them.
|
||||
let (path, vertices) = match (routing, pts.as_slice()) {
|
||||
(Routing::Curved, [p0, c0, c1, p1]) => {
|
||||
let mut path = BezPath::new();
|
||||
path.move_to(*p0);
|
||||
path.curve_to(*c0, *c1, *p1);
|
||||
(path, vec![*p0, *p1])
|
||||
}
|
||||
_ => (pts.iter().enumerate().map(|(i, p)| if i == 0 { PathEl::MoveTo(*p) } else { PathEl::LineTo(*p) }).collect(), pts.clone()),
|
||||
};
|
||||
let b = path.bounding_box();
|
||||
let (x0, y0) = (b.x0, b.y0);
|
||||
o.insert("relativeTransform".into(), rows(space * Affine::translate((x0, y0))));
|
||||
o.insert("size".into(), json!({"x": (a.x - b.x).abs(), "y": (a.y - b.y).abs()}));
|
||||
o.insert(
|
||||
"vectorNetwork".into(),
|
||||
json!({"vertices": [{"x": a.x - x0, "y": a.y - y0}, {"x": b.x - x0, "y": b.y - y0}], "segments": [{"a": 0, "b": 1}]}),
|
||||
);
|
||||
o.insert("size".into(), json!({"x": b.width(), "y": b.height()}));
|
||||
let segments: Vec<Value> = match (routing, pts.as_slice()) {
|
||||
(Routing::Curved, [p0, c0, c1, p1]) => vec![json!({"a": 0, "b": 1, "ta": [c0.x - p0.x, c0.y - p0.y], "tb": [c1.x - p1.x, c1.y - p1.y]})],
|
||||
_ => (1..vertices.len()).map(|i| json!({"a": i - 1, "b": i})).collect(),
|
||||
};
|
||||
let vertices: Vec<Value> = vertices.iter().map(|p| json!({"x": p.x - x0, "y": p.y - y0})).collect();
|
||||
o.insert("vectorNetwork".into(), json!({"vertices": vertices, "segments": segments}));
|
||||
}
|
||||
|
||||
pub(super) fn connector_snapshot_supported(&self, v: &View<'a>) -> bool {
|
||||
@@ -260,3 +293,21 @@ pub(super) fn connector_has_saved_label(v: &View<'_>) -> bool {
|
||||
.and_then(Kv::str)
|
||||
.is_some_and(|s| !s.trim().is_empty())
|
||||
}
|
||||
|
||||
/// The side of its layer a connector end leaves from: the `magnet` the file names, else the edge
|
||||
/// its point on the layer (`relativePosition`, 0–1) lies on, else the side facing the other end.
|
||||
fn side(e: &Kv<'_>, at: Option<(f64, f64)>) -> Side {
|
||||
match e.get("magnet").and_then(Kv::str) {
|
||||
Some("TOP") => Side::Top,
|
||||
Some("RIGHT") => Side::Right,
|
||||
Some("BOTTOM") => Side::Bottom,
|
||||
Some("LEFT") => Side::Left,
|
||||
_ => match at {
|
||||
Some((x, _)) if x <= 0.0 => Side::Left,
|
||||
Some((x, _)) if x >= 1.0 => Side::Right,
|
||||
Some((_, y)) if y <= 0.0 => Side::Top,
|
||||
Some((_, y)) if y >= 1.0 => Side::Bottom,
|
||||
_ => Side::Auto,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -33,10 +33,17 @@ impl<'a> Mapper<'a> {
|
||||
|
||||
/// The style node a layer links in `key` (or the older `legacy` field), recorded for the REST
|
||||
/// `styles` map under `slot`.
|
||||
/// A link to a style the file doesn't hold leaves the layer its own values (the colours it
|
||||
/// stored with it), with a note.
|
||||
pub(super) fn style(&mut self, v: &View<'a>, key: &str, legacy: &str, slot: &str, out: &mut Obj) -> Option<&'a Kv<'a>> {
|
||||
let id = self.style_link(v, key, legacy)?;
|
||||
let node = *self.nodes.get(&id)?;
|
||||
node.get("styleType").and_then(Kv::str)?;
|
||||
let found = self.style_link(v, key, legacy).and_then(|id| Some((self.nodes.get(&id).copied()?, id)));
|
||||
let Some((node, id)) = found.filter(|(n, _)| n.get("styleType").and_then(Kv::str).is_some()) else {
|
||||
let named = |l: &Kv| l.get("assetRef").is_some() || l.get("guid").and_then(guid).is_some_and(|g| g != NO_STYLE);
|
||||
if v.get(key).is_some_and(named) || v.get(legacy).and_then(guid).is_some_and(|g| g != NO_STYLE) {
|
||||
self.warn("links to shared styles that aren't in the file kept the layers' own values");
|
||||
}
|
||||
return None;
|
||||
};
|
||||
out.insert(slot.into(), json!(id));
|
||||
if !self.styles.contains_key(&id) {
|
||||
let name = node.get("name").and_then(Kv::str).unwrap_or("Style");
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Design data whose image fill names an image it doesn't carry, as a clipboard copied from
|
||||
//! another design app does (#1646): the fill keeps the image's name, so the image can be put back
|
||||
//! under it; the layer shows the missing-image placeholder (light grey with darker stripes, not a
|
||||
//! grey a design could have chosen); and the import says so, once per image. Built in code and
|
||||
//! read as `edit.pasteDesignLayers` reads a clipboard's design data.
|
||||
|
||||
use figcraft_doc::Document;
|
||||
use figcraft_doc::color::PaintKind;
|
||||
|
||||
use super::kiwi::Kv;
|
||||
use super::tests::{arr, canvas, e, node, o, schema, xy};
|
||||
use super::try_import;
|
||||
use crate::rest::{is_missing_image, missing_image_placeholder};
|
||||
|
||||
const HASH: [u8; 20] = [0x5a; 20];
|
||||
|
||||
fn image_fill() -> Kv<'static> {
|
||||
o(vec![("type", e("IMAGE")), ("image", o(vec![("hash", Kv::Bytes(HASH.to_vec()))])), ("imageScaleMode", e("FILL"))])
|
||||
}
|
||||
|
||||
/// Two layers filled with the same image, which the data doesn't carry.
|
||||
fn pasted() -> (Document, Vec<String>) {
|
||||
let nodes = vec![
|
||||
node(0, None, "DOCUMENT", "Document", vec![]),
|
||||
node(1, Some((0, "!")), "CANVAS", "Page", vec![]),
|
||||
node(10, Some((1, "!")), "RECTANGLE", "Hero", vec![("size", xy(200.0, 100.0)), ("fillPaints", arr(vec![image_fill()]))]),
|
||||
node(11, Some((1, "\"")), "RECTANGLE", "Hero copy", vec![("size", xy(200.0, 100.0)), ("fillPaints", arr(vec![image_fill()]))]),
|
||||
];
|
||||
let bytes = canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))]));
|
||||
try_import(&bytes, "Pasted layers").expect("design data").expect("it imports")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_image_keeps_its_name_and_shows_as_missing() {
|
||||
let (doc, warnings) = pasted();
|
||||
let hex: String = HASH.iter().map(|b| format!("{b:02x}")).collect();
|
||||
for name in ["Hero", "Hero copy"] {
|
||||
let layer = doc.nodes().find(|n| n.name == name).unwrap();
|
||||
let Some(PaintKind::Image(img)) = layer.fills.first().map(|p| &p.kind) else { panic!("{name}: {:?}", layer.fills) };
|
||||
assert_eq!(img.image, hex, "{name}: the image's own name, so it can come back under it");
|
||||
assert!(doc.images.get(&img.image).is_some_and(is_missing_image), "{name}: the missing-image placeholder");
|
||||
}
|
||||
let note = "image fills whose image isn't in the file show a missing-image placeholder and keep its name";
|
||||
assert_eq!(warnings.iter().filter(|w| w.starts_with(note)).count(), 1, "{warnings:?}");
|
||||
assert!(!warnings.iter().any(|w| w.contains("without image data")), "the REST JSON note isn't given too: {warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_placeholder_reads_as_missing_not_as_grey() {
|
||||
let png = image::load_from_memory(missing_image_placeholder().unwrap()).unwrap().to_rgba8();
|
||||
// Two tones (their anti-aliased edges between): darker stripes over a light ground, not one
|
||||
// flat grey.
|
||||
let count = |c: u8| png.pixels().filter(|p| p.0 == [c, c, c, 0xFF]).count();
|
||||
let all = png.pixels().count();
|
||||
let (ground, stripe) = (count(0xE6), count(0xB4));
|
||||
assert!(ground > all / 2 && stripe > all / 8 && ground + stripe > all * 3 / 4, "{ground} ground, {stripe} stripe of {all}");
|
||||
// A real image of one colour is never taken for the placeholder.
|
||||
let flat = figcraft_doc::ImageData { bytes: std::sync::Arc::new(vec![1, 2, 3]), width: 64, height: 64 };
|
||||
assert!(!is_missing_image(&flat));
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Layers pasted from another design app whose paints come from a shared paint style (#1638): the
|
||||
//! clipboard links the style by the guid it has in the library it came from, which this payload
|
||||
//! doesn't hold, and by its library asset, whose copy it does hold. The layers (a component's
|
||||
//! glyph and the same glyph in an instance of it) take the style's colour and stay linked to it.
|
||||
//! A link to a style the payload doesn't hold at all keeps the colour stored with the layer, with
|
||||
//! a note. Built in code and read as `edit.pasteDesignLayers` reads a clipboard's design data.
|
||||
|
||||
use figcraft_doc::{Document, Node};
|
||||
|
||||
use super::kiwi::Kv;
|
||||
use super::tests::{arr, canvas, e, g, node, o, s, schema, solid, xy};
|
||||
use super::try_import;
|
||||
|
||||
/// A style link by `guid` (absent from the payload) and, given, the library asset `key`.
|
||||
fn link(guid: u64, asset: Option<&str>) -> Kv<'static> {
|
||||
let mut fields = vec![("guid", g(guid))];
|
||||
if let Some(key) = asset {
|
||||
fields.push(("assetRef", o(vec![("key", s(key)), ("version", s("7"))])));
|
||||
}
|
||||
o(fields)
|
||||
}
|
||||
|
||||
/// The pasted payload: on the internal page, the file's copy of library style "Red" (#FF4040);
|
||||
/// on the page, component "Icon" whose "Glyph" stores stale black and links the style, an
|
||||
/// instance of it, and "Lost", which stores #1F1F1F and links a style the payload doesn't hold.
|
||||
fn pasted() -> (Document, Vec<String>) {
|
||||
let nodes = vec![
|
||||
node(0, None, "DOCUMENT", "Document", vec![]),
|
||||
node(1, Some((0, "!")), "CANVAS", "Page", vec![]),
|
||||
node(2, Some((0, "~")), "CANVAS", "Internal", vec![("internalOnly", Kv::Bool(true))]),
|
||||
node(
|
||||
10,
|
||||
Some((2, "!")),
|
||||
"RECTANGLE",
|
||||
"Red",
|
||||
vec![("styleType", e("FILL")), ("key", s("lib-red")), ("version", s("7")), ("fillPaints", arr(vec![solid(1.0, 0.25, 0.25)]))],
|
||||
),
|
||||
node(20, Some((1, "!")), "SYMBOL", "Icon", vec![("size", xy(24.0, 24.0))]),
|
||||
node(
|
||||
21,
|
||||
Some((20, "!")),
|
||||
"RECTANGLE",
|
||||
"Glyph",
|
||||
vec![("size", xy(16.0, 16.0)), ("fillPaints", arr(vec![solid(0.0, 0.0, 0.0)])), ("styleIdForFill", link(99, Some("lib-red")))],
|
||||
),
|
||||
node(30, Some((1, "\"")), "INSTANCE", "Icon copy", vec![("size", xy(24.0, 24.0)), ("symbolData", o(vec![("symbolID", g(20))]))]),
|
||||
node(
|
||||
40,
|
||||
Some((1, "#")),
|
||||
"RECTANGLE",
|
||||
"Lost",
|
||||
vec![("size", xy(16.0, 16.0)), ("fillPaints", arr(vec![solid(0.12, 0.12, 0.12)])), ("styleIdForFill", link(98, None))],
|
||||
),
|
||||
];
|
||||
let bytes = canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))]));
|
||||
try_import(&bytes, "Pasted layers").expect("design data").expect("it imports")
|
||||
}
|
||||
|
||||
fn fill_hex(n: &Node) -> Option<String> {
|
||||
n.fills.first()?.solid_color().map(|c| c.to_hex())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_library_paint_style_colours_the_component_and_its_instance() {
|
||||
let (doc, _) = pasted();
|
||||
let glyphs: Vec<&Node> = doc.nodes().filter(|n| n.name == "Glyph").collect();
|
||||
assert_eq!(glyphs.len(), 2, "the component's glyph and the instance's");
|
||||
for glyph in glyphs {
|
||||
assert_eq!(fill_hex(glyph).as_deref(), Some("FF4040"), "the style's colour, not the stale black");
|
||||
let style = glyph.styles.fill.as_deref().and_then(|id| doc.style(id)).expect("linked to the style");
|
||||
assert_eq!(style.name, "Red");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_style_the_paste_doesnt_hold_keeps_the_stored_colour() {
|
||||
let (doc, warnings) = pasted();
|
||||
let lost = doc.nodes().find(|n| n.name == "Lost").unwrap();
|
||||
assert_eq!(fill_hex(lost).as_deref(), Some("1F1F1F"), "the colour stored with the layer, not black");
|
||||
assert!(lost.styles.fill.is_none());
|
||||
assert!(warnings.iter().any(|w| w.contains("shared styles that aren't in the file")), "{warnings:?}");
|
||||
}
|
||||
@@ -613,12 +613,13 @@ fn other_layer_kinds_import_as_the_closest_layers() {
|
||||
let kids: Vec<&figcraft_doc::Node> = bubble.children.iter().map(|c| doc.node(*c).unwrap()).collect();
|
||||
assert!(matches!(kids[0].kind, NodeKind::Vector { .. }) && kids[0].fills.len() == 1);
|
||||
assert_eq!(text_of(bubble.children[1]), "Hi");
|
||||
// A connector from a free point to the centre of a sibling, with its end cap.
|
||||
// An elbowed connector from a free point to a sibling: in right angles, into the side of the
|
||||
// box facing the point, with its end cap.
|
||||
let link = named(&doc, "Link");
|
||||
let world = doc.world_transform(link.id);
|
||||
let ends: Vec<(f64, f64)> =
|
||||
network_of(&doc, "Link").vertices.iter().map(|v| world * figcraft_doc::geom::Point::new(v.x, v.y)).map(|p| (p.x, p.y)).collect();
|
||||
assert_eq!(ends, [(0.0, 300.0), (450.0, 50.0)]);
|
||||
assert_eq!(ends, [(0.0, 300.0), (200.0, 300.0), (200.0, 50.0), (400.0, 50.0)]);
|
||||
assert_eq!(link.stroke.cap_end, StrokeCap::TriangleArrow);
|
||||
// A table: a frame.
|
||||
assert!(matches!(named(&doc, "Grid").kind, NodeKind::Frame));
|
||||
@@ -627,6 +628,44 @@ fn other_layer_kinds_import_as_the_closest_layers() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Connectors saved without their path: straight to the point on the layer, elbowed and curved
|
||||
/// from the side the end's point lies on, as a board connector would run.
|
||||
#[test]
|
||||
fn connectors_without_a_saved_path_follow_their_line_style() {
|
||||
let mut message = tail_message();
|
||||
let Kv::Array(nodes) = field_mut(&mut message, "nodeChanges").unwrap() else { panic!() };
|
||||
let box_at = |id, name: &str, x| node(id, Some((1, "*")), "RECTANGLE", name, vec![("size", xy(100.0, 100.0)), ("transform", at(x, 600.0))]);
|
||||
let joined = |id, name: &str, style: &'static str| {
|
||||
let side = |node, x| o(vec![("endpointNodeID", g(node)), ("relativePosition", xy(x, 0.5))]);
|
||||
node(
|
||||
id,
|
||||
Some((1, "+")),
|
||||
"CONNECTOR",
|
||||
name,
|
||||
vec![
|
||||
("strokePaints", arr(vec![solid(0.0, 0.0, 0.0)])),
|
||||
("connectorStart", side(60, 1.0)),
|
||||
("connectorEnd", side(61, 0.0)),
|
||||
("connectorLineStyle", e(style)),
|
||||
],
|
||||
)
|
||||
};
|
||||
nodes.extend([box_at(60, "A", 0.0), box_at(61, "B", 300.0), joined(62, "Curve", "CURVED"), joined(63, "Line", "STRAIGHT")]);
|
||||
let (doc, warnings) = import(&canvas(&schema(), &message), "Routes").unwrap();
|
||||
let world = |name: &str| doc.world_transform(named(&doc, name).id);
|
||||
// Curved: one curve from A's right side to B's left, leaving and arriving level.
|
||||
let curve = network_of(&doc, "Curve");
|
||||
let ends: Vec<_> = curve.vertices.iter().map(|v| world("Curve") * figcraft_doc::geom::Point::new(v.x, v.y)).collect();
|
||||
assert_eq!(ends.iter().map(|p| (p.x, p.y)).collect::<Vec<_>>(), [(100.0, 650.0), (300.0, 650.0)]);
|
||||
assert_eq!(curve.segments.len(), 1);
|
||||
assert_eq!((curve.segments[0].ta, curve.segments[0].tb), ((100.0, 0.0), (-100.0, 0.0)), "the curve leaves and arrives along the sides");
|
||||
// Straight: unchanged, to the points the file gives on the layers.
|
||||
let line: Vec<_> =
|
||||
network_of(&doc, "Line").vertices.iter().map(|v| world("Line") * figcraft_doc::geom::Point::new(v.x, v.y)).map(|p| (p.x, p.y)).collect();
|
||||
assert_eq!(line, [(100.0, 650.0), (300.0, 650.0)]);
|
||||
assert!(warnings.iter().any(|w| w.contains("FigCraft's own routing")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variable_prototype_actions_import() {
|
||||
let (doc, warnings) = import(&tail_canvas(), "Tail").unwrap();
|
||||
|
||||
+251
-16
@@ -59,7 +59,7 @@ message TextData string characters, uint[] characterStyleIDs, NodeChange[] style
|
||||
message Path WindingRule windingRule, uint commandsBlob
|
||||
message GUIDPath GUID[] guids
|
||||
message SymbolData GUID symbolID, NodeChange[] symbolOverrides
|
||||
message StyleId GUID guid
|
||||
message StyleId GUID guid, AssetRef assetRef
|
||||
message PropValue bool boolValue, TextData textValue
|
||||
message PropDef GUID id, string name, PropValue initialValue, PropType type
|
||||
message PropAssignment GUID defID, PropValue value
|
||||
@@ -79,41 +79,41 @@ message Blob byte[] bytes
|
||||
message Message NodeChange[] nodeChanges, Blob[] blobs
|
||||
";
|
||||
|
||||
fn schema() -> Schema {
|
||||
pub(super) fn schema() -> Schema {
|
||||
Schema::parse(SCHEMA).unwrap()
|
||||
}
|
||||
|
||||
fn o(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
|
||||
pub(super) fn o(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
|
||||
Kv::Object(fields)
|
||||
}
|
||||
fn f(x: f64) -> Kv<'static> {
|
||||
pub(super) fn f(x: f64) -> Kv<'static> {
|
||||
Kv::Float(x as f32)
|
||||
}
|
||||
fn u(x: u64) -> Kv<'static> {
|
||||
pub(super) fn u(x: u64) -> Kv<'static> {
|
||||
Kv::Uint(x)
|
||||
}
|
||||
fn e(n: &'static str) -> Kv<'static> {
|
||||
pub(super) fn e(n: &'static str) -> Kv<'static> {
|
||||
Kv::Enum(n)
|
||||
}
|
||||
fn s(x: &str) -> Kv<'static> {
|
||||
pub(super) fn s(x: impl Into<String>) -> Kv<'static> {
|
||||
Kv::Str(x.into())
|
||||
}
|
||||
fn g(l: u64) -> Kv<'static> {
|
||||
pub(super) fn g(l: u64) -> Kv<'static> {
|
||||
o(vec![("sessionID", u(1)), ("localID", u(l))])
|
||||
}
|
||||
fn arr(v: Vec<Kv<'static>>) -> Kv<'static> {
|
||||
pub(super) fn arr(v: Vec<Kv<'static>>) -> Kv<'static> {
|
||||
Kv::Array(v)
|
||||
}
|
||||
fn xy(x: f64, y: f64) -> Kv<'static> {
|
||||
pub(super) fn xy(x: f64, y: f64) -> Kv<'static> {
|
||||
o(vec![("x", f(x)), ("y", f(y))])
|
||||
}
|
||||
fn at(x: f64, y: f64) -> Kv<'static> {
|
||||
pub(super) fn at(x: f64, y: f64) -> Kv<'static> {
|
||||
o(vec![("m00", f(1.0)), ("m01", f(0.0)), ("m02", f(x)), ("m10", f(0.0)), ("m11", f(1.0)), ("m12", f(y))])
|
||||
}
|
||||
fn rgb(r: f64, gr: f64, b: f64) -> Kv<'static> {
|
||||
o(vec![("r", f(r)), ("g", f(gr)), ("b", f(b)), ("a", f(1.0))])
|
||||
}
|
||||
fn solid(r: f64, gr: f64, b: f64) -> Kv<'static> {
|
||||
pub(super) fn solid(r: f64, gr: f64, b: f64) -> Kv<'static> {
|
||||
o(vec![("type", e("SOLID")), ("color", rgb(r, gr, b)), ("opacity", f(1.0)), ("visible", Kv::Bool(true))])
|
||||
}
|
||||
fn root_guid() -> Kv<'static> {
|
||||
@@ -128,7 +128,7 @@ fn prop_ref(def: u64, field: &'static str) -> Kv<'static> {
|
||||
}
|
||||
|
||||
/// A node: id (0: the document), parent (id, position), type, name and further fields.
|
||||
fn node(id: u64, par: Option<(u64, &str)>, ty: &'static str, name: &str, mut more: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
|
||||
pub(super) fn node(id: u64, par: Option<(u64, &str)>, ty: &'static str, name: &str, mut more: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
|
||||
let mut v = vec![("guid", if id == 0 { root_guid() } else { g(id) })];
|
||||
if let Some((p, pos)) = par {
|
||||
v.push(("parentIndex", o(vec![("guid", if p == 0 { root_guid() } else { g(p) }), ("position", s(pos))])));
|
||||
@@ -795,6 +795,233 @@ fn hashes_naming_one_blob_share_one_copy_of_it() {
|
||||
assert_eq!(*copies[&"63".repeat(20)], [7; 16]);
|
||||
}
|
||||
|
||||
mod image_budget {
|
||||
use super::*;
|
||||
|
||||
fn image(hash: u8) -> Vec<u8> {
|
||||
let img = image::RgbaImage::from_pixel(2, 1, image::Rgba([hash, 0, 0, 255]));
|
||||
let mut out = Vec::new();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
fn file(references: &[u8], members: &[(u8, Vec<u8>)]) -> Vec<u8> {
|
||||
let mut nodes = vec![node(0, None, "DOCUMENT", "Document", vec![]), node(1, Some((0, "!")), "CANVAS", "Page", vec![])];
|
||||
for (i, hash) in references.iter().enumerate() {
|
||||
nodes.push(node(
|
||||
i as u64 + 2,
|
||||
Some((1, &format!("{i:04}"))),
|
||||
"RECTANGLE",
|
||||
&format!("Image {i}"),
|
||||
vec![
|
||||
("size", xy(10.0, 10.0)),
|
||||
("fillPaints", arr(vec![o(vec![("type", e("IMAGE")), ("image", o(vec![("hash", Kv::Bytes(vec![*hash; 20]))]))])])),
|
||||
],
|
||||
));
|
||||
}
|
||||
let canvas = canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))]));
|
||||
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let deflated = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||
z.start_file("canvas.fig", deflated).unwrap();
|
||||
z.write_all(&canvas).unwrap();
|
||||
for (hash, bytes) in members {
|
||||
z.start_file(format!("images/{}", format!("{hash:02x}").repeat(20)), deflated).unwrap();
|
||||
z.write_all(bytes).unwrap();
|
||||
}
|
||||
z.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn bytes(doc: &Document, index: usize) -> &[u8] {
|
||||
let node = named(doc, &format!("Image {index}"));
|
||||
let figcraft_doc::color::PaintKind::Image(fill) = &node.fills[0].kind else { panic!("not an image fill") };
|
||||
&doc.images[&fill.image].bytes
|
||||
}
|
||||
|
||||
fn budget_warnings(warnings: &[String]) -> Vec<&str> {
|
||||
warnings.iter().filter(|w| w.contains("total ZIP image byte budget")).map(String::as_str).collect()
|
||||
}
|
||||
|
||||
fn change_member_field(zip: &mut [u8], hash: u8, offset: usize, value: u32) {
|
||||
let start = {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&*zip)).unwrap();
|
||||
z.by_name(&format!("images/{}", format!("{hash:02x}").repeat(20))).unwrap().central_header_start() as usize
|
||||
};
|
||||
zip[start + offset..start + offset + 4].copy_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_total_limit_keeps_all_images_and_each_import_gets_its_own_budget() {
|
||||
let members = [(1, image(1)), (2, image(2))];
|
||||
let limit = members.iter().map(|(_, b)| b.len() as u64).sum();
|
||||
let zip = file(&[1, 2], &members);
|
||||
for _ in 0..2 {
|
||||
let (doc, warnings) = import_with_image_limit(&zip, "Images", limit).unwrap();
|
||||
assert_eq!(bytes(&doc, 0), members[0].1);
|
||||
assert_eq!(bytes(&doc, 1), members[1].1);
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn individually_acceptable_images_overflow_the_total_and_leave_placeholders() {
|
||||
let members = [(1, image(1)), (2, image(2))];
|
||||
let limit = members.iter().map(|(_, b)| b.len() as u64).sum::<u64>() - 1;
|
||||
assert!(members.iter().all(|(_, b)| b.len() as u64 <= limit));
|
||||
let (doc, warnings) = import_with_image_limit(&file(&[1, 2], &members), "Images", limit).unwrap();
|
||||
assert_eq!(bytes(&doc, 0), members[0].1);
|
||||
assert_ne!(bytes(&doc, 1), members[1].1);
|
||||
assert!(!doc.images.contains_key(&crate::image_hash(&members[1].1)));
|
||||
let notes = budget_warnings(&warnings);
|
||||
assert_eq!(notes.len(), 1, "{warnings:?}");
|
||||
assert!(notes[0].contains("1 ZIP image") && notes[0].contains(&limit.to_string()), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_references_do_not_charge_or_count_a_rejection_twice() {
|
||||
let members = [(1, image(1)), (2, image(2))];
|
||||
let (doc, warnings) = import_with_image_limit(&file(&[1, 1, 2, 2, 1], &members), "Images", members[0].1.len() as u64).unwrap();
|
||||
for i in [0, 1, 4] {
|
||||
assert_eq!(bytes(&doc, i), members[0].1);
|
||||
}
|
||||
assert_eq!(bytes(&doc, 2), bytes(&doc, 3));
|
||||
assert_ne!(bytes(&doc, 2), members[1].1);
|
||||
let notes = budget_warnings(&warnings);
|
||||
assert_eq!(notes.len(), 1, "{warnings:?}");
|
||||
assert!(notes[0].contains("1 ZIP image"), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_members_with_identical_contents_still_use_the_read_budget() {
|
||||
let png = image(1);
|
||||
let members = [(1, png.clone()), (2, png.clone())];
|
||||
let (doc, warnings) = import_with_image_limit(&file(&[1, 2], &members), "Images", png.len() as u64).unwrap();
|
||||
assert_eq!(bytes(&doc, 0), png);
|
||||
assert_ne!(bytes(&doc, 1), png);
|
||||
assert_eq!(budget_warnings(&warnings).len(), 1, "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejecting_a_member_before_reading_leaves_room_for_a_smaller_one() {
|
||||
let png = image(1);
|
||||
let members = [(1, vec![0; png.len() + 1]), (2, png.clone())];
|
||||
let (doc, warnings) = import_with_image_limit(&file(&[1, 2], &members), "Images", png.len() as u64).unwrap();
|
||||
assert_ne!(bytes(&doc, 0), members[0].1);
|
||||
assert_eq!(bytes(&doc, 1), png);
|
||||
assert_eq!(budget_warnings(&warnings).len(), 1, "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_crc_reads_still_consume_the_bytes_read() {
|
||||
let members = [(1, image(1)), (2, image(2))];
|
||||
let mut zip = file(&[1, 2], &members);
|
||||
change_member_field(&mut zip, 1, 16, 0);
|
||||
let (doc, warnings) = import_with_image_limit(&zip, "Images", members[0].1.len() as u64 + 1).unwrap();
|
||||
assert_ne!(bytes(&doc, 0), members[0].1);
|
||||
assert_ne!(bytes(&doc, 1), members[1].1);
|
||||
assert_eq!(budget_warnings(&warnings).len(), 1, "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_truncated_deflate_read_keeps_its_partial_charge() {
|
||||
let members = [(1, image(1)), (2, image(2))];
|
||||
let mut zip = file(&[1, 2], &members);
|
||||
// Shorten the first member's compressed size, cutting its deflate stream mid-image.
|
||||
change_member_field(&mut zip, 1, 20, 15);
|
||||
let limit = members[0].1.len() as u64;
|
||||
let archive = Archive::open(&zip).unwrap();
|
||||
archive.image_budget.remaining.set(limit);
|
||||
assert!(archive.image(&"01".repeat(20)).is_none());
|
||||
let remaining = archive.image_budget.remaining.get();
|
||||
assert!(remaining > 0 && remaining < limit, "partial read left {remaining} of {limit} bytes");
|
||||
assert!(archive.image(&"02".repeat(20)).is_none());
|
||||
assert_eq!(archive.image_budget.remaining.get(), remaining);
|
||||
let (doc, warnings) = import_with_image_limit(&zip, "Images", limit).unwrap();
|
||||
assert_ne!(bytes(&doc, 1), members[1].1);
|
||||
assert_eq!(budget_warnings(&warnings).len(), 1, "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_individual_image_cap_is_preserved_without_reading_a_large_member() {
|
||||
let members = [(1, image(1)), (2, image(2))];
|
||||
let mut zip = file(&[1, 2], &members);
|
||||
change_member_field(&mut zip, 1, 24, (MAX_IMAGE + 1) as u32);
|
||||
let (doc, warnings) = import_with_image_limit(&zip, "Images", members[1].1.len() as u64).unwrap();
|
||||
assert_ne!(bytes(&doc, 0), members[0].1);
|
||||
assert_eq!(bytes(&doc, 1), members[1].1);
|
||||
assert!(budget_warnings(&warnings).is_empty(), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc_is_checked_even_when_the_read_ends_exactly_at_the_budget() {
|
||||
let members = [(1, image(1))];
|
||||
let mut zip = file(&[1], &members);
|
||||
change_member_field(&mut zip, 1, 16, 0);
|
||||
let (doc, warnings) = import_with_image_limit(&zip, "Images", members[0].1.len() as u64).unwrap();
|
||||
assert_ne!(bytes(&doc, 0), members[0].1);
|
||||
assert!(warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}");
|
||||
assert!(budget_warnings(&warnings).is_empty(), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_understated_size_cannot_read_past_the_remaining_budget() {
|
||||
let members = [(1, image(1)), (2, image(2))];
|
||||
let mut zip = file(&[1, 2], &members);
|
||||
change_member_field(&mut zip, 1, 24, 1);
|
||||
let archive = Archive::open(&zip).unwrap();
|
||||
archive.image_budget.remaining.set(members[0].1.len() as u64 - 1);
|
||||
assert!(archive.image(&"01".repeat(20)).is_none());
|
||||
assert_eq!(archive.image_budget.remaining.get(), 0);
|
||||
assert_eq!(archive.image_budget.skipped.get(), 1);
|
||||
assert!(archive.image(&"02".repeat(20)).is_none());
|
||||
assert_eq!(archive.image_budget.remaining.get(), 0);
|
||||
assert_eq!(archive.image_budget.skipped.get(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_image_contents_are_charged_before_image_validation() {
|
||||
let members = [(1, vec![0; 8]), (2, image(2))];
|
||||
let limit = members[1].1.len() as u64 + 7;
|
||||
let (doc, warnings) = import_with_image_limit(&file(&[1, 2], &members), "Images", limit).unwrap();
|
||||
assert_ne!(bytes(&doc, 1), members[1].1);
|
||||
assert_eq!(budget_warnings(&warnings).len(), 1, "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_and_unreferenced_members_do_not_consume_the_budget() {
|
||||
let members = [(1, image(1)), (2, vec![0; 128])];
|
||||
let (doc, warnings) = import_with_image_limit(&file(&[9, 1], &members), "Images", members[0].1.len() as u64).unwrap();
|
||||
assert_eq!(bytes(&doc, 1), members[0].1);
|
||||
assert!(warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}");
|
||||
assert!(budget_warnings(&warnings).is_empty(), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_image_fallback_is_independent_of_the_zip_budget() {
|
||||
let canvas = canvas(&schema(), &message_with_image_in_blobs(0));
|
||||
for bytes in [&canvas, &zip_file(&canvas)] {
|
||||
let (doc, warnings) = import_with_image_limit(bytes, "Images", 0).unwrap();
|
||||
let photo = named(&doc, "Photo");
|
||||
let figcraft_doc::color::PaintKind::Image(fill) = &photo.fills[0].kind else { panic!("not an image fill") };
|
||||
assert_eq!(*doc.images[&fill.image].bytes, png());
|
||||
assert!(!warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}");
|
||||
assert_eq!(budget_warnings(&warnings).len(), usize::from(bytes.starts_with(b"PK")), "{warnings:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_import_preserves_image_bytes_dimensions_and_existing_warnings() {
|
||||
let members = [(1, image(1)), (2, image(2))];
|
||||
let (doc, warnings) = import(&file(&[1, 2], &members), "Images").unwrap();
|
||||
assert_eq!(bytes(&doc, 0), members[0].1);
|
||||
assert_eq!(bytes(&doc, 1), members[1].1);
|
||||
assert!(doc.images.values().all(|img| (img.width, img.height) == (2, 1)));
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
let (doc, warnings) = import(&fixture(), "Fixture").unwrap();
|
||||
assert!(doc.images.values().any(|img| *img.bytes == png() && (img.width, img.height) == (2, 1)));
|
||||
assert!(budget_warnings(&warnings).is_empty(), "{warnings:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_files_are_not_fig() {
|
||||
assert!(try_import(b"{\"document\": {}}", "x").is_none());
|
||||
@@ -803,10 +1030,10 @@ fn other_files_are_not_fig() {
|
||||
assert!(try_import(&native, "x").is_none());
|
||||
}
|
||||
|
||||
/// A cheap deterministic generator for the mangle tests.
|
||||
struct Lcg(u64);
|
||||
/// A cheap deterministic generator (mangled inputs, generated fixtures).
|
||||
pub(super) struct Lcg(pub(super) u64);
|
||||
impl Lcg {
|
||||
fn next(&mut self) -> u64 {
|
||||
pub(super) fn next(&mut self) -> u64 {
|
||||
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
self.0 >> 33
|
||||
}
|
||||
@@ -889,6 +1116,14 @@ fn large_files_get_a_larger_value_budget() {
|
||||
assert!(kiwi::value_budget(39_528_420) > 29_703_730);
|
||||
// Still bounded by the largest canvas read.
|
||||
assert!(kiwi::value_budget(MAX_CANVAS as usize) <= 128 << 20);
|
||||
// Counting every scalar field, a dense file may reach about 2.4 values per compressed byte (the
|
||||
// 15 MB text-heavy fixture in `value_budget_tests` measures 1.8): below the ceiling the budget must
|
||||
// cover that, the floor still holds up to 5 MB of canvas, and 32 MiB is enough for the ceiling.
|
||||
assert!(kiwi::value_budget(8 << 20) >= (8 << 20) * 12 / 5);
|
||||
#[cfg(not(target_pointer_width = "32"))]
|
||||
assert!(kiwi::value_budget(30_000_000) >= 30_000_000 * 12 / 5);
|
||||
assert_eq!(kiwi::value_budget(5_000_000), 20_000_000);
|
||||
assert_eq!(kiwi::value_budget(32 << 20), kiwi::value_budget(MAX_CANVAS as usize));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
//! Dense legitimate canvases must open whole within the value budget their size gives them, and a
|
||||
//! small canvas that unpacks past the floor (tens of millions of values) must stay refused. The dense ones take seconds and gigabytes:
|
||||
//! `cargo test -p figcraft-format --release --lib value_budget -- --ignored --test-threads=1`.
|
||||
|
||||
use super::kiwi::{Kv, Schema, encode};
|
||||
use super::tests::{Lcg, arr, at, f, g, node, o, s, schema, solid, u, xy};
|
||||
use super::{decode_canvas, export, import, kiwi};
|
||||
|
||||
fn frac(r: &mut Lcg, hi: f64) -> f64 {
|
||||
(r.next() % 100_000) as f64 / 100_000.0 * hi
|
||||
}
|
||||
|
||||
fn hex(r: &mut Lcg, nibbles: usize) -> String {
|
||||
(0..nibbles).map(|_| format!("{:x}", r.next() % 16)).collect()
|
||||
}
|
||||
|
||||
fn word(r: &mut Lcg) -> &'static str {
|
||||
const WORDS: &[&str] = &[
|
||||
"frame", "card", "hero", "row", "cell", "icon", "badge", "button", "input", "label", "group", "section", "header", "footer", "nav", "list",
|
||||
"item", "menu", "panel", "view", "page", "modal", "form", "field", "text", "title", "body", "caption", "avatar", "thumb", "chart", "graph",
|
||||
"axis", "grid", "slot", "chip", "tag", "pill", "note", "hint", "tip", "alert", "toast", "banner", "strip", "rail", "tabs", "tab", "step",
|
||||
"flow", "state", "theme", "mode", "token",
|
||||
];
|
||||
WORDS[(r.next() as usize) % WORDS.len()]
|
||||
}
|
||||
|
||||
/// Strings and GUIDs with real entropy on every record (a key, a version, a path of GUIDs), so
|
||||
/// deflate packs the fixture about as tightly as a real file rather than far tighter.
|
||||
fn identity(r: &mut Lcg, depth: usize) -> Vec<(&'static str, Kv<'static>)> {
|
||||
let path = (0..depth).map(|_| g(r.next() % (1 << 30))).collect();
|
||||
vec![
|
||||
("key", s(format!("{}:{}{}", hex(r, 4), hex(r, 4), r.next() % 997))),
|
||||
("version", s(format!("{}:0", hex(r, 6)))),
|
||||
("guidPath", o(vec![("guids", arr(path))])),
|
||||
]
|
||||
}
|
||||
|
||||
/// Somewhere on a page the size of a large design file.
|
||||
fn placed(r: &mut Lcg) -> (&'static str, Kv<'static>) {
|
||||
("transform", at(frac(r, 12_000.0), frac(r, 8_000.0)))
|
||||
}
|
||||
|
||||
fn rect(id: u64, page: u64, r: &mut Lcg) -> Kv<'static> {
|
||||
let name = format!("{} {} {}", word(r), word(r), r.next() % 9_999);
|
||||
let mut fields = vec![
|
||||
("size", xy(24.0 + frac(r, 300.0), 16.0 + frac(r, 200.0))),
|
||||
placed(r),
|
||||
("fillPaints", arr(vec![solid(frac(r, 1.0), frac(r, 1.0), frac(r, 1.0))])),
|
||||
("strokeWeight", f(1.0 + frac(r, 3.0))),
|
||||
("cornerRadius", f(frac(r, 24.0))),
|
||||
("opacity", f(0.5 + frac(r, 0.5))),
|
||||
("visible", Kv::Bool(true)),
|
||||
];
|
||||
fields.extend(identity(r, 3));
|
||||
node(id, Some((page, "A")), "RECTANGLE", &name, fields)
|
||||
}
|
||||
|
||||
/// Text saved with per-character styles: its characters and one style id per character.
|
||||
fn text(id: u64, page: u64, chars: usize, r: &mut Lcg) -> Kv<'static> {
|
||||
let mut characters = String::new();
|
||||
while characters.len() < chars {
|
||||
characters.push_str(word(r));
|
||||
characters.push_str(if r.next().is_multiple_of(5) { ". " } else { " " });
|
||||
}
|
||||
characters.truncate(chars);
|
||||
let styles = arr((0..characters.chars().count()).map(|_| u(0)).collect());
|
||||
let mut fields = vec![
|
||||
("size", xy(320.0, 18.0)),
|
||||
placed(r),
|
||||
("fontSize", f(11.0 + frac(r, 26.0))),
|
||||
("fontName", o(vec![("family", s("Inter")), ("style", s("Regular"))])),
|
||||
("fillPaints", arr(vec![solid(0.1, 0.1, 0.1)])),
|
||||
("textData", o(vec![("characters", s(characters)), ("characterStyleIDs", styles)])),
|
||||
];
|
||||
fields.extend(identity(r, 4));
|
||||
node(id, Some((page, "A")), "TEXT", "Body", fields)
|
||||
}
|
||||
|
||||
/// A canvas whose message writes one list field (field 1) of `count` already-encoded elements.
|
||||
fn one_list_canvas(sc: &Schema, count: u32, elements: impl IntoIterator<Item = u8>) -> Vec<u8> {
|
||||
let mut head = encode::Writer::default();
|
||||
head.var_uint(1);
|
||||
head.var_uint(count);
|
||||
let mut msg = head.0;
|
||||
msg.extend(elements);
|
||||
msg.push(0);
|
||||
export::canvas(&encode::schema(sc), &msg).unwrap()
|
||||
}
|
||||
|
||||
/// `pages` pages of `rects` rectangles and `texts` texts of `chars` characters, framed the way the
|
||||
/// exporter frames a file.
|
||||
fn canvas(pages: usize, rects: usize, texts: usize, chars: usize) -> Vec<u8> {
|
||||
let sc = schema();
|
||||
let nc = sc.find("NodeChange").unwrap();
|
||||
let mut r = Lcg(0x5eed_1234_abcd_9999);
|
||||
// Layers are encoded one at a time into the one field written (`nodeChanges`): one Kv tree of
|
||||
// the whole message would take gigabytes of its own.
|
||||
let mut layers = Vec::new();
|
||||
let mut count = 0u32;
|
||||
let mut add = |n: Kv<'static>| {
|
||||
layers.extend(encode::message(&sc, nc, &n).unwrap());
|
||||
count += 1;
|
||||
};
|
||||
add(node(0, None, "DOCUMENT", "Fixture", vec![]));
|
||||
let mut id = 10;
|
||||
for p in 0..pages {
|
||||
let page = id;
|
||||
id += 1;
|
||||
add(node(page, Some((0, "A")), "CANVAS", &format!("Page {p}"), vec![]));
|
||||
for _ in 0..rects {
|
||||
add(rect(id, page, &mut r));
|
||||
id += 1;
|
||||
}
|
||||
for _ in 0..texts {
|
||||
add(text(id, page, chars, &mut r));
|
||||
id += 1;
|
||||
}
|
||||
}
|
||||
one_list_canvas(&sc, count, layers)
|
||||
}
|
||||
|
||||
/// Every layer comes in and nothing is reported — and the fixture makes its point: one value per
|
||||
/// compressed byte refuses it, and it decodes within 90% of its budget. Returns the canvas size.
|
||||
fn opens_whole(label: &str, pages: usize, rects: usize, texts: usize, chars: usize) -> usize {
|
||||
let file = canvas(pages, rects, texts, chars);
|
||||
let (sc, msg) = decode_canvas(&file).unwrap();
|
||||
let root = sc.find("Message").unwrap();
|
||||
let one_per_byte = file.len().clamp(kiwi::value_budget(0), kiwi::value_budget(usize::MAX));
|
||||
let refused = sc.decode_message_within(root, &msg, one_per_byte).is_err_and(|e| e.0 == "message: too many values");
|
||||
assert!(refused, "{label}: one value per compressed byte no longer refuses it");
|
||||
let (doc, warnings) = import(&file, label).unwrap_or_else(|e| panic!("{label}: refused: {e}"));
|
||||
assert_eq!(doc.node_count(), pages * (rects + texts) + pages, "{label}: layers");
|
||||
assert!(warnings.is_empty(), "{label}: {warnings:?}");
|
||||
println!("{label}: {} canvas bytes, {} layers", file.len(), doc.node_count());
|
||||
drop(doc);
|
||||
assert!(sc.decode_message_within(root, &msg, kiwi::value_budget(file.len()) / 10 * 9).is_ok(), "{label}: not within 90% of its budget");
|
||||
file.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "heavy: --release --ignored, about 4.5 GB"]
|
||||
fn dense_canvases_open_whole() {
|
||||
opens_whole("21 MB design-shape canvas", 44, 3000, 2000, 140);
|
||||
opens_whole("15 MB text-heavy canvas", 12, 4500, 4500, 400);
|
||||
}
|
||||
|
||||
/// From 32 MiB of canvas the budget is the ceiling. Its 83 million values are past the 32-bit
|
||||
/// ceiling, so this one is for 64-bit targets.
|
||||
#[cfg(not(target_pointer_width = "32"))]
|
||||
#[test]
|
||||
#[ignore = "heavy: --release --ignored, about 9 GB"]
|
||||
fn a_canvas_budgeted_at_the_ceiling_opens_whole() {
|
||||
let packed = opens_whole("50 MB text-heavy canvas", 106, 0, 5000, 100);
|
||||
assert_eq!(kiwi::value_budget(packed), kiwi::value_budget(usize::MAX), "budgeted at the ceiling");
|
||||
}
|
||||
|
||||
/// However far a small canvas inflates, the floor bounds what it may decode.
|
||||
#[test]
|
||||
fn a_small_canvas_that_unpacks_to_millions_of_values_is_refused() {
|
||||
let n = 30_000_000;
|
||||
let file = one_list_canvas(&Schema::parse("message Message uint[] xs").unwrap(), n, std::iter::repeat_n(0, n as usize));
|
||||
assert!(file.len() < 100_000, "{} bytes", file.len());
|
||||
let Err(e) = import(&file, "x") else { panic!("a {} byte canvas holding {n} values opened", file.len()) };
|
||||
assert_eq!(e.to_string(), "not a valid FigCraft file: .fig file: message: too many values");
|
||||
}
|
||||
@@ -56,6 +56,9 @@ const MAX_DOCUMENT: u64 = 4 * 1024 * 1024 * 1024;
|
||||
const MAX_DOCUMENT: u64 = 1024 * 1024 * 1024;
|
||||
/// Most versions a file keeps: saving more is refused, extra entries in a file are skipped.
|
||||
pub const MAX_VERSIONS: usize = 100;
|
||||
/// The export warning for colour calculations that resolve to nothing (an operand missing, or more
|
||||
/// than 32 variables to read), shared by the Penpot and `.fig` exports.
|
||||
pub(crate) const CALCULATIONS_LEFT_OUT: &str = "colour calculations that don't resolve were left out";
|
||||
/// Longest version name and description kept (chars).
|
||||
pub const MAX_VERSION_NAME: usize = 200;
|
||||
pub const MAX_VERSION_DESCRIPTION: usize = 2000;
|
||||
|
||||
@@ -1657,7 +1657,7 @@ impl<'a> Exporter<'a> {
|
||||
if let Some(name) = self.token_names.get(variable) {
|
||||
return Some(name.clone());
|
||||
}
|
||||
let var = self.doc.variables.get(variable)?;
|
||||
let var = self.doc.variables.get_by_id(variable)?;
|
||||
let name = if variable.starts_with("penpot:tokens:") {
|
||||
var.name.split_once('/').map_or(var.name.clone(), |(_, n)| n.to_owned())
|
||||
} else {
|
||||
@@ -1701,6 +1701,11 @@ impl<'a> Exporter<'a> {
|
||||
(!out.is_empty()).then_some(Value::Object(out))
|
||||
}
|
||||
|
||||
/// The archive's `tokens.json`: the file's own Penpot tokens when it came from Penpot and its
|
||||
/// variables are unchanged since, else one token set per collection (per mode, with a theme
|
||||
/// for each, when it has several). Aliases go out as `{name}` references and colour
|
||||
/// calculations as the colour they show; a token that resolves to nothing is left out with a
|
||||
/// warning, so no reference may name it.
|
||||
fn tokens_json(&mut self) -> Option<String> {
|
||||
let first = self.doc.pages.first().and_then(|p| self.node(*p));
|
||||
let source = first.and_then(|p| p.plugin_data.iter().find(|(k, _)| k.starts_with("penpot.tokens.")).map(|(_, v)| v.clone()));
|
||||
@@ -1715,6 +1720,7 @@ impl<'a> Exporter<'a> {
|
||||
if variables.variables.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let flattener = variables.flattener();
|
||||
let mut root = Map::new();
|
||||
let mut themes = Vec::new();
|
||||
let mut order = Vec::new();
|
||||
@@ -1726,8 +1732,15 @@ impl<'a> Exporter<'a> {
|
||||
let set_name = if many { format!("{}/{}", collection.name, mode.name) } else { collection.name.clone() };
|
||||
let mut tokens = Map::new();
|
||||
for var in variables.variables.iter().filter(|v| v.collection == collection.id) {
|
||||
let Some(value) = var.values.get(&mode.id).or_else(|| var.values.values().next()) else { continue };
|
||||
let (kind, value) = match value {
|
||||
let Some(value) = var.value_or_first(&mode.id) else { continue };
|
||||
// Penpot tokens have no shape for colour calculations: the colour they resolve
|
||||
// to in this mode goes out as a plain colour token, and so does an alias to one
|
||||
// some set leaves out (its `{name}` would dangle whenever that set is active).
|
||||
let Some(value) = flattener.value(var, &mode.id, value) else {
|
||||
self.warn(crate::CALCULATIONS_LEFT_OUT);
|
||||
continue;
|
||||
};
|
||||
let (kind, value) = match &value {
|
||||
VariableValue::Alias(target) => {
|
||||
let Some(target_name) = self.token_name(target) else { continue };
|
||||
let kind = match var.kind {
|
||||
@@ -1742,6 +1755,8 @@ impl<'a> Exporter<'a> {
|
||||
let [r, g, b, a] = c.to_rgba8();
|
||||
("color", json!(if a == 255 { format!("#{r:02x}{g:02x}{b:02x}") } else { format!("#{r:02x}{g:02x}{b:02x}{a:02x}") }))
|
||||
}
|
||||
// `flattened` leaves none: this format has no shape for a calculation.
|
||||
VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. } => continue,
|
||||
VariableValue::Number(n) => ("number", json!(num_text(*n))),
|
||||
VariableValue::String(s) => ("string", json!(s)),
|
||||
VariableValue::Boolean(b) => ("boolean", json!(b.to_string())),
|
||||
|
||||
@@ -5,8 +5,9 @@ use figcraft_doc::color::{Color, ColorStop, Gradient, GradientKind, ImageScaleMo
|
||||
use figcraft_doc::geom::{Affine, Point, Shape};
|
||||
use figcraft_doc::proto::{Action, Direction, Easing, Flow, Interaction, OverlayPosition, Transition, Trigger};
|
||||
use figcraft_doc::{
|
||||
AutoLayout, Blur, Collection, CrossAlign, Document, Effect, EffectKind, GridTrack, LayoutMode, LineHeight, MainAlign, Mode, NodeId, NodeKind,
|
||||
Shadow, Sizing, StrokeAlign, StrokeCap, StyleRun, TextAlignH, TextAutoResize, TextData, Variable, VariableType, VariableValue, VectorNetwork,
|
||||
AutoLayout, Blur, Collection, ColorOperand, CrossAlign, Document, Effect, EffectKind, GridTrack, LayoutMode, LineHeight, MainAlign, Mode, NodeId,
|
||||
NodeKind, Shadow, Sizing, StrokeAlign, StrokeCap, StyleRun, TextAlignH, TextAutoResize, TextData, Variable, VariableType, VariableValue,
|
||||
VectorNetwork,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -523,15 +524,25 @@ fn variable(id: &str, name: &str, collection: &str, kind: VariableType, values:
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_variables_become_token_sets_themes_and_applied_tokens() {
|
||||
let mut doc = Document::new("Tokens");
|
||||
doc.variables.collections.push(Collection {
|
||||
/// A "Color" collection `c1` with Light and Dark modes.
|
||||
fn light_dark_colors() -> Collection {
|
||||
Collection {
|
||||
id: "c1".into(),
|
||||
name: "Color".into(),
|
||||
modes: vec![Mode { id: "light".into(), name: "Light".into() }, Mode { id: "dark".into(), name: "Dark".into() }],
|
||||
extends: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The export's `tokens.json`.
|
||||
fn tokens(bytes: &[u8]) -> Value {
|
||||
entries(bytes).into_iter().find(|(name, _)| name.ends_with("tokens.json")).map(|(_, v)| v).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_variables_become_token_sets_themes_and_applied_tokens() {
|
||||
let mut doc = Document::new("Tokens");
|
||||
doc.variables.collections.push(light_dark_colors());
|
||||
doc.variables.collections.push(Collection {
|
||||
id: "c2".into(),
|
||||
name: "Size".into(),
|
||||
@@ -560,11 +571,7 @@ fn native_variables_become_token_sets_themes_and_applied_tokens() {
|
||||
doc.node_mut(id).unwrap().bound.insert("width".into(), "v2".into());
|
||||
|
||||
let (bytes, _) = export(&doc).unwrap();
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(&bytes[..])).unwrap();
|
||||
let tokens_path = z.file_names().find(|n| n.ends_with("tokens.json")).unwrap().to_owned();
|
||||
let mut text = String::new();
|
||||
z.by_name(&tokens_path).unwrap().read_to_string(&mut text).unwrap();
|
||||
let tokens: Value = serde_json::from_str(&text).unwrap();
|
||||
let tokens = tokens(&bytes);
|
||||
assert_eq!(tokens["Color/Light"]["brand"]["primary"]["$value"], "#ff0000");
|
||||
assert_eq!(tokens["Color/Dark"]["brand"]["primary"]["$value"], "#880000");
|
||||
assert_eq!(tokens["Color/Light"]["brand"]["alias"]["$value"], "{brand.primary}");
|
||||
@@ -580,6 +587,67 @@ fn native_variables_become_token_sets_themes_and_applied_tokens() {
|
||||
assert!(bound.bound.contains_key("width"));
|
||||
}
|
||||
|
||||
/// Penpot tokens have no shape for a colour calculation: each mode's token is the colour it shows,
|
||||
/// and one that resolves to nothing is left out with a warning. No reference names a token a set
|
||||
/// leaves out: an alias to one goes out as the colour it shows, or is left out with it.
|
||||
#[test]
|
||||
fn colour_calculations_export_as_the_colour_they_show() {
|
||||
let mut doc = Document::new("Calculated");
|
||||
doc.variables.collections.push(light_dark_colors());
|
||||
let hex = |h: &str| Color::from_hex(h).unwrap();
|
||||
doc.variables.variables.push(variable(
|
||||
"v1",
|
||||
"brand/primary",
|
||||
"c1",
|
||||
VariableType::Color,
|
||||
&[("light", VariableValue::Color(hex("#ff0000"))), ("dark", VariableValue::Color(hex("#0000ff")))],
|
||||
));
|
||||
let mix = VariableValue::ColorMix {
|
||||
from: ColorOperand::Variable { variable: "v1".into() },
|
||||
to: ColorOperand::Literal { literal: hex("#00ff0080") },
|
||||
t: 0.5,
|
||||
};
|
||||
doc.variables.variables.push(variable(
|
||||
"v2",
|
||||
"brand/veil",
|
||||
"c1",
|
||||
VariableType::Color,
|
||||
&[("light", VariableValue::ColorAlpha { variable: "v1".into(), opacity: 0.5 }), ("dark", mix)],
|
||||
));
|
||||
let gone = VariableValue::ColorAlpha { variable: "v-gone".into(), opacity: 0.5 };
|
||||
doc.variables.variables.push(variable("v3", "brand/lost", "c1", VariableType::Color, &[("light", gone.clone()), ("dark", gone.clone())]));
|
||||
// Aliases: to the lost one (same collection), to one lost only in Dark (from a collection with
|
||||
// one mode, which reads Color in its default mode, Light), to an alias that is left out, and to
|
||||
// one that always resolves.
|
||||
let alias = |to: &str| VariableValue::Alias(to.into());
|
||||
doc.variables.variables.push(variable("v4", "brand/lost-too", "c1", VariableType::Color, &[("light", alias("v3")), ("dark", alias("v3"))]));
|
||||
let half = VariableValue::ColorAlpha { variable: "v1".into(), opacity: 0.5 };
|
||||
doc.variables.variables.push(variable("v5", "brand/half", "c1", VariableType::Color, &[("light", half), ("dark", gone)]));
|
||||
doc.variables.collections.push(Collection {
|
||||
id: "c2".into(),
|
||||
name: "Theme".into(),
|
||||
modes: vec![Mode { id: "one".into(), name: "One".into() }],
|
||||
extends: None,
|
||||
});
|
||||
doc.variables.variables.push(variable("v6", "theme/accent", "c2", VariableType::Color, &[("one", alias("v5"))]));
|
||||
doc.variables.variables.push(variable("v7", "theme/veil", "c2", VariableType::Color, &[("one", alias("v2"))]));
|
||||
doc.variables.variables.push(variable("v8", "theme/deep", "c2", VariableType::Color, &[("one", alias("v4"))]));
|
||||
let (bytes, warnings) = export(&doc).unwrap();
|
||||
assert!(warnings.iter().any(|w| w == crate::CALCULATIONS_LEFT_OUT), "{warnings:?}");
|
||||
let tokens = tokens(&bytes);
|
||||
assert!(["Color/Light", "Color/Dark"].iter().all(|set| tokens[set]["brand"].get("lost").is_none()), "left out, not written");
|
||||
assert!(["Color/Light", "Color/Dark"].iter().all(|set| tokens[set]["brand"].get("lost-too").is_none()), "an alias to it too");
|
||||
assert!(tokens["Color/Dark"]["brand"].get("half").is_none() && tokens["Color/Light"]["brand"]["half"]["$value"] == "#ff000080");
|
||||
assert_eq!(tokens["Theme"]["theme"]["accent"]["$value"], "#ff000080", "the colour it shows, not {{brand.half}}");
|
||||
assert_eq!(tokens["Theme"]["theme"]["veil"]["$value"], "{brand.veil}", "a reference that always resolves stays one");
|
||||
assert!(tokens["Theme"]["theme"].get("deep").is_none(), "an alias to an alias that is left out is left out too");
|
||||
let text = tokens.to_string();
|
||||
assert!(!text.contains("{brand.lost") && !text.contains("{brand.half}"), "no reference to a token a set leaves out: {text}");
|
||||
assert_eq!(tokens["Color/Light"]["brand"]["veil"]["$value"], "#ff000080");
|
||||
assert_eq!(tokens["Color/Dark"]["brand"]["veil"]["$value"], "#008080c0");
|
||||
assert_eq!(tokens["Color/Dark"]["brand"]["veil"]["$type"], "color");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_penpot_tokens_are_written_back_verbatim() {
|
||||
let mut doc = Document::new("Kept tokens");
|
||||
@@ -909,3 +977,27 @@ fn page_guides_reloaded_from_a_figcraft_file_are_exported() {
|
||||
let page_json = e.iter().find(|(k, _)| k.matches('/').count() == 3 && k.contains("/pages/")).unwrap().1;
|
||||
assert_eq!(page_json["guides"].as_object().unwrap().values().next().unwrap()["position"], 410);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_keep_penpot_token_identity() {
|
||||
for missing in [false, true] {
|
||||
let mut doc = Document::new("Token identity");
|
||||
doc.variables.collections.push(Collection {
|
||||
id: "c".into(),
|
||||
name: "Theme".into(),
|
||||
modes: vec![Mode { id: "m".into(), name: "Default".into() }],
|
||||
extends: None,
|
||||
});
|
||||
doc.variables.variables.push(variable("shadow", "target", "c", VariableType::Number, &[("m", VariableValue::Number(20.0))]));
|
||||
if !missing {
|
||||
doc.variables.variables.push(variable("target", "surface", "c", VariableType::Number, &[("m", VariableValue::Number(40.0))]));
|
||||
}
|
||||
let p = page(&doc);
|
||||
let r = add(&mut doc, p, NodeKind::Rectangle, "Bound", 0.0, 0.0, 40.0, 10.0);
|
||||
doc.node_mut(r).unwrap().bound.insert("width".into(), "target".into());
|
||||
let exported = entries(&export(&doc).unwrap().0);
|
||||
let width = &shape(&exported, "Bound")["appliedTokens"]["width"];
|
||||
let expected = if missing { Value::Null } else { json!("surface") };
|
||||
assert_eq!(width, &expected);
|
||||
}
|
||||
}
|
||||
+43
-13
@@ -7,7 +7,7 @@
|
||||
//! corners, constraints, auto layout, layout grids, export settings, text with styled runs,
|
||||
//! vectors from `fillGeometry` / `strokeGeometry`, components, component sets, instances and
|
||||
//! 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).
|
||||
//! image key and show a missing-image 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
|
||||
@@ -1003,7 +1003,7 @@ impl<'a> Ctx<'a> {
|
||||
}
|
||||
|
||||
/// The document image key for an `imageRef`: its bytes when the resolver has them, else a
|
||||
/// grey placeholder stored under the `imageRef` itself when that name can legitimately be an
|
||||
/// missing-image placeholder stored under the `imageRef` itself when that name can legitimately be an
|
||||
/// archive member (`is_valid_key`) and claims no content hash (`claims_content_hash`: the
|
||||
/// placeholder's bytes don't hash to it); any other name shares the `rest-image-missing`
|
||||
/// placeholder.
|
||||
@@ -1016,6 +1016,11 @@ impl<'a> Ctx<'a> {
|
||||
Some((hash, _, _)) => hash,
|
||||
None => {
|
||||
self.missing_images += 1;
|
||||
// A design file's (or a paste's) image fill names its image by hash: one note per
|
||||
// fill, counted, read the same in every language.
|
||||
if self.fig_sources.is_some() {
|
||||
self.warn("image fills whose image isn't in the file show a missing-image placeholder and keep its name");
|
||||
}
|
||||
// An `imageRef` arrives from an untrusted file, and keys become archive member
|
||||
// names on save: a name that can't legitimately be one must not reach the map,
|
||||
// nor one claiming a content hash the placeholder's bytes don't have.
|
||||
@@ -1024,9 +1029,11 @@ impl<'a> Ctx<'a> {
|
||||
let key = if named { candidate } else { MISSING_IMAGE };
|
||||
let key = key.to_string();
|
||||
if !self.doc.images.contains_key(&key)
|
||||
&& let Some(png) = placeholder_png()
|
||||
&& let Some(png) = missing_image_placeholder()
|
||||
{
|
||||
self.doc.images.insert(key.clone(), ImageData { bytes: Arc::new(png), width: PLACEHOLDER_SIDE, height: PLACEHOLDER_SIDE });
|
||||
self.doc
|
||||
.images
|
||||
.insert(key.clone(), ImageData { bytes: Arc::new(png.to_vec()), width: PLACEHOLDER_SIDE, height: PLACEHOLDER_SIDE });
|
||||
}
|
||||
key
|
||||
}
|
||||
@@ -1046,6 +1053,8 @@ impl<'a> Ctx<'a> {
|
||||
|
||||
/// Cross-references that need every layer imported first.
|
||||
fn finish(&mut self) {
|
||||
// A `.fig` import (its sources are taken below) noted each missing image already.
|
||||
let fig = self.fig_sources.is_some();
|
||||
self.resolve_swap_defaults();
|
||||
// Variants share their set's property definitions.
|
||||
for (set, defs) in std::mem::take(&mut self.set_defs) {
|
||||
@@ -1079,11 +1088,11 @@ impl<'a> Ctx<'a> {
|
||||
flows.push(Flow { name, start: id, description: String::new() });
|
||||
}
|
||||
}
|
||||
if self.missing_images > 0 {
|
||||
if self.missing_images > 0 && !fig {
|
||||
let n = self.missing_images;
|
||||
let (s, verb) = if n == 1 { ("", "shows") } else { ("s", "show") };
|
||||
self.warn(format!(
|
||||
"{n} image{s} without image data {verb} a grey placeholder, named by its imageRef when that can legitimately name a file and by the shared name otherwise (put the downloaded images, named by imageRef, in an `images` folder next to the JSON)"
|
||||
"{n} image{s} without image data {verb} a missing-image placeholder, named by its imageRef when that can legitimately name a file and by the shared name otherwise (put the downloaded images, named by imageRef, in an `images` folder next to the JSON)"
|
||||
));
|
||||
}
|
||||
let unused = self.style_meta.keys().filter(|k| !self.doc.styles.iter().any(|s| s.id == **k)).count();
|
||||
@@ -1465,12 +1474,33 @@ fn sane(p: &BezPath) -> bool {
|
||||
}
|
||||
|
||||
/// Side of the placeholder image (large enough that scaling it up stays flat).
|
||||
const PLACEHOLDER_SIDE: u32 = 64;
|
||||
const PLACEHOLDER_SIDE: u32 = 256;
|
||||
|
||||
/// A light-grey PNG standing in for image fills whose bytes aren't available.
|
||||
fn placeholder_png() -> Option<Vec<u8>> {
|
||||
let img = image::RgbaImage::from_pixel(PLACEHOLDER_SIDE, PLACEHOLDER_SIDE, image::Rgba([0xD9, 0xD9, 0xD9, 0xFF]));
|
||||
let mut out = Vec::new();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?;
|
||||
Some(out)
|
||||
/// The PNG standing in for image fills whose bytes aren't available (#1646): light grey crossed
|
||||
/// by darker diagonal stripes, so it reads as an image that is missing, not as a grey the design
|
||||
/// chose. Made once; [`is_missing_image`] recognises it.
|
||||
pub fn missing_image_placeholder() -> Option<&'static [u8]> {
|
||||
static PNG: std::sync::OnceLock<Option<Vec<u8>>> = std::sync::OnceLock::new();
|
||||
PNG.get_or_init(|| {
|
||||
// Stripes 6 px wide every 18 px across the diagonal, their edges anti-aliased, so they
|
||||
// stay clean when a fill scales the picture up.
|
||||
let (period, width) = (18.0f32, 6.0f32);
|
||||
let (ground, stripe) = ([0xE6u8, 0xE6, 0xE6], [0xB4u8, 0xB4, 0xB4]);
|
||||
let img = image::RgbaImage::from_fn(PLACEHOLDER_SIDE, PLACEHOLDER_SIDE, |x, y| {
|
||||
let t = ((x + y) as f32 + 1.0) / std::f32::consts::SQRT_2 % period;
|
||||
let inside = if t < width { t.min(width - t) + 0.5 } else { 0.5 - (t - width).min(period - t) };
|
||||
let k = inside.clamp(0.0, 1.0);
|
||||
let mix = |i: usize| (f32::from(ground[i]) + (f32::from(stripe[i]) - f32::from(ground[i])) * k).round() as u8;
|
||||
image::Rgba([mix(0), mix(1), mix(2), 0xFF])
|
||||
});
|
||||
let mut out = Vec::new();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?;
|
||||
Some(out)
|
||||
})
|
||||
.as_deref()
|
||||
}
|
||||
|
||||
/// `data` is the placeholder an import stored for an image it didn't have.
|
||||
pub fn is_missing_image(data: &ImageData) -> bool {
|
||||
missing_image_placeholder().is_some_and(|png| data.bytes.as_slice() == png)
|
||||
}
|
||||
@@ -248,3 +248,34 @@ fn junk_in_conditional_actions_never_panics() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_variable_ids_keep_imported_mode_lookup_identity() {
|
||||
for collection in [false, true] {
|
||||
let mut vars = variables_json();
|
||||
if collection {
|
||||
let mut shadow = vars["meta"]["variableCollections"][THEME].clone();
|
||||
shadow["id"] = json!("VariableCollectionId:0:0");
|
||||
shadow["name"] = json!(THEME);
|
||||
shadow["modes"] = json!([{"modeId": "other", "name": "Other"}]);
|
||||
vars["meta"]["variableCollections"]["VariableCollectionId:0:0"] = shadow;
|
||||
} else {
|
||||
let mut shadow = vars["meta"]["variables"][SPACE].clone();
|
||||
shadow["id"] = json!("VariableID:0:0");
|
||||
shadow["name"] = json!(LABEL);
|
||||
vars["meta"]["variables"]["VariableID:0:0"] = shadow;
|
||||
}
|
||||
let lookup = expr("VAR_MODE_LOOKUP", vec![var(LABEL), data("STRING", json!("1:0"))]);
|
||||
let (doc, warnings) = import_with(&file(vec![on("ON_CLICK", vec![set(LABEL, lookup)])]), Some(&vars), &no_images).unwrap();
|
||||
let actions = &button(&doc).interactions[0].actions;
|
||||
assert_eq!(
|
||||
actions[0],
|
||||
Action::SetVariable {
|
||||
variable: LABEL.into(),
|
||||
value: Value::Null,
|
||||
expr: Some(Expr::InMode { variable: LABEL.into(), mode: "1:0".into() }),
|
||||
},
|
||||
"{warnings:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -355,7 +355,7 @@ impl Ctx<'_> {
|
||||
Some(VariableValue::Number(n)) => json!(n),
|
||||
Some(VariableValue::String(s)) => json!(s),
|
||||
Some(VariableValue::Boolean(b)) => json!(b),
|
||||
Some(VariableValue::Alias(_)) | None => {
|
||||
Some(VariableValue::Alias(_) | VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. }) | None => {
|
||||
self.warn("set-variable actions with values that don't fit the variable were skipped");
|
||||
return None;
|
||||
}
|
||||
@@ -442,8 +442,8 @@ impl Ctx<'_> {
|
||||
};
|
||||
let vars = &self.doc.variables;
|
||||
let found = parts.filter(|(id, _)| self.var_kinds.contains_key(*id)).and_then(|(id, mode)| {
|
||||
let v = vars.get(id)?;
|
||||
let m = vars.collection(&v.collection)?.modes.iter().find(|m| m.id == mode)?;
|
||||
let v = vars.get_by_id(id)?;
|
||||
let m = vars.collection_by_id(&v.collection)?.mode_by_id(mode)?;
|
||||
Some(Expr::InMode { variable: v.id.clone(), mode: m.id.clone() })
|
||||
});
|
||||
if found.is_none() {
|
||||
|
||||
@@ -67,7 +67,8 @@ pub(super) fn value(kind: VariableType, v: &Value) -> Option<VariableValue> {
|
||||
|
||||
/// `collections` of `vars` (with their variables) as a local variables response, which
|
||||
/// [`Ctx::variables`] reads back. Aliases to variables outside them are written as the value they
|
||||
/// resolve to in the default modes.
|
||||
/// resolve to in the default modes. Colour calculations have no shape in this JSON, so they go out
|
||||
/// as the colour they resolve to in the mode they are written for.
|
||||
pub fn export(vars: &Variables, collections: &[&Collection]) -> Value {
|
||||
let ids: BTreeSet<&str> = collections.iter().map(|c| c.id.as_str()).collect();
|
||||
let members: Vec<&Variable> = vars.variables.iter().filter(|v| ids.contains(v.collection.as_str())).collect();
|
||||
@@ -91,7 +92,10 @@ pub fn export(vars: &Variables, collections: &[&Collection]) -> Value {
|
||||
Some(r) => r,
|
||||
None => continue,
|
||||
},
|
||||
other => other.clone(),
|
||||
other => match vars.flattened(v, mode, other) {
|
||||
Some(flat) => flat,
|
||||
None => continue,
|
||||
},
|
||||
};
|
||||
values.insert(mode.clone(), raw(&val));
|
||||
}
|
||||
@@ -112,7 +116,8 @@ pub fn export(vars: &Variables, collections: &[&Collection]) -> Value {
|
||||
json!({"status": 200, "error": false, "meta": {"variableCollections": colls, "variables": out}})
|
||||
}
|
||||
|
||||
/// A value as the variables JSON writes it (the inverse of [`value`]).
|
||||
/// A value as the variables JSON writes it (the inverse of [`value`]). The export resolves
|
||||
/// calculations before calling this, so they have no shape of their own here.
|
||||
fn raw(v: &VariableValue) -> Value {
|
||||
match v {
|
||||
VariableValue::Color(c) => json!({"r": c.r, "g": c.g, "b": c.b, "a": c.a}),
|
||||
@@ -120,6 +125,7 @@ fn raw(v: &VariableValue) -> Value {
|
||||
VariableValue::String(s) => json!(s),
|
||||
VariableValue::Boolean(b) => json!(b),
|
||||
VariableValue::Alias(a) => json!({"type": "VARIABLE_ALIAS", "id": a}),
|
||||
VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. } => Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -252,7 +252,8 @@ impl Headless {
|
||||
)),
|
||||
"document.inspect" => {
|
||||
let depth = p.get("depth").and_then(Value::as_u64).unwrap_or(8).min(64) as usize;
|
||||
Ok(figcraft_engine::inspect::document(&self.session, depth, b(&p, "allPages")))
|
||||
let options = figcraft_engine::inspect::InspectOptions::parse(&p)?;
|
||||
Ok(figcraft_engine::inspect::document_with_options(&self.session, depth, b(&p, "allPages"), &options))
|
||||
}
|
||||
"node.get" => {
|
||||
let id = p.get("id").and_then(figcraft_engine::params::id_of).ok_or("`id` required (a node id)")?;
|
||||
@@ -261,7 +262,8 @@ impl Headless {
|
||||
if !doc.contains(id) {
|
||||
return Err(format!("no such node {id}"));
|
||||
}
|
||||
Ok(figcraft_engine::inspect::node_json(doc, id, depth))
|
||||
let options = figcraft_engine::inspect::InspectOptions::parse(&p)?;
|
||||
Ok(figcraft_engine::inspect::node_json_with_options(doc, id, depth, &options))
|
||||
}
|
||||
"ui.inspect" => Ok(self.inspect_ui()),
|
||||
"ui.pointer" => self.pointer(&p),
|
||||
|
||||
+35
-8
@@ -64,6 +64,15 @@ fn id_schema(desc: &str) -> Value {
|
||||
fn ids_schema(desc: &str) -> Value {
|
||||
json!({"type": "array", "description": desc, "items": {"anyOf": [{"type": "integer"}, {"type": "string"}]}})
|
||||
}
|
||||
fn fields_schema() -> Value {
|
||||
json!({"type": "array", "items": {"type": "string", "enum": figcraft_engine::inspect::NODE_KEYS},
|
||||
"description": "Exact, case-sensitive top-level node keys to include at every level, pages too (e.g. name, type, x, fills, characters, pluginData). Omit for all properties; [] keeps only ids and structure. id, children, childCount and the transport's truncated marker are always kept. Selecting visible emits an explicit boolean for every node. Unknown keys are an error."})
|
||||
}
|
||||
fn outline_schema() -> Value {
|
||||
boolean(
|
||||
"Compact tree: id, name, type, characters (first 120 characters, then ... only if longer), mainComponentName and explicit visible booleans, plus structure. Cannot combine true with fields; false permits fields.",
|
||||
)
|
||||
}
|
||||
fn obj(props: Value, required: &[&str]) -> Value {
|
||||
let mut o = json!({"type": "object", "properties": props});
|
||||
if !required.is_empty() {
|
||||
@@ -213,6 +222,8 @@ fn tools() -> Vec<Value> {
|
||||
obj(
|
||||
json!({
|
||||
"depth": int("Tree depth for the current page (default 4)"),
|
||||
"fields": fields_schema(),
|
||||
"outline": outline_schema(),
|
||||
"allPages": boolean("Expand every page, not only the current one"),
|
||||
"maxBytes": int(
|
||||
"Largest response to send, in bytes of the whole JSON-RPC message (default 8388608, which fits the usual 10 MiB client buffer); 1024 of it is kept for the envelope and the request id"
|
||||
@@ -230,6 +241,8 @@ fn tools() -> Vec<Value> {
|
||||
json!({
|
||||
"id": id_schema("Layer id"),
|
||||
"depth": int("Child depth (default 1)"),
|
||||
"fields": fields_schema(),
|
||||
"outline": outline_schema(),
|
||||
"offset": int("First child to report (default 0), for reading a node with more children than fit in one message"),
|
||||
"maxBytes": int(
|
||||
"Largest response to send, in bytes of the whole JSON-RPC message (default 8388608, which fits the usual 10 MiB client buffer); 1024 of it is kept for the envelope and the request id"
|
||||
@@ -751,19 +764,31 @@ fn wire_size(value: &Value) -> usize {
|
||||
|
||||
/// The document at `depth`, and what its text block would take on the wire. The tree is sent as a
|
||||
/// text block, so it is measured pretty-printed *and* escaped -- escaping adds about a tenth.
|
||||
fn inspect_at(b: &mut dyn Backend, depth: u64, all: bool) -> Result<(Value, usize), String> {
|
||||
let value = b.call("document.inspect", json!({"depth": depth, "allPages": all}))?;
|
||||
fn inspect_at(b: &mut dyn Backend, depth: u64, all: bool, options: &Value) -> Result<(Value, usize), String> {
|
||||
let mut params = options.clone();
|
||||
params["depth"] = json!(depth);
|
||||
params["allPages"] = json!(all);
|
||||
let value = b.call("document.inspect", params)?;
|
||||
let wire = wire_size(&value);
|
||||
Ok((value, wire))
|
||||
}
|
||||
|
||||
/// One layer's subtree at `depth`, and what its text block would take on the wire.
|
||||
fn node_at(b: &mut dyn Backend, id: u64, depth: u64) -> Result<(Value, usize), String> {
|
||||
let value = b.call("node.get", json!({"id": id, "depth": depth}))?;
|
||||
fn node_at(b: &mut dyn Backend, id: u64, depth: u64, options: &Value) -> Result<(Value, usize), String> {
|
||||
let mut params = options.clone();
|
||||
params["id"] = json!(id);
|
||||
params["depth"] = json!(depth);
|
||||
let value = b.call("node.get", params)?;
|
||||
let wire = wire_size(&value);
|
||||
Ok((value, wire))
|
||||
}
|
||||
|
||||
fn inspection_options(a: &Map<String, Value>) -> Result<Value, String> {
|
||||
let options = Value::Object(a.iter().filter(|(k, _)| matches!(k.as_str(), "fields" | "outline")).map(|(k, v)| (k.clone(), v.clone())).collect());
|
||||
figcraft_engine::inspect::InspectOptions::parse(&options)?;
|
||||
Ok(options)
|
||||
}
|
||||
|
||||
fn inspect_document(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult, String> {
|
||||
let depth = n_arg(a, "depth")?.unwrap_or(4.0).clamp(0.0, 64.0) as u64;
|
||||
let all = match a.get("allPages") {
|
||||
@@ -771,9 +796,10 @@ fn inspect_document(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolR
|
||||
Some(Value::Bool(x)) => *x,
|
||||
Some(_) => return Err("`allPages` must be a boolean".into()),
|
||||
};
|
||||
let options = inspection_options(a)?;
|
||||
let (max_bytes, budget) = budget(a)?;
|
||||
|
||||
let (value, wire) = inspect_at(b, depth, all)?;
|
||||
let (value, wire) = inspect_at(b, depth, all, &options)?;
|
||||
if wire <= budget {
|
||||
return Ok(ToolResult::json(&value));
|
||||
}
|
||||
@@ -791,7 +817,7 @@ fn inspect_document(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolR
|
||||
let mut best: Option<(u64, Value, usize)> = None;
|
||||
while lo <= hi {
|
||||
let mid = lo + (hi - lo) / 2;
|
||||
let (v, w) = inspect_at(b, mid, all)?;
|
||||
let (v, w) = inspect_at(b, mid, all, &options)?;
|
||||
if w + MARKER_RESERVE <= budget {
|
||||
best = Some((mid, v, w));
|
||||
if mid == hi {
|
||||
@@ -840,9 +866,10 @@ fn get_node(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult, S
|
||||
let id = id_arg(a.get("id").ok_or("`id` is required")?)?;
|
||||
let depth = n_arg(a, "depth")?.unwrap_or(1.0).clamp(0.0, 64.0) as u64;
|
||||
let offset = n_arg(a, "offset")?.unwrap_or(0.0).max(0.0).min(u32::MAX as f64) as usize;
|
||||
let options = inspection_options(a)?;
|
||||
let (max_bytes, budget) = budget(a)?;
|
||||
|
||||
let (value, wire) = node_at(b, id, depth)?;
|
||||
let (value, wire) = node_at(b, id, depth, &options)?;
|
||||
if value.is_null() {
|
||||
return Err(format!("no such layer {id}"));
|
||||
}
|
||||
@@ -858,7 +885,7 @@ fn get_node(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult, S
|
||||
let (mut lo, mut hi) = (1u64, depth - 1);
|
||||
while lo <= hi {
|
||||
let mid = lo + (hi - lo) / 2;
|
||||
let (v, w) = node_at(b, id, mid)?;
|
||||
let (v, w) = node_at(b, id, mid, &options)?;
|
||||
if w + MARKER_RESERVE <= budget {
|
||||
(used, value, used_wire) = (mid, v, w);
|
||||
if mid == hi {
|
||||
|
||||
@@ -64,6 +64,24 @@ fn attached_backend_forwards_everything() {
|
||||
assert_eq!(err["isError"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attached_inspection_forwards_compact_options_to_the_app() {
|
||||
let mut s = Server::new(Box::new(Attached::port(fake_app()).unwrap()));
|
||||
let created = call(&mut s, "create_node", json!({"type": "text", "text": "界".repeat(121), "name": "Title"}));
|
||||
let id = serde_json::from_str::<Value>(created["content"][0]["text"].as_str().unwrap()).unwrap()["id"].clone();
|
||||
let r = call(&mut s, "get_node", json!({"id": id, "outline": true}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
let outline: Value = serde_json::from_str(r["content"][0]["text"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(outline["characters"], format!("{}...", "界".repeat(120)));
|
||||
assert_eq!(outline["visible"], true);
|
||||
assert!(outline.get("fontSize").is_none());
|
||||
let r = call(&mut s, "inspect_document", json!({"fields": [], "outline": false}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
let compact: Value = serde_json::from_str(r["content"][0]["text"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(compact["pages"][0]["children"][0], json!({"id": id}));
|
||||
assert!(compact["pages"][0].get("name").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_attached_agent_comments_under_its_own_name() {
|
||||
let mut s = Server::new(Box::new(Attached::port(fake_app()).unwrap()));
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
//! Compact trees are built by the backend, including every size-budget retry.
|
||||
|
||||
use crate::common::Client;
|
||||
use figcraft_mcp::{Backend, Headless, call_tool};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn tree(mut create: impl FnMut(Value) -> Value) -> Value {
|
||||
let root = create(json!({"type": "frame", "name": "Product list", "width": 1200, "height": 2400}));
|
||||
for i in 0..24 {
|
||||
let card = create(json!({"type": "frame", "parent": root, "name": format!("Product card {i}"), "width": 280, "height": 160,
|
||||
"props": {"fill": "#F0F0F0", "cornerRadius": 12, "pluginData": {"source": "synthetic".repeat(40)}}}));
|
||||
for j in 0..6 {
|
||||
create(json!({"type": "text", "parent": card, "name": format!("Description {j}"), "text": "Synthetic product description. ".repeat(8),
|
||||
"x": 12, "y": j * 20, "width": 240}));
|
||||
}
|
||||
}
|
||||
root
|
||||
}
|
||||
|
||||
fn compact_keys(node: &Value, outline: bool) {
|
||||
let allowed: &[&str] = if outline {
|
||||
&["id", "name", "type", "characters", "mainComponentName", "visible", "children", "childCount", "truncated"]
|
||||
} else {
|
||||
&["id", "name", "children", "childCount", "truncated"]
|
||||
};
|
||||
for key in node.as_object().unwrap().keys() {
|
||||
assert!(allowed.contains(&key.as_str()), "unexpected {key}: {node}");
|
||||
}
|
||||
for child in node["children"].as_array().into_iter().flatten() {
|
||||
compact_keys(child, outline);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_schemas_describe_compact_inspection_options() {
|
||||
let mut c = Client::new();
|
||||
let tools = c.rpc("tools/list", json!({}));
|
||||
for name in ["inspect_document", "get_node"] {
|
||||
let tool = tools["result"]["tools"].as_array().unwrap().iter().find(|t| t["name"] == name).unwrap();
|
||||
let props = &tool["inputSchema"]["properties"];
|
||||
assert_eq!(props["fields"]["type"], "array");
|
||||
assert_eq!(props["fields"]["items"]["type"], "string");
|
||||
assert!(props["fields"]["items"]["enum"].as_array().unwrap().contains(&json!("absoluteBounds")));
|
||||
assert_eq!(props["outline"]["type"], "boolean");
|
||||
assert!(props["fields"]["description"].as_str().unwrap().contains("case-sensitive"));
|
||||
assert!(props["outline"]["description"].as_str().unwrap().contains("120"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_realistic_nested_tree_is_much_smaller_with_fields_or_outline() {
|
||||
let mut c = Client::new();
|
||||
let root = tree(|params| c.create(params));
|
||||
for (tool, mut args) in [("inspect_document", json!({"depth": 6})), ("get_node", json!({"id": root, "depth": 6}))] {
|
||||
let full = c.ok(tool, args.clone());
|
||||
let full_bytes = full.to_string().len();
|
||||
args["outline"] = json!(true);
|
||||
let outline = c.ok(tool, args.clone());
|
||||
assert!(outline.to_string().len() * 2 < full_bytes, "{tool}: outline {} vs full {full_bytes}", outline.to_string().len());
|
||||
args.as_object_mut().unwrap().remove("outline");
|
||||
args["fields"] = json!([]);
|
||||
let ids = c.ok(tool, args);
|
||||
assert!(ids.to_string().len() * 5 < full_bytes, "{tool}: ids {} vs full {full_bytes}", ids.to_string().len());
|
||||
if tool == "inspect_document" {
|
||||
assert_eq!(outline["history"], full["history"]);
|
||||
assert_eq!(ids["selection"], full["selection"]);
|
||||
compact_keys(&outline["pages"][0], true);
|
||||
} else {
|
||||
compact_keys(&outline, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Recording {
|
||||
inner: Headless,
|
||||
calls: Vec<(String, Value)>,
|
||||
}
|
||||
|
||||
impl Backend for Recording {
|
||||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
self.calls.push((method.into(), params.clone()));
|
||||
self.inner.call(method, params)
|
||||
}
|
||||
fn has_ui(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn describe(&self) -> String {
|
||||
"recording headless".into()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fields_and_outline_survive_every_depth_retry() {
|
||||
let mut backend = Recording { inner: Headless::new(), calls: Vec::new() };
|
||||
let root = tree(|params| {
|
||||
let result = call_tool(&mut backend, "create_node", ¶ms);
|
||||
assert!(!result.is_error, "{}", result.first_text());
|
||||
serde_json::from_str::<Value>(result.first_text()).unwrap()["id"].clone()
|
||||
});
|
||||
for options in [json!({"fields": ["name"], "outline": false}), json!({"outline": true})] {
|
||||
for (tool, method) in [("inspect_document", "document.inspect"), ("get_node", "node.get")] {
|
||||
backend.calls.clear();
|
||||
let mut args = options.clone();
|
||||
if tool == "get_node" {
|
||||
args["id"] = root.clone();
|
||||
}
|
||||
args["depth"] = json!(6);
|
||||
args["maxBytes"] = json!(6000);
|
||||
let result = call_tool(&mut backend, tool, &args);
|
||||
assert!(!result.is_error, "{}", result.first_text());
|
||||
let value: Value = serde_json::from_str(result.first_text()).unwrap();
|
||||
assert!(value["truncated"].is_object(), "{tool}: {value}");
|
||||
assert!(backend.calls.len() > 1, "must exercise retries: {tool}");
|
||||
for (called, params) in &backend.calls {
|
||||
assert_eq!(called, method);
|
||||
for key in ["fields", "outline"] {
|
||||
assert_eq!(params.get(key), options.get(key), "{tool}: {params}");
|
||||
}
|
||||
}
|
||||
let outline = options["outline"] == true;
|
||||
if tool == "inspect_document" {
|
||||
compact_keys(&value["pages"][0], outline);
|
||||
} else {
|
||||
compact_keys(&value, outline);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_children_keep_window_markers_and_page_in_order() {
|
||||
let mut c = Client::new();
|
||||
let root = tree(|params| c.create(params));
|
||||
let full = c.ok("get_node", json!({"id": root, "depth": 1, "fields": []}));
|
||||
let expected = full["children"].as_array().unwrap();
|
||||
let wire = serde_json::to_string(&serde_json::to_string_pretty(&full).unwrap()).unwrap().len();
|
||||
let limit = wire + 1024 - 1;
|
||||
assert!(limit > 2048, "room for both the envelope and the marker");
|
||||
let mut seen = Vec::new();
|
||||
let mut offset = Some(0);
|
||||
let mut pages = 0;
|
||||
while let Some(at) = offset {
|
||||
let page = c.ok("get_node", json!({"id": root, "depth": 1, "fields": [], "offset": at, "maxBytes": limit}));
|
||||
assert!(page["truncated"]["children"].is_object(), "{page}");
|
||||
let wire = serde_json::to_string(&serde_json::to_string_pretty(&page).unwrap()).unwrap().len();
|
||||
assert!(wire + 1024 <= limit, "page at {at} exceeds maxBytes: {wire} + envelope > {limit}");
|
||||
assert_eq!(page["truncated"]["children"]["from"], at);
|
||||
seen.extend(page["children"].as_array().unwrap().iter().cloned());
|
||||
offset = page["truncated"]["children"]["nextOffset"].as_u64();
|
||||
pages += 1;
|
||||
assert!(pages <= expected.len());
|
||||
}
|
||||
assert!(pages > 1, "the fixture must require a child window");
|
||||
assert_eq!(&seen, expected);
|
||||
}
|
||||
|
||||
/// `text` with Unix line ends: a Windows checkout with `core.autocrlf` has the docs with CRLF.
|
||||
fn lf(text: &str) -> String {
|
||||
text.replace("\r\n", "\n")
|
||||
}
|
||||
|
||||
fn json_example(section: &str) -> &str {
|
||||
section.split("```json\n").nth(1).unwrap().split("```").next().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_examples_run_verbatim_from_the_docs() {
|
||||
let mut c = Client::new();
|
||||
for _ in 0..2 {
|
||||
c.create(json!({"type": "rectangle"}));
|
||||
}
|
||||
let docs = lf(include_str!("../../../../docs/mcp.md"));
|
||||
let section = docs.split("### Compact layer trees\n").nth(1).unwrap();
|
||||
for line in json_example(section).lines() {
|
||||
let reply: Value = serde_json::from_str(&c.server.handle_line(line).unwrap()).unwrap();
|
||||
assert_eq!(reply["result"]["isError"], false, "{reply}");
|
||||
}
|
||||
let mut h = Headless::new();
|
||||
for _ in 0..2 {
|
||||
h.call("engine.execute", json!({"command": "node.create", "params": {"type": "rectangle"}})).unwrap();
|
||||
}
|
||||
let docs = lf(include_str!("../../../../docs/control-protocol.md"));
|
||||
let section = docs.split("### `node.get ").nth(1).unwrap();
|
||||
for line in json_example(section).lines() {
|
||||
let request: Value = serde_json::from_str(line).unwrap();
|
||||
h.call(request["method"].as_str().unwrap(), request["params"].clone()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_compact_options_fail_in_tools_and_headless_control() {
|
||||
let mut c = Client::new();
|
||||
let id = c.create(json!({"type": "rectangle"}));
|
||||
let mut h = Headless::new();
|
||||
let created = h.call("engine.execute", json!({"command": "node.create", "params": {"type": "rectangle"}})).unwrap();
|
||||
assert_eq!(created["id"], id);
|
||||
for mut args in [
|
||||
json!({"fields": [1]}),
|
||||
json!({"fields": null}),
|
||||
json!({"fields": ["Name"]}),
|
||||
json!({"outline": "yes"}),
|
||||
json!({"outline": true, "fields": []}),
|
||||
] {
|
||||
args["id"] = id.clone();
|
||||
for tool in ["inspect_document", "get_node"] {
|
||||
assert!(!c.err(tool, args.clone()).is_empty());
|
||||
}
|
||||
for method in ["document.inspect", "node.get"] {
|
||||
assert!(h.call(method, args.clone()).is_err(), "{method}: {args}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@ mod export_xml;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod garbage;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod inspect_fields;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod inspect_response_limit;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod overlay_anchor;
|
||||
|
||||
@@ -11,7 +11,7 @@ pub mod widget;
|
||||
|
||||
pub use manifest::{API_VERSION, Access, MANIFEST_FILE, Manifest, Panel, Param, ParamKind, Permissions, PluginCommand};
|
||||
pub use sandbox::{ENTRY, Host, IMPORT_MODULE, Instance, Limits, PluginError, RunOutput, UiChange, check_module, run};
|
||||
pub use widget::{MenuItem, MenuKind, WidgetKind, WidgetNode, WidgetRender};
|
||||
pub use widget::{MenuItem, MenuKind, WidgetHover, WidgetKind, WidgetNode, WidgetRender};
|
||||
|
||||
#[cfg(test)]
|
||||
mod bundle_tests;
|
||||
|
||||
Loaded 100 of 187 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user