Merge main into #1499

This commit is contained in:
bflatastic
2026-10-29 23:22:53 +07:00
305 changed files with 23358 additions and 1784 deletions
Generated
+92
View File
@@ -939,11 +939,46 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-atomic"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"rustix 1.1.5",
]
[[package]]
name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
[[package]]
name = "craft-font-catalog"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"fontique",
"skrifa 0.47.0",
"thiserror 2.0.21",
"windows-registry",
]
[[package]]
name = "craft-font-store"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
dependencies = [
"craft-atomic",
"craft-font-catalog",
"flate2",
"serde",
"serde_json",
"sha2",
"skrifa 0.47.0",
"thiserror 2.0.21",
"ureq",
]
[[package]]
name = "craft-fonts-build"
version = "0.1.0"
@@ -1653,6 +1688,8 @@ dependencies = [
"fmv-macos-events",
"image",
"log",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"rfd",
"serde_json",
"wat",
@@ -1744,6 +1781,8 @@ version = "0.12.1"
dependencies = [
"base64 0.22.1",
"craft-docpath",
"craft-font-catalog",
"craft-font-store",
"deunicode",
"figcraft-collab",
"figcraft-color",
@@ -1775,6 +1814,7 @@ name = "figcraft-format"
version = "0.12.1"
dependencies = [
"figcraft-doc",
"figcraft-layout",
"figcraft-pathops",
"flate2",
"image",
@@ -1864,6 +1904,7 @@ dependencies = [
"crc32fast",
"figcraft-color",
"figcraft-doc",
"figcraft-format",
"figcraft-geom",
"figcraft-pathops",
"figcraft-text",
@@ -1920,6 +1961,7 @@ name = "figcraft-text"
version = "0.12.1"
dependencies = [
"core-text",
"craft-font-catalog",
"figcraft-doc",
"figcraft-geom",
"harfrust 0.13.3",
@@ -2113,6 +2155,28 @@ dependencies = [
"tinyvec",
]
[[package]]
name = "fontique"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6688bc1294fe7117d788937b6c53480169b29c566954af490830d4c09da9516a"
dependencies = [
"hashbrown 0.17.1",
"linebender_resource_handle",
"memmap2",
"objc2 0.6.5",
"objc2-core-foundation",
"objc2-core-text",
"objc2-foundation 0.3.2",
"parlance",
"read-fonts 0.41.0",
"roxmltree 0.21.2",
"smallvec",
"windows",
"windows-core",
"yeslogic-fontconfig-sys",
]
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -3422,6 +3486,16 @@ dependencies = [
"objc2-foundation 0.2.2",
]
[[package]]
name = "objc2-core-text"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
dependencies = [
"bitflags 2.13.2",
"objc2-core-foundation",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
@@ -3680,6 +3754,12 @@ dependencies = [
"windows-link",
]
[[package]]
name = "parlance"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73072a9470554c8e794778acece2312661218aaabb635600957d952cf01c716e"
[[package]]
name = "pdf-writer"
version = "0.15.0"
@@ -5356,6 +5436,7 @@ dependencies = [
"rustls-platform-verifier",
"ureq-proto",
"utf8-zero",
"webpki-roots",
]
[[package]]
@@ -6501,6 +6582,17 @@ dependencies = [
"serde_json",
]
[[package]]
name = "yeslogic-fontconfig-sys"
version = "6.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76"
dependencies = [
"dlib",
"once_cell",
"pkg-config",
]
[[package]]
name = "yoke"
version = "0.8.4"
+5 -1
View File
@@ -42,6 +42,10 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l
# pull a second egui family version).
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", 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" }
craft-font-catalog = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# Shared dropdown menus: a combo box whose menu grows back after shrinking (craft-libs).
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# The macOS app menu whose Quit (⌘Q) asks the app before quitting, like the window's close button
@@ -136,7 +140,7 @@ lto = "thin"
codegen-units = 4
# The browser build only (apps/figcraft-web/index.html: data-cargo-profile). Optimised for size:
# packaging/web/package.sh fails a .wasm over 25 MiB - 512 KiB (Cloudflare's per-file cap is 25 MiB).
# packaging/web/package.sh fails a .wasm over 24 MiB (Cloudflare's per-file cap is 25 MiB).
# wasm32-unknown-unknown always aborts on panic, so panic = "abort" changes nothing there.
[profile.web-release]
inherits = "release"
+1 -1
View File
@@ -208,7 +208,7 @@ exposes to agents, and exported at 2x by FigCraft's own renderer. The source fil
</td>
<td width="50%" valign="top">
<h3>Prototype and hand off</h3>
Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like <code>count + 1</code>), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, Vue, Leptos, SwiftUI, Compose) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like <code>count + 1</code>), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, Vue, Leptos, SwiftUI, Compose, ArkUI, Flutter) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
</td>
</tr>
</table>
+30 -1
View File
@@ -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-28 · **Change:** minor (M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -136,7 +136,10 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
- **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.
- **2026-10-27** · M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143. Numbers unchanged.
- **2026-10-27** · M6, #1341: FigJam sticky notes and shapes with text import their generated layers (#1397): the background (its saved vector network, or the outer fill or stroke outline when the generated geometry is damaged, before a rectangle), text with its typography and paints, saved bounds and sticky author lines, as editable frame children; missing or invalid generated text falls back to the outer text within the layer budget. Synthetic regressions and a local check of the report sample; native board metadata and direct `.jam` opening remain outside this fix. Numbers unchanged.
- **2026-10-27** · Batch continue-on-error (M0.9, #1386): `command.batch` and MCP `batch` accept `onError: "continue"`, roll back failed steps individually and return one outcome per step. References to failed steps fail with both step numbers; successful edits stay one undo step, and the default abort mode is unchanged. Engine and MCP regression tests. Numbers unchanged.
- **2026-10-26** · Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` runs the name through `deunicode` before its ASCII filter, so 로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` in the HTML/CSS/React/Leptos class names, component roots and `data-*` variant attributes (SwiftUI/Compose type names unchanged); a name left with nothing (symbols only) takes the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. New dependency: deunicode (pure Rust tables, zero deps, BSD-3-Clause, the `slug` crate's base). `html_codegen_tests`. Numbers unchanged.
- **2026-10-25** · M6, #1143: offline Penpot 2.x open/import/export through the editor, CLI and MCP using the existing document model. Unsupported layout settings retain saved positions and bounded source metadata with warnings; font binaries and toggle-overlay are reported, and provider fonts are never downloaded. Shared ZIP/JSON/token budgets guard untrusted input. Figma parity numbers unchanged.
- **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged.
- **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change.
- **2026-10-24** · Boards (M12): board pages get a dot grid under their layers (drawn by the canvas render after the page colour, thinned out when zoomed out); a selected connector shows its ends as handles, and dragging one onto another object re-attaches it there, onto empty canvas leaves it free (`board.reattach`, one undo step, also over MCP); connectors can be curved, and elbows go around the objects they join when the short way would cross them. Checked in the app, through MCP and in the web build ([boards-parity](docs/boards-parity.md)).
@@ -352,12 +355,37 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-29 | patch | M4, #1478: a letter and its combining marks are shaped with one font that has them all. Text typed decomposed (NFD, `E` + U+0302 + U+0309 for Ể) in a font with the letters but not the marks drew missing-glyph boxes beside and above the letter, while the precomposed form fell back to a font that has it; each grapheme cluster now takes the first face (the style's own, then the fallbacks) that covers every char in it (joiners and variation selectors need no glyph), else the letter's own face as before. Fonts that cover Vietnamese were already right: mark positioning (GPOS `mark`/`mkmk`) stacks the marks over the letter on the canvas, in PNG, SVG and PDF. Tests in `crates/text/src/vietnamese_tests.rs`. Numbers unchanged |
| 2026-10-29 | minor | M9, #1415: `comment.list` (CLI, MCP `run_command`, the `figcraft://comments` resource) gives every message, the first and each reply, its `mentions`: `{name, known, you}` for each name it @mentions, by the rule the thread view highlights them with (`figcraft_doc::mentions`, which the highlighting now shares). A name resolves against the file's people and you, as the file spells it. An unknown name is reported with `known: false`. `a@b.com` mentions nobody. Filtering threads by a mentioned name and listing mentioned names in `comment.people` stay open. Numbers unchanged |
| 2026-10-29 | minor | M8, #1061: a mask group's pixmaps (the mask, its own layer, the content and the pixels it goes onto) cover only where the mask or its content can paint (effects included) within the view, rounded out to whole pixels, instead of the whole view: a 40 × 40 mask on a 3000 × 2000 view held 96 MB, now 61.5 kB, drawing the same pixels. Isolated layers (effects, opacity, masks, clipping frames) also share one budget of 2 GiB held at once per render thread (`figcraft_render::layer_budget`); a layer past it is left out with a warning, so 64 nested full-view layers no longer ask for tens of GB. Tests: `mask_bounds_tests`. Numbers unchanged |
| 2026-10-29 | minor | M2: components can't contain their own instances (#1084). Any edit that would make a component hold an instance of itself, directly or through other components (an instance-swap property's current value included), is refused with the loop named: creating, placing, swapping, switching variants, moving, pasting and instance-swap values. Each variant counts as a component of its own, so a variant holding a sibling variant's instance is fine unless that one leads back to it (`components::check_component_cycles`, beside the instance structure check); a dropped instance lands on the page instead. A file holding such a loop (opened or imported) has one instance per loop detached, with a note; a loop merged in a live session is reported, and a rebuild never expands an instance inside a copy of itself, so its copies stop growing (12 rebuilds of a two-component loop: 25 → 113 layers before, 25 throughout now). Tests: `component_cycle_tests`. Numbers unchanged |
| 2026-10-29 | minor | Which copy is which (M8, #995): `edit.duplicate` with `map: true` also returns `map`, each original layer's id, descendants included (nested frames, groups, instances and their sublayers), paired with its copy's id, so a batch can edit inside a copy through a step reference (`"field": "map/6"`) instead of inspecting it. Without `map` the result is unchanged. Regression tests in `crates/engine/src/duplicate_map_tests.rs`. Numbers unchanged |
| 2026-10-29 | minor | Bounded replay log (#1069, from a security audit): the batches a session keeps for apps that reconnect were bounded only by count (10,000 batches, 1,000,000 changes) on a desktop host and a private `figcraft-server`, so a long busy session could hold any number of bytes. `Limits::max_log_bytes` now defaults to 64 MiB, and `figcraft-server` takes it from a new `max_log_mb` setting (default 64; open to the public, also at most a quarter of `max_file_mb`, as before). An app further behind than the trimmed log catches up with the whole file, as it already did past the count limits. Nine 8 MiB renames with default limits kept all 75.5 MB before; now 58.7 MB, and a client that missed them gets a snapshot. The protocol is unchanged. |
| 2026-10-29 | minor | M8, #998: MCP tools that act on a document (`run_command`, `batch`, `create_node`, `set_properties`, `inspect_document`, `get_node`, `select`, `export`, `screenshot`, `save_file`, `undo`, `redo`) take an optional `document`, an open file's `id` (`doc.list` and the `document.inspect` outline now carry it) or path, and so do the control methods `engine.execute`, `document.inspect`, `node.get` and `ui.render`: the call acts on that file and the tab in front stays in front, so an attached agent keeps editing its own file when the user switches tabs. Calls without it act on the file in front as before; other control methods refuse a `document` behind the tab in front. Numbers unchanged |
| 2026-10-29 | minor | Sibling placement in live sessions (#1065, from a security audit): every replica (the server's and each app's) placed a created, moved or reordered layer by counting each sibling that sorts before it, so a batch of many layers under one parent took quadratic time, on the server's single hub thread too. It now binary-searches the siblings' `(key, id)` order on each side of the layer's old place. The order is unchanged, ties and fractional positions included: a test replays 5,000 creations and 2,500 moves against the counting rule. 20,000 creations and 10,000 moves under two parents took 20 s in a debug build; they now take 0.3 s. |
| 2026-10-29 | minor | Comments from an AI agent (M8, #1414): an agent attached over MCP commented, reacted and read threads as the user (the name from Preferences) unless it passed `author` on every call. Every command an MCP client runs now acts as that client's name from `initialize` (`clientInfo.title`, else `.name`) with " (agent)" after it, or "AI agent" without one, carried to the app as `engine.execute {agent}`; its messages, reactions and read state are its own, and its messages are marked (`agent: true` in the file and in `comment.list`, an Agent label by the name in the thread). The user's name and the comments made in the app are unchanged, and in a live session the name and marker travel with the thread to everyone. Tests in `comment_agent_tests`, the MCP `agent_comments` and attached suites, the UI comments suite and the collab diff round trip. Numbers unchanged |
| 2026-10-29 | minor | M3, #1338: the instance-swap property's menu (on an instance, its main component's default and the component playground) and the instance's swap menu open with a search field at the top that has the keyboard; typing filters the components by name (any case, headings without matches drop out), an empty result reads "No matching components", Up and Down step through the matches and Enter picks one (the first without a step), Escape clears the search and then closes the menu. Only the list scrolls; the menu grows back when the search is cleared. Tests: `component_swap_search_tests`. Numbers unchanged |
| 2026-10-29 | minor | A refused batch's fixes in linear time (#1063, from a security audit): the server restores each deleted layer once, walking each subtree once and stopping at layers already restored, and looks up and sends each style, variable, comment or document property fix once. The fixes sent back are unchanged. Deleting every layer of a 20,000-deep chain in a refused batch took 27 s on the hub thread in a debug build (each deleted layer's whole subtree walked again, each entry fix compared with all the earlier ones); it now takes 0.1 s (`a_refused_deep_delete_restores_each_node_once_in_linear_time`). |
| 2026-10-29 | minor | M12, #1387: while a connector is dragged out with the connector tool, the canvas shows the connector itself as it will be made (its straight, elbow or curved route, line, weight and arrowhead, built by the same code as `board.connector`), not a thin line; the object under the pointer is outlined with its four attach points (the middles of its sides), the one the end goes to ringed; within 10 px of one the end keeps that side (`fromSide`/`toSide`, and `side` when re-attaching). Hovering with the tool shows the attach points too. Escape drops the preview; releasing makes the connector in one undo step. Tests: `board_connector_tests`. Numbers unchanged |
| 2026-10-29 | minor | Large images on the canvas (#1483): an image shown smaller than its pixels draws on the canvas from a copy scaled down to 4096 or 8192 px on its longer side, the smallest that holds what the screen shows (`figcraft_render::images::shown`), cached beside the image itself in the same 512 MB budget. Two 16 000 × 10 000 images took 640 MB each and evicted each other on every frame that drew both; as copies they take 80 MB together zoomed out and 336 MB at 8192. Moving one over the other went from about 2.1 s a frame to 11 ms zoomed to fit and 15 ms at 30% in the app (debug build). Closer than that, and for tiles, the canvas draws from the image itself, so 100% and closer stay sharp; a view of both at 100% still takes about 2 s a frame, and an image with shadows still decodes in full for them. Exports, thumbnails and every other render are unchanged. Tests: `large_image_tests`. Numbers unchanged |
| 2026-10-29 | patch | M8: the progressive JPEG check from #1491 (a frame sampling a component more finely than its first, which the JPEG decoder asserts on, and in the browser a panic aborts the page) moved to `figcraft_format::jpeg` and now also guards the pictures files carry (`thumbnail::decode_thumbnail`: the home screen and recent files), so a crafted `.figcraft`, `.fig` or `.sketch` thumbnail or image file shows no picture instead of crashing. The canvas, exports and SVG/PDF keep it through the renderer. Regression tests in `jpeg_tests` (format) and `jpeg_sampling_tests` (render). Numbers unchanged |
| 2026-10-29 | minor | M6, #1234: `.fig` import resolves style links that name a library style by its asset key and version instead of a guid (the file's own copy of the style), as variable links already were; text in instances of library components kept a stale copy of the style's values and no link. The linked text style beats the copies of its values on the layer and on the override rows below the row that links it, and rows above that row (an outer instance's size, say) still win. Override rows that restate a nested level's overrides (`overrideLevel`) apply at that level, below the instance's own rows, instead of in wire order. Synthetic regression tests in `text_style_tests`. Numbers unchanged |
| 2026-10-29 | minor | M6, #1236 (gap 1): imported `.fig` text keeps the line breaks the file saved with it (`derivedTextData`), so a font whose advances differ slightly from the file's (another build of the family, a stand-in) no longer wraps a word early or splits one in a narrow box. The breaks hold only while the box width and everything that moves a break are as saved (characters, fonts, sizes, spacing, case, axes, features, lists, paragraph settings; paints, links and vertical settings don't count), and are given up where a line would come out more than 3% (+1 px) wider than its box; they are kept in `.figcraft`. The line breaker was checked and is unchanged: trailing spaces and the letter spacing after a line's last glyph already stay out of the fit test, box widths aren't rounded and the tolerance is 1e-6 px, so the differences come from the fonts. Synthetic tests in `saved_breaks_tests` and `fig::text_wrap_tests`; not checked on real files. Numbers unchanged |
| 2026-10-29 | minor | #754, #1025: the Missing fonts window offers Download for a family the suite's font store has (Google Fonts, Noto: craft-libs' `craft-font-store`, its pinned indexes compiled in). The first Download asks the user to allow downloading fonts (off until then; the setting is the Craft apps' shared one); the files download on a thread of their own, each checked against the index's size and sha256 before it is installed into the shared font folder, which the font scan now reads; the row shows progress or why it failed, and once installed the file's text fits the real font and the family leaves the list. `font.download {family}` and `font.downloads` are commands, but no command turns downloading on; agents install only while the user has it on. Desktop only. No cancel, no turning it off from FigCraft, no substitute picker. Tests: `fonts::download::tests`, `missing_fonts_tests`. Numbers unchanged |
| 2026-10-29 | minor | Web wasm size (#1472): the release wasm is 23,915,213 bytes (22.8 MiB), down from 25,982,540 (24.8 MiB), 1.2 MiB under the gate. Catalog rows hold their plural forms behind a reference (two words, not eight) and the browser build compiles with wasm SIMD, so the glyph rasterizer's scalar fallback is left out; the 24 MiB gate in `packaging/web/package.sh` is restored. The browser now needs WebAssembly SIMD (Safari 16.4 or later). Numbers unchanged. |
| 2026-10-29 | minor | Shadows of images with transparent pixels (M3, #1289): a drop shadow is cast from the picture's alpha and hidden only behind its pixels, not its whole box (a transparent PNG's shadow was cut away inside its bounding box), unless shown behind transparent areas; its inner shadow shades the inside of its alpha, not the box's edge; the same for Fit images, crops smaller than the box and pattern fills. Opaque pictures that fill their box keep their outline (spread still offsets it exactly); each image is scanned for transparency once. Canvas, PNG, SVG (knocked out by the layer's alpha; an inner shadow embeds a render) and PDF (`image_shadow_tests` in render and svg). Numbers unchanged |
| 2026-10-29 | minor | M1, #1464: Crop image. Object ▸ Crop image, the right-click menu (one image layer) and a crop button on the image fill row run `image.crop`: the layer's top visible image fill (or the one `index` names) switches to Crop with the picture left where Fill or Fit drew it, as one undo step, and the canvas enters crop mode (the image shows dimmed outside the layer, whose box gets corner brackets and edge bars; the layer's edges and corners crop it with the picture held still, the image drags to move and its corners scale it; Enter, Escape or a click outside finishes). A layer without an image fill, or several layers, are refused with a reason. Tests: `image_crop_tests`, `crop_image_tests`. Numbers unchanged (the menu item is FigCraft's own and not counted by `parity`) |
| 2026-10-29 | minor | The Design panel's sections on one grid (M4, #858): 16 pt left and 8 pt right margins, two 88 pt columns with 8 pt gutters and a 24 pt button column, 24 pt rows under 10 pt captions, 4 pt between a row's two buttons; section titles stay 11 pt semibold; the panel's edge is drawn inside it, so its right margin no longer loses 1 pt to a frame stroke. Numbers unchanged |
| 2026-10-29 | minor | The toolbar on one 32 pt grid (M4, #1210): the pill's cells, padding, gaps and divider share one grid in every state, and the Resources button is gone, as in UI3; the Assets panel opens from the left rail, Shift+I, Alt+2 and View ▸ Panels ▸ Assets. Numbers unchanged |
| 2026-10-28 | minor | Arabic draft: 1,857 UI strings, 620 command labels, 31 menu wordings; count-neutral plurals and web font gate. Native review and panel mirroring remain open; grade and estimates unchanged. Evidence: `crates/ui-egui/locales/ar.toml`, i18n catalog tests and [translation notes](docs/translating.md#arabic-draft) |
| 2026-10-28 | minor | M3, #922: instances scaled with the Scale tool keep their layers' scaled size and points when their main is rebuilt (`size`/`position` overrides recorded on scale). Numbers unchanged. |
| 2026-10-28 | minor | M6, #1059: `.fig` import copies embedded image blobs once per blob, not once per hash naming them. Numbers unchanged. |
| 2026-10-28 | minor | M1, #1476: page ruler guides survive save and reopen; files written with a page's `guides` key twice open again with both lists. Numbers unchanged. |
| 2026-10-27 | minor | M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143. Numbers unchanged. |
| 2026-10-27 | minor | M6, #1341: FigJam sticky notes and shapes with text import their generated layers (#1397): the background (its saved vector network, or the outer fill or stroke outline when the generated geometry is damaged, before a rectangle), text with its typography and paints, saved bounds and sticky author lines, as editable frame children; missing or invalid generated text falls back to the outer text within the layer budget. Synthetic regressions and a local check of the report sample; native board metadata and direct `.jam` opening remain outside this fix. Numbers unchanged. |
| 2026-10-27 | minor | Placed images keep their own size (M7, #1360): dropping, Place image, paste and `image.place` put an image at its pixel size and fit only a side past 4096 px (they fitted it into 1000 px); a PNG whose `pHYs` records a whole 2x–4x multiple of 72 dpi (macOS screenshots record 144 dpi) is placed at its point size, so an 800 × 600 px capture comes in at 400 × 300; other densities, such as 300 dpi scans, keep their pixel size; the image data stays at full resolution and an explicit width or height still wins. On macOS, paste reads the clipboard's PNG as is (its density and colour profile kept) and falls back to the decoded image. Numbers unchanged |
| 2026-10-26 | minor | Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` transliterates through the new `deunicode` dependency (pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`) in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes; symbols-only names take the node's kind (`.text`) instead of `layer-`, and the 40-character cut lands on a word boundary. `html_codegen_tests` updated and extended. Numbers unchanged |
| 2026-10-25 | minor | M6, #1143: offline Penpot 2.x open/import/export through the editor, CLI and MCP using the existing document model. Unsupported layout settings retain saved positions and bounded source metadata with warnings; font binaries and toggle-overlay are reported, and provider fonts are never downloaded. Shared ZIP/JSON/token budgets guard untrusted input. Figma parity numbers unchanged. |
| 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. |
| 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged |
| 2026-10-24 | minor | Boards (M12): a dot grid on board pages under the layers, connector ends dragged onto other objects or empty canvas (`board.reattach`), curved connectors, elbows that go around what they join. Boards row ~35% → ~37%, ~20% → ~21%, 30–60 h → 28–56 h; headline numbers unchanged (boards are outside the weighted sum) |
@@ -433,6 +461,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. |
| 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. |
| 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. |
| 2026-10-16 | minor | Flutter code (ours; the incumbent generates CSS, iOS and Android only): `inspect.code`, `inspect.componentCode` and `codeConnect.set` take `language: flutter` (`dart` is an alias): a `StatelessWidget` for a component (properties as constructor parameters with their defaults, instances as calls, variants as a Dart enum, swaps a `Widget`, slots a `List<Widget>?`) and a Dart widget tree for a layer (`Container` with a `BoxDecoration`, `Row`/`Column` with `spacing`, `Expanded`, `Stack` + `Positioned`, `Text` with a `TextStyle`, `Image.asset`, `Opacity`), plus Dart `import '…';` lines in Code Connect. The Dev Mode language list includes it. Numbers unchanged |
| 2026-10-16 | minor | Adobe Fonts found on macOS (M7, #298): fonts activated through Adobe Fonts rendered in the fallback font without a warning, because Creative Cloud keeps them in its own folder as hidden files named by number, which the font scan never read. The macOS scan now includes `~/Library/Application Support/Adobe/CoreSync/plugins/livetype`, and files without an extension are indexed when they start with a font signature. Fonts are read in place. Regression test in the font database tests; docs/cli.md lists the folder. Numbers unchanged |
| 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged |
| 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. |
+33 -5
View File
@@ -11,7 +11,13 @@ use serde_json::{Value, json};
use crate::{CliError, CliResult};
pub fn load(path: &str) -> CliResult<Document> {
load_noted(path).map(|(d, _)| d)
let (doc, notes) = load_noted(path)?;
if ext(path) == "penpot" {
for note in notes {
eprintln!("note: {note}");
}
}
Ok(doc)
}
/// The document with the loader's notes (repairs of a damaged file, or import warnings).
@@ -120,7 +126,8 @@ pub fn write_doc(doc: &Document, page: NodeId, out: &str) -> CliResult<()> {
"png" | "jpg" | "jpeg" | "webp" => encode(doc, &render_page(doc, page, 1.0)?, &e)?,
"pdf" => page_pdf(doc, page, &PrintSettings::default())?,
"fig" => fig_bytes(doc)?,
other => return Err(CliError::Usage(format!("unknown output format `.{other}` (figcraft, json, svg, png, jpg, webp, pdf, fig)"))),
"penpot" => penpot_bytes(doc)?,
other => return Err(CliError::Usage(format!("unknown output format `.{other}` (figcraft, json, svg, png, jpg, webp, pdf, fig, penpot)"))),
};
write(out, &bytes)
}
@@ -134,6 +141,14 @@ fn fig_bytes(doc: &Document) -> CliResult<Vec<u8>> {
Ok(bytes)
}
fn penpot_bytes(doc: &Document) -> CliResult<Vec<u8>> {
let (bytes, warnings) = figcraft_engine::cmd::file::penpot_bytes(doc).map_err(|e| CliError::Failed(e.to_string()))?;
for w in warnings {
eprintln!("note: {w}");
}
Ok(bytes)
}
/// A page as PDF: its top-level frames one page each, or (without frames) everything on one page.
fn page_pdf(doc: &Document, page: NodeId, print: &PrintSettings) -> CliResult<Vec<u8>> {
let r = if doc.top_frames(page).iter().any(|f| doc.node(*f).is_some_and(|n| n.visible)) {
@@ -237,12 +252,16 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
println!("{out}");
return Ok(ExitCode::SUCCESS);
}
if t.format.as_deref().map_or(ext(out) == "penpot", |f| f.eq_ignore_ascii_case("penpot")) {
write(out, &penpot_bytes(&doc)?)?;
println!("{out}");
return Ok(ExitCode::SUCCESS);
}
let page = page_named(&doc, t.page.as_deref())?;
let (scale, node, print) = (t.scale, t.node.as_deref(), &t.print);
let fmt = match t.format.as_deref() {
Some(f) => {
image_format(&f.to_ascii_lowercase()).ok_or_else(|| CliError::Usage(format!("unknown --format `{f}` (png|jpg|svg|webp|pdf|xml|fig)")))?
}
Some(f) => image_format(&f.to_ascii_lowercase())
.ok_or_else(|| CliError::Usage(format!("unknown --format `{f}` (png|jpg|svg|webp|pdf|xml|fig|penpot)")))?,
None => image_format(&ext(out)).unwrap_or(ExportFormat::Png),
};
let target = node.map(|q| find_node(&doc, page, q)).transpose()?;
@@ -321,6 +340,11 @@ fn refuse_dropping_images_from_script(input: Option<(&str, usize)>, active: &fig
pub fn convert(input: &str, out: &str) -> CliResult<ExitCode> {
let (loaded, _) = figcraft_engine::cmd::file::load_path(input, None).map_err(|e| CliError::Failed(format!("can't open {input}: {e}")))?;
refuse_dropping_images(input, out, loaded.images_past_budget)?;
if ext(input) == "penpot" {
for note in &loaded.issues {
eprintln!("note: {note}");
}
}
let doc = loaded.doc;
let page = doc
.current_page
@@ -366,6 +390,7 @@ pub fn parse_script(text: &str) -> Result<Vec<BatchStep>, String> {
.enumerate()
.map(|(i, s)| {
let cmd = s.get("command").and_then(Value::as_str).ok_or_else(|| format!("step {i}: `command` (a string) is required"))?;
figcraft_engine::batch::refuse_unknown_step_keys(i, &s).map_err(|e| e.to_string())?;
let params = match s.get("params") {
None | Some(Value::Null) => json!({}),
Some(p @ Value::Object(_)) => p.clone(),
@@ -443,6 +468,9 @@ mod tests {
assert!(parse_script("[{\"command\":\"a\",\"params\":3}]").is_err());
assert!(parse_script("not json\n").is_err());
assert!(parse_script("42").is_err());
// `args` for `params` would run the step without them (#1413).
let e = parse_script(r#"[{"command":"a"},{"command":"b","args":{"x":1}}]"#).unwrap_err().to_string();
assert!(e.contains("step 1: unknown key `args`"), "{e}");
}
#[test]
+3 -3
View File
@@ -17,7 +17,7 @@ pub const USAGE: &str = "figcraft-cli — FigCraft without a window
USAGE:
figcraft-cli info <file> [--json]
figcraft-cli export <file> -o <out> [--format png|jpg|svg|webp|pdf|xml|fig] [--scale N] [--page NAME] [--node ID|NAME]
figcraft-cli export <file> -o <out> [--format png|jpg|svg|webp|pdf|xml|fig|penpot] [--scale N] [--page NAME] [--node ID|NAME]
[--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk] [--vd-compat bestEffort|exact|ignore]
figcraft-cli convert <in> <out>
figcraft-cli render <file> -o <out.png> [--scale N] [--page NAME]
@@ -33,11 +33,11 @@ Every subcommand takes --no-system-fonts (or set FIGCRAFT_NO_SYSTEM_FONTS=1): te
fonts built into FigCraft, for renders that match on every machine. Otherwise installed fonts are
indexed the first time text is laid out, as in the app.
Files: .figcraft (native), .json (FigCraft or REST API file JSON), .fig (best effort; `info` lists what
Files: .figcraft (native), .penpot (Penpot 2.x), .json (FigCraft or REST API file JSON), .fig (best effort; `info` lists what
wasn't imported), .sketch (Sketch 48 or newer, best effort), .svg, .png/.jpg/.webp/.gif/.bmp (open as an
image layer). `export` writes one file per top-level frame into the <out> directory, or one file when
<out> has an image extension or --node is given (`fig`, or an <out> ending in .fig: the whole file as .fig, experimental; other apps may
not open every file); PDFs take print options (a bleed like 3mm or 0.125in,
not open every file; `penpot`, or an <out> ending in .penpot: the whole file as a Penpot 2.x archive); PDFs take print options (a bleed like 3mm or 0.125in,
crop and bleed marks, CMYK colour). `run` executes a JSON array (or JSON lines) of
{\"command\": id, \"params\": {...}} in a headless session and prints one JSON result line per step.
`plugin publish` sends a plugin folder (figcraft-plugin.json, its module, an optional assets/ folder)
+34
View File
@@ -13,6 +13,40 @@ fn tmp(name: &str) -> std::path::PathBuf {
d
}
/// Conversion and image commands must report the Penpot loader's notes too.
#[test]
fn penpot_import_notes_reach_convert_export_and_render() {
use figcraft_doc::proto::{Action, Easing, Interaction, Transition, Trigger};
use figcraft_doc::{Document, NodeKind};
let d = tmp("penpot-notes");
let mut doc = Document::new("Prototype");
let page = doc.first_page().unwrap();
let home = doc.create(page, NodeKind::Frame, "Home", 0.0, 0.0, 40.0, 40.0).unwrap();
let dest = doc.create(page, NodeKind::Frame, "Next", 60.0, 0.0, 40.0, 40.0).unwrap();
doc.node_mut(home).unwrap().interactions.push(Interaction {
trigger: Trigger::OnClick,
actions: vec![Action::Navigate { dest, preserve_scroll: false }],
transition: Transition::Instant,
duration_ms: 0,
easing: Easing::Linear,
delay_ms: 0,
});
let input = d.join("source.penpot");
std::fs::write(&input, figcraft_format::penpot::export(&doc).unwrap().0).unwrap();
for (command, name) in [("convert", "out.figcraft"), ("export", "out.svg"), ("render", "out.png")] {
let mut cmd = bin();
cmd.arg(command).arg(&input);
if command != "convert" {
cmd.arg("-o");
}
let out = cmd.arg(d.join(name)).output().unwrap();
let notes = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "{command}: {notes}");
assert!(notes.contains("note: Imported 1 Penpot prototype connection"), "{command}: {notes}");
}
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn option_terminator_allows_dash_prefixed_filenames() {
let dir = tmp("option-terminator");
+2 -2
View File
@@ -43,8 +43,8 @@ Options (also keys of the --config TOML file):
--data DIR the data directory (default ./figcraft-data)
--public-url URL how links name the server behind a reverse proxy (wss://host/path/)
--config FILE settings: listen, data, public_url, max_files, max_file_mb, max_links,
max_connections, max_people, compact_after, compact_mb, max_plugins,
max_plugin_mb, max_plugins_mb; for a public server: open_publish,
max_connections, max_people, compact_after, compact_mb, max_log_mb,
max_plugins, max_plugin_mb, max_plugins_mb; for a public server: open_publish,
operator_contact, terms_url, trusted_proxy, idle_days, max_total_mb,
max_files_per_ip, max_mb_per_ip, max_connections_per_ip,
connects_per_minute, publishes_per_hour, messages_per_second
+14 -7
View File
@@ -39,6 +39,9 @@ pub struct Config {
pub compact_after: usize,
/// …or once the log is this large (MiB).
pub compact_mb: u64,
/// Most MiB of recent batches a file in use keeps in memory for apps that reconnect (one
/// further behind catches up with the whole file).
pub max_log_mb: u64,
/// Most plugin versions the plugin directory keeps (0 turns it off).
pub max_plugins: usize,
/// Largest plugin bundle, in MiB.
@@ -84,6 +87,7 @@ impl Default for Config {
max_people: 256,
compact_after: 1000,
compact_mb: 16,
max_log_mb: 64,
max_plugins: 500,
max_plugin_mb: 16,
max_plugins_mb: 1024,
@@ -209,6 +213,7 @@ impl Config {
("max_people", self.max_people as u64),
("compact_after", self.compact_after as u64),
("compact_mb", self.compact_mb),
("max_log_mb", self.max_log_mb),
("max_plugin_mb", self.max_plugin_mb),
("max_plugins_mb", self.max_plugins_mb),
] {
@@ -216,7 +221,7 @@ impl Config {
return Err(format!("`{name}` must be at least 1"));
}
}
if self.file_mb() > 4096 || self.compact_mb > 4096 {
if self.file_mb() > 4096 || self.compact_mb > 4096 || self.max_log_mb > 4096 {
return Err("sizes are in MiB and at most 4096".into());
}
// A bundle travels in one message (at most 64 MiB) next to its entry.
@@ -274,17 +279,19 @@ impl Config {
usize::try_from(self.file_mb().max(self.max_plugin_mb).saturating_add(1).saturating_mul(1 << 20)).unwrap_or(usize::MAX)
}
/// The collaboration core's part: when open, messages as large as a file at most, and the
/// batches kept for reconnecting clients (in memory, and in the file's snapshot) at most a
/// quarter of a file's bytes (256 KiB at least).
/// The collaboration core's part: the batches kept for reconnecting clients (in memory, and
/// in the file's snapshot) at most `max_log_mb`; when open, messages as large as a file at
/// most, and those batches also at most a quarter of a file's bytes (256 KiB at least).
fn core(&self) -> figcraft_collab::Limits {
let base = figcraft_collab::Limits { max_peers: self.max_people, ..figcraft_collab::Limits::default() };
let bytes = |mb: u64| usize::try_from(mb.saturating_mul(1 << 20)).unwrap_or(usize::MAX);
let base =
figcraft_collab::Limits { max_peers: self.max_people, max_log_bytes: bytes(self.max_log_mb), ..figcraft_collab::Limits::default() };
if !self.open_publish {
return figcraft_collab::Limits { max_message_bytes: base.max_message_bytes.max(self.open_message()), ..base };
}
let max = self.open_message();
let file = usize::try_from(self.file_mb().saturating_mul(1 << 20)).unwrap_or(usize::MAX);
figcraft_collab::Limits { max_message_bytes: max, max_log_bytes: (file / 4).max(256 << 10), ..base }
let log = (bytes(self.file_mb()) / 4).max(256 << 10).min(base.max_log_bytes);
figcraft_collab::Limits { max_message_bytes: max, max_log_bytes: log, ..base }
}
/// The transport's part. A private server keeps the defaults; open to the public, connections
+13
View File
@@ -584,6 +584,19 @@ fn files_above_64_mib_fit_every_limit() {
assert_eq!(NetLimits::default().max_server_message, 1 << 30);
}
#[test]
fn the_batches_kept_for_reconnecting_apps_follow_max_log_mb() {
let log = |cfg: Config| cfg.store().core.max_log_bytes;
assert_eq!(log(Config::default()), 64 << 20, "a private server's log is bounded too");
assert_eq!(log(Config { max_log_mb: 8, ..Config::default() }), 8 << 20);
// Open to the public: also at most a quarter of a file (16 MiB unless set).
assert_eq!(log(Config { open_publish: true, ..Config::default() }), 4 << 20);
assert_eq!(log(Config { open_publish: true, max_log_mb: 1, ..Config::default() }), 1 << 20);
for mb in [0, 4097] {
assert!(Config { max_log_mb: mb, ..Config::default() }.check().is_err(), "max_log_mb = {mb}");
}
}
#[test]
fn large_files_warn_the_operator() {
let public_url = Some("wss://figcraft.example.com/".into());
+6
View File
@@ -0,0 +1,6 @@
# The browser build (trunk runs cargo from this folder; packaging/web/package.sh). With simd128 the
# glyph rasterizer's SIMD code compiles for wasm SIMD instead of its much larger scalar fallback:
# about 1.6 MB less wasm. Every current browser has wasm SIMD (Safari since 16.4). The plugin
# workspace is built from the repository root, so its modules stay without SIMD for the plugin runtime.
[target.wasm32-unknown-unknown]
rustflags = ["-Ctarget-feature=+simd128"]
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no, viewport-fit=cover">
<title>FigCraft</title>
<link data-trunk rel="rust" data-bin="figcraft-web" data-cargo-profile="web-release" data-wasm-opt="z" data-wasm-opt-params="--enable-bulk-memory --enable-bulk-memory-opt --enable-nontrapping-float-to-int --enable-sign-ext --enable-reference-types --enable-multivalue" data-weak-refs />
<link data-trunk rel="rust" data-bin="figcraft-web" data-cargo-profile="web-release" data-wasm-opt="z" data-wasm-opt-params="--enable-bulk-memory --enable-bulk-memory-opt --enable-nontrapping-float-to-int --enable-sign-ext --enable-reference-types --enable-multivalue --enable-simd" data-weak-refs />
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; overscroll-behavior: none; background: #f5f5f5; }
#figcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; touch-action: none; }
+3
View File
@@ -48,6 +48,9 @@ zbus = { version = "5", default-features = false, features = ["async-io", "block
# unsafe, which the workspace forbids). Pinned exactly: review the diff before bumping.
[target.'cfg(target_os = "macos")'.dependencies]
fmv-macos-events = "=0.1.0"
# Paste reads the clipboard's public.png as is, keeping the screen density a screenshot records.
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSPasteboard"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSData", "NSString"] }
# A macOS app menu whose Quit (⌘Q) asks the app first (sends the root window a close request, the
# same guarded path as the close button); winit's default Quit ends the process through
# `terminate:`, losing unsaved changes without a word (issue #857).
+9
View File
@@ -327,6 +327,9 @@ fn picked(pick: impl Future<Output = Option<rfd::FileHandle>> + 'static) -> Dial
Box::pin(async move { pick.await.map(|f| f.path().to_path_buf()) })
}
#[cfg(target_os = "macos")]
const MACOS_PASTEBOARD_PNG: &str = "public.png";
/// How long a clipboard read may wait on the app that copied (Linux and the BSDs).
#[cfg(all(unix, not(target_os = "macos")))]
const CLIPBOARD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
@@ -438,6 +441,12 @@ impl Services for Desktop {
}
Some((w, h, img.bytes.into_owned()))
}
#[cfg(target_os = "macos")]
fn clipboard_get_png(&mut self) -> Option<Vec<u8>> {
let board = objc2_app_kit::NSPasteboard::generalPasteboard();
let png = board.dataForType(&objc2_foundation::NSString::from_str(MACOS_PASTEBOARD_PNG))?;
(png.len() <= figcraft_ui_egui::MAX_CLIPBOARD_PNG_BYTES).then(|| png.to_vec())
}
fn open_url(&mut self, url: &str) {
let _ = webbrowser::open(url);
}
+3
View File
@@ -92,3 +92,6 @@ sync_github_name = true
[people.culiulemon]
sync_github_name = true
[people.StrajnarFilip]
display_name = "Filip Strajnar"
+62 -2
View File
@@ -131,9 +131,10 @@ fn document_level_data_round_trips() {
x: 1.0,
y: 2.0,
anchor: None,
first: Message { author: "Ada".into(), text: "Hi".into(), created: 1, edited: 0, reactions: vec![] },
first: Message { author: "Ada".into(), text: "Hi".into(), created: 1, edited: 0, reactions: vec![], agent: false },
resolved: false,
replies: vec![],
// An AI agent's reply reaches the others with its name and marker.
replies: vec![Message { agent: true, ..Message::new("Claude (agent)", "Done", 2) }],
read_by: None,
});
b.images.insert("hash".into(), ImageData { bytes: Arc::new(vec![1, 2, 3, 250]), width: 2, height: 1 });
@@ -250,3 +251,62 @@ fn invalid_changes_are_refused_and_lenient_mode_skips_them() {
let mut r = a.clone();
assert!(r.apply(&[Change::Delete { id: NodeId(12345) }, Change::Remove { entry: Entry::Comment(1) }], Mode::Strict).is_ok());
}
/// Where `id` goes among `kids`, as placement worked before it searched: after every other sibling
/// that sorts before it by `(key, id)`, counted one by one.
fn place_counting(kids: &mut Vec<NodeId>, keys: &BTreeMap<NodeId, String>, id: NodeId) {
kids.retain(|c| *c != id);
let key = |c: NodeId| keys.get(&c).map_or("", String::as_str);
let at = kids.iter().filter(|c| (key(**c), **c) < (key(id), id)).count();
kids.insert(at, id);
}
#[test]
fn many_sibling_placements_keep_the_order_of_counting_them() {
// The reference counts, so it's quadratic: placing 20,000 here took 20 s before the search
// and 0.3 s after (debug build).
const N: u64 = 5_000;
let mut doc = Document::new("Order");
let page = doc.first_page().unwrap();
let parents = [page, doc.create(page, NodeKind::Frame, "A", 0.0, 0.0, 10.0, 10.0).unwrap()];
let mut r = Replica::new(doc);
let mut rng = crate::sim::Rng::new(1065);
// Few whole keys and the fractions between them: many ties (broken by id), many fractions.
let whole = crate::frac::n_between(None, None, 12).unwrap();
let mut pool = whole.clone();
pool.extend(whole.windows(2).map(|w| crate::frac::between(Some(&w[0]), Some(&w[1])).unwrap()));
let mut keys: BTreeMap<NodeId, String> = parents.iter().map(|p| (*p, r.key(*p).to_string())).collect();
let mut kids: BTreeMap<NodeId, Vec<NodeId>> = parents.iter().map(|p| (*p, r.document().node(*p).unwrap().children.clone())).collect();
let mut parent_of = BTreeMap::new();
// One batch of creations in a scrambled id order, under either parent.
let mut creates = Vec::new();
for i in 0..N {
let (id, p, key) = (NodeId(1_000_000 + (i * 7919) % N), parents[rng.below(2)], pool[rng.below(pool.len())].clone());
keys.insert(id, key.clone());
parent_of.insert(id, p);
place_counting(kids.get_mut(&p).unwrap(), &keys, id);
creates.push(Change::Create { id, kind: Json::of(&NodeKind::Rectangle).unwrap(), parent: Some(p), position: key, props: vec![] });
}
// Then a batch of moves: to another key (often a tie), or under either parent (its own too).
let mut moves = Vec::new();
for _ in 0..N / 2 {
let id = NodeId(1_000_000 + rng.below(N as usize) as u64);
if rng.chance(0.5) {
let key = pool[rng.below(pool.len())].clone();
keys.insert(id, key.clone());
place_counting(kids.get_mut(&parent_of[&id]).unwrap(), &keys, id);
moves.push(Change::Set { id, prop: Prop::Position, value: Json::of(&key).unwrap() });
} else {
let p = parents[rng.below(2)];
let old = parent_of.insert(id, p).unwrap();
kids.get_mut(&old).unwrap().retain(|c| *c != id);
place_counting(kids.get_mut(&p).unwrap(), &keys, id);
moves.push(Change::Set { id, prop: Prop::Parent, value: Json::of(&Some(p)).unwrap() });
}
}
r.apply(&creates, Mode::Strict).unwrap();
r.apply(&moves, Mode::Strict).unwrap();
for p in parents {
assert_eq!(r.document().node(p).unwrap().children, kids[&p], "children of {p}");
}
}
+3 -2
View File
@@ -88,7 +88,8 @@ pub struct Limits {
pub max_log_batches: usize,
/// Most changes in those batches together.
pub max_log_changes: usize,
/// About how many bytes those batches may hold together ([`server::weight`]).
/// About how many bytes those batches may hold together ([`server::weight`]). 64 MiB by
/// default, so a long busy session (on a desktop host too) keeps a bounded log.
pub max_log_bytes: usize,
}
@@ -102,7 +103,7 @@ impl Default for Limits {
max_name: 64,
max_log_batches: 10_000,
max_log_changes: 1_000_000,
max_log_bytes: usize::MAX,
max_log_bytes: 64 << 20,
}
}
}
+37
View File
@@ -133,6 +133,43 @@ fn a_long_absence_catches_up_with_a_snapshot() {
assert_eq!(a.view(), s.replica());
}
#[test]
fn the_default_log_is_bounded_in_bytes_and_a_client_behind_it_catches_up_with_a_snapshot() {
// The defaults are what a desktop host runs with.
let limits = Limits::default();
assert_eq!(limits.max_log_bytes, 64 << 20);
let mut s = Server::new(base(), "tok", limits);
let mut a = join(&mut s);
let mut b = join(&mut s);
let page = s.document().first_page().unwrap();
a.disconnect();
s.leave(a.id());
// Nine renames of 8 MiB each: more than the log holds, in a few batches.
for i in 0..9 {
let m = edit(&mut b, |d| d.node_mut(page).unwrap().name = format!("{i}{}", "y".repeat(8 << 20))).unwrap();
let out = s.receive(b.id(), m);
deliver(&mut b, &out);
assert!(s.log_bytes() <= limits.max_log_bytes, "{} bytes kept", s.log_bytes());
}
assert!(s.log_bytes() > 0, "recent batches are still kept");
let ClientMsg::Hello(h) = a.reconnect(hello()) else { panic!() };
let (_, out) = s.join(h).unwrap();
assert!(matches!(&out[0], Outgoing::To(_, ServerMsg::Welcome { catch_up: CatchUp::Snapshot(_), .. })));
deliver(&mut a, &out);
assert_eq!(a.view(), s.replica());
// Within the log, a short absence still gets the missed batch only.
a.disconnect();
s.leave(a.id());
let m = edit(&mut b, |d| d.node_mut(page).unwrap().name = "Later".into()).unwrap();
let out = s.receive(b.id(), m);
deliver(&mut b, &out);
let ClientMsg::Hello(h) = a.reconnect(hello()) else { panic!() };
let (_, out) = s.join(h).unwrap();
assert!(matches!(&out[0], Outgoing::To(_, ServerMsg::Welcome { catch_up: CatchUp::Delta(d), .. }) if d.len() == 1));
deliver(&mut a, &out);
assert_eq!(a.view(), s.replica());
}
#[test]
fn after_a_snapshot_undo_leaves_what_others_changed_meanwhile() {
let mut s = Server::new(base(), "tok", Limits { max_log_batches: 2, ..Limits::default() });
+8 -2
View File
@@ -175,9 +175,15 @@ impl Replica {
}
/// Where `id` with `key` goes among `siblings` (sorted by `(key, id)`; `id` itself skipped):
/// how many of them sort before it.
/// how many of them sort before it. A binary search on each side of `id`, which may still
/// sit where its old key put it.
fn slot(&self, siblings: &[NodeId], id: NodeId, key: &str) -> usize {
siblings.iter().filter(|c| **c != id && (self.key(**c), **c) < (key, id)).count()
let before = |c: &NodeId| (self.key(*c), *c) < (key, id);
let (head, tail) = match siblings.iter().position(|c| *c == id) {
Some(i) => (siblings.get(..i).unwrap_or_default(), siblings.get(i + 1..).unwrap_or_default()),
None => (siblings, &[][..]),
};
head.partition_point(before) + tail.partition_point(before)
}
/// Apply `changes` in order. Node changes come first in a batch made by [`diff`](crate::diff);
+18 -13
View File
@@ -557,6 +557,7 @@ impl Server {
let doc = self.state.document();
let mut props: BTreeMap<NodeId, BTreeSet<Prop>> = BTreeMap::new();
let mut gone: BTreeSet<NodeId> = BTreeSet::new();
let (mut asked, mut sent) = (BTreeSet::new(), BTreeSet::new());
let mut out = Vec::new();
for c in changes {
match c {
@@ -569,9 +570,11 @@ impl Server {
Change::Delete { id } => {
gone.insert(*id);
}
// The same target has the same fix: look each one up once, and send each fix once.
other => {
if let Some(fix) = docprops::current(doc, other)?
&& !out.contains(&fix)
if Target::of(other).is_some_and(|t| asked.insert(t))
&& let Some(fix) = docprops::current(doc, other)?
&& Target::of(&fix).is_some_and(|t| sent.insert(t))
{
out.push(fix);
}
@@ -589,19 +592,21 @@ impl Server {
nodes.extend(t.write(self.state.value(&t)?.as_ref()));
}
}
let mut restored = std::collections::BTreeSet::new();
// Each deleted node's subtree, pre-order, every node once: a restored node's whole subtree
// went out with it, so the walk stops there (deleting a deep chain stays linear).
let mut restored = BTreeSet::new();
for id in gone {
match doc.node(id) {
Some(_) => {
for d in doc.subtree(id) {
if restored.insert(d)
&& let Some(n) = doc.node(d)
{
nodes.push(create_change(n, self.state.key(d))?);
}
}
if !doc.contains(id) {
nodes.push(Change::Delete { id });
continue;
}
let mut stack = vec![id];
while let Some(d) = stack.pop() {
let Some(n) = doc.node(d) else { continue };
if restored.insert(d) {
nodes.push(create_change(n, self.state.key(d))?);
stack.extend(n.children.iter().rev());
}
None => nodes.push(Change::Delete { id }),
}
}
nodes.extend(out);
+38 -1
View File
@@ -2,7 +2,7 @@
use figcraft_doc::{Document, Node, NodeId, NodeKind};
use crate::change::{Change, Json, Prop};
use crate::change::{Change, Entry, Json, Prop};
use crate::message::{ClientMsg, Hello, PROTOCOL, Presence, ServerMsg};
use crate::replica::Mode;
use crate::{Client, Limits, NoRecompute, Outgoing, Server};
@@ -217,6 +217,43 @@ fn fixes_roll_a_rejected_client_back_to_the_server_state() {
assert_eq!(b.view(), s.replica());
}
#[test]
fn a_refused_deep_delete_restores_each_node_once_in_linear_time() {
const DEPTH: usize = 20_000;
let (mut d, _, frame) = doc();
let mut chain = vec![frame];
while chain.len() < DEPTH {
let parent = *chain.last().unwrap();
chain.push(d.create(parent, NodeKind::Frame, "F", 0.0, 0.0, 1.0, 1.0).unwrap());
}
let mut s = Server::new(d, "tok", Limits::default());
let a = join(&mut s);
// Every node of the chain deleted, plus a rename of a node nobody has: refused whole.
let missing = NodeId(u64::MAX >> 1);
let mut changes: Vec<Change> = chain.iter().map(|&id| Change::Delete { id }).collect();
changes.push(Change::Set { id: missing, prop: Prop::Name, value: Json("x".into()) });
// As many comment threads put, each twice: they go back to not existing, once each.
let put = |c: u64| Change::Put { entry: Entry::Comment(c), value: Json(serde_json::json!({})) };
changes.extend((1..=DEPTH as u64).chain(1..=DEPTH as u64).map(put));
let started = std::time::Instant::now();
let (_, fixes) = rejected(&mut s, a.id(), changes);
let took = started.elapsed();
assert_eq!(fixes.first(), Some(&Change::Delete { id: missing }));
let restored: Vec<NodeId> = fixes[1..=DEPTH]
.iter()
.map(|c| match c {
Change::Create { id, .. } => *id,
other => panic!("not a creation: {other:?}"),
})
.collect();
assert_eq!(restored, chain, "each deleted node comes back once, parents first");
let removed: Vec<Change> = (1..=DEPTH as u64).map(|c| Change::Remove { entry: Entry::Comment(c) }).collect();
assert_eq!(fixes[DEPTH + 1..], removed[..], "each entry once, in batch order");
// Walking every deleted node's subtree again and comparing each fix with all the earlier
// ones took 27 s in a debug build; once each takes 0.1 s.
assert!(took < std::time::Duration::from_secs(10), "{took:?}");
}
#[test]
fn remote_batches_apply_under_pending_ones() {
let (d, _, frame) = doc();
+15 -1
View File
@@ -486,9 +486,23 @@ impl ImagePaint {
{
return c;
}
self.fitted_crop(iw, ih, w, h, false)
}
/// The crop transform that keeps the picture where its scale mode draws it: the crop in effect
/// in Crop mode, the Fit placement in Fit mode, else (Fill, Tile) the Fill placement.
pub fn placement_crop(&self, iw: f64, ih: f64, w: f64, h: f64) -> [f64; 6] {
match self.scale_mode {
ImageScaleMode::Crop => self.effective_crop(iw, ih, w, h),
mode => self.fitted_crop(iw, ih, w, h, mode == ImageScaleMode::Fit),
}
}
/// The Fill placement (`fit`: the Fit placement) as a crop transform.
fn fitted_crop(&self, iw: f64, ih: f64, w: f64, h: f64, fit: bool) -> [f64; 6] {
let (rw, rh) = self.rotated_size(iw.max(1.0), ih.max(1.0));
let (w, h) = (w.max(1e-6), h.max(1e-6));
let s = (w / rw).max(h / rh);
let s = if fit { (w / rw).min(h / rh) } else { (w / rw).max(h / rh) };
let (x0, y0) = ((w - rw * s) / 2.0, (h - rh * s) / 2.0);
[w / (rw * s), 0.0, 0.0, h / (rh * s), -x0 / (rw * s), -y0 / (rh * s)]
}
+56 -1
View File
@@ -2,7 +2,7 @@
//! picks the group, and a double click goes one level deeper than the selection.
use crate::geometry::{enter_target, select_target};
use crate::{BoolOp, Document, NodeId, NodeKind};
use crate::{BoolOp, Document, NodeId, NodeKind, Region, Segment, VectorNetwork, Vertex, WindingRule};
use figcraft_geom::Point;
fn page(d: &Document) -> NodeId {
@@ -40,6 +40,61 @@ fn hits_reach_operands_but_clicks_pick_the_group() {
assert_eq!(listed, vec![f, g, b]);
}
#[test]
fn ordinary_hit_uses_region_paints_without_layer_fill() {
let mut d = Document::new("region hit");
let p = page(&d);
let network = VectorNetwork {
vertices: vec![Vertex::new(0.0, 0.0), Vertex::new(10.0, 0.0), Vertex::new(10.0, 10.0), Vertex::new(0.0, 10.0)],
segments: (0..4).map(|i| Segment::line(i, (i + 1) % 4)).collect(),
regions: vec![
Region {
loops: vec![vec![0, 1, 2, 3]],
winding: WindingRule::NonZero,
fills: Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]),
..Default::default()
},
Region { loops: vec![vec![0, 1, 2, 3]], winding: WindingRule::NonZero, fills: Some(Vec::new()), ..Default::default() },
],
};
let id = d.create(p, NodeKind::Vector { network }, "V", 0.0, 0.0, 10.0, 10.0).unwrap();
d.get_mut(id).unwrap().fills.clear();
assert!(d.node(id).unwrap().fills.is_empty());
assert_eq!(d.hit_test(p, Point::new(5.0, 5.0), 0.0, None), Some(id));
assert_eq!(d.hit_test(p, Point::new(15.0, 5.0), 0.0, None), None);
}
#[test]
fn inherited_paint_frames_use_per_region_hit_testing() {
let mut d = Document::new("inherited region hit");
let p = page(&d);
let mut network = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0), Point::new(0.0, 10.0)], true);
network.regions[0].winding = WindingRule::EvenOdd;
network.regions.push(network.regions[0].clone());
let id = d.create(p, NodeKind::Vector { network }, "V", 0.0, 0.0, 10.0, 10.0).unwrap();
assert_eq!(d.hit_test(p, Point::new(5.0, 5.0), 0.0, None), None, "legacy even-odd loops cancel");
let NodeKind::Vector { network } = &mut d.get_mut(id).unwrap().kind else { panic!("expected vector") };
network.regions[0].paint_space = Some(crate::RegionPaintSpace { width: 10.0, height: 10.0, transform: figcraft_geom::Affine::IDENTITY });
assert!(network.regions.iter().all(|region| region.fills.is_none()));
assert_eq!(d.hit_test(p, Point::new(5.0, 5.0), 0.0, None), Some(id), "paint-framed regions inherit the node fill independently");
assert_eq!(d.hit_test(p, Point::new(15.0, 5.0), 0.0, None), None);
}
#[test]
fn explicitly_unfilled_regions_keep_the_stroke_only_selection_band() {
let mut d = Document::new("stroke selection");
let p = page(&d);
let mut network =
VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)], true);
network.regions[0].fills = Some(Vec::new());
let id = d.create(p, NodeKind::Vector { network }, "V", 0.0, 0.0, 100.0, 100.0).unwrap();
let n = d.get_mut(id).unwrap();
n.fills.clear();
n.strokes = vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)];
n.stroke.weight = 1.0;
assert_eq!(d.hit_test(p, Point::new(50.0, -4.0), 2.0, None), Some(id));
}
#[test]
fn double_clicks_go_one_level_deeper() {
let (d, f, g, b, r1, _) = setup();
+3
View File
@@ -60,6 +60,9 @@ impl Node {
f(&mut prototype.background);
}
NodeKind::Text(t) => t.for_each_color_mut(f),
NodeKind::Vector { network } => {
network.regions.iter_mut().filter_map(|r| r.fills.as_mut()).flatten().for_each(|p| p.for_each_color_mut(f))
}
_ => {}
}
for it in &mut self.interactions {
+19 -9
View File
@@ -26,6 +26,9 @@ pub struct Message {
pub edited: u64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub reactions: Vec<Reaction>,
/// Written by an AI agent (a command run for an MCP client), not by a person in the app.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub agent: bool,
}
/// A reply in a comment thread.
@@ -37,7 +40,7 @@ fn is_zero(v: &u64) -> bool {
impl Message {
pub fn new(author: impl Into<String>, text: impl Into<String>, created: u64) -> Self {
Self { author: author.into(), text: text.into(), created, edited: 0, reactions: Vec::new() }
Self { author: author.into(), text: text.into(), created, edited: 0, reactions: Vec::new(), agent: false }
}
/// `user` left the `emoji` reaction.
@@ -180,10 +183,16 @@ impl Comment {
}
}
/// The byte ranges of `@Name` mentions of `people` in `text` (names match ignoring ASCII case; the
/// longest name wins; a mention starts the text or follows a non-alphanumeric character and ends
/// before one).
/// The byte ranges of `@Name` mentions of `people` in `text` (see [`mentions`]).
pub fn mention_ranges(text: &str, people: &[&str]) -> Vec<Range<usize>> {
mentions(text, people).into_iter().filter(|(_, who)| who.is_some()).map(|(r, _)| r).collect()
}
/// Every `@` mention in `text`: its byte range and the one of `people` it names (names match
/// ignoring ASCII case; the longest name wins), or `None` for a name nobody in `people` has (then
/// the letters and digits after the `@`). A mention starts the text or follows a non-alphanumeric
/// character and ends before one, so `a@b.com` mentions nobody.
pub fn mentions<'p>(text: &str, people: &[&'p str]) -> Vec<(Range<usize>, Option<&'p str>)> {
let mut out = Vec::new();
let mut from = 0;
while let Some(off) = text.get(from..).and_then(|t| t.find('@')) {
@@ -194,15 +203,16 @@ pub fn mention_ranges(text: &str, people: &[&str]) -> Vec<Range<usize>> {
continue;
}
let rest = text.get(at + 1..).unwrap_or("");
let best = people
let who = people
.iter()
.copied()
.filter(|n| !n.is_empty())
.filter(|n| rest.get(..n.len()).is_some_and(|h| h.eq_ignore_ascii_case(n)))
.filter(|n| rest.get(n.len()..).and_then(|t| t.chars().next()).is_none_or(|c| !c.is_alphanumeric()))
.map(|n| n.len())
.max();
if let Some(len) = best {
out.push(at..at + 1 + len);
.max_by_key(|n| n.len());
let len = who.map_or_else(|| rest.find(|c: char| !c.is_alphanumeric()).unwrap_or(rest.len()), str::len);
if len > 0 {
out.push((at..at + 1 + len, who));
from = at + 1 + len;
}
}
+17
View File
@@ -74,6 +74,23 @@ fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, o
NodeKind::Frame | NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. } | NodeKind::Section { .. } => {
n.local_rect().inflate(tol_l, tol_l).contains(lp)
}
NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some()) => {
let rounded = network.rounded();
let net = rounded.as_ref().unwrap_or(network);
if !outline {
for (i, region) in net.regions.iter().enumerate().rev() {
let paints = region.fills.as_deref().unwrap_or(&n.fills);
if paints.iter().any(|p| p.visible) && net.region_contains(i, lp) {
return true;
}
}
}
let path = net.stroke_path();
let has_fill = net.regions.iter().any(|region| region.fills.as_deref().unwrap_or(&n.fills).iter().any(|paint| paint.visible));
let stroke_only = n.has_visible_stroke() && !has_fill;
let reach = (n.stroke.max_weight() / 2.0).max(if stroke_only { tol_l * STROKE_REACH } else { tol_l });
path.bounding_box().inflate(reach, reach).contains(lp) && near_path(&path, lp, reach)
}
_ => shape_path(n).is_some_and(|path| hits_path(n, &path, lp, tol_l, !outline && is_closed(n) && fills_point(n, &path, lp))),
}
}
+9
View File
@@ -915,6 +915,15 @@ impl Document {
pt.source = m.0;
}
}
if let NodeKind::Vector { network } = &mut c.kind {
for p in network.regions.iter_mut().filter_map(|r| r.fills.as_mut()).flatten() {
if let figcraft_color::PaintKind::Pattern(pt) = &mut p.kind
&& let Some(m) = map.get(&NodeId(pt.source))
{
pt.source = m.0;
}
}
}
self.reserve_ids(*new_id);
self.nodes.insert(*new_id, Arc::new(c));
self.dirty.touch(*new_id);
+5
View File
@@ -300,6 +300,11 @@ pub fn paint_images<'a>(paints: impl IntoIterator<Item = &'a Paint>, out: &mut B
/// Image hashes a node paints (fills, strokes, text and text run fills).
pub fn node_images(n: &Node, out: &mut BTreeSet<String>) {
paint_images(n.fills.iter().chain(&n.strokes), out);
if let NodeKind::Vector { network } = &n.kind {
for r in &network.regions {
paint_images(r.fills.iter().flatten(), out);
}
}
if let NodeKind::Text(t) = &n.kind {
paint_images(t.style.fills.iter().flatten(), out);
for r in &t.runs {
+1 -1
View File
@@ -222,7 +222,7 @@ pub struct Annotation {
/// (`figcraft_engine::code_connect`).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CodeLink {
/// `react`, `swiftui` or `compose`.
/// `react`, `leptos`, `vue`, `swiftui`, `compose`, `arkui` or `flutter`.
pub language: String,
/// What the snippet imports from (`@acme/ui`, a Swift module, a Kotlin name).
#[serde(default, skip_serializing_if = "String::is_empty")]
+31 -3
View File
@@ -125,6 +125,34 @@ fn hit_test_respects_z_and_clip() {
assert_eq!(d.hit_test(p, Point::new(5.0, 95.0), 0.0, None), Some(f));
}
#[test]
fn region_paints_participate_in_colors_images_and_pattern_remapping() {
let mut d = Document::new("region resources");
let p = page(&d);
let group = d.create(p, NodeKind::Group, "Group", 0.0, 0.0, 40.0, 20.0).unwrap();
let source = d.create(group, NodeKind::Rectangle, "Source", 0.0, 0.0, 4.0, 4.0).unwrap();
let mut network = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(20.0, 0.0), Point::new(20.0, 20.0), Point::new(0.0, 20.0)], true);
network.regions[0].fills = Some(vec![
figcraft_color::Paint::solid(figcraft_color::Color::from_hex("FF0000").unwrap()),
figcraft_color::Paint::image("region-image", figcraft_color::ImageScaleMode::Fill),
figcraft_color::Paint::pattern(source.0),
]);
let vector = d.create(group, NodeKind::Vector { network }, "Vector", 0.0, 0.0, 20.0, 20.0).unwrap();
assert!(used_images(&d).contains("region-image"));
let dup = d.duplicate_into(group, p, None).unwrap();
let copied_children = d.node(dup).unwrap().children.clone();
let copied_source = copied_children[0];
let copied_vector = d.node(copied_children[1]).unwrap();
let NodeKind::Vector { network } = &copied_vector.kind else { panic!() };
let figcraft_color::PaintKind::Pattern(pattern) = &network.regions[0].fills.as_ref().unwrap()[2].kind else { panic!() };
assert_eq!(pattern.source, copied_source.0);
let red = figcraft_color::Color::from_hex("FF0000").unwrap();
d.map_colors(|c| if c == red { figcraft_color::Color::BLACK } else { c });
let NodeKind::Vector { network } = &d.node(vector).unwrap().kind else { panic!() };
assert_eq!(network.regions[0].fills.as_ref().unwrap()[0].solid_color(), Some(figcraft_color::Color::BLACK));
}
#[test]
fn click_target_rules() {
let mut d = Document::new("x");
@@ -212,7 +240,7 @@ fn vector_network_paths() {
let mut bad = VectorNetwork {
vertices: vec![Vertex::new(0.0, 0.0)],
segments: vec![Segment::line(0, 5)],
regions: vec![Region { loops: vec![vec![0, 9]], winding: WindingRule::EvenOdd }],
regions: vec![Region { loops: vec![vec![0, 9]], winding: WindingRule::EvenOdd, fills: None, ..Default::default() }],
};
bad.sanitize();
assert!(bad.segments.is_empty());
@@ -534,7 +562,7 @@ fn hit_test_even_odd_vector_hole_misses() {
VectorNetwork {
vertices: v1.into_iter().chain(v2).collect(),
segments: s1.into_iter().chain(s2).collect(),
regions: vec![Region { loops: vec![vec![0, 1, 2, 3], vec![4, 5, 6, 7]], winding: rule }],
regions: vec![Region { loops: vec![vec![0, 1, 2, 3], vec![4, 5, 6, 7]], winding: rule, ..Default::default() }],
}
};
let mut d = Document::new("x");
@@ -637,7 +665,7 @@ fn two_segment_closed_loop_rounded_corners() {
let net = VectorNetwork {
vertices: vec![v0, v1],
segments: vec![Segment { a: 0, b: 1, ta: (0.0, 0.0), tb: (0.0, 0.0) }, Segment { a: 1, b: 0, ta: (0.0, 5.0), tb: (0.0, -5.0) }],
regions: vec![Region { loops: vec![vec![0, 1]], winding: WindingRule::NonZero }],
regions: vec![Region { loops: vec![vec![0, 1]], winding: WindingRule::NonZero, ..Default::default() }],
};
let r = net.rounded().unwrap();
assert_eq!(r.regions[0].loops[0].len(), 4, "both arcs are in the loop");
+52
View File
@@ -445,6 +445,21 @@ pub struct TextData {
/// data small.
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<Box<TextPath>>,
/// Where the file this text came from wrapped its lines (an imported `.fig`'s saved layout).
/// Layout follows them only while the box width and everything that moves a line break are
/// as they were saved, so an edit that could change the wrap drops them.
#[serde(skip_serializing_if = "Option::is_none")]
pub saved_breaks: Option<Box<SavedBreaks>>,
}
/// Line breaks a file saved with a text: the chars that start a wrapped line (not those after a
/// hard break), for the box width and the content ([`TextData::layout_key`]) they were made for.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct SavedBreaks {
pub starts: Vec<usize>,
pub width: f64,
pub for_text: u64,
}
/// Text on a path: the baseline follows `d`. The text starts at `start` (aligned there by the
@@ -503,6 +518,43 @@ impl TextData {
Self { characters: characters.into(), ..Default::default() }
}
/// A stable hash of what decides where lines break: everything but paints, links, style links,
/// decorations, vertical settings and the saved breaks themselves (FNV-1a over the JSON, so it
/// stays the same across builds and can be saved).
pub fn layout_key(&self) -> u64 {
let clear = |s: &mut TextStyle| {
(s.fills, s.link, s.style_id, s.decoration, s.decoration_look) = (None, None, None, TextDecoration::None, None);
};
let mut t = TextData {
align_v: TextAlignV::default(),
paragraph_spacing: 0.0,
list_spacing: 0.0,
leading_trim: LeadingTrim::default(),
max_lines: 0,
saved_breaks: None,
..self.clone()
};
clear(&mut t.style);
t.runs.iter_mut().for_each(|r| clear(&mut r.style));
t.normalize();
serde_json::to_vec(&t).unwrap_or_default().iter().fold(0xcbf2_9ce4_8422_2325, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x0100_0000_01b3))
}
/// Keep `starts` (chars that start a wrapped line) as this text's saved line breaks in a box
/// `width` wide.
pub fn save_breaks(&mut self, starts: Vec<usize>, width: f64) {
self.saved_breaks = Some(Box::new(SavedBreaks { starts, width, for_text: self.layout_key() }));
}
/// The saved line starts (empty: no wrapped line) while they still hold: the same box width and
/// layout content, and starts increasing inside the text.
pub fn saved_breaks_for(&self, width: f64) -> Option<&[usize]> {
let b = self.saved_breaks.as_deref()?;
let inside = b.starts.first().is_none_or(|s| *s > 0) && b.starts.last().is_none_or(|s| *s < self.char_count());
let same_box = (b.width - width).abs() <= 1e-6 * width.abs().max(1.0);
(inside && same_box && b.starts.is_sorted_by(|a, b| a < b) && b.for_text == self.layout_key()).then_some(b.starts.as_slice())
}
/// How far text on a path paints past its path's box (glyphs stand on the path, lines after
/// the first run beside it): about one and a half of the largest size per line; 0 off a path.
pub fn path_reach(&self) -> f64 {
+48 -3
View File
@@ -1,8 +1,9 @@
//! Vector networks: vertices joined by curved segments, any number of segments per vertex, plus
//! filled regions made of closed loops.
use figcraft_color::Paint;
use figcraft_geom::kurbo::{CubicBez, ParamCurve, ParamCurveArclen, ParamCurveDeriv};
use figcraft_geom::{BezPath, Point, Vec2};
use figcraft_geom::{Affine, BezPath, Point, Vec2};
use serde::{Deserialize, Serialize};
use crate::{StrokeCap, StrokeJoin};
@@ -88,12 +89,44 @@ pub enum WindingRule {
EvenOdd,
}
/// The paint coordinate frame retained when a region moves or its vector is refit.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct RegionPaintSpace {
/// Width of the box that defines paint coordinates.
pub width: f64,
/// Height of the box that defines paint coordinates.
pub height: f64,
/// Maps paint-local coordinates into this network's local coordinates.
pub transform: Affine,
}
/// A filled region: loops of segment indices.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
pub struct Region {
pub loops: Vec<Vec<usize>>,
#[serde(default)]
pub winding: WindingRule,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fills: Option<Vec<Paint>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub paint_space: Option<RegionPaintSpace>,
}
impl Region {
/// Paint box and transform, falling back to the current node box for old or invalid metadata.
pub fn paint_frame(&self, width: f64, height: f64) -> (f64, f64, Affine) {
let Some(frame) = self.paint_space else { return (width, height, Affine::IDENTITY) };
let coefficients = frame.transform.as_coeffs();
let valid = frame.width.is_finite()
&& frame.height.is_finite()
&& frame.width > 0.0
&& frame.height > 0.0
&& coefficients.iter().all(|value| value.is_finite())
&& frame.transform.determinant().is_finite()
&& frame.transform.determinant() != 0.0
&& frame.transform.inverse().as_coeffs().iter().all(|value| value.is_finite());
if valid { (frame.width, frame.height, frame.transform) } else { (width, height, Affine::IDENTITY) }
}
}
/// A rounded point as drawn: point `v` moves to `a` (on segment `s1`), segment `s2` starts again
@@ -153,7 +186,7 @@ impl VectorNetwork {
let mut regions = Vec::new();
if closed && n > 2 {
segments.push(Segment::line(n - 1, 0));
regions.push(Region { loops: vec![(0..segments.len()).collect()], winding: WindingRule::NonZero });
regions.push(Region { loops: vec![(0..segments.len()).collect()], winding: WindingRule::NonZero, fills: None, paint_space: None });
}
Self { vertices, segments, regions }
}
@@ -242,7 +275,7 @@ impl VectorNetwork {
}
close_open(&mut net, &mut loop_segs, &mut all_loops, first_v, cur_v);
if !all_loops.is_empty() {
net.regions.push(Region { loops: all_loops, winding: WindingRule::NonZero });
net.regions.push(Region { loops: all_loops, winding: WindingRule::NonZero, fills: None, paint_space: None });
}
net
}
@@ -637,6 +670,12 @@ impl VectorNetwork {
s.ta = (s.ta.0 * sx, s.ta.1 * sy);
s.tb = (s.tb.0 * sx, s.tb.1 * sy);
}
let scale = Affine::scale_non_uniform(sx, sy);
for region in &mut self.regions {
if let Some(frame) = &mut region.paint_space {
frame.transform = scale * frame.transform;
}
}
}
pub fn translate(&mut self, dx: f64, dy: f64) {
@@ -644,6 +683,12 @@ impl VectorNetwork {
v.x += dx;
v.y += dy;
}
let translate = Affine::translate((dx, dy));
for region in &mut self.regions {
if let Some(frame) = &mut region.paint_space {
frame.transform = translate * frame.transform;
}
}
}
/// Bounds of the curves.
+53 -8
View File
@@ -30,7 +30,12 @@ impl Face {
/// The fill region for this face.
pub fn region(&self) -> Region {
Region { loops: self.loops.clone(), winding: if self.loops.len() > 1 { WindingRule::EvenOdd } else { WindingRule::NonZero } }
Region {
loops: self.loops.clone(),
winding: if self.loops.len() > 1 { WindingRule::EvenOdd } else { WindingRule::NonZero },
fills: None,
paint_space: None,
}
}
/// Some point strictly inside the face (not in a hole).
@@ -115,7 +120,13 @@ impl VectorNetwork {
/// Like [`Self::paint`], but with `want` the face ends up filled (`Some(true)`) or unfilled
/// (`Some(false)`) whatever it was: a paint bucket drag keeps doing what its first click did.
pub fn paint_to(&mut self, p: Point, want: Option<bool>) -> Option<bool> {
self.paint_to_with(p, want, None)
}
/// Like [`Self::paint_to`], with the layer fill stack used for inherited regions.
pub fn paint_to_with(&mut self, p: Point, want: Option<bool>, inherited: Option<&[figcraft_color::Paint]>) -> Option<bool> {
let face = self.face_at(p)?;
let legacy = self.regions.iter().all(|region| region.fills.is_none() && region.paint_space.is_none());
let key = |loops: &[Vec<usize>]| -> Vec<Vec<usize>> {
let mut k: Vec<Vec<usize>> = loops
.iter()
@@ -131,18 +142,52 @@ impl VectorNetwork {
let faces = self.faces();
let face_keys: Vec<Vec<Vec<usize>>> = faces.iter().map(|f| key(&f.loops)).collect();
if !self.regions.iter().all(|r| face_keys.contains(&key(&r.loops))) {
let filled: Vec<Region> = faces.iter().filter(|f| f.interior_point().is_some_and(|q| self.fill_contains(q))).map(Face::region).collect();
self.regions = filled;
if legacy {
// Legacy vectors use one combined fill rule across all regions.
self.regions =
faces.iter().filter(|face| face.interior_point().is_some_and(|point| self.fill_contains(point))).map(Face::region).collect();
} else {
// Split each styled source region independently to retain its stack and frame.
let source_regions = self.regions.clone();
let mut filled = Vec::new();
for (source_index, source) in source_regions.into_iter().enumerate() {
let source_path = self.region_path(source_index);
for face in &faces {
if face.interior_point().is_some_and(|q| {
source_path.as_ref().is_some_and(|path| match source.winding {
WindingRule::NonZero => path.winding(q) != 0,
WindingRule::EvenOdd => path.winding(q) % 2 != 0,
})
}) {
let mut region = face.region();
region.fills = source.fills.clone();
region.paint_space = source.paint_space;
filled.push(region);
}
}
}
self.regions = filled;
}
}
let k = key(&face.loops);
match (self.regions.iter().position(|r| key(&r.loops) == k), want) {
(Some(_), None | Some(false)) => {
let filled = if legacy {
self.fill_contains(p) && inherited.is_none_or(|fills| fills.iter().any(|paint| paint.visible))
} else {
self.regions.iter().filter(|r| key(&r.loops) == k).any(|region| match &region.fills {
Some(fills) => fills.iter().any(|paint| paint.visible),
None => inherited.is_none_or(|fills| fills.iter().any(|paint| paint.visible)),
})
};
let want = want.unwrap_or(!filled);
match (filled, want) {
(true, false) => {
self.regions.retain(|r| key(&r.loops) != k);
Some(false)
}
(Some(_), Some(true)) => Some(true),
(None, Some(false)) => Some(false),
(None, _) => {
(true, true) => Some(true),
(false, false) => Some(false),
(false, true) => {
self.regions.retain(|r| key(&r.loops) != k);
self.regions.push(face.region());
Some(true)
}
+26 -1
View File
@@ -30,7 +30,32 @@ fn nested() -> VectorNetwork {
}
fn region(loops: Vec<Vec<usize>>, winding: WindingRule) -> Region {
Region { loops, winding }
Region { loops, winding, fills: None, ..Default::default() }
}
#[test]
fn region_fills_default_to_inheritance_and_keep_explicit_empty() {
let legacy: Region = serde_json::from_value(serde_json::json!({"loops": []})).unwrap();
assert_eq!(legacy.fills, None);
let empty = Region { fills: Some(Vec::new()), ..legacy };
let value = serde_json::to_value(empty).unwrap();
assert!(value["fills"].as_array().is_some_and(Vec::is_empty));
let inherited = serde_json::to_value(region(Vec::new(), WindingRule::NonZero)).unwrap();
assert!(inherited.get("fills").is_none());
}
#[test]
fn region_paint_space_roundtrips_and_rejects_nonfinite_inverse() {
use crate::RegionPaintSpace;
use figcraft_geom::Affine;
let mut r = region(Vec::new(), WindingRule::EvenOdd);
assert_eq!(r.paint_frame(100.0, 40.0), (100.0, 40.0, Affine::IDENTITY));
r.paint_space = Some(RegionPaintSpace { width: 30.0, height: 20.0, transform: Affine::translate((4.0, 8.0)) });
let decoded: Region = serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap();
assert_eq!(decoded, r);
assert_eq!(decoded.paint_frame(100.0, 40.0), (30.0, 20.0, Affine::translate((4.0, 8.0))));
r.paint_space.as_mut().unwrap().transform = Affine::scale_non_uniform(1e-320, 1.0);
assert_eq!(r.paint_frame(100.0, 40.0), (100.0, 40.0, Affine::IDENTITY));
}
fn p(x: f64, y: f64) -> Point {
+6
View File
@@ -33,6 +33,12 @@ deunicode = { workspace = true }
same-file = { workspace = true }
web-time = "1.1.0"
# Downloading a missing font (desktop only; the web keeps its built-in fonts). The bundled index
# (~620 KB) lets the Missing fonts window tell which fonts can be downloaded without a request.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
craft-font-store = { workspace = true, features = ["bundled-index"] }
craft-font-catalog = { workspace = true }
[dev-dependencies]
zip = { workspace = true }
wat = { workspace = true }
+44 -1
View File
@@ -6,7 +6,7 @@ use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
use figcraft_doc::{CellAlign, MainAlign, NodeId, Sizing};
use figcraft_doc::{CellAlign, CrossAlign, LayoutMode, MainAlign, NodeId, Sizing};
fn id(v: Value) -> NodeId {
id_of(&v["id"]).unwrap()
@@ -233,3 +233,46 @@ fn a_size_given_with_a_new_path_fixes_that_axis() {
assert_eq!(sizing(&s, v).0, Sizing::Fixed);
assert!((size(&s, v).0 - 30.0).abs() < 1e-6, "{:?}", size(&s, v));
}
/// What one call's auto layout keys give, whichever way they are spelled: the mode (a string, or
/// an object that replaces the whole auto layout) applies first and the other keys tune it.
#[test]
fn auto_layout_keys_in_one_call_tune_the_mode_given_beside_them() {
for (key, mode) in ["layout", "layoutMode"].into_iter().flat_map(|k| [(k, json!("vertical")), (k, json!({"mode": "vertical"}))]) {
let mut props = json!({"gap": 8, "padding": 4, "crossAlign": "center"});
props[key] = mode;
let mut s = Session::new();
let created = id(s.execute("node.create", &json!({"type": "frame", "props": props})).unwrap());
let set_on = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
set(&mut s, set_on, props.clone());
for f in [created, set_on] {
let l = s.doc().unwrap().node(f).unwrap().layout.clone().unwrap();
assert_eq!((l.mode, l.gap, l.padding, l.cross_align), (LayoutMode::Vertical, 8.0, [4.0; 4], CrossAlign::Center), "{props}");
}
}
}
/// Auto layout keys beside a `layout` that turns auto layout off would turn it back on: the call
/// is refused and the layer stays as it was.
#[test]
fn auto_layout_keys_beside_no_auto_layout_are_refused() {
let mut s = Session::new();
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
for props in
[json!({"layout": null, "padding": 4}), json!({"layout": "none", "gap": 8, "padding": 4}), json!({"layoutMode": "NONE", "wrap": true})]
{
let e = s.execute("node.set", &json!({"id": f, "props": props})).unwrap_err().to_string();
assert!(e.contains("turns auto layout off"), "{props}: {e}");
assert!(s.doc().unwrap().node(f).unwrap().layout.is_none(), "{props}");
let e = s.execute("node.create", &json!({"type": "frame", "props": props})).unwrap_err().to_string();
assert!(e.contains("turns auto layout off"), "{props}: {e}");
}
let e = s.execute("node.set", &json!({"id": f, "props": {"layout": null, "gap": 8, "padding": 4}})).unwrap_err().to_string();
assert!(e.contains("`gap`, `padding` are auto layout properties, and `layout` in the same call"), "{e}");
let e = s.execute("node.set", &json!({"id": f, "props": {"layoutMode": "NONE", "wrap": true}})).unwrap_err().to_string();
assert!(e.contains("`wrap` is an auto layout property, and `layoutMode` in the same call"), "{e}");
// Without other auto layout keys, `layout: null` still removes it.
set(&mut s, f, json!({"layout": "vertical", "gap": 8}));
set(&mut s, f, json!({"layout": null, "name": "Plain"}));
assert!(s.doc().unwrap().node(f).unwrap().layout.is_none());
}
+4
View File
@@ -39,6 +39,7 @@ use figcraft_doc::{
use figcraft_geom::Affine;
use serde_json::Value;
mod cycles;
mod diff;
mod guard;
mod instances;
@@ -47,6 +48,9 @@ mod rebase;
mod sync;
mod variants;
pub use cycles::{
break_component_cycles, check_component_cycles, component_cycle, contains_itself_error, cycle_text, new_cycle, would_contain_itself,
};
pub use diff::{diff_groups, property_owners, set_boolean_default, variable_groups};
pub use guard::{check_override_edit, check_structure_edit, refused_override, unlink_layers};
pub(crate) use instances::copy_group;
@@ -0,0 +1,214 @@
//! A component can't contain its own instances, directly or through other components (#1084):
//! every instance would show itself without end. Each variant is a component of its own here: one
//! holding an instance of another variant of its set is fine unless that one leads back to it.
//! Commands that would close such a loop are refused ([`check_component_cycles`]);
//! files that hold one are broken on opening ([`break_component_cycles`]); and a rebuild never
//! expands an instance inside a copy of itself ([`expansion_layers`]), for loops that arrive some
//! other way (two edits merged in a live session).
use super::patch::rebuild_instances;
use super::{BTreeMap, BTreeSet, Document, NodeId, NodeKind, PROP_BINDING, PropDef, inside_instance, root_instance};
/// The components each main component holds instances of.
type Graph = BTreeMap<NodeId, BTreeSet<NodeId>>;
fn is_component(doc: &Document, id: NodeId) -> bool {
doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))
}
/// The main components `id` is (in).
fn hosts(doc: &Document, id: NodeId) -> BTreeSet<NodeId> {
std::iter::once(id).chain(doc.ancestors(id)).filter(|a| is_component(doc, *a)).collect()
}
/// The components instance `id` shows: its main, and those its instance swap properties pick
/// (they swap a layer inside it when it's rebuilt).
fn shown(doc: &Document, id: NodeId) -> Vec<NodeId> {
let Some(NodeKind::Instance { component, props }) = doc.node(id).map(|n| &n.kind) else { return Vec::new() };
let Some(NodeKind::Component { props: defs, .. }) = doc.node(*component).map(|n| &n.kind) else { return Vec::new() };
let swaps =
defs.iter().filter(|(_, d)| matches!(d, PropDef::InstanceSwap { .. })).filter_map(|(k, _)| props.get(k).and_then(crate::params::id_of));
std::iter::once(*component).chain(swaps).filter(|c| is_component(doc, *c)).collect()
}
fn graph(doc: &Document) -> Graph {
let mut g = Graph::new();
for n in doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { .. })) {
let shown = shown(doc, n.id);
for h in hosts(doc, n.id) {
g.entry(h).or_default().extend(shown.iter().copied());
}
}
g
}
/// A loop in `g`, its first component repeated last (`A, B, A`).
fn find_cycle(g: &Graph) -> Option<Vec<NodeId>> {
let next = |u: &NodeId| -> Vec<NodeId> { g.get(u).map(|s| s.iter().rev().copied().collect()).unwrap_or_default() };
let mut done: BTreeSet<NodeId> = BTreeSet::new();
for start in g.keys() {
if done.contains(start) {
continue;
}
// Depth first: the path so far, and the components each step on it has left to visit.
let mut path = vec![*start];
let mut todo = vec![next(start)];
while let Some(left) = todo.last_mut() {
match left.pop() {
Some(u) if path.contains(&u) => {
let at = path.iter().position(|p| *p == u)?;
let mut cycle = path.split_off(at);
cycle.push(u);
return Some(cycle);
}
Some(u) if done.contains(&u) => {}
Some(u) => {
todo.push(next(&u));
path.push(u);
}
None => {
todo.pop();
done.extend(path.pop());
}
}
}
}
None
}
/// A loop of components whose mains contain each other's instances, if the document has one.
pub fn component_cycle(doc: &Document) -> Option<Vec<NodeId>> {
find_cycle(&graph(doc))
}
/// A loop as its names: `Card → Button → Card`.
fn cycle_names(doc: &Document, cycle: &[NodeId]) -> String {
cycle.iter().map(|u| doc.node(*u).map_or_else(|| u.to_string(), |n| format!("\"{}\"", n.name))).collect::<Vec<_>>().join(" → ")
}
/// Whether an instance of `component` under `parent` would contain itself: `parent` is (in) a
/// main that instance shows, directly or through other components.
pub fn would_contain_itself(doc: &Document, component: NodeId, parent: NodeId) -> bool {
let hosts = hosts(doc, parent);
if hosts.is_empty() {
return false;
}
let g = graph(doc);
let mut seen = BTreeSet::new();
let mut todo = vec![component];
while let Some(u) = todo.pop() {
if hosts.contains(&u) {
return true;
}
if seen.insert(u) {
todo.extend(g.get(&u).into_iter().flatten().copied());
}
}
false
}
/// Why an instance of `component` can't go where it would contain itself.
pub fn contains_itself_error(doc: &Document, component: NodeId) -> String {
let name = doc.node(component).map(|n| n.name.clone()).unwrap_or_default();
format!("an instance of \"{name}\" can't go inside \"{name}\" or a component it contains: a component can't contain itself")
}
/// The loop `after` has and `before` didn't, if layers `touched` (edited, added or removed) closed
/// one: only layers in main components can.
pub fn new_cycle<'a>(before: &Document, after: &Document, mut touched: impl Iterator<Item = &'a NodeId>) -> Option<String> {
if !touched.any(|d| !hosts(after, *d).is_empty()) {
return None;
}
cycle_text(after).filter(|_| component_cycle(before).is_none())
}
/// The document's loop of components containing each other's instances ([`component_cycle`]),
/// as its names.
pub fn cycle_text(doc: &Document) -> Option<String> {
component_cycle(doc).map(|c| cycle_names(doc, &c))
}
/// Refuse an edit that makes components contain each other's instances (it is rolled back). A
/// document that already held a loop stays editable.
pub fn check_component_cycles(before: &Document, after: &Document) -> Result<(), String> {
match new_cycle(before, after, after.dirty().iter()) {
Some(names) => Err(format!("a component can't contain an instance of itself, directly or through other components: {names}")),
None => Ok(()),
}
}
/// The instance to detach to cut `from → to` out of a loop: one in a main of `from` showing `to`,
/// preferably one of the main's own (not a layer of another instance, else its outer instance).
fn cut_point(doc: &Document, from: NodeId, to: NodeId) -> Option<NodeId> {
let found: Vec<NodeId> = doc
.nodes()
.filter(|n| matches!(n.kind, NodeKind::Instance { .. }))
.map(|n| n.id)
.filter(|i| shown(doc, *i).contains(&to) && hosts(doc, *i).contains(&from))
.collect();
found.iter().copied().find(|i| !inside_instance(doc, *i)).or_else(|| found.first().and_then(|i| root_instance(doc, *i)))
}
/// Detach instance `top` and every instance inside it: its layers stay as they are, linked to no
/// main.
fn detach_all(doc: &mut Document, top: NodeId) {
for d in doc.subtree(top) {
let modes = match doc.node(d).map(|n| &n.kind) {
Some(NodeKind::Instance { component, .. }) => doc.node(*component).map(|m| m.var_modes.clone()),
_ => None,
};
let Some(n) = doc.node_mut(d) else { continue };
if let Some(modes) = modes {
n.kind = NodeKind::Frame;
n.bound.retain(|k, _| !k.starts_with(PROP_BINDING));
for (c, m) in modes {
n.var_modes.entry(c).or_insert(m);
}
}
n.source = None;
n.overrides.clear();
n.prop_refs.clear();
}
}
/// Break every loop of components containing each other's instances (a file from elsewhere, or
/// written before they were refused): an instance closing each loop is detached, and the instances
/// of its main rebuild without it. Returns a note for each.
pub fn break_component_cycles(doc: &mut Document) -> Vec<String> {
let mut notes = Vec::new();
// Each pass detaches an instance: never more passes than there are layers.
for _ in 0..doc.nodes().count() {
let Some(cycle) = component_cycle(doc) else { break };
let (Some(from), Some(to)) = (cycle.iter().rev().nth(1).copied(), cycle.last().copied()) else { break };
let Some(cut) = cut_point(doc, from, to) else { break };
let name = doc.node(cut).map(|n| n.name.clone()).unwrap_or_default();
detach_all(doc, cut);
rebuild_instances(doc, &BTreeSet::from([from]));
notes.push(format!("\"{name}\" was detached: components can't contain their own instances ({})", cycle_names(doc, &cycle)));
}
notes
}
/// The layers of `component` an instance `inst` of it copies, in pre-order: all of them but those
/// inside a nested instance of a component being expanded already (its own, or a main `inst` is
/// in), which would show itself again. `None` when `inst` itself is in its own main.
pub(super) fn expansion_layers(doc: &Document, inst: NodeId, component: NodeId) -> Option<Vec<NodeId>> {
let mut stops = hosts(doc, inst);
if !stops.insert(component) {
return None;
}
let repeats = |kind: &NodeKind| matches!(kind, NodeKind::Instance { component: c, .. } if stops.contains(c));
let mut out = Vec::new();
let mut stack: Vec<NodeId> = doc.node(component).map(|n| n.children.iter().rev().copied().collect()).unwrap_or_default();
while let Some(id) = stack.pop() {
let Some(n) = doc.node(id) else { continue };
out.push(id);
if out.len() > 5_000_000 {
break;
}
if !repeats(&n.kind) {
stack.extend(n.children.iter().rev());
}
}
Some(out)
}
@@ -1,3 +1,4 @@
use super::cycles::{contains_itself_error, would_contain_itself};
use super::diff::{binding_group, bindings_of};
use super::sync::{insert_linked, sync_instance_depth};
use super::{
@@ -16,8 +17,8 @@ pub fn create_instance_at(doc: &mut Document, component: NodeId, parent: NodeId,
if !matches!(comp.kind, NodeKind::Component { .. }) {
return Err(DocError::Invalid(format!("{component} is not a component")));
}
if doc.is_ancestor(component, parent) {
return Err(DocError::Invalid("an instance can't be placed inside its own main component".into()));
if would_contain_itself(doc, component, parent) {
return Err(DocError::Invalid(contains_itself_error(doc, component)));
}
let id = doc.alloc_id();
let mut root = comp.clone();
+1 -1
View File
@@ -500,7 +500,7 @@ fn sized_own(n: &Node) -> bool {
/// Rebuild every instance of `comps` (and of the components that hold those instances, and so on
/// outwards), each outermost instance once, inner components first.
fn rebuild_instances(doc: &mut Document, comps: &BTreeSet<NodeId>) {
pub(super) fn rebuild_instances(doc: &mut Document, comps: &BTreeSet<NodeId>) {
if comps.is_empty() {
return;
}
+101 -60
View File
@@ -1,3 +1,4 @@
use super::cycles::expansion_layers;
use super::diff::diff_groups;
use super::instances::{Carry, apply_prop, carry_overrides, carry_prop_bindings, copy_group, field_group, follow_variant_resizing, path_map};
use super::{
@@ -23,26 +24,76 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize
// Old layers (kept alive for their overrides), by (parent, source), by source alone (when only
// one layer mirrors it) and, only when those don't match, by name path.
let (mut old, by_source, by_src) = index_old_layers(doc, inst, spare);
// Never a copy of itself inside it (components containing each other).
let Some(src) = expansion_layers(doc, inst, component) else { return };
let Matched { mut map, used, edited, fresh } = match_layers(doc, inst, component, &src, &mut old, &by_source, &by_src);
for c in root.children.clone() {
let _ = doc.remove(c);
}
for s in fresh {
let nid = doc.alloc_id();
map.insert(s, nid);
}
let Copies { nodes: new_nodes, nested, mut remeasure } = copy_layers(doc, inst, &src, &map, &old, &edited, eff_props);
for n in new_nodes {
let (id, parent) = (n.id, n.parent);
if insert_linked(doc, n).is_err()
&& let Some(p) = parent.and_then(|p| doc.node_mut(p))
{
p.children.retain(|c| *c != id);
}
}
let fresh = fresh_root(doc, &mut root, &comp, inst, component, &map);
if let Some(r) = doc.node_mut(inst) {
*r = fresh;
}
// A resized instance: children follow their constraints (auto layout re-flows later).
let (iw, ih) = doc.node(inst).map(|n| (n.width, n.height)).unwrap_or((comp.width, comp.height));
if (iw - comp.width).abs() > 1e-9 || (ih - comp.height).abs() > 1e-9 {
figcraft_layout::apply_constraints(doc, inst, comp.width, comp.height);
remeasure = map.values().copied().collect();
}
follow_geometry_overrides(doc, &src, &map, &old, &mut remeasure);
remeasure_text(doc, remeasure);
rebuild_nested(doc, nested, &old, &used, depth);
}
/// Which instance layer each main layer a rebuild copies becomes.
struct Matched {
/// Main layer → its copy's id (the matching old layer's, so a layer the main moved to another
/// parent keeps its id and overrides); the main itself → the instance.
map: BTreeMap<NodeId, NodeId>,
/// The old layers reused.
used: BTreeSet<NodeId>,
/// Slots holding the instance's own content (their main layers' default content isn't copied).
edited: BTreeSet<NodeId>,
/// Main layers no old layer matches: their copies get new ids.
fresh: Vec<NodeId>,
}
/// Match the main layers `src` of instance `inst`'s main `component` to its old layers: by
/// (parent, source), by source alone (when only one layer mirrors it) and, only when those don't
/// match, by name path. Old layers matched across variants drop what the two variants differ in.
fn match_layers(
doc: &Document,
inst: NodeId,
component: NodeId,
src: &[NodeId],
old: &mut BTreeMap<NodeId, std::sync::Arc<Node>>,
by_source: &BTreeMap<(NodeId, NodeId), NodeId>,
by_src: &BTreeMap<NodeId, Option<NodeId>>,
) -> Matched {
let mut by_path: Option<BTreeMap<String, NodeId>> = None;
let mut main_paths: Option<BTreeMap<NodeId, String>> = None;
// Ids for the copies: reuse the matching old layer's id (a layer the main moved to another
// parent keeps its id and overrides).
let src: Vec<NodeId> = doc.subtree(component).into_iter().filter(|i| *i != component).collect();
let mut map: BTreeMap<NodeId, NodeId> = BTreeMap::new();
map.insert(component, inst);
let mut used: BTreeSet<NodeId> = BTreeSet::new();
// Slots holding the instance's own content (new ids), and main layers not copied: the
// default content of those slots.
let mut edited: BTreeSet<NodeId> = BTreeSet::new();
let mut m = Matched { map: BTreeMap::from([(component, inst)]), used: BTreeSet::new(), edited: BTreeSet::new(), fresh: Vec::new() };
let mut skipped: BTreeSet<NodeId> = BTreeSet::new();
let mut fresh_ids = Vec::new();
for s in &src {
for s in src {
let Some(sn) = doc.node(*s) else { continue };
if sn.parent.is_some_and(|p| skipped.contains(&p) || map.get(&p).is_some_and(|pn| edited.contains(pn))) {
if sn.parent.is_some_and(|p| skipped.contains(&p) || m.map.get(&p).is_some_and(|pn| m.edited.contains(pn))) {
skipped.insert(*s);
continue;
}
let parent_new = sn.parent.and_then(|p| map.get(&p).copied());
let parent_new = sn.parent.and_then(|p| m.map.get(&p).copied());
let mut found = parent_new.and_then(|pn| by_source.get(&(pn, *s)).copied()).or_else(|| by_src.get(s).copied().flatten());
if found.is_none() {
let paths = by_path.get_or_insert_with(|| path_map(doc, inst, true).into_iter().map(|(k, v)| (v, k)).collect());
@@ -50,37 +101,47 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize
found = mains.get(s).and_then(|p| paths.get(p)).copied();
}
let nid = match found {
Some(o) if used.insert(o) => o,
Some(o) if m.used.insert(o) => o,
_ => {
fresh_ids.push(*s);
m.fresh.push(*s);
continue;
}
};
if let Some(m) = old.get(&nid).and_then(|o| variant_kept(doc, o, *s)) {
old.insert(nid, std::sync::Arc::new(m));
if let Some(kept) = old.get(&nid).and_then(|o| variant_kept(doc, o, *s)) {
old.insert(nid, std::sync::Arc::new(kept));
}
if old.get(&nid).is_some_and(|o| holds_content(o)) && is_slot(doc, *s) {
edited.insert(nid);
m.edited.insert(nid);
}
map.insert(*s, nid);
m.map.insert(*s, nid);
}
for c in root.children.clone() {
let _ = doc.remove(c);
}
for s in fresh_ids {
let nid = doc.alloc_id();
map.insert(s, nid);
}
// Build the copies (pre-order: parents first).
let mut enclosing: BTreeMap<NodeId, Vec<NodeId>> = BTreeMap::new();
enclosing.insert(inst, vec![inst]);
let mut inst_props: BTreeMap<NodeId, BTreeMap<String, Value>> = BTreeMap::new();
inst_props.insert(inst, eff_props);
let mut nested = Vec::new();
let mut new_nodes = Vec::new();
// Text layers showing other text or style than their main layer (to re-measure).
let mut remeasure = Vec::new();
for s in &src {
m
}
/// A rebuild's copies of the main's layers, pre-order (parents first).
struct Copies {
nodes: Vec<Node>,
/// Nested instances showing another main than their main layer (swapped): rebuilt from it.
nested: Vec<NodeId>,
/// Text layers showing other text or style than their main layer (to re-measure).
remeasure: Vec<NodeId>,
}
/// Copy main layers `src` into instance `inst` as `map` names them, with the overrides of the
/// `old` layers they replace and the component property values (`props`: the instance's).
fn copy_layers(
doc: &Document,
inst: NodeId,
src: &[NodeId],
map: &BTreeMap<NodeId, NodeId>,
old: &BTreeMap<NodeId, std::sync::Arc<Node>>,
edited: &BTreeSet<NodeId>,
props: BTreeMap<String, Value>,
) -> Copies {
let mut enclosing: BTreeMap<NodeId, Vec<NodeId>> = BTreeMap::from([(inst, vec![inst])]);
let mut inst_props: BTreeMap<NodeId, BTreeMap<String, Value>> = BTreeMap::from([(inst, props)]);
let mut out = Copies { nodes: Vec::new(), nested: Vec::new(), remeasure: Vec::new() };
for s in src {
let Some(orig) = doc.node(*s) else { continue };
let Some(&nid) = map.get(s) else { continue };
let Some(parent) = orig.parent.and_then(|p| map.get(&p).copied()) else { continue };
@@ -113,7 +174,7 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize
e.push(nid);
enclosing.insert(nid, e);
if !matches!(orig.kind, NodeKind::Instance { component: oc, .. } if oc == *c) {
nested.push(nid);
out.nested.push(nid);
}
} else {
enclosing.insert(nid, enc);
@@ -121,31 +182,11 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize
if let (NodeKind::Text(a), NodeKind::Text(b)) = (&orig.kind, &n.kind)
&& a != b
{
remeasure.push(nid);
out.remeasure.push(nid);
}
new_nodes.push(n);
out.nodes.push(n);
}
for n in new_nodes {
let (id, parent) = (n.id, n.parent);
if insert_linked(doc, n).is_err()
&& let Some(p) = parent.and_then(|p| doc.node_mut(p))
{
p.children.retain(|c| *c != id);
}
}
let fresh = fresh_root(doc, &mut root, &comp, inst, component, &map);
if let Some(r) = doc.node_mut(inst) {
*r = fresh;
}
// A resized instance: children follow their constraints (auto layout re-flows later).
let (iw, ih) = doc.node(inst).map(|n| (n.width, n.height)).unwrap_or((comp.width, comp.height));
if (iw - comp.width).abs() > 1e-9 || (ih - comp.height).abs() > 1e-9 {
figcraft_layout::apply_constraints(doc, inst, comp.width, comp.height);
remeasure = map.values().copied().collect();
}
follow_geometry_overrides(doc, &src, &map, &old, &mut remeasure);
remeasure_text(doc, remeasure);
rebuild_nested(doc, nested, &old, &used, depth);
out
}
/// Geometry overrides of children win over constraints.
+2 -2
View File
@@ -52,7 +52,7 @@ pub fn specs() -> Vec<CommandSpec> {
vec![spec!(
"component.playground",
"Component playground",
"{id? (an instance, a component or a component set; default the selection), properties? {name: value} (variant, boolean, text and instance-swap properties, as instance.setProperties takes them), language? css|html|react|leptos|vue|swiftui|compose|arkui (default react), path? (write the preview there as a PNG), scale? (the PNG's, default 2)} → {component, properties: [{name, type, options?, value}], language, code, width, height, path?} the component with those property values, tried on a copy: its code and its look. Nothing in the document changes",
"{id? (an instance, a component or a component set; default the selection), properties? {name: value} (variant, boolean, text and instance-swap properties, as instance.setProperties takes them), language? css|html|react|leptos|vue|swiftui|compose|arkui|flutter (default react), path? (write the preview there as a PNG), scale? (the PNG's, default 2)} → {component, properties: [{name, type, options?, value}], language, code, width, height, path?} the component with those property values, tried on a copy: its code and its look. Nothing in the document changes",
false,
|s, p| {
let cmd = "component.playground";
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
Some(_) => return Err(bad(cmd, "properties: {name: value}")),
};
let lang = match string(p, "language") {
Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|vue|swiftui|compose|arkui"))?,
Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|vue|swiftui|compose|arkui|flutter"))?,
None => CodeLang::React,
};
let (copy, inst) = build(s.doc()?, target, &values)?;
+25 -3
View File
@@ -5,7 +5,7 @@
//! takes a `field`, a key of that result or a slash path into it (`ids/0`, `map/3`, `bounds/x`), the
//! way `variable.bind` names `fills/0/color`. The CLI's script runner resolves its steps the same way.
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use serde_json::{Map, Value};
@@ -39,12 +39,25 @@ pub fn step_name(step: usize, json: &Value) -> Result<Option<String>> {
}
}
/// Refuse a step key other than `command`, `params` and `as` (`step` is its index, for the
/// error): `args` in place of `params` would run the command without them.
pub fn refuse_unknown_step_keys(step: usize, json: &Value) -> Result<()> {
let Some(o) = json.as_object() else { return Ok(()) };
let unknown: Vec<String> = o.keys().filter(|k| !matches!(k.as_str(), "command" | "params" | "as")).map(|k| format!("`{k}`")).collect();
if unknown.is_empty() {
return Ok(());
}
let s = if unknown.len() == 1 { "" } else { "s" };
Err(bad("command.batch", format!("step {step}: unknown key{s} {} (a step takes command, params and as)", unknown.join(", "))))
}
/// The steps of one batch run so far: their results, and which step each `as` name belongs to.
/// A nested `command.batch` step gets its own scope, so its references are to its own steps.
#[derive(Clone, Debug, Default)]
pub struct BatchScope {
names: HashMap<String, usize>,
results: Vec<Value>,
failed: HashSet<usize>,
}
impl BatchScope {
@@ -57,10 +70,10 @@ impl BatchScope {
return Err(bad("command.batch", format!("step {i}: `as` \"{name}\" is already step {first}'s name")));
}
}
Ok(Self { names, results: Vec::with_capacity(steps.len()) })
Ok(Self { names, results: Vec::with_capacity(steps.len()), failed: HashSet::new() })
}
/// The results of the steps run so far, in order.
/// The results of the steps run so far, in order (failed slots hold null).
pub fn results(&self) -> &[Value] {
&self.results
}
@@ -78,6 +91,12 @@ impl BatchScope {
self.results.push(result);
}
pub(crate) fn push_failed(&mut self) {
// Keep positions even after a failure; a successful null result is not a failed step.
self.failed.insert(self.next_step());
self.results.push(Value::Null);
}
/// `params` of the next step with every reference in it replaced.
pub(crate) fn resolve(&self, params: &Value) -> Result<Value> {
self.resolve_in(params, 0)
@@ -125,6 +144,9 @@ impl BatchScope {
}
(n, format!("step {n} (\"{name}\")"))
};
if self.failed.contains(&n) {
return Err(fail(format!("references {who}, which failed")));
}
let path = match o.get("field") {
None => "id",
Some(Value::String(f)) => f.as_str(),
+1
View File
@@ -344,6 +344,7 @@ pub fn document(s: &Session, depth: usize, all_pages: bool) -> Value {
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();
json!({
"id": ds.uid,
"name": doc.name,
"kind": doc.kind.id(),
"path": ds.path,
@@ -0,0 +1,33 @@
//! A batch that goes on after a failed step (`onError: "continue"`) reports the live events of the
//! steps that stayed, not of the ones rolled back.
use std::time::Duration;
use serde_json::json;
use crate::Session;
#[test]
fn a_continued_batch_reports_only_the_events_of_steps_that_stayed() {
let mut s = Session::new();
let before = s.events.latest();
let r = s
.execute(
"command.batch",
&json!({"onError": "continue", "commands": [
{"command": "comment.add", "params": {"x": 10, "y": 10, "text": "Stays"}},
{"command": "command.batch", "params": {"commands": [
{"command": "comment.add", "params": {"x": 20, "y": 20, "text": "Rolled back"}},
{"command": "node.set", "params": {"ids": [999_999], "props": {"name": "x"}}}
]}},
{"command": "comment.add", "params": {"x": 30, "y": 30, "text": "Stays too"}}
]}),
)
.unwrap();
let ok: Vec<bool> = r.as_array().unwrap().iter().map(|o| o["ok"].as_bool().unwrap()).collect();
assert_eq!(ok, [true, false, true]);
let texts: Vec<String> = s.doc().unwrap().comments.iter().map(|c| c.first.text.clone()).collect();
assert_eq!(texts, ["Stays", "Stays too"]);
let events = s.events.poll(before, &["comments".into()], 10, Duration::ZERO).events;
assert_eq!(events.iter().map(|e| e.kind.as_str()).collect::<Vec<_>>(), ["comment.added", "comment.added"]);
}
+372
View File
@@ -280,3 +280,375 @@ fn a_failing_command_names_its_step_and_rolls_back() {
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, name);
assert_eq!(s.ds().unwrap().history.undo.len(), undo);
}
#[test]
fn a_continue_batch_can_undo_then_redo_between_steps() {
for mode in ["abort", "continue"] {
let mut s = Session::new();
let rect = id(&s.execute("node.create", &json!({"type": "rectangle"})).unwrap());
let before = s.doc().unwrap().clone();
let results =
s.execute("command.batch", &json!({"onError": mode, "commands": [{"command": "edit.undo"}, {"command": "edit.redo"}]})).unwrap();
let undo = if mode == "continue" { &results[0]["result"] } else { &results[0] };
let redo = if mode == "continue" { &results[1]["result"] } else { &results[1] };
assert_eq!(undo["undone"], true, "{mode}: {results}");
assert_eq!(redo["redone"], true, "{mode}: {results}");
assert!(s.doc().unwrap().contains(rect));
assert_eq!(s.doc().unwrap(), &before);
}
}
#[test]
fn a_continue_batch_can_open_a_document_after_closing_the_last_tab() {
let mut s = Session::new();
let uid = s.ds().unwrap().uid;
let results = s
.execute(
"command.batch",
&json!({"onError": "continue", "commands": [
{"command": "file.close", "params": {"force": true}},
{"command": "file.new"},
{"command": "page.rename", "params": {"name": "New page"}}
]}),
)
.unwrap();
assert!(results.as_array().unwrap().iter().all(|r| r["ok"] == true), "{results}");
assert_eq!(s.docs.len(), 1);
assert_ne!(s.ds().unwrap().uid, uid);
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, "New page");
assert!(s.closed_in_group.is_empty());
}
#[test]
fn a_continue_batch_repeats_the_last_successful_command_after_a_failed_step() {
let mut s = Session::new();
s.execute("node.create", &json!({"type": "rectangle"})).unwrap();
let last = s.last_command.clone();
let results = s
.execute(
"command.batch",
&json!({"onError": "continue", "commands": [
{"command": "command.batch", "params": {"commands": [
{"command": "node.create", "params": {"type": "text", "text": "Rolled back"}},
{"command": "no.such.command"}
]}},
{"command": "edit.repeat"}
]}),
)
.unwrap();
assert_eq!(results[0]["ok"], false);
assert_eq!(results[1]["ok"], true);
let repeated = s.doc().unwrap().node(id(&results[1]["result"])).unwrap();
assert!(matches!(repeated.kind, figcraft_doc::NodeKind::Rectangle), "{repeated:?}");
assert_eq!(s.last_command, last);
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len(), 2);
}
#[test]
fn a_continue_batch_keeps_successful_steps_as_one_undo_entry() {
let mut s = Session::new();
let frame = id(&s.execute("node.create", &json!({"type": "frame", "width": 50, "height": 50})).unwrap());
let text = id(&s.execute("node.create", &json!({"type": "text", "text": "orig"})).unwrap());
assert_eq!((frame.0, text.0), (2, 3));
let undo = s.undo_depth().0;
let name = s.doc().unwrap().node(s.page().unwrap()).unwrap().name.clone();
let results = s
.execute(
"command.batch",
&json!({"label": "Continue", "onError": "continue", "commands": [
{"command": "text.set", "params": {"ids": [3], "text": "novo"}},
{"command": "text.set", "params": {"ids": [2], "text": "erro"}},
{"command": "page.rename", "params": {"name": "Depois do erro"}}
]}),
)
.unwrap();
assert_eq!(results.as_array().unwrap().len(), 3);
assert_eq!(results[0], json!({"step": 0, "command": "text.set", "ok": true, "result": {"ok": true}}));
assert_eq!(results[2], json!({"step": 2, "command": "page.rename", "ok": true, "result": {"ok": true}}));
assert_eq!(results[1]["step"], 1);
assert_eq!(results[1]["command"], "text.set");
assert_eq!(results[1]["ok"], false);
let error = results[1]["error"].as_str().unwrap();
assert!(error.contains("step 1:") && error.contains("is not text"), "{error}");
assert!(results[1].get("result").is_none());
undo_and_redo_the_continued_batch(&mut s, frame, text, &name, undo);
}
/// The successful steps of the batch above undo and redo as one step.
fn undo_and_redo_the_continued_batch(s: &mut Session, frame: figcraft_doc::NodeId, text: figcraft_doc::NodeId, name: &str, undo: usize) {
assert!(matches!(&s.doc().unwrap().node(text).unwrap().kind, figcraft_doc::NodeKind::Text(t) if t.characters == "novo"));
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, "Depois do erro");
assert_eq!(s.undo_depth(), (undo + 1, 0));
assert_eq!(s.undo_labels(1), ["Continue"]);
assert!(s.undo().unwrap());
assert!(matches!(&s.doc().unwrap().node(text).unwrap().kind, figcraft_doc::NodeKind::Text(t) if t.characters == "orig"));
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, name);
assert!(s.doc().unwrap().contains(frame), "undo leaves earlier edits alone");
assert_eq!(s.undo_depth(), (undo, 1));
assert!(s.redo().unwrap());
assert!(matches!(&s.doc().unwrap().node(text).unwrap().kind, figcraft_doc::NodeKind::Text(t) if t.characters == "novo"));
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, "Depois do erro");
assert_eq!(s.undo_depth(), (undo + 1, 0));
}
#[test]
fn a_continue_batch_rolls_back_partial_steps_and_keeps_reference_positions() {
let mut s = Session::new();
let text = id(&s.execute("node.create", &json!({"type": "text", "text": "orig"})).unwrap());
let frame = id(&s.execute("node.create", &json!({"type": "frame"})).unwrap());
let results = s
.execute(
"command.batch",
&json!({"onError": "continue", "commands": [
{"command": "text.set", "as": "bad", "params": {"ids": [text.0, frame.0], "text": "partial"}},
{"command": "node.create", "as": "card", "params": {"type": "frame"}},
{"command": "node.create", "params": {"type": "text", "parent": {"$step": 0}}},
{"command": "node.create", "params": {"type": "text", "parent": {"$ref": "bad"}}},
{"command": "node.create", "params": {"type": "text", "parent": {"$step": 1}}},
{"command": "node.create", "params": {"type": "text", "parent": {"$ref": "card"}}},
{"command": "node.set", "params": {"ids": [{"$step": 4}], "props": {"name": "Child"}}}
]}),
)
.unwrap();
assert_eq!(results.as_array().unwrap().len(), 7);
for (i, result) in results.as_array().unwrap().iter().enumerate() {
assert_eq!(result["step"], i);
assert_eq!(result["ok"], ![0, 2, 3].contains(&i));
}
assert!(results[2]["error"].as_str().unwrap().contains("step 2: references step 0, which failed"));
assert!(results[3]["error"].as_str().unwrap().contains("step 3: references step 0 (\"bad\"), which failed"));
assert!(
matches!(&s.doc().unwrap().node(text).unwrap().kind, figcraft_doc::NodeKind::Text(t) if t.characters == "orig"),
"the failed step's partial edit was rolled back"
);
let card = id(&results[1]["result"]);
for i in [4, 5] {
assert_eq!(s.doc().unwrap().node(id(&results[i]["result"])).unwrap().parent, Some(card), "references use raw results, not outcomes");
}
assert_eq!(s.doc().unwrap().node(id(&results[4]["result"])).unwrap().name, "Child");
}
#[test]
fn invalid_batch_policies_run_nothing() {
for mode in [json!("skip"), json!(""), json!(true), json!(3), json!(["continue"]), json!({})] {
let mut s = Session::new();
let before = s.doc().unwrap().clone();
let journal = s.journal.clone();
let error = s
.execute("command.batch", &json!({"onError": mode, "commands": [{"command": "page.rename", "params": {"name": "Changed"}}]}))
.unwrap_err();
assert!(matches!(error, EngineError::BadParams { .. }));
assert!(error.to_string().contains("`onError` must be \"abort\" or \"continue\""), "{error}");
assert_eq!(s.doc().unwrap(), &before);
assert_eq!(s.journal, journal);
assert_eq!(s.undo_depth(), (0, 0));
}
}
#[test]
fn invalid_batch_structure_runs_nothing_in_either_mode() {
let rename = json!({"command": "page.rename", "params": {"name": "Changed"}});
for mode in ["abort", "continue"] {
for commands in [
json!(null),
json!({}),
json!([rename, null]),
json!([rename, {}]),
json!([rename, {"command": 3}]),
json!([rename, {"command": "page.rename", "as": ""}]),
json!([rename, {"command": "page.rename", "as": 3}]),
json!([{"command": "page.rename", "as": "same", "params": {"name": "Changed"}}, {"command": "select.none", "as": "same"}]),
] {
let mut s = Session::new();
let before = s.doc().unwrap().clone();
let journal = s.journal.clone();
let result = s.execute("command.batch", &json!({"onError": mode, "commands": commands}));
assert!(matches!(result, Err(EngineError::BadParams { .. })), "{mode}: {result:?}");
assert_eq!(s.doc().unwrap(), &before);
assert_eq!(s.journal, journal);
assert_eq!(s.undo_depth(), (0, 0));
}
}
}
#[test]
fn an_explicit_abort_batch_keeps_the_default_results_and_errors() {
let commands = json!([
{"command": "node.create", "params": {"type": "frame"}},
{"command": "node.create", "params": {"type": "text", "text": "orig"}}
]);
let mut default = Session::new();
let mut abort = Session::new();
let raw = default.execute("command.batch", &json!({"commands": commands})).unwrap();
assert_eq!(abort.execute("command.batch", &json!({"onError": "abort", "commands": commands})).unwrap(), raw);
assert!(raw[0].get("id").is_some() && raw[0].get("ok").is_none(), "still raw results");
let commands = json!([
{"command": "text.set", "params": {"ids": [3], "text": "novo"}},
{"command": "text.set", "params": {"ids": [2], "text": "erro"}},
{"command": "page.rename", "params": {"name": "Depois do erro"}}
]);
let before = abort.doc().unwrap().clone();
let undo = abort.undo_depth();
let error = default.execute("command.batch", &json!({"commands": commands})).unwrap_err();
let explicit = abort.execute("command.batch", &json!({"onError": "abort", "commands": commands})).unwrap_err();
assert_eq!(explicit.to_string(), error.to_string());
assert!(matches!(explicit, EngineError::BadParams { .. }));
assert_eq!(abort.doc().unwrap(), &before);
assert_eq!(abort.undo_depth(), undo);
}
#[test]
fn nested_batch_error_policies_are_local() {
for outer in ["abort", "continue"] {
let mut s = Session::new();
let results = s
.execute(
"command.batch",
&json!({"onError": outer, "commands": [
{"command": "command.batch", "as": "inner", "params": {"onError": "continue", "commands": [
{"command": "node.create", "as": "card", "params": {"type": "frame"}},
{"command": "no.such.command"},
{"command": "node.create", "params": {"type": "text", "parent": {"$ref": "card"}}}
]}},
{"command": "node.set", "params": {"ids": [{"$ref": "inner", "field": "2/result/id"}], "props": {"name": "Inner child"}}}
]}),
)
.unwrap();
let inner = if outer == "continue" { &results[0]["result"] } else { &results[0] };
assert_eq!(inner[1]["ok"], false);
let child = s.doc().unwrap().node(id(&inner[2]["result"])).unwrap();
assert_eq!(child.name, "Inner child");
assert_eq!(child.parent, Some(id(&inner[0]["result"])));
assert_eq!(s.undo_depth(), (1, 0));
assert!(s.undo().unwrap());
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len(), 0);
assert!(s.redo().unwrap());
assert!(s.doc().unwrap().contains(id(&inner[2]["result"])));
}
}
#[test]
fn an_outer_continue_batch_rolls_back_a_failed_inner_abort_batch() {
for explicit in [false, true] {
let mut s = Session::new();
let mut inner = json!({"commands": [
{"command": "file.close", "params": {"force": true}},
{"command": "file.new", "params": {"name": "Temporary"}},
{"command": "node.create", "params": {"type": "frame"}},
{"command": "no.such.command"}
]});
if explicit {
inner["onError"] = json!("abort");
}
let uid = s.ds().unwrap().uid;
let results = s
.execute(
"command.batch",
&json!({"onError": "continue", "commands": [
{"command": "page.rename", "params": {"name": "Before"}},
{"command": "command.batch", "params": inner},
{"command": "page.rename", "params": {"name": "After"}}
]}),
)
.unwrap();
assert_eq!(results[1]["ok"], false);
assert!(results[1]["error"].as_str().unwrap().contains("unknown command"));
assert_eq!(results[2]["ok"], true);
assert_eq!(s.docs.len(), 1);
assert_eq!(s.ds().unwrap().uid, uid);
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, "After");
assert!(s.doc().unwrap().node(s.page().unwrap()).unwrap().children.is_empty());
assert!(s.closed_in_group.is_empty());
assert_eq!(s.undo_depth(), (1, 0));
}
}
#[test]
fn an_outer_abort_batch_rolls_back_successes_of_an_inner_continue_batch() {
let mut s = Session::new();
let before = s.doc().unwrap().clone();
let result = s.execute(
"command.batch",
&json!({"commands": [
{"command": "command.batch", "params": {"onError": "continue", "commands": [
{"command": "node.create", "params": {"type": "frame"}},
{"command": "no.such.command"}
]}},
{"command": "no.such.command"}
]}),
);
assert!(matches!(result, Err(EngineError::UnknownCommand(_))));
assert_eq!(s.doc().unwrap(), &before);
assert_eq!(s.undo_depth(), (0, 0));
}
#[test]
fn an_all_failed_continue_batch_adds_no_undo_and_keeps_redo() {
let mut s = Session::new();
let frame = id(&s.execute("node.create", &json!({"type": "frame"})).unwrap());
s.execute("page.rename", &json!({"name": "Redo name"})).unwrap();
assert!(s.undo().unwrap());
let depth = s.undo_depth();
let before = s.doc().unwrap().clone();
let results = s
.execute(
"command.batch",
&json!({"onError": "continue", "commands": [
{"command": "node.set", "params": {"ids": [frame.0, 999999], "props": {"name": "Partial"}}},
{"command": "no.such.command"},
{"command": "node.create", "params": {"type": "text", "parent": {"$step": 0}}}
]}),
)
.unwrap();
assert!(results.as_array().unwrap().iter().all(|r| r["ok"] == false));
assert_eq!(s.doc().unwrap(), &before);
assert_eq!(s.undo_depth(), depth);
assert!(s.redo().unwrap());
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, "Redo name");
}
#[test]
fn an_empty_continue_batch_returns_an_empty_array_without_undo() {
let mut s = Session::new();
assert_eq!(s.execute("command.batch", &json!({"onError": "continue", "commands": []})).unwrap(), json!([]));
assert_eq!(s.undo_depth(), (0, 0));
}
#[test]
fn a_null_batch_result_is_not_a_failed_slot() {
let mut scope = crate::BatchScope::default();
scope.push(serde_json::Value::Null);
scope.push_failed();
let null = scope.resolve(&json!({"$step": 0})).unwrap_err().to_string();
assert!(null.contains("step 0's result has no `id`") && !null.contains("which failed"), "{null}");
let failed = scope.resolve(&json!({"$step": 1})).unwrap_err().to_string();
assert!(failed.contains("step 2: references step 1, which failed"), "{failed}");
assert_eq!(scope.next_step(), 2);
}
#[test]
fn a_continue_batch_reports_runtime_errors_and_runs_later_steps() {
let mut s = Session::new();
let results = s
.execute(
"command.batch",
&json!({"onError": "continue", "commands": [
{"command": "no.such.command"},
{"command": "node.create", "params": {"type": "no-such-type"}},
{"command": "node.create", "params": {"type": "text", "parent": {"$ref": "missing"}}},
{"command": "node.create", "params": {"type": "text", "parent": {"$step": 99}}},
{"command": "node.create", "params": {"type": "frame"}},
{"command": "node.set", "params": {"ids": [{"$step": 4, "field": "missing"}], "props": {"name": "No"}}},
{"command": "page.rename", "params": {"name": "Done"}}
]}),
)
.unwrap();
assert_eq!(results.as_array().unwrap().len(), 7);
for (i, needle) in [(0, "unknown command"), (1, "unknown type"), (2, "names no step"), (3, "earlier step"), (5, "has no `missing`")] {
assert_eq!(results[i]["ok"], false);
assert!(results[i]["error"].as_str().unwrap().contains(needle), "{}", results[i]);
}
assert_eq!(results[4]["ok"], true);
assert_eq!(results[6]["ok"], true);
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, "Done");
}
@@ -177,3 +177,76 @@ fn reattach_survives_junk() {
assert!(matches!(node(&s, k).board, Some(BoardItem::Connector(_))));
assert!(drawn(&s, k).iter().all(|p| p.x.is_finite() && p.y.is_finite()));
}
/// The connector on the page, if any (the one being drawn shows in the document).
fn the_connector(s: &Session) -> Option<figcraft_doc::Node> {
s.doc().unwrap().nodes().find(|n| matches!(n.board, Some(BoardItem::Connector(_)))).cloned()
}
#[test]
fn the_connector_being_drawn_is_the_one_made() {
for style in ["straight", "elbow", "curved"] {
for to in [Point::new(350.0, 250.0), Point::new(700.0, 600.0)] {
let mut s = board();
let mut shape = |x: f64, y: f64| id(&s.execute("board.shape", &json!({"x": x, "y": y, "width": 100, "height": 100})).unwrap(), "id");
let (_, b) = (shape(0.0, 0.0), shape(300.0, 200.0));
s.execute("tool.connector", &json!({"style": style})).unwrap();
let undo = s.undo_depth().0;
let ev = |k, p: Point| PointerEvent::at(k, p.x, p.y, 1.0);
s.pointer(&ev(PointerKind::Down, Point::new(50.0, 50.0))).unwrap();
s.pointer(&ev(PointerKind::Move, Point::new(200.0, 150.0))).unwrap();
s.pointer(&ev(PointerKind::Move, to)).unwrap();
let drawing = the_connector(&s).unwrap_or_else(|| panic!("{style}: the connector shows while dragged"));
let ov = &s.tool.overlay;
if to.x < 400.0 {
// Over b: its four attach points, the one the end is at lit, and b outlined.
assert_eq!(ov.drop_target, Some(b));
let lit: Vec<[f64; 2]> = ov.attach.iter().filter(|a| a.1).map(|a| a.0).collect();
let end = *drawn(&s, drawing.id).last().unwrap();
assert_eq!((ov.attach.len(), lit), (4, vec![[end.x, end.y]]), "{style}");
} else {
assert!(ov.attach.is_empty() && ov.drop_target.is_none(), "a free end");
}
assert!(s.tool.overlay.rubber.is_none(), "no rubber band: the connector itself shows");
s.pointer(&ev(PointerKind::Up, to)).unwrap();
let made = the_connector(&s).unwrap();
assert_eq!(made.kind, drawing.kind, "{style} to {to:?}: the same path");
assert_eq!((made.transform, made.width, made.height), (drawing.transform, drawing.width, drawing.height));
assert_eq!((&made.strokes, &made.stroke, &made.board), (&drawing.strokes, &drawing.stroke, &drawing.board), "line, caps and ends");
assert_eq!(s.undo_depth().0, undo + 1, "one undo step");
assert!(s.tool.overlay.attach.is_empty());
}
}
// Escape mid-drag: the preview goes and nothing is made.
let mut s = board();
s.execute("tool.connector", &json!({})).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Down, 0.0, 0.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Move, 200.0, 0.0, 1.0)).unwrap();
assert!(the_connector(&s).is_some());
s.escape().unwrap();
assert!(the_connector(&s).is_none());
}
#[test]
fn a_connector_end_snaps_to_the_attach_point_under_the_pointer() {
let mut s = board();
let mut shape = |x: f64, y: f64| id(&s.execute("board.shape", &json!({"x": x, "y": y, "width": 100, "height": 100})).unwrap(), "id");
let (a, b) = (shape(0.0, 0.0), shape(300.0, 200.0));
s.execute("tool.connector", &json!({"style": "straight"})).unwrap();
let ev = |k, x, y| PointerEvent::at(k, x, y, 1.0);
// Hovering: the object's attach points, lit only near one.
s.pointer(&ev(PointerKind::Hover, 50.0, 50.0)).unwrap();
assert_eq!(s.tool.overlay.attach.len(), 4);
assert!(s.tool.overlay.attach.iter().all(|a| !a.1));
s.pointer(&ev(PointerKind::Hover, 50.0, 3.0)).unwrap();
assert_eq!(s.tool.overlay.attach.iter().filter(|a| a.1).map(|a| a.0).collect::<Vec<_>>(), vec![[50.0, 0.0]]);
s.pointer(&ev(PointerKind::Hover, 900.0, 900.0)).unwrap();
assert!(s.tool.overlay.attach.is_empty());
// From near a's right middle to near b's top middle: those sides, kept.
s.pointer(&ev(PointerKind::Down, 97.0, 50.0)).unwrap();
s.pointer(&ev(PointerKind::Move, 352.0, 204.0)).unwrap();
s.pointer(&ev(PointerKind::Up, 352.0, 204.0)).unwrap();
let k = the_connector(&s).unwrap().id;
assert_eq!((connector(&s, k).from, connector(&s, k).to), (End::Node { node: a, side: Side::Right }, End::Node { node: b, side: Side::Top }));
assert_eq!(drawn(&s, k), vec![Point::new(100.0, 50.0), Point::new(350.0, 200.0)]);
}
+1 -1
View File
@@ -207,7 +207,7 @@ fn the_shape_stamp_highlighter_and_connector_tools_and_double_clicks() {
click(&mut s, 100.0, 100.0);
s.pointer(&PointerEvent::at(PointerKind::Down, 60.0, 120.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Move, 100.0, 140.0, 1.0)).unwrap();
assert!(s.tool.overlay.rubber.is_some(), "the rubber band shows while dragging");
assert!(!s.doc().unwrap().nodes().any(|n| matches!(n.board, Some(BoardItem::Connector(_)))), "nothing previewed: none would be made");
s.pointer(&PointerEvent::at(PointerKind::Up, 100.0, 140.0, 1.0)).unwrap();
assert!(!s.doc().unwrap().nodes().any(|n| matches!(n.board, Some(BoardItem::Connector(_)))));
// From the shape out to empty canvas: a connector to a free point.
+29 -12
View File
@@ -48,11 +48,26 @@ pub(super) fn connector_cmd(s: &mut Session, p: &Value) -> Result<Value> {
Some(v) => routing_of(cmd, &v)?,
None => s.tool.board.routing,
};
let start = cap_of(cmd, p, "startCap")?.unwrap_or(StrokeCap::None);
let end = cap_of(cmd, p, "endCap")?.unwrap_or(StrokeCap::TriangleArrow);
let c = Connector { from, to, routing };
let start = cap_of(cmd, p, "startCap")?.unwrap_or(CAPS.0);
let end = cap_of(cmd, p, "endCap")?.unwrap_or(CAPS.1);
let page = s.page()?;
let doc = s.doc_mut()?;
let id = make(doc, page, Connector { from, to, routing }, (start, end))?;
let mut out = json!({"id": id});
if let Some(text) = string(p, "label").filter(|t| !t.trim().is_empty()) {
out["label"] = json!(set_label(doc, id, &text)?);
}
s.set_selection(vec![id]);
Ok(out)
}
/// A new connector's (start, end) caps.
pub(super) const CAPS: (StrokeCap, StrokeCap) = (StrokeCap::None, StrokeCap::TriangleArrow);
/// Connector `c` added to `page` with its line, `caps` (start, end) and route: what
/// `board.connector` makes, and the connector tool shows while it is dragged.
pub(super) fn make(doc: &mut Document, page: NodeId, c: Connector, caps: (StrokeCap, StrokeCap)) -> Result<NodeId> {
let cmd = "board.connector";
let pts = route(doc, &c).ok_or_else(|| bad(cmd, "the ends can't be placed"))?;
if pts.windows(2).all(|w| w.first() == w.get(1)) {
return Err(bad(cmd, "the ends are in the same place"));
@@ -62,17 +77,11 @@ pub(super) fn connector_cmd(s: &mut Session, p: &Value) -> Result<Value> {
n.strokes = vec![Paint::solid(LINE)];
n.stroke.weight = LINE_WEIGHT;
n.stroke.join = StrokeJoin::Round;
n.stroke.cap_start = start;
n.stroke.cap_end = end;
(n.stroke.cap_start, n.stroke.cap_end) = caps;
n.board = Some(BoardItem::Connector(c));
})?;
place_route(doc, id, &pts, routing);
let mut out = json!({"id": id});
if let Some(text) = string(p, "label").filter(|t| !t.trim().is_empty()) {
out["label"] = json!(set_label(doc, id, &text)?);
}
s.set_selection(vec![id]);
Ok(out)
place_route(doc, id, &pts, c.routing);
Ok(id)
}
/// The connector `id` names: a connector, or the label on one.
@@ -121,6 +130,14 @@ pub(super) fn end_json(e: End) -> Value {
}
}
/// The side a connector end names, as the commands take it (none for a free point).
pub(super) fn side_json(e: End) -> Value {
match e {
End::Node { side, .. } => json!(side),
End::Point { .. } => Value::Null,
}
}
/// The end of connector `id` that stays while its other one (`from`: its start) is dragged.
pub(super) fn other_end(doc: &Document, id: NodeId, from: bool) -> Option<End> {
match &doc.node(id)?.board {
+82 -32
View File
@@ -1,12 +1,18 @@
use super::connectors::{end_json, other_end};
use super::derived::ends_now;
use super::connectors::{CAPS, end_json, make, other_end, side_json};
use super::derived::{ends_now, route};
use super::edit::label_of;
use super::items::edit_text;
use super::{
BoardItem, Document, ERASER_PX, End, EngineError, Gesture, NodeId, Point, PointerEvent, Result, SHAPE, STICKY, Session, Side, ToolId, id_of,
is_board, json,
BoardItem, Connector, Document, ERASER_PX, End, EngineError, Gesture, NodeId, Point, PointerEvent, Result, SHAPE, STICKY, Session, Side, ToolId,
id_of, is_board, json,
};
/// How near (screen pixels) the pointer must be to the middle of an object's side for a connector
/// end to attach there rather than to the side facing the other end.
const ATTACH_PX: f64 = 10.0;
/// The sides an object takes a connector end at (their middles are its attach points).
const SIDES: [Side; 4] = [Side::Top, Side::Right, Side::Bottom, Side::Left];
// ---------------------------------------------------------------------------------------------
// The board tools.
@@ -51,7 +57,8 @@ impl Session {
ToolId::Connector => {
let from = self.end_at(ev);
self.tool.gesture = Gesture::Connect { from, start: ev.p() };
Ok(())
// While it is dragged the connector shows as it will be made (a preview).
self.begin_interaction("Connector")
}
ToolId::Eraser => {
self.begin_interaction("Erase")?;
@@ -69,47 +76,87 @@ impl Session {
}
}
/// What a connector end at the pointer attaches to: the object there, or the point.
/// What a connector end at the pointer attaches to: the object there (at the side whose middle
/// is within [`ATTACH_PX`], else the side facing the other end), or the point.
fn end_at(&self, ev: &PointerEvent) -> End {
let zoom = ev.zoom.max(1e-6);
let hit = (|| {
let doc = self.doc().ok()?;
let leaf = self.hit(doc, self.page().ok()?, ev.p(), 2.0 / ev.zoom.max(1e-6))?;
let ds = self.ds().ok()?;
// Under the connector being dragged out (previewed in the document), not on it.
let doc = ds.interaction.as_ref().map_or(&*ds.doc, |it| &*it.doc);
let leaf = self.hit(doc, ds.page, ev.p(), 2.0 / zoom)?;
let t = figcraft_doc::geometry::select_target(doc, leaf, &[], false);
let n = doc.node(t)?;
(!matches!(n.board, Some(BoardItem::Connector(_)) | Some(BoardItem::Label { .. }) | Some(BoardItem::Ink))).then_some(t)
if matches!(n.board, Some(BoardItem::Connector(_)) | Some(BoardItem::Label { .. }) | Some(BoardItem::Ink)) {
return None;
}
let b = doc.world_bounds(t);
let far = |s: &Side| b.map(|b| (s.point(b) - ev.p()).hypot() * zoom).filter(|d| *d <= ATTACH_PX);
let side = SIDES.iter().filter_map(|s| Some((*s, far(s)?))).min_by(|x, y| x.1.total_cmp(&y.1)).map_or(Side::Auto, |(s, _)| s);
Some(End::Node { node: t, side })
})();
match hit {
Some(node) => End::Node { node, side: Side::Auto },
None => End::Point { x: ev.x, y: ev.y },
}
hit.unwrap_or(End::Point { x: ev.x, y: ev.y })
}
pub(crate) fn connect_drag(&mut self, ev: &PointerEvent, from: End, start: Point) {
let a = match from {
End::Point { x, y } => Point::new(x, y),
End::Node { .. } => start,
/// Where a connector dragged from `from` (pressed at `start`) to the pointer ends: none for a
/// click, or back on the object it started from (nothing is made).
fn drag_end(&self, ev: &PointerEvent, from: End, start: Point) -> Option<End> {
if (ev.p() - start).hypot() * ev.zoom.max(1e-6) < 4.0 {
return None;
}
let to = self.end_at(ev);
(to.node().is_none() || to.node() != from.node()).then_some(to)
}
/// The object `end` is on (if any) outlined, with its attach points; the one the end is at
/// (`at`, else its named side's) lit.
fn show_attach(&mut self, end: Option<End>, at: Option<Point>) {
let attach = match end {
Some(End::Node { node, side }) => self.doc().ok().and_then(|d| d.world_bounds(node)).map(|b| {
let on = at.or_else(|| (side != Side::Auto).then(|| side.point(b)));
SIDES.map(|s| s.point(b)).map(|p| ([p.x, p.y], on.is_some_and(|o| (o - p).hypot() < 1e-6))).to_vec()
}),
_ => None,
};
self.tool.overlay.rubber = Some([a.x, a.y, ev.x, ev.y]);
self.tool.overlay.drop_target = from.node();
self.tool.overlay.attach = attach.unwrap_or_default();
self.tool.overlay.drop_target = end.and_then(|e| e.node());
}
/// The pointer moving with the connector tool and no button down: the attach points under it.
pub(crate) fn connector_hover(&mut self, ev: &PointerEvent) {
let end = self.doc().is_ok_and(is_board).then(|| self.end_at(ev));
self.show_attach(end, None);
}
/// The connector being dragged out, drawn as it will be made (a preview) to the attach point
/// or free point under the pointer.
pub(crate) fn connect_drag(&mut self, ev: &PointerEvent, from: End, start: Point) {
self.tool.gesture = Gesture::Connect { from, start };
let c = self.drag_end(ev, from, start).map(|to| Connector { from, to, routing: self.tool.board.routing });
if let Ok(page) = self.page() {
// One that can't be placed shows nothing, as none would be made.
let _ = self.preview(|d, _| {
if let Some(c) = c {
let _ = make(d, page, c, CAPS);
}
Ok(())
});
}
let at = c.and_then(|c| route(self.doc().ok()?, &c)).and_then(|pts| pts.last().copied());
self.show_attach(c.map(|c| c.to), at);
if let Ok(ds) = self.ds_mut() {
ds.revision += 1;
}
}
pub(crate) fn connect_up(&mut self, ev: &PointerEvent, from: End, start: Point) -> Result<()> {
self.tool.overlay.rubber = None;
self.tool.overlay.drop_target = None;
// A click, not a drag: nothing to join.
if (ev.p() - start).hypot() * ev.zoom.max(1e-6) < 4.0 {
return Ok(());
}
let to = self.end_at(ev);
if to.node().is_some() && to.node() == from.node() {
return Ok(());
}
self.show_attach(None, None);
// The preview goes: the command makes the connector, one undo step.
self.cancel_interaction()?;
let Some(to) = self.drag_end(ev, from, start) else { return Ok(()) };
let style = self.tool.board.routing.id();
self.execute("board.connector", &json!({"from": end_json(from), "to": end_json(to), "style": style}))?;
let p = json!({"from": end_json(from), "fromSide": side_json(from), "to": end_json(to), "toSide": side_json(to), "style": style});
self.execute("board.connector", &p)?;
self.board_placed();
Ok(())
}
@@ -122,7 +169,8 @@ impl Session {
let fixed = ends_now(doc, id).map(|(a, b)| if from { b } else { a });
let other = other_end(doc, id, from).and_then(|e| e.node());
self.tool.overlay.rubber = fixed.map(|f| [f.x, f.y, ev.x, ev.y]);
self.tool.overlay.drop_target = self.end_at(ev).node().filter(|n| Some(*n) != other);
let target = Some(self.end_at(ev)).filter(|e| e.node().is_none() || e.node() != other);
self.show_attach(target, None);
if let Ok(ds) = self.ds_mut() {
ds.revision += 1;
}
@@ -132,6 +180,7 @@ impl Session {
/// A click, or a drop on the object the other end is on, changes nothing.
pub(crate) fn reattach_up(&mut self, ev: &PointerEvent, id: NodeId, from: bool, start: Point) -> Result<()> {
self.tool.overlay.rubber = None;
self.show_attach(None, None);
if (ev.p() - start).hypot() * ev.zoom.max(1e-6) < 4.0 {
return Ok(());
}
@@ -140,7 +189,8 @@ impl Session {
if target.node().is_some() && target.node() == other {
return Ok(());
}
self.execute("board.reattach", &json!({"id": id, "end": if from { "from" } else { "to" }, "target": end_json(target)}))?;
let end = if from { "from" } else { "to" };
self.execute("board.reattach", &json!({"id": id, "end": end, "target": end_json(target), "side": side_json(target)}))?;
Ok(())
}
+140 -20
View File
@@ -2,9 +2,10 @@
//!
//! The selected shapes' outlines split the plane into **regions** (the faces of their arrangement):
//! connected areas covered by the same shapes. Merging regions makes one new vector from them,
//! styled like the top-most shape covering the first region; removing regions deletes them. Either
//! way every shape covering a picked region loses that area (it becomes a vector, or is deleted
//! when nothing is left), so the rest of the drawing stays exactly where it was.
//! styled like the top-most fill or stroke covering the first selected region (using that vector
//! region's paints when applicable); removing regions deletes them. Either way every shape covering
//! a picked region loses that area (it becomes a vector, or is deleted when nothing is left), so
//! the rest of the drawing stays exactly where it was.
//!
//! **Strokes and open paths.** A visible stroke takes part as its outlined area (a [`Part`] of its
//! own, above the shape's fill), so stroked lines and open paths split the regions they cross and
@@ -37,6 +38,9 @@ use crate::{EngineError, Result, Session, bad, spec};
pub struct Part {
pub id: NodeId,
pub stroke: bool,
/// The source vector region for a fill (`None` for ordinary fills and strokes).
#[serde(skip)]
pub region: Option<usize>,
}
/// One region of the arrangement, in page coordinates.
@@ -126,7 +130,16 @@ fn world_area(doc: &Document, part: Part) -> Option<(BezPath, bool)> {
if !(figcraft_doc::geometry::is_closed(n) || matches!(n.kind, NodeKind::BooleanOp { .. } | NodeKind::Text(_))) {
return None;
}
figcraft_render::node_outline(doc, n)?
match (&n.kind, part.region) {
(NodeKind::Vector { network }, Some(i)) => {
let rounded = network.rounded();
let net = rounded.as_ref().unwrap_or(network);
let region = net.regions.get(i)?;
let path = net.region_path(i)?;
(path, if region.winding == WindingRule::EvenOdd { figcraft_render::FillRule::EvenOdd } else { figcraft_render::FillRule::Winding })
}
_ => figcraft_render::node_outline(doc, n)?,
}
};
p.apply_affine(doc.world_transform(part.id));
(!p.elements().is_empty() && figcraft_pathops::finite(&p)).then_some((p, rule == figcraft_render::FillRule::EvenOdd))
@@ -155,10 +168,17 @@ fn compute(doc: Arc<Document>, uid: u64, ids: &[NodeId]) -> Result<Arrangement>
let mut inputs = Vec::new();
for id in eligible(&doc, ids) {
let before = parts.len();
for part in [Part { id, stroke: false }, Part { id, stroke: true }] {
if inputs.len() < figcraft_pathops::MAX_ARRANGEMENT_INPUTS
&& let Some(o) = world_area(&doc, part)
{
let fill_parts: Vec<Part> = match &doc.get(id)?.kind {
NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some()) => {
network.regions.iter().enumerate().map(|(region, _)| Part { id, stroke: false, region: Some(region) }).collect()
}
_ => vec![Part { id, stroke: false, region: None }],
};
for part in fill_parts.into_iter().chain([Part { id, stroke: true, region: None }]) {
if let Some(o) = world_area(&doc, part) {
if inputs.len() >= figcraft_pathops::MAX_ARRANGEMENT_INPUTS {
return Err(EngineError::Other("shape builder: too many fill regions and strokes to process safely".into()));
}
parts.push(part);
inputs.push(o);
}
@@ -253,6 +273,68 @@ fn network_from(path: &BezPath, to_local: Affine) -> Option<VectorNetwork> {
Some(net)
}
fn append_network(into: &mut VectorNetwork, mut part: VectorNetwork) {
let (vertices, segments) = (into.vertices.len(), into.segments.len());
for segment in &mut part.segments {
segment.a += vertices;
segment.b += vertices;
}
for region in &mut part.regions {
for loop_segments in &mut region.loops {
for segment in loop_segments {
*segment += segments;
}
}
}
into.vertices.extend(part.vertices);
into.segments.extend(part.segments);
into.regions.extend(part.regions);
}
fn paint_frame(region: Option<&figcraft_doc::Region>, node: &Node) -> (f64, f64, Affine) {
region.map_or((node.width, node.height, Affine::IDENTITY), |r| r.paint_frame(node.width, node.height))
}
/// Rebuild `id` from each remaining source region, retaining region order, paint stacks and frames.
fn reshape_vector_regions(doc: &mut Document, id: NodeId, areas: &[(usize, BezPath)]) -> Result<()> {
let old = doc.get(id)?.clone();
let (world, network) = match &old.kind {
NodeKind::Vector { network } => (doc.world_transform(id), network),
_ => return Err(EngineError::Other("expected a vector with regions".into())),
};
if !(world.determinant().is_finite() && world.determinant().abs() > 1e-12) {
return Err(EngineError::Other("can't reshape a layer scaled to nothing".into()));
}
let mut rebuilt = VectorNetwork::default();
for (index, area) in areas {
let Some(source) = network.regions.get(*index) else { continue };
let Some(mut part) = network_from(area, world.inverse()) else { return Err(EngineError::Other("invalid geometry".into())) };
if part.regions.is_empty() {
continue;
}
let (width, height, frame) = paint_frame(Some(source), &old);
for region in &mut part.regions {
region.fills = source.fills.clone();
region.paint_space = Some(figcraft_doc::RegionPaintSpace { width, height, transform: frame });
}
append_network(&mut rebuilt, part);
}
let Some(bounds) = rebuilt.bounds() else {
let n = doc.get_mut(id)?;
n.kind = NodeKind::Vector { network: rebuilt };
return Ok(());
};
rebuilt.translate(-bounds.x0, -bounds.y0);
let n = doc.get_mut(id)?;
n.kind = NodeKind::Vector { network: rebuilt };
n.children.clear();
n.corners = Corners::default();
n.transform *= Affine::translate((bounds.x0, bounds.y0));
n.width = bounds.width();
n.height = bounds.height();
Ok(())
}
/// Replace shape `id`'s geometry with the page-space area `area`, keeping its transform and style.
fn reshape(doc: &mut Document, id: NodeId, area: &BezPath) -> Result<()> {
let w = doc.world_transform(id);
@@ -273,10 +355,16 @@ fn reshape(doc: &mut Document, id: NodeId, area: &BezPath) -> Result<()> {
crate::vectoredit::refit(doc, id)
}
/// A new vector for the page-space area `area`, styled like part `style` (a stroke part: filled
/// with the stroke's paints), just above its shape.
/// A new vector for page-space `area`, styled like `style` (a stroke part: filled with stroke
/// paints), just above its shape. Vector region paints and their original frame stay attached.
fn create_merged(doc: &mut Document, style: Part, area: &BezPath) -> Result<NodeId> {
let mut src = doc.get(style.id)?.clone();
let source_region = match (&src.kind, style.region) {
(NodeKind::Vector { network }, Some(i)) => network.regions.get(i),
_ => None,
};
let (paint_width, paint_height, source_paint_frame) = paint_frame(source_region, &src);
let region_fills = source_region.and_then(|r| r.fills.clone());
if style.stroke {
src.fills = std::mem::take(&mut src.strokes);
src.stroke = StrokeProps::default();
@@ -291,6 +379,12 @@ fn create_merged(doc: &mut Document, style: Part, area: &BezPath) -> Result<Node
let mut net = network_from(area, pw.inverse()).ok_or_else(|| EngineError::Other("invalid geometry".into()))?;
let bb = net.bounds().ok_or_else(|| EngineError::Other("empty region".into()))?;
net.translate(-bb.x0, -bb.y0);
let to_world = pw * Affine::translate((bb.x0, bb.y0));
let paint_transform = to_world.inverse() * doc.world_transform(style) * source_paint_frame;
for region in &mut net.regions {
region.fills = region_fills.clone();
region.paint_space = Some(figcraft_doc::RegionPaintSpace { width: paint_width, height: paint_height, transform: paint_transform });
}
let id = doc.alloc_id();
let mut v = Node::new(id, NodeKind::Vector { network: net }, src.name.clone(), bb.width(), bb.height());
v.transform = Affine::translate((bb.x0, bb.y0));
@@ -321,7 +415,9 @@ fn apply(s: &mut Session, cmd: &str, p: &Value, merge: bool) -> Result<Value> {
// Merging every region of a single shape would only replace it with a copy of itself.
let whole_shape =
touched.len() == 1 && a.regions.iter().enumerate().all(|(i, r)| pick.contains(&i) || !r.sources.iter().any(|x| touched.contains(x)));
if merge && whole_shape {
// A vector's distinct region paints are separate fill parts that merging does unify.
let fill_parts = touched_parts.iter().filter(|part| !part.stroke).count();
if merge && whole_shape && fill_parts <= 1 {
return Ok(json!({"id": null, "changed": [], "removed": [], "outlines": [], "regions": pick}));
}
let doc = s.doc_mut()?;
@@ -347,7 +443,8 @@ fn apply(s: &mut Session, cmd: &str, p: &Value, merge: bool) -> Result<Value> {
let mut changed = Vec::new();
let mut outlined: Vec<(NodeId, NodeId)> = Vec::new();
for src in a.sources.iter().copied().filter(|i| touched.contains(i)) {
let (fill, stroke) = (Part { id: src, stroke: false }, Part { id: src, stroke: true });
let stroke = Part { id: src, stroke: true, region: None };
let fill_parts: Vec<Part> = a.parts.iter().copied().filter(|part| part.id == src && !part.stroke).collect();
// The stroke becomes an outline of its own (what's left of it), the shape loses it.
if a.parts.contains(&stroke) {
if let Some(area) = kept(stroke)? {
@@ -357,16 +454,35 @@ fn apply(s: &mut Session, cmd: &str, p: &Value, merge: bool) -> Result<Value> {
n.strokes.clear();
n.styles.stroke = None;
}
let left = if !a.parts.contains(&fill) {
None
} else if touched_parts.contains(&fill) {
kept(fill)?
} else {
if fill_parts.is_empty() {
doc.remove(src)?;
removed.push(src);
continue;
}
let touched_fills: Vec<Part> = fill_parts.iter().copied().filter(|part| touched_parts.contains(part)).collect();
if touched_fills.is_empty() {
// The fill wasn't picked: the shape stays as it is.
changed.push(src);
continue;
};
match left {
}
if fill_parts.iter().any(|part| part.region.is_some()) {
let mut areas = Vec::new();
for part in &fill_parts {
let Some(region) = part.region else { continue };
if let Some(area) = kept(*part)? {
areas.push((region, area));
}
}
if areas.is_empty() {
doc.remove(src)?;
removed.push(src);
} else {
reshape_vector_regions(doc, src, &areas)?;
changed.push(src);
}
continue;
}
match kept(fill_parts[0])? {
Some(area) => {
reshape(doc, src, &area)?;
changed.push(src);
@@ -427,7 +543,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"shapeBuilder.merge",
"Merge regions",
"{ids?, regions?: [i, ...], points?: [[x, y], ...] (page)} → {id, changed, removed, outlines, regions} — one new shape from the regions, styled like the top-most fill or stroke over the first; the shapes lose those areas (strokes of the shapes involved become outlined vectors: `outlines`)",
"{ids?, regions?: [i, ...], points?: [[x, y], ...] (page)} → {id, changed, removed, outlines, regions} — one new shape from the regions, styled like the top-most fill or stroke over the first selected region (using its vector-region paints); the shapes lose those areas (strokes of the shapes involved become outlined vectors: `outlines`)",
true,
|s, p| apply(s, "shapeBuilder.merge", p, true)
),
@@ -464,6 +580,10 @@ pub fn key(s: &mut Session, name: &str) -> Result<bool> {
Ok(true)
}
#[cfg(test)]
#[path = "shapebuilder_region_paints_tests.rs"]
mod region_paints_tests;
impl Session {
/// The Shape builder tool: hover highlights a region, click/drag collects regions, release
/// merges (or removes, with Alt or in Remove mode) them as one undo step. Clicking outside
@@ -0,0 +1,307 @@
use figcraft_color::{Color, Gradient, GradientKind, Paint};
use figcraft_doc::{NodeId, NodeKind, Region, VectorNetwork, WindingRule};
use figcraft_geom::{Affine, Point, Shape};
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
use crate::shapebuilder::arrangement;
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn id(v: &Value) -> NodeId {
id_of(&v["id"]).unwrap()
}
fn rect(s: &mut Session, x: f64, y: f64, width: f64, height: f64, fill: &str) -> NodeId {
id(&ex(s, "shape.rectangle", json!({"x": x, "y": y, "width": width, "height": height, "props": {"fill": fill}})))
}
fn append(into: &mut VectorNetwork, mut part: VectorNetwork) {
let (vertices, segments) = (into.vertices.len(), into.segments.len());
for segment in &mut part.segments {
segment.a += vertices;
segment.b += vertices;
}
for region in &mut part.regions {
for lp in &mut region.loops {
for segment in lp {
*segment += segments;
}
}
}
into.vertices.extend(part.vertices);
into.segments.extend(part.segments);
into.regions.extend(part.regions);
}
fn box_path(x0: f64, y0: f64, x1: f64, y1: f64) -> VectorNetwork {
VectorNetwork::from_points(&[Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)], true)
}
fn split_vector(s: &mut Session) -> NodeId {
let id = rect(s, 0.0, 0.0, 100.0, 100.0, "#00ff00");
let mut network = VectorNetwork::default();
append(&mut network, box_path(0.0, 0.0, 50.0, 100.0));
append(&mut network, box_path(50.0, 0.0, 100.0, 100.0));
network.regions[0].fills = Some(vec![Paint::solid(Color::from_hex("#ff0000").unwrap())]);
network.regions[1].fills = Some(vec![Paint::solid(Color::from_hex("#0000ff").unwrap())]);
s.doc_mut().unwrap().get_mut(id).unwrap().kind = NodeKind::Vector { network };
id
}
fn network(s: &Session, id: NodeId) -> &VectorNetwork {
let NodeKind::Vector { network } = &s.doc().unwrap().node(id).unwrap().kind else { panic!("expected vector") };
// This helper's borrow is tied to the session document.
network
}
fn world_region_area(s: &Session, id: NodeId, index: usize) -> f64 {
let doc = s.doc().unwrap();
let mut path = network(s, id).region_path(index).unwrap();
path.apply_affine(doc.world_transform(id));
path.area().abs()
}
fn normalized_paint_world(s: &Session, id: NodeId, index: usize) -> Affine {
let doc = s.doc().unwrap();
let node = doc.node(id).unwrap();
let (width, height, transform) = network(s, id).regions[index].paint_frame(node.width, node.height);
doc.world_transform(id) * transform * Affine::scale_non_uniform(width, height)
}
fn assert_same_transform(a: Affine, b: Affine) {
for (x, y) in a.as_coeffs().into_iter().zip(b.as_coeffs()) {
assert!((x - y).abs() < 1e-8, "paint frame changed: {a:?} vs {b:?}");
}
}
#[test]
fn arrangement_keeps_internal_paint_boundaries_when_another_shape_crosses_them() {
let mut s = Session::new();
let vector = split_vector(&mut s);
let crossing = rect(&mut s, 25.0, -10.0, 50.0, 120.0, "#ffffff");
s.set_selection(vec![vector, crossing]);
let a = arrangement(&mut s, &[vector, crossing]).unwrap();
let left = a.region_at(Point::new(35.0, 50.0)).unwrap();
let right = a.region_at(Point::new(65.0, 50.0)).unwrap();
assert_ne!(left, right, "the old paint boundary must split the crossing shape's overlap");
assert!(a.regions[left].parts.iter().any(|p| p.id == vector && p.region == Some(0)));
assert!(a.regions[right].parts.iter().any(|p| p.id == vector && p.region == Some(1)));
}
#[test]
fn legacy_unstyled_nested_evenodd_regions_keep_the_hole_unpainted() {
let mut s = Session::new();
let vector = rect(&mut s, 0.0, 0.0, 100.0, 100.0, "#00ff00");
let mut net = box_path(0.0, 0.0, 100.0, 100.0);
let inner = VectorNetwork::from_points(&[Point::new(25.0, 25.0), Point::new(25.0, 75.0), Point::new(75.0, 75.0), Point::new(75.0, 25.0)], true);
append(&mut net, inner);
for region in &mut net.regions {
region.winding = WindingRule::EvenOdd;
region.fills = None;
region.paint_space = None;
}
s.doc_mut().unwrap().get_mut(vector).unwrap().kind = NodeKind::Vector { network: net };
let crossing = rect(&mut s, -5.0, 45.0, 15.0, 10.0, "#ffffff");
s.set_selection(vec![vector, crossing]);
let a = arrangement(&mut s, &[vector, crossing]).unwrap();
assert!(a.region_at(Point::new(50.0, 50.0)).is_none(), "the legacy combined even-odd fill leaves its nested center empty");
ex(&mut s, "shapeBuilder.remove", json!({"points": [[5, 50]]}));
let NodeKind::Vector { network } = &s.doc().unwrap().node(vector).unwrap().kind else { panic!("expected vector") };
assert!(!network.fill_contains(Point::new(50.0, 50.0)), "the nested hole survives removal");
assert!(network.fill_contains(Point::new(85.0, 50.0)), "unselected outer area remains filled");
}
#[test]
fn merge_all_painted_regions_uses_the_first_picked_region_style() {
let mut s = Session::new();
let vector = split_vector(&mut s);
s.set_selection(vec![vector]);
let result = ex(&mut s, "shapeBuilder.merge", json!({"points": [[25, 50], [75, 50]]}));
let merged = id(&result);
assert!(!s.doc().unwrap().contains(vector));
assert_eq!(network(&s, merged).regions[0].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "FF0000");
assert!((world_region_area(&s, merged, 0) - 10_000.0).abs() < 0.1);
s.undo().unwrap();
assert_eq!(network(&s, vector).regions.len(), 2);
}
#[test]
fn merge_all_inherited_gradient_regions_with_distinct_frames_is_not_a_noop() {
let mut s = Session::new();
let vector = split_vector(&mut s);
{
let doc = s.doc_mut().unwrap();
let node = doc.get_mut(vector).unwrap();
node.fills = vec![Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::from_hex("#ff0000").unwrap()))];
let NodeKind::Vector { network } = &mut node.kind else { panic!("expected vector") };
for (region, offset) in network.regions.iter_mut().zip([0.0, 12.0]) {
region.fills = None;
region.paint_space = Some(figcraft_doc::RegionPaintSpace { width: 100.0, height: 100.0, transform: Affine::translate((offset, 0.0)) });
}
}
let before_a = normalized_paint_world(&s, vector, 0);
let before_b = normalized_paint_world(&s, vector, 1);
assert_ne!(before_a, before_b, "source regions use distinct inherited-gradient frames");
s.set_selection(vec![vector]);
let result = ex(&mut s, "shapeBuilder.merge", json!({"points": [[25, 50], [75, 50]]}));
let merged = id(&result);
assert!(!s.doc().unwrap().contains(vector), "the selected regions are merged into a new vector");
assert_eq!(network(&s, merged).regions.len(), 1);
assert_eq!(network(&s, merged).regions[0].fills, None, "node gradient remains inherited");
assert!(matches!(s.doc().unwrap().node(merged).unwrap().fills[0].kind, figcraft_color::PaintKind::Gradient(_)));
assert_same_transform(normalized_paint_world(&s, merged, 0), before_a);
}
#[test]
fn exceeding_arrangement_capacity_never_drops_unprocessed_regions() {
let mut s = Session::new();
let vector = split_vector(&mut s);
let mut net = VectorNetwork::default();
for i in 0..=figcraft_pathops::MAX_ARRANGEMENT_INPUTS {
let mut part = box_path(i as f64 * 12.0, 0.0, i as f64 * 12.0 + 10.0, 10.0);
part.regions[0].fills = Some(vec![Paint::solid(Color::BLACK)]);
append(&mut net, part);
}
s.doc_mut().unwrap().get_mut(vector).unwrap().kind = NodeKind::Vector { network: net.clone() };
s.set_selection(vec![vector]);
let error = s.execute("shapeBuilder.remove", &json!({"points": [[5, 5]]})).unwrap_err();
assert!(error.to_string().contains("too many fill regions"));
assert_eq!(network(&s, vector), &net);
}
#[test]
fn remove_changes_only_the_picked_painted_face() {
let mut s = Session::new();
let vector = split_vector(&mut s);
let crossing = rect(&mut s, 25.0, -10.0, 50.0, 120.0, "#ffffff");
s.set_selection(vec![vector, crossing]);
ex(&mut s, "shapeBuilder.remove", json!({"points": [[35, 50]]}));
assert!((world_region_area(&s, vector, 0) - 2500.0).abs() < 0.1);
assert!((world_region_area(&s, vector, 1) - 5000.0).abs() < 0.1, "the untouched blue region keeps its area");
let net = network(&s, vector);
assert_eq!(net.regions[0].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "FF0000");
assert_eq!(net.regions[1].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "0000FF");
}
#[test]
fn reshape_keeps_inherited_and_explicitly_empty_region_fills_distinct() {
let mut s = Session::new();
let vector = split_vector(&mut s);
{
let doc = s.doc_mut().unwrap();
let NodeKind::Vector { network } = &mut doc.get_mut(vector).unwrap().kind else { panic!("expected vector") };
network.regions[0].fills = Some(Vec::new());
network.regions[1].fills = None;
}
let crossing = rect(&mut s, 25.0, -10.0, 50.0, 120.0, "#ffffff");
s.set_selection(vec![vector, crossing]);
ex(&mut s, "shapeBuilder.remove", json!({"points": [[35, 50]]}));
assert_eq!(network(&s, vector).regions[0].fills, Some(Vec::new()));
assert_eq!(network(&s, vector).regions[1].fills, None);
assert_eq!(s.doc().unwrap().node(vector).unwrap().fills[0].solid_color().unwrap().to_hex(), "00FF00");
}
#[test]
fn merge_uses_selected_region_paints_and_retains_other_region_stack() {
let mut s = Session::new();
// Create crossing first, then vector above it so vector region supplies merge appearance.
let crossing = rect(&mut s, 25.0, -10.0, 50.0, 120.0, "#ffffff");
let vector = split_vector(&mut s);
s.set_selection(vec![crossing, vector]);
let result = ex(&mut s, "shapeBuilder.merge", json!({"points": [[35, 50]]}));
let merged = id(&result);
let merged_net = network(&s, merged);
assert_eq!(merged_net.regions[0].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "FF0000");
assert!((world_region_area(&s, vector, 1) - 5000.0).abs() < 0.1, "unselected blue region survives");
assert_eq!(network(&s, vector).regions[1].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "0000FF");
assert!((world_region_area(&s, merged, 0) - 2500.0).abs() < 0.1);
assert!(s.undo().unwrap());
assert_eq!(network(&s, vector).regions[0].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "FF0000");
assert!(s.redo().unwrap());
assert_eq!(network(&s, merged).regions[0].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "FF0000");
}
#[test]
fn merged_region_keeps_a_transformed_source_gradient_frame() {
let mut s = Session::new();
let crossing = rect(&mut s, 0.0, 0.0, 300.0, 200.0, "#ffffff");
let vector = split_vector(&mut s);
let mut transform = Affine::translate((100.0, 20.0)) * Affine::rotate(0.2);
transform *= Affine::scale_non_uniform(1.2, 0.8);
{
let doc = s.doc_mut().unwrap();
let node = doc.get_mut(vector).unwrap();
node.transform = transform;
let NodeKind::Vector { network } = &mut node.kind else { panic!("expected vector") };
network.regions[0].fills = Some(vec![Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::from_hex("#ff0000").unwrap()))]);
}
let before = normalized_paint_world(&s, vector, 0);
let point = s.doc().unwrap().world_transform(vector) * Point::new(35.0, 50.0);
s.set_selection(vec![crossing, vector]);
let result = ex(&mut s, "shapeBuilder.merge", json!({"points": [[point.x, point.y]]}));
let merged = id(&result);
assert!(matches!(&network(&s, merged).regions[0].fills.as_ref().unwrap()[0].kind, figcraft_color::PaintKind::Gradient(_)));
assert_same_transform(before, normalized_paint_world(&s, merged, 0));
}
#[test]
fn removal_preserves_holes_and_transformed_gradient_frames() {
let mut s = Session::new();
let vector = rect(&mut s, 0.0, 0.0, 100.0, 100.0, "#00ff00");
let mut outer = box_path(0.0, 0.0, 100.0, 100.0);
let inner = VectorNetwork::from_points(&[Point::new(25.0, 25.0), Point::new(25.0, 75.0), Point::new(75.0, 75.0), Point::new(75.0, 25.0)], true);
let outer_loop = outer.regions[0].loops[0].clone();
let inner_loop: Vec<usize> = inner.regions[0].loops[0].iter().map(|i| i + outer.segments.len()).collect();
append(&mut outer, inner);
outer.regions = vec![Region {
loops: vec![outer_loop, inner_loop],
winding: WindingRule::EvenOdd,
fills: Some(vec![Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::from_hex("#ff0000").unwrap()))]),
paint_space: None,
}];
let transform = Affine::translate((120.0, 20.0)) * Affine::rotate(0.2);
{
let doc = s.doc_mut().unwrap();
let node = doc.get_mut(vector).unwrap();
node.kind = NodeKind::Vector { network: outer };
node.transform = transform;
}
let before = normalized_paint_world(&s, vector, 0);
let world = s.doc().unwrap().world_transform(vector) * Point::new(10.0, 50.0);
let overlay = rect(&mut s, world.x - 5.0, world.y - 5.0, 10.0, 10.0, "#ffffff");
s.set_selection(vec![overlay, vector]);
ex(&mut s, "shapeBuilder.remove", json!({"points": [[world.x, world.y]]}));
let net = network(&s, vector);
assert_eq!(net.regions.len(), 1);
assert_eq!(net.regions[0].loops.len(), 3, "the original hole and removed face stay separate loops");
assert!(!net.region_contains(0, Point::new(50.0, 50.0)), "the hole remains empty");
assert!(!net.region_contains(0, Point::new(10.0, 50.0)), "the removed face remains empty");
assert!(net.region_contains(0, Point::new(90.0, 50.0)), "the unselected ring remains filled");
assert_same_transform(before, normalized_paint_world(&s, vector, 0));
}
#[test]
fn removing_the_left_region_keeps_the_right_region_paint_frame() {
let mut s = Session::new();
let vector = split_vector(&mut s);
{
let NodeKind::Vector { network } = &mut s.doc_mut().unwrap().get_mut(vector).unwrap().kind else { panic!("expected vector") };
network.regions[1].fills = Some(vec![Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::from_hex("#0000ff").unwrap()))]);
}
let before = normalized_paint_world(&s, vector, 1);
s.set_selection(vec![vector]);
ex(&mut s, "shapeBuilder.remove", json!({"points": [[25, 50]]}));
let node = s.doc().unwrap().node(vector).unwrap();
assert!((node.width - 50.0).abs() < 1e-6, "the layer is reboxed to the surviving region: {}", node.width);
let net = network(&s, vector);
assert_eq!(net.regions.len(), 1);
assert!(matches!(&net.regions[0].fills.as_ref().unwrap()[0].kind, figcraft_color::PaintKind::Gradient(_)));
assert!((world_region_area(&s, vector, 0) - 5000.0).abs() < 1e-6);
assert_same_transform(before, normalized_paint_world(&s, vector, 0));
}
+18
View File
@@ -138,6 +138,24 @@ impl Targets {
self.boxes.push(r);
}
/// Take back what [`Targets::add_box`] added for `r`.
pub fn remove_box(&mut self, r: Rect) {
let take = |v: &mut Vec<Span>, s: Span| {
if let Some(i) = v.iter().rposition(|x| *x == s) {
v.remove(i);
}
};
for x in [r.x0, r.center().x, r.x1] {
take(&mut self.xs, (x, r.y0, r.y1));
}
for y in [r.y0, r.center().y, r.y1] {
take(&mut self.ys, (y, r.x0, r.x1));
}
if let Some(i) = self.boxes.iter().rposition(|b| *b == r) {
self.boxes.remove(i);
}
}
/// A point to snap onto (a vector point, a segment midpoint): lines through it on both axes,
/// and a marker when a moving point lands on it.
pub fn add_point(&mut self, p: Point) {
+3
View File
@@ -336,6 +336,9 @@ pub struct Overlay {
pub rubber_handle: Option<[f64; 2]>,
/// Frame/section the pointer is drawing or dropping into (highlighted).
pub drop_target: Option<NodeId>,
/// Connector tool: the attach points (side middles, page x, y) of the object under the pointer,
/// and whether the connector end is at each.
pub attach: Vec<([f64; 2], bool)>,
/// Auto layout insertion indicator (x0, y0, x1, y1).
pub insert_line: Option<[f64; 4]>,
/// The instance an Alt-drop of a component from the Assets panel would swap (highlighted).
+15 -9
View File
@@ -178,7 +178,20 @@ impl Session {
}
pub(super) fn drag(&mut self, ev: &PointerEvent) -> Result<()> {
let g = std::mem::take(&mut self.tool.gesture);
let mut g = std::mem::take(&mut self.tool.gesture);
// Alt makes a move a copy whenever it goes down, before the drag or during it, and letting
// go of it moves the original again; a copy snaps to the layers it came from too.
if let Gesture::Move { targets, duplicate, .. } = &mut g
&& *duplicate != ev.alt
{
*duplicate = ev.alt;
if let Some(b) = targets.moving {
if ev.alt { targets.add_box(b) } else { targets.remove_box(b) }
}
if let Some(it) = self.ds_mut().ok().and_then(|ds| ds.interaction.as_mut()) {
it.label = if ev.alt { "Duplicate" } else { "Move" }.into();
}
}
let res = match &g {
Gesture::None => return self.hover(ev),
Gesture::Marquee { start, additive, base, within } => {
@@ -533,15 +546,8 @@ impl Session {
}
let Some(data) = doc.images.get(&img.image) else { continue };
let (iw, ih) = (f64::from(data.width), f64::from(data.height));
let crop = if img.scale_mode == ImageScaleMode::Crop {
img.effective_crop(iw, ih, w, h)
} else {
let (rw, rh) = img.rotated_size(iw.max(1.0), ih.max(1.0));
let s = if img.scale_mode == ImageScaleMode::Fit { (w / rw).min(h / rh) } else { (w / rw).max(h / rh) };
[w / (rw * s), 0.0, 0.0, h / (rh * s), (rw * s - w) / (2.0 * rw * s), (rh * s - h) / (2.0 * rh * s)]
};
// A degenerate (zero-scale) layer has no inverse: its image keeps its placement.
let crop = (Affine::new(crop) * to_old).as_coeffs();
let crop = (Affine::new(img.placement_crop(iw, ih, w, h)) * to_old).as_coeffs();
if !crop.iter().all(|c| c.is_finite()) {
continue;
}
+4
View File
@@ -100,6 +100,10 @@ impl Session {
}
pub(super) fn up(&mut self, ev: &PointerEvent) -> Result<()> {
// Alt went down or up since the last move: the drop is a copy or a move as the release has it.
if matches!(&self.tool.gesture, Gesture::Move { moved: true, duplicate, .. } if *duplicate != ev.alt) {
self.drag(ev)?;
}
let g = std::mem::take(&mut self.tool.gesture);
self.tool.overlay.marquee = None;
self.tool.overlay.guides.clear();
@@ -257,6 +257,9 @@ impl Session {
}
}
self.tool.overlay.guides = guides;
if tool == ToolId::Connector {
self.connector_hover(ev);
}
let ds = self.ds_mut()?;
if ds.hover != hover {
ds.hover = hover;
+16 -2
View File
@@ -1219,17 +1219,31 @@ pub(crate) fn cut_across(s: &mut Session, a: Point, b: Point) -> Result<Vec<Node
/// under it, the others get the same (points outside closed areas are skipped). A vector without
/// fills gets the default fill so the painted area shows.
pub fn paint_all(doc: &mut Document, id: NodeId, pts: &[Point]) -> Result<bool> {
let inherited = doc.get(id)?.fills.clone();
let filled = edit(doc, id, |net| {
let mut want = None;
for p in pts {
want = net.paint_to(*p, want).or(want);
want = net.paint_to_with(*p, want, Some(&inherited)).or(want);
}
want
})?
.ok_or_else(|| bad("vector.edit.paint", "click inside a closed area"))?;
let n = doc.get_mut(id)?;
if filled && n.fills.iter().all(|f| !f.visible) {
n.fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::DEFAULT_SHAPE)];
let default = figcraft_color::Paint::solid(figcraft_color::Color::DEFAULT_SHAPE);
let targets: std::collections::BTreeSet<usize> = match &n.kind {
NodeKind::Vector { network } => pts.iter().filter_map(|point| network.region_at(*point)).collect(),
_ => std::collections::BTreeSet::new(),
};
if let NodeKind::Vector { network } = &mut n.kind {
for (index, region) in network.regions.iter_mut().enumerate() {
if region.fills.is_none() && !targets.contains(&index) {
// Freeze an untouched inherited region before adding a node-wide default.
region.fills = Some(Vec::new());
}
}
}
n.fills = vec![default];
}
Ok(filled)
}
+29 -8
View File
@@ -19,15 +19,21 @@ pub fn move_vertices(net: &mut VectorNetwork, idx: &[usize], d: Vec2) {
if !finite(d) {
return;
}
let mut seen = std::collections::BTreeSet::new();
for i in idx {
if seen.insert(*i)
&& let Some(v) = net.vertices.get_mut(*i)
{
let seen: std::collections::BTreeSet<usize> = idx.iter().copied().filter(|i| *i < net.vertices.len()).collect();
for i in &seen {
if let Some(v) = net.vertices.get_mut(*i) {
v.x += d.x;
v.y += d.y;
}
}
if seen.len() == net.vertices.len() {
let translate = Affine::translate(d);
for region in &mut net.regions {
if let Some(frame) = &mut region.paint_space {
frame.transform = translate * frame.transform;
}
}
}
}
/// Map vertices `idx` through `m` (scale, rotate, flip…); the handles at them follow its linear
@@ -65,6 +71,13 @@ pub fn transform_vertices(net: &mut VectorNetwork, idx: &[usize], m: Affine) {
}
}
}
if set.len() == net.vertices.len() {
for region in &mut net.regions {
if let Some(frame) = &mut region.paint_space {
frame.transform = m * frame.transform;
}
}
}
}
/// Set a handle to `rel` (relative to its vertex). The vertex's other handle follows its mirroring
@@ -586,7 +599,7 @@ pub fn part(net: &VectorNetwork, verts: &[usize]) -> VectorNetwork {
let loops: Vec<Vec<usize>> =
r.loops.iter().map(|l| l.iter().filter_map(|i| smap.get(*i).copied().flatten()).collect::<Vec<_>>()).filter(|l| !l.is_empty()).collect();
if !loops.is_empty() {
out.regions.push(Region { loops, winding: r.winding });
out.regions.push(Region { loops, ..r.clone() });
}
}
reopen_loops(&mut out);
@@ -648,7 +661,7 @@ pub fn connect(net: &mut VectorNetwork, a: usize, b: usize, ta: (f64, f64), tb:
let unfilled = !net.regions.iter().any(|r| r.loops.iter().flatten().any(|s| lp.contains(s)));
if unfilled {
lp.insert(0, si);
net.regions.push(Region { loops: vec![lp], winding: WindingRule::NonZero });
net.regions.push(Region { loops: vec![lp], winding: WindingRule::NonZero, fills: None, ..Default::default() });
}
}
Some(si)
@@ -862,6 +875,14 @@ mod tests {
VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(50.0, 0.0), Point::new(100.0, 0.0)], false)
}
#[test]
fn split_part_keeps_region_paint_metadata() {
let mut n = square();
n.regions[0].fills = Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]);
let part = part(&n, &[0, 1, 2, 3]);
assert_eq!(part.regions[0].fills, n.regions[0].fills);
}
#[test]
fn handles_follow_mirroring() {
let mut n = open_line();
@@ -899,7 +920,7 @@ mod tests {
// Triangle closed 0→2: its loop [2, 1, 0] walks segment 1 backwards.
let mut n = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0)], false);
n.segments.push(Segment::line(0, 2));
n.regions.push(Region { loops: vec![vec![2, 1, 0]], winding: WindingRule::NonZero });
n.regions.push(Region { loops: vec![vec![2, 1, 0]], winding: WindingRule::NonZero, fills: None, ..Default::default() });
let p = Point::new(25.0, 10.0);
assert!(n.fill_contains(p));
for (seg, lp) in [(1, vec![2, 1, 0]), (2, vec![2, 1, 0]), (1, vec![0, 1, 2])] {
@@ -52,9 +52,8 @@ pub fn hatch_spacing(zoom: f64) -> Option<f64> {
(s.is_finite() && s > 0.0).then_some(s)
}
/// Whether vector `n` shows a fill (only filled areas are regions).
fn filled(n: &figcraft_doc::Node) -> bool {
n.fills.iter().any(|f| f.visible)
fn region_filled(n: &figcraft_doc::Node, net: &VectorNetwork, i: usize) -> bool {
net.regions.get(i).is_some_and(|r| r.fills.as_deref().unwrap_or(&n.fills).iter().any(|f| f.visible))
}
/// The network as its fill draws it: rounded points drawn in (region indexes are kept).
@@ -73,7 +72,8 @@ pub fn region_hit(doc: &Document, ve: &VectorEdit, p: Point, zoom: f64) -> Optio
return None;
}
let net = network(doc, ve.id)?;
if net.regions.is_empty() || !doc.node(ve.id).is_some_and(filled) {
let node = doc.node(ve.id)?;
if net.regions.is_empty() || !net.regions.iter().enumerate().any(|(i, _)| region_filled(node, net, i)) {
return None;
}
let w = doc.world_transform(ve.id);
@@ -90,7 +90,8 @@ pub fn region_hit(doc: &Document, ve: &VectorEdit, p: Point, zoom: f64) -> Optio
if (0..net.segments.len()).filter_map(|i| net.cubic(i)).any(|c| c.nearest(lp, 0.01).distance_sq.sqrt() <= sc) {
return None;
}
drawn(net).region_at(lp)
let drawn = drawn(net);
(0..drawn.regions.len()).rev().find(|i| region_filled(node, &drawn, *i) && drawn.region_contains(*i, lp))
}
/// The regions to hatch: the selected ones and the hovered one (not while a drag is on), each once.
@@ -31,7 +31,7 @@ fn nested(regions: Vec<Region>) -> VectorNetwork {
}
fn region(loops: Vec<Vec<usize>>, winding: WindingRule) -> Region {
Region { loops, winding }
Region { loops, winding, fills: None, ..Default::default() }
}
/// The 7.1 region: both squares as one region.
@@ -118,6 +118,17 @@ fn only_filled_areas_are_regions() {
assert!(ve(&s).regions.is_empty());
}
#[test]
fn styled_region_hit_ignores_empty_override_and_needs_no_layer_fill() {
let mut n = overlapping();
n.regions[0].fills = Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]);
n.regions[1].fills = Some(Vec::new());
let mut s = Session::new();
let id = editing_vector(&mut s, n);
s.doc_mut().unwrap().get_mut(id).unwrap().fills.clear();
assert_eq!(hover(&mut s, 100.0, 100.0, 1.0), Some(0));
}
#[test]
fn hover_and_press_select_a_region_with_all_its_points() {
// 20.
+1 -1
View File
@@ -244,7 +244,7 @@ pub(crate) fn specs() -> Vec<CommandSpec> {
vec![spec!(
"vector.edit.widthPoint",
"Width point",
"{action?: add|set|remove, index?, t? (0-1 along the path), width? (px) | scale? (× stroke weight)} — add needs t (width defaults to the current one); set moves or resizes point `index`; with `side` only that side of the path changes (looking along it): `width` is then the distance from the centre line to that edge and `scale` that side's scale (× stroke weight, as if both sides were that wide)",
"{action?: add|set|remove, index?, t? (0-1 along the path), width? (px) | scale? (× stroke weight), side? (left|right)} — add needs t (width defaults to the current one); set moves or resizes point `index`; with `side` only that side of the path changes (looking along it): `width` is then the distance from the centre line to that edge and `scale` that side's scale (× stroke weight, as if both sides were that wide)",
true,
width_point
)]
+3 -1
View File
@@ -151,6 +151,8 @@ pub static MENU: &[MenuItem] = &[
m!("Object", "Use as mask", "object.mask"),
m!("Object", "Release mask", "object.releaseMask"),
m!("Object", "Set as thumbnail", "object.setThumbnail"),
// FigCraft's own (in `OWN`): the way into crop mode that the Design panel also offers.
m!("Object", "Crop image", "image.crop"),
m!("Object", SEP, ""),
m!("Object", "Bring to front", "arrange.front"),
m!("Object", "Bring forward", "arrange.forward"),
@@ -290,7 +292,7 @@ pub fn items() -> impl Iterator<Item = &'static MenuItem> {
}
/// Items of FigCraft's own that the parity count leaves out (as it does the `collab.` ones).
const OWN: [&str; 4] = ["file.newFromTemplate", "file.newBoard", "help.gettingStarted", "text.missingFonts"];
const OWN: [&str; 5] = ["file.newFromTemplate", "file.newBoard", "help.gettingStarted", "text.missingFonts", "image.crop"];
/// Coverage: (implemented, total, missing ids). `extra` lists ids implemented by the UI layer.
pub fn parity(extra: &[&str]) -> (usize, usize, Vec<&'static str>) {
+111 -1
View File
@@ -63,6 +63,8 @@ macro_rules! spec {
struct Registry {
list: Vec<CommandSpec>,
index: HashMap<&'static str, usize>,
/// The parameter names each command's doc lists (`list` order; see [`doc_param_names`]).
names: Vec<Option<Vec<&'static str>>>,
}
fn registry() -> &'static Registry {
@@ -123,7 +125,8 @@ fn registry() -> &'static Registry {
log::warn!("duplicate command id {}", s.id);
}
}
Registry { list, index }
let names = list.iter().map(|s| doc_param_names(s.params)).collect();
Registry { list, index, names }
})
}
@@ -136,6 +139,113 @@ pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
r.index.get(id).and_then(|i| r.list.get(*i))
}
/// The parameter names command `id` takes, from its doc; `None` when the doc isn't a `{…}` list
/// (`node.set` documents its props, and refuses the ones it doesn't know itself).
pub fn param_names(id: &str) -> Option<&'static [&'static str]> {
let r = registry();
r.index.get(id).and_then(|i| r.names.get(*i)).and_then(|n| n.as_deref())
}
/// Refuse params naming something the command doesn't take: a misspelt name would otherwise be
/// ignored and the command answer as if it hadn't been given. `ids` and `id` are always taken:
/// the layers a command acts on (`params::targets` reads either).
pub fn refuse_unknown_params(id: &str, params: &Value) -> Result<()> {
let (Some(names), Some(given)) = (param_names(id), params.as_object()) else { return Ok(()) };
let taken = |k: &str| matches!(k, "ids" | "id") || names.contains(&k);
let unknown: Vec<String> = given.keys().filter(|k| !taken(k)).map(|k| format!("`{k}`")).collect();
if unknown.is_empty() {
return Ok(());
}
let s = if unknown.len() == 1 { "" } else { "s" };
let takes = if names.is_empty() { "none".to_string() } else { names.join(", ") };
Err(crate::bad(id, format!("unknown parameter{s} {} (it takes {takes})", unknown.join(", "))))
}
/// The top-level items of a `{…}` params doc (`{ids?, direction? (horizontal|vertical)}` →
/// `ids?`, ` direction? (horizontal|vertical)`), with those of the alternatives after it
/// (`{x, y} | {points}`); `None` when the doc doesn't start with one.
pub(crate) fn doc_items(doc: &'static str) -> Option<Vec<&'static str>> {
let mut body = doc.trim_start().strip_prefix('{')?;
let (mut items, mut depth, mut start) = (Vec::new(), 0usize, 0);
loop {
let mut rest = None;
for (i, c) in body.char_indices() {
match c {
'{' | '[' | '(' => depth += 1,
'}' | ']' | ')' if depth > 0 => depth -= 1,
'}' | ',' if depth == 0 => {
items.push(body.get(start..i).unwrap_or_default());
start = i + c.len_utf8();
if c == '}' {
rest = body.get(start..);
break;
}
}
_ => {}
}
}
let rest = rest?;
match rest.trim_start().strip_prefix('|').and_then(|r| r.trim_start().strip_prefix('{')) {
Some(next) => (body, start) = (next, 0),
None => return Some(items),
}
}
}
/// The names one doc item gives: its first word and the words joined to it by `|`, `/` or `+`
/// (`id?/ids?`, `path | data (base64) + name`, `value? + newValue?`), without the `?` of optional
/// ones. Bracketed text describes a name, and what follows a `:`, `=` or `→` its value.
pub(crate) fn item_names(item: &'static str) -> Vec<&'static str> {
let mut outside = Vec::new();
let (mut depth, mut start) = (0usize, None);
for (i, c) in item.char_indices() {
let value_follows = depth == 0 && matches!(c, ':' | '=' | '→');
let in_word = depth == 0 && !value_follows && !c.is_whitespace() && !matches!(c, '(' | '[' | '{' | ')' | ']' | '}');
match c {
'(' | '[' | '{' => depth += 1,
')' | ']' | '}' => depth = depth.saturating_sub(1),
_ => {}
}
match (in_word, start) {
(true, None) => start = Some(i),
(false, Some(s)) => {
outside.push(item.get(s..i).unwrap_or_default());
start = None;
}
_ => {}
}
if value_follows {
break;
}
}
if let Some(s) = start {
outside.push(item.get(s..).unwrap_or_default());
}
let is_name =
|n: &&str| n.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_') && n.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
let mut words = outside.into_iter();
let (mut names, mut word) = (Vec::new(), words.next());
while let Some(w) = word {
names.extend(w.split(['/', '|']).map(|n| n.trim_end_matches('?')).filter(is_name));
word = match words.next() {
Some("|" | "/" | "+") => words.next(),
_ => None,
};
}
names
}
/// Every name a `{…}` params doc lists, once each (see [`item_names`]).
fn doc_param_names(doc: &'static str) -> Option<Vec<&'static str>> {
let mut names = Vec::new();
for n in doc_items(doc)?.into_iter().flat_map(item_names) {
if !names.contains(&n) {
names.push(n);
}
}
Some(names)
}
/// `{"ok": true}` shorthand.
pub fn ok() -> Result<Value> {
Ok(serde_json::json!({"ok": true}))
+94 -41
View File
@@ -1,12 +1,13 @@
//! Comments: add, reply, edit, react, resolve, read state, delete, find and reveal comment threads.
//!
//! Messages are addressed by `reply` (an index into the replies; omitted: the thread's first
//! message). `author` is who is acting (default: the name from Preferences, `pref.userName`): only
//! a message's author edits or deletes it, and only the thread's author deletes the thread.
//! message). `author` is who is acting (default: the AI agent the command runs for, else the name
//! from Preferences, `pref.userName`): only a message's author edits or deletes it, and only the
//! thread's author deletes the thread.
use std::fmt::Write as _;
use figcraft_doc::{Comment, Document, Message, Node, NodeId, NodeKind, geom};
use figcraft_doc::{Comment, Document, Message, Node, NodeId, NodeKind, geom, mentions};
use serde_json::{Value, json};
use super::{CommandSpec, ok};
@@ -16,6 +17,12 @@ use crate::{Session, bad, spec};
/// The name comments are signed with until the user sets theirs.
pub const DEFAULT_AUTHOR: &str = "You";
/// The name of an AI agent that gave none.
pub const DEFAULT_AGENT: &str = "AI agent";
/// Most characters of an agent's own name in its comments.
const MAX_AGENT_NAME: usize = 64;
/// Links to a thread: `figcraft://comment/<id>`.
const LINK_PREFIX: &str = "figcraft://comment/";
@@ -260,9 +267,27 @@ pub fn user_name(s: &Session) -> String {
if n.is_empty() { DEFAULT_AUTHOR.into() } else { n.to_string() }
}
/// Who is acting: `author` from the params, else the user.
/// How an AI agent's comments are signed: its client's name, marked so it never reads as a
/// person's ("Claude" → "Claude (agent)"), or [`DEFAULT_AGENT`] when it gave none.
pub fn agent_author(name: &str) -> String {
let n: String = name.chars().filter(|c| !c.is_control()).collect();
let n: String = n.trim().chars().take(MAX_AGENT_NAME).collect();
if n.is_empty() { DEFAULT_AGENT.into() } else { format!("{n} (agent)") }
}
/// Who acts when no `author` is given: the AI agent the command runs for, else the user.
pub fn acting_name(s: &Session) -> String {
s.agent.clone().unwrap_or_else(|| user_name(s))
}
/// Who is acting: `author` from the params, else [`acting_name`].
fn author(s: &Session, p: &Value) -> String {
string(p, "author").map(|a| a.trim().to_string()).filter(|a| !a.is_empty()).unwrap_or_else(|| user_name(s))
string(p, "author").map(|a| a.trim().to_string()).filter(|a| !a.is_empty()).unwrap_or_else(|| acting_name(s))
}
/// A new message by `who`, marked as an agent's when the command runs for one.
fn new_message(s: &Session, who: String, text: String) -> Message {
Message { agent: s.agent.is_some(), ..Message::new(who, text, now_secs()) }
}
fn text(cmd: &str, p: &Value) -> crate::Result<String> {
@@ -301,25 +326,42 @@ fn local(doc: &Document, anchor: Option<NodeId>, x: f64, y: f64) -> (f64, f64) {
}
}
fn message_json(m: &Message) -> Value {
/// The names an @mention resolves to: the file's people (`Document::comment_people`) and `user`,
/// as the thread view highlights them.
fn mentionable<'a>(people: &'a [String], user: &'a str) -> Vec<&'a str> {
people.iter().map(String::as_str).chain([user]).collect()
}
/// A message as JSON, with the names it @mentions: `{name, known, you}` each, resolved against
/// `people` (`mentionable`); `known: false` is a name nobody in the file has.
fn message_json(m: &Message, people: &[&str], user: &str) -> Value {
let mut named: Vec<Value> = Vec::new();
for (r, who) in mentions(&m.text, people) {
let name = who.or_else(|| m.text.get(r.start + 1..r.end)).unwrap_or_default();
let v = json!({"name": name, "known": who.is_some(), "you": who.is_some_and(|w| w.eq_ignore_ascii_case(user))});
if !named.contains(&v) {
named.push(v);
}
}
json!({
"author": m.author, "text": m.text, "created": m.created, "edited": m.edited,
"author": m.author, "agent": m.agent, "text": m.text, "created": m.created, "edited": m.edited,
"reactions": m.reactions.iter().map(|r| json!({"emoji": r.emoji, "users": r.users})).collect::<Vec<_>>(),
"mentions": named,
})
}
/// JSON view of a comment as `user` sees it: its page position, frame, read state and link, the
/// first message's fields, and the replies.
pub fn comment_json(doc: &Document, c: &Comment, user: &str) -> Value {
/// first message's fields, and the replies; mentions resolve against `people` (`mentionable`).
pub fn comment_json(doc: &Document, c: &Comment, user: &str, people: &[&str]) -> Value {
let (x, y) = c.position(doc);
let frame = frame_of(doc, c);
let mut v = message_json(&c.first);
let mut v = message_json(&c.first, people, user);
let extra = json!({
"id": c.id, "page": c.page, "pageName": doc.node(c.page).map(|n| n.name.as_str()),
"x": x, "y": y, "anchor": c.anchor,
"frame": frame, "frameName": frame.and_then(|f| doc.node(f)).map(|n| n.name.as_str()),
"resolved": c.resolved, "unread": c.is_unread(user), "link": link(c.id),
"replies": c.replies.iter().map(message_json).collect::<Vec<_>>(),
"replies": c.replies.iter().map(|m| message_json(m, people, user)).collect::<Vec<_>>(),
});
if let (Some(m), Value::Object(e)) = (v.as_object_mut(), extra) {
m.extend(e);
@@ -338,7 +380,7 @@ fn comment_brief_json(doc: &Document, c: &Comment, user: &str) -> Value {
"x": x, "y": y, "anchor": c.anchor,
"frame": frame, "frameName": frame.and_then(|f| doc.node(f)).map(|n| clip_chars(&n.name, 80)),
"resolved": c.resolved, "unread": c.is_unread(user), "link": link(c.id),
"author": clip_chars(&c.first.author, 80), "preview": clip_chars(&c.first.text, 160),
"author": clip_chars(&c.first.author, 80), "agent": c.first.agent, "preview": clip_chars(&c.first.text, 160),
"replies": c.replies.len(),
})
}
@@ -385,7 +427,8 @@ fn open(s: &mut Session, id: u64, how: &str, who: &str) -> crate::Result<Value>
s.request_ui("comment.open", json!({"id": id}));
let doc = s.doc()?;
let c = doc.comment(id).ok_or_else(|| bad("comment.open", "no comment"))?;
Ok(comment_json(doc, c, who))
let people = doc.comment_people();
Ok(comment_json(doc, c, who, &mentionable(&people, who)))
}
/// A list's page (None: every page), filter, sort and search text from params.
@@ -565,7 +608,7 @@ pub fn specs() -> Vec<CommandSpec> {
x: lx,
y: ly,
anchor,
first: Message::new(who.clone(), text, now_secs()),
first: new_message(s, who.clone(), text),
resolved: false,
replies: Vec::new(),
read_by: None,
@@ -576,17 +619,17 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"id": id, "link": link(id)}))
}
),
spec!("comment.reply", "Reply to comment", "{id, text, author?}", true, |s, p| {
spec!("comment.reply", "Reply to comment", "{id | link, text, author?}", true, |s, p| {
let id = req_comment("comment.reply", s, p)?;
let who = author(s, p);
let r = Message::new(who.clone(), text("comment.reply", p)?, now_secs());
let r = new_message(s, who.clone(), text("comment.reply", p)?);
let c = s.doc_mut()?.comment_mut(id).ok_or_else(|| bad("comment.reply", "no comment"))?;
c.replies.push(r);
c.resolved = false;
c.touched_by(&who);
Ok(json!({"reply": c.replies.len() - 1}))
}),
spec!("comment.edit", "Edit comment", "{id, text, reply? (index; omit for the first message), author?}", true, |s, p| {
spec!("comment.edit", "Edit comment", "{id | link, text, reply? (index; omit for the first message), author?}", true, |s, p| {
let id = req_comment("comment.edit", s, p)?;
let t = text("comment.edit", p)?;
let who = author(s, p);
@@ -607,7 +650,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"comment.react",
"React to comment",
"{id, emoji, reply? (index; omit for the first message), on? (default: toggle), author?}",
"{id | link, emoji, reply? (index; omit for the first message), on? (default: toggle), author?}",
true,
|s, p| {
let id = req_comment("comment.react", s, p)?;
@@ -629,7 +672,7 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"on": on}))
}
),
spec!("comment.resolve", "Resolve comment", "{id, resolved? (default true)}", true, |s, p| {
spec!("comment.resolve", "Resolve comment", "{id | link, resolved? (default true)}", true, |s, p| {
let id = req_comment("comment.resolve", s, p)?;
let v = boolean(p, "resolved").unwrap_or(true);
if let Some(c) = s.doc_mut()?.comment_mut(id) {
@@ -640,7 +683,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"comment.move",
"Move comment",
"{id, x, y, anchor? (default true: pin to the innermost frame under the point; false: to the page; or a node id)}",
"{id | link, x, y, anchor? (default true: pin to the innermost frame under the point; false: to the page; or a node id)}",
true,
|s, p| {
let id = req_comment("comment.move", s, p)?;
@@ -655,24 +698,30 @@ pub fn specs() -> Vec<CommandSpec> {
ok()
}
),
spec!("comment.delete", "Delete comment", "{id, reply? (index: delete one reply; omit to delete the thread), author?}", true, |s, p| {
let id = req_comment("comment.delete", s, p)?;
let who = author(s, p);
let doc = s.doc_mut()?;
let c = doc.comment_mut(id).ok_or_else(|| bad("comment.delete", "no comment"))?;
match message_index("comment.delete", c, p)? {
0 => {
own("comment.delete", &c.first, &who, "delete this thread")?;
doc.comments.retain(|c| c.id != id);
}
i => {
let m = c.message(i).ok_or_else(|| bad("comment.delete", "no message"))?;
own("comment.delete", m, &who, "delete this reply")?;
c.replies.remove(i - 1);
spec!(
"comment.delete",
"Delete comment",
"{id | link, reply? (index: delete one reply; omit to delete the thread), author?}",
true,
|s, p| {
let id = req_comment("comment.delete", s, p)?;
let who = author(s, p);
let doc = s.doc_mut()?;
let c = doc.comment_mut(id).ok_or_else(|| bad("comment.delete", "no comment"))?;
match message_index("comment.delete", c, p)? {
0 => {
own("comment.delete", &c.first, &who, "delete this thread")?;
doc.comments.retain(|c| c.id != id);
}
i => {
let m = c.message(i).ok_or_else(|| bad("comment.delete", "no message"))?;
own("comment.delete", m, &who, "delete this reply")?;
c.replies.remove(i - 1);
}
}
ok()
}
ok()
}),
),
spec!(
"comment.markRead",
"Mark comment as read",
@@ -711,7 +760,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"comment.next",
"Next comment",
"{id? (the thread to step from; omitted: the first or last), dir? (1 next, -1 previous; default 1), page?, filter?, sort?, query? (the list, as comment.list), author?}",
"{id? (the thread to step from; omitted: the first or last), dir? (1 next, -1 previous; default 1), page?, filter?, sort?, query?, frame?, resolved? (the list, as comment.list), author?}",
false,
|s, p| {
let dir = num(p, "dir").unwrap_or(1.0);
@@ -748,11 +797,13 @@ pub fn specs() -> Vec<CommandSpec> {
}
Ok(json!({"zoom": v.zoom}))
}),
spec!("comment.link", "Copy link to comment", "{id}", false, |s, p| {
spec!("comment.link", "Copy link to comment", "{id | link}", false, |s, p| {
let id = req_comment("comment.link", s, p)?;
Ok(json!({"link": link(id)}))
}),
spec!("comment.people", "People in comments", "{}", false, |s, _| { Ok(json!({"people": s.doc()?.comment_people(), "you": user_name(s)})) }),
spec!("comment.people", "People in comments", "{}", false, |s, _| {
Ok(json!({"people": s.doc()?.comment_people(), "you": acting_name(s)}))
}),
spec!(
"comment.list",
"List comments",
@@ -763,7 +814,9 @@ pub fn specs() -> Vec<CommandSpec> {
let (page, filter, sort, text) = list_params("comment.list", s, p)?;
let who = author(s, p);
let q = Query { page, frame: id(p, "frame"), filter, sort, text: &text };
let list: Vec<Value> = query(doc, &q, &who).into_iter().map(|c| comment_json(doc, c, &who)).collect();
let people = doc.comment_people();
let people = mentionable(&people, &who);
let list: Vec<Value> = query(doc, &q, &who).into_iter().map(|c| comment_json(doc, c, &who, &people)).collect();
Ok(json!({"comments": list}))
}
),
@@ -811,7 +864,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
Some(_) => return Err(bad("comment.prompt", "`ids` must be an array of comment ids")),
};
let who = user_name(s);
let who = acting_name(s);
let selected: Vec<&Comment> = if let Some(ids) = ids {
let mut out = Vec::with_capacity(ids.len());
for id in ids {
+2 -2
View File
@@ -292,8 +292,8 @@ pub(crate) fn place(s: &mut Session, p: &Value) -> Result<Value> {
let doc = s.doc_mut()?;
let c = main_of(doc, c).ok_or_else(|| bad(cmd, "`component` must be a component or component set"))?;
let mut slot = crate::drop::slot(doc, page, pt);
// Never inside its own main component (or a copy of it).
if doc.is_ancestor(c, slot.parent) || slot.parent == c {
// Never inside its own main component, or one it contains (directly or through others).
if crate::components::would_contain_itself(doc, c, slot.parent) {
slot = crate::drop::DropSlot { parent: page, index: None, line: None };
}
let (w, h) = doc.node(c).map(|n| (n.width, n.height)).unwrap_or((0.0, 0.0));
+2 -2
View File
@@ -376,8 +376,8 @@ pub(super) fn swap(s: &mut Session, p: &Value) -> Result<Value> {
/// Make instance `i` show main component `c`, keeping the values of properties with the same names
/// (the layers' overrides follow by name when the instance is rebuilt).
pub(crate) fn swap_instance(doc: &mut Document, i: NodeId, c: NodeId) -> std::result::Result<(), String> {
if doc.is_ancestor(c, i) {
return Err("an instance can't show the component it is inside".into());
if crate::components::would_contain_itself(doc, c, i) {
return Err(crate::components::contains_itself_error(doc, c));
}
let defaults = doc.node(c).map(default_props).unwrap_or_default();
match doc.get_mut(i).map(|n| &mut n.kind) {
+42 -17
View File
@@ -9,6 +9,8 @@ use super::{CommandSpec, ok};
use crate::params::{boolean, f64_of, get, id, num, num_or, opt_id, req_targets, string};
use crate::{Result, Session, bad, spec};
pub const PLACED_IMAGE_MAX_SIDE: f64 = 4096.0;
/// Frame presets shown by the Frame tool (category, name, width, height).
pub const FRAME_PRESETS: &[(&str, &str, f64, f64)] = &[
("Phone", "iPhone 16", 393.0, 852.0),
@@ -53,37 +55,61 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"node.create",
"Create layer",
"{type: rectangle|ellipse|polygon|star|line|arrow|vector|text|frame|section|component|slice, x?, y? (relative to the parent frame; groups don't count), width?, height?, parent? (id; default: the page), index?, name?, text? (for text), path? (SVG path data for vector), props? {node.set keys}, select? (default true)}",
"{type: rectangle|ellipse|polygon|star|line|arrow|vector|text|frame|section|component|slice, x?, y? (relative to the parent frame; groups don't count), width?, height?, parent? (id; default: the page), index?, name?, text? | characters? (for text), path? | points? (vector: SVG path data | [[x,y],..]), closed? (vector), props? {node.set keys}, select? (default true)}",
true,
create
),
spec!(
"frame.create",
"Create frame",
"{preset? (name from FRAME_PRESETS), x?, y?, width?, height?, parent?, name?, props?} (a preset without x/y lands to the right of the page's frames)",
"{preset? (name from FRAME_PRESETS), x?, y?, width?, height?, parent?, index?, name?, props?, select? (default true)} (a preset without x/y lands to the right of the page's frames)",
true,
frame_create
),
spec!("shape.rectangle", "Rectangle", "{x, y, width, height, parent?, props?}", true, |s, p| create_kind(s, p, "rectangle")),
spec!("shape.ellipse", "Ellipse", "{x, y, width, height, parent?, props?}", true, |s, p| create_kind(s, p, "ellipse")),
spec!("shape.polygon", "Polygon", "{x, y, width, height, parent?, props? {pointCount}}", true, |s, p| create_kind(s, p, "polygon")),
spec!("shape.star", "Star", "{x, y, width, height, parent?, props? {pointCount, innerRadius}}", true, |s, p| create_kind(s, p, "star")),
spec!("shape.line", "Line", "{x, y, width (length), parent?, props? {rotation}}", true, |s, p| create_kind(s, p, "line")),
spec!("shape.arrow", "Arrow", "{x, y, width (length), parent?, props?}", true, |s, p| create_kind(s, p, "arrow")),
spec!("text.create", "Text", "{text, x, y, parent?, width? (fixed width wraps), props? (fontSize, fontFamily, …)}", true, |s, p| {
create_kind(s, p, "text")
spec!("shape.rectangle", "Rectangle", "{x, y, width, height, parent?, index?, name?, props?, select? (default true)}", true, |s, p| {
create_kind(s, p, "rectangle")
}),
spec!("shape.ellipse", "Ellipse", "{x, y, width, height, parent?, index?, name?, props?, select? (default true)}", true, |s, p| create_kind(
s, p, "ellipse"
)),
spec!(
"shape.polygon",
"Polygon",
"{x, y, width, height, parent?, index?, name?, props? {pointCount}, select? (default true)}",
true,
|s, p| create_kind(s, p, "polygon")
),
spec!(
"shape.star",
"Star",
"{x, y, width, height, parent?, index?, name?, props? {pointCount, innerRadius}, select? (default true)}",
true,
|s, p| create_kind(s, p, "star")
),
spec!("shape.line", "Line", "{x, y, width (length), parent?, index?, name?, props? {rotation}, select? (default true)}", true, |s, p| {
create_kind(s, p, "line")
}),
spec!("shape.arrow", "Arrow", "{x, y, width (length), parent?, index?, name?, props?, select? (default true)}", true, |s, p| create_kind(
s, p, "arrow"
)),
spec!(
"text.create",
"Text",
"{text | characters, x, y, parent?, index?, name? (default: the first line), width? (fixed width wraps), props? (fontSize, fontFamily, …), select? (default true)}",
true,
|s, p| { create_kind(s, p, "text") }
),
spec!(
"vector.create",
"Vector",
"{path (SVG path data) | points [[x,y],..], closed?, x?, y?, width?, height? (scale the path; one keeps its proportion), parent?, props?}",
"{path (SVG path data) | points [[x,y],..], closed?, x?, y?, width?, height? (scale the path; one keeps its proportion), parent?, index?, name?, props?, select? (default true)}",
true,
|s, p| create_kind(s, p, "vector")
),
spec!(
"image.place",
"Place image",
"{data (base64) | path, x?, y?, width?, height?, parent?, target? (an existing layer: the image is added on top of its fills instead of making a layer), mode? (fill|fit|crop|tile)} → {id, image, width, height, imageProfile (its embedded colour profile; null: taken as sRGB)}",
"{data (base64) | path, x?, y?, width?, height?, parent?, name? (default Image), target? (an existing layer: the image is added on top of its fills instead of making a layer), mode? (fill|fit|crop|tile)} → {id, image, width, height, imageProfile (its embedded colour profile; null: taken as sRGB)}",
true,
place_image
),
@@ -443,13 +469,12 @@ pub fn place_image(s: &mut Session, p: &Value) -> Result<Value> {
let parent = crate::params::opt_id(cmd, p, "parent")?.unwrap_or(page);
let ds = s.ds_mut()?;
let doc = ds.doc_mut();
let density = figcraft_format::pixel_density(&bytes);
let (hash, iw, ih) = figcraft_format::add_image(doc, bytes).map_err(|e| bad(cmd, e.to_string()))?;
let profile = figcraft_render::images::profile_name(&hash, doc.images.get(&hash));
let (iw, ih) = (f64::from(iw), f64::from(ih));
// Fit large images into 1000 px like Figma does when placing.
let k = (1000.0 / iw.max(ih)).min(1.0);
let w = num(p, "width").unwrap_or(iw * k);
let h = num(p, "height").unwrap_or(if num(p, "width").is_some() { w * ih / iw.max(1.0) } else { ih * k });
let (placed_w, placed_h) = figcraft_format::placed_size(iw, ih, density, PLACED_IMAGE_MAX_SIDE);
let w = num(p, "width").unwrap_or(placed_w);
let h = num(p, "height").unwrap_or(if num(p, "width").is_some() { w * f64::from(ih) / f64::from(iw).max(1.0) } else { placed_h });
let nid = doc.alloc_id();
let name = string(p, "name").unwrap_or_else(|| "Image".into());
let mut node = Node::new(nid, NodeKind::Rectangle, name, w, h);
+103 -9
View File
@@ -8,8 +8,8 @@ use serde_json::{Map, Value, json};
use super::{CommandSpec, ok};
use crate::params::{
blend, boolean, color, fraction_of, get, id, id_of, num, num_or, opt_index, paint_of, parse, req_id, req_index, req_num, req_string, req_targets,
string, targets,
blend, boolean, color, fraction_of, get, id, id_of, num, num_or, opt_id, opt_index, paint_of, parse, req_id, req_index, req_num, req_string,
req_targets, string, targets,
};
use crate::{BatchStep, EngineError, Result, Session, bad, spec};
@@ -29,6 +29,80 @@ use select::{
select_parent, select_same, select_sibling, toggle,
};
/// The layer types `node.find` takes, as `NodeKind::type_name` spells them.
const LAYER_TYPES: [&str; 16] = [
"PAGE",
"FRAME",
"GROUP",
"SECTION",
"RECTANGLE",
"ELLIPSE",
"POLYGON",
"STAR",
"LINE",
"VECTOR",
"TEXT",
"BOOLEAN_OPERATION",
"COMPONENT",
"COMPONENT_SET",
"INSTANCE",
"SLICE",
];
/// Lowercase for `node.find`'s substring filters. Greek final sigma becomes σ on both sides, so a
/// query lowercased on its own (`Σ` to `σ`) still matches a name ending in it (`ΟΣ` to `ος`).
fn fold(s: &str) -> String {
s.to_lowercase().replace('ς', "σ")
}
fn node_find(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "node.find";
let filter = |key| -> Result<Option<String>> {
get(p, key).map(|v| v.as_str().map(fold).ok_or_else(|| bad(cmd, format!("`{key}` must be a string")))).transpose()
};
let name = filter("name")?;
let text = filter("text")?;
let component = filter("component")?;
let kind = filter("type")?;
if let Some(kind) = &kind
&& !LAYER_TYPES.iter().any(|t| t.eq_ignore_ascii_case(kind))
{
return Err(bad(cmd, format!("unknown type; use one of {}", LAYER_TYPES.join(", "))));
}
let limit = match get(p, "limit") {
None => 100,
Some(v) => v.as_u64().filter(|n| *n <= 10000).ok_or_else(|| bad(cmd, "`limit` must be an integer from 0 to 10000"))? as usize,
};
let root = opt_id(cmd, p, "root")?.map_or_else(|| s.page(), Ok)?;
let doc = s.doc()?;
// Keep one child iterator per ancestor, not a materialised subtree or all pending siblings.
let mut stack = vec![doc.get(root)?.children.iter()];
let mut ids = Vec::new();
let mut total = 0usize;
while let Some(children) = stack.last_mut() {
let Some(&id) = children.next() else {
stack.pop();
continue;
};
let n = doc.get(id)?;
stack.push(n.children.iter());
if name.as_ref().is_some_and(|q| !fold(&n.name).contains(q))
|| kind.as_ref().is_some_and(|q| !n.kind.type_name().eq_ignore_ascii_case(q))
|| text.as_ref().is_some_and(|q| !matches!(&n.kind, NodeKind::Text(t) if fold(&t.characters).contains(q)))
|| component.as_ref().is_some_and(
|q| !matches!(&n.kind, NodeKind::Instance { component, .. } if doc.node(*component).is_some_and(|c| fold(&c.name).contains(q))),
)
{
continue;
}
total += 1;
if ids.len() < limit {
ids.push(id);
}
}
Ok(json!({"ids": ids, "total": total}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!("edit.undo", "Undo", "{}", false, |s, _| Ok(json!({"undone": s.undo()?}))),
@@ -37,7 +111,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"edit.duplicate",
"Duplicate",
"{ids?, dx?, dy? (page offset; default: the offset of the last duplicate when ids are its copies, still selected; else top-level frames to the right with a 100 gap, everything else in place)} a main component's copy is an instance of it (a variant stays a new variant in its set)",
"{ids?, dx?, dy? (page offset; default: the offset of the last duplicate when ids are its copies, still selected; else top-level frames to the right with a 100 gap, everything else in place), map? (true: also return `map`, each original layer's id, descendants included, → its copy's id)} a main component's copy is an instance of it (a variant stays a new variant in its set)",
true,
duplicate
),
@@ -235,6 +309,13 @@ pub fn specs() -> Vec<CommandSpec> {
let i = req_id("node.get", p, "id")?;
Ok(crate::inspect::node_json(s.doc()?, i, 1))
}),
spec!(
"node.find",
"Find layers",
"{root? (strict descendants; default current page), name?, type? (canonical layer type), text?, component? (main component name), limit? (0-10000; default 100)}: every filter must match; name, text and component are case-insensitive substrings, type is exact (any case); returns {ids, total} and does not select",
false,
node_find
),
spec!(
"node.reparent",
"Move layer into",
@@ -248,8 +329,8 @@ pub fn specs() -> Vec<CommandSpec> {
s.doc_mut()?.reorder(i, idx)?;
ok()
}),
spec!("fill.add", "Add fill", "{ids?, paint? (default #D9D9D9 or white for frames)}", true, |s, p| paint_list(s, p, true, true)),
spec!("stroke.add", "Add stroke", "{ids?, paint? (default black)}", true, |s, p| paint_list(s, p, false, true)),
spec!("fill.add", "Add fill", "{ids?, paint? | color? (default #D9D9D9 or white for frames)}", true, |s, p| paint_list(s, p, true, true)),
spec!("stroke.add", "Add stroke", "{ids?, paint? | color? (default black)}", true, |s, p| paint_list(s, p, false, true)),
spec!("fill.remove", "Remove fill", "{ids?, index}", true, |s, p| paint_list(s, p, true, false)),
spec!("stroke.remove", "Remove stroke", "{ids?, index}", true, |s, p| paint_list(s, p, false, false)),
spec!("fill.set", "Set fill", "{ids?, index?, paint | color, opacity?, visible?, blend?}", true, |s, p| paint_set(s, p, true)),
@@ -273,7 +354,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"fill.pattern",
"Pattern fill",
"{source (layer id), ids?, index? (default the top fill), tile? (grid|horizontalHex|verticalHex), scale? (1 = the source's size), spacing? [x, y], align? (start|center|end), opacity?} the source layer tiled; it updates as the source changes",
"{source (layer id), ids?, index? (default the top fill), tile? (grid|horizontalHex|verticalHex), scale? (1 = the source's size), spacing? [x, y], align? (start|center|end), opacity?, visible?, blend?} the source layer tiled; it updates as the source changes",
true,
pattern_fill
),
@@ -354,7 +435,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"export.update",
"Edit export setting",
"{ids?, index, size? (2x|512w|512h|{by, value}), suffix?, format? (png|jpg|svg|pdf|webp|xml: Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore)}",
"{ids?, index, size? (2x|512w|512h|{by, value}), suffix?, format? (png|jpg|svg|pdf|webp|xml: Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore), contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?}",
true,
|s, p| {
let cmd = "export.update";
@@ -379,7 +460,14 @@ pub fn specs() -> Vec<CommandSpec> {
}
ok()
}),
spec!("command.batch", "Run several commands", "{label?, commands: [{command, params, as?}]} (one undo step)", false, |s, p| {
spec!("command.batch", "Run several commands", "{label?, onError?, commands: [{command, params, as?}]} (one undo step)", false, |s, p| {
// `continue` rolls back only the failed steps and returns one outcome per step.
let continue_on_error = match get(p, "onError") {
None => false,
Some(Value::String(mode)) if mode == "abort" => false,
Some(Value::String(mode)) if mode == "continue" => true,
Some(_) => return Err(bad("command.batch", "`onError` must be \"abort\" or \"continue\"")),
};
let label = string(p, "label").unwrap_or_else(|| "Batch".into());
let cmds: Vec<BatchStep> = match get(p, "commands") {
Some(Value::Array(a)) => {
@@ -387,6 +475,7 @@ pub fn specs() -> Vec<CommandSpec> {
for (i, c) in a.iter().enumerate() {
let cmd =
c.get("command").and_then(Value::as_str).ok_or_else(|| bad("command.batch", format!("step {i} has no `command`")))?;
crate::batch::refuse_unknown_step_keys(i, c)?;
let params = c.get("params").cloned().unwrap_or(json!({}));
out.push(BatchStep { name: crate::batch::step_name(i, c)?, ..BatchStep::new(cmd, params) });
}
@@ -394,7 +483,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
_ => return Err(bad("command.batch", "`commands` must be an array")),
};
Ok(Value::Array(s.batch(&label, &cmds)?))
Ok(Value::Array(if continue_on_error { s.batch_continue(&label, &cmds)? } else { s.batch(&label, &cmds)? }))
}),
spec!("edit.repeat", "Repeat last command", "{}", true, |s, _| {
let Some((id, p)) = s.last_command.clone().filter(|(id, _)| id != "edit.repeat") else {
@@ -543,6 +632,11 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
crate::board::rejoin(doc, &crate::board::copied_items(doc, &map), &map, false);
s.set_selection(out.clone());
s.ds_mut()?.last_duplicate = Some(Duplicated { sources: ids, copies: out.clone() });
if boolean(p, "map") == Some(true) {
// JSON object keys are strings: each original layer's id → its copy's.
let map: Map<String, Value> = map.into_iter().map(|(o, c)| (o.to_string(), json!(c))).collect();
return Ok(json!({"ids": out, "map": map}));
}
Ok(json!({"ids": out}))
}
+3
View File
@@ -107,6 +107,9 @@ pub(super) fn set(s: &mut Session, p: &Value) -> Result<Value> {
if props.is_empty() {
return Err(bad("node.set", "no properties given"));
}
for (k, v) in &props {
crate::cmd::props::variable_prop("node.set", k, v)?;
}
// A new rotation for the selection turns it about its moved rotation origin: each layer's
// point under the origin stays put.
let ds = s.ds()?;
+43 -31
View File
@@ -26,9 +26,10 @@ use export::add_member;
pub(super) use export::export_setting;
pub use export::{
ExportJob, copy_png, drawn_layers, export_area, export_bytes, export_bytes_with, export_jobs, export_scale, exportable_layers, fig_bytes,
frames_pdf, jobs_for, safe_file_name,
frames_pdf, jobs_for, penpot_bytes, safe_file_name,
};
use export::{export, export_frames_pdf, export_layers, pdf_bytes};
use load::font_report;
#[cfg(test)]
pub(crate) use load::{installed_family, sketch_line};
pub use load::{load, load_bytes, load_path};
@@ -36,7 +37,7 @@ pub(crate) use load::{load_fig, read_capped, recovered, variables_param, write};
pub use open::MAX_RECENT;
#[cfg(test)]
use open::append_pages;
use open::{import, open, open_recent};
use open::{import, open, open_recent, revert};
pub(crate) use save::save_ends_preview;
pub use save::{FileSnapshot, file_bytes, thumbnail};
use save::{color_profile, mark_saved, save, save_as};
@@ -75,7 +76,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"file.open",
"Open",
"{path | data (base64) + name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `<name>.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .sketch (Sketch 48 or newer, best effort), .svg, .png, .jpg …); REST, .fig and .sketch imports return `warnings`; a file opened anew returns `summary` {name, pages, layers, images} (one that was already open just returns its `index`)",
"{path | data (base64) + name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `<name>.variables.json` or `variables.json` next to the file), reload? (a file already open: read it again into its tab, which must have no unsaved changes)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .penpot, .sketch (Sketch 48 or newer, best effort), .svg, .png, .jpg …); REST, .fig, .penpot and .sketch imports return `warnings`; a file opened anew returns `summary` {name, pages, layers, images} (one that was already open returns its `index`, with `stale: true` when the file changed on disk since the tab read or saved it, or `reloaded: true` after `reload`)",
false,
open
),
@@ -86,6 +87,13 @@ pub fn specs() -> Vec<CommandSpec> {
false,
open_recent
),
spec!(
"file.fontReport",
"Inspect document fonts",
"{} list fonts used by text layers, which families are available, and sample layers; does not substitute or change the original family names",
false,
|s, _| Ok(font_report(s.doc()?))
),
spec!(
"file.save",
"Save",
@@ -110,7 +118,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"file.export",
"Export",
"{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp|xml (an Android Vector Drawable, with `warnings` for what it changed or left out), or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), svgIdAttribute? (SVG ids, XML android:name), vdCompat? (XML: bestEffort (default: approximate with a warning)|exact (leave out what can't be exact, with a warning)|ignore (no warnings))}",
"{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp|xml (an Android Vector Drawable, with `warnings` for what it changed or left out), or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file; or penpot: the whole file as a Penpot 2.x archive, with warnings for unsupported content), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), contentsOnly? (default true; false also draws the layers that show in their area), includeBoundingBox? (true: only the layers' own boxes, cutting off shadows and outside strokes), svgOutlineText? (default true), svgSimplifyStroke? (default true), svgIdAttribute? (SVG ids, XML android:name), vdCompat? (XML: bestEffort (default: approximate with a warning)|exact (leave out what can't be exact, with a warning)|ignore (no warnings))}",
false,
export
),
@@ -121,13 +129,19 @@ pub fn specs() -> Vec<CommandSpec> {
false,
export_layers
),
spec!("file.exportSvg", "Export selection as SVG", "{ids?, path?}", false, |s, p| {
let mut q = p.clone();
if let Some(o) = q.as_object_mut() {
o.insert("format".into(), json!("svg"));
spec!(
"file.exportSvg",
"Export selection as SVG",
"{ids?, path?, scale? | size?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke? (file.export's SVG options)}",
false,
|s, p| {
let mut q = p.clone();
if let Some(o) = q.as_object_mut() {
o.insert("format".into(), json!("svg"));
}
export(s, &q)
}
export(s, &q)
}),
),
spec!(
"file.exportFramesPdf",
"Export frames to PDF",
@@ -138,11 +152,17 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"file.import",
"Import",
"{path | data (base64) + name, x?, y?, center? (SVG: centre it on x, y), variables? (REST: the local variables JSON as an object, JSON text or a path)} SVG becomes an editable frame (shapes, text, gradients, masks, effects; with `warnings` for what it left out), images become image layers, .figcraft, REST API file JSON, .fig and .sketch pages are appended with their variables and flows (REST, .fig and .sketch: with `warnings`; those return `summary` {name, pages, layers, images} of the file read)",
"{path | data (base64) + name, x?, y?, center? (SVG: centre it on x, y), variables? (REST: the local variables JSON as an object, JSON text or a path)} SVG becomes an editable frame (shapes, text, gradients, masks, effects; with `warnings` for what it left out), images become image layers, .figcraft, .penpot, REST API file JSON, .fig and .sketch pages are appended with their variables and flows (REST, .fig, .penpot and .sketch: with `warnings`; those return `summary` {name, pages, layers, images} of the file read)",
true,
import
),
spec!("file.placeImage", "Place image", "{path | data, x?, y?}", true, super::create::place_image),
spec!(
"file.placeImage",
"Place image",
"{path | data, x?, y?, width?, height?, parent?, name?, target?, mode? (as image.place)}",
true,
super::create::place_image
),
spec!("file.close", "Close tab", "{index?, force? (discard unsaved changes)}", false, close),
spec!("file.moveTab", "Move tab", "{from, to} (indices among open documents)", false, |s, p| {
let from = req_index("file.moveTab", p, "from")?;
@@ -191,21 +211,7 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"index": s.active}))
}),
spec!("file.revert", "Revert to saved", "{} leave the live session first, if any", false, |s, _| {
let path = s.ds()?.path.clone().ok_or_else(|| EngineError::Disabled("file.revert".into(), "the file was never saved".into()))?;
// Stamped before reading: a change made during the read makes the next save refuse.
let on_disk = crate::library::stat(&path).ok();
let (loaded, _) = load_path(&path, None)?;
let i = s.active;
let uid = s.ds()?.uid;
if let Some(slot) = s.docs.get_mut(i) {
*slot = DocState::new(loaded.doc, Some(path.clone()))
.with_versions(loaded.versions)
.with_branches(loaded.branches)
.with_images_left_in_file(&path, loaded.images_past_budget);
slot.on_disk = on_disk;
}
// Edits on its branch tabs are reverted too.
crate::branches::close_tabs(s, uid, None);
revert(s)?;
ok()
}),
spec!(
@@ -312,15 +318,21 @@ pub fn specs() -> Vec<CommandSpec> {
}),
spec!("doc.nextTab", "Next tab", "{}", false, |s, _| cycle_doc(s, 1)),
spec!("doc.previousTab", "Previous tab", "{}", false, |s, _| cycle_doc(s, -1)),
spec!("doc.list", "List documents", "{}", false, |s, _| {
Ok(Value::Array(
spec!(
"doc.list",
"List documents",
"{} → [{index, id, name, path, dirty, active}]: `id` names the file in a control or MCP call's `document`",
false,
|s, _| {
Ok(Value::Array(
s.docs
.iter()
.enumerate()
.map(|(i, d)| json!({"index": i, "name": d.doc.name, "path": d.path, "dirty": d.is_dirty(), "active": i == s.active}))
.map(|(i, d)| json!({"index": i, "id": d.uid, "name": d.doc.name, "path": d.path, "dirty": d.is_dirty(), "active": i == s.active}))
.collect(),
))
}),
}
),
spec!("doc.home", "Home", "{}", false, |s, _| {
s.request_ui("home", json!({}));
ok()
+22 -5
View File
@@ -75,12 +75,14 @@ pub fn copy_png(doc: &Document, ids: &[NodeId]) -> Option<figcraft_render::Pixma
pub(super) fn export(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "file.export";
let fig = match string(p, "format") {
Some(f) => f.eq_ignore_ascii_case("fig"),
None => string(p, "path").is_some_and(|pp| ext(&pp) == "fig"),
let whole_file = match string(p, "format") {
Some(f) => f.to_ascii_lowercase(),
None => string(p, "path").map(|pp| ext(&pp)).unwrap_or_default(),
};
if fig {
return export_fig(s, p);
match whole_file.as_str() {
"fig" => return export_fig(s, p),
"penpot" => return export_penpot(s, p),
_ => {}
}
let doc = s.doc()?;
let mut ids = targets(s, p);
@@ -127,6 +129,21 @@ pub(super) fn export_fig(s: &mut Session, p: &Value) -> Result<Value> {
}
}
fn export_penpot(s: &mut Session, p: &Value) -> Result<Value> {
let (bytes, warnings) = penpot_bytes(s.doc()?)?;
match string(p, "path") {
Some(path) => {
write(&path, &bytes)?;
Ok(json!({"path": path, "bytes": bytes.len(), "format": "penpot", "warnings": warnings}))
}
None => Ok(json!({"format": "penpot", "data": base64::engine::general_purpose::STANDARD.encode(&bytes), "warnings": warnings})),
}
}
pub fn penpot_bytes(doc: &Document) -> Result<(Vec<u8>, Vec<String>)> {
figcraft_format::penpot::export(doc).map_err(|e| EngineError::Other(e.to_string()))
}
/// `doc` as a `.fig` file (with a thumbnail) and the warnings about what was left out.
pub fn fig_bytes(doc: &Document) -> Result<(Vec<u8>, Vec<String>)> {
figcraft_format::fig::export(doc, thumbnail(doc).as_deref()).map_err(|e| EngineError::Other(e.to_string()))
+398
View File
@@ -1,3 +1,5 @@
use std::collections::{BTreeMap, BTreeSet};
use super::{DocState, Document, EngineError, LineHeight, NodeId, NodeKind, Result, StyleKind, TextStyle, Value, bad, get, json};
/// The recovery copy at `path` as a document with unsaved changes and no path.
@@ -68,11 +70,24 @@ pub fn load_path(path: &str, variables: Option<&Value>) -> Result<(figcraft_form
/// Load from bytes. Returns the document with its notes (repairs of a native file, or import
/// warnings) and version history, and whether it was imported from REST API file JSON or a `.fig`
/// file (such a file is never saved over). `variables`: the local variables JSON for a REST API file JSON.
/// Components containing each other's instances are broken apart, with a note.
pub(super) fn load_with(
bytes: &[u8],
name_hint: &str,
variables: Option<&Value>,
images: figcraft_format::rest::ImageResolver,
) -> Result<(figcraft_format::Loaded, bool)> {
let (mut loaded, rest) = load_any(bytes, name_hint, variables, images)?;
let notes = crate::components::break_component_cycles(&mut loaded.doc);
loaded.issues.extend(notes);
Ok((loaded, rest))
}
fn load_any(
bytes: &[u8],
name_hint: &str,
variables: Option<&Value>,
images: figcraft_format::rest::ImageResolver,
) -> Result<(figcraft_format::Loaded, bool)> {
match ext(name_hint).as_str() {
"svg" => {
@@ -83,6 +98,19 @@ pub(super) fn load_with(
.notes;
Ok((figcraft_format::Loaded { issues: notes, ..doc.into() }, false))
}
"penpot" => {
let (mut doc, mut notes) = figcraft_format::penpot::import(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
expand_penpot_raw_svgs(&mut doc, &mut notes);
// Penpot imports stay offline: referenced fonts must be installed locally.
// The format reader stays platform-agnostic. Font availability is checked here,
// where FigCraft's system/embedded font registry is accessible. Never replace
// the original family in the document: it can resolve after installation.
figcraft_text::scan_system_fonts();
let families = figcraft_text::family_names();
let mut font_notes = missing_penpot_font_warnings(&doc, &families);
font_notes.append(&mut notes); // Show missing-font details first in the UI toast.
Ok((figcraft_format::Loaded { issues: font_notes, ..doc.into() }, true))
}
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "tif" | "tiff" => {
let mut doc = Document::new(file_stem(name_hint));
let page = doc.first_page().ok_or(EngineError::NoDocument)?;
@@ -165,7 +193,23 @@ fn reflow_overridden_text(doc: &mut Document) {
}
for id in changed {
if let Some((w, h)) = doc.node(id).and_then(figcraft_text::auto_size) {
// Sketch grows auto-width text from its aligned edge: a centred one stays centred, a
// right-aligned one keeps its right edge.
let shift = doc.node(id).map_or(0.0, |n| match &n.kind {
NodeKind::Text(t) if t.auto_resize == figcraft_doc::TextAutoResize::WidthAndHeight => match t.align_h {
figcraft_doc::TextAlignH::Center => (n.width - w) / 2.0,
figcraft_doc::TextAlignH::Right => n.width - w,
_ => 0.0,
},
_ => 0.0,
});
figcraft_layout::set_size(doc, id, w, h);
if shift.is_finite()
&& shift != 0.0
&& let Some(n) = doc.node_mut(id)
{
n.transform *= figcraft_doc::geom::Affine::translate((shift, 0.0));
}
}
figcraft_layout::layout_up(doc, id, &m);
}
@@ -335,6 +379,249 @@ pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option<Result<figcraft_forma
}))
}
/// Turn Penpot raw SVG placeholders into editable FigCraft SVG content, using the
/// normal SVG importer. The parser (figcraft-format) only stores bounded XML so it
/// does not need to depend on the SVG crate; the engine does this second pass.
/// Any failure leaves the original placeholder and XML data intact for later use.
fn expand_penpot_raw_svgs(doc: &mut Document, notes: &mut Vec<String>) {
let layers = doc
.nodes()
.filter_map(|node| {
let xml = node.plugin_data.get("penpot.svgRawXml")?;
(node.plugin_data.get("penpot.svgRawStatus").map(String::as_str) == Some("pending"))
.then(|| (node.id, node.name.clone(), node.width, node.height, xml.clone(), node.children.is_empty()))
})
.collect::<Vec<_>>();
let mut ok_count = 0usize;
for (id, name, width, height, xml, empty) in layers {
if !empty {
notes.push(format!("Penpot SVG '{name}': has separate child layers; the original tree was preserved to prevent duplicate rendering"));
continue;
}
match figcraft_svg::import_svg_with_notes(doc, xml.as_bytes(), id, 0.0, 0.0, &name) {
Ok(imported) => {
// An SVG can be syntactically valid yet contain no supported drawable
// elements. Do not claim success or leave an empty extra frame.
if doc.node(imported.frame).is_none_or(|n| n.children.is_empty()) {
let _ = doc.remove(imported.frame);
if let Some(n) = doc.node_mut(id) {
n.plugin_data.insert("penpot.svgRawStatus".into(), "not-expanded".into());
}
if notes.len() < 100 {
notes.push(format!("Penpot SVG '{name}' has no supported drawable elements; the original markup is preserved"));
}
continue;
}
let size = doc.node(imported.frame).map(|n| (n.width, n.height));
if let Some((svg_w, svg_h)) = size
&& svg_w.is_finite()
&& svg_h.is_finite()
&& svg_w > 0.0
&& svg_h > 0.0
{
let sx = (width / svg_w).clamp(0.0001, 10_000.0);
let sy = (height / svg_h).clamp(0.0001, 10_000.0);
if let Some(n) = doc.node_mut(imported.frame) {
n.transform = figcraft_geom::Affine::scale_non_uniform(sx, sy);
}
}
if let Some(n) = doc.node_mut(id) {
n.plugin_data.insert("penpot.svgRawStatus".into(), "expanded".into());
}
for issue in imported.notes {
if notes.len() < 100 {
notes.push(format!("Penpot SVG '{name}': {issue}"));
}
}
ok_count += 1;
}
Err(err) => {
if let Some(n) = doc.node_mut(id) {
n.plugin_data.insert("penpot.svgRawStatus".into(), "not-expanded".into());
}
if notes.len() < 100 {
notes.push(format!("Penpot SVG '{name}' could not be imported: {err}; its original markup is preserved"));
}
}
}
}
if ok_count > 0 && notes.len() < 100 {
notes.push(format!("Penpot: {ok_count} raw SVG layer(s) converted to editable SVG content"));
}
}
/// Non-destructive font diagnostic for the action palette, CLI and control API.
/// Includes styled runs, not just the default typeface of each text layer.
pub(super) fn font_report(doc: &Document) -> Value {
figcraft_text::scan_system_fonts();
font_report_with_families(doc, &figcraft_text::family_names())
}
fn font_report_with_families(doc: &Document, installed: &[String]) -> Value {
let known: BTreeSet<String> = installed.iter().map(|s| s.trim().to_lowercase()).collect();
let mut families: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for node in doc.nodes() {
let NodeKind::Text(text) = &node.kind else { continue };
if text.characters.is_empty() {
continue;
}
let mut covered = vec![false; text.characters.chars().count().min(100_000)];
let mut used = BTreeSet::new();
for run in &text.runs {
let start = run.start.min(covered.len());
let end = run.end.min(covered.len());
if start < end {
if !run.style.font_family.trim().is_empty() {
used.insert(run.style.font_family.clone());
}
for entry in covered.iter_mut().take(end).skip(start) {
*entry = true;
}
}
}
if covered.iter().any(|entry| !entry) {
used.insert(text.style.font_family.clone());
}
for family in used {
let family = family.trim();
if family.is_empty() {
continue;
}
if families.len() >= 256 && !families.contains_key(family) {
continue;
}
let layer_name = node.name.chars().filter(|c| !c.is_control()).take(120).collect::<String>();
let layers = families.entry(family.chars().filter(|c| !c.is_control()).take(160).collect()).or_default();
if layers.len() < 25 {
layers.insert(layer_name);
}
}
}
// Explain WHERE a Penpot family comes from. `custom-*` references are
// stored in Penpot's font service, not necessarily in its design archive.
// Keep the id for diagnostics; never treat it as a download URL.
let mut external: BTreeMap<(String, String), BTreeSet<String>> = BTreeMap::new();
for node in doc.nodes() {
for key in ["penpot.googleFontRefs", "penpot.customFontRefs", "penpot.customLibraryFontRefs"] {
let Some(raw) = node.plugin_data.get(key) else {
continue;
};
let Ok(Value::Array(items)) = serde_json::from_str::<Value>(raw) else {
continue;
};
for item in items.iter().take(128) {
let Some(id) = item.get("id").and_then(Value::as_str) else {
continue;
};
let Some(family) = item.get("family").and_then(Value::as_str) else {
continue;
};
if id.len() > 128 || family.len() > 128 || external.len() >= 256 {
continue;
}
let key = (id.to_owned(), family.to_owned());
external.entry(key).or_default().insert(node.name.chars().take(80).collect());
}
}
}
let external_fonts: Vec<Value> = external
.into_iter()
.map(|((id, family), layers)| {
json!({
"source": if id.starts_with("custom-") { "penpot-custom" } else { "google-fonts" },
"referenceKind": if id.starts_with("custom-") { "custom-original-unverified" } else { "google-fonts-reference" },
"originNote": if id.starts_with("custom-") {
"Penpot-managed font: a matching installed/downloaded family is not proof of the original font file"
} else {
"Google Fonts provider reference; availability is reported separately"
},
"id": id, "family": family, "layers": layers.into_iter().collect::<Vec<_>>()
})
})
.collect();
let entries = families
.into_iter()
.map(|(family, layers)| {
let lower = family.to_lowercase();
let generic = matches!(lower.as_str(), "sans-serif" | "serif" | "monospace" | "system-ui");
// No registered families can mean an uninitialised/no-font runtime; it is
// not evidence that all names are absent from the user's computer.
let available = if generic {
Some(true)
} else if known.is_empty() {
None
} else {
Some(known.contains(&lower))
};
json!({"family":family,"available":available,"layers":layers.into_iter().collect::<Vec<_>>()})
})
.collect::<Vec<_>>();
let missing = entries.iter().filter(|v| v.get("available") == Some(&Value::Bool(false))).count();
json!({"total": entries.len(), "missing": missing, "embeddedFontFiles": 0,
"fontLicenseNotices": 0, "fonts": entries,
"externalPenpotFontRefs": external_fonts})
}
/// Describe fonts unavailable on this machine, while keeping their original family names
/// editable in the document. The detected families are provided separately for testability
/// and to avoid a dependency from the format parser to the font renderer.
fn missing_penpot_font_warnings(doc: &Document, families: &[String]) -> Vec<String> {
// An uninitialised font registry cannot prove a family is unavailable.
if families.is_empty() {
return Vec::new();
}
let available: BTreeSet<String> = families.iter().map(|s| s.trim().to_lowercase()).collect();
let mut missing: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for node in doc.nodes() {
let NodeKind::Text(text) = &node.kind else { continue };
let text_len = text.characters.chars().count();
if text_len == 0 {
continue;
}
let mut covered = vec![false; text_len.min(100_000)];
let mut used = BTreeSet::new();
for run in &text.runs {
let start = run.start.min(covered.len());
let end = run.end.min(covered.len());
if start < end {
used.insert(run.style.font_family.clone());
for flag in covered.iter_mut().take(end).skip(start) {
*flag = true;
}
}
}
if covered.iter().any(|present| !present) {
used.insert(text.style.font_family.clone());
}
for family in used {
let family = family.trim();
let lower = family.to_lowercase();
if family.is_empty() || matches!(lower.as_str(), "sans-serif" | "serif" | "monospace" | "system-ui") || available.contains(&lower) {
continue;
}
let display_family: String = family.chars().filter(|c| !c.is_control()).take(160).collect();
let display_layer: String = node.name.chars().filter(|c| !c.is_control()).take(80).collect();
missing.entry(display_family).or_default().insert(display_layer);
}
}
// A single warning remains readable even for documents with hundreds of
// type layers and does not push out more urgent Penpot import diagnostics.
if missing.is_empty() {
return Vec::new();
}
let count = missing.len();
let names = missing
.into_iter()
.take(8)
.map(|(family, layers)| format!("'{family}' (e.g. '{}')", layers.iter().next().map(String::as_str).unwrap_or("text")))
.collect::<Vec<_>>()
.join(", ");
let extra = if count > 8 { format!(" and {} more", count - 8) } else { String::new() };
vec![format!(
"Penpot: {count} font family/families are not installed or embedded: {names}{extra}. FigCraft uses fallback fonts for display, but preserves the original family names; install the licensed fonts and reopen the file to restore text metrics."
)]
}
/// Link imported instances to their mains (layer sources, overrides found by comparing values),
/// innermost first so outer instances settle last.
fn link_instances(doc: &mut Document) {
@@ -414,3 +701,114 @@ pub(crate) fn variables_param(cmd: &str, p: &Value) -> Result<Option<Value>> {
}
Ok(Some(v))
}
#[cfg(test)]
mod penpot_font_warning_tests {
use super::*;
use figcraft_doc::{StyleRun, TextData};
#[test]
fn font_report_explains_custom_and_google_provenance() {
let mut doc = Document::new("External font refs");
let page = doc.first_page().unwrap();
let text = TextData::new("Playball");
let id = doc.create(page, NodeKind::Text(text), "Custom caption", 0., 0., 120., 30.).unwrap();
doc.node_mut(id).unwrap().plugin_data.insert(
"penpot.customFontRefs".into(),
r#"[{"id":"custom-123abc","family":"Playball","variants":[{"weight":400,"italic":false}]}]"#.into(),
);
let report = font_report_with_families(&doc, &[]);
assert_eq!(report["externalPenpotFontRefs"][0]["source"], "penpot-custom");
assert_eq!(report["externalPenpotFontRefs"][0]["family"], "Playball");
assert_eq!(report["externalPenpotFontRefs"][0]["referenceKind"], "custom-original-unverified");
assert!(report["externalPenpotFontRefs"][0]["originNote"].as_str().unwrap().contains("not proof"));
}
#[test]
fn reports_only_missing_families_without_modifying_original_text() {
let mut doc = Document::new("Penpot fonts");
let page = doc.first_page().unwrap();
let mut text = TextData::new("hello world");
text.style.font_family = "Rare Licensed Family".into();
doc.create(page, NodeKind::Text(text), "Headline", 0.0, 0.0, 200.0, 30.0).unwrap();
let warnings = missing_penpot_font_warnings(&doc, &["Inter".into(), "Source Sans Pro".into()]);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("Rare Licensed Family"));
assert!(warnings[0].contains("Headline"));
assert_eq!(
doc.nodes().find(|n| n.name == "Headline").and_then(|n| match &n.kind {
NodeKind::Text(t) => Some(t.style.font_family.as_str()),
_ => None,
}),
Some("Rare Licensed Family")
);
assert!(missing_penpot_font_warnings(&doc, &["RARE LICENSED FAMILY".into()]).is_empty());
assert!(missing_penpot_font_warnings(&doc, &[]).is_empty());
}
#[test]
fn completely_styled_text_does_not_report_an_unused_base_font() {
let mut doc = Document::new("Run fonts");
let page = doc.first_page().unwrap();
let mut t = TextData::new("AB");
t.style.font_family = "Unused Base".into();
let mut run = t.style.clone();
run.font_family = "Available Font".into();
t.runs.push(StyleRun { start: 0, end: 2, style: run });
doc.create(page, NodeKind::Text(t), "Full run", 0.0, 0.0, 100.0, 30.0).unwrap();
assert!(missing_penpot_font_warnings(&doc, &["Available Font".into()]).is_empty());
}
}
#[cfg(test)]
mod penpot_svg_v10_tests {
use super::*;
use figcraft_doc::TextData;
#[test]
fn expands_inline_svg_into_editable_shapes_without_discarding_source() {
let mut doc = Document::new("SVG fixture");
let page = doc.first_page().unwrap();
let id = doc.create(page, NodeKind::Frame, "SVG ornament", 10.0, 20.0, 100.0, 50.0).unwrap();
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="50"><rect x="10" y="10" width="20" height="20" fill="red"/></svg>"#;
let layer = doc.node_mut(id).unwrap();
layer.plugin_data.insert("penpot.svgRawXml".into(), svg.into());
layer.plugin_data.insert("penpot.svgRawStatus".into(), "pending".into());
let mut warnings = Vec::new();
expand_penpot_raw_svgs(&mut doc, &mut warnings);
let layer = doc.node(id).unwrap();
assert_eq!(layer.plugin_data.get("penpot.svgRawStatus").map(String::as_str), Some("expanded"));
assert!(!layer.children.is_empty());
assert!(doc.nodes().any(|node| matches!(&node.kind, NodeKind::Rectangle | NodeKind::Vector { .. })));
assert_eq!(layer.plugin_data.get("penpot.svgRawXml").map(String::as_str), Some(svg));
}
#[test]
fn invalid_raw_svg_keeps_placeholder_and_markup() {
let mut doc = Document::new("Invalid SVG fixture");
let page = doc.first_page().unwrap();
let id = doc.create(page, NodeKind::Frame, "Broken", 0.0, 0.0, 80.0, 30.0).unwrap();
let layer = doc.node_mut(id).unwrap();
layer.plugin_data.insert("penpot.svgRawXml".into(), "<svg><broken".into());
layer.plugin_data.insert("penpot.svgRawStatus".into(), "pending".into());
let mut warnings = Vec::new();
expand_penpot_raw_svgs(&mut doc, &mut warnings);
let layer = doc.node(id).unwrap();
assert!(layer.children.is_empty());
assert_eq!(layer.plugin_data.get("penpot.svgRawStatus").map(String::as_str), Some("not-expanded"));
assert!(!warnings.is_empty());
}
#[test]
fn font_inventory_reports_missing_families_without_rewriting_text() {
let mut doc = Document::new("Font inventory");
let page = doc.first_page().unwrap();
let mut text = TextData::new("Example");
text.style.font_family = "Definitely Never Installed V10 Font Q7".into();
doc.create(page, NodeKind::Text(text), "Caption", 0.0, 0.0, 100.0, 24.0).unwrap();
let report = font_report_with_families(&doc, &["Inter".into()]);
assert_eq!(report["missing"], 1);
assert_eq!(report["fonts"][0]["family"], "Definitely Never Installed V10 Font Q7");
assert_eq!(report["fonts"][0]["layers"][0], "Caption");
}
}
+48 -7
View File
@@ -62,20 +62,35 @@ pub(super) fn open(s: &mut Session, p: &Value) -> Result<Value> {
return open_data(s, &d, p, variables.as_ref());
}
let path = req_string("file.open", p, "path")?;
// Already open: switch to it.
// Already open: switch to it, and say when the file changed on disk since this tab read or
// saved it (`stale`), or read it again (`reload`).
if let Some(i) = s.docs.iter().position(|d| d.path.as_deref() == Some(path.as_str())) {
s.active = i;
remember(s, &path);
return Ok(json!({"index": i}));
let stale = s.ds()?.on_disk.as_ref().is_some_and(|read| crate::library::stat(&path).ok().as_ref() != Some(read));
if !boolean(p, "reload").unwrap_or(false) {
return Ok(if stale { json!({"index": i, "stale": true}) } else { json!({"index": i}) });
}
if s.ds()?.is_dirty() {
return Err(EngineError::Disabled(
"file.open".into(),
"the open copy has unsaved changes: save them, or Revert to saved to drop them".into(),
));
}
if s.live().doc_uid.is_some() {
return Err(EngineError::Disabled("file.open".into(), "leave the live session before reading the file again".into()));
}
revert(s)?;
return Ok(json!({"index": i, "reloaded": true}));
}
// Stamped before reading: a change made during the read makes the next save refuse.
let on_disk = crate::library::stat(&path).ok();
let (loaded, rest) = load_path(&path, variables.as_ref())?;
remember(s, &path);
let native = !rest && matches!(ext(&path).as_str(), "figcraft" | "json");
let warnings = rest || ext(&path) == "svg";
let warnings = rest || matches!(ext(&path).as_str(), "svg" | "penpot");
// A native file that lost something (a damaged image, say): the app says so, not only the result.
if native && !loaded.issues.is_empty() {
if (native || warnings) && !loaded.issues.is_empty() {
s.message = Some(match loaded.issues.as_slice() {
[one] => format!("{}: {one}", file_stem(&path)),
more => format!("{}: {} problems were repaired or left out while opening it, e.g. {}", file_stem(&path), more.len(), more[0]),
@@ -95,6 +110,26 @@ pub(super) fn open(s: &mut Session, p: &Value) -> Result<Value> {
Ok(out)
}
/// Replace the active tab's document with its file as saved (`file.revert`, `file.open {reload}`).
pub(super) fn revert(s: &mut Session) -> Result<()> {
let path = s.ds()?.path.clone().ok_or_else(|| EngineError::Disabled("file.revert".into(), "the file was never saved".into()))?;
// Stamped before reading: a change made during the read makes the next save refuse.
let on_disk = crate::library::stat(&path).ok();
let (loaded, _) = load_path(&path, None)?;
let i = s.active;
let uid = s.ds()?.uid;
if let Some(slot) = s.docs.get_mut(i) {
*slot = DocState::new(loaded.doc, Some(path.clone()))
.with_versions(loaded.versions)
.with_branches(loaded.branches)
.with_images_left_in_file(&path, loaded.images_past_budget);
slot.on_disk = on_disk;
}
// Edits on its branch tabs are reverted too.
crate::branches::close_tabs(s, uid, None);
Ok(())
}
/// [`open`] for a file given as base64 `data` (a file dropped without a path on disk): `name` tells
/// the format. It opens as a new document with no path, so the first save asks where to put it.
fn open_data(s: &mut Session, data: &str, p: &Value, variables: Option<&Value>) -> Result<Value> {
@@ -102,7 +137,7 @@ fn open_data(s: &mut Session, data: &str, p: &Value, variables: Option<&Value>)
let data = data.split_once(',').map_or(data, |(_, b)| b);
let bytes = base64::engine::general_purpose::STANDARD.decode(data.trim()).map_err(|e| bad("file.open", e.to_string()))?;
let (loaded, rest) = load_with(&bytes, &name, variables, &figcraft_format::rest::no_images)?;
let warnings = rest || ext(&name) == "svg";
let warnings = rest || matches!(ext(&name).as_str(), "svg" | "penpot");
let summary = import_summary(&loaded.doc);
s.docs.push(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches));
s.active = s.docs.len() - 1;
@@ -148,7 +183,7 @@ pub(super) fn import(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(out)
}
"figcraft" | "json" | "fig" | "sketch" => {
"figcraft" | "json" | "fig" | "sketch" | "penpot" => {
let resolver = path.as_deref().map(rest_images);
let images: figcraft_format::rest::ImageResolver = match &resolver {
Some(r) => r,
@@ -158,8 +193,14 @@ pub(super) fn import(s: &mut Session, p: &Value) -> Result<Value> {
let (other, rest) = load_with(&bytes, &name, variables.as_ref().or(sibling.as_ref()), images)?;
let summary = import_summary(&other.doc);
let pages = append_pages(s.doc_mut()?, &other.doc)?;
if !other.issues.is_empty() {
s.message = Some(match other.issues.as_slice() {
[one] => format!("{}: {one}", file_stem(&name)),
more => format!("{}: {} import warnings; first: {}", file_stem(&name), more.len(), more[0]),
});
}
let mut out = json!({"pages": pages, "summary": summary});
if rest {
if rest || !other.issues.is_empty() {
out["warnings"] = json!(other.issues);
}
Ok(out)
+46 -4
View File
@@ -1,12 +1,12 @@
//! Image paints: adjustment presets, built in and saved in the file.
//! Image paints: adjustment presets, built in and saved in the file, and Crop image.
use figcraft_color::{ImageFilters, PaintKind};
use figcraft_doc::{Document, ImagePreset};
use figcraft_color::{ImageFilters, ImageScaleMode, PaintKind};
use figcraft_doc::{Document, ImagePreset, Node};
use serde_json::{Value, json};
use super::{CommandSpec, ok};
use crate::params::{f64_of, get, num, req_index, req_string, req_targets, string, targets};
use crate::{Result, Session, bad, spec};
use crate::{EngineError, Result, Session, bad, spec};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -34,6 +34,13 @@ pub fn specs() -> Vec<CommandSpec> {
}
ok()
}),
spec!(
"image.crop",
"Crop image",
"{ids? (one layer), index? (an image fill; default its top visible one)} → {id, index}: the image fill switches to Crop, keeping the picture where it was, and the canvas enters crop mode on it",
true,
crop
),
spec!("image.presets", "Image presets", "{} → {builtIn: [{id, label, filters}], saved: [{name, filters}]}", false, |s, _| {
let built_in: Vec<Value> = ImageFilters::PRESETS.iter().map(|(id, label, f)| json!({"id": id, "label": label, "filters": f})).collect();
Ok(json!({"builtIn": built_in, "saved": s.doc()?.image_presets}))
@@ -135,6 +142,41 @@ fn adjust(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"changed": changed}))
}
/// The index of a layer's top visible image fill (fills paint bottom to top).
pub fn top_image_fill(n: &Node) -> Option<usize> {
n.fills.iter().rposition(|f| f.visible && matches!(f.kind, PaintKind::Image(_)))
}
fn crop(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "image.crop";
let ids = req_targets(cmd, s, p)?;
// A selection with nothing to crop is refused as unavailable, not as bad input.
let refuse = |why: &str| EngineError::Disabled(cmd.into(), why.into());
let [id] = ids[..] else { return Err(refuse("select one layer to crop its image")) };
let doc = s.doc()?;
let n = doc.get(id)?;
let index = match get(p, "index") {
None | Some(Value::Null) => top_image_fill(n).ok_or_else(|| refuse("the layer has no image fill to crop"))?,
Some(_) => req_index(cmd, p, "index")?,
};
let mut paint = n.fills.get(index).cloned().ok_or_else(|| bad(cmd, "no fill at that index"))?;
let PaintKind::Image(img) = &mut paint.kind else { return Err(bad(cmd, "that fill is not an image")) };
// An image already in Crop mode only enters crop mode: no edit, no undo step.
if img.scale_mode != ImageScaleMode::Crop {
let data = doc.images.get(&img.image).ok_or_else(|| bad(cmd, "the image's data is missing"))?;
img.crop = Some(img.placement_crop(f64::from(data.width), f64::from(data.height), n.width, n.height));
img.scale_mode = ImageScaleMode::Crop;
let n = s.doc_mut()?.get_mut(id)?;
if let Some(f) = n.fills.get_mut(index) {
*f = paint;
}
// Like any paint edit, the layer leaves its paint style.
n.styles.fill = None;
}
s.request_ui(cmd, json!({"id": id, "index": index}));
Ok(json!({"id": id, "index": index}))
}
fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "image.savePreset";
let name: String = req_string(cmd, p, "name")?.trim().chars().filter(|c| !c.is_control()).take(MAX_NAME).collect();
+67 -12
View File
@@ -17,7 +17,7 @@ pub const STROKE_SIDES: [&str; 4] = ["strokeTopWeight", "strokeRightWeight", "st
/// Keys `node.set` understands (documented for agents).
pub const KEYS: &str = "name, visible, locked, opacity (0-1 or \"50%\"), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), \
fills (paints or colours; gradients as {type: linearGradient|radialGradient|angularGradient|diamondGradient, stops: [\"#hex\", ...], from?, to?} too), fill (colour, or \"none\"/null: no fill), strokes, stroke (colour, or \"none\"/null: no stroke), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \
fills (paints or colours; gradients as {type: linearGradient|radialGradient|angularGradient|diamondGradient, stops: [\"#hex\", ...], from?, to?} too), fill (colour, {$var: variable id or name}, or \"none\"/null: no fill), strokes, stroke (colour, {$var: variable id or name}, or \"none\"/null: no stroke), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \
strokeCap / startCap / endCap, strokeJoin, miterAngle (degrees: sharper joins bevel; or miterLimit as a ratio), dashes [..], widthProfile (preset uniform|wedge|taper|eye|rounded|pinch, [[t, scale], ...] or null), brush (preset id, {preset, spacing, sizeJitter, rotationJitter, scatter, seed} or null), cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \
clip, isMask, layout (null|horizontal|vertical|grid|{...}), gap, crossGap, padding (n or [t,r,b,l]), paddingTop/Right/Bottom/Left, \
mainAlign (min|center|max, or the auto gaps spaceBetween|spaceAround|spaceEvenly), crossAlign (min|center|max|baseline: first text baselines, horizontal layouts), contentAlign (wrapped lines: auto = packed where crossAlign says | spaceBetween), wrap (rows of a horizontal layout, columns of a vertical one), strokesIncluded (the frame's inside stroke takes up space), reverseZ (canvas stacking: true = first on top), \
@@ -387,20 +387,55 @@ fn paint_none(v: &Value) -> bool {
v.is_null() || v.as_str().is_some_and(|s| s.eq_ignore_ascii_case("none"))
}
/// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, style
/// links after the values so a link wins over values its style sets, variable bindings last so
/// paints set in the same call don't detach them.
fn apply_order(k: &str) -> u8 {
/// A top-level variable wrapper. Validate before `node.set` skips type-specific properties too,
/// so an unsupported wrapper is always an error, even on a mixed selection.
pub(crate) fn variable_prop<'a>(cmd: &str, key: &str, value: &'a Value) -> Result<Option<&'a str>> {
let Some(o) = value.as_object().filter(|o| o.contains_key("$var")) else { return Ok(None) };
if !matches!(key, "fill" | "stroke") {
return Err(bad(cmd, format!("`{key}`: only `fill` and `stroke` take variables in props; use `variable.bind` for other fields")));
}
if o.len() != 1 {
return Err(bad(cmd, format!("`{key}`: a `$var` wrapper takes only `$var`, with no extra keys")));
}
let name = o
.get("$var")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.ok_or_else(|| bad(cmd, format!("`{key}`: `$var` must be a variable id or name (a non-empty string)")))?;
Ok(Some(name))
}
/// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, the
/// auto layout mode before the keys that tune it (an object `layout` replaces the whole auto
/// layout), style links after the values so a link wins over values its style sets, variable
/// bindings last so paints set in the same call don't detach them. `$var` wrappers also win over style links.
fn apply_order(k: &str, v: &Value) -> u8 {
if v.get("$var").is_some() {
return 7;
}
match k {
"path" | "width" | "height" | "w" | "h" => 0,
"rotation" => 2,
"x" | "y" => 3,
"fillStyle" | "strokeStyle" | "effectStyle" | "textStyle" | "gridStyle" => 4,
"boundVariables" => 5,
_ => 1,
"layout" | "layoutMode" => 1,
"rotation" => 3,
"x" | "y" => 4,
"fillStyle" | "strokeStyle" | "effectStyle" | "textStyle" | "gridStyle" => 5,
"boundVariables" => 6,
_ => 2,
}
}
/// Auto layout keys given beside a `layout` (or `layoutMode`) that turns auto layout off: they
/// would turn it back on (`layout_mut`), so the call is refused rather than one of them losing.
fn layout_keys_beside_none(cmd: &str, props: &Map<String, Value>) -> Result<()> {
let off = ["layout", "layoutMode"]
.into_iter()
.find(|k| props.get(*k).is_some_and(|v| v.is_null() || v.as_str().is_some_and(|s| s.eq_ignore_ascii_case("none"))));
let tuning: Vec<String> = props.keys().filter(|k| LAYOUT_KEYS.contains(&k.as_str())).map(|k| format!("`{k}`")).collect();
let Some(off) = off.filter(|_| !tuning.is_empty()) else { return Ok(()) };
let (what, are) = if tuning.len() == 1 { ("an auto layout property", "is") } else { ("auto layout properties", "are") };
Err(bad(cmd, format!("{} {are} {what}, and `{off}` in the same call turns auto layout off", tuning.join(", "))))
}
/// An explicit width (`is_w`) or height makes hugging or filling on that axis fixed, like typing
/// into W or H.
fn fix_axis(doc: &mut Document, id: NodeId, is_w: bool) -> Result<()> {
@@ -993,16 +1028,36 @@ fn apply_shape(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) ->
Ok(true)
}
/// A `{$var}` wrapper on `fill` or `stroke`: the first paint binds to that variable.
fn apply_variable(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) -> Result<bool> {
let Some(name) = variable_prop(cmd, k, v)? else { return Ok(false) };
let vid = super::variables::resolve_strict(cmd, doc, name)?;
// All aliases of the first paint name one slot; other paint slots stay bound.
super::styles::unbind_slot(doc.get_mut(id)?, k == "fill", 0);
super::styles::bind_variable(cmd, doc, &[id], k, Some(&vid))?;
// A paint bound through props follows the variable, not its former style. Keep this
// local to `$var`, including when the style and variable happen to give the same colour.
let n = doc.get_mut(id)?;
if k == "fill" {
n.styles.fill = None;
} else {
n.styles.stroke = None;
}
Ok(true)
}
/// Apply a property map to one node. `x`/`y` are relative to the parent.
pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Value>) -> Result<()> {
layout_keys_beside_none(cmd, props)?;
let mut unknown = Vec::new();
let mut keys: Vec<(&String, &Value)> = props.iter().collect();
keys.sort_by_key(|(k, _)| apply_order(k));
keys.sort_by_key(|(k, v)| apply_order(k, v));
// Where a new `path` had its own box, for `x`/`y` in the same call (as `node.create` places it).
let mut path_offset = (0.0, 0.0);
for (k, v) in keys {
let k = k.as_str();
if apply_geometry(cmd, doc, id, props, k, v, &mut path_offset)?
if apply_variable(cmd, doc, id, k, v)?
|| apply_geometry(cmd, doc, id, props, k, v, &mut path_offset)?
|| apply_look(cmd, doc, id, k, v)?
|| apply_corners(cmd, doc, id, k, v)?
|| apply_layout(cmd, doc, id, k, v)?
+2 -2
View File
@@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"proto.interaction.add",
"Add interaction",
"{id?, trigger? {type: onClick|onHover|onPress|onDrag|mouseEnter|mouseLeave|mouseDown|mouseUp|afterDelay {ms}|keyPress {key (a key or a combination: Shift+K, Ctrl+Alt+Delete; or a controller button: Gamepad A|B|X|Y|LB|RB|LT|RT|View|Menu|LS|RS|Up|Down|Left|Right|Home)}}, action {type: navigate {preserveScroll?}|back|scrollTo {offset? [x, y] (where the layer ends up from the top-left)}|openOverlay {position?, anchor? screen|triggerInside|triggerOutside (default screen; the trigger ones measure the position preset on the layer that opens the overlay: outside puts top presets above it and bottom presets below it, edges lined up), offset? [x, y] (position manual: the overlay's top-left from the top-left of the layer that opens it; without it, right below that layer), close_on_outside? (default true), background?}|swapOverlay|closeOverlay|openUrl {url}|changeTo|setVariable {variable, value | expression (text, e.g. \"count + 1\")}|setVariableMode {collection, mode}|conditional {blocks [{condition? | expression? (text, e.g. \"count >= 3\"), actions}]}, dest?}, actions? [..], transition? {type: instant|dissolve|smartAnimate|moveIn|moveOut|push|slideIn|slideOut, direction?: left|right|top|bottom}, durationMs?, easing? linear|easeIn|easeOut|easeInOut|easeInBack|easeOutBack|easeInOutBack|gentle|quick|bouncy|slow|{customBezier {x1, y1, x2, y2}}|{customSpring {mass, stiffness, damping}}}, delayMs? (mouse enter, leave, down and up: how long after the gesture the actions run, up to 600000)} — or {id, dest} for a click-navigate. Returns {index}",
"{id?, trigger? {type: onClick|onHover|onPress|onDrag|mouseEnter|mouseLeave|mouseDown|mouseUp|afterDelay {ms}|keyPress {key (a key or a combination: Shift+K, Ctrl+Alt+Delete; or a controller button: Gamepad A|B|X|Y|LB|RB|LT|RT|View|Menu|LS|RS|Up|Down|Left|Right|Home)}}, action {type: navigate {preserveScroll?}|back|scrollTo {offset? [x, y] (where the layer ends up from the top-left)}|openOverlay {position?, anchor? screen|triggerInside|triggerOutside (default screen; the trigger ones measure the position preset on the layer that opens the overlay: outside puts top presets above it and bottom presets below it, edges lined up), offset? [x, y] (position manual: the overlay's top-left from the top-left of the layer that opens it; without it, right below that layer), close_on_outside? (default true), background?}|swapOverlay|closeOverlay|openUrl {url}|changeTo|setVariable {variable, value | expression (text, e.g. \"count + 1\")}|setVariableMode {collection, mode}|conditional {blocks [{condition? | expression? (text, e.g. \"count >= 3\"), actions}]}, dest?}, actions? [..], dest? (without action: a click-navigate to it), transition? {type: instant|dissolve|smartAnimate|moveIn|moveOut|push|slideIn|slideOut, direction?: left|right|top|bottom}, durationMs? | duration_ms?, easing? linear|easeIn|easeOut|easeInOut|easeInBack|easeOutBack|easeInOutBack|gentle|quick|bouncy|slow|{customBezier {x1, y1, x2, y2}}|{customSpring {mass, stiffness, damping}}, delayMs? | delay_ms? (mouse enter, leave, down and up: how long after the gesture the actions run, up to 600000)} — or {id, dest} for a click-navigate. Returns {index}",
true,
add
),
@@ -51,7 +51,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"proto.interaction.set",
"Edit interaction",
"{id?, index, interaction {..}} replaces it; or patch fields: {id?, index, trigger?, action? (first action), actions?, transition?, durationMs?, easing?, delayMs?}",
"{id?, index, interaction? {..} (replaces it whole), trigger?, action? (first action), actions?, transition?, durationMs? | duration_ms?, easing?, delayMs? | delay_ms?} patch fields on top of the current interaction (or of interaction)",
true,
set
),
+275 -65
View File
@@ -2,8 +2,8 @@
use figcraft_color::{Color, Paint};
use figcraft_doc::{
AutoLayout, BoolOp, Corners, Document, LayoutMode, Node, NodeId, NodeKind, Region, Segment, Sizing, StrokeAlign, StrokeJoin, StrokeProps,
VectorNetwork, WindingRule, proto,
AutoLayout, BoolOp, Corners, Document, LayoutMode, Node, NodeId, NodeKind, Region, RegionPaintSpace, Segment, Sizing, StrokeAlign, StrokeJoin,
StrokeProps, VectorNetwork, WindingRule, proto,
};
use figcraft_geom::{Affine, BezPath, Rect, Shape};
use serde_json::{Value, json};
@@ -83,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
ok()
}),
spec!("layout.suggest", "Suggest auto layout", "{ids?}", true, layout_add),
spec!("layout.suggest", "Suggest auto layout", "{ids?, direction? (horizontal|vertical|grid)}", true, layout_add),
spec!("object.moveToPage", "Move to page", "{ids?, page}", true, move_to_page),
spec!("object.setThumbnail", "Set as thumbnail", "{id?}", true, |s, p| {
let id = targets(s, p).first().copied().ok_or_else(|| bad("object.setThumbnail", "select a frame"))?;
@@ -385,56 +385,35 @@ fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
let parent = doc.get(top)?.parent.ok_or_else(|| bad("vector.flatten", "can't flatten a page"))?;
let index = doc.index_in_parent(top).map(|i| i + 1);
let pw = doc.world_transform(parent);
let mut net = VectorNetwork::default();
for i in &ids {
let Some(n) = doc.node(*i) else { continue };
// Vectors keep their own network (open paths and unfilled loops have no fill outline);
// booleans flatten to their result; strokes don't get outlined.
let mut part = match &n.kind {
NodeKind::Vector { network } => network.clone(),
_ => {
let Some((op, rule)) = (match &n.kind {
NodeKind::Group | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. } => {
figcraft_render::subtree_outline(doc, n, 0)
}
_ => figcraft_render::node_outline(doc, n),
}) else {
continue;
};
let mut part = VectorNetwork::from_path(&op);
if rule == figcraft_render::FillRule::EvenOdd {
for r in &mut part.regions {
r.winding = WindingRule::EvenOdd;
}
}
part
let first = ids.first().and_then(|i| doc.node(*i)).cloned();
let fallback_fills = first
.as_ref()
.filter(|node| matches!(node.kind, NodeKind::Group | NodeKind::Line) && node.fills.is_empty())
.map(|_| vec![Paint::solid(Color::DEFAULT_SHAPE)]);
let output_fills = first
.as_ref()
.map(|node| {
if matches!(node.kind, NodeKind::Group | NodeKind::Line) && node.fills.is_empty() {
fallback_fills.clone().unwrap_or_default()
} else {
node.fills.clone()
}
};
// Into the common parent's space.
let all: Vec<usize> = (0..part.vertices.len()).collect();
crate::vectoredit::ops::transform_vertices(&mut part, &all, pw.inverse() * doc.world_transform(*i));
let (nv, ns) = (net.vertices.len(), net.segments.len());
net.vertices.extend(part.vertices);
net.segments.extend(part.segments.into_iter().map(|s| Segment { a: s.a + nv, b: s.b + nv, ..s }));
net.regions.extend(
part.regions.into_iter().map(|r| Region { loops: r.loops.into_iter().map(|l| l.into_iter().map(|s| s + ns).collect()).collect(), ..r }),
);
})
.unwrap_or_default();
let mut net = VectorNetwork::default();
for id in &ids {
collect_flatten_parts(doc, *id, pw, fallback_fills.as_deref(), &output_fills, &mut net, 0)?;
}
let Some(bb) = net.bounds() else {
return Err(EngineError::Disabled("vector.flatten".into(), "nothing to flatten".into()));
};
let first = ids.first().and_then(|i| doc.node(*i)).cloned();
net.translate(-bb.x0, -bb.y0);
let vid = doc.alloc_id();
let name = first.as_ref().map(|f| f.name.clone()).unwrap_or_else(|| "Vector".into());
let mut v = Node::new(vid, NodeKind::Vector { network: net }, name, bb.width(), bb.height());
v.transform = Affine::translate((bb.x0, bb.y0));
if let Some(f) = first {
v.fills = if matches!(f.kind, NodeKind::Group | NodeKind::Line) && f.fills.is_empty() {
vec![Paint::solid(Color::DEFAULT_SHAPE)]
} else {
f.fills.clone()
};
v.fills = output_fills;
v.strokes = f.strokes.clone();
v.stroke = StrokeProps { align: StrokeAlign::Center, ..f.stroke.clone() };
v.effects = f.effects.clone();
@@ -449,6 +428,99 @@ fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"id": vid}))
}
fn collect_flatten_parts(
doc: &Document,
id: NodeId,
parent_world: Affine,
fallback_fills: Option<&[Paint]>,
output_fills: &[Paint],
net: &mut VectorNetwork,
depth: usize,
) -> Result<()> {
if depth > 64 {
return Err(bad("vector.flatten", "group nesting exceeds 64"));
}
let Some(node) = doc.node(id) else { return Ok(()) };
if matches!(node.kind, NodeKind::Group | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. }) && !node.children.is_empty() {
// Match subtree_outline: a nonempty frame contributes its own visible fill, then
// visible descendants in stacking order. Child clipping remains the container's outline rule.
if node.kind.is_frame_like()
&& node.fills.iter().any(|paint| paint.visible)
&& let Some(path) = figcraft_doc::geometry::shape_path(node)
{
let frame = VectorNetwork::from_path(&path);
let into_parent = parent_world.inverse() * doc.world_transform(id);
append_flatten_part(node, fallback_fills, output_fills, into_parent, frame, net);
}
for child in &node.children {
if !doc.node(*child).is_some_and(|child| child.visible) {
continue;
}
collect_flatten_parts(doc, *child, parent_world, fallback_fills, output_fills, net, depth + 1)?;
}
return Ok(());
}
let part = match &node.kind {
NodeKind::Vector { network } => {
let mut part = network.clone();
if !part.regions.is_empty() && part.regions.iter().all(|region| region.fills.is_none() && region.paint_space.is_none()) {
let winding = if part.regions.iter().any(|region| region.winding == WindingRule::EvenOdd) {
WindingRule::EvenOdd
} else {
WindingRule::NonZero
};
let loops = std::mem::take(&mut part.regions).into_iter().flat_map(|region| region.loops).collect();
part.regions = vec![Region { loops, winding, ..Default::default() }];
}
part
}
_ => {
let Some((path, rule)) = figcraft_render::node_outline(doc, node) else { return Ok(()) };
let mut part = VectorNetwork::from_path(&path);
for region in &mut part.regions {
if rule == figcraft_render::FillRule::EvenOdd {
region.winding = WindingRule::EvenOdd;
}
}
part
}
};
let into_parent = parent_world.inverse() * doc.world_transform(id);
append_flatten_part(node, fallback_fills, output_fills, into_parent, part, net);
Ok(())
}
fn append_flatten_part(
node: &Node,
fallback_fills: Option<&[Paint]>,
output_fills: &[Paint],
into_parent: Affine,
mut part: VectorNetwork,
net: &mut VectorNetwork,
) {
for region in &mut part.regions {
let (width, height, paint_transform) = region.paint_frame(node.width, node.height);
if region.fills.is_none() {
let effective = if node.fills.is_empty() { fallback_fills.unwrap_or(&node.fills) } else { &node.fills };
if effective != output_fills {
region.fills = Some(effective.to_vec());
}
}
region.paint_space = Some(RegionPaintSpace { width, height, transform: paint_transform });
}
let vertices: Vec<usize> = (0..part.vertices.len()).collect();
crate::vectoredit::ops::transform_vertices(&mut part, &vertices, into_parent);
let (nv, ns) = (net.vertices.len(), net.segments.len());
net.vertices.extend(part.vertices);
net.segments.extend(part.segments.into_iter().map(|segment| Segment { a: segment.a + nv, b: segment.b + nv, ..segment }));
net.regions.extend(
part.regions.into_iter().map(|region| Region {
loops: region.loops.into_iter().map(|lp| lp.into_iter().map(|segment| segment + ns).collect()).collect(),
..region
}),
);
}
fn outline_stroke(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("vector.outlineStroke", s, p)?;
let mut out = Vec::new();
@@ -489,7 +561,8 @@ fn outline_stroke(s: &mut Session, p: &Value) -> Result<Value> {
v.fills = n.strokes.clone();
v.strokes.clear();
v.styles.fill = n.styles.stroke;
if n.fills.iter().any(|f| f.visible) && closed {
let region_fill = matches!(&n.kind, NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.as_ref().is_some_and(|fills| fills.iter().any(|f| f.visible))));
if (n.fills.iter().any(|f| f.visible) || region_fill) && closed {
// Keep the fill as its own layer under the outline, grouped.
doc.insert(v, parent, idx.map(|x| x + 1))?;
let fill_id = i;
@@ -634,12 +707,53 @@ fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
let doc = s.doc_mut()?;
let n = doc.get_mut(i)?;
if let NodeKind::Vector { network } = &mut n.kind {
let fill = network.fill_path();
let src = if fill.elements().is_empty() { network.stroke_path() } else { fill };
let simplified = figcraft_geom::kurbo::simplify::simplify_bezpath(src.iter(), tol, &Default::default());
let regions = !network.regions.is_empty();
let mut net = VectorNetwork::from_path(&simplified);
if !regions {
let source = network.clone();
let mut net = VectorNetwork::default();
let legacy = source.regions.iter().all(|region| region.fills.is_none() && region.paint_space.is_none());
if legacy && !source.regions.is_empty() {
let winding = if source.regions.iter().any(|region| region.winding == WindingRule::EvenOdd) {
WindingRule::EvenOdd
} else {
WindingRule::NonZero
};
let path = source.fill_path();
let path = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
let (path, output_winding) = match figcraft_pathops::simplify_with_rule(&path, winding == WindingRule::EvenOdd) {
Ok(path) => (path, WindingRule::EvenOdd),
Err(_) => (path, winding),
};
let combined = Region { winding: output_winding, ..Default::default() };
append_region_path(&mut net, &path, &combined, output_winding);
} else {
for (index, region) in source.regions.iter().enumerate() {
let Some(path) = source.region_path(index) else { continue };
let path = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
let path = figcraft_pathops::simplify_with_rule(&path, region.winding == WindingRule::EvenOdd).unwrap_or(path);
append_region_path(&mut net, &path, region, region.winding);
}
}
if !source.regions.is_empty() {
let mut used = vec![false; source.segments.len()];
for segment in source.regions.iter().flat_map(|region| region.loops.iter().flatten()) {
if let Some(used) = used.get_mut(*segment) {
*used = true;
}
}
let mut open = source.clone();
open.segments = source.segments.iter().enumerate().filter_map(|(index, segment)| (!used[index]).then_some(*segment)).collect();
open.regions.clear();
if !open.segments.is_empty() {
let path = open.stroke_path();
let simplified = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
let mut loose = VectorNetwork::from_path(&simplified);
loose.regions.clear();
append_network(&mut net, loose);
}
}
if source.regions.is_empty() {
let path = source.stroke_path();
let simplified = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
net = VectorNetwork::from_path(&simplified);
net.regions.clear();
}
*network = net;
@@ -657,22 +771,99 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
if !n.children.is_empty() {
continue;
}
let Some((path, _)) = figcraft_render::node_outline(doc, &n) else { continue };
let props = StrokeProps { weight: d.abs() * 2.0, join: StrokeJoin::Round, align: StrokeAlign::Center, ..Default::default() };
let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
let res = if d >= 0.0 {
figcraft_pathops::boolean(&[path, ring], BoolOp::Union)
} else {
figcraft_pathops::boolean(&[path, ring], BoolOp::Subtract)
};
let Ok(mut res) = res else { continue };
let bb = res.bounding_box();
res.apply_affine(Affine::translate((-bb.x0, -bb.y0)));
let nm = doc.get_mut(i)?;
let mut net = VectorNetwork::from_path(&res);
for r in &mut net.regions {
r.winding = WindingRule::EvenOdd;
if d == 0.0 {
continue;
}
let props = StrokeProps { weight: d.abs() * 2.0, join: StrokeJoin::Round, align: StrokeAlign::Center, ..Default::default() };
let NodeKind::Vector { network: source } = &n.kind else {
let Some((path, _)) = figcraft_render::node_outline(doc, &n) else { continue };
let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
let result = if d >= 0.0 {
figcraft_pathops::boolean(&[path, ring], BoolOp::Union)
} else {
figcraft_pathops::boolean(&[path, ring], BoolOp::Subtract)
}
.map_err(|error| bad("vector.offset", error.to_string()))?;
let mut result = result;
let bounds = result.bounding_box();
result.apply_affine(Affine::translate((-bounds.x0, -bounds.y0)));
let node = doc.get_mut(i)?;
node.kind = NodeKind::Vector { network: VectorNetwork::from_path(&result) };
node.transform *= Affine::translate((bounds.x0, bounds.y0));
node.width = bounds.width();
node.height = bounds.height();
continue;
};
let mut net = VectorNetwork::default();
let legacy = source.regions.iter().all(|region| region.fills.is_none() && region.paint_space.is_none());
if source.regions.is_empty() {
let Some((path, _)) = figcraft_render::node_outline(doc, &n) else { continue };
let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
let mut res = if d >= 0.0 {
figcraft_pathops::boolean(&[path.clone(), ring], BoolOp::Union)
} else {
figcraft_pathops::boolean(&[path.clone(), ring], BoolOp::Subtract)
}
.map_err(|error| bad("vector.offset", error.to_string()))?;
let bb = res.bounding_box();
res.apply_affine(Affine::translate((-bb.x0, -bb.y0)));
let nm = doc.get_mut(i)?;
nm.kind = NodeKind::Vector { network: VectorNetwork::from_path(&res) };
nm.transform *= Affine::translate((bb.x0, bb.y0));
nm.width = bb.width();
nm.height = bb.height();
continue;
}
if legacy {
let path = source.fill_path();
let even_odd = source.regions.iter().any(|region| region.winding == WindingRule::EvenOdd);
let path = figcraft_pathops::simplify_with_rule(&path, even_odd).map_err(|error| bad("vector.offset", error.to_string()))?;
let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
let result = if d >= 0.0 {
figcraft_pathops::boolean(&[path.clone(), ring], BoolOp::Union)
} else {
figcraft_pathops::boolean(&[path, ring], BoolOp::Subtract)
}
.map_err(|error| bad("vector.offset", error.to_string()))?;
let mut part = VectorNetwork::from_path(&result);
let (paint_width, paint_height, paint_transform) = source.regions[0].paint_frame(n.width, n.height);
for region in &mut part.regions {
region.winding = WindingRule::EvenOdd;
region.fills = source.regions[0].fills.clone();
region.paint_space = Some(RegionPaintSpace { width: paint_width, height: paint_height, transform: paint_transform });
}
append_network(&mut net, part);
} else {
for (index, region) in source.regions.iter().enumerate() {
let Some(path) = source.region_path(index) else { continue };
let mut paint_region = region.clone();
let (paint_width, paint_height, paint_transform) = region.paint_frame(n.width, n.height);
paint_region.paint_space = Some(RegionPaintSpace { width: paint_width, height: paint_height, transform: paint_transform });
let path = figcraft_pathops::simplify_with_rule(&path, region.winding == WindingRule::EvenOdd)
.map_err(|error| bad("vector.offset", error.to_string()))?;
let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
let res = if d >= 0.0 {
figcraft_pathops::boolean(&[path.clone(), ring], BoolOp::Union)
} else {
figcraft_pathops::boolean(&[path.clone(), ring], BoolOp::Subtract)
}
.map_err(|error| bad("vector.offset", error.to_string()))?;
// A collapsed negative offset removes this region, while other source regions stay.
if res.elements().is_empty() {
continue;
}
append_region_path(&mut net, &res, &paint_region, WindingRule::EvenOdd);
}
}
let Some(bb) = net.bounds() else {
let nm = doc.get_mut(i)?;
nm.kind = NodeKind::Vector { network: net };
nm.width = 0.0;
nm.height = 0.0;
continue;
};
net.translate(-bb.x0, -bb.y0);
let nm = doc.get_mut(i)?;
nm.kind = NodeKind::Vector { network: net };
nm.transform *= Affine::translate((bb.x0, bb.y0));
nm.width = bb.width();
@@ -681,6 +872,25 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
fn append_region_path(net: &mut VectorNetwork, path: &BezPath, source: &Region, winding: WindingRule) {
let mut part = VectorNetwork::from_path(path);
for region in &mut part.regions {
region.winding = winding;
region.fills = source.fills.clone();
region.paint_space = source.paint_space;
}
append_network(net, part);
}
fn append_network(net: &mut VectorNetwork, part: VectorNetwork) {
let (nv, ns) = (net.vertices.len(), net.segments.len());
net.vertices.extend(part.vertices);
net.segments.extend(part.segments.into_iter().map(|s| Segment { a: s.a + nv, b: s.b + nv, ..s }));
net.regions.extend(
part.regions.into_iter().map(|r| Region { loops: r.loops.into_iter().map(|l| l.into_iter().map(|s| s + ns).collect()).collect(), ..r }),
);
}
/// Join open vectors whose end points touch into one vector.
fn join(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("vector.join", s, p)?;
@@ -695,7 +905,7 @@ fn join(s: &mut Session, p: &Value) -> Result<Value> {
let last = network.vertices.len() - 1;
network.segments.push(Segment::line(last, 0));
let all: Vec<usize> = (0..network.segments.len()).collect();
network.regions.push(Region { loops: vec![all], winding: WindingRule::NonZero });
network.regions.push(Region { loops: vec![all], winding: WindingRule::NonZero, fills: None, paint_space: None });
}
}
return ok();
+29 -12
View File
@@ -164,7 +164,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"variable.create",
"Create variable",
"{collection, name, type: color|number|string|boolean, value (default mode) | values {modeName: value}}",
"{collection, name, type: color|number|string|boolean, value (default mode) | values {modeName: value}, description?}",
true,
var_create
),
@@ -775,13 +775,20 @@ pub fn bind(s: &mut Session, p: &Value) -> Result<Value> {
Some(v) => Some(doc.variables.get(v).map(|x| x.id.clone()).ok_or_else(|| bad(cmd, "no such variable"))?),
None => None,
};
bind_variable(cmd, doc, &ids, &field, vid.as_deref())?;
ok()
}
/// Bind an already resolved variable id, or unbind, inside the caller's transaction. Props and
/// `variable.bind` share type checks and paint alpha handling; normalisation applies the value.
pub(crate) fn bind_variable(cmd: &str, doc: &mut Document, ids: &[NodeId], field: &str, vid: Option<&str>) -> Result<()> {
// An instance's component property takes a variable of its type; other fields the types their
// scopes belong to.
if let Some(v) = vid.as_ref().and_then(|v| doc.variables.get(v)) {
for i in &ids {
if let Some(v) = vid.and_then(|v| doc.variables.get(v)) {
for i in ids {
match field.strip_prefix(crate::components::PROP_BINDING) {
Some(name) => super::component::check_prop_binding(doc, *i, name, v.kind).map_err(|e| bad(cmd, e))?,
None if !super::variables::field_takes(doc, *i, &field, v.kind) => {
None if !super::variables::field_takes(doc, *i, field, v.kind) => {
return Err(bad(cmd, format!("`{field}` doesn't take a {} variable", v.kind.id())));
}
None => {}
@@ -789,22 +796,32 @@ pub fn bind(s: &mut Session, p: &Value) -> Result<Value> {
}
}
for i in ids {
let n = doc.get_mut(i)?;
match &vid {
let n = doc.get_mut(*i)?;
match vid {
Some(v) => {
if n.bound.get(&field) != Some(v) {
rebind_paint_alpha(&mut n.fills, &mut n.strokes, &field, true);
if n.bound.get(field).map(String::as_str) != Some(v) {
rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, true);
}
n.bound.insert(field.clone(), v.clone());
n.bound.insert(field.to_string(), v.to_string());
}
None => {
if n.bound.remove(&field).is_some() {
rebind_paint_alpha(&mut n.fills, &mut n.strokes, &field, false);
if n.bound.remove(field).is_some() {
rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, false);
}
}
}
}
ok()
Ok(())
}
/// Remove every binding key of the slot (`fill` and `fills/0/color` name the same one); true when
/// one was there.
pub(crate) fn unbind_slot(n: &mut figcraft_doc::Node, fills: bool, index: usize) -> bool {
use super::lists::{BoundList, list_binding};
let list = if fills { BoundList::Fills } else { BoundList::Strokes };
let before = n.bound.len();
n.bound.retain(|key, _| list_binding(key).is_none_or(|(l, i, field)| !(l == list && i == index && field == "color")));
n.bound.len() != before
}
/// A colour variable bound to a solid paint owns its whole colour, alpha included. Binding takes the
+15 -9
View File
@@ -279,13 +279,19 @@ pub fn specs() -> Vec<CommandSpec> {
.ok_or_else(|| bad("text.decoration", "`decoration`: none|underline|strikethrough"))?;
style(s, p, |st| st.decoration = d)
}),
spec!("text.listStyle", "List style", "{style: none|bulleted|numbered, ids?} while editing: the selected paragraphs", true, |s, p| {
let l: ListStyle = get(p, "style")
.or_else(|| get(p, "list"))
.and_then(super::props::enum_of)
.ok_or_else(|| bad("text.listStyle", "`style`: none|bulleted|numbered"))?;
set_list(s, p, l, false)
}),
spec!(
"text.listStyle",
"List style",
"{style | list: none|bulleted|numbered, ids?} while editing: the selected paragraphs",
true,
|s, p| {
let l: ListStyle = get(p, "style")
.or_else(|| get(p, "list"))
.and_then(super::props::enum_of)
.ok_or_else(|| bad("text.listStyle", "`style`: none|bulleted|numbered"))?;
set_list(s, p, l, false)
}
),
spec!("text.paragraphIndent", "Paragraph indent", "{indent (px), ids?}", true, |s, p| {
let v = finite(req_num("text.paragraphIndent", p, "indent")?).clamp(0.0, 10_000.0);
para(s, p, |t| t.paragraph_indent = v)
@@ -374,7 +380,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("text.endPreview", "End text preview", "{} puts the document back as it was before text.preview → {ended}", false, |s, _| {
Ok(json!({"ended": crate::textpreview::end(s)}))
}),
spec!("text.set", "Set text", "{id?, characters}", true, |s, p| {
spec!("text.set", "Set text", "{id?, characters | text}", true, |s, p| {
let c = string(p, "characters").or_else(|| string(p, "text")).ok_or_else(|| bad("text.set", "`characters` required"))?;
let ids = req_targets("text.set", s, p)?;
let doc = s.doc_mut()?;
@@ -390,7 +396,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"text.style",
"Style text range",
"{id?, start?, end?, fontFamily?, fontStyle?, fontWeight?, fontSize?, lineHeight?, letterSpacing?, textDecoration?, textCase?, hyperlink?, textFills?}",
"{id?, start?, end?, fontFamily?, fontStyle?, fontWeight?, fontVariations? | variationSettings?, fontSize?, lineHeight?, letterSpacing?, textDecoration?, textDecorationStyle?, textDecorationThickness?, textDecorationOffset?, textDecorationSkipInk?, textDecorationColor?, textCase?, hyperlink?, textFills?}",
true,
|s, p| {
let ids = req_targets("text.style", s, p)?;
+28
View File
@@ -46,6 +46,34 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// Props resolve an exact id first, then an unambiguous exact name or `Collection/name`. Compare
/// whole names: both collection and variable names can contain `/`. Other commands keep their
/// existing first-match lookup.
pub(crate) fn resolve_strict(cmd: &str, doc: &Document, name: &str) -> Result<VariableId> {
let vars = &doc.variables;
if let Some(v) = vars.variables.iter().find(|v| v.id == name) {
return Ok(v.id.clone());
}
let collections: std::collections::BTreeMap<_, _> = vars.collections.iter().map(|c| (c.id.as_str(), c.name.as_str())).collect();
let mut candidates = std::collections::BTreeMap::new();
for v in &vars.variables {
let collection = collections.get(v.collection.as_str()).copied().unwrap_or(v.collection.as_str());
let qualified = format!("{collection}/{}", v.name);
if v.name == name || qualified == name {
candidates.insert(v.id.as_str(), qualified);
}
}
match candidates.keys().next().copied() {
None => Err(bad(cmd, format!("no such variable `{name}`"))),
Some(id) if candidates.len() == 1 => Ok(id.to_string()),
Some(_) => {
let mut names: Vec<_> = candidates.into_values().collect();
names.sort();
Err(bad(cmd, format!("ambiguous variable `{name}`: {} (use a variable id to choose)", names.join(", "))))
}
}
}
/// The first of `base`, `base 2`, `base 3`… that no collection is named.
pub(crate) fn free_collection_name(doc: &Document, base: &str) -> String {
(1..1000)
+1 -1
View File
@@ -373,7 +373,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"pref.uiScale",
"Interface size",
"{scale?: a size from 0.75 to 3 relative to the system's display scale, 1 being the system's own size, so 1.25 is 125 %, not 125 (outside the range it stops at the nearest end; \"auto\" is 1; omit: just read it)} → {scale}: how large the whole interface is drawn; the canvas zoom is separate",
"{scale? (a size from 0.75 to 3 relative to the system's display scale: 1 is the system's own size, so 1.25 is 125 %, not 125; outside the range it stops at the nearest end; \"auto\" is 1; omit: just read it)} → {scale}: how large the whole interface is drawn; the canvas zoom is separate",
false,
|s, p| {
match get(p, "scale") {
+78
View File
@@ -0,0 +1,78 @@
//! Comments from an AI agent (#1414): a command run as an agent (`Session::execute_as`, which the
//! control channel's and MCP's `engine.execute {agent}` use) signs comments with the agent's own
//! name and marks them as an agent's; the user's own comments stay as they were.
use serde_json::{Value, json};
use crate::Session;
use crate::cmd::comment::agent_author;
use crate::live::initials;
fn as_agent(s: &mut Session, agent: &str, cmd: &str, p: Value) -> Value {
s.execute_as(Some(agent), cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn thread(s: &mut Session, agent: Option<&str>) -> Value {
s.execute_as(agent, "comment.list", &json!({"filter": "all"})).unwrap()["comments"][0].clone()
}
#[test]
fn an_agents_comments_carry_its_name_and_the_agent_marker() {
let mut s = Session::new();
let c = s.execute("comment.add", &json!({"x": 10, "y": 10, "text": "@Claude make this bigger"})).unwrap()["id"].as_u64().unwrap();
as_agent(&mut s, "Claude", "comment.reply", json!({"id": c, "text": "Done"}));
let t = thread(&mut s, None);
assert_eq!((t["author"].as_str(), t["agent"].as_bool()), (Some("You"), Some(false)), "the user's own comment is unchanged: {t}");
let r = &t["replies"][0];
assert_eq!((r["author"].as_str(), r["agent"].as_bool()), (Some("Claude (agent)"), Some(true)), "{r}");
let doc = s.doc().unwrap();
let m = &doc.comment(c).unwrap().replies[0];
assert!(m.agent && m.author == "Claude (agent)");
// The agent acts as itself again later: no `author` on any call.
as_agent(&mut s, "Claude", "comment.react", json!({"id": c, "emoji": "👍"}));
assert_eq!(s.doc().unwrap().comment(c).unwrap().first.reactions[0].users, ["Claude (agent)"]);
assert_eq!(as_agent(&mut s, "Claude", "comment.people", json!({}))["you"], "Claude (agent)");
assert_eq!(s.execute("comment.people", &json!({})).unwrap()["you"], "You", "the user's name never changes");
assert_eq!(s.agent, None, "the agent acts only for its own call");
}
#[test]
fn an_agents_read_state_is_its_own() {
let mut s = Session::new();
let c = s.execute("comment.add", &json!({"x": 10, "y": 10, "text": "Look"})).unwrap()["id"].as_u64().unwrap();
as_agent(&mut s, "Claude", "comment.reply", json!({"id": c, "text": "On it"}));
assert_eq!(thread(&mut s, None)["unread"], true, "the agent's reply is unread for the user");
as_agent(&mut s, "Claude", "comment.markRead", json!({}));
assert_eq!(thread(&mut s, None)["unread"], true, "the agent reading it doesn't read it for the user");
assert_eq!(thread(&mut s, Some("Claude"))["unread"], false);
}
#[test]
fn an_agent_without_a_name_is_still_not_the_user() {
let mut s = Session::new();
as_agent(&mut s, "", "comment.add", json!({"x": 0, "y": 0, "text": "Hi"}));
let t = thread(&mut s, None);
assert_eq!((t["author"].as_str(), t["agent"].as_bool()), (Some("AI agent"), Some(true)), "{t}");
// An explicit `author` still wins, and the message is still an agent's.
as_agent(&mut s, "Claude", "comment.reply", json!({"id": t["id"], "text": "x", "author": "Helper"}));
let r = &thread(&mut s, None)["replies"][0];
assert_eq!((r["author"].as_str(), r["agent"].as_bool()), (Some("Helper"), Some(true)), "{r}");
}
#[test]
fn agent_names_are_cleaned() {
assert_eq!(agent_author(" Claude "), "Claude (agent)");
assert_eq!(agent_author("\u{7}\n "), "AI agent");
assert_eq!(agent_author("a\u{0}b"), "ab (agent)");
let long = "x".repeat(500);
assert_eq!(agent_author(&long).chars().count(), 64 + " (agent)".len());
}
#[test]
fn an_agents_avatar_shows_its_names_initial() {
assert_eq!(initials("Claude (agent)"), "C");
assert_eq!(initials("AI agent"), "AA");
assert_eq!(initials("Ada Lovelace"), "AL");
assert_eq!(initials("(x) [y]"), "([", "no word starts with a letter: as before");
assert_eq!(initials(" "), "?");
}
@@ -0,0 +1,44 @@
//! `comment.list` reports the names each message @mentions, by the rule the thread view highlights
//! them with: resolved against the file's people where it can be, and whether the name is you.
use serde_json::{Value, json};
use crate::Session;
fn mentions(s: &mut Session, p: Value) -> Vec<Value> {
let l = s.execute("comment.list", &p).unwrap();
let c = &l["comments"][0];
[&c["mentions"], &c["replies"][0]["mentions"]].into_iter().cloned().collect()
}
#[test]
fn a_message_lists_the_names_it_mentions_and_not_an_email_address() {
let mut s = Session::new();
let id = s.execute("comment.add", &json!({"x": 0, "y": 0, "text": "Hi", "author": "Ada Lovelace"})).unwrap()["id"].as_u64().unwrap();
let text = "@ada lovelace and @You: please check, or ask @Bob (mail a@b.com). Thanks @Ada Lovelace";
s.execute("comment.reply", &json!({"id": id, "text": text, "author": "Sam"})).unwrap();
let [first, reply] = <[Value; 2]>::try_from(mentions(&mut s, json!({}))).unwrap();
assert_eq!(first, json!([]));
assert_eq!(
reply,
json!([
{"name": "Ada Lovelace", "known": true, "you": false},
{"name": "You", "known": true, "you": true},
{"name": "Bob", "known": false, "you": false},
]),
"two people the file knows (once each, as the file spells them), one name it doesn't, no email"
);
// `author` says whose list it is: then Ada is "you".
let reply = mentions(&mut s, json!({"author": "Ada Lovelace"})).remove(1);
assert_eq!(reply[0], json!({"name": "Ada Lovelace", "known": true, "you": true}));
}
#[test]
fn the_mention_rule_matches_the_highlighting_rule() {
let people = ["Sam", "Sam Lee"];
let found = figcraft_doc::mentions("@Sam Lee, @sam, x@Sam, @Zoe!", &people);
let names: Vec<_> = found.iter().map(|(r, who)| (r.clone(), *who)).collect();
assert_eq!(names, vec![(0..8, Some("Sam Lee")), (10..14, Some("Sam")), (23..27, None)]);
assert_eq!(figcraft_doc::mention_ranges("@Sam Lee, @sam, x@Sam, @Zoe!", &people), vec![0..8, 10..14]);
assert!(figcraft_doc::mentions("@ and a@b.com", &people).is_empty());
}
@@ -106,6 +106,17 @@ fn components_take_their_properties_as_parameters() {
] {
assert!(leptos.contains(want), "Leptos has {want:?}:\n{leptos}");
}
let dart = code(&mut s, c, "flutter");
for want in [
"import 'package:flutter/material.dart';\n\nclass Card extends StatelessWidget {\n",
" const Card({\n super.key,\n this.showDot = true,\n this.title = \"Hello\",\n });\n",
" final bool showDot;\n final String title;\n",
" Widget build(BuildContext context) {\n return Container(",
"if (showDot)\n",
"Text(\n title,",
] {
assert!(dart.contains(want), "Flutter has {want:?}:\n{dart}");
}
// The command describes the properties.
let v = component_code(&mut s, c, "react");
assert_eq!(v["name"], "Card");
@@ -140,6 +151,7 @@ fn instances_are_calls_passing_the_values_they_set() {
assert!(react.contains("export function Card({"));
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Card(showDot: false, title: \"Buy\")\n");
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Buy\")\n");
assert_eq!(component_code(&mut s, i, "flutter")["usage"], "Card(showDot: false, title: \"Buy\")\n");
assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Card({ showDot: false, title: \"Buy\" })\n");
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "<Card show_dot={false} title=\"Buy\" />\n");
// Text that needs escaping is an expression in JSX and escaped per language.
@@ -147,6 +159,7 @@ fn instances_are_calls_passing_the_values_they_set() {
assert_eq!(component_code(&mut s, i, "react")["usage"], "<Card showDot={false} title={\"Say \\\"hi\\\" $5\\nnow\"} />\n");
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Card(showDot: false, title: \"Say \\\"hi\\\" $5\\nnow\")\n");
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Say \\\"hi\\\" \\$5\\nnow\")\n");
assert_eq!(component_code(&mut s, i, "flutter")["usage"], "Card(showDot: false, title: \"Say \\\"hi\\\" \\$5\\nnow\")\n");
assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Card({ showDot: false, title: \"Say \\\"hi\\\" $5\\nnow\" })\n");
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "<Card show_dot={false} title={\"Say \\\"hi\\\" $5\\nnow\"} />\n");
// A quoted title becomes a binding whose attribute value is escaped: the expression itself
@@ -175,6 +188,8 @@ fn nested_instances_in_a_layer_tree_are_calls() {
assert!(!react.contains("Hello"), "the instance's layers aren't spelled out: {react}");
assert!(code(&mut s, screen, "swiftui").contains("Card(title: \"Inbox\")"));
assert!(code(&mut s, screen, "compose").contains("Card(title = \"Inbox\")"));
let dart = code(&mut s, screen, "flutter");
assert!(dart.contains("Card(title: \"Inbox\")") && !dart.contains("Hello"), "{dart}");
let leptos = code(&mut s, screen, "leptos");
assert!(leptos.contains("pub fn HomeScreen() -> impl IntoView {") && leptos.contains("<Card title=\"Inbox\" />"), "{leptos}");
assert!(!leptos.contains("Hello"), "{leptos}");
@@ -232,6 +247,19 @@ fn variant_sets_share_one_definition() {
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "<Button state=ButtonState::Hover />\n");
}
#[test]
fn flutter_variant_sets_share_one_enum() {
let mut s = Session::new();
let (set, _, _) = button_set(&mut s);
let dart = code(&mut s, set, "flutter");
assert!(dart.contains("enum ButtonState { defaultValue, hover }\n"), "{dart}");
assert!(dart.contains(" this.state = ButtonState.defaultValue,\n"), "{dart}");
assert!(dart.contains(" final ButtonState state;\n"), "{dart}");
let i = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 600})));
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"State": "Hover"}}));
assert_eq!(component_code(&mut s, i, "flutter")["usage"], "Button(state: ButtonState.hover)\n");
}
#[test]
fn instance_swap_properties_take_views() {
let mut s = Session::new();
@@ -252,6 +280,10 @@ fn instance_swap_properties_take_views() {
assert!(swift.contains(" var icon: AnyView = AnyView(HeartIcon())"), "{swift}");
let kt = code(&mut s, chip, "compose");
assert!(kt.contains(" icon: @Composable () -> Unit = { HeartIcon() },") && kt.contains("icon()"), "{kt}");
let dart = code(&mut s, chip, "flutter");
assert!(dart.contains(" final Widget icon;\n") && dart.contains(" this.icon = const HeartIcon(),\n"), "{dart}");
assert!(dart.contains("child: icon\n"), "the nested instance is the parameter: {dart}");
assert!(!dart.contains("child: Container("), "the swap's widget tree isn't spelled out: {dart}");
let ark = code(&mut s, chip, "arkui");
for want in [
" @BuilderParam icon: () => void = this.defaultIconBuilder",
@@ -272,6 +304,7 @@ fn instance_swap_properties_take_views() {
assert_eq!(component_code(&mut s, i, "react")["usage"], "<Chip icon={<StarIcon />} />\n");
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Chip(icon: AnyView(StarIcon()))\n");
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Chip(icon = { StarIcon() })\n");
assert_eq!(component_code(&mut s, i, "flutter")["usage"], "Chip(icon: StarIcon())\n");
assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Chip({ icon: () => { StarIcon() } })\n");
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "<Chip icon={|| view! { <StarIcon /> }} />\n");
assert_eq!(component_code(&mut s, i, "vue")["usage"], "<Chip :icon=\"StarIcon\" />\n");
@@ -289,6 +322,9 @@ fn slots_take_optional_content() {
assert!(react.contains(" content?: React.ReactNode;") && react.contains("{content ?? ("), "{react}");
assert!(code(&mut s, panel, "swiftui").contains("var content: AnyView? = nil"));
assert!(code(&mut s, panel, "compose").contains("content: (@Composable () -> Unit)? = null,"));
let dart = code(&mut s, panel, "flutter");
assert!(dart.contains(" final List<Widget>? content;\n") && dart.contains(" this.content,\n"), "{dart}");
assert!(dart.contains("...?content,\n") && dart.contains("if (content == null) ...[\n"), "{dart}");
let ark = code(&mut s, panel, "arkui");
for want in [
" @BuilderParam content: () => void = this.defaultContentBuilder",
@@ -305,11 +341,14 @@ fn slots_take_optional_content() {
let badge = idv(&ex(&mut s, "component.create", json!({"ids": [g]})));
let i = idv(&ex(&mut s, "instance.create", json!({"component": panel, "x": 400, "y": 0})));
assert_eq!(component_code(&mut s, i, "react")["usage"], "<Panel />\n", "the main's content isn't passed");
assert_eq!(component_code(&mut s, i, "flutter")["usage"], "Panel()\n", "the main's content isn't passed");
ex(&mut s, "instance.slotInsert", json!({"id": i, "slot": "Content", "component": badge, "replace": true}));
let usage = component_code(&mut s, i, "react")["usage"].as_str().unwrap_or_default().to_string();
assert!(usage.starts_with("<Panel content={(\n") && usage.contains("<Badge />"), "{usage}");
let usage = component_code(&mut s, i, "leptos")["usage"].as_str().unwrap_or_default().to_string();
assert!(usage.starts_with("<Panel content={|| view! {\n") && usage.contains("<Badge />") && usage.ends_with("}} />\n"), "{usage}");
let usage = component_code(&mut s, i, "flutter")["usage"].as_str().unwrap_or_default().to_string();
assert!(usage.starts_with("Panel(content: [\n") && usage.contains("Badge()"), "{usage}");
let usage = component_code(&mut s, i, "arkui")["usage"].as_str().unwrap_or_default().to_string();
assert!(usage.starts_with("Panel({ content: () => {\n") && usage.contains("Badge()") && usage.ends_with("} })\n"), "{usage}");
}
@@ -359,10 +398,22 @@ fn identifiers_are_escaped_and_unique() {
] {
assert!(leptos.contains(want), "Leptos has {want:?}:\n{leptos}");
}
// Dart: lowerCamel members, keywords suffixed, never a type the widget tree names.
let dart = code_for(doc, c, CodeLang::Flutter);
for want in [
"class TextComponent extends StatelessWidget {",
"enum TextComponentColorVariant { red }\n",
" this.defaultValue = false,",
" this.p2Columns = false,",
" final String type;",
] {
assert!(dart.contains(want), "Flutter has {want:?}:\n{dart}");
}
// Errors.
let r = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})));
assert!(s.execute("inspect.componentCode", &json!({"id": r})).is_err(), "not a component");
assert!(s.execute("inspect.componentCode", &json!({"id": c, "language": "html"})).is_err());
assert!(s.execute("inspect.componentCode", &json!({"id": c, "language": "flutter"})).is_ok());
assert!(s.execute("inspect.componentCode", &json!({"id": 999_999})).is_err());
}
+234
View File
@@ -0,0 +1,234 @@
//! Components can't contain their own instances, directly, through other components or through
//! the variants of a set (#1084); files holding such a loop open with it broken.
use std::sync::Arc;
use base64::Engine;
use figcraft_doc::{Document, NodeId, NodeKind};
use serde_json::{Value, json};
use crate::Session;
use crate::components::component_cycle;
use crate::components::tests::{copy_of, ex, idv, kids, node};
/// A 100×50 component named `name` at (x, 0), holding a rectangle; returns its id.
fn component(s: &mut Session, name: &str, x: f64) -> NodeId {
let f = idv(&ex(s, "frame.create", json!({"x": x, "y": 0, "width": 100, "height": 50, "name": name})));
ex(s, "shape.rectangle", json!({"x": 5, "y": 5, "width": 10, "height": 10, "parent": f}));
idv(&ex(s, "component.create", json!({"ids": [f]})))
}
fn instance(s: &mut Session, component: NodeId, parent: NodeId) -> NodeId {
idv(&ex(s, "instance.create", json!({"component": component, "x": 20, "y": 20, "parent": parent})))
}
/// `cmd` is refused with the loop explained, and the document is left as it was.
fn refused(s: &mut Session, cmd: &str, p: Value) {
let before = s.doc().unwrap().clone();
let undo = s.ds().unwrap().history.undo.len();
let err = s.execute(cmd, &p).expect_err(&format!("{cmd} {p} should be refused")).to_string();
assert!(err.contains("can't contain"), "{cmd}: {err}");
assert_eq!(*s.doc().unwrap(), before, "{cmd} rolled back");
assert_eq!(s.ds().unwrap().history.undo.len(), undo, "{cmd} left no undo step");
}
/// Card holds a Badge instance; Badge holds an Icon instance. Returns (card, badge, icon, the
/// Icon instance in Badge).
fn chain(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId) {
let icon = component(s, "Icon", 0.0);
let badge = component(s, "Badge", 200.0);
let card = component(s, "Card", 400.0);
let in_badge = instance(s, icon, badge);
instance(s, badge, card);
(card, badge, icon, in_badge)
}
#[test]
fn direct_and_indirect_loops_are_refused() {
let mut s = Session::new();
let (card, badge, _, in_badge) = chain(&mut s);
// Directly, and through Badge: Card already shows Badge.
refused(&mut s, "instance.create", json!({"component": card, "parent": card}));
refused(&mut s, "instance.create", json!({"component": card, "parent": badge}));
refused(&mut s, "instance.swap", json!({"ids": [in_badge], "component": card}));
// A Card instance moved or pasted into Badge.
let page = s.page().unwrap();
let loose = instance(&mut s, card, page);
refused(&mut s, "node.reparent", json!({"ids": [loose], "parent": badge}));
ex(&mut s, "edit.copy", json!({"ids": [loose]}));
refused(&mut s, "edit.paste", json!({"parent": badge}));
// A copied main pastes as an instance of it.
ex(&mut s, "edit.copy", json!({"ids": [card]}));
refused(&mut s, "edit.paste", json!({"parent": badge}));
assert!(component_cycle(s.doc().unwrap()).is_none());
}
#[test]
fn dropping_an_instance_where_it_would_contain_itself_places_it_on_the_page() {
let mut s = Session::new();
let (card, badge, _, _) = chain(&mut s);
let r = ex(&mut s, "instance.place", json!({"component": card, "x": 250, "y": 25}));
assert_eq!(r["parent"], json!(s.page().unwrap()), "not into Badge: {r}");
assert!(!s.doc().unwrap().is_ancestor(badge, idv(&r)));
}
#[test]
fn an_instance_swap_property_can_not_close_a_loop() {
let mut s = Session::new();
let (card, badge, icon, in_badge) = chain(&mut s);
ex(&mut s, "component.addProperty", json!({"id": badge, "name": "Icon", "type": "instanceSwap", "default": icon, "layer": in_badge}));
let in_card = kids(&s, card).into_iter().find(|k| matches!(node(&s, *k).kind, NodeKind::Instance { .. })).unwrap();
refused(&mut s, "instance.setProperties", json!({"ids": [in_card], "properties": {"Icon": card}}));
// Another component is fine.
let star = component(&mut s, "Star", 600.0);
ex(&mut s, "instance.setProperties", json!({"ids": [in_card], "properties": {"Icon": star}}));
}
/// A set of two variants, "State=On" holding an instance of "State=Off". Returns (on, off, the Off
/// instance in On).
fn nested_variants(s: &mut Session) -> (NodeId, NodeId, NodeId) {
let on = component(s, "State=On", 0.0);
let off = component(s, "State=Off", 200.0);
ex(s, "component.combineVariants", json!({"ids": [on, off]}));
let held = instance(s, off, on);
(on, off, held)
}
#[test]
fn a_variant_holds_a_sibling_but_no_loop_back_through_it() {
let mut s = Session::new();
let (on, off, held) = nested_variants(&mut s);
assert!(component_cycle(s.doc().unwrap()).is_none(), "a sibling variant's instance is no loop");
// Off leading back to On, directly or through another component, is one.
refused(&mut s, "instance.create", json!({"component": on, "parent": off}));
let row = component(&mut s, "Row", 600.0);
instance(&mut s, on, row);
refused(&mut s, "instance.create", json!({"component": row, "parent": off}));
// So is switching the held instance to the variant holding it.
refused(&mut s, "instance.setProperties", json!({"ids": [held], "properties": {"State": "On"}}));
// Combining a component with one holding its instance is fine too.
let a = component(&mut s, "A", 0.0);
let b = component(&mut s, "B", 200.0);
instance(&mut s, a, b);
ex(&mut s, "component.combineVariants", json!({"ids": [a, b]}));
assert!(component_cycle(s.doc().unwrap()).is_none());
}
/// `doc` saved and opened again: the session and what opening it reported.
fn reopened(doc: &Document) -> (Session, Value) {
let data = base64::engine::general_purpose::STANDARD.encode(figcraft_format::save_bytes(doc, None).unwrap());
let mut s = Session::new();
let r = ex(&mut s, "file.open", json!({"data": data, "name": "variants.figcraft"}));
(s, r)
}
#[test]
fn a_file_with_nested_sibling_variants_opens_unchanged_and_a_loop_through_them_is_repaired() {
let mut s = Session::new();
let (on, off, held) = nested_variants(&mut s);
let doc = s.doc().unwrap().clone();
let (t, r) = reopened(&doc);
assert_eq!(r["repairs"], json!([]), "{r}");
assert_eq!(t.doc().unwrap().nodes().count(), doc.nodes().count());
assert!(matches!(node(&t, held).kind, NodeKind::Instance { component, .. } if component == off));
// On → Off → On: an On instance in Off, written directly as an older file could have it.
let spare = component(&mut s, "Spare", 600.0);
let back = instance(&mut s, spare, off);
let mut doc = s.doc().unwrap().clone();
if let NodeKind::Instance { component, .. } = &mut doc.get_mut(back).unwrap().kind {
*component = on;
}
assert!(component_cycle(&doc).is_some());
let (t, r) = reopened(&doc);
assert!(r["repairs"].as_array().is_some_and(|n| n.iter().any(|n| n.as_str().is_some_and(|n| n.contains("was detached")))), "{r}");
assert!(component_cycle(t.doc().unwrap()).is_none());
}
#[test]
fn components_around_instances_and_instances_in_slots_stay_allowed() {
let mut s = Session::new();
let (card, badge, ..) = chain(&mut s);
// A new component around a Card instance.
let page = s.page().unwrap();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 200, "height": 100, "parent": page})));
instance(&mut s, card, f);
let holder = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
instance(&mut s, badge, holder);
// An instance in a slot of an instance of its own component: the slot holds that instance's
// own content, outside its main.
let slot = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 50, "height": 20, "parent": card, "name": "Slot"})));
ex(&mut s, "component.makeSlot", json!({"id": slot}));
let outer = instance(&mut s, card, page);
let outer_slot = copy_of(&s, outer, slot);
instance(&mut s, card, outer_slot);
assert!(component_cycle(s.doc().unwrap()).is_none());
}
/// A document where Card holds a Badge instance and Badge a Card instance, made by editing the
/// layers directly (as an older file or a merge could have it). Returns it with (card, badge).
fn looped() -> (Document, NodeId, NodeId) {
let mut s = Session::new();
let (card, badge, _, in_badge) = chain(&mut s);
let mut doc = s.doc().unwrap().clone();
if let NodeKind::Instance { component, .. } = &mut doc.get_mut(in_badge).unwrap().kind {
*component = card;
}
crate::components::sync_instance(&mut doc, in_badge);
assert!(component_cycle(&doc).is_some());
(doc, card, badge)
}
/// Edit mains `a` and `b` `n` times, every instance rebuilt in full each time; the layer count
/// after each round.
fn edit_mains(s: &mut Session, a: NodeId, b: NodeId, n: usize) -> Vec<usize> {
crate::components::FULL_REBUILD.with(|f| f.set(true));
let sizes = (0..n)
.map(|i| {
ex(s, "node.set", json!({"id": a, "props": {"fill": format!("#{:02x}0000", i * 20)}}));
ex(s, "node.set", json!({"id": b, "props": {"fill": format!("#00{:02x}00", i * 20)}}));
s.doc().unwrap().nodes().count()
})
.collect();
crate::components::FULL_REBUILD.with(|f| f.set(false));
sizes
}
#[test]
fn a_file_with_a_loop_opens_with_it_broken_and_a_note() {
let (doc, card, badge) = looped();
let bytes = figcraft_format::save_bytes(&doc, None).unwrap();
let data = base64::engine::general_purpose::STANDARD.encode(&bytes);
let mut s = Session::new();
let r = ex(&mut s, "file.open", json!({"data": data, "name": "looped.figcraft"}));
let notes = r["repairs"].as_array().cloned().unwrap_or_default();
assert!(notes.iter().any(|n| n.as_str().is_some_and(|n| n.contains("was detached"))), "{r}");
let doc = s.doc().unwrap();
assert!(component_cycle(doc).is_none());
// The detached instance kept its layers; the other side is still an instance.
assert!(doc.contains(card) && doc.contains(badge));
let count = doc.nodes().count();
// Editing the mains no longer grows the file.
assert_eq!(edit_mains(&mut s, card, badge, 10), vec![count; 10]);
// Importing it into another file breaks it the same way.
let mut t = Session::new();
let r = ex(&mut t, "file.import", json!({"data": data, "name": "looped.figcraft"}));
assert!(r["warnings"].as_array().is_some_and(|w| !w.is_empty()), "{r}");
assert!(component_cycle(t.doc().unwrap()).is_none());
}
#[test]
fn a_loop_merged_in_stops_expanding_and_the_document_stays_editable() {
let (doc, card, badge) = looped();
let mut s = Session::new();
let uid = s.ds().unwrap().uid;
s.apply_remote(uid, Arc::new(doc));
assert!(s.message.as_deref().is_some_and(|m| m.contains("contain each other")), "{:?}", s.message);
let sizes = edit_mains(&mut s, card, badge, 12);
// A couple of rebuilds settle the copies; after that nothing grows.
assert!(sizes.windows(2).skip(2).all(|w| w[0] == w[1]), "{sizes:?}");
// Editing goes on: layers added to a main reach its instances.
ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 4, "height": 4, "parent": card}));
let page = s.page().unwrap();
let loose = instance(&mut s, badge, page);
assert!(!kids(&s, loose).is_empty());
}
+2 -2
View File
@@ -247,7 +247,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"annotation.add",
"Add annotation",
"{id? (default the first selected layer), label? (the note), properties?: [width|height|top|left|rotation|opacity|cornerRadius|direction|gap|padding|alignment|fontFamily|fontStyle|fontSize|lineHeight|letterSpacing|textAlign|fill|stroke|strokeWeight|effects], category? (id or name)} → {id, index} (a note, pinned properties or both)",
"{id? (default the first selected layer), label? | text? (the note), properties?: [width|height|top|left|rotation|opacity|cornerRadius|direction|gap|padding|alignment|fontFamily|fontStyle|fontSize|lineHeight|letterSpacing|textAlign|fill|stroke|strokeWeight|effects], category? (id or name)} → {id, index} (a note, pinned properties or both)",
true,
|s, p| {
let cmd = "annotation.add";
@@ -270,7 +270,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"annotation.edit",
"Edit annotation",
"{id?, index, label?, properties?: [..] (replaces the pinned properties), category? (id or name; null: none)}",
"{id?, index, label? | text?, properties?: [..] (replaces the pinned properties), category? (id or name; null: none)}",
true,
|s, p| {
let cmd = "annotation.edit";
+16 -6
View File
@@ -37,8 +37,9 @@ const MAX_WARNINGS: usize = 50;
/// The languages a component can be connected in.
pub fn language(s: &str) -> Option<CodeLang> {
CodeLang::from_id(s)
.filter(|l| matches!(l, CodeLang::React | CodeLang::Leptos | CodeLang::Vue | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi))
CodeLang::from_id(s).filter(|l| {
matches!(l, CodeLang::React | CodeLang::Leptos | CodeLang::Vue | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi | CodeLang::Flutter)
})
}
/// The layer `id`'s links are stored on: its component set, else its component (an instance's
@@ -69,6 +70,7 @@ pub fn import_line(link: &CodeLink, lang: CodeLang) -> String {
CodeLang::Leptos => format!("use {path}::{};", link.name.split('.').next().unwrap_or_default()),
// A single-file component is a default export.
CodeLang::Vue => format!("import {} from {};", link.name.split('.').next().unwrap_or_default(), js_string(path)),
CodeLang::Flutter => format!("import '{}';", path.replace(['\'', '\\'], "")),
_ => format!("import {path}"),
}
}
@@ -273,6 +275,7 @@ fn expr(doc: &Document, c: &Component, p: &Param, v: &Value, lang: CodeLang) ->
(Some(l), CodeLang::Vue) => l.name.clone(),
(Some(l), CodeLang::SwiftUi) => format!("{}()", l.name),
(Some(l), CodeLang::ArkUi) => format!("() => {{ {}() }}", l.name),
(Some(l), CodeLang::Flutter) => format!("{}()", l.name),
(Some(l), _) => format!("{{ {}() }}", l.name),
(None, _) => value_expr(doc, c, p, v, lang),
}
@@ -456,7 +459,7 @@ fn default_template(c: &Component, lang: CodeLang, name: &str) -> String {
format!("<{name}{} />", params.iter().map(|p| format!(" {}={{{p}}}", crate::component_codegen::rust_ident(p))).collect::<String>())
}
CodeLang::Vue => format!("<{name}{} />", params.iter().map(|p| format!(" :{p}={{{p}}}")).collect::<String>()),
CodeLang::SwiftUi => format!("{name}({})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::<Vec<_>>().join(", ")),
CodeLang::SwiftUi | CodeLang::Flutter => format!("{name}({})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::<Vec<_>>().join(", ")),
CodeLang::ArkUi => format!("{name}({{ {} }})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::<Vec<_>>().join(", ")),
_ => format!("{name}({})", params.iter().map(|p| format!("{p} = {{{p}}}")).collect::<Vec<_>>().join(", ")),
}
@@ -478,6 +481,7 @@ pub fn check(c: &Component, link: &CodeLink, lang: CodeLang) -> std::result::Res
CodeLang::React | CodeLang::ArkUi => !path.chars().any(|ch| ch.is_control() || ch == '"' || ch == '\\'),
CodeLang::Leptos => path.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == ':'),
CodeLang::Vue => path.chars().all(|ch| ch.is_ascii_alphanumeric() || "_./@-".contains(ch)),
CodeLang::Flutter => path.chars().all(|ch| ch.is_ascii_alphanumeric() || "_./:-".contains(ch)),
_ => path.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '.'),
};
if path.chars().count() > MAX_IMPORT || !path_ok {
@@ -486,6 +490,9 @@ pub fn check(c: &Component, link: &CodeLink, lang: CodeLang) -> std::result::Res
CodeLang::ArkUi => format!("importPath: a module path without quotes, up to {MAX_IMPORT} characters"),
CodeLang::Leptos => format!("importPath: a Rust module path (letters, digits, _ and ::), up to {MAX_IMPORT} characters"),
CodeLang::Vue => format!("importPath: a module path (letters, digits, _ . / @ and -), up to {MAX_IMPORT} characters"),
CodeLang::Flutter => {
format!("importPath: a Dart import (package:acme/card.dart; letters, digits, _ . / : and -), up to {MAX_IMPORT} characters")
}
_ => format!("importPath: a module or package name (letters, digits, _ and .), up to {MAX_IMPORT} characters"),
});
}
@@ -521,7 +528,7 @@ fn text(cmd: &str, p: &Value, key: &str) -> Result<Option<String>> {
/// The `language` parameter (default React).
fn lang_param(cmd: &str, p: &Value) -> Result<CodeLang> {
match text(cmd, p, "language")? {
Some(l) => language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|vue|swiftui|compose|arkui")),
Some(l) => language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|vue|swiftui|compose|arkui|flutter")),
None => Ok(CodeLang::React),
}
}
@@ -551,7 +558,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"codeConnect.set",
"Connect to code",
"{id? (component, set or instance; default the selection), language?: react|leptos|vue|swiftui|compose|arkui (default react), importPath?, name? (default the generated name), template? (default a call passing every property)} links the component (its set, for a variant) to its code component; fields left out keep their current value. Placeholders: {property} (its name or parameter name) and {property ? \"a\" : \"b\"}; other {…} stay as written → {component, language, code (the snippet for the layer), unknown (placeholder names that aren't properties)}",
"{id? (component, set or instance; default the selection), language?: react|leptos|vue|swiftui|compose|arkui|flutter (default react), importPath?, name? (default the generated name), template? (default a call passing every property)} links the component (its set, for a variant) to its code component; fields left out keep their current value. Placeholders: {property} (its name or parameter name) and {property ? \"a\" : \"b\"}; other {…} stay as written → {component, language, code (the snippet for the layer), unknown (placeholder names that aren't properties)}",
true,
|s, p| {
let cmd = "codeConnect.set";
@@ -666,7 +673,10 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
let (Some(component), Some(lang), Some(name), Some(template)) =
(field("component"), field("language").and_then(language), field("name"), e.get("template").and_then(Value::as_str))
else {
warnings.push(format!("link {}: needs component, language (react|leptos|vue|swiftui|compose|arkui), name and template strings", i + 1));
warnings.push(format!(
"link {}: needs component, language (react|leptos|vue|swiftui|compose|arkui|flutter), name and template strings",
i + 1
));
continue;
};
let link = CodeLink {
+129 -7
View File
@@ -1,7 +1,7 @@
//! Code for a layer, as shown by the Inspect panel and returned by `inspect.code`: CSS for the
//! layer itself, HTML or React for its subtree, and SwiftUI, Jetpack Compose or ArkUI (HarmonyOS;
//! stacks for auto layout, absolute offsets otherwise). Components become components whose
//! properties are parameters and instances become calls passing their values (see
//! layer itself, HTML or React for its subtree, and SwiftUI, Jetpack Compose, ArkUI (HarmonyOS) or
//! Flutter (stacks for auto layout, absolute offsets otherwise). Components become components
//! whose properties are parameters and instances become calls passing their values (see
//! [`crate::component_codegen`]); variables bound to a layer become CSS custom properties. The
//! output is a starting point, not a pixel-exact translation.
@@ -20,6 +20,7 @@ use crate::{bad, spec};
mod arkui;
mod compose;
mod css;
mod flutter;
mod html;
mod swift;
@@ -30,6 +31,7 @@ pub(crate) use css::Tokens;
pub(crate) use css::css_decls;
use css::{css, tok};
pub use css::{css_var_name, css_var_of};
pub(crate) use flutter::{FLUTTER_IMPORT, Place, flutter, flutter_indent};
use html::html;
pub(crate) use html::{Markup, class_name};
pub(crate) use swift::swift;
@@ -38,13 +40,15 @@ pub fn specs() -> Vec<CommandSpec> {
vec![spec!(
"inspect.code",
"Code for layer",
"{id?, language?: css|html|react|leptos|vue|tailwind|swiftui|compose|arkui (default css; html, react, leptos and vue are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead; tailwind is React with Tailwind CSS v4 utilities for pages: fluid auto layout boxes, instances inlined with their overrides, bound variables as theme utilities), mobile? (tailwind: a mobile frame with the same layer names; the code is mobile first with the desktop layer's classes behind lg:), unit?: px|rem (CSS, HTML, React, Leptos and Vue; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}",
"{id?, language?: css|html|react|leptos|vue|tailwind|swiftui|compose|arkui|flutter (default css; html, react, leptos and vue are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead; tailwind is React with Tailwind CSS v4 utilities for pages: fluid auto layout boxes, instances inlined with their overrides, bound variables as theme utilities), mobile? (tailwind: a mobile frame with the same layer names; the code is mobile first with the desktop layer's classes behind lg:), unit?: px|rem (CSS, HTML, React, Leptos and Vue; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}",
false,
|s, p| {
let cmd = "inspect.code";
let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer"))?;
let lang = match string(p, "language") {
Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|vue|tailwind|swiftui|compose|arkui"))?,
Some(l) => {
CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|vue|tailwind|swiftui|compose|arkui|flutter"))?
}
None => CodeLang::Css,
};
let rem = match string(p, "unit").map(|u| u.to_ascii_lowercase()).as_deref() {
@@ -96,10 +100,12 @@ pub enum CodeLang {
Compose,
/// HarmonyOS ArkUI: an ArkTS struct (`@Component`, `build`).
ArkUi,
/// A Flutter `StatelessWidget` (Dart) with its widget tree.
Flutter,
}
impl CodeLang {
pub const ALL: [CodeLang; 9] = [
pub const ALL: [CodeLang; 10] = [
CodeLang::Css,
CodeLang::Html,
CodeLang::React,
@@ -109,6 +115,7 @@ impl CodeLang {
CodeLang::SwiftUi,
CodeLang::Compose,
CodeLang::ArkUi,
CodeLang::Flutter,
];
pub fn id(self) -> &'static str {
match self {
@@ -121,6 +128,7 @@ impl CodeLang {
CodeLang::SwiftUi => "swiftui",
CodeLang::Compose => "compose",
CodeLang::ArkUi => "arkui",
CodeLang::Flutter => "flutter",
}
}
pub fn label(self) -> &'static str {
@@ -134,6 +142,7 @@ impl CodeLang {
CodeLang::SwiftUi => "SwiftUI",
CodeLang::Compose => "Compose",
CodeLang::ArkUi => "ArkUI",
CodeLang::Flutter => "Flutter",
}
}
/// Languages whose lengths are CSS pixels that can be shown in rem (not Tailwind: its lengths
@@ -141,6 +150,11 @@ impl CodeLang {
pub fn is_web(self) -> bool {
matches!(self, CodeLang::Css | CodeLang::Html | CodeLang::React | CodeLang::Leptos | CodeLang::Vue)
}
/// Languages whose strings interpolate `$` (Kotlin's `$name`, Dart's `$name`), where a literal
/// `$` has to be escaped.
pub fn escapes_dollar(self) -> bool {
matches!(self, CodeLang::Compose | CodeLang::Flutter)
}
pub fn from_id(s: &str) -> Option<CodeLang> {
if s.eq_ignore_ascii_case("tsx") || s.eq_ignore_ascii_case("jsx") {
return Some(CodeLang::React);
@@ -151,6 +165,9 @@ impl CodeLang {
if s.eq_ignore_ascii_case("arkts") {
return Some(CodeLang::ArkUi);
}
if s.eq_ignore_ascii_case("dart") {
return Some(CodeLang::Flutter);
}
CodeLang::ALL.into_iter().find(|l| l.id().eq_ignore_ascii_case(s) || l.label().eq_ignore_ascii_case(s))
}
}
@@ -262,7 +279,7 @@ pub fn code_for(doc: &Document, id: NodeId, lang: CodeLang) -> String {
CodeLang::Css => css(doc, id),
CodeLang::Html => html(doc, id),
CodeLang::Tailwind => crate::tailwind::code(doc, id, None),
CodeLang::React | CodeLang::Leptos | CodeLang::Vue | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi => {
CodeLang::React | CodeLang::Leptos | CodeLang::Vue | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi | CodeLang::Flutter => {
crate::component_codegen::code(doc, id, lang)
}
}
@@ -549,6 +566,111 @@ mod tests {
assert!(ark.contains(&format!(".position({{ x: {}, y: {} }})", num(x), num(y))), "the absolute child keeps its place: {ark}");
}
#[test]
fn flutter_builds_a_widget_tree() {
let mut s = Session::new();
let f = card(&mut s);
let doc = s.doc().unwrap();
let dart = code_for(doc, f, CodeLang::Flutter);
// The card hugs its content, so its width is the layout's, not the 200 it was created with.
let w = doc.node(f).map(|n| n.width).unwrap_or_default();
assert!(dart.starts_with(&format!("Container(\n width: {},\n height: 100,\n", num(w))), "{dart}");
assert!(dart.contains("borderRadius: BorderRadius.circular(12)"), "{dart}");
assert!(dart.contains("child: Row(\n"), "{dart}");
assert!(dart.contains(" Text(\n \"Say \\\"hi\\\" \\$5\\nnow\",\n"), "escaped: {dart}");
assert!(dart.contains("fontWeight: FontWeight.w400,"), "{dart}");
assert!(dart.contains("shape: BoxShape.circle"), "{dart}");
assert!(dart.ends_with(')'), "a bare expression, like the other native targets: {dart}");
// Through the command: `dart` names the same language, and rem is for the web only.
let v = s.execute("inspect.code", &json!({"id": f, "language": "flutter"})).unwrap();
assert_eq!(v["code"].as_str().unwrap(), dart);
assert_eq!(v["language"], "flutter");
assert_eq!(s.execute("inspect.code", &json!({"id": f, "language": "dart"})).unwrap()["language"], "flutter");
let rem = s.execute("inspect.code", &json!({"id": f, "language": "flutter", "unit": "rem"})).unwrap();
assert_eq!(rem["code"].as_str().unwrap(), dart, "rem doesn't apply to Dart");
}
#[test]
fn flutter_positions_free_children_and_bounds_the_tree() {
let mut s = Session::new();
let root = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"]).unwrap();
s.execute("shape.rectangle", &json!({"x": 7, "y": 9, "width": 5, "height": 5, "parent": root})).unwrap();
let mut parent = root;
for _ in 0..20 {
parent = id_of(&s.execute("frame.create", &json!({"x": 1, "y": 1, "width": 40, "height": 40, "parent": parent})).unwrap()["id"]).unwrap();
}
let dart = code_for(s.doc().unwrap(), root, CodeLang::Flutter);
assert!(dart.contains("Stack(\n alignment: Alignment.topLeft,\n"), "{dart}");
assert!(dart.contains("Positioned(\n left: 7,\n top: 9,\n"), "{dart}");
// A wrapper's closing parenthesis sits on its own line, not glued to the widget's.
assert!(dart.contains(" )\n ),\n"), "{dart}");
assert!(dart.contains("…"), "the depth is capped: {dart}");
assert!(dart.contains("const SizedBox()"), "a capped layer is still a widget in the list: {dart}");
}
#[test]
fn flutter_keeps_a_capped_layers_name_on_its_comment_line() {
let mut s = Session::new();
let root = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"]).unwrap();
let mut parent = root;
for _ in 0..20 {
let p = json!({"x": 1, "y": 1, "width": 40, "height": 40, "parent": parent, "name": "deep\nvoid main() {}"});
parent = id_of(&s.execute("frame.create", &p).unwrap()["id"]).unwrap();
}
let dart = code_for(s.doc().unwrap(), root, CodeLang::Flutter);
assert!(dart.contains("…"), "the depth is capped: {dart}");
assert!(!dart.lines().any(|l| l.trim_start().starts_with("void main()")), "a name never leaves its comment: {dart}");
}
#[test]
fn flutter_expands_and_stretches_fill_children() {
let mut s = Session::new();
let row = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 40})).unwrap()["id"]).unwrap();
let a = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 20, "parent": row})).unwrap()["id"]).unwrap();
s.execute("shape.rectangle", &json!({"x": 60, "y": 0, "width": 50, "height": 20, "parent": row})).unwrap();
s.execute("layout.add", &json!({"ids": [row], "direction": "horizontal"})).unwrap();
s.execute("node.set", &json!({"id": a, "props": {"sizingH": "fill", "sizingV": "fill"}})).unwrap();
let dart = code_for(s.doc().unwrap(), row, CodeLang::Flutter);
assert!(dart.contains("child: Row(\n"), "{dart}");
assert!(dart.contains("Expanded(\n"), "a child filling the main axis takes the room left: {dart}");
assert!(dart.contains("crossAxisAlignment: CrossAxisAlignment.stretch,"), "a child filling the cross axis stretches the line: {dart}");
}
#[test]
fn flutter_lifts_a_child_that_ignores_its_auto_layout_parent_out_of_the_line() {
let mut s = Session::new();
let row = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 40})).unwrap()["id"]).unwrap();
let flow = id_of(&s.execute("shape.rectangle", &json!({"x": 4, "y": 4, "width": 50, "height": 20, "parent": row})).unwrap()["id"]).unwrap();
let over = id_of(&s.execute("shape.ellipse", &json!({"x": 150, "y": 6, "width": 30, "height": 30, "parent": row})).unwrap()["id"]).unwrap();
s.execute("layout.add", &json!({"ids": [row], "direction": "horizontal"})).unwrap();
s.execute("node.set", &json!({"id": row, "props": {"padding": [4, 4, 4, 4]}})).unwrap();
// It asks for both axes, which the line would read as "stretch me and take the room left".
s.execute("node.set", &json!({"id": over, "props": {"absolute": true, "sizingH": "fill", "sizingV": "fill"}})).unwrap();
let doc = s.doc().unwrap();
let dart = code_for(doc, row, CodeLang::Flutter);
let at = |needle: &str| dart.find(needle).unwrap_or_else(|| panic!("no {needle:?}: {dart}"));
// Over the line, in a Stack at the parent's own box, at its own offsets.
let [_, _, _, _, x, y] = doc.node(over).unwrap().transform.as_coeffs();
assert!(
dart.contains(&format!("Positioned(\n left: {},\n top: {},\n", num(x), num(y))),
"the absolute child keeps its place: {dart}"
);
let stack = at("child: Stack(\n");
let fill = at("Positioned.fill(\n");
let padding = at("padding: EdgeInsets.all(4),");
let line = at("child: Row(\n");
assert!(stack < fill && fill < padding && padding < line, "the line sits in the Stack, padded: {dart}");
assert!(!dart[..padding].contains("padding: "), "the parent's padding would offset the absolute child: {dart}");
// Only the flow child is in the line, so neither `Expanded` nor a stretch comes from it.
let over_at = at("\n Positioned(\n");
let line_part = &dart[line..over_at];
assert!(line_part.contains("width: 50,"), "the flow child is in the line: {dart}");
assert!(!line_part.contains("width: 30,"), "the absolute child left the line: {dart}");
assert!(!dart.contains("Expanded(\n"), "it takes no room in the line either: {dart}");
assert!(dart.contains("crossAxisAlignment: CrossAxisAlignment.start,"), "{dart}");
assert!(doc.node(flow).is_some());
}
#[test]
fn arkui_wraps_a_positioned_instance_swap_so_the_position_lands_on_a_container() {
let mut s = Session::new();
+364
View File
@@ -0,0 +1,364 @@
use figcraft_doc::AutoLayout;
use super::swift::one_line;
use super::{
CodeLang, CrossAlign, Ctx, LayoutMode, LetterSpacing, LineHeight, MainAlign, Node, NodeId, NodeKind, Param, Sizing, TextAlignH, TextDecoration,
drop_shadow, first_fill, kotlin_color, literal, num, radius, stroke_color,
};
// ------------------------------------------------------------------ Flutter (Dart)
/// The import a generated Flutter unit starts with (a widget tree on its own needs it too).
pub(crate) const FLUTTER_IMPORT: &str = "import 'package:flutter/material.dart';\n\n";
/// Dart indents by two spaces.
pub(crate) fn flutter_indent(depth: usize) -> String {
" ".repeat(depth)
}
/// Where a child sits in its parent: in a Stack at its position in the frame, taking the room left
/// in a Row or Column, or wherever the parent puts it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum Place {
Flow,
At(f64, f64),
Fill,
}
/// A child and where its parent puts it.
type Placed = (NodeId, Place);
/// Dart's `FontWeight.w600` (a weight is hundreds).
fn flutter_weight(w: u16) -> String {
format!("FontWeight.w{}", (w.clamp(100, 900) / 100) * 100)
}
/// A Row or Column's main-axis alignment (`None` for the default, the start).
fn flutter_main_align(a: MainAlign) -> Option<&'static str> {
match a {
MainAlign::Min => None,
MainAlign::Center => Some("MainAxisAlignment.center"),
MainAlign::Max => Some("MainAxisAlignment.end"),
MainAlign::SpaceBetween => Some("MainAxisAlignment.spaceBetween"),
MainAlign::SpaceAround => Some("MainAxisAlignment.spaceAround"),
MainAlign::SpaceEvenly => Some("MainAxisAlignment.spaceEvenly"),
}
}
/// A Row or Column's cross-axis alignment; a flow child that fills it stretches the line instead.
fn flutter_cross_align(a: CrossAlign, stretch: bool) -> &'static str {
if stretch {
return "CrossAxisAlignment.stretch";
}
match a {
CrossAlign::Min => "CrossAxisAlignment.start",
CrossAlign::Center => "CrossAxisAlignment.center",
CrossAlign::Max => "CrossAxisAlignment.end",
CrossAlign::Baseline => "CrossAxisAlignment.baseline",
}
}
/// A frame's auto layout padding as `EdgeInsets` (`None` when it has none).
fn flutter_padding(n: &Node) -> Option<String> {
let l = n.layout.as_ref()?;
let [t, r, b, left] = l.padding;
if ![t, r, b, left].iter().any(|v| *v > 0.0) {
return None;
}
Some(if t == r && r == b && b == left {
format!("EdgeInsets.all({})", num(t))
} else if t == b && left == r {
format!("EdgeInsets.symmetric(vertical: {}, horizontal: {})", num(t), num(left))
} else {
format!("EdgeInsets.fromLTRB({}, {}, {}, {})", num(left), num(t), num(r), num(b))
})
}
/// A layer's fill, shape, corner radius, stroke and shadow as a `BoxDecoration`; `None` when it has
/// none of them. `pad` is the indentation of the constructor itself.
fn box_decoration(n: &Node, pad: &str) -> Option<String> {
let mut parts: Vec<String> = Vec::new();
if let Some(c) = first_fill(n) {
parts.push(format!("color: {}", kotlin_color(c)));
}
if matches!(n.kind, NodeKind::Ellipse { .. }) {
parts.push("shape: BoxShape.circle".into());
} else if radius(n) > 0.0 {
parts.push(format!("borderRadius: BorderRadius.circular({})", num(radius(n))));
}
if let Some(c) = stroke_color(n) {
parts.push(format!("border: Border.all(color: {}, width: {})", kotlin_color(c), num(n.stroke.weight)));
}
if let Some(s) = drop_shadow(n) {
parts.push(format!(
"boxShadow: [BoxShadow(color: {}, blurRadius: {}, offset: Offset({}, {}))]",
kotlin_color(s.color),
num(s.radius / 2.0),
num(s.offset.0),
num(s.offset.1)
));
}
if parts.is_empty() {
return None;
}
let inner = format!("{pad} ");
Some(format!("BoxDecoration(\n{}{pad})", parts.iter().map(|p| format!("{inner}{p},\n")).collect::<String>()))
}
/// Flutter for a layer and its subtree at `depth`. A layer a boolean parameter shows sits behind an
/// `if (…)`: Dart's lists take conditions, so the element itself carries it.
pub(crate) fn flutter(ctx: &Ctx, id: NodeId, depth: usize, out: &mut String, place: Place) {
let Some(n) = ctx.doc.node(id) else { return };
if id != ctx.root
&& let Some(p) = ctx.param(n, "visible")
{
out.push_str(&format!("{}if ({})\n", flutter_indent(depth), p.ident));
flutter_placed(ctx, n, depth + 1, out, place);
return;
}
flutter_placed(ctx, n, depth, out, place);
}
/// A layer at `depth` and what wraps it: where its parent puts it, then its opacity. Every closing
/// parenthesis sits at its own level, so the caller can end the element with a comma in a list.
fn flutter_placed(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, place: Place) {
if ctx.too_deep(depth) {
let pad = flutter_indent(depth);
out.push_str(&format!("{pad}// {} …\n{pad}const SizedBox()", one_line(&n.name)));
return;
}
let mut depth = depth;
let mut close: Vec<String> = Vec::new();
match place {
Place::Flow => {}
Place::At(x, y) => {
let (pad, inner) = (flutter_indent(depth), flutter_indent(depth + 1));
out.push_str(&format!("{pad}Positioned(\n{inner}left: {},\n{inner}top: {},\n{inner}child: ", num(x), num(y)));
close.push(format!("\n{pad})"));
depth += 1;
}
Place::Fill => {
let (pad, inner) = (flutter_indent(depth), flutter_indent(depth + 1));
out.push_str(&format!("{pad}Expanded(\n{inner}child: "));
close.push(format!("\n{pad})"));
depth += 1;
}
}
if n.opacity < 1.0 {
let (pad, inner) = (flutter_indent(depth), flutter_indent(depth + 1));
out.push_str(&format!("{pad}Opacity(\n{inner}opacity: {},\n{inner}child: ", num(f64::from(n.opacity))));
close.push(format!("\n{pad})"));
depth += 1;
}
// A wrapper leaves its line open ("child: "), so the widget carries no indentation of its own.
let mut body = String::new();
flutter_widget(ctx, n, depth, &mut body);
out.push_str(if close.is_empty() { body.as_str() } else { body.trim_start() });
for c in close.iter().rev() {
out.push_str(c);
}
}
/// A layer's own look and its subtree as a Flutter widget at `depth`: a complete expression whose
/// closing parenthesis sits at `depth`.
fn flutter_widget(ctx: &Ctx, n: &Node, depth: usize, out: &mut String) {
let (pad, inner) = (flutter_indent(depth), flutter_indent(depth + 1));
// Nested instances are calls; an instance swap parameter is a widget.
let call = match ctx.param(n, "mainComponent").filter(|_| n.id != ctx.root) {
Some(p) => Some(p.ident.clone()),
None => ctx.call(n, CodeLang::Flutter, depth).map(|(code, _)| code),
};
if let Some(call) = call {
out.push_str(call.trim_end_matches('\n'));
return;
}
match &n.kind {
NodeKind::Text(t) => {
let text = ctx.param(n, "characters").map_or_else(|| literal(&t.characters, true), |p| p.ident.clone());
out.push_str(&format!("{pad}Text(\n{inner}{text},\n"));
match t.align_h {
TextAlignH::Left => {}
TextAlignH::Center => out.push_str(&format!("{inner}textAlign: TextAlign.center,\n")),
TextAlignH::Right => out.push_str(&format!("{inner}textAlign: TextAlign.right,\n")),
TextAlignH::Justified => out.push_str(&format!("{inner}textAlign: TextAlign.justify,\n")),
}
let s = &t.style;
let mut style: Vec<String> = Vec::new();
if !s.font_family.is_empty() {
style.push(format!("fontFamily: {}", literal(&s.font_family, true)));
}
style.push(format!("fontSize: {}", num(s.font_size)));
style.push(format!("fontWeight: {}", flutter_weight(s.weight())));
if let Some(c) = first_fill(n) {
style.push(format!("color: {}", kotlin_color(c)));
}
if s.italic() {
style.push("fontStyle: FontStyle.italic".into());
}
match s.line_height {
// Dart's height is a multiple of the font size, not a length.
LineHeight::Pixels(v) if v > 0.0 && s.font_size > 0.0 => style.push(format!("height: {}", num(v / s.font_size))),
LineHeight::Percent(p) if p > 0.0 => style.push(format!("height: {}", num(p / 100.0))),
_ => {}
}
match s.letter_spacing {
LetterSpacing::Pixels(v) if v != 0.0 => style.push(format!("letterSpacing: {}", num(v))),
LetterSpacing::Percent(p) if p != 0.0 && s.font_size > 0.0 => style.push(format!("letterSpacing: {}", num(p / 100.0 * s.font_size))),
_ => {}
}
match s.decoration {
TextDecoration::None => {}
TextDecoration::Underline => style.push("decoration: TextDecoration.underline".into()),
TextDecoration::Strikethrough => style.push("decoration: TextDecoration.lineThrough".into()),
}
let sp = flutter_indent(depth + 2);
out.push_str(&format!("{inner}style: TextStyle(\n{}{inner}),\n{pad})", style.iter().map(|x| format!("{sp}{x},\n")).collect::<String>()));
}
NodeKind::Ellipse { .. } | NodeKind::Rectangle => {
out.push_str(&format!("{pad}Container(\n{inner}width: {},\n{inner}height: {},\n", num(n.width), num(n.height)));
if let Some(d) = box_decoration(n, &inner) {
out.push_str(&format!("{inner}decoration: {d},\n"));
}
out.push_str(&format!("{pad})"));
}
k if k.is_frame_like() || matches!(k, NodeKind::Group) => {
let children = ctx.kids(n);
let slot = ctx.param(n, "slot");
// The children block says whether it kept the padding for itself (an absolute child
// puts the line in a Stack at the parent's own box).
let mut kids = String::new();
let padded_inside = !(children.is_empty() && slot.is_none()) && flutter_children(ctx, n, &children, slot, depth + 1, &mut kids);
out.push_str(&format!("{pad}Container(\n{inner}width: {},\n{inner}height: {},\n", num(n.width), num(n.height)));
if !padded_inside && let Some(p) = flutter_padding(n) {
out.push_str(&format!("{inner}padding: {p},\n"));
}
if let Some(d) = box_decoration(n, &inner) {
out.push_str(&format!("{inner}decoration: {d},\n"));
}
if children.is_empty() && slot.is_none() {
out.push_str(&format!("{pad})"));
return;
}
out.push_str(&format!("{inner}alignment: Alignment.topLeft,\n{inner}child: "));
out.push_str(kids.trim_start());
out.push_str(&format!(",\n{pad})"));
}
_ => out.push_str(&format!(
"{pad}// {}: export as an image asset\n{pad}Image.asset({}, width: {}, height: {})",
n.kind.label(),
literal(&format!("{}.png", n.name), true),
num(n.width),
num(n.height)
)),
}
}
/// The Row, Column or Stack holding a frame-like layer's children, at `depth`. Returns whether it
/// took the parent's padding over (see [`flutter_widget`]).
fn flutter_children(ctx: &Ctx, n: &Node, children: &[NodeId], slot: Option<&Param>, depth: usize, out: &mut String) -> bool {
let doc = ctx.doc;
let (pad, inner) = (flutter_indent(depth), flutter_indent(depth + 1));
// Where each child sits, and whether one of the children in the line asks it to stretch.
let mut placed: Vec<Placed> = Vec::new();
let mut stretch = false;
for c in children {
let Some(cn) = doc.node(*c) else { continue };
let place = match n.layout.as_ref() {
// A child that ignores its auto layout parent keeps its own offsets: it leaves the
// line, so it neither stretches it nor takes the room left in it.
Some(_) if cn.child_layout.absolute => {
let [_, _, _, _, x, y] = cn.transform.as_coeffs();
Place::At(x, y)
}
Some(l) => {
let vertical = l.mode == LayoutMode::Vertical;
let (main, cross) = if vertical {
(cn.child_layout.sizing_v, cn.child_layout.sizing_h)
} else {
(cn.child_layout.sizing_h, cn.child_layout.sizing_v)
};
stretch |= cross == Sizing::Fill;
if main == Sizing::Fill { Place::Fill } else { Place::Flow }
}
// Free children sit at their position in the frame.
None => {
let [_, _, _, _, x, y] = cn.transform.as_coeffs();
Place::At(x, y)
}
};
placed.push((*c, place));
}
let Some(l) = n.layout.as_ref() else {
out.push_str(&format!("{pad}Stack(\n{inner}alignment: Alignment.topLeft,\n{inner}children: [\n"));
flutter_items(ctx, &placed, slot, depth + 2, out);
out.push_str(&format!("{inner}],\n{pad})"));
return false;
};
let (over, flow): (Vec<Placed>, Vec<Placed>) = placed.into_iter().partition(|(_, p)| matches!(p, Place::At(..)));
if over.is_empty() {
out.push_str(&flutter_line(ctx, l, &flow, slot, stretch, depth));
return false;
}
// A child at its own offsets goes over the line, in a Stack at the parent's own box, so its
// offsets stay the parent's and the line keeps the parent's padding.
let pad_edges = flutter_padding(n);
let list = flutter_indent(depth + 2);
let line_depth = depth + 3 + usize::from(pad_edges.is_some());
out.push_str(&format!("{pad}Stack(\n{inner}alignment: Alignment.topLeft,\n{inner}children: [\n"));
if !flow.is_empty() || slot.is_some() {
// Filling the Stack gives the line the parent's size, which `Expanded` children need.
out.push_str(&format!("{list}Positioned.fill(\n{list} child: "));
match pad_edges {
Some(p) => {
out.push_str(&format!("Padding(\n{list} padding: {p},\n{list} child: "));
out.push_str(flutter_line(ctx, l, &flow, slot, stretch, line_depth).trim_start());
out.push_str(&format!(",\n{list} )"));
}
None => out.push_str(flutter_line(ctx, l, &flow, slot, stretch, line_depth).trim_start()),
}
out.push_str(&format!(",\n{list}),\n"));
}
flutter_items(ctx, &over, None, depth + 2, out);
out.push_str(&format!("{inner}],\n{pad})"));
true
}
/// The Row or Column holding a frame's flow children, at `depth`: a complete widget expression.
fn flutter_line(ctx: &Ctx, l: &AutoLayout, flow: &[Placed], slot: Option<&Param>, stretch: bool, depth: usize) -> String {
let (pad, inner) = (flutter_indent(depth), flutter_indent(depth + 1));
let mut out = format!("{pad}{}(\n", if l.mode == LayoutMode::Vertical { "Column" } else { "Row" });
if let Some(m) = flutter_main_align(l.main_align) {
out.push_str(&format!("{inner}mainAxisAlignment: {m},\n"));
}
out.push_str(&format!("{inner}crossAxisAlignment: {},\n", flutter_cross_align(l.cross_align, stretch)));
if !stretch && matches!(l.cross_align, CrossAlign::Baseline) {
out.push_str(&format!("{inner}textBaseline: TextBaseline.alphabetic,\n"));
}
// The gap between the children; the space distributions already spread them.
if l.gap > 0.0 && flutter_main_align(l.main_align).is_none_or(|m| !m.contains("Space")) {
out.push_str(&format!("{inner}spacing: {},\n", num(l.gap)));
}
out.push_str(&format!("{inner}children: [\n"));
flutter_items(ctx, flow, slot, depth + 2, &mut out);
out.push_str(&format!("{inner}],\n{pad})"));
out
}
/// A children list (`[` already open) at `depth`; a slot shows the instance's content, or else the
/// main's.
fn flutter_items(ctx: &Ctx, kids: &[Placed], slot: Option<&Param>, depth: usize, out: &mut String) {
let list = flutter_indent(depth);
if let Some(p) = slot {
let name = &p.ident;
out.push_str(&format!("{list}...?{name},\n"));
out.push_str(&format!("{list}if ({name} == null) ...[\n"));
}
let kid = depth + usize::from(slot.is_some());
for (id, place) in kids {
flutter(ctx, *id, kid, out, *place);
out.push_str(",\n");
}
if slot.is_some() {
out.push_str(&format!("{list}],\n"));
}
}
+136 -22
View File
@@ -1,17 +1,17 @@
//! Component code: a component (or component set) becomes a React, Leptos, Vue, SwiftUI, Jetpack
//! Compose or ArkUI component whose properties are its parameters (a variant property → a union or
//! enum, boolean → a flag showing the layers bound to it, text → a string, instance swap → a view
//! parameter, slot → optional content), and an instance becomes a call passing the values it sets.
//! Variants of a set share one definition (the set's default variant); in React and Leptos their
//! differences become CSS rules on `data-*` attributes. Returned by `inspect.componentCode` and
//! used by `inspect.code`. A component connected to code (see [`crate::code_connect`]) shows its
//! real snippet instead.
//! Compose, ArkUI or Flutter component whose properties are its parameters (a variant property → a
//! union or enum, boolean → a flag showing the layers bound to it, text → a string, instance swap →
//! a view parameter, slot → optional content), and an instance becomes a call passing the values it
//! sets. Variants of a set share one definition (the set's default variant); in React and Leptos
//! their differences become CSS rules on `data-*` attributes. Returned by `inspect.componentCode`
//! and used by `inspect.code`. A component connected to code (see [`crate::code_connect`]) shows
//! its real snippet instead.
use figcraft_doc::{Document, NodeId, NodeKind, PropDef};
use serde_json::{Value, json};
use crate::cmd::CommandSpec;
use crate::codegen::{CodeLang, Ctx, Markup, arkui, class_name, compose, indent, literal, swift};
use crate::codegen::{CodeLang, Ctx, FLUTTER_IMPORT, Markup, Place, arkui, class_name, compose, flutter, flutter_indent, indent, literal, swift};
use crate::components::{prop_default_value, slot_edited, slots_of, variant_options, variant_props, variant_set};
use crate::params::{id_of, string, targets};
use crate::{bad, spec};
@@ -68,14 +68,14 @@ pub fn specs() -> Vec<CommandSpec> {
vec![spec!(
"inspect.componentCode",
"Component code",
"{id? (component, set or instance; default the selection), language?: react|leptos|vue|swiftui|compose|arkui (default react)} → {component, name, set, properties: [{name, param, type, default, value? (instances)}], definition, usage? (instances; the connected snippet when the component is connected to code), connected: {importPath, name, template, import, code} | null}",
"{id? (component, set or instance; default the selection), language?: react|leptos|vue|swiftui|compose|arkui|flutter (default react)} → {component, name, set, properties: [{name, param, type, default, value? (instances)}], definition, usage? (instances; the connected snippet when the component is connected to code), connected: {importPath, name, template, import, code} | null}",
false,
|s, p| {
let cmd = "inspect.componentCode";
let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component or instance"))?;
let lang = match string(p, "language") {
// One list of code languages, shared with codeConnect (see `code_connect::language`).
Some(l) => crate::code_connect::language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|vue|swiftui|compose|arkui"))?,
Some(l) => crate::code_connect::language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|vue|swiftui|compose|arkui|flutter"))?,
None => CodeLang::React,
};
let doc = s.doc()?;
@@ -108,10 +108,10 @@ pub fn specs() -> Vec<CommandSpec> {
)]
}
/// Code for any layer in React, Leptos, Vue, SwiftUI, Compose or ArkUI: an instance's call and its
/// component's definition, a component's (or set's) definition, or the layer tree (React and
/// Leptos wrap it in a function component), with the imports of the connected components it calls
/// at the top.
/// Code for any layer in React, Leptos, Vue, SwiftUI, Compose, ArkUI or Flutter: an instance's call
/// and its component's definition, a component's (or set's) definition, or the layer tree (React
/// and Leptos wrap it in a function component), with the imports of the connected components it
/// calls at the top.
pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String {
let Some(n) = doc.node(id) else { return String::new() };
if let Some(code) = crate::code_connect::link(doc, id, lang).and_then(|l| crate::code_connect::connected_code(doc, id, lang, l)) {
@@ -119,7 +119,15 @@ pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String {
}
let comp = main_of(doc, id).and_then(|m| describe(doc, m));
let code = match (&n.kind, comp) {
(NodeKind::Instance { .. }, Some(c)) => format!("{}\n{}", usage(doc, id, lang, 0).unwrap_or_default(), definition(doc, &c, lang)),
(NodeKind::Instance { .. }, Some(c)) => {
let call = usage(doc, id, lang, 0).unwrap_or_default();
let def = definition(doc, &c, lang);
match def.strip_prefix(FLUTTER_IMPORT) {
// Dart's imports come before the code that uses them.
Some(rest) if lang == CodeLang::Flutter => format!("{FLUTTER_IMPORT}{call}\n{rest}"),
_ => format!("{call}\n{def}"),
}
}
(NodeKind::Component { .. } | NodeKind::ComponentSet { .. }, Some(c)) => definition(doc, &c, lang),
_ => match lang {
CodeLang::React | CodeLang::Leptos | CodeLang::Vue => {
@@ -140,6 +148,11 @@ pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String {
arkui(&Ctx::new(doc, id), id, 0, &mut out, &[]);
out
}
CodeLang::Flutter => {
let mut out = String::new();
flutter(&Ctx::new(doc, id), id, 0, &mut out, Place::Flow);
out
}
_ => {
let mut out = String::new();
compose(&Ctx::new(doc, id), id, 0, &mut out);
@@ -328,24 +341,33 @@ const RESERVED: &[&str] = &[
"yield",
];
/// Types the generated SwiftUI, Compose and ArkUI code names: a variant enum may not shadow them.
/// Types the generated SwiftUI, Compose, ArkUI and Flutter code names: a variant enum may not shadow them.
const TYPES: &[&str] = &[
"Alignment",
"AnyView",
"Arrangement",
"Bool",
"Boolean",
"Border",
"BorderRadius",
"BorderSide",
"Box",
"BoxDecoration",
"BoxShadow",
"BoxShape",
"BuildContext",
"CaseIterable",
"Circle",
"CircleShape",
"Color",
"Column",
"Composable",
"CrossAxisAlignment",
"Edge",
"EdgeInsets",
"Ellipse",
"EmptyView",
"Expanded",
"FlexAlign",
"Font",
"FontStyle",
@@ -354,19 +376,28 @@ const TYPES: &[&str] = &[
"HStack",
"HorizontalAlign",
"Image",
"MainAxisAlignment",
"Modifier",
"Offset",
"Opacity",
"Positioned",
"Rect",
"RectangleShape",
"RoundedCornerShape",
"RoundedRectangle",
"Row",
"Shape",
"SizedBox",
"Stack",
"StatelessWidget",
"String",
"Text",
"TextAlign",
"TextBaseline",
"TextCase",
"TextDecoration",
"TextDecorationType",
"TextStyle",
"Unit",
"VStack",
"VerticalAlign",
@@ -481,6 +512,13 @@ fn cases(values: &[String], swift: bool) -> Vec<String> {
.collect()
}
/// Enum member names for variant values in Dart: lowerCamel, unique, never a keyword (a `default`
/// value becomes `defaultValue`).
fn dart_cases(values: &[String]) -> Vec<String> {
let mut used = Vec::new();
values.iter().map(|v| ident(v, &mut used)).collect()
}
/// A Rust string literal.
pub(crate) fn rust_string(s: &str) -> String {
format!("{:?}", s.chars().take(10_000).collect::<String>())
@@ -533,7 +571,7 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value
let i = opts.iter().position(|o| Some(o.as_str()) == value.as_str()).unwrap_or(0);
format!("{}::{}", leptos_enum(c, p), rust_cases(opts).get(i).cloned().unwrap_or_else(|| "Unknown".into()))
}
(ParamKind::Text, l) => literal(value.as_str().unwrap_or(""), l == CodeLang::Compose),
(ParamKind::Text, l) => literal(value.as_str().unwrap_or(""), l.escapes_dollar()),
(ParamKind::Variant(opts), l) => {
let v = value.as_str().unwrap_or("");
if l == CodeLang::Vue {
@@ -542,10 +580,12 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value
if l == CodeLang::React {
return js_string(v);
}
let swift = l == CodeLang::SwiftUi;
let i = opts.iter().position(|o| o == v).unwrap_or(0);
let case = cases(opts, swift).get(i).cloned().unwrap_or_else(|| "unknown".into());
if swift { format!(".{case}") } else { format!("{}.{case}", enum_name(c, p, false)) }
match l {
CodeLang::SwiftUi => format!(".{}", cases(opts, true).get(i).cloned().unwrap_or_else(|| "unknown".into())),
CodeLang::Flutter => format!("{}.{}", enum_name(c, p, false), dart_cases(opts).get(i).cloned().unwrap_or_else(|| "unknown".into())),
_ => format!("{}.{}", enum_name(c, p, false), cases(opts, false).get(i).cloned().unwrap_or_else(|| "Unknown".into())),
}
}
(ParamKind::InstanceSwap, l) => {
let t = id_of(value).and_then(|i| component_type(doc, i));
@@ -556,12 +596,14 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value
(Some(t), CodeLang::Leptos) => format!("|| view! {{ <{} /> }}", rust_type(&t)),
(None, CodeLang::Leptos) => "|| ()".into(),
(Some(t), CodeLang::SwiftUi) => format!("AnyView({t}())"),
(Some(t), CodeLang::Flutter) => format!("{t}()"),
// ArkUI takes a builder: `@BuilderParam` is a `() => void`.
(Some(t), CodeLang::ArkUi) => format!("() => {{ {t}() }}"),
(None, CodeLang::ArkUi) => "() => {}".into(),
(Some(t), _) => format!("{{ {t}() }}"),
(None, _) if js => "null".into(),
(None, CodeLang::SwiftUi) => "AnyView(EmptyView())".into(),
(None, CodeLang::Flutter) => "const SizedBox.shrink()".into(),
(None, _) => "{}".into(),
}
}
@@ -616,7 +658,7 @@ pub fn usage(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option
pub(crate) fn usage_with_styles(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option<(String, String)> {
let c = main_of(doc, id).and_then(|m| describe(doc, m))?;
let values = instance_values(doc, id, &c)?;
let pad = if matches!(lang, CodeLang::React | CodeLang::Vue) { " ".repeat(depth) } else { indent(depth) };
let pad = if matches!(lang, CodeLang::React | CodeLang::Vue | CodeLang::Flutter) { " ".repeat(depth) } else { indent(depth) };
// A connected component: its real snippet (its import is at the top of the code: `code`).
if let Some(link) = crate::code_connect::link(doc, id, lang) {
return Some((crate::code_connect::render(doc, &c, &values, link, lang, &pad), String::new()));
@@ -684,7 +726,9 @@ pub(crate) fn usage_with_styles(doc: &Document, id: NodeId, lang: CodeLang, dept
.collect();
if slots.is_empty() { format!("{pad}<{t}{attrs} />\n") } else { format!("{pad}<{t}{attrs}>\n{slots}{pad}</{t}>\n") }
}
CodeLang::SwiftUi => format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::<Vec<_>>().join(", ")),
CodeLang::SwiftUi | CodeLang::Flutter => {
format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::<Vec<_>>().join(", "))
}
CodeLang::ArkUi => {
let list = args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::<Vec<_>>().join(", ");
// ArkTS calls take an object literal, or nothing at all.
@@ -743,6 +787,15 @@ fn slot_content(doc: &Document, slot: NodeId, lang: CodeLang, depth: usize, pref
}
(format!("() => {{\n{out}{}}}", indent(depth + 1)), String::new())
}
CodeLang::Flutter => {
// A slot holds a list of widgets, so it takes however many layers the slot has.
let mut out = String::new();
for k in kids {
flutter(&ctx, k, depth + 1, &mut out, Place::Flow);
out.push_str(",\n");
}
(format!("[\n{out}{}]", flutter_indent(depth)), String::new())
}
_ => {
let mut out = String::new();
for k in kids {
@@ -760,6 +813,7 @@ pub fn definition(doc: &Document, c: &Component, lang: CodeLang) -> String {
match lang {
CodeLang::SwiftUi => swift_definition(doc, c),
CodeLang::Compose => compose_definition(doc, c),
CodeLang::Flutter => flutter_definition(doc, c),
CodeLang::Leptos => leptos(doc, c),
CodeLang::Vue => vue(doc, c),
CodeLang::ArkUi => arkui_definition(doc, c),
@@ -994,6 +1048,66 @@ fn compose_definition(doc: &Document, c: &Component) -> String {
out
}
/// A Flutter `StatelessWidget`: variant enums, a const constructor with defaults, and the widget
/// tree in `build`.
fn flutter_definition(doc: &Document, c: &Component) -> String {
let ctx = Ctx { doc, root: c.comp, comp: Some(c), base: 2 };
let mut out = String::from(FLUTTER_IMPORT);
for p in &c.params {
if let ParamKind::Variant(opts) = &p.kind {
out.push_str(&format!("enum {} {{ {} }}\n\n", enum_name(c, p, false), dart_cases(opts).join(", ")));
}
}
let t = &c.type_name;
out.push_str(&format!("class {t} extends StatelessWidget {{\n"));
if c.params.is_empty() {
out.push_str(&format!(" const {t}({{super.key}});\n"));
} else {
out.push_str(&format!(" const {t}({{\n super.key,\n"));
for p in &c.params {
match flutter_default(doc, c, p) {
Some(d) => out.push_str(&format!(" this.{} = {d},\n", p.ident)),
None => out.push_str(&format!(" this.{},\n", p.ident)),
}
}
out.push_str(" });\n\n");
for p in &c.params {
out.push_str(&format!(" final {} {};\n", flutter_type(c, p), p.ident));
}
}
out.push_str("\n @override\n Widget build(BuildContext context) {\n return ");
let mut body = String::new();
flutter(&ctx, c.comp, 2, &mut body, Place::Flow);
out.push_str(body.trim_start());
out.push_str(";\n }\n}\n");
out
}
/// A parameter's Dart type.
fn flutter_type(c: &Component, p: &Param) -> String {
match &p.kind {
ParamKind::Variant(_) => enum_name(c, p, false),
ParamKind::Boolean => "bool".into(),
ParamKind::Text => "String".into(),
ParamKind::InstanceSwap => "Widget".into(),
// A slot holds however many layers it was filled with.
ParamKind::Slot => "List<Widget>?".into(),
}
}
/// A parameter's default as a Dart constant (`None` for a slot, which has none): Dart wants its
/// defaults to be constants, so an instance swap is a `const` widget.
fn flutter_default(doc: &Document, c: &Component, p: &Param) -> Option<String> {
match &p.kind {
ParamKind::Slot => None,
ParamKind::InstanceSwap => Some(match id_of(&p.default).and_then(|i| component_type(doc, i)) {
Some(t) => format!("const {t}()"),
None => "const SizedBox.shrink()".into(),
}),
_ => Some(value_expr(doc, c, p, &p.default, CodeLang::Flutter)),
}
}
/// An ArkUI (`@Component`) struct: variant enums, `@Prop` parameters, a default builder for each
/// instance swap and slot, and the `build` body.
fn arkui_definition(doc: &Document, c: &Component) -> String {
+131
View File
@@ -0,0 +1,131 @@
//! `edit.duplicate` with `map: true` (#995): every original layer, descendants included, paired
//! with its copy, so a caller can edit inside a copy without inspecting it.
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
use figcraft_doc::{Document, NodeId};
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn idv(v: &Value) -> NodeId {
id_of(&v["id"]).unwrap()
}
/// A "Screen" frame holding an instance of a "Card" component (with a "Label" sublayer), a group
/// of two rectangles and a nested frame with a text; returns (screen, card).
fn screen(s: &mut Session) -> (NodeId, NodeId) {
let card = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 80, "height": 40, "name": "Card"})));
ex(s, "text.create", json!({"text": "Hi", "x": 4, "y": 4, "parent": card, "props": {"name": "Label"}}));
let card = idv(&ex(s, "component.create", json!({"ids": [card]})));
let scr = idv(&ex(s, "frame.create", json!({"x": 200, "y": 0, "width": 300, "height": 300, "name": "Screen"})));
ex(s, "instance.create", json!({"component": card, "x": 210, "y": 10, "parent": scr}));
let r = |s: &mut Session, y: f64| idv(&ex(s, "shape.rectangle", json!({"x": 210, "y": y, "width": 20, "height": 20, "parent": scr})));
let (a, b) = (r(s, 100.0), r(s, 130.0));
ex(s, "object.group", json!({"ids": [a, b]}));
let inner = idv(&ex(s, "frame.create", json!({"x": 250, "y": 100, "width": 100, "height": 100, "parent": scr, "name": "Inner"})));
ex(s, "text.create", json!({"text": "Body", "x": 260, "y": 110, "parent": inner}));
(scr, card)
}
/// The child indexes from `root` down to `id`.
fn path(doc: &Document, root: NodeId, id: NodeId) -> Vec<usize> {
let mut out = Vec::new();
let mut at = id;
while at != root {
out.push(doc.index_in_parent(at).unwrap());
at = doc.node(at).unwrap().parent.unwrap();
}
out.reverse();
out
}
fn pairs(out: &Value) -> Vec<(NodeId, NodeId)> {
out["map"].as_object().unwrap().iter().map(|(k, v)| (NodeId(k.parse().unwrap()), id_of(v).unwrap())).collect()
}
/// The map pairs every layer of each original's subtree with the layer at the same place in its
/// copy, of the same type and name.
fn assert_complete(s: &Session, roots: &[(NodeId, NodeId)], out: &Value) {
let doc = s.doc().unwrap();
let map = pairs(out);
let all: usize = roots.iter().map(|(o, _)| doc.subtree(*o).len()).sum();
assert_eq!(map.len(), all, "every original layer, descendants included, is in the map: {out}");
for (o, c) in map {
let (root_o, root_c) = roots.iter().find(|(r, _)| doc.subtree(*r).contains(&o)).copied().unwrap();
assert!(doc.subtree(root_c).contains(&c), "{c} is inside the copy of {root_o}");
assert_eq!(path(doc, root_o, o), path(doc, root_c, c), "{c} sits where {o} does");
let (a, b) = (doc.node(o).unwrap(), doc.node(c).unwrap());
assert_eq!(a.name, b.name, "{o} → {c}");
// A main component's copy is an instance of it (the tests check the roots' types).
if o != root_o {
assert_eq!(a.kind.type_name(), b.kind.type_name(), "{o} → {c}");
}
}
}
#[test]
fn map_pairs_nested_frames_groups_and_instances_with_their_copies() {
let mut s = Session::new();
let (scr, _) = screen(&mut s);
let out = ex(&mut s, "edit.duplicate", json!({"ids": [scr], "map": true}));
let copy = id_of(&out["ids"][0]).unwrap();
assert_eq!(out["map"][scr.to_string()], json!(copy), "the root maps to the copy in `ids`");
assert_complete(&s, &[(scr, copy)], &out);
}
#[test]
fn map_covers_several_roots_and_a_main_component_copied_as_an_instance() {
let mut s = Session::new();
let (scr, card) = screen(&mut s);
let out = ex(&mut s, "edit.duplicate", json!({"ids": [card, scr], "map": true}));
let ids: Vec<NodeId> = out["ids"].as_array().unwrap().iter().map(|v| id_of(v).unwrap()).collect();
assert_eq!(ids.len(), 2);
let (card_copy, scr_copy) = (out["map"][card.to_string()].clone(), out["map"][scr.to_string()].clone());
assert!(ids.contains(&id_of(&card_copy).unwrap()) && ids.contains(&id_of(&scr_copy).unwrap()), "{out}");
let doc = s.doc().unwrap();
let card_copy = id_of(&card_copy).unwrap();
assert_eq!(doc.node(card_copy).unwrap().kind.type_name(), "INSTANCE", "a main component's copy is an instance");
assert_complete(&s, &[(card, card_copy), (scr, id_of(&scr_copy).unwrap())], &out);
}
#[test]
fn without_map_the_result_is_just_the_ids() {
let mut s = Session::new();
let (scr, _) = screen(&mut s);
for p in [json!({"ids": [scr]}), json!({"ids": [scr], "map": false})] {
let out = ex(&mut s, "edit.duplicate", p);
let keys: Vec<&String> = out.as_object().unwrap().keys().collect();
assert_eq!(keys, ["ids"], "{out}");
}
}
#[test]
fn a_batch_edits_inside_the_copy_through_the_map() {
let mut s = Session::new();
let (scr, _) = screen(&mut s);
let doc = s.doc().unwrap();
let inner = *doc.node(scr).unwrap().children.last().unwrap();
let body = doc.node(inner).unwrap().children[0];
let field = format!("map/{body}");
ex(
&mut s,
"command.batch",
json!({"commands": [
{"command": "edit.duplicate", "as": "copy", "params": {"ids": [scr], "map": true}},
{"command": "node.set", "params": {"ids": [{"$ref": "copy", "field": field}], "props": {"text": "Copy"}}},
]}),
);
let doc = s.doc().unwrap();
let text = |id: NodeId| match &doc.node(id).unwrap().kind {
figcraft_doc::NodeKind::Text(t) => t.characters.clone(),
_ => String::new(),
};
assert_eq!(text(body), "Body", "the original is untouched");
let copy = s.selection()[0];
let copy_body = doc.subtree(copy).into_iter().find(|i| path(doc, copy, *i) == path(doc, scr, body)).unwrap();
assert_eq!(text(copy_body), "Copy", "the edit landed in the copy");
}
@@ -169,6 +169,58 @@ fn drag_move(s: &mut Session, from: (f64, f64), by: (f64, f64)) {
s.pointer(&ev(PointerKind::Up, (from.0 + by.0, from.1 + by.1))).unwrap();
}
/// Drag from `from` to `to` in four steps, Alt held at step `k` when `alt(k)` (0: the press, 1–4:
/// the moves, 5: the release).
fn drag_alt_when(s: &mut Session, from: (f64, f64), to: (f64, f64), alt: impl Fn(u8) -> bool) {
let ev = |k: u8, kind, (x, y): (f64, f64)| PointerEvent { alt: alt(k), ..PointerEvent::at(kind, x, y, 1.0) };
s.pointer(&ev(0, PointerKind::Down, from)).unwrap();
for k in 1..=4 {
let t = f64::from(k) / 4.0;
s.pointer(&ev(k, PointerKind::Move, (from.0 + (to.0 - from.0) * t, from.1 + (to.1 - from.1) * t))).unwrap();
}
s.pointer(&ev(5, PointerKind::Up, to)).unwrap();
}
fn layers(s: &Session) -> usize {
s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len()
}
fn last_step(s: &Session) -> String {
s.ds().unwrap().history.undo.last().map(|e| e.label.clone()).unwrap_or_default()
}
#[test]
fn alt_pressed_during_a_drag_turns_the_move_into_a_copy() {
// #1385: as in other design apps, Alt counts whenever it goes down, not only at the press.
let mut s = Session::new();
let a = rect(&mut s, 0.0, 0.0);
// Alt from the third step: the original goes back, a copy lands where the drag ends and snaps
// to the original (2 px below its top edge).
drag_alt_when(&mut s, (50.0, 50.0), (250.0, 52.0), |k| k >= 3);
let copy = s.selection()[0];
assert_ne!(copy, a);
assert_eq!((at(&s, a), at(&s, copy)), ((0.0, 0.0), (200.0, 0.0)));
assert_eq!((layers(&s), last_step(&s).as_str()), (2, "Duplicate"));
assert!(s.undo().unwrap());
assert_eq!((layers(&s), at(&s, a)), (1, (0.0, 0.0)), "one undo step");
// Let go of Alt during the drag: no copy, the original moves (and no longer snaps to where it
// started, as only a copy does).
drag_alt_when(&mut s, (50.0, 50.0), (250.0, 52.0), |k| k < 3);
assert_eq!((layers(&s), s.selection(), at(&s, a)), (1, vec![a], (200.0, 2.0)));
assert_eq!(last_step(&s), "Move");
// Down and up again during one drag: a copy, as Alt is held at the end.
s.undo().unwrap();
drag_alt_when(&mut s, (50.0, 50.0), (250.0, 52.0), |k| k == 1 || k >= 4);
assert_eq!((layers(&s), at(&s, a)), (2, (0.0, 0.0)));
// Alt changed only by the release (no move in between): the release decides.
s.undo().unwrap();
drag_alt_when(&mut s, (50.0, 50.0), (250.0, 52.0), |k| k == 5);
assert_eq!((layers(&s), at(&s, a), last_step(&s).as_str()), (2, (0.0, 0.0), "Duplicate"), "Alt down at the release: a copy");
s.undo().unwrap();
drag_alt_when(&mut s, (50.0, 50.0), (250.0, 52.0), |k| k < 5);
assert_eq!((layers(&s), at(&s, a), last_step(&s).as_str()), (1, (200.0, 2.0), "Move"), "Alt up at the release: a move");
}
/// The reported path (#567): Cmd+D, drag the copy with the mouse, Cmd+D again. The other tests
/// here set the copy's position with `node.set` or Alt-drag it; dragging is how it is usually done,
/// and the step is measured from where the copy ended up, whichever way it got there.
@@ -187,6 +187,23 @@ fn contents_only_leaves_out_overlapping_layers() {
assert_eq!(pixels(&data).get_pixel(75, 75).0, [0, 0, 255, 255], "a layer's own setting is honoured");
}
#[test]
fn penpot_v20_export_limits_shadows_in_all_directions() {
let mut session = Session::new();
for (index, offset, size) in
[((0.0, 0.0), (-12.0, -7.0), (112, 107)), ((200.0, 0.0), (13.0, -9.0), (113, 109)), ((400.0, 0.0), (-3.0, 14.0), (103, 114))]
{
let r = rect(&mut session, index.0, index.1, 100.0, 100.0, "#FF0000", None);
shadow(&mut session, r, offset, 0.0, 0.0);
assert_eq!(png_size(&export(&mut session, json!({"ids":[r]}))), size, "a sharp offset shadow must extend only the touched export edges");
assert_eq!(
png_size(&export(&mut session, json!({"ids":[r], "includeBoundingBox": true}))),
(100, 100),
"explicit bounding-box exports must not grow due to a shadow"
);
}
}
#[test]
fn what_an_export_covers() {
let mut s = Session::new();
+51 -6
View File
@@ -19,6 +19,8 @@ pub struct Missing {
pub family: String,
/// Text layers that use it (0: only a text style or a variable names it).
pub layers: usize,
/// The font store source it can be downloaded from (`Google Fonts`, `Noto`; desktop only).
pub source: Option<String>,
}
/// The families `doc` names that the font database doesn't have, by name. On the desktop the
@@ -61,9 +63,45 @@ pub fn missing(doc: &Document) -> Vec<Missing> {
}
}
}
used.into_values().filter(|(family, _)| !figcraft_text::has_family(family)).map(|(family, layers)| Missing { family, layers }).collect()
used.into_values()
.filter(|(family, _)| !figcraft_text::has_family(family))
.map(|(family, layers)| Missing { source: source(&family), family, layers })
.collect()
}
/// A family's download ([`downloads`]).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Download {
/// Bytes downloaded of all its files.
Running { done: u64, total: u64 },
/// Installed and scanned: the next `font.download` of it fits the open document's text.
Installed,
/// Why it failed (nothing was installed).
Failed(String),
}
#[cfg(not(target_arch = "wasm32"))]
pub use download::{allow_downloads, downloads, downloads_allowed, source};
/// The browser keeps its built-in fonts: nothing is downloaded there.
#[cfg(target_arch = "wasm32")]
mod no_download {
pub fn source(_: &str) -> Option<String> {
None
}
pub fn downloads() -> Vec<(String, super::Download)> {
Vec::new()
}
pub fn downloads_allowed() -> bool {
false
}
pub fn allow_downloads() -> std::result::Result<(), String> {
Err("fonts aren't downloaded in the browser".into())
}
}
#[cfg(target_arch = "wasm32")]
pub use no_download::{allow_downloads, downloads, downloads_allowed, source};
/// Every string value of variable `id` in any mode, following aliases (each variable once).
fn string_values(vars: &Variables, id: &str) -> Vec<String> {
let mut out = Vec::new();
@@ -134,18 +172,19 @@ fn refit_text(s: &mut Session, p: &Value) -> Result<Value> {
}
fn missing_fonts(s: &mut Session, _: &Value) -> Result<Value> {
let families: Vec<Value> = missing(s.doc()?).into_iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect();
let families: Vec<Value> = missing(s.doc()?).into_iter().map(|m| json!({"family": m.family, "layers": m.layers, "source": m.source})).collect();
s.request_ui("dialog.missingFonts", json!({}));
Ok(json!({"families": families}))
}
/// The font commands: `text.missingFonts` and `text.refit`.
/// The font commands: `text.missingFonts`, `text.refit`, and on the desktop [`download`]'s.
pub fn specs() -> Vec<CommandSpec> {
vec![
#[allow(unused_mut)]
let mut list = vec![
spec!(
"text.missingFonts",
"Missing fonts",
"{} fonts the open document names that aren't installed (text layers, styled ranges, text styles, font family variables); the app shows them → {families: [{family, layers}]}",
"{} fonts the open document names that aren't installed (text layers, styled ranges, text styles, font family variables); the app shows them → {families: [{family, layers, source (the font store source font.download gets it from, or null)}]}",
false,
missing_fonts
),
@@ -156,8 +195,14 @@ pub fn specs() -> Vec<CommandSpec> {
true,
refit_text
),
]
];
#[cfg(not(target_arch = "wasm32"))]
list.extend(download::specs());
list
}
#[cfg(not(target_arch = "wasm32"))]
pub mod download;
#[cfg(test)]
mod tests;
+168
View File
@@ -0,0 +1,168 @@
//! Downloading a missing font (#754) through the suite's font store, `craft-font-store`: its
//! pinned Google Fonts and Noto indexes (compiled in) tell which families can be had, and it
//! downloads their files, checks each one's size and sha256 and installs them into the one folder
//! every Craft app's font scan reads. Desktop only.
//!
//! Downloading is off until the user turns it on, in the app's own window ([`allow_downloads`],
//! which is not a command). The commands drive the store as its agent, as the store's rules
//! have it: they install while the user has downloading on, are refused while it is off, and can
//! never turn it on.
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock, PoisonError};
use craft_font_catalog::{FontEnv, font_store_dir};
use craft_font_store::{Caller, Cancel, FontStore, Index, Network};
use serde_json::{Value, json};
use super::Download;
use crate::cmd::CommandSpec;
use crate::params::req_string;
use crate::{EngineError, Result, Session, bad, spec};
/// Lower-case family → (spelling, download). Installs are the machine's, so the jobs are the
/// process's, whichever document asked.
static JOBS: Mutex<BTreeMap<String, (String, Download)>> = Mutex::new(BTreeMap::new());
/// Set by [`use_store_dir`].
static STORE_DIR: OnceLock<PathBuf> = OnceLock::new();
fn jobs() -> std::sync::MutexGuard<'static, BTreeMap<String, (String, Download)>> {
JOBS.lock().unwrap_or_else(PoisonError::into_inner)
}
fn set(family: &str, state: Download) {
if let Some(job) = jobs().get_mut(&family.to_lowercase()) {
job.1 = state;
}
}
/// Use `dir` as the font store instead of the suite's folder, for this process (tests: they never
/// read the user's setting). The first call wins.
pub fn use_store_dir(dir: PathBuf) {
let _ = STORE_DIR.set(dir);
}
fn store_dir() -> Option<PathBuf> {
if let Some(dir) = STORE_DIR.get() {
return Some(dir.clone());
}
// This crate's tests never touch the user's store.
if cfg!(test) {
return Some(std::env::temp_dir().join(format!("figcraft-font-store-tests-{}", std::process::id())));
}
font_store_dir(&FontEnv::from_process())
}
fn store(caller: Caller) -> Option<FontStore> {
Some(FontStore::new(store_dir()?, caller))
}
/// The indexes, read once: the built-in ones compiled in and the sources the user added as last
/// fetched. No request.
fn index() -> &'static Index {
static INDEX: OnceLock<Index> = OnceLock::new();
INDEX.get_or_init(|| store(Caller::Agent).map(|s| s.offline_indexes()).unwrap_or_else(|| Index::bundled().unwrap_or_default()))
}
/// The name of the source `family` can be downloaded from (`Google Fonts`, `Noto`), if any.
pub fn source(family: &str) -> Option<String> {
let index = index();
index.family(family.trim()).and_then(|f| index.source_of(f)).map(|s| s.name.clone())
}
/// Whether the user turned downloading fonts on (the setting is the store's, shared by every
/// Craft app).
pub fn downloads_allowed() -> bool {
store(Caller::Agent).is_some_and(|s| s.network() == Network::On)
}
/// Turn downloading fonts on. Only from the user's own action in the app's interface (a click in
/// the Missing fonts window), never from a command: agents can't.
pub fn allow_downloads() -> std::result::Result<(), String> {
store(Caller::User).ok_or_else(|| "this system has no font store folder".to_string())?.enable_network().map_err(|e| e.to_string())
}
/// The downloads of this process: (family, state).
pub fn downloads() -> Vec<(String, Download)> {
jobs().values().cloned().collect()
}
fn state_json(family: &str, state: &Download) -> Value {
match state {
Download::Running { done, total } => json!({"family": family, "state": "downloading", "done": done, "total": total}),
Download::Installed => json!({"family": family, "state": "installed"}),
Download::Failed(error) => json!({"family": family, "state": "failed", "error": error}),
}
}
/// `font.download`: start downloading `family`'s files on a thread of its own, or report the
/// download under way; a family that is there now fits the open document's text to it instead.
fn download(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "font.download";
let family = req_string(CMD, p, "family")?.trim().to_string();
let key = family.to_lowercase();
let state = jobs().get(&key).map(|(_, st)| st.clone());
if let Some(running @ Download::Running { .. }) = &state {
return Ok(state_json(&family, running));
}
// Installed: even when no scan sees it (system fonts opted out), it isn't downloaded again.
if state == Some(Download::Installed) || figcraft_text::has_family(&family) {
jobs().remove(&key);
let refitted = crate::fonts::refit_open(s, None, Some(std::slice::from_ref(&family)));
return Ok(json!({"family": family, "state": "installed", "refitted": refitted}));
}
let index = index();
let found = index.family(&family).ok_or_else(|| bad(CMD, format!("{family} is not in the font store's index")))?;
let store = store(Caller::Agent).ok_or_else(|| EngineError::Other("this system has no font store folder".into()))?;
if store.network() != Network::On {
return Err(EngineError::Disabled(CMD.into(), "downloading fonts is turned off; the user turns it on in Text ▸ Missing fonts".into()));
}
let total: u64 = found.files.iter().map(|f| f.size).sum();
let source = index.source_of(found).map(|src| src.name.clone());
jobs().insert(key, (found.family.clone(), Download::Running { done: 0, total }));
let name = found.family.clone();
let spawned = std::thread::Builder::new().name("figcraft-font-download".into()).spawn(move || {
let result = store.install_family(index, found, &Cancel::new(), &mut |p| set(&name, Download::Running { done: p.done, total: p.total }));
match result {
Ok(_) => {
// The new files are scanned here, off the interface's thread.
figcraft_text::rescan_system_fonts();
set(&name, Download::Installed);
}
Err(e) => set(&name, Download::Failed(e.to_string())),
}
});
if let Err(e) = spawned {
jobs().remove(&family.to_lowercase());
return Err(EngineError::Other(format!("couldn't start the download: {e}")));
}
Ok(json!({"family": found.family, "source": source, "state": "downloading", "done": 0, "total": total, "files": found.files.len()}))
}
/// The font commands that download: `font.download` and `font.downloads`.
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!(
"font.download",
"Download font",
"{family} downloads a missing font family from the font store (Google Fonts, Noto: pinned, checksum-verified files installed into the Craft apps' shared font folder) on a thread of its own; refused while the user has downloading turned off (only the user turns it on, in Text ▸ Missing fonts). Call again to see its progress; once it is installed, the call fits the open document's text to it (text.refit) → {family, state: downloading|installed, done?, total?, refitted?}",
true,
download
),
spec!(
"font.downloads",
"Font downloads",
"{} this session's font downloads and whether downloading fonts is turned on → {allowed, downloads: [{family, state: downloading|installed|failed, done?, total?, error?}]}",
false,
|_, _| {
let list: Vec<Value> = downloads().iter().map(|(f, st)| state_json(f, st)).collect();
Ok(json!({"allowed": downloads_allowed(), "downloads": list}))
}
),
]
}
#[cfg(test)]
#[path = "download_tests.rs"]
mod tests;
+61
View File
@@ -0,0 +1,61 @@
//! `font.download` without the network: this crate's tests use a store folder of their own, where
//! downloading was never turned on.
use serde_json::{Value, json};
use super::{Download, downloads, downloads_allowed, jobs, set, source};
use crate::{EngineError, Session};
#[test]
fn the_store_index_says_which_missing_families_can_be_downloaded() {
assert_eq!(source("Lato").as_deref(), Some("Google Fonts"));
assert_eq!(source(" lato ").as_deref(), Some("Google Fonts"), "as the document spells it");
assert_eq!(source("FigCraft Not A Store Font"), None);
let mut s = Session::new();
for family in ["FigCraft Not A Store Font", "Lato"] {
s.execute("text.create", &json!({"text": "Hamburgefonstiv", "props": {"fontFamily": family}})).unwrap();
}
let listed = s.execute("text.missingFonts", &json!({})).unwrap();
let ours = |name: &str| listed["families"].as_array().unwrap().iter().find(|f| f["family"] == name).cloned();
assert_eq!(ours("FigCraft Not A Store Font").unwrap()["source"], Value::Null);
if !figcraft_text::has_family("Lato") {
assert_eq!(ours("Lato").unwrap()["source"], "Google Fonts");
}
}
#[test]
fn nothing_downloads_until_the_user_turns_downloading_on() {
let mut s = Session::new();
assert!(!downloads_allowed(), "off by default");
let Some(family) = ["Lato", "Merriweather", "Bungee Shade"].into_iter().find(|f| !figcraft_text::has_family(f)) else { return };
let refused = s.execute("font.download", &json!({"family": family})).unwrap_err();
assert!(matches!(&refused, EngineError::Disabled(cmd, why) if cmd == "font.download" && why.contains("turned off")), "{refused}");
assert!(!downloads().iter().any(|(f, _)| f == family), "no download started");
assert_eq!(s.execute("font.downloads", &json!({})).unwrap()["allowed"], false);
assert!(matches!(s.execute("font.download", &json!({})), Err(EngineError::BadParams { .. })));
let unknown = s.execute("font.download", &json!({"family": "FigCraft Not A Store Font"})).unwrap_err();
assert!(unknown.to_string().contains("not in the font store's index"), "{unknown}");
}
#[test]
fn a_download_reports_its_progress_then_fits_the_text_once_installed() {
let family = "FigCraft Download Progress";
let mut s = Session::new();
jobs().insert(family.to_lowercase(), (family.into(), Download::Running { done: 50, total: 200 }));
let r = s.execute("font.download", &json!({"family": family})).unwrap();
assert_eq!(r, json!({"family": family, "state": "downloading", "done": 50, "total": 200}), "no second download");
let listed = s.execute("font.downloads", &json!({})).unwrap();
assert!(listed["downloads"].as_array().unwrap().contains(&json!({"family": family, "state": "downloading", "done": 50, "total": 200})));
set(family, Download::Failed("downloading x failed".into()));
let listed = s.execute("font.downloads", &json!({})).unwrap();
assert!(listed["downloads"].as_array().unwrap().contains(&json!({"family": family, "state": "failed", "error": "downloading x failed"})));
// Installed (and scanned by the download's thread): the next call fits the text, once.
set(family, Download::Installed);
let r = s.execute("font.download", &json!({"family": family})).unwrap();
assert_eq!(r, json!({"family": family, "state": "installed", "refitted": []}));
assert!(!downloads().iter().any(|(f, _)| f == family));
}
+1 -1
View File
@@ -188,7 +188,7 @@ fn threads_replies_and_resolving() {
|c| c.replies.iter().map(|m| m.text.as_str()).eq(["Sure", "Done"]),
"replies stay in the order they were written",
),
("comment.resolve", json!({"id": c, "author": "Sam"}), |c| c.resolved, "anyone in the file can resolve a thread, not just its author"),
("comment.resolve", json!({"id": c}), |c| c.resolved, "anyone in the file can resolve a thread, not just its author (it takes no author)"),
("comment.resolve", json!({"id": c, "resolved": false}), |c| !c.resolved, "a resolved thread can be reopened"),
("comment.resolve", json!({"id": c}), |c| c.resolved, "resolving again marks it resolved"),
(
+110
View File
@@ -0,0 +1,110 @@
//! `image.crop` (Object ▸ Crop image, the right-click menu and the image fill row): the layer's
//! top visible image fill (or the one `index` names) switches to Crop with the picture left where
//! its old mode drew it, as one undo step, and the canvas is asked to enter crop mode. A layer
//! without an image fill, or several layers, are refused with a reason and nothing changes.
use base64::Engine as _;
use figcraft_color::{ImageScaleMode, Paint, PaintKind};
use figcraft_doc::NodeId;
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
/// A 200×100 synthetic image layer (every pixel differs), resized to 200×150 so Fill and Fit
/// place the picture differently.
fn image_layer(s: &mut Session) -> NodeId {
let image = image::RgbaImage::from_fn(200, 100, |x, y| image::Rgba([x as u8, y as u8, 120, 255]));
let mut bytes = std::io::Cursor::new(Vec::new());
image.write_to(&mut bytes, image::ImageFormat::Png).unwrap();
let data = base64::engine::general_purpose::STANDARD.encode(bytes.into_inner());
let id = id_of(&ex(s, "file.import", json!({"data": data, "name": "crop.png", "x": 0, "y": 0}))["id"]).unwrap();
ex(s, "node.set", json!({"ids": [id], "props": {"height": 150}}));
id
}
fn fills(s: &Session, id: NodeId) -> Vec<Paint> {
s.doc().unwrap().node(id).unwrap().fills.clone()
}
fn set_mode(s: &mut Session, id: NodeId, mode: ImageScaleMode) {
let mut f = fills(s, id);
let PaintKind::Image(img) = &mut f[0].kind else { panic!("image fill") };
img.scale_mode = mode;
ex(s, "node.set", json!({"ids": [id], "props": {"fills": f}}));
}
fn pixels(s: &Session, id: NodeId) -> Vec<u8> {
figcraft_render::render_node(s.doc().unwrap(), id, 1.0).unwrap().data().to_vec()
}
fn undo_steps(s: &Session) -> usize {
s.ds().unwrap().history.undo.len()
}
#[test]
fn crop_image_switches_to_crop_keeping_the_picture_as_one_undo_step() {
for mode in [ImageScaleMode::Fill, ImageScaleMode::Fit, ImageScaleMode::Crop] {
let mut s = Session::new();
let layer = image_layer(&mut s);
set_mode(&mut s, layer, mode);
let (before, doc_before, steps) = (pixels(&s, layer), s.doc().unwrap().clone(), undo_steps(&s));
s.ui_requests.clear();
let r = ex(&mut s, "image.crop", json!({}));
assert_eq!(r, json!({"id": layer, "index": 0}), "{mode:?}");
let PaintKind::Image(img) = &fills(&s, layer)[0].kind else { panic!("image fill") };
assert_eq!(img.scale_mode, ImageScaleMode::Crop, "{mode:?}");
assert_eq!(pixels(&s, layer), before, "{mode:?}: the picture stays where it was");
let asked: Vec<_> = s.ui_requests.iter().filter(|u| u.kind == "image.crop").map(|u| u.data.clone()).collect();
assert_eq!(asked, vec![json!({"id": layer, "index": 0})], "{mode:?}: the canvas enters crop mode");
if mode == ImageScaleMode::Crop {
assert_eq!(undo_steps(&s), steps, "already cropped: nothing to undo");
continue;
}
assert_eq!(undo_steps(&s), steps + 1, "{mode:?}: one undo step");
ex(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc().unwrap(), &doc_before, "{mode:?}: undo restores the fill");
}
}
#[test]
fn crop_image_takes_the_top_visible_image_fill_or_the_one_named() {
let mut s = Session::new();
let layer = image_layer(&mut s);
let mut f = fills(&s, layer);
let image = f[0].clone();
let mut hidden = image.clone();
hidden.visible = false;
// Bottom to top: image, image, solid, hidden image.
f = vec![image.clone(), image, Paint::solid(figcraft_color::Color::BLACK), hidden];
ex(&mut s, "node.set", json!({"ids": [layer], "props": {"fills": f}}));
assert_eq!(ex(&mut s, "image.crop", json!({"ids": [layer]}))["index"], 1, "the top visible image fill");
let modes =
|s: &Session| fills(s, layer).iter().map(|p| if let PaintKind::Image(i) = &p.kind { Some(i.scale_mode) } else { None }).collect::<Vec<_>>();
assert_eq!(modes(&s), [Some(ImageScaleMode::Fill), Some(ImageScaleMode::Crop), None, Some(ImageScaleMode::Fill)]);
assert_eq!(ex(&mut s, "image.crop", json!({"ids": [layer], "index": 0}))["index"], 0, "an image fill by index");
assert_eq!(modes(&s)[0], Some(ImageScaleMode::Crop));
let err = s.execute("image.crop", &json!({"ids": [layer], "index": 2})).unwrap_err().to_string();
assert!(err.contains("not an image"), "{err}");
let err = s.execute("image.crop", &json!({"ids": [layer], "index": 9})).unwrap_err().to_string();
assert!(err.contains("no fill at that index"), "{err}");
}
#[test]
fn crop_image_is_refused_without_one_image_layer() {
let mut s = Session::new();
let rect = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 300, "y": 0, "width": 50, "height": 50}))["id"]).unwrap();
let layer = image_layer(&mut s);
let doc = s.doc().unwrap().clone();
s.ui_requests.clear();
for (ids, why) in [(json!([rect]), "no image fill"), (json!([rect, layer]), "select one layer")] {
let err = s.execute("image.crop", &json!({"ids": ids})).unwrap_err();
assert!(matches!(&err, crate::EngineError::Disabled(..)) && err.to_string().contains(why), "{err}");
}
assert_eq!(s.doc().unwrap(), &doc, "nothing changes");
assert!(s.ui_requests.is_empty(), "no crop mode");
}
+9 -4
View File
@@ -1,8 +1,9 @@
//! Inspect behaviour suite, written from the public documentation of design tools' inspect and
//! developer handoff modes: the reported layout (size, position, rotation, radius, opacity, blend),
//! colour formats, units, typography, the generated CSS, SwiftUI and Compose for common layers,
//! the asset list and redline measurements. Each rule is a table row or an assert with a one-line
//! derivation; where the documentation is silent the row says so and pins today's behaviour.
//! colour formats, units, typography, the generated CSS, SwiftUI, Compose and Flutter for common
//! layers, the asset list and redline measurements. Each rule is a table row or an assert with a
//! one-line derivation; where the documentation is silent the row says so and pins today's
//! behaviour.
// The tables read best as tuples.
#![allow(clippy::type_complexity)]
@@ -241,12 +242,16 @@ fn native_code_for_a_simple_layer() {
set(&mut s, &[r], json!({"cornerRadius": 8}));
let swift = code(&mut s, r, json!({"language": "swiftui"}));
let compose = code(&mut s, r, json!({"language": "compose"}));
let flutter = code(&mut s, r, json!({"language": "flutter"}));
// (code, expected fragment, derivation)
let rows: [(&str, &str, &str); 4] = [
let rows: [(&str, &str, &str); 7] = [
(&swift, "frame(width: 100, height: 50)", "SwiftUI sizes a view with frame, in points"),
(&swift, "Color(red: 1, green: 0, blue: 0, opacity: 1)", "docs silent: the fill as a SwiftUI colour"),
(&compose, "Modifier.size(100.dp, 50.dp)", "Compose sizes in dp"),
(&compose, "RoundedCornerShape(8.dp)", "docs silent: corner radius as a rounded shape"),
(&flutter, "width: 100,\n height: 50,", "Flutter sizes a Container in logical pixels"),
(&flutter, "Color(0xFFFF0000)", "docs silent: the fill as a Flutter colour"),
(&flutter, "borderRadius: BorderRadius.circular(8)", "docs silent: corner radius as a rounded border"),
];
for (c, want, why) in rows {
assert!(c.contains(want), "{want}: {why}\n{c}");

Some files were not shown because too many files have changed in this diff Show More