Compare commits

...

16 Commits

Author SHA1 Message Date
Nathan Fontenele 5e90592e80
M1: report images a native file lost on loading (#207) (#242)
A `.figcraft` image member that is damaged, too large or missing was skipped
without a word, and saving the file again dropped it for good. The load now
lists each lost image (up to 20, then a count) with its hash and the reason,
file.open puts it in `repairs` and sets a session message, and the app shows
that as a toast.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: bflatastic <bombayapirat@gmail.com>
2026-10-14 10:32:44 +07:00
Nathan Fontenele ef2c6cb30e
M1: SVG import budgets for SVGZ and embedded SVGs (#190) (#241)
usvg unpacks SVGZ with no limit and parses SVGs embedded in `<image>` data
URIs with no shared depth or byte budget, and the nesting check ran on the
compressed bytes. FigCraft now unpacks SVGZ itself within 64 MiB, checks the
real document, and resolves embedded SVGs with its own data resolver: one
32 MiB budget of unpacked bytes and at most 4 levels, an embedded document
past a budget is left out. Rasters still resolve; linked files stay unread.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: bflatastic <bombayapirat@gmail.com>
2026-10-14 10:32:30 +07:00
Justin John 24c5617230
feat(dev-mode): Leptos code with component properties, instance calls and Code Connect (#240)
* feat(dev-mode): Leptos code for a layer tree (#223)

inspect.code takes language: leptos, and the Inspect panel lists it: the
HTML markup inside a #[component] fn's view!, with the stylesheet after it
as for React. Text is Rust string literals (line breaks as <br/>) so the
macro parses it; px or rem like the other web languages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(dev-mode): Leptos components take their properties, instances are calls (#223)

Leptos joins React in component code: a component or set is a #[component]
fn with #[prop] defaults (variant enums with as_str, bool, String, ViewFn
for instance swaps, Option<ViewFn> for slots), layers bound to booleans
show through .then, variants differ by data-* CSS rules, and instances
(also inside layer trees) are calls passing the values they change. Names
are snake_case, Rust keywords get a trailing _. Code Connect takes leptos
too (use <path>::<Name>; view! markup). Markup now takes the language
instead of jsx/leptos flags; React and Leptos share the stylesheet pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Justin John <babyjjbabyjj549@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bflatastic <bombayapirat@gmail.com>
2026-10-14 10:32:14 +07:00
Nathan Fontenele fb8ace5bf4
M1: full screen (#227) (#238)
View > Full screen (`view.fullscreen {on?}`, F11 on Windows and Linux,
Ctrl+Cmd+F on macOS) makes the window fill the screen, taskbar included,
and leaves it again. FigCraft's own menu item, translated in every catalog.

Tests: the key and the command toggle the window, junk parameters are
ignored; the shortcut table pins the new binding (49 pinned).

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: bflatastic <bombayapirat@gmail.com>
2026-10-14 10:31:59 +07:00
Justin John ce84d1f27f
fix(ui): keep wrapped colour picker swatches inside the picker (#180) (#237)
add_space(16) only indented the first row of a horizontal_wrapped, so with
many colours on a page the later rows started at the picker's edge and ran
past its right side. The swatch rows now sit in a frame inset 16 points on
both sides.

Co-authored-by: Justin John <babyjjbabyjj549@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:31:46 +07:00
Justin John 8e12e5eeda
fix(server): never persist a snapshot the reader refuses (#188) (#236)
Snapshots hold more than the document (recent batches, deletions), so near
the cap compaction could write one over max_file_bytes, which both readers
then refused: the file vanished on restart. Reading now allows twice the
cap, and compaction refuses a snapshot beyond that, keeping the old
snapshot and log.

Co-authored-by: Justin John <babyjjbabyjj549@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:31:30 +07:00
Justin John 118ca7c60b
fix(desktop): write preferences owner-only on Unix (#200) (#235)
state.json holds online owner tokens. The temporary file is now created
0600 (and narrowed if a crash left one behind) before anything is written,
and the rename carries that mode to state.json, so an older
world-readable file is replaced on the next save.

Co-authored-by: Justin John <babyjjbabyjj549@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:31:17 +07:00
Justin John d61ccc633a
fix(doc): reject and repair layer cycles and repeated children (#205) (#233)
validate() now reports children listed twice, pages with a parent, and
nodes not reachable from a page (cycles, detached subtrees). repair()
drops repeated children, clears page parents and removes every node not
reachable from a page in one linear pass, replacing the orphan loop.

Co-authored-by: Justin John <babyjjbabyjj549@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:31:03 +07:00
Justin John badcb6ec21
fix(deps): require ruzstd 0.9.1 for RUSTSEC-2026-0344 (#182) (#234)
Co-authored-by: Justin John <babyjjbabyjj549@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:30:46 +07:00
BFlat 7a9a67a8c3
Docs: roadmap entries for the community fixes in #211, #213, #214, #216 and #218 (#230)
* Docs: roadmap entries for the community fixes landed in #211, #213, #214, #216 and #218

Their ROADMAP.md lines were left out when the five PRs were rebased (all five edited the
same header line); the wording is the contributors' own.

* Docs: the header lists all five community fixes
2026-10-14 09:30:55 +07:00
Muhammadyusuf dd3d4d326c
feat(packaging): run figcraft-cli from the AppImage with `cli <args>` (#232)
The AppImage ships figcraft-cli in usr/bin, but AppRun only ever launched the
app, so the CLI (and its MCP server) could not be reached without extracting
the image. `FigCraft.AppImage cli mcp` now execs `figcraft-cli mcp`, skipping
desktop integration because the CLI is headless.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:30:42 +07:00
Justin John 6d735653a2
fix(render): keep one outline depth budget through nested booleans (#193) (#231)
Boolean operands restarted subtree_outline at depth 0, so booleans nested
in booleans bypassed the 64-level limit and could overflow the stack.

Co-authored-by: Justin John <babyjjbabyjj549@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:30:29 +07:00
Giuseppe Cautiero ac65168417
Implement centralized transactional fix for command batch failures (#229)
* Agent Host changes for agents/fix-command-batch-history-restore

* Implement centralized transactional fix for command batch failures - #1

* The batch rollback tests compile, are formatted and expect the tab that was in front

The new module used a private helper of plugins_tests and an unused import; the plugin test
asserted tab 0 was in front, but it opens a second file first, which is the one in front
when the plugin starts and the one the rollback puts back.

---------

Co-authored-by: bflatastic <bombayapirat@gmail.com>
2026-10-14 09:30:16 +07:00
Jeremy Yao f7e726714a
fix(text): don't rebuild every time a craft-fonts manifest is missing (#228)
crates/text/build.rs asked cargo to watch both craft-fonts manifests
whether or not they existed. Cargo takes a watched path that doesn't
exist as changed, so figcraft-text and every crate above it were rebuilt
on every build: with no craft-fonts checkout, and also with a checkout
of craft-fonts' main branch, which has no fonts/latin-manifest.txt.

Watch a manifest only when it exists, and its folder while it is
missing, so a manifest added later is still noticed. With no checkout
at all there is nothing to watch; the build warning and the README now
say how to pick one up after a first build.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:30:03 +07:00
Jeremy Yao 7d589c4456
fix(desktop): use Direct3D 12 on Windows instead of Vulkan (#226)
On Windows, eframe offered wgpu every backend and wgpu ranked Vulkan
first. On a laptop with two AMD GPUs whose drivers are years apart, the
Vulkan driver faults (0xc0000005 inside amdvlk64.dll) right after the
first frame, so the app died as its window opened. An access violation
in a driver thread is not something the app can catch.

Offer wgpu Direct3D 12 on Windows, with OpenGL behind it for hardware
without a Direct3D 12 driver. WGPU_BACKEND still names the backend on
every system, and other systems keep eframe's defaults.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:29:21 +07:00
Sir Clangington III. 79a0e58a3c
fix(plugin): meter every host call and copied byte (#221)
Every plugin import now counts as a host call (reply_len, reply_read and set_output were free), and bytes copied out of or into the plugin's memory spend fuel, reserved before the copy or allocation, including when call overhead is set to zero. Fixes #192.

The log byte budget test from #216 runs with the default fuel, since copying a megabyte of log bytes is itself charged now.
2026-10-14 07:42:06 +07:00
62 changed files with 1386 additions and 171 deletions

5
Cargo.lock generated
View File

@ -1667,6 +1667,7 @@ dependencies = [
"figcraft-pathops",
"figcraft-render",
"figcraft-text",
"flate2",
"thiserror 2.0.21",
"usvg",
]
@ -3924,9 +3925,9 @@ dependencies = [
[[package]]
name = "ruzstd"
version = "0.9.0"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a252f5e20f038fe7b4ea53e073e65398d652c864cc162fc77c56c2f13717b888"
checksum = "69e4ce4a039f42bf25920b95667e4d789e98749ab11bbb8d2c2936f788421bb5"
[[package]]
name = "ryu"

View File

@ -59,7 +59,7 @@ linesweeper = "0.4"
zip = { version = "2", default-features = false, features = ["deflate"] }
# `.fig` import: raw deflate chunks (already built for zip) and zstd chunks (pure Rust, MIT, wasm-safe).
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
ruzstd = { version = "0.9", default-features = false, features = ["std"] }
ruzstd = { version = "0.9.1", default-features = false, features = ["std"] }
sha2 = "0.10"
base64 = "0.22"
crc32fast = "1"

View File

@ -191,7 +191,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, 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, 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.
</td>
</tr>
</table>
@ -251,6 +251,8 @@ Fonts for the UI and the Text tool come from [craft-fonts](https://github.com/st
git clone https://github.com/storytold/craft-fonts ../craft-fonts
```
If you clone it after a first build, run `cargo clean -p figcraft-text` once so the next build embeds the fonts.
**Start from a template.** The home screen's Templates tab has eight starter files (a mobile app, a landing page, a dashboard, a design system starter, a wireframe kit, social post sizes, presentation slides and an icon set starter), also under File ▸ New from template…. They are built in code from original, synthetic content, so scripts and agents can open them too: `file.newFromTemplate {template}` ([`docs/mcp.md`](docs/mcp.md#starter-templates)).
For the web build, install [trunk](https://trunkrs.dev/) and run `cargo xtask wasm`.

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-13 · **Change:** minor (repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@ -131,6 +131,9 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
## Progress log
- **2026-10-14** · SVG/PDF export depth (M0.9, #208): visible descendants deeper than 256 levels below an exported root are refused before bounds or drawing, including masks and raster fallbacks. SVG has a checked API; the engine and CLI report the limit and suggest selecting an inner layer. Existing destination files survive refusal. Boundary, inner-root, hidden-subtree, multi-root/page and command/CLI regressions cover the change. Numbers unchanged.
- **2026-10-13** · MCP/control transport hardening (#201): 32 MiB incoming frames are bounded while reading; oversized frames return an error and close without draining. The opt-in loopback control listener caps active workers at 8, applies 60 s socket read/write inactivity timeouts and caps both request queues at 8, discarding timed-out requests including those deferred to hidden windows. Reader, queue, idle-client and blocked-writer regressions cover the limits. Numbers unchanged.
- **2026-10-13** · Attached MCP (M0.9, #194): a transport failure no longer replays a request that may already have edited the document. The caller gets an outcome-unknown error and must inspect before retrying; the next explicit call reconnects. Loopback tests cover a lost mutation reply, a single applied edit, reconnect and a connection failure before sending. Numbers unchanged.
- **2026-10-13** · Constrain proportions in the Layout panel (M1, #220): with the W/H link on, typing a width or height scales the other side by the layer's own ratio; the property edit dropped the ratio, so the link only worked while dragging resize handles. Both sides in one edit stay as typed, the `w`/`h` aliases work the same, each layer of a multi-selection keeps its own ratio, and a zero side has no ratio to keep. Regression test in the transform behaviour suite. Numbers unchanged.
- **2026-10-13** · Repeat last command (M1, #181): repeated edits keep the underlying command and parameters, so repeated nudges accumulate with one undo step each. A stored self-reference is refused instead of recursing. Regression tests cover three repeats, their undo steps, and missing or self-referencing commands. Numbers unchanged.
- **2026-10-13** · Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged.
@ -307,6 +310,10 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | minor | SVG/PDF export depth (#208): explicit preflight refusal beyond 256 descendant levels, propagated through public APIs, commands and CLI, with boundary/content and destination-preservation regressions. Numbers unchanged. |
| 2026-10-13 | minor | MCP/control transport hardening (#201): frame limits during reads, bounded connections and both UI request queues, socket inactivity timeouts; percentages and estimates unchanged |
| 2026-10-13 | minor | Plugin log limits (#191): cap each UTF-8 line at 8 KiB, total log bytes at 1 MiB and lines at 1,000 per run/callback before copying guest memory or forwarding to the host. Over-budget runs fail and roll back document edits; callbacks get fresh budgets. Added sandbox boundary/forwarding tests and an engine rollback regression. Percentages and hours unchanged. |
| 2026-10-13 | minor | Live-session revert guard (M9, #198): `file.revert` is refused while its document belongs to a live session, including publishing, reconnecting and hosting before the welcome. The document, previews, branch tabs and session stay intact; leave the session before reverting a local save. Offline revert is unchanged. Scripted access and sync regressions in `live_access_tests`. Numbers unchanged. |
| 2026-10-13 | minor | Repeat last command (M1, #181): keep the underlying command and parameters across repeats, reject self-reference, and retain one undo step per repeated edit. Numbers unchanged. |
| 2026-10-13 | minor | Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged. |
| 2026-10-13 | minor | Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged. |

View File

@ -235,6 +235,21 @@ fn a_file_cant_grow_past_its_cap_through_batches() {
// Its snapshot holds the document and the recent batches kept for reconnecting clients (at
// most a quarter of the cap), and the last batch let in may finish what the cap allows.
assert!(on_disk <= max + max / 4 + 210_000, "{on_disk} bytes on disk");
drop(raw);
server.stop();
// Everything confirmed opens again with the same settings, after compacting on the way out.
let kept = client.document().nodes().count();
let mut server = crate::start(&cfg).unwrap();
let mut raw = Raw::connect(server.addr());
raw.send(&Client::hello(&file.id, &edit.token, "Grower", ""));
let welcome = loop {
match raw.next() {
w @ ServerMsg::Welcome { .. } => break w,
ServerMsg::Refused { reason } => panic!("the file doesn't open again: {reason}"),
_ => {}
}
};
assert_eq!(Client::join(welcome).unwrap().document().nodes().count(), kept);
server.stop();
}

View File

@ -426,7 +426,7 @@ impl Store {
let meta: Meta = serde_json::from_slice(&disk::read_capped(&path.join(META), 1 << 20)?).map_err(|e| format!("{META} is damaged ({e})"))?;
let links: Vec<Link> =
serde_json::from_slice(&disk::read_capped(&path.join(LINKS), 16 << 20)?).map_err(|e| format!("{LINKS} is damaged ({e})"))?;
let generation = disk::check_snapshot(&disk::read_capped(&path.join(SNAPSHOT), self.cfg.max_file_bytes)?)?;
let generation = disk::check_snapshot(&disk::read_capped(&path.join(SNAPSHOT), self.snapshot_cap())?)?;
// A damaged log only loses its end; it is reported when the file opens.
if let Ok(bytes) = disk::read_capped(&path.join(LOG), self.cfg.max_file_bytes)
&& let Ok(log) = disk::read_log(&bytes)
@ -1086,7 +1086,7 @@ impl Store {
fn load(&mut self, file: &str) -> Result<Server, String> {
self.touch(file);
let dir = self.dir(file);
let (generation, state) = disk::decode_snapshot(&disk::read_capped(&dir.join(SNAPSHOT), self.cfg.max_file_bytes)?)?;
let (generation, state) = disk::decode_snapshot(&disk::read_capped(&dir.join(SNAPSHOT), self.snapshot_cap())?)?;
let mut server = Server::restore(state, "", self.cfg.core).map_err(|e| format!("the snapshot can't be used: {e}"))?;
let mut issues = Vec::new();
let mut fresh_log = true;
@ -1130,11 +1130,22 @@ impl Store {
Ok(server)
}
/// The largest snapshot read back: a file's cap plus room for what its state keeps beside the
/// document (recent batches for reconnecting clients, deletions), which may take it over.
fn snapshot_cap(&self) -> u64 {
self.cfg.max_file_bytes.saturating_mul(2)
}
/// Write `server`'s state as snapshot `generation` and start an empty log of it.
fn rewrite(&mut self, file: &str, generation: u64, server: &Server) -> Result<Log, String> {
let dir = self.dir(file);
let state = server.state().map_err(|e| e.to_string())?;
let bytes = disk::encode_snapshot(generation, &state)?;
// Never replace a readable snapshot with one that can't be opened again: the old snapshot
// and log still hold everything.
if bytes.len() as u64 > self.snapshot_cap() {
return Err(format!("the snapshot would be {} MiB, more than can be opened again", bytes.len() >> 20));
}
if bytes.len() as u64 > self.cfg.max_file_bytes {
log::warn!("file {file} is {} MiB, over the {} MiB cap (kept anyway)", bytes.len() >> 20, self.cfg.max_file_bytes >> 20);
}

View File

@ -0,0 +1,34 @@
//! Which graphics API draws the window.
//!
//! eframe offers wgpu every backend it was built with, and between equal GPUs wgpu takes Vulkan
//! first, on Windows too. A Vulkan driver that faults there takes the app down as the window opens:
//! an access violation inside the vendor's driver, which no guard of ours can catch (seen on a laptop
//! with two AMD GPUs whose drivers were years apart). Direct3D 12 is part of Windows, so on Windows
//! wgpu is offered Direct3D 12, and OpenGL for hardware without a Direct3D 12 driver.
//!
//! `WGPU_BACKEND` (`dx12`, `vulkan`, `gl`, `metal`) still names the backend on every system, and
//! systems other than Windows keep eframe's defaults.
use eframe::egui_wgpu::{WgpuConfiguration, WgpuSetup};
use eframe::wgpu::Backends;
/// Direct3D 12, then OpenGL: wgpu ranks a hardware GPU on either ahead of Direct3D's software one.
const WINDOWS: Backends = Backends::DX12.union(Backends::GL);
/// The backends wgpu chooses from: the ones `WGPU_BACKEND` asks for (`requested`), else [`WINDOWS`]
/// on Windows, else eframe's `defaults`.
pub fn backends(requested: Option<Backends>, windows: bool, defaults: Backends) -> Backends {
match requested {
Some(requested) => requested,
None if windows => WINDOWS,
None => defaults,
}
}
/// Offer wgpu [`backends`] instead of eframe's defaults.
pub fn configure(options: &mut WgpuConfiguration) {
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_setup {
let offered = &mut create.instance_descriptor.backends;
*offered = backends(Backends::from_env(), cfg!(windows), *offered);
}
}

View File

@ -0,0 +1,32 @@
//! The backends offered to wgpu: no Vulkan on Windows unless it is asked for (a faulting Vulkan
//! driver crashed the app as its window opened), and nothing changed anywhere else.
use eframe::wgpu::Backends;
use crate::graphics::backends;
/// What eframe offers when `WGPU_BACKEND` isn't set.
const DEFAULTS: Backends = Backends::PRIMARY.union(Backends::GL);
#[test]
fn windows_is_offered_direct3d_and_not_vulkan() {
let offered = backends(None, true, DEFAULTS);
assert!(offered.contains(Backends::DX12), "Direct3D 12 draws the window");
assert!(offered.contains(Backends::GL), "OpenGL is there for hardware without a Direct3D 12 driver");
assert!(!offered.contains(Backends::VULKAN), "a Vulkan driver that faults can't be picked");
}
#[test]
fn wgpu_backend_names_the_backend_on_every_system() {
for windows in [true, false] {
for asked in [Backends::VULKAN, Backends::DX12, Backends::GL, Backends::VULKAN | Backends::GL] {
assert_eq!(backends(Some(asked), windows, DEFAULTS), asked, "windows: {windows}");
}
}
}
#[test]
fn other_systems_keep_eframes_defaults() {
assert_eq!(backends(None, false, DEFAULTS), DEFAULTS);
assert_eq!(backends(None, false, Backends::METAL), Backends::METAL);
}

View File

@ -12,12 +12,15 @@
mod args;
mod control_server;
mod gamepad;
mod graphics;
mod recovery;
mod services;
#[cfg(test)]
mod args_tests;
#[cfg(test)]
mod graphics_tests;
#[cfg(test)]
mod recovery_online_tests;
#[cfg(test)]
mod recovery_tests;
@ -182,7 +185,8 @@ fn main() -> eframe::Result {
if let Some(icon) = icon {
viewport = viewport.with_icon(icon);
}
let options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() };
let mut options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() };
graphics::configure(&mut options.wgpu_options);
figcraft_ui_egui::windows::install(fig);
eframe::run_native("FigCraft", options, Box::new(move |_cc| Ok(Box::new(App { queue, ctx_slot, title: String::new(), recovery }))))
}

View File

@ -141,17 +141,31 @@ pub fn save_state(app: &FigApp) {
}
/// Write `prefs` to `dir` atomically (a temporary file renamed over the old one: a crash leaves
/// either version whole).
fn write_prefs(dir: &Path, prefs: &Prefs) {
/// either version whole). They hold online owner tokens, so on Unix only the owner can read them.
pub(crate) fn write_prefs(dir: &Path, prefs: &Prefs) {
let tmp = dir.join("state.json.tmp");
let written = std::fs::create_dir_all(dir)
.and_then(|()| std::fs::write(&tmp, json!({"prefs": prefs}).to_string()))
.and_then(|()| write_private(&tmp, json!({"prefs": prefs}).to_string().as_bytes()))
.and_then(|()| std::fs::rename(&tmp, dir.join(STATE)));
if let Err(e) = written {
log::warn!("preferences: couldn't save them in {}: {e}", dir.display());
}
}
/// Write `bytes` to `path`, owner read/write only on Unix from before the first byte (a leftover
/// file from a crash is narrowed too: the mode given at open only applies to new files).
fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write;
let mut o = std::fs::OpenOptions::new();
o.write(true).create(true).truncate(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut o, 0o600);
let mut f = o.open(path)?;
#[cfg(unix)]
f.set_permissions(std::os::unix::fs::PermissionsExt::from_mode(0o600))?;
f.write_all(bytes)
}
impl Services for Desktop {
fn pick_open(&mut self, title: &str, exts: &[&str]) -> Option<PathBuf> {
rfd::FileDialog::new().set_title(title).add_filter("Supported files", exts).pick_file()

View File

@ -66,3 +66,19 @@ fn plugins_are_kept_when_the_app_is_killed() {
assert!(!dir.join("state.json.tmp").exists(), "no temporary file is left behind");
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn preferences_are_readable_by_their_owner_only() {
use std::os::unix::fs::PermissionsExt;
let dir = root("private");
let mode = |p: &std::path::Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
// An older world-readable file and a leftover temporary one from a crash.
for name in ["state.json", "state.json.tmp"] {
std::fs::write(dir.join(name), "{}").unwrap();
std::fs::set_permissions(dir.join(name), std::fs::Permissions::from_mode(0o644)).unwrap();
}
crate::services::write_prefs(&dir, &figcraft_engine::Prefs::default());
assert_eq!(mode(&dir.join("state.json")), 0o600);
let _ = std::fs::remove_dir_all(&dir);
}

View File

@ -939,7 +939,11 @@ impl Document {
if n.id != *id {
issues.push(format!("node key {id} has id {}", n.id));
}
let mut seen = std::collections::BTreeSet::new();
for c in &n.children {
if !seen.insert(*c) {
issues.push(format!("child {c} listed twice in {id}"));
}
match self.node(*c) {
Some(cn) if cn.parent == Some(*id) => {}
Some(_) => issues.push(format!("child {c} of {id} has another parent")),
@ -956,10 +960,32 @@ impl Document {
if n.is_page() != listed {
issues.push(format!("page list mismatch for {id}"));
}
if n.is_page() && n.parent.is_some() {
issues.push(format!("page {id} has a parent"));
}
}
let reachable = self.reachable();
for id in self.nodes.keys().filter(|k| !reachable.contains(*k)) {
issues.push(format!("{id} is not under a page (cycle or detached)"));
}
issues
}
/// Every node reachable from a page through reciprocal parent/child links, each once (cycles
/// and repeated children are walked once, so this is linear in the node count).
fn reachable(&self) -> std::collections::BTreeSet<NodeId> {
let mut seen = std::collections::BTreeSet::new();
let mut stack: Vec<NodeId> = self.nodes.iter().filter(|(_, n)| n.is_page() && n.parent.is_none()).map(|(k, _)| *k).collect();
while let Some(id) = stack.pop() {
if !seen.insert(id) {
continue;
}
let Some(n) = self.nodes.get(&id) else { continue };
stack.extend(n.children.iter().filter(|c| !seen.contains(*c) && self.nodes.get(*c).is_some_and(|cn| cn.parent == Some(id))));
}
seen
}
/// Repair what [`validate`](Self::validate) reports (lenient loading of untrusted files).
pub fn repair(&mut self) {
let ids: Vec<NodeId> = self.nodes.keys().copied().collect();
@ -967,34 +993,30 @@ impl Document {
if let Some(n) = self.nodes.get_mut(id) {
let n = Arc::make_mut(n);
n.id = *id;
// Pages are roots.
if n.is_page() {
n.parent = None;
}
}
}
// Drop dangling children and children claimed by another parent.
// Drop dangling children, children claimed by another parent and repeats.
let parents: BTreeMap<NodeId, Option<NodeId>> = self.nodes.iter().map(|(k, n)| (*k, n.parent)).collect();
for id in &ids {
let Some(n) = self.nodes.get(id) else { continue };
let keep: Vec<NodeId> = n.children.iter().copied().filter(|c| parents.get(c).copied().flatten() == Some(*id)).collect();
let mut seen = std::collections::BTreeSet::new();
let keep: Vec<NodeId> =
n.children.iter().copied().filter(|c| parents.get(c).copied().flatten() == Some(*id) && seen.insert(*c)).collect();
if keep != n.children
&& let Some(n) = self.nodes.get_mut(id)
{
Arc::make_mut(n).children = keep;
}
}
// Orphans (parent missing or not listing them) are removed.
loop {
let orphan = self.nodes.iter().find_map(|(k, n)| {
if n.is_page() {
return None;
}
match n.parent {
Some(p) if self.nodes.get(&p).is_some_and(|pn| pn.children.contains(k)) => None,
_ => Some(*k),
}
});
let Some(o) = orphan else { break };
for d in self.subtree(o) {
self.nodes.remove(&d);
}
// Nodes not under a page (orphans, detached subtrees, cycles) are removed.
let reachable = self.reachable();
let gone: Vec<NodeId> = self.nodes.keys().filter(|k| !reachable.contains(*k)).copied().collect();
for k in gone {
self.nodes.remove(&k);
}
// Snapshots of layers that are gone or no longer changed since marked ready for dev, or that
// don't start with their layer, are dropped.

View File

@ -281,6 +281,30 @@ fn serde_roundtrip_and_repair() {
assert!(bad.validate().is_empty(), "{:?}", bad.validate());
}
#[test]
fn repair_drops_cycles_and_repeated_children() {
let mut d = Document::new("x");
let p = page(&d);
let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
let g = d.create(f, NodeKind::Frame, "G", 0.0, 0.0, 10.0, 10.0).unwrap();
let r = d.create(p, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
let mut v = serde_json::to_value(&d).unwrap();
// F and G each other's parent and child (G listing F twice), and R listed twice by the page.
v["nodes"][f.to_string()]["parent"] = serde_json::json!(g);
v["nodes"][g.to_string()]["children"] = serde_json::json!([f, f]);
v["nodes"][p.to_string()]["children"].as_array_mut().unwrap().push(serde_json::json!(r));
let mut bad: Document = serde_json::from_value(v).unwrap();
let issues = bad.validate();
for want in ["listed twice", "not under a page"] {
assert!(issues.iter().any(|i| i.contains(want)), "{want}: {issues:?}");
}
bad.repair();
assert!(bad.validate().is_empty(), "{:?}", bad.validate());
assert!(bad.node(f).is_none() && bad.node(g).is_none());
assert_eq!(bad.node(p).unwrap().children, vec![r]);
assert_eq!(bad.subtree(p), vec![p, r]);
}
#[test]
fn auto_layout_loads_files_without_grid_tracks() {
// Saved before grid tracks, cells and spans existed.

View File

@ -0,0 +1,63 @@
use serde_json::json;
use crate::plugins_tests::{OnRefusal, install, manifest, script};
use crate::{EngineError, Session};
fn id(value: &serde_json::Value) -> crate::NodeId {
crate::params::id_of(&value["id"]).unwrap()
}
#[test]
fn failed_group_closes_documents_opened_inside_it() {
let mut s = Session::new();
let original = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
let before_journal = s.journal.clone();
let before_history = s.ds().unwrap().history.undo.len();
let result = s.execute(
"command.batch",
&json!({
"label": "Open then fail",
"commands": [
{"command": "file.new", "params": {"name": "Temporary"}},
{"command": "shape.rectangle", "params": {"x": 20, "y": 20, "width": 10, "height": 10}},
{"command": "node.set", "params": {"id": 999999, "props": {"name": "must fail"}}}
]
}),
);
assert!(result.is_err());
assert_eq!(s.docs.len(), 1);
assert_eq!(s.active, 0);
assert!(s.doc().unwrap().contains(original));
assert_eq!(s.ds().unwrap().history.undo.len(), before_history);
assert_eq!(s.journal, before_journal);
}
#[test]
fn failed_plugin_run_restores_each_document_without_restoring_unrelated_state() {
let mut s = Session::new();
s.execute("file.new", &json!({"name": "Second"})).unwrap();
let before_history: Vec<_> = s.docs.iter().map(|ds| ds.history.undo.len()).collect();
let calls = [
("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})),
("doc.nextTab", json!({})),
("shape.rectangle", json!({"x": 20, "y": 0, "width": 10, "height": 10})),
("node.set", json!({"id": 999999, "props": {"name": "must fail"}})),
];
install(&mut s, manifest("org.test.rollback", "write", &[]), &script(&calls, OnRefusal::Fail, ""));
s.message = Some("keep this".into());
let before_journal = s.journal.clone();
let before_message = s.message.clone();
// The new file is the one in front when the plugin starts.
let before_active = s.active;
let result = s.execute("plugin.run", &json!({"id": "org.test.rollback"}));
assert!(matches!(result, Err(EngineError::Other(_))));
assert_eq!(s.docs.iter().map(|ds| ds.doc.node(ds.page).map_or(0, |page| page.children.len())).collect::<Vec<_>>(), vec![0, 0]);
assert_eq!(s.active, before_active, "the tab in front is put back");
assert_eq!(s.docs.iter().map(|ds| ds.history.undo.len()).collect::<Vec<_>>(), before_history);
assert_eq!(s.journal, before_journal);
assert_eq!(s.message, before_message);
}

View File

@ -104,6 +104,8 @@ pub static MENU: &[MenuItem] = &[
m!("View", "Soft-proof sRGB", "view.proofSrgb"),
m!("View", SEP, ""),
m!("View/Panels", "Show/Hide UI", "view.toggleUi"),
// FigCraft's own: the window fills the screen (F11), for setups that hide the taskbar then.
m!("View", "Full screen", "view.fullscreen"),
m!("View/Panels", "Reset panel widths", "view.panelWidths"),
m!("View/Panels", "Layers", "view.layers"),
m!("View/Panels", "Assets", "view.assets"),

View File

@ -662,6 +662,13 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
remember(s, &path);
let native = !rest && matches!(ext(&path).as_str(), "figcraft" | "json");
let warnings = rest || ext(&path) == "svg";
// A native file that lost something (a damaged image, say): the app says so, not only the result.
if native && !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]),
});
}
s.docs.push(DocState::new(loaded.doc, native.then_some(path)).with_versions(loaded.versions).with_branches(loaded.branches));
s.active = s.docs.len() - 1;
let mut out = json!({"index": s.active});

View File

@ -268,6 +268,10 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("view.snapObjects", "Snap to objects", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_objects)),
spec!("view.snapPixel", "Snap to pixel grid", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_pixel)),
spec!("view.toggleUi", "Show/Hide UI", "{on?}", false, |s, p| flag(s, p, |v| &mut v.ui_hidden)),
spec!("view.fullscreen", "Full screen", "{on?} the window fills the screen (the desktop app; a no-op headless)", false, |s, p| {
s.request_ui("window.fullscreen", json!({"on": boolean(p, "on")}));
ok()
}),
spec!("view.layers", "Layers panel", "{}", false, |s, _| ui(s, "panel.layers")),
spec!("view.assets", "Assets panel", "{}", false, |s, _| ui(s, "panel.assets")),
spec!("view.design", "Design panel", "{}", false, |s, _| ui(s, "panel.design")),

View File

@ -37,7 +37,7 @@ 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::SwiftUi | CodeLang::Compose))
CodeLang::from_id(s).filter(|l| matches!(l, CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose))
}
/// The layer `id`'s links are stored on: its component set, else its component (an instance's
@ -65,6 +65,7 @@ pub fn import_line(link: &CodeLink, lang: CodeLang) -> String {
match lang {
_ if path.is_empty() => String::new(),
CodeLang::React => format!("import {{ {} }} from {};", link.name.split('.').next().unwrap_or_default(), js_string(path)),
CodeLang::Leptos => format!("use {path}::{};", link.name.split('.').next().unwrap_or_default()),
_ => format!("import {path}"),
}
}
@ -172,6 +173,7 @@ fn expr(doc: &Document, c: &Component, p: &Param, v: &Value, lang: CodeLang) ->
let swapped = (p.kind == ParamKind::InstanceSwap).then(|| id_of(v)).flatten().and_then(|i| link(doc, i, lang));
match (swapped, lang) {
(Some(l), CodeLang::React) => format!("<{} />", l.name),
(Some(l), CodeLang::Leptos) => format!("|| view! {{ <{} /> }}", l.name),
(Some(l), CodeLang::SwiftUi) => format!("{}()", l.name),
(Some(l), _) => format!("{{ {}() }}", l.name),
(None, _) => value_expr(doc, c, p, v, lang),
@ -191,9 +193,10 @@ pub(crate) fn render(doc: &Document, c: &Component, values: &[(String, Value)],
Piece::Value { name, inner } => match find(c, values, name) {
Some((p, v)) => {
let e = expr(doc, c, p, v, lang);
// JSX: a plain string reads best as an attribute value, anything else is an
// expression in braces (JSX attribute strings have no escapes).
if lang != CodeLang::React || (out.ends_with('=') && e.starts_with('"') && !e.contains('\\')) {
// JSX and `view!`: a plain string reads best as an attribute value, anything
// else is an expression in braces (JSX attribute strings have no escapes).
let markup = matches!(lang, CodeLang::React | CodeLang::Leptos);
if !markup || (out.ends_with('=') && e.starts_with('"') && !e.contains('\\')) {
out.push_str(&e);
} else {
out.push_str(&format!("{{{e}}}"));
@ -215,6 +218,9 @@ fn default_template(c: &Component, lang: CodeLang, name: &str) -> String {
let params: Vec<&str> = c.params.iter().filter(|p| p.kind != ParamKind::Slot).map(|p| p.ident.as_str()).collect();
match lang {
CodeLang::React => format!("<{name}{} />", params.iter().map(|p| format!(" {p}={{{p}}}")).collect::<String>()),
CodeLang::Leptos => {
format!("<{name}{} />", params.iter().map(|p| format!(" {}={{{p}}}", crate::component_codegen::rust_ident(p))).collect::<String>())
}
CodeLang::SwiftUi => format!("{name}({})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::<Vec<_>>().join(", ")),
_ => format!("{name}({})", params.iter().map(|p| format!("{p} = {{{p}}}")).collect::<Vec<_>>().join(", ")),
}
@ -234,11 +240,13 @@ pub fn check(c: &Component, link: &CodeLink, lang: CodeLang) -> std::result::Res
let path = &link.import_path;
let path_ok = match lang {
CodeLang::React => !path.chars().any(|ch| ch.is_control() || ch == '"' || ch == '\\'),
CodeLang::Leptos => path.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == ':'),
_ => path.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '.'),
};
if path.chars().count() > MAX_IMPORT || !path_ok {
return Err(match lang {
CodeLang::React => 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"),
_ => format!("importPath: a module or package name (letters, digits, _ and .), up to {MAX_IMPORT} characters"),
});
}
@ -274,7 +282,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|swiftui|compose")),
Some(l) => language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|swiftui|compose")),
None => Ok(CodeLang::React),
}
}
@ -304,7 +312,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"codeConnect.set",
"Connect to code",
"{id? (component, set or instance; default the selection), language?: react|swiftui|compose (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|swiftui|compose (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";
@ -419,7 +427,7 @@ 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|swiftui|compose), name and template strings", i + 1));
warnings.push(format!("link {}: needs component, language (react|leptos|swiftui|compose), name and template strings", i + 1));
continue;
};
let link = CodeLink {

View File

@ -65,6 +65,15 @@ fn connected_instances_show_the_real_snippet() {
);
assert_eq!(code(&mut s, i, "swiftui"), "import AcmeUI\n\nCard(title: \"Say \\\"hi\\\"\", style: .plain)\n");
assert!(code(&mut s, i, "compose").contains("@Composable"), "other languages stay generated");
// Leptos: a `use` line and `view!` markup (strings with escapes in braces).
ex(
&mut s,
"codeConnect.set",
json!({"id": c, "language": "leptos", "importPath": "crate::ui", "template": "<Card title={title} dotted={Show dot} />"}),
);
assert_eq!(code(&mut s, i, "leptos"), "use crate::ui::Card;\n\n<Card title={\"Say \\\"hi\\\"\"} dotted={false} />\n");
assert!(s.execute("codeConnect.set", &json!({"id": c, "language": "leptos", "importPath": "crate/ui"})).is_err(), "a Rust path");
ex(&mut s, "codeConnect.remove", json!({"id": c, "language": "leptos"}));
// inspect.componentCode: the snippet as the usage, and the link.
let v = ex(&mut s, "inspect.componentCode", json!({"id": i, "language": "swiftui"}));
assert_eq!(v["usage"], "Card(title: \"Say \\\"hi\\\"\", style: .plain)\n");

View File

@ -18,13 +18,13 @@ pub fn specs() -> Vec<CommandSpec> {
vec![spec!(
"inspect.code",
"Code for layer",
"{id?, language?: css|html|react|swiftui|compose (default css; html and react 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), unit?: px|rem (CSS, HTML and React; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}",
"{id?, language?: css|html|react|leptos|swiftui|compose (default css; html, react and leptos 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), unit?: px|rem (CSS, HTML, React and Leptos; 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|swiftui|compose"))?,
Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|swiftui|compose"))?,
None => CodeLang::Css,
};
let rem = match string(p, "unit").map(|u| u.to_ascii_lowercase()).as_deref() {
@ -42,7 +42,7 @@ pub fn specs() -> Vec<CommandSpec> {
let connected = crate::code_connect::link(doc, id, lang).is_some();
let code = code_for(doc, id, lang);
let code = match rem {
Some(root) if matches!(lang, CodeLang::Css | CodeLang::Html | CodeLang::React) => px_to_rem(&code, root),
Some(root) if lang.is_web() => px_to_rem(&code, root),
_ => code,
};
Ok(json!({"code": code, "language": lang.id(), "connected": connected}))
@ -59,17 +59,20 @@ pub enum CodeLang {
Html,
/// A React function component (TSX) with its stylesheet.
React,
/// A Leptos component (`view!` markup) with its stylesheet.
Leptos,
SwiftUi,
Compose,
}
impl CodeLang {
pub const ALL: [CodeLang; 5] = [CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::SwiftUi, CodeLang::Compose];
pub const ALL: [CodeLang; 6] = [CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::Leptos, CodeLang::SwiftUi, CodeLang::Compose];
pub fn id(self) -> &'static str {
match self {
CodeLang::Css => "css",
CodeLang::Html => "html",
CodeLang::React => "react",
CodeLang::Leptos => "leptos",
CodeLang::SwiftUi => "swiftui",
CodeLang::Compose => "compose",
}
@ -79,10 +82,15 @@ impl CodeLang {
CodeLang::Css => "CSS",
CodeLang::Html => "HTML + CSS",
CodeLang::React => "React",
CodeLang::Leptos => "Leptos",
CodeLang::SwiftUi => "SwiftUI",
CodeLang::Compose => "Compose",
}
}
/// Languages whose lengths are CSS pixels (they can be shown in rem).
pub fn is_web(self) -> bool {
matches!(self, CodeLang::Css | CodeLang::Html | CodeLang::React | CodeLang::Leptos)
}
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);
@ -136,7 +144,7 @@ pub fn code_for(doc: &Document, id: NodeId, lang: CodeLang) -> String {
match lang {
CodeLang::Css => css(doc, id),
CodeLang::Html => html(doc, id),
CodeLang::React | CodeLang::SwiftUi | CodeLang::Compose => crate::component_codegen::code(doc, id, lang),
CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose => crate::component_codegen::code(doc, id, lang),
}
}
@ -561,16 +569,18 @@ fn jsx_text(s: &str) -> String {
/// flexboxes, other children are placed absolutely in their parent, text becomes paragraphs.
fn html(doc: &Document, id: NodeId) -> String {
let ctx = Ctx::new(doc, id);
let mut out = Markup::new(&ctx, false);
let mut out = Markup::new(&ctx, CodeLang::Html);
out.node(id, None, 0, "");
format!("{}\n<style>\n{}{}</style>\n", out.markup, out.tokens.root_rule(), out.styles)
}
/// Markup being built for a layer tree: HTML (`class`) or React JSX (`className`, component
/// parameters, instances as calls), with its stylesheet.
/// Markup being built for a layer tree: HTML (`class`), React JSX (`className`) or Leptos `view!`
/// markup (text as Rust string literals); the last two with component parameters and instances
/// as calls. With its stylesheet.
pub(crate) struct Markup<'c, 'a> {
ctx: &'c Ctx<'a>,
jsx: bool,
/// Html, React or Leptos.
lang: CodeLang,
used: Vec<String>,
pub markup: String,
pub styles: String,
@ -584,10 +594,10 @@ pub(crate) struct Markup<'c, 'a> {
}
impl<'c, 'a> Markup<'c, 'a> {
pub fn new(ctx: &'c Ctx<'a>, jsx: bool) -> Self {
pub fn new(ctx: &'c Ctx<'a>, lang: CodeLang) -> Self {
Markup {
ctx,
jsx,
lang,
used: Vec::new(),
markup: String::new(),
styles: String::new(),
@ -598,22 +608,34 @@ impl<'c, 'a> Markup<'c, 'a> {
}
}
/// JSX indents by two spaces, HTML by four.
/// JSX indents by two spaces, HTML and Leptos by four.
fn pad(&self, depth: usize) -> String {
if self.jsx { " ".repeat(depth) } else { indent(depth) }
if self.lang == CodeLang::React { " ".repeat(depth) } else { indent(depth) }
}
/// A layer, shown only while its boolean parameter is on (JSX).
/// Component code (React or Leptos): parameters and instances as calls.
fn code(&self) -> bool {
self.lang != CodeLang::Html
}
/// A parameter's name in the generated code.
fn ident(&self, p: &Param) -> String {
if self.lang == CodeLang::Leptos { crate::component_codegen::rust_ident(&p.ident) } else { p.ident.clone() }
}
/// A layer, shown only while its boolean parameter is on (component code).
pub fn node(&mut self, id: NodeId, parent: Option<&Node>, depth: usize, path: &str) {
let Some(n) = self.ctx.doc.node(id) else { return };
if self.jsx
if self.code()
&& id != self.ctx.root
&& let Some(p) = self.ctx.param(n, "visible")
{
let pad = self.pad(depth);
self.markup.push_str(&format!("{pad}{{{} && (\n", p.ident));
let (pad, p) = (self.pad(depth), self.ident(p));
let (open, close) =
if self.lang == CodeLang::Leptos { (format!("{{{p}.then(|| view! {{"), "})}") } else { (format!("{{{p} && ("), ")}") };
self.markup.push_str(&format!("{pad}{open}\n"));
self.element(n, parent, depth + 1, path);
self.markup.push_str(&format!("{pad})}}\n"));
self.markup.push_str(&format!("{pad}{close}\n"));
return;
}
self.element(n, parent, depth, path);
@ -639,7 +661,7 @@ impl<'c, 'a> Markup<'c, 'a> {
None => class_name(&n.name, &mut self.used),
};
let pad = self.pad(depth);
let attr = if self.jsx { "className" } else { "class" };
let attr = if self.lang == CodeLang::React { "className" } else { "class" };
// How it sits in its parent: in the flex flow, or placed at its position.
let mut decls = String::new();
let in_flow = parent.is_some_and(|p| p.layout.is_some()) && !n.child_layout.absolute;
@ -663,12 +685,13 @@ impl<'c, 'a> Markup<'c, 'a> {
decls.push_str(&format!("transform: rotate({}deg);\ntransform-origin: 0 0;\n", num(-rot)));
}
}
// Nested instances are calls (JSX), in a wrapper that places them when needed.
if self.jsx && !root && matches!(n.kind, NodeKind::Instance { .. }) {
// Nested instances are calls (component code), in a wrapper that places them when needed.
if self.code() && !root && matches!(n.kind, NodeKind::Instance { .. }) {
let at = depth + usize::from(!decls.is_empty());
let run = if self.lang == CodeLang::Leptos { ".run()" } else { "" };
let inner = match self.ctx.param(n, "mainComponent") {
Some(p) => Some(format!("{}{{{}}}\n", self.pad(at), p.ident)),
None => self.ctx.call(n, CodeLang::React, at),
Some(p) => Some(format!("{}{{{}{run}}}\n", self.pad(at), self.ident(p))),
None => self.ctx.call(n, self.lang, at),
};
if let Some(inner) = inner {
if decls.is_empty() {
@ -692,19 +715,26 @@ impl<'c, 'a> Markup<'c, 'a> {
let open = |tag: &str| format!("{pad}<{tag} {attr}=\"{class}\"{attrs}>");
match &n.kind {
NodeKind::Text(t) => {
let body = match self.ctx.param(n, "characters").filter(|_| self.jsx) {
Some(p) => format!("{{{}}}", p.ident),
None if self.jsx => jsx_text(&t.characters),
None => html_escape(&t.characters),
let body = match (self.ctx.param(n, "characters").filter(|_| self.code()), self.lang) {
(Some(p), CodeLang::Leptos) => format!("{{{}.clone()}}", self.ident(p)),
(Some(p), _) => format!("{{{}}}", p.ident),
(None, CodeLang::React) => jsx_text(&t.characters),
// `view!` takes text as string literals and void elements self-closed.
(None, CodeLang::Leptos) => {
t.characters.chars().take(10_000).collect::<String>().lines().map(|l| format!("{l:?}")).collect::<Vec<_>>().join("<br/>")
}
(None, _) => html_escape(&t.characters),
};
self.markup.push_str(&format!("{}{body}</p>\n", open("p")));
}
_ => {
let kids = self.ctx.kids(n);
let slot = self.ctx.param(n, "slot").filter(|_| self.jsx);
let slot = self.ctx.param(n, "slot").filter(|_| self.code());
let leptos = self.lang == CodeLang::Leptos;
let open = open("div");
if let Some(p) = slot.filter(|_| kids.is_empty()) {
self.markup.push_str(&format!("{open}{{{}}}</div>\n", p.ident));
let content = if leptos { format!("{}.as_ref().map(|c| c.run())", self.ident(p)) } else { p.ident.clone() };
self.markup.push_str(&format!("{open}{{{content}}}</div>\n"));
} else if kids.is_empty() || self.ctx.too_deep(depth) {
self.markup.push_str(&format!("{open}</div>\n"));
} else {
@ -712,6 +742,10 @@ impl<'c, 'a> Markup<'c, 'a> {
// A slot shows the instance's content, or else the main's.
let (p1, p2) = (self.pad(depth + 1), self.pad(depth + 2));
let inner = match slot {
Some(p) if leptos => {
self.markup.push_str(&format!("{p1}{{match &{} {{ Some(c) => c.run(), None => view! {{\n", self.ident(p)));
depth + 2
}
Some(p) => {
self.markup.push_str(&format!("{p1}{{{} ?? (\n{p2}<>\n", p.ident));
depth + 3
@ -726,8 +760,10 @@ impl<'c, 'a> Markup<'c, 'a> {
seen.push(&kn.name);
self.node(k, Some(n), inner, &format!("{path}/{}#{count}", kn.name));
}
if slot.is_some() {
self.markup.push_str(&format!("{p2}</>\n{p1})}}\n"));
match slot {
Some(_) if leptos => self.markup.push_str(&format!("{p1}}}.into_any() }}}}\n")),
Some(_) => self.markup.push_str(&format!("{p2}</>\n{p1})}}\n")),
None => {}
}
self.markup.push_str(&format!("{pad}</div>\n"));
}

View File

@ -1,9 +1,9 @@
//! Component code: a component (or component set) becomes a React, SwiftUI or Jetpack Compose
//! component whose properties are its parameters (a variant property → a union or enum, boolean →
//! Component code: a component (or component set) becomes a React, Leptos, SwiftUI or Jetpack
//! Compose 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 their differences become CSS rules on
//! `data-*` attributes. Returned by `inspect.componentCode` and used by `inspect.code`. A component
//! 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::*;
@ -67,14 +67,14 @@ pub fn specs() -> Vec<CommandSpec> {
vec![spec!(
"inspect.componentCode",
"Component code",
"{id? (component, set or instance; default the selection), language?: react|swiftui|compose (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|swiftui|compose (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").map(|l| CodeLang::from_id(&l)) {
Some(Some(l @ (CodeLang::React | CodeLang::SwiftUi | CodeLang::Compose))) => l,
Some(_) => return Err(bad(cmd, "language: react|swiftui|compose")),
Some(Some(l @ (CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose))) => l,
Some(_) => return Err(bad(cmd, "language: react|leptos|swiftui|compose")),
None => CodeLang::React,
};
let doc = s.doc()?;
@ -107,9 +107,9 @@ pub fn specs() -> Vec<CommandSpec> {
)]
}
/// Code for any layer in React, SwiftUI or Compose: an instance's call and its component's
/// definition, a component's (or set's) definition, or the layer tree (React wraps it in a
/// function component).
/// Code for any layer in React, Leptos, SwiftUI or Compose: 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).
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)) {
@ -120,7 +120,10 @@ pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String {
(NodeKind::Instance { .. }, Some(c)) => format!("{}\n{}", usage(doc, id, lang, 0).unwrap_or_default(), definition(doc, &c, lang)),
(NodeKind::Component { .. } | NodeKind::ComponentSet { .. }, Some(c)) => definition(doc, &c, lang),
_ => match lang {
CodeLang::React => react(doc, &Component { comp: id, set: None, type_name: component_type_name(&n.name), params: Vec::new() }),
CodeLang::React | CodeLang::Leptos => {
let c = Component { comp: id, set: None, type_name: component_type_name(&n.name), params: Vec::new() };
if lang == CodeLang::Leptos { leptos(doc, &c) } else { react(doc, &c) }
}
CodeLang::SwiftUi => {
let mut out = String::new();
swift(&Ctx::new(doc, id), id, 0, &mut out, &[]);
@ -358,6 +361,52 @@ fn ident(name: &str, used: &mut Vec<String>) -> String {
out
}
/// Rust's keywords, and `children` (Leptos passes a component's children by that name).
const RUST_RESERVED: &[&str] = &[
"abstract", "as", "async", "await", "become", "box", "break", "children", "const", "continue", "crate", "do", "dyn", "else", "enum", "extern",
"false", "final", "fn", "for", "gen", "if", "impl", "in", "let", "loop", "macro", "match", "mod", "move", "mut", "override", "priv", "pub",
"ref", "return", "self", "static", "struct", "super", "trait", "true", "try", "type", "typeof", "unsafe", "unsized", "use", "virtual", "where",
"while", "yield",
];
/// A parameter's Rust name: its camelCase name in snake_case (`showIcon` → `show_icon`), with a
/// trailing `_` when Rust reserves it (`type_`; camelCase names have no `_`, so it stays unique).
pub(crate) fn rust_ident(ident: &str) -> String {
let mut out = String::new();
for (i, ch) in ident.chars().enumerate() {
if ch.is_ascii_uppercase() {
if i > 0 {
out.push('_');
}
out.push(ch.to_ascii_lowercase());
} else {
out.push(ch);
}
}
if RUST_RESERVED.contains(&out.as_str()) {
out.push('_');
}
out
}
/// A Rust type name: `Self` is taken.
fn rust_type(t: &str) -> String {
if t == "Self" { "SelfComponent".into() } else { t.to_string() }
}
/// A Leptos variant enum's name: the component's name and the property's, never the props struct
/// `#[component]` makes (`ButtonProps`).
fn leptos_enum(c: &Component, p: &Param) -> String {
let t = rust_type(&c.type_name);
let name = format!("{t}{}", p.enum_name);
if name == format!("{t}Props") { format!("{name}Variant") } else { name }
}
/// Rust enum variants for variant values: [`cases`] in PascalCase, `Self` taken.
fn rust_cases(values: &[String]) -> Vec<String> {
cases(values, false).into_iter().map(|c| if c == "Self" { "SelfValue".into() } else { c }).collect()
}
/// Enum case names for variant values, unique: Swift's lowerCamel (`.small`, `` .`default` ``),
/// Kotlin's PascalCase (`Small`).
fn cases(values: &[String], swift: bool) -> Vec<String> {
@ -383,6 +432,11 @@ fn cases(values: &[String], swift: bool) -> Vec<String> {
.collect()
}
/// A Rust string literal.
pub(crate) fn rust_string(s: &str) -> String {
format!("{:?}", s.chars().take(10_000).collect::<String>())
}
/// A JavaScript string literal.
pub(crate) fn js_string(s: &str) -> String {
let s: String = s.chars().take(10_000).collect();
@ -405,6 +459,11 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value
match (&p.kind, lang) {
(ParamKind::Boolean, _) => value.as_bool().unwrap_or(false).to_string(),
(ParamKind::Text, CodeLang::React) => js_string(value.as_str().unwrap_or("")),
(ParamKind::Text, CodeLang::Leptos) => rust_string(value.as_str().unwrap_or("")),
(ParamKind::Variant(opts), CodeLang::Leptos) => {
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::Variant(opts), l) => {
let v = value.as_str().unwrap_or("");
@ -420,6 +479,8 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value
let t = id_of(value).and_then(|i| component_type(doc, i));
match (t, l) {
(Some(t), CodeLang::React) => format!("<{t} />"),
(Some(t), CodeLang::Leptos) => format!("|| view! {{ <{} /> }}", rust_type(&t)),
(None, CodeLang::Leptos) => "|| ()".into(),
(Some(t), CodeLang::SwiftUi) => format!("AnyView({t}())"),
(Some(t), _) => format!("{{ {t}() }}"),
(None, CodeLang::React) => "null".into(),
@ -486,10 +547,17 @@ pub fn usage(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option
}
let t = &c.type_name;
Some(match lang {
CodeLang::React => {
CodeLang::React | CodeLang::Leptos => {
let leptos = lang == CodeLang::Leptos;
// Plain strings read best as attribute values.
let attrs: String =
args.iter().map(|(name, expr, plain)| if *plain { format!(" {name}={expr}") } else { format!(" {name}={{{expr}}}") }).collect();
let attrs: String = args
.iter()
.map(|(name, expr, plain)| {
let name = if leptos { rust_ident(name) } else { name.to_string() };
if *plain { format!(" {name}={expr}") } else { format!(" {name}={{{expr}}}") }
})
.collect();
let t = if leptos { rust_type(t) } else { t.clone() };
format!("{pad}<{t}{attrs} />\n")
}
CodeLang::SwiftUi => format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::<Vec<_>>().join(", ")),
@ -504,13 +572,20 @@ fn slot_content(doc: &Document, slot: NodeId, lang: CodeLang, depth: usize) -> S
let kids = ctx.kids(n);
match lang {
CodeLang::React => {
let mut m = Markup::new(&ctx, true);
let mut m = Markup::new(&ctx, CodeLang::React);
for k in kids {
m.node(k, Some(n), depth + 2, "");
}
let (p1, p2) = (" ".repeat(depth), " ".repeat(depth + 1));
format!("(\n{p2}<>\n{}{p2}</>\n{p1})", m.markup)
}
CodeLang::Leptos => {
let mut m = Markup::new(&ctx, CodeLang::Leptos);
for k in kids {
m.node(k, Some(n), depth + 1, "");
}
format!("|| view! {{\n{}{}}}", m.markup, indent(depth))
}
CodeLang::SwiftUi => {
let mut out = String::new();
for k in kids {
@ -535,6 +610,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::Leptos => leptos(doc, c),
_ => react(doc, c),
}
}
@ -545,23 +621,29 @@ fn data_attr(p: &Param) -> String {
format!("data-{}", class_name(&p.prop, &mut used))
}
/// A TSX function component with its props interface, and its stylesheet (variables as custom
/// properties, other variants as rules on their `data-*` attributes).
fn react(doc: &Document, c: &Component) -> String {
/// `c`'s markup in React or Leptos (two levels in) and its stylesheet (variables as custom
/// properties, other variants as rules on their `data-*` attributes): (root class, markup, CSS).
fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String) {
let ctx = Ctx { doc, root: c.comp, comp: Some(c), base: 2 };
let root_name = c.set.and_then(|s| doc.node(s)).or_else(|| doc.node(c.comp)).map(|n| n.name.clone()).unwrap_or_default();
let root_class = class_name(&root_name, &mut Vec::new());
let variants: Vec<&Param> = c.params.iter().filter(|p| matches!(p.kind, ParamKind::Variant(_))).collect();
let pass = |root: NodeId| {
let vctx = Ctx { doc, root, comp: Some(c), base: 2 };
let mut m = Markup::new(&vctx, true);
let mut m = Markup::new(&vctx, lang);
m.root_class = Some(root_class.clone());
m.node(root, None, 2, "");
m.rules
};
let mut m = Markup::new(&ctx, true);
let mut m = Markup::new(&ctx, lang);
m.root_class = Some(root_class.clone());
m.root_attrs = variants.iter().map(|p| format!(" {}={{{}}}", data_attr(p), p.ident)).collect();
m.root_attrs = variants
.iter()
.map(|p| match lang {
CodeLang::Leptos => format!(" {}={{{}.as_str()}}", data_attr(p), rust_ident(&p.ident)),
_ => format!(" {}={{{}}}", data_attr(p), p.ident),
})
.collect();
m.node(c.comp, None, 2, "");
// Other variants: what differs from this one, layer by layer.
let mut variant_rules = String::new();
@ -584,6 +666,13 @@ fn react(doc: &Document, c: &Component) -> String {
variant_rules.push_str(&format!("{sel} {{\n{}}}\n", diff.iter().map(|l| format!(" {l}\n")).collect::<String>()));
}
}
let css = format!("{}{}{variant_rules}", m.tokens.root_rule(), m.styles);
(root_class, m.markup, css)
}
/// A TSX function component with its props interface, and its stylesheet.
fn react(doc: &Document, c: &Component) -> String {
let (root_class, markup, css) = web(doc, c, CodeLang::React);
let t = &c.type_name;
let mut out = format!("import \"./{root_class}.css\";\n\n");
if c.params.is_empty() {
@ -610,7 +699,46 @@ fn react(doc: &Document, c: &Component) -> String {
.collect();
out.push_str(&format!("export function {t}({{\n{}}}: {t}Props) {{\n", args.iter().map(|a| format!(" {a},\n")).collect::<String>()));
}
out.push_str(&format!(" return (\n{} );\n}}\n\n/* {root_class}.css */\n{}{}{variant_rules}", m.markup, m.tokens.root_rule(), m.styles));
out.push_str(&format!(" return (\n{markup} );\n}}\n\n/* {root_class}.css */\n{css}"));
out
}
/// A Leptos `#[component]` with its variant enums and props (defaults as `#[prop]` attributes),
/// and its stylesheet.
fn leptos(doc: &Document, c: &Component) -> String {
let (root_class, markup, css) = web(doc, c, CodeLang::Leptos);
let mut out = String::from("use leptos::prelude::*;\n\n");
for p in &c.params {
let ParamKind::Variant(opts) = &p.kind else { continue };
let (e, cases) = (leptos_enum(c, p), rust_cases(opts));
let default = opts.iter().position(|o| Some(o.as_str()) == p.default.as_str()).unwrap_or(0);
out.push_str(&format!("#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]\npub enum {e} {{\n"));
for (i, case) in cases.iter().enumerate() {
out.push_str(&format!("{} {case},\n", if i == default { " #[default]\n" } else { "" }));
}
out.push_str(&format!("}}\n\nimpl {e} {{\n /// The variant's name, as its `data-*` attribute.\n pub fn as_str(self) -> &'static str {{\n match self {{\n"));
for (o, case) in opts.iter().zip(&cases) {
out.push_str(&format!(" Self::{case} => {},\n", rust_string(o)));
}
out.push_str(" }\n }\n}\n\n");
}
let t = rust_type(&c.type_name);
out.push_str(&format!("#[component]\npub fn {t}("));
if !c.params.is_empty() {
out.push('\n');
for p in &c.params {
let default = value_expr(doc, c, p, &p.default, CodeLang::Leptos);
let (attr, ty) = match &p.kind {
ParamKind::Variant(_) => ("optional".to_string(), leptos_enum(c, p)),
ParamKind::Boolean => (format!("default = {default}"), "bool".into()),
ParamKind::Text => (format!("into, default = {default}.to_string()"), "String".into()),
ParamKind::InstanceSwap => (format!("into, default = ({default}).into()"), "ViewFn".into()),
ParamKind::Slot => ("optional, into".to_string(), "Option<ViewFn>".into()),
};
out.push_str(&format!(" #[prop({attr})] {}: {ty},\n", rust_ident(&p.ident)));
}
}
out.push_str(&format!(") -> impl IntoView {{\n view! {{\n{markup} }}\n}}\n\n/* {root_class}.css */\n{css}"));
out
}

View File

@ -1,5 +1,5 @@
//! Component code: components become React / SwiftUI / Compose components whose properties are
//! parameters, and instances become calls passing the values they set.
//! Component code: components become React / Leptos / SwiftUI / Compose components whose
//! properties are parameters, and instances become calls passing the values they set.
use crate::Session;
use crate::codegen::{CodeLang, code_for};
@ -71,6 +71,18 @@ fn components_take_their_properties_as_parameters() {
for want in ["@Composable\nfun Card(", " showDot: Boolean = true,", " title: String = \"Hello\",", "if (showDot) {", "text = title,"] {
assert!(kt.contains(want), "Compose has {want:?}:\n{kt}");
}
let leptos = code(&mut s, c, "leptos");
for want in [
"use leptos::prelude::*;\n\n#[component]\npub fn Card(\n",
" #[prop(default = true)] show_dot: bool,\n",
" #[prop(into, default = \"Hello\".to_string())] title: String,\n",
") -> impl IntoView {\n view! {\n",
"{show_dot.then(|| view! {",
">{title.clone()}</p>",
"/* card.css */",
] {
assert!(leptos.contains(want), "Leptos has {want:?}:\n{leptos}");
}
// The command describes the properties.
let v = component_code(&mut s, c, "react");
assert_eq!(v["name"], "Card");
@ -105,11 +117,13 @@ 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, "leptos")["usage"], "<Card show_dot={false} title=\"Buy\" />\n");
// Text that needs escaping is an expression in JSX and escaped per language.
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Say \"hi\" $5\nnow"}}));
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, "leptos")["usage"], "<Card show_dot={false} title={\"Say \\\"hi\\\" $5\\nnow\"} />\n");
}
#[test]
@ -125,6 +139,9 @@ 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 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}");
// HTML and CSS stay plain markup.
assert!(code(&mut s, screen, "html").contains("Inbox"));
}
@ -156,12 +173,23 @@ fn variant_sets_share_one_definition() {
let kt = code(&mut s, set, "compose");
assert!(kt.contains("enum class ButtonState { Default, Hover }"), "{kt}");
assert!(kt.contains(" state: ButtonState = ButtonState.Default,"), "{kt}");
let leptos = code(&mut s, set, "leptos");
for want in [
"#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]\npub enum ButtonState {\n #[default]\n Default,\n Hover,\n}",
" Self::Hover => \"Hover\",",
" #[prop(optional)] state: ButtonState,",
"data-state={state.as_str()}",
".button[data-state=\"Hover\"] .bg {\n background: #0000FF;",
] {
assert!(leptos.contains(want), "Leptos has {want:?}:\n{leptos}");
}
// An instance showing the hover variant passes it.
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, "react")["usage"], "<Button state=\"Hover\" />\n");
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Button(state: .hover)\n");
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Button(state = ButtonState.Hover)\n");
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "<Button state=ButtonState::Hover />\n");
}
#[test]
@ -184,11 +212,15 @@ 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 leptos = code(&mut s, chip, "leptos");
assert!(leptos.contains(" #[prop(into, default = (|| view! { <HeartIcon /> }).into())] icon: ViewFn,"), "{leptos}");
assert!(leptos.contains("{icon.run()}"), "{leptos}");
let i = idv(&ex(&mut s, "instance.create", json!({"component": chip, "x": 0, "y": 100})));
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Icon": star}}));
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, "leptos")["usage"], "<Chip icon={|| view! { <StarIcon /> }} />\n");
}
#[test]
@ -203,6 +235,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 leptos = code(&mut s, panel, "leptos");
assert!(leptos.contains(" #[prop(optional, into)] content: Option<ViewFn>,"), "{leptos}");
assert!(leptos.contains("{match &content { Some(c) => c.run(), None => view! {\n") && leptos.contains("}.into_any() }}\n"), "{leptos}");
// An instance filling the slot passes its content.
let g = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 400, "width": 40, "height": 20, "name": "Badge"})));
let badge = idv(&ex(&mut s, "component.create", json!({"ids": [g]})));
@ -211,6 +246,8 @@ fn slots_take_optional_content() {
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}");
}
#[test]
@ -225,6 +262,8 @@ fn identifiers_are_escaped_and_unique() {
("label", "text", json!("b")),
("2 columns", "boolean", json!(false)),
("Color", "variant", json!("Red")),
("type", "text", json!("c")),
("Props", "variant", json!("Self")),
] {
ex(&mut s, "component.addProperty", json!({"id": c, "name": name, "type": ty, "default": default}));
}
@ -242,6 +281,20 @@ fn identifiers_are_escaped_and_unique() {
assert_eq!(color.as_deref(), Some("ColorVariant"), "never shadows SwiftUI's Color");
let swift = code_for(doc, c, CodeLang::SwiftUi);
assert!(swift.contains("struct TextComponent: View {") && swift.contains("enum ColorVariant: String, CaseIterable {"), "{swift}");
// Rust: snake_case, keywords suffixed, never the props struct's name or `Self`.
let leptos = code_for(doc, c, CodeLang::Leptos);
for want in [
"pub fn TextComponent(",
"] default_value: bool,",
"] show_icon: bool,",
"] p2_columns: bool,",
"] type_: String,",
"pub enum TextComponentPropsVariant {",
" SelfValue,",
"Self::SelfValue => \"Self\",",
] {
assert!(leptos.contains(want), "Leptos has {want:?}:\n{leptos}");
}
// 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");

View File

@ -57,3 +57,23 @@ fn class_names_are_valid_and_deep_trees_are_bounded() {
// Nesting stops at the depth limit instead of running away.
assert!(code.matches("<div").count() <= 14, "{}", code.matches("<div").count());
}
#[test]
fn leptos_wraps_the_markup_in_a_component_with_text_as_string_literals() {
let mut s = Session::new();
let card = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 120})).unwrap());
s.execute("node.set", &json!({"id": card, "props": {"name": "Card"}})).unwrap();
let t = id(s.execute("text.create", &json!({"text": "Say \"hi\" {x}\nسلام", "x": 0, "y": 60})).unwrap());
s.execute("node.reparent", &json!({"ids": [t], "parent": card})).unwrap();
let code = code_for(s.doc().unwrap(), card, CodeLang::Leptos);
assert!(
code.starts_with("use leptos::prelude::*;\n\n#[component]\npub fn Card() -> impl IntoView {\n view! {\n <div class=\"card\">"),
"{code}"
);
// Quotes and braces stay inside Rust string literals; lines break with a self-closed tag.
assert!(code.contains(r#">"Say \"hi\" {x}"<br/>"سلام"</p>"#), "{code}");
assert!(code.contains("/* card.css */\n") && code.contains(".card {\n position: relative;\n"), "{code}");
let out = s.execute("inspect.code", &json!({"id": card, "language": "leptos", "unit": "rem"})).unwrap();
assert!(out["code"].as_str().unwrap().contains("width: 12.5rem;"), "{}", out["code"]);
}

View File

@ -162,6 +162,7 @@ pub static KEYS: &[Binding] = &[
k(Essential, "app.actions", &["Cmd+K", "Cmd+/"]).label("Actions…"),
k(Essential, "help.shortcuts", &["Ctrl+Shift+?"]).both(),
k(Essential, "view.toggleUi", &["Cmd+\\"]),
k(Essential, "view.fullscreen", &["Ctrl+Cmd+F"]).win(&["F11"]),
k(Essential, "edit.pickColor", &["I", "Ctrl+C"]).win(&["I"]),
k(Essential, "app.preferences", &["Cmd+,"]),
k(Essential, "file.new", &["Cmd+N"]),

View File

@ -1066,24 +1066,57 @@ impl Session {
}
/// Run `f` as one undo step called `label`: the commands it executes push no steps of their
/// own, and when it fails the document, selection, page and history are put back.
/// own, and when it fails the documents stay at their group-start state.
pub fn grouped<T>(&mut self, label: &str, f: impl FnOnce(&mut Self) -> Result<T>) -> Result<T> {
let ds = self.ds_mut()?;
let (before, sel, page, saved_len, uid) = (ds.doc.clone(), ds.selection.clone(), ds.page, ds.history.undo.len(), ds.uid);
self.ds()?;
let before: Vec<_> =
self.docs.iter().map(|ds| (ds.uid, ds.doc.clone(), ds.selection.clone(), ds.page, ds.history.undo.len(), ds.history.limit)).collect();
let active = self.active;
let journal_len = self.journal.len();
// The steps inside are dropped again below, so they mustn't push the oldest ones out.
let limit = std::mem::replace(&mut ds.history.limit, usize::MAX);
self.live_hold(uid, true);
for ds in &mut self.docs {
ds.history.limit = usize::MAX;
}
for (uid, _, _, _, _, _) in &before {
self.live_hold(*uid, true);
}
let result = f(self);
self.live_hold(uid, false);
let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) else { return result };
ds.history.limit = limit;
ds.history.undo.truncate(saved_len);
for (uid, _, _, _, _, _) in &before {
self.live_hold(*uid, false);
}
if result.is_err() {
ds.doc = before;
ds.selection = sel;
ds.page = page;
} else if !Arc::ptr_eq(&before, &ds.doc) {
self.record(uid, Some(label), before, sel, page);
self.docs.retain(|ds| before.iter().any(|(uid, _, _, _, _, _)| *uid == ds.uid));
for (uid, doc, selection, page, undo_len, limit) in &before {
if let Some(ds) = self.docs.iter_mut().find(|ds| ds.uid == *uid) {
ds.doc = doc.clone();
ds.selection = selection.clone();
ds.page = *page;
ds.history.undo.truncate(*undo_len);
ds.history.limit = *limit;
}
}
self.active = active;
self.journal.truncate(journal_len);
return result;
}
let changed: Vec<_> = before
.iter()
.filter_map(|(uid, doc, selection, page, _, _)| {
let ds = self.docs.iter().find(|d| d.uid == *uid)?;
(!Arc::ptr_eq(doc, &ds.doc) && ds.interaction.is_none()).then(|| (*uid, doc.clone(), selection.clone(), *page))
})
.collect();
for ds in &mut self.docs {
if let Some((_, _, _, _, undo_len, limit)) = before.iter().find(|before| before.0 == ds.uid) {
ds.history.limit = *limit;
ds.history.undo.truncate(*undo_len);
}
}
for (uid, doc, selection, page) in changed {
self.record(uid, Some(label), doc, selection, page);
}
result
}
@ -1347,6 +1380,8 @@ mod arc_handles_tests;
#[cfg(test)]
mod autosave_tests;
#[cfg(test)]
mod batch_rollback_tests;
#[cfg(test)]
mod binding_lists_tests;
#[cfg(test)]
mod booleans_tests;
@ -1459,6 +1494,8 @@ mod nested_override_tests;
#[cfg(test)]
mod online_tests;
#[cfg(test)]
mod open_issues_tests;
#[cfg(test)]
mod outline_options_tests;
#[cfg(test)]
mod paint_reorder_tests;
@ -1477,7 +1514,7 @@ mod plugin_directory_tests;
#[cfg(test)]
mod plugin_panels_tests;
#[cfg(test)]
mod plugins_tests;
pub(crate) mod plugins_tests;
#[cfg(test)]
mod progressive_blur_tests;
#[cfg(test)]

View File

@ -0,0 +1,51 @@
//! Opening a native file that lost something (a damaged image): the session says so, with the
//! reason, and the file still opens.
use figcraft_doc::Document;
use serde_json::json;
use crate::Session;
/// A 1×1 PNG.
const PNG: [u8; 67] = [
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x60, 0x00, 0x00, 0x02,
0x00, 0x00, 0x01, 0xe2, 0x21, 0xbc, 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
];
fn file(name: &str, damage: bool) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("figcraft-open-issues-{}-{name}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut d = Document::new("Pics");
figcraft_format::add_image(&mut d, PNG.to_vec()).unwrap();
let mut bytes = figcraft_format::save_bytes(&d, None).unwrap();
if damage {
// Images are stored as they are: change one byte of the PNG, not its checksum.
let at = bytes.windows(PNG.len()).position(|w| w == PNG).unwrap();
bytes[at + 30] ^= 0xff;
}
let path = dir.join("pics.figcraft");
std::fs::write(&path, bytes).unwrap();
path
}
#[test]
fn a_file_with_a_damaged_image_opens_and_says_what_was_lost() {
let path = file("damaged", true);
let mut s = Session::new();
let r = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap();
assert!(r["repairs"].as_array().is_some_and(|a| a.len() == 1), "{r}");
let m = s.message.take().expect("the session says so");
assert!(m.starts_with("pics: image ") && m.contains("couldn't be read") && m.contains("drops it"), "{m}");
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}
#[test]
fn an_intact_file_opens_without_a_message() {
let path = file("intact", false);
let mut s = Session::new();
let r = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap();
assert!(r["repairs"].as_array().is_some_and(Vec::is_empty), "{r}");
assert!(s.message.is_none());
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}

View File

@ -12,7 +12,7 @@ use figcraft_doc::NodeId;
/// What a test plugin does when a call is refused: fail the run (status 1), or carry on and report
/// the refusal as its output.
#[derive(Clone, Copy, PartialEq)]
enum OnRefusal {
pub(crate) enum OnRefusal {
Fail,
Report,
}
@ -26,7 +26,7 @@ pub(crate) fn lit(s: &str) -> String {
/// A module that reads the selection, makes `calls` in order and outputs the selection it read
/// (or the first refusal). `tail` runs after the calls (an endless loop, a trap…).
fn script(calls: &[(&str, Value)], on_refusal: OnRefusal, tail: &str) -> Vec<u8> {
pub(crate) fn script(calls: &[(&str, Value)], on_refusal: OnRefusal, tail: &str) -> Vec<u8> {
let mut data = String::new();
let mut body = String::from("(local.set $sel (call $reply_read (i32.const 60000) (call $selection)))\n");
let mut at = 0usize;
@ -114,7 +114,7 @@ fn a_run_reads_the_selection_and_its_edits_are_one_undo_step() {
let (layers, undo) = (layer_count(&s), undo_len(&s));
let out = ex(&mut s, "plugin.run", json!({"id": "org.test.tidy"}));
assert_eq!(out["output"], json!([a.0]), "the plugin read the selection");
assert_eq!(out["calls"], json!(4));
assert_eq!(out["calls"], json!(6));
assert_eq!(node(&s, a).name, "Tidy");
assert_eq!(layer_count(&s), layers + 1);
assert_eq!(undo_len(&s), undo + 1, "one undo step for the whole run");
@ -197,9 +197,11 @@ fn traps_endless_loops_and_memory_hogs_roll_back() {
let a = rect(&mut s, 0.0);
ex(&mut s, "select.set", json!({"ids": [a]}));
let edit = [("shape.rectangle", json!({"x": 90, "y": 0, "width": 5, "height": 5})), ("object.rename", json!({"name": "Half done"}))];
let bulk_output = "(call $set_output (i32.const 0) (i32.const 65536))".repeat(32);
let cases = [
("org.test.trap", "unreachable", "crashed"),
("org.test.loop", "(loop $l (br $l))", "instruction budget"),
("org.test.bulk", bulk_output.as_str(), "instruction budget"),
("org.test.grow", "(if (i32.eq (memory.grow (i32.const 2000)) (i32.const -1)) (then unreachable))", "crashed"),
];
for (id, tail, want) in cases {

View File

@ -91,6 +91,7 @@ static ROWS: &[Row] = &[
doc(Essential, "Save to version history", &["Alt+Cmd+S"], &["Ctrl+Alt+S"], Bound("file.saveVersion"), ""),
doc(Essential, "Run the last plugin", &["Alt+Cmd+P"], &["Ctrl+Alt+P"], Moved("plugins.runLast", &["Alt+Cmd+P"], &["Ctrl+Alt+Shift+P"]),
"conflict: the documentation also gives Ctrl+Alt+P to pixel preview on Windows; pixel preview keeps it, the plugin takes Ctrl+Alt+Shift+P"),
pin(Essential, "view.fullscreen", &["Ctrl+Cmd+F"], &["F11"]),
pin(Essential, "app.preferences", &["Cmd+,"], &[]),
pin(Essential, "file.new", &["Cmd+N"], &[]).reserved(BROWSER_TAB),
pin(Essential, "file.open", &["Cmd+O"], &[]),
@ -397,7 +398,7 @@ fn counts_match_the_docs() {
let reserved = documented.iter().filter(|r| !r.reserved.is_empty()).count();
let pinned = ROWS.len() - documented.len();
// docs/ui-parity.md quotes these.
assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (129, 128, 1, 0, 2, 48));
assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (129, 128, 1, 0, 2, 49));
}
#[test]

View File

@ -76,6 +76,36 @@ fn node_set_and_errors_roll_back() {
assert!(s.execute("no.such", &json!({})).is_err());
}
#[test]
fn failed_batch_restores_all_documents_and_history() {
let mut s = sess();
let original = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
s.execute("file.new", &json!({"name": "Second"})).unwrap();
s.execute("doc.nextTab", &json!({})).unwrap();
let before_journal = s.journal.clone();
let before_history: Vec<_> = s.docs.iter().map(|d| d.history.undo.len()).collect();
let result = s.execute(
"command.batch",
&json!({
"label": "Broken batch",
"commands": [
{"command": "doc.nextTab"},
{"command": "shape.rectangle", "params": {"x": 20, "y": 20, "width": 10, "height": 10}},
{"command": "node.set", "params": {"id": 999999, "props": {"name": "must fail"}}}
]
}),
);
assert!(result.is_err());
assert_eq!(s.active, 0, "the active document is part of the transaction");
assert_eq!(s.docs.len(), 2, "a failed batch cannot leave a newly opened tab behind");
assert_eq!(s.doc().unwrap().node(original).unwrap().name, "Rectangle");
assert_eq!(s.docs[1].doc.node(s.docs[1].page).map_or(0, |page| page.children.len()), 0);
assert_eq!(s.docs.iter().map(|d| d.history.undo.len()).collect::<Vec<_>>(), before_history);
assert_eq!(s.journal, before_journal, "failed inner commands are not journaled");
}
#[test]
fn group_ungroup_and_frame() {
let mut s = sess();

View File

@ -0,0 +1,88 @@
//! Images a native file can't give back: damaged or missing members are reported, not dropped
//! without a word.
use std::io::Write;
use figcraft_doc::{Document, NodeKind};
use super::*;
fn png() -> Vec<u8> {
let img = image::RgbaImage::from_pixel(2, 2, image::Rgba([9, 0, 0, 255]));
let mut out = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
out
}
fn doc_with_image() -> (Document, String) {
let mut d = Document::new("File");
let p = d.first_page().unwrap();
d.create(p, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
let (hash, ..) = add_image(&mut d, png()).unwrap();
(d, hash)
}
/// The archive of `d` rebuilt with `edit` applied to each member (None drops it).
fn rebuild(bytes: &[u8], edit: impl Fn(&str, Vec<u8>) -> Option<Vec<u8>>) -> Vec<u8> {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let names: Vec<String> = z.file_names().map(str::to_string).collect();
let mut buf = std::io::Cursor::new(Vec::new());
let mut w = zip::ZipWriter::new(&mut buf);
for n in names {
let data = read_member(&mut z, &n, u64::MAX).unwrap();
if let Some(data) = edit(&n, data) {
w.start_file(n, zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored)).unwrap();
w.write_all(&data).unwrap();
}
}
w.finish().unwrap();
buf.into_inner()
}
#[test]
fn an_intact_file_loads_without_image_issues() {
let (d, hash) = doc_with_image();
let (loaded, issues) = load_bytes(&save_bytes(&d, None).unwrap()).unwrap();
assert!(issues.is_empty(), "{issues:?}");
assert!(loaded.images.contains_key(&hash));
}
#[test]
fn a_missing_image_member_is_reported() {
let (d, hash) = doc_with_image();
let bytes = rebuild(&save_bytes(&d, None).unwrap(), |n, data| (!n.starts_with("images/")).then_some(data));
let (loaded, issues) = load_bytes(&bytes).unwrap();
assert!(!loaded.images.contains_key(&hash));
assert!(issues.iter().any(|i| i.contains(&hash) && i.contains("missing") && i.contains("drops it")), "{issues:?}");
}
#[test]
fn a_damaged_image_member_is_reported_with_its_reason() {
let (d, hash) = doc_with_image();
let bytes = save_bytes(&d, None).unwrap();
// The member's bytes change but not its checksum: reading it fails.
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
let name = format!("images/{hash}");
let stored = read_member(&mut z, &name, u64::MAX).unwrap();
let at = bytes.windows(stored.len()).position(|w| w == stored).unwrap();
let mut damaged = bytes.clone();
damaged[at + stored.len() / 2] ^= 0xff;
let (loaded, issues) = load_bytes(&damaged).unwrap();
assert!(!loaded.images.contains_key(&hash));
assert!(issues.iter().any(|i| i.contains(&hash) && i.contains("couldn't be read") && i.contains("drops it")), "{issues:?}");
}
#[test]
fn many_lost_images_are_counted_not_listed_one_by_one() {
let mut d = Document::new("File");
for k in 0..40u8 {
let img = image::RgbaImage::from_pixel(2, 2, image::Rgba([k, 1, 0, 255]));
let mut out = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
add_image(&mut d, out).unwrap();
}
let bytes = rebuild(&save_bytes(&d, None).unwrap(), |n, data| (!n.starts_with("images/")).then_some(data));
let (_, issues) = load_bytes(&bytes).unwrap();
assert_eq!(issues.len(), MAX_IMAGE_ISSUES + 1, "{issues:?}");
assert_eq!(issues.last().map(String::as_str), Some("20 more images couldn't be loaded"));
}

View File

@ -465,11 +465,17 @@ pub fn load_bytes_with_versions(bytes: &[u8]) -> Result<Loaded, FormatError> {
let (mut doc, mut issues, own_images) = parse_json(&text)?;
let names: Vec<String> = z.file_names().filter(|n| n.starts_with("images/")).map(str::to_string).collect();
let mut images = BTreeMap::new();
let mut unread = Vec::new();
for name in names {
let Ok(data) = read_member(&mut z, &name, MAX_MEMBER) else { continue };
let hash = name.trim_start_matches("images/").to_string();
images.insert(hash, image_data(data));
match read_member(&mut z, &name, MAX_MEMBER) {
Ok(data) => {
images.insert(hash, image_data(data));
}
Err(e) => unread.push((hash, e)),
}
}
report_lost_images(&mut issues, &unread, own_images.as_ref(), &images);
match library::read(&mut z, &images, u64::MAX) {
Ok(lib) => doc.published = lib.map(Arc::new),
Err(e) => issues.push(format!("the published library was skipped: {e}")),
@ -490,6 +496,42 @@ pub fn load_bytes_with_versions(bytes: &[u8]) -> Result<Loaded, FormatError> {
Ok(Loaded { doc, issues, versions, branches })
}
/// Most lost images reported one by one (the rest as a count).
const MAX_IMAGE_ISSUES: usize = 20;
/// Report the images a file lost on loading: members that couldn't be read (damaged, too large)
/// and images its document lists but the archive doesn't hold. They are left out, so saving the
/// file again would drop them for good.
fn report_lost_images(
issues: &mut Vec<String>,
unread: &[(String, FormatError)],
listed: Option<&BTreeSet<String>>,
images: &BTreeMap<String, ImageData>,
) {
let mut lost: Vec<String> = unread
.iter()
.map(|(hash, e)| {
let why = match e {
FormatError::Corrupt(m) | FormatError::Io(m) => m.clone(),
FormatError::Newer(v) => format!("format version {v}"),
};
format!("image {hash} couldn't be read ({why}): it is left out, and saving this file again drops it")
})
.collect();
let unread_hashes: BTreeSet<&str> = unread.iter().map(|(h, _)| h.as_str()).collect();
for hash in listed.into_iter().flatten() {
if !images.contains_key(hash) && !unread_hashes.contains(hash.as_str()) {
lost.push(format!("image {hash} is used by the file but its data is missing: it is left out, and saving this file again drops it"));
}
}
let more = lost.len().saturating_sub(MAX_IMAGE_ISSUES);
lost.truncate(MAX_IMAGE_ISSUES);
issues.extend(lost);
if more > 0 {
issues.push(format!("{more} more images couldn't be loaded"));
}
}
/// The version history kept under `dir` in an archive (none when it has no index), reading at
/// most `budget` bytes of snapshots. Damaged entries are skipped and reported in `issues`; nothing
/// here fails the load.
@ -630,6 +672,8 @@ pub fn load_file(path: &std::path::Path) -> Result<(Document, Vec<String>), Form
#[cfg(test)]
mod branch_tests;
#[cfg(test)]
mod image_tests;
#[cfg(test)]
mod large_tests;
#[cfg(test)]
mod version_tests;

View File

@ -63,7 +63,8 @@ const IMPORTS: &[(&str, usize, usize)] = &[
/// What a run may use.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Limits {
/// Instruction budget (wasmi fuel). Each host call costs [`Limits::call_fuel`] more.
/// Instruction budget (wasmi fuel). Each host call costs [`Limits::call_fuel`] more,
/// plus one fuel per byte copied out of or into guest memory.
pub fuel: u64,
pub call_fuel: u64,
/// Linear memory cap in bytes.
@ -281,10 +282,10 @@ fn compile(engine: &Engine, bytes: &[u8], limits: &Limits) -> Result<Module, Plu
}
}
let export = |name: &str| module.exports().find(|e| e.name() == name).map(|e| e.ty().clone());
if !export("memory").is_some_and(|t| t.memory().is_some()) {
if export("memory").is_none_or(|t| t.memory().is_none()) {
return Err(PluginError::InvalidModule("it doesn't export its `memory`".into()));
}
if !export(ENTRY).is_some_and(|t| t.func() == Some(&func_type(1, 1))) {
if export(ENTRY).is_none_or(|t| t.func() != Some(&func_type(1, 1))) {
return Err(PluginError::InvalidModule(format!("it doesn't export `{ENTRY}(input_len: i32) -> i32`")));
}
Ok(module)
@ -399,11 +400,7 @@ fn ask(caller: &mut Ctx<'_>, request: Request) -> wasmi::Error {
/// Charge a host call: fuel and the call count.
fn charge(caller: &mut Ctx<'_>) -> Result<(), wasmi::Error> {
let cost = caller.data().limits.call_fuel;
let left = caller.get_fuel()?;
if left < cost {
return Err(abort(caller, PluginError::OutOfFuel));
}
caller.set_fuel(left - cost)?;
spend_fuel(caller, cost)?;
let st = caller.data_mut();
st.out.calls = st.out.calls.saturating_add(1);
if st.out.calls > st.limits.max_calls {
@ -413,12 +410,21 @@ fn charge(caller: &mut Ctx<'_>) -> Result<(), wasmi::Error> {
Ok(())
}
/// Reserve work before copying or allocating, including when call overhead is disabled.
fn spend_fuel(caller: &mut Ctx<'_>, cost: u64) -> Result<(), wasmi::Error> {
let Some(left) = caller.get_fuel()?.checked_sub(cost) else {
return Err(abort(caller, PluginError::OutOfFuel));
};
caller.set_fuel(left)
}
/// The bytes at `(ptr, len)` in the plugin's memory.
fn read_bytes(caller: &mut Ctx<'_>, ptr: i32, len: i32) -> Result<Vec<u8>, wasmi::Error> {
let (ptr, len) = (ptr as u32 as usize, len as u32 as usize);
if len > caller.data().limits.max_message {
return Err(abort(caller, PluginError::Limit(format!("a {len}-byte string is too long"))));
}
spend_fuel(caller, len as u64)?;
let Some(mem) = caller.get_export("memory").and_then(Extern::into_memory) else {
return Err(abort(caller, PluginError::InvalidModule("no memory".into())));
};
@ -472,14 +478,20 @@ fn linker(engine: &Engine) -> Result<Linker<State>, PluginError> {
Err(ask(&mut c, Request::Selection))
})
.map_err(err)?;
l.func_wrap(IMPORT_MODULE, "reply_len", |c: Ctx<'_>| -> i32 { i32::try_from(c.data().reply.len()).unwrap_or(i32::MAX) }).map_err(err)?;
l.func_wrap(IMPORT_MODULE, "reply_len", |mut c: Ctx<'_>| -> Result<i32, wasmi::Error> {
charge(&mut c)?;
Ok(i32::try_from(c.data().reply.len()).unwrap_or(i32::MAX))
})
.map_err(err)?;
l.func_wrap(IMPORT_MODULE, "reply_read", |mut c: Ctx<'_>, ptr: i32, len: i32| -> Result<i32, wasmi::Error> {
charge(&mut c)?;
let (ptr, want) = (ptr as u32 as usize, len as u32 as usize);
let n = want.min(c.data().reply.len());
spend_fuel(&mut c, n as u64)?;
let Some(mem) = c.get_export("memory").and_then(Extern::into_memory) else {
return Err(abort(&mut c, PluginError::InvalidModule("no memory".into())));
};
let (data, st) = mem.data_and_store_mut(&mut c);
let n = want.min(st.reply.len());
let src = st.reply.get(..n).ok_or(TrapCode::MemoryOutOfBounds)?;
let dst = ptr.checked_add(n).and_then(|end| data.get_mut(ptr..end)).ok_or(TrapCode::MemoryOutOfBounds)?;
dst.copy_from_slice(src);
@ -500,6 +512,7 @@ fn linker(engine: &Engine) -> Result<Linker<State>, PluginError> {
})
.map_err(err)?;
l.func_wrap(IMPORT_MODULE, "set_output", |mut c: Ctx<'_>, ptr: i32, len: i32| -> Result<(), wasmi::Error> {
charge(&mut c)?;
let out = read_str(&mut c, ptr, len)?;
c.data_mut().out.output = out;
Ok(())

View File

@ -81,7 +81,7 @@ fn echoes_input_calls_commands_and_reads_replies() {
assert_eq!(host.logs, out.logs);
assert_eq!(host.calls, vec![("shape.rectangle".into(), r#"{"x":1}"#.into())]);
assert_eq!(host.notes, vec!["made it".to_string()]);
assert_eq!(out.calls, 3);
assert_eq!(out.calls, 7);
assert!(out.fuel > 0);
}
@ -142,7 +142,9 @@ fn logs_over_the_default_byte_budget_fail_before_forwarding() {
(local.set $i (i32.add (local.get $i) (i32.const 1)))
(br_if $logs (i32.lt_u (local.get $i) (i32.const 129)))) (i32.const 0)",
);
let (result, host) = go(&m, "{}");
// The default fuel: copying a megabyte of log bytes out of the plugin is itself charged.
let mut host = Mock::default();
let result = run(&m, "{}", &Limits::default(), &mut host);
assert!(matches!(result, Err(PluginError::Limit(_))), "logs over 1 MiB must fail the run");
assert_eq!(host.logs.len(), 128);
assert_eq!(host.logs.iter().map(String::len).sum::<usize>(), 1024 * 1024);
@ -254,6 +256,41 @@ fn logs_still_observe_the_general_message_limit() {
assert!(host.logs.is_empty());
}
#[test]
fn reply_and_output_imports_obey_host_call_limit() {
for call in
["(drop (call $reply_len))", "(drop (call $reply_read (i32.const 0) (i32.const 2)))", "(call $set_output (i32.const 0) (i32.const 2))"]
{
let m = module(r#"(data (i32.const 0) "ok")"#, &format!("{call} {call} (i32.const 0)"));
let limits = Limits { max_calls: 1, ..small() };
assert!(matches!(run(&m, "{}", &limits, &mut Mock::default()), Err(PluginError::Limit(_))), "{call}");
let limits = Limits { max_calls: 2, ..small() };
assert_eq!(run(&m, "{}", &limits, &mut Mock::default()).unwrap().calls, 2, "{call}");
}
}
#[test]
fn repeated_bulk_imports_spend_fuel_for_bytes_even_without_call_overhead() {
for call in ["(drop (call $reply_read (i32.const 0) (i32.const 4096)))", "(call $set_output (i32.const 0) (i32.const 4096))"] {
let m = module("", &format!("{} (i32.const 0)", call.repeat(16)));
let limits = Limits { fuel: 40_000, call_fuel: 0, ..small() };
assert_eq!(run(&m, &"x".repeat(4096), &limits, &mut Mock::default()), Err(PluginError::OutOfFuel), "{call}");
}
}
#[test]
fn reply_copy_charges_actual_bytes_and_each_callback_gets_fresh_limits() {
let m = module("", "(call $set_output (i32.const 0) (call $reply_read (i32.const 0) (i32.const 2147483647))) (i32.const 0)");
let limits = Limits { max_calls: 2, fuel: 1000, call_fuel: 0, ..small() };
let mut instance = crate::Instance::new(&m, &limits).unwrap();
assert_eq!(instance.call(&"x".repeat(2048), &mut Mock::default()), Err(PluginError::OutOfFuel));
for input in ["hello", "again"] {
let out = instance.call(input, &mut Mock::default()).unwrap();
assert_eq!(out.output, input);
assert_eq!(out.calls, 2);
}
}
#[test]
fn memory_is_capped() {
// Declared larger than the cap: refused at instantiation.

View File

@ -133,3 +133,23 @@ fn one_sided_widths_paint_one_side() {
let b = d.world_render_bounds(l).unwrap();
assert!(b.y1 >= 120.0 - 1e-6, "{b:?}");
}
#[test]
fn deep_boolean_nesting_stops_at_the_outline_depth_limit() {
// Booleans directly inside booleans used to restart the depth count at every level.
let mut d = Document::new("t");
let mut parent = d.first_page().unwrap();
let top = d.create(parent, NodeKind::BooleanOp { op: BoolOp::Union }, "B", 0.0, 0.0, 10.0, 10.0).unwrap();
parent = top;
for _ in 0..20_000 {
parent = d.create(parent, NodeKind::BooleanOp { op: BoolOp::Union }, "B", 0.0, 0.0, 10.0, 10.0).unwrap();
}
d.create(parent, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
// Too deep: no stack overflow, and the unreachable square contributes nothing.
assert!(outline(&d, top).area().abs() < 1e-9);
// Within the limit, nested booleans still combine.
let (mut d, _, b, _) = row_of_squares(BoolOp::Union, 1);
let inner = d.create(b, NodeKind::BooleanOp { op: BoolOp::Union }, "B", 0.0, 0.0, 0.0, 0.0).unwrap();
d.create(inner, NodeKind::Rectangle, "R", 40.0, 10.0, 20.0, 20.0).unwrap();
assert!((outline(&d, b).area().abs() - 2.0 * 20.0 * 20.0).abs() < 0.5);
}

View File

@ -203,6 +203,11 @@ pub(crate) fn stroke_rule(n: &Node, closed: bool) -> FillRule {
/// The geometry a node fills, in local space: shapes, booleans (computed from children) and text
/// (glyph outlines). Groups and pages have none.
pub fn node_outline(doc: &Document, n: &Node) -> Option<(BezPath, FillRule)> {
node_outline_at(doc, n, 0)
}
/// [`node_outline`] `depth` levels into a boolean or group nesting (one budget for both).
fn node_outline_at(doc: &Document, n: &Node, depth: usize) -> Option<(BezPath, FillRule)> {
match &n.kind {
NodeKind::BooleanOp { op } => {
let operands: Vec<(BezPath, figcraft_doc::BoolOp)> = n
@ -211,7 +216,7 @@ pub fn node_outline(doc: &Document, n: &Node) -> Option<(BezPath, FillRule)> {
.filter_map(|c| doc.node(*c))
.filter(|c| c.visible)
.filter_map(|c| {
let (mut p, _) = subtree_outline(doc, c, 0)?;
let (mut p, _) = subtree_outline(doc, c, depth + 1)?;
p.apply_affine(c.transform);
Some((p, c.bool_op.unwrap_or(*op)))
})
@ -258,7 +263,7 @@ pub fn subtree_outline(doc: &Document, n: &Node, depth: usize) -> Option<(BezPat
}
Some((out, FillRule::Winding))
}
_ => node_outline(doc, n),
_ => node_outline_at(doc, n, depth),
}
}

View File

@ -17,4 +17,5 @@ figcraft-text = { workspace = true }
figcraft-pathops = { workspace = true }
usvg = { workspace = true }
base64 = { workspace = true }
flate2 = { workspace = true }
thiserror = { workspace = true }

View File

@ -0,0 +1,108 @@
//! Resource budgets for what an SVG expands into while it is parsed: compressed (SVGZ) input,
//! and SVG documents embedded in `<image>` data URIs (themselves possibly compressed or nested).
//! usvg decompresses with no cap and parses embedded SVGs with no shared depth or byte limit, so
//! both are done here: a bounded gunzip before usvg sees the bytes, and our own data resolver.
use std::io::Read;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use usvg::{ImageHrefResolver, ImageKind, Options};
/// Largest document, once decompressed.
const MAX_DOCUMENT_BYTES: usize = 64 << 20;
/// Largest total of all embedded SVG documents, once decompressed.
const MAX_EMBEDDED_BYTES: usize = 32 << 20;
/// Most SVG documents embedded inside one another.
const MAX_EMBEDDED_DEPTH: usize = 4;
#[derive(Clone, Copy)]
pub(super) struct Limits {
pub document: usize,
pub embedded: usize,
pub depth: usize,
}
impl Default for Limits {
fn default() -> Self {
Limits { document: MAX_DOCUMENT_BYTES, embedded: MAX_EMBEDDED_BYTES, depth: MAX_EMBEDDED_DEPTH }
}
}
fn gzipped(data: &[u8]) -> bool {
data.starts_with(&[0x1f, 0x8b])
}
/// `data` as plain text bytes: gunzipped when compressed, never past `limit` bytes.
fn inflate(data: &[u8], limit: usize) -> Result<Vec<u8>, String> {
if !gzipped(data) {
return if data.len() > limit { Err(format!("SVG larger than {} MiB", limit >> 20)) } else { Ok(data.to_vec()) };
}
let mut out = Vec::new();
let cap = u64::try_from(limit).unwrap_or(u64::MAX).saturating_add(1);
flate2::read::GzDecoder::new(data).take(cap).read_to_end(&mut out).map_err(|_| "malformed compressed SVG".to_string())?;
if out.len() > limit {
return Err(format!("compressed SVG expands past {} MiB", limit >> 20));
}
Ok(out)
}
/// The document `data` (SVG or SVGZ) as plain bytes, within the document budget.
pub(super) fn document(data: &[u8], limits: &Limits) -> Result<Vec<u8>, String> {
// The caller already holds a plain document; only what compression expands to is bounded.
if gzipped(data) { inflate(data, limits.document) } else { Ok(data.to_vec()) }
}
/// What the embedded SVGs of one import have used so far.
struct Used {
bytes: AtomicUsize,
depth: AtomicUsize,
}
fn raster(data: &[u8]) -> bool {
data.starts_with(b"\x89PNG")
|| data.starts_with(&[0xff, 0xd8, 0xff])
|| data.starts_with(b"GIF8")
|| (data.len() > 12 && data.get(8..12) == Some(b"WEBP"))
}
/// An embedded SVG document, if the budgets allow it.
fn embedded(used: &Used, limits: &Limits, data: &[u8], opts: &Options) -> Option<ImageKind> {
if used.depth.load(Ordering::Relaxed) >= limits.depth {
return None;
}
let left = limits.embedded.saturating_sub(used.bytes.load(Ordering::Relaxed));
let plain = inflate(data, left).ok()?;
used.bytes.fetch_add(plain.len(), Ordering::Relaxed);
if super::nesting(&plain, super::MAX_NESTING) > super::MAX_NESTING {
return None;
}
used.depth.fetch_add(1, Ordering::Relaxed);
let tree = usvg::Tree::from_data_nested(&plain, opts);
used.depth.fetch_sub(1, Ordering::Relaxed);
tree.ok().map(ImageKind::SVG)
}
/// Resolve `<image>` data URIs: rasters as they are, SVGs within `limits`; nothing else.
pub(super) fn resolver(limits: Limits) -> ImageHrefResolver<'static> {
let used = Arc::new(Used { bytes: AtomicUsize::new(0), depth: AtomicUsize::new(0) });
ImageHrefResolver {
resolve_data: Box::new(move |mime, data, opts| match mime {
"image/jpg" | "image/jpeg" => Some(ImageKind::JPEG(data)),
"image/png" => Some(ImageKind::PNG(data)),
"image/gif" => Some(ImageKind::GIF(data)),
"image/webp" => Some(ImageKind::WEBP(data)),
"image/svg+xml" => embedded(&used, &limits, &data, opts),
"text/plain" if raster(&data) => match data.first() {
Some(0x89) => Some(ImageKind::PNG(data)),
Some(0xff) => Some(ImageKind::JPEG(data)),
Some(b'G') => Some(ImageKind::GIF(data)),
_ => Some(ImageKind::WEBP(data)),
},
"text/plain" => embedded(&used, &limits, &data, opts),
_ => None,
}),
// Linked files are never read.
resolve_string: Box::new(|_, _| None),
}
}

View File

@ -0,0 +1,113 @@
//! Budgets on what SVGZ and embedded SVG documents expand into, with reduced limits so the
//! fixtures stay small.
use std::io::Write;
use base64::Engine as _;
use figcraft_doc::Document;
use super::budget::Limits;
use super::{import_svg, import_within};
fn gzip(data: &[u8]) -> Vec<u8> {
let mut e = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
e.write_all(data).unwrap();
e.finish().unwrap()
}
fn svg(body: &str) -> String {
format!(r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="100">{body}</svg>"##)
}
fn uri(svg: &[u8]) -> String {
format!("data:image/svg+xml;base64,{}", base64::engine::general_purpose::STANDARD.encode(svg))
}
/// `levels` SVGs, each embedding the next, the innermost holding one rectangle.
fn nested(levels: usize) -> String {
let mut s = svg(r##"<rect width="10" height="10" fill="red"/>"##);
for _ in 1..levels {
s = svg(&format!(r##"<image width="100" height="100" xlink:href="{}"/>"##, uri(s.as_bytes())));
}
s
}
/// The layers an import makes (None when it is refused).
fn layers(data: &[u8], limits: Limits) -> Result<usize, String> {
let mut d = Document::new("budget");
let p = d.first_page().unwrap();
let f = import_within(&mut d, data, p, 0.0, 0.0, "x", limits).map_err(|e| e.0)?.frame;
assert!(d.validate().is_empty(), "{:?}", d.validate());
Ok(d.subtree(f).len())
}
#[test]
fn an_svgz_is_unpacked_and_imported() {
let z = gzip(svg(r##"<rect width="10" height="10"/>"##).as_bytes());
let mut d = Document::new("z");
let p = d.first_page().unwrap();
assert!(import_svg(&mut d, &z, p, 0.0, 0.0, "z").is_ok());
}
#[test]
fn an_svgz_that_expands_past_the_budget_is_refused_early() {
let limits = Limits { document: 4096, ..Limits::default() };
// Tiny compressed, far past 4 KiB expanded.
let z = gzip(svg(&" ".repeat(1 << 20)).as_bytes());
assert!(z.len() < 4096, "the bomb is small: {}", z.len());
let e = layers(&z, limits).unwrap_err();
assert!(e.contains("expands past"), "{e}");
// The same document is fine within a bigger budget.
assert!(layers(&z, Limits { document: 2 << 20, ..Limits::default() }).is_ok());
}
#[test]
fn a_compressed_document_is_checked_for_nesting_once_unpacked() {
let deep = format!("{}{}", "<g>".repeat(600), "</g>".repeat(600));
let z = gzip(svg(&deep).as_bytes());
assert!(layers(&z, Limits::default()).unwrap_err().contains("nested more than"));
}
#[test]
fn embedded_svgs_nest_up_to_the_depth_budget_and_no_further() {
let data = nested(4).into_bytes();
let full = layers(&data, Limits::default()).unwrap();
let shallow = layers(&data, Limits { depth: 1, ..Limits::default() }).unwrap();
assert!(shallow < full, "depth 1 drops the inner documents: {shallow} < {full}");
// Far deeper than the budget: still imports, bounded.
let deep = nested(40).into_bytes();
assert!(layers(&deep, Limits::default()).is_ok());
}
#[test]
fn embedded_svgs_share_one_byte_budget() {
let inner = svg(r##"<rect width="10" height="10"/>"##);
let one = format!(r##"<image width="10" height="10" xlink:href="{}"/>"##, uri(inner.as_bytes()));
let data = svg(&one.repeat(8)).into_bytes();
let all = layers(&data, Limits::default()).unwrap();
let limits = Limits { embedded: inner.len() * 3, ..Limits::default() };
let some = layers(&data, limits).unwrap();
assert!(some < all, "the budget runs out part way: {some} < {all}");
}
#[test]
fn an_embedded_compressed_bomb_is_dropped_not_expanded() {
let bomb = gzip(svg(&" ".repeat(1 << 20)).as_bytes());
let data = svg(&format!(
r##"<image width="10" height="10" xlink:href="data:image/svg+xml;base64,{}"/>"##,
base64::engine::general_purpose::STANDARD.encode(&bomb)
))
.into_bytes();
let limits = Limits { embedded: 4096, ..Limits::default() };
let with_bomb = layers(&data, limits).unwrap();
let empty = layers(svg("").as_bytes(), limits).unwrap();
assert_eq!(with_bomb, empty, "the oversized embedded document left nothing behind");
}
#[test]
fn linked_files_and_other_mime_types_stay_unresolved() {
let data = svg(
r##"<image width="10" height="10" xlink:href="/etc/passwd"/><image width="10" height="10" xlink:href="data:application/x-foo;base64,AAAA"/>"##,
);
assert_eq!(layers(data.as_bytes(), Limits::default()).unwrap(), layers(svg("").as_bytes(), Limits::default()).unwrap());
}

View File

@ -16,11 +16,14 @@
//! What can't be represented (filters other than shadows and blurs, rotated repeating patterns,
//! nested clip paths) is left out and reported in the import notes. Linked files are never read: only data URIs.
mod budget;
mod fx;
mod paint;
mod shape;
mod text;
#[cfg(test)]
mod budget_tests;
#[cfg(test)]
mod fx_tests;
#[cfg(test)]
@ -592,10 +595,10 @@ pub(crate) fn nesting(data: &[u8], limit: usize) -> usize {
max
}
fn options(data: &[u8]) -> usvg::Options<'static> {
let mut o = usvg::Options::default();
// Only data URIs: an opened or pasted SVG never pulls other files off the disk.
o.image_href_resolver.resolve_string = Box::new(|_, _| None);
fn options(data: &[u8], limits: budget::Limits) -> usvg::Options<'static> {
// Only data URIs, within budgets: an opened or pasted SVG never pulls other files off the disk
// and never expands without limit.
let mut o = usvg::Options { image_href_resolver: budget::resolver(limits), ..Default::default() };
if data.windows(4).any(|w| w == b"text") {
text::prepare(&mut o);
}
@ -634,10 +637,24 @@ pub fn import_svg(doc: &mut Document, data: &[u8], parent: NodeId, x: f64, y: f6
/// [`import_svg`], with notes on what couldn't be represented.
pub fn import_svg_with_notes(doc: &mut Document, data: &[u8], parent: NodeId, x: f64, y: f64, name: &str) -> Result<Imported, ImportError> {
if nesting(data, MAX_NESTING) > MAX_NESTING {
import_within(doc, data, parent, x, y, name, budget::Limits::default())
}
fn import_within(
doc: &mut Document,
data: &[u8],
parent: NodeId,
x: f64,
y: f64,
name: &str,
limits: budget::Limits,
) -> Result<Imported, ImportError> {
// SVGZ is unpacked here, within a budget, so every check below sees the real document.
let data = budget::document(data, &limits).map_err(ImportError)?;
if nesting(&data, MAX_NESTING) > MAX_NESTING {
return Err(ImportError(format!("elements nested more than {MAX_NESTING} deep")));
}
let tree = usvg::Tree::from_data(data, &options(data)).map_err(|e| ImportError(e.to_string()))?;
let tree = usvg::Tree::from_data(&data, &options(&data, limits)).map_err(|e| ImportError(e.to_string()))?;
let size = tree.size();
let frame =
doc.create(parent, NodeKind::Frame, name, x, y, f64::from(size.width()), f64::from(size.height())).map_err(|e| ImportError(e.to_string()))?;

View File

@ -25,7 +25,7 @@ fn main() {
}
}
if !ok {
println!("cargo::warning=building without craft-fonts");
println!("cargo::warning=building without craft-fonts (once it is there, `cargo clean -p figcraft-text` makes the next build use it)");
}
}
src.push_str("];\n");
@ -35,9 +35,23 @@ fn main() {
}
}
/// Rebuild when `manifest` changes or, while it is missing, when it is added to its folder.
///
/// Cargo takes a watched path that doesn't exist as changed on every build, and then rebuilds this
/// crate and every crate that depends on it each time. So a missing manifest is never watched
/// itself: its folder is, when there is one (a craft-fonts checkout without the Latin manifest).
/// With no craft-fonts at all there is nothing to watch, so the build can't notice it arriving:
/// the warning in `main` says how to pick it up.
fn watch(manifest: &std::path::Path) {
let watched = if manifest.exists() { Some(manifest) } else { manifest.parent().filter(|folder| folder.is_dir()) };
if let Some(path) = watched {
println!("cargo::rerun-if-changed={}", path.display());
}
}
fn craft_fonts(dir: &std::path::Path, manifest_rel: &str) -> Result<String, String> {
let manifest = dir.join(manifest_rel);
println!("cargo::rerun-if-changed={}", manifest.display());
watch(&manifest);
let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?;
let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32");
let mut out = String::new();

View File

@ -211,7 +211,7 @@ status = "draft — needs native review"
"Changes on main" = "Änderungen in der Hauptdatei"
"Changes on the branch" = "Änderungen im Branch"
"Changes unknown" = "Änderungen unbekannt"
"Choose React, SwiftUI or Compose above to connect this component to its code." = "Wählen Sie oben React, SwiftUI oder Compose, um diese Komponente mit ihrem Code zu verbinden."
"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Wählen Sie oben React, Leptos, SwiftUI oder Compose, um diese Komponente mit ihrem Code zu verbinden."
"Choose another profile to change it." = "Wählen Sie ein anderes Profil, um es zu ändern."
"Choose collection" = "Sammlung wählen"
"Choose image" = "Bild wählen"
@ -2034,6 +2034,7 @@ status = "draft — needs native review"
"view.prevPage" = "Vorherige Seite"
"view.previousRegion" = "Vorherigen Bereich fokussieren"
"view.proofSrgb" = "Softproof sRGB"
"view.fullscreen" = "Vollbild"
"view.prototype" = "Bereich Prototyp"
"view.rulers" = "Lineale"
"view.set" = "Ansicht festlegen"

View File

@ -216,7 +216,7 @@ name = "English"
"Changes on main" = "Changes on main"
"Changes on the branch" = "Changes on the branch"
"Changes unknown" = "Changes unknown"
"Choose React, SwiftUI or Compose above to connect this component to its code." = "Choose React, SwiftUI or Compose above to connect this component to its code."
"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Choose React, Leptos, SwiftUI or Compose above to connect this component to its code."
"Choose another profile to change it." = "Choose another profile to change it."
"Choose collection" = "Choose collection"
"Choose image" = "Choose image"

View File

@ -217,7 +217,7 @@ status = "draft — needs native review"
"Changes on main" = "Cambios en la principal"
"Changes on the branch" = "Cambios en la rama"
"Changes unknown" = "Cambios desconocidos"
"Choose React, SwiftUI or Compose above to connect this component to its code." = "Elige React, SwiftUI o Compose arriba para conectar este componente con su código."
"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Elige React, Leptos, SwiftUI o Compose arriba para conectar este componente con su código."
"Choose another profile to change it." = "Elige otro perfil para cambiarlo."
"Choose collection" = "Elegir colección"
"Choose image" = "Elegir imagen"
@ -2087,6 +2087,7 @@ status = "draft — needs native review"
"view.prevPage" = "Página anterior"
"view.previousRegion" = "Enfocar la región anterior"
"view.proofSrgb" = "Prueba en pantalla sRGB"
"view.fullscreen" = "Pantalla completa"
"view.prototype" = "Panel de prototipo"
"view.rulers" = "Reglas"
"view.set" = "Establecer vista"

View File

@ -211,7 +211,7 @@ status = "draft — needs native review"
"Changes on main" = "Modifications dans le fichier principal"
"Changes on the branch" = "Modifications dans la branche"
"Changes unknown" = "Modifications inconnues"
"Choose React, SwiftUI or Compose above to connect this component to its code." = "Choisissez React, SwiftUI ou Compose ci-dessus pour relier ce composant à son code."
"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Choisissez React, Leptos, SwiftUI ou Compose ci-dessus pour relier ce composant à son code."
"Choose another profile to change it." = "Choisissez un autre profil pour le changer."
"Choose collection" = "Choisir une collection"
"Choose image" = "Choisir une image"
@ -2034,6 +2034,7 @@ status = "draft — needs native review"
"view.prevPage" = "Page précédente"
"view.previousRegion" = "Activer la région précédente"
"view.proofSrgb" = "Épreuve écran sRGB"
"view.fullscreen" = "Plein écran"
"view.prototype" = "Panneau Prototype"
"view.rulers" = "Règles"
"view.set" = "Définir l’affichage"

View File

@ -213,7 +213,7 @@ status = "draft — needs native review"
"Changes on main" = "メインの変更"
"Changes on the branch" = "ブランチの変更"
"Changes unknown" = "変更内容は不明"
"Choose React, SwiftUI or Compose above to connect this component to its code." = "上で React、SwiftUI、Compose のいずれかを選び、このコンポーネントをコードに接続。"
"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "上で React、Leptos、SwiftUI、Compose のいずれかを選び、このコンポーネントをコードに接続。"
"Choose another profile to change it." = "変更するには別のプロファイルを選択。"
"Choose collection" = "コレクションを選択"
"Choose image" = "画像を選択"
@ -2036,6 +2036,7 @@ status = "draft — needs native review"
"view.prevPage" = "前のページ"
"view.previousRegion" = "前の領域にフォーカス"
"view.proofSrgb" = "sRGB でソフトプルーフ"
"view.fullscreen" = "全画面表示"
"view.prototype" = "プロトタイプパネル"
"view.rulers" = "定規"
"view.set" = "ビューを設定"

View File

@ -212,7 +212,7 @@ status = "draft — needs native review"
"Changes on main" = "메인의 변경 사항"
"Changes on the branch" = "브랜치의 변경 사항"
"Changes unknown" = "변경 내용 알 수 없음"
"Choose React, SwiftUI or Compose above to connect this component to its code." = "위에서 React, SwiftUI 또는 Compose를 선택해 이 컴포넌트를 코드에 연결하세요."
"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "위에서 React, Leptos, SwiftUI 또는 Compose를 선택해 이 컴포넌트를 코드에 연결하세요."
"Choose another profile to change it." = "변경하려면 다른 프로필을 선택하세요."
"Choose collection" = "컬렉션 선택"
"Choose image" = "이미지 선택"
@ -2035,6 +2035,7 @@ status = "draft — needs native review"
"view.prevPage" = "이전 페이지"
"view.previousRegion" = "이전 영역에 포커스"
"view.proofSrgb" = "sRGB 교정 미리 보기"
"view.fullscreen" = "전체 화면"
"view.prototype" = "프로토타입 패널"
"view.rulers" = "눈금자"
"view.set" = "보기 설정"

View File

@ -210,7 +210,7 @@ status = "draft — needs native review"
"Changes on main" = "Alterações no principal"
"Changes on the branch" = "Alterações na branch"
"Changes unknown" = "Alterações desconhecidas"
"Choose React, SwiftUI or Compose above to connect this component to its code." = "Escolha React, SwiftUI ou Compose acima para conectar este componente ao código dele."
"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Escolha React, Leptos, SwiftUI ou Compose acima para conectar este componente ao código dele."
"Choose another profile to change it." = "Escolha outro perfil para alterá-lo."
"Choose collection" = "Escolher coleção"
"Choose image" = "Escolher imagem"
@ -2033,6 +2033,7 @@ status = "draft — needs native review"
"view.prevPage" = "Página anterior"
"view.previousRegion" = "Focar a região anterior"
"view.proofSrgb" = "Prova em tela sRGB"
"view.fullscreen" = "Tela cheia"
"view.prototype" = "Painel de protótipo"
"view.rulers" = "Réguas"
"view.set" = "Definir visualização"

View File

@ -213,7 +213,7 @@ status = "draft — needs native review"
"Changes on main" = "主文件上的更改"
"Changes on the branch" = "分支上的更改"
"Changes unknown" = "更改未知"
"Choose React, SwiftUI or Compose above to connect this component to its code." = "在上方选择 React、SwiftUI 或 Compose,将此组件关联到其代码。"
"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "在上方选择 React、Leptos、SwiftUI 或 Compose,将此组件关联到其代码。"
"Choose another profile to change it." = "选择其他配置文件以更改。"
"Choose collection" = "选择集合"
"Choose image" = "选择图片"
@ -2036,6 +2036,7 @@ status = "draft — needs native review"
"view.prevPage" = "上一页"
"view.previousRegion" = "聚焦上一个区域"
"view.proofSrgb" = "sRGB 软打样"
"view.fullscreen" = "全屏"
"view.prototype" = "原型面板"
"view.rulers" = "标尺"
"view.set" = "设置视图"

View File

@ -782,8 +782,8 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
ui.add_space(16.0);
widgets::label(ui, tr("On this page"), t.text_secondary);
});
ui.horizontal_wrapped(|ui| {
ui.add_space(16.0);
// Inset on both sides: `add_space` would only indent the first row of a wrap.
inset(ui, |ui| {
for c in &doc_colors {
if widgets::swatch(ui, &Paint::solid(*c), 14.0).on_hover_text(c.to_hex()).clicked() {
set_color(app, &target, &mut paint, *c, sel_stop_id, ui);
@ -798,8 +798,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
ui.add_space(16.0);
widgets::label(ui, tr("Recent"), t.text_secondary);
});
ui.horizontal_wrapped(|ui| {
ui.add_space(16.0);
inset(ui, |ui| {
for c in recent.iter().take(14) {
if widgets::swatch(ui, &Paint::solid(*c), 14.0).clicked() {
set_color(app, &target, &mut paint, *c, sel_stop_id, ui);
@ -816,6 +815,11 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
let _ = Pos2::ZERO;
}
/// Swatches wrapped in rows inset 16 points from both sides of the picker.
fn inset(ui: &mut Ui, add: impl FnOnce(&mut Ui)) {
egui::Frame::NONE.inner_margin(egui::Margin::symmetric(16, 0)).show(ui, |ui| ui.horizontal_wrapped(add));
}
/// The colour picker's note under the fields for a colour outside sRGB.
fn gamut_warning(ui: &mut Ui, t: &Tokens) {
use crate::a11y::Tip;

View File

@ -396,7 +396,7 @@ pub fn connect_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId
let text = if connectable {
tr("Link this component to its code component: instances then show your real snippet with their values.")
} else {
tr("Choose React, SwiftUI or Compose above to connect this component to its code.")
tr("Choose React, Leptos, SwiftUI or Compose above to connect this component to its code.")
};
widgets::note(ui, text, t.text_secondary);
return;

View File

@ -113,6 +113,8 @@ mod live_text_tests;
#[cfg(test)]
mod online_tests;
#[cfg(test)]
mod open_issues_tests;
#[cfg(test)]
mod panel_reorder_tests;
#[cfg(test)]
mod panel_width_tests;
@ -735,7 +737,7 @@ impl FigApp {
"dialog.unsaved" => self.toast(i18n::tr("This file has unsaved changes")),
"dialog.plugins" => self.ui.plugins_window = true,
"dialog.cursorChat" => live::open_chat(self),
"window.new" | "window.focus" => self.window_requests.push(r),
"window.new" | "window.focus" | "window.fullscreen" => self.window_requests.push(r),
_ => {}
}
}

View File

@ -0,0 +1,37 @@
//! Opening a native file with a damaged image: the app says what was lost.
use base64::Engine as _;
use figcraft_engine::Session;
use serde_json::json;
use crate::layers_drag_tests::Ui;
/// A 1×1 PNG.
const PNG: [u8; 67] = [
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x60, 0x00, 0x00, 0x02,
0x00, 0x00, 0x01, 0xe2, 0x21, 0xbc, 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
];
#[test]
fn opening_a_file_with_a_damaged_image_toasts_what_was_lost() {
let dir = std::env::temp_dir().join(format!("figcraft-ui-open-issues-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("pics.figcraft");
let mut s = Session::new();
let data = base64::engine::general_purpose::STANDARD.encode(PNG);
s.execute("file.import", &json!({"data": data, "name": "dot.png"})).unwrap();
s.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap();
// Images are stored as they are: change one byte of the PNG, not its checksum.
let mut bytes = std::fs::read(&path).unwrap();
let at = bytes.windows(PNG.len()).position(|w| w == PNG).unwrap();
bytes[at + 30] ^= 0xff;
std::fs::write(&path, bytes).unwrap();
let mut u = Ui::new(Session::new());
u.app.run("file.open", json!({"path": path.to_string_lossy()}));
u.frame(vec![]);
let toast = u.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default();
assert!(toast.starts_with("pics: image ") && toast.contains("couldn't be read"), "{toast}");
let _ = std::fs::remove_dir_all(&dir);
}

View File

@ -2238,7 +2238,7 @@ fn inspect(app: &mut FigApp, ui: &mut Ui) {
let copy = widgets::section_header(ui, tr("Code"), &[(Icon::Export, tr("Copy"))], false).is_some();
// Pixels or rem (of a 16 px root) for the web languages.
let rem_id = Id::new("inspect_rem");
let web = matches!(lang, CodeLang::Css | CodeLang::Html | CodeLang::React);
let web = lang.is_web();
let rem = web && ui.data(|d| d.get_temp::<bool>(rem_id)).unwrap_or(false);
row(ui, 32.0, |ui| {
let resp = widgets::dropdown(ui, lang.label(), 120.0, Some(Icon::Code));

View File

@ -189,3 +189,25 @@ fn enter_with_a_shape_tool_places_one_in_the_middle_of_the_view() {
press(&mut u, Key::Enter, Modifiers::NONE);
assert!(u.app.session.ds().unwrap().vector_edit.is_some());
}
#[test]
fn full_screen_key_and_command_toggle_the_window() {
let mut u = Ui::new(Session::new());
let fullscreen = |out: &egui::FullOutput| -> Vec<ViewportCommand> {
out.viewport_output.get(&ViewportId::ROOT).map(|v| v.commands.clone()).unwrap_or_default()
};
// F11 (Windows and Linux) or Ctrl+Cmd+F (macOS) asks the window to fill the screen.
let out = if crate::shortcuts::platform() == Platform::Mac {
press(&mut u, Key::F, Modifiers::MAC_CMD | Modifiers::COMMAND | Modifiers::CTRL)
} else {
press(&mut u, Key::F11, Modifiers::NONE)
};
assert!(fullscreen(&out).contains(&ViewportCommand::Fullscreen(true)), "full screen key, {:?}", fullscreen(&out));
// `on: false` leaves it, whatever the window reports.
u.app.run("view.fullscreen", json!({"on": false}));
let out = u.frame_output(vec![]);
assert!(fullscreen(&out).contains(&ViewportCommand::Fullscreen(false)), "{:?}", fullscreen(&out));
// Junk parameters are ignored, not fatal.
u.app.run("view.fullscreen", json!({"on": "yes"}));
u.frame(vec![]);
}

View File

@ -242,6 +242,12 @@ pub fn focus(app: &mut FigApp, ctx: &Context, id: u64) -> Result<Value, String>
pub(crate) fn requests(app: &mut FigApp, ctx: &Context) {
for r in std::mem::take(&mut app.window_requests) {
let n = |k: &str| r.data.get(k).and_then(Value::as_u64);
if r.kind == "window.fullscreen" {
let full = ctx.input(|i| i.viewport().fullscreen.unwrap_or(false));
let on = r.data.get("on").and_then(Value::as_bool).unwrap_or(!full);
ctx.send_viewport_cmd(ViewportCommand::Fullscreen(on));
continue;
}
let res = if r.kind == "window.new" {
open(app, ctx, n("index").map(|i| i as usize), r.data.get("path").and_then(Value::as_str), None).map(|_| ())
} else {

View File

@ -10,13 +10,13 @@ inside generated layer trees. Remaining: 0–1 h.
|---|---|---|
| Dev Mode toggle (Shift+D), Inspect panel | done | |
| Properties with their styles and variables; redlines on hover | done | positions from the frame around the layer (groups aren't frames), rotation, radius (CSS corner order), opacity, blend; colours in hex, RGB, CSS, HSL or HSB (`inspect.properties {colorFormat}`, the Inspect tab's Color format) |
| Code: CSS, iOS (SwiftUI), Android (Compose) | done | plus HTML and React (ours); components take properties as parameters, instances are calls; CSS in px or rem (`inspect.code {unit, rootFontSize}`), with rotation, blend modes, gradient directions and kinds (linear, radial, conic), text alignment, case and decoration |
| Code: CSS, iOS (SwiftUI), Android (Compose) | done | plus HTML, React and Leptos (ours; Leptos: `#[component]` functions with `#[prop]` defaults, variant enums and `view!` markup); components take properties as parameters, instances are calls; CSS in px or rem (`inspect.code {unit, rootFontSize}`), with rotation, blend modes, gradient directions and kinds (linear, radial, conic), text alignment, case and decoration |
| Assets list with export | done | layers with export settings, icons (small, vector shapes only: SVG) and images (PNG); the icon rule is ours |
| Annotations: pinned properties, categories, coloured callouts, filter | done | |
| Ready for dev list, statuses, "Changed" after edits | done | |
| Focus view (one frame, previous / next) | done | |
| Compare changes (before/after side by side or overlaid, property diffs) | done | only against the snapshot taken when a frame was marked ready; not against any named version |
| Code connect (map components to repository code) | done | per language (React, SwiftUI, Compose): import path, name and a template with `{property}` and `{property ? "a" : "b"}` placeholders; instances (also inside generated layer trees) show the snippet with their values; edited in the Inspect panel with a live preview; `figcraft.connect.json` export/import. Inside a generated layer tree, connected instances' imports aren't collected |
| Code connect (map components to repository code) | done | per language (React, Leptos, SwiftUI, Compose): import path, name and a template with `{property}` and `{property ? "a" : "b"}` placeholders; instances (also inside generated layer trees) show the snippet with their values; edited in the Inspect panel with a live preview; `figcraft.connect.json` export/import. Inside a generated layer tree, connected instances' imports aren't collected |
| Plugins in Dev Mode (Jira, GitHub, etc.) | partial | FigCraft's own WASM plugins that only read run from the Inspect tab; no plugin UI at run time, no third-party integrations |
| VS Code extension | out of scope | FigCraft's MCP server and control channel already serve design data, code and connected snippets to agents and editors |
| Dev resources links on layers | done | a URL and a name per link on any layer (`devResource.*`, saved, synced, undoable); instances list their main component's (and its set's) read-only; URLs checked as http/https and never fetched (no page titles: a link without a name is named after its URL); the Inspect tab lists, adds, edits and removes them; `get_node` returns them |

View File

@ -40,6 +40,9 @@ built-in fonts. Attached, the app's own fonts apply. The browser build has no sy
Build the CLI first (`cargo build --release -p figcraft-cli`) and use the absolute path to
`target/release/figcraft-cli`.
The Linux AppImage bundles the CLI: `FigCraft.AppImage cli <args>` runs `figcraft-cli <args>`, so
`/path/to/FigCraft.AppImage cli mcp` works wherever `/path/to/figcraft-cli mcp` appears below.
### Claude Code
```sh
@ -215,8 +218,8 @@ Or in a project's `.mcp.json`:
| Tool | What it does |
|---|---|
| `list_commands {filter?}` | Every command: id, label, menu, shortcut, undoable, params doc. |
| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `component.makeSlot` / `instance.slotInsert` / `instance.resetSlot` (slots, below), `inspect.code` (CSS, HTML + CSS for a whole frame, React, SwiftUI or Jetpack Compose for a layer; components take their properties as parameters and instances are calls, see Dev Mode below), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names; `colorFormat`: hex, rgb, css, hsl or hsb), `inspect.assets` (layers under the selection with export settings, icons and images, each with its `kind`), `inspect.code` takes `unit: rem` and `rootFontSize` for CSS, HTML and React, `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}`, `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`, `codeConnect.set` (components linked to real code, see Dev Mode below)). |
| `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`); all-or-nothing. |
| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `component.makeSlot` / `instance.slotInsert` / `instance.resetSlot` (slots, below), `inspect.code` (CSS, HTML + CSS for a whole frame, React, Leptos, SwiftUI or Jetpack Compose for a layer; components take their properties as parameters and instances are calls, see Dev Mode below), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names; `colorFormat`: hex, rgb, css, hsl or hsb), `inspect.assets` (layers under the selection with export settings, icons and images, each with its `kind`), `inspect.code` takes `unit: rem` and `rootFontSize` for CSS, HTML, React and Leptos, `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}`, `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`, `codeConnect.set` (components linked to real code, see Dev Mode below)). |
| `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`); all-or-nothing for open documents and their document state. Failed inner commands are removed from the journal; preferences, clipboard, tool state and UI requests are not rolled back. |
| `create_node {type, x, y, width, height, parent, index, name, text, path, props}` | Create a frame, rectangle, ellipse, polygon, star, line, arrow, text, vector, section, component or slice; returns `{id, node}`. |
| `set_properties {ids, props}` | `node.set` on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, masks (`isMask`, `maskType: alpha\|vector\|luminance`), handoff status (`devStatus: none\|readyForDev\|completed`; marking again clears "Changed"), a section's `sectionContentsHidden`, text…); returns the updated layers. |
| `inspect_document {depth, allPages}` | The document as JSON (layer tree, styles, selection, history). |
@ -1168,27 +1171,30 @@ permissions: without it nothing is downloaded); `plugin.directory.publish {path
### Dev Mode
- **Component code:** `inspect.code {id, language: react|swiftui|compose}` on a component, a
component set or a variant writes the component as a reusable unit: React (TSX) with a props
interface, SwiftUI with a `View` struct, Compose with a `@Composable` function. Its component
properties are the parameters, with their defaults: variant properties become string unions
(React) or enums, booleans become flags around the layers they show, text properties strings,
instance swaps views (`React.ReactNode`, `AnyView`, a composable lambda) and slots optional
content. Every variant of a set shares one definition; in React the other variants' differences
become CSS rules on `data-*` attributes. On an instance (or a layer tree containing instances)
each instance is a call passing only the values it changes, e.g. `<Button state="Hover"
label="Buy" />`, `Button(state: .hover, label: "Buy")`, `Button(state = ButtonState.Hover, label =
"Buy")`. Names are escaped and unique (keywords get a suffix, `default` → `defaultValue`; names
that would shadow framework types get one too, `Text` → `TextComponent`). `inspect.componentCode
- **Component code:** `inspect.code {id, language: react|leptos|swiftui|compose}` on a component,
a component set or a variant writes the component as a reusable unit: React (TSX) with a props
interface, Leptos with a `#[component]` function (`#[prop]` defaults, `view!` markup), SwiftUI
with a `View` struct, Compose with a `@Composable` function. Its component properties are the
parameters, with their defaults: variant properties become string unions (React) or enums,
booleans become flags around the layers they show, text properties strings, instance swaps views
(`React.ReactNode`, Leptos `ViewFn`, `AnyView`, a composable lambda) and slots optional content.
Every variant of a set shares one definition; in React and Leptos the other variants'
differences become CSS rules on `data-*` attributes. On an instance (or a layer tree containing
instances) each instance is a call passing only the values it changes, e.g. `<Button
state="Hover" label="Buy" />`, `<Button state=ButtonState::Hover label="Buy" />`, `Button(state:
.hover, label: "Buy")`, `Button(state = ButtonState.Hover, label = "Buy")`. Names are escaped and
unique (keywords get a suffix, `default` → `defaultValue`, Rust's `type` → `type_`; Leptos names
are snake_case; names that would shadow framework types get one too, `Text` → `TextComponent`). `inspect.componentCode
{id?, language?}` returns the parts: `{component, name, set, properties: [{name, param, type,
default, value? (instances)}], definition, usage? (instances)}`.
- **Code connect:** link a component (a variant links its set) to the code component it is built
as, per language (`react`, `swiftui`, `compose`): `codeConnect.set {id?, language?, importPath?,
as, per language (`react`, `leptos`, `swiftui`, `compose`): `codeConnect.set {id?, language?, importPath?,
name?, template?}` (undoable; fields left out keep their value; the defaults are the generated
name and a call passing every property) → `{component, language, code, unknown}`. Templates are
text, never executed: `{property}` (its name or parameter name) is the instance's value escaped
for the language (React keeps JSX's braces, so `size={size}` gives `size="Large"` and
`disabled={disabled}` gives `disabled={true}`), `{property ? "a" : "b"}` inserts one of two
for the language (React and Leptos keep the markup's braces, so `size={size}` gives
`size="Large"` and `disabled={disabled}` gives `disabled={true}`; Leptos imports with `use
<importPath>::<name>;`), `{property ? "a" : "b"}` inserts one of two
strings, any other `{…}` stays as written (`unknown` lists placeholder names that aren't
properties). Then `inspect.code` on an instance or the component returns the import line and the
snippet instead of generated code (`connected: true`), connected instances inside a generated

View File

@ -251,12 +251,17 @@ it can handle):
Limits per run, and again per panel callback: 1,000,000,000 fuel (about a second or two of plain
WebAssembly in a debug build, far less in a release build), 64 MB of memory, 100,000 host calls
(each also costs 10,000 fuel), 16 MB per string either way, 1,000 log lines, and modules up to
(each also costs 10,000 fuel, including `reply_len`, `reply_read` and `set_output`), 16 MB per string either way, 1,000 log lines, and modules up to
16 MB. A panel spec is at most 64 KB with 64 controls; settings are at most 64 KB per plugin (keys
and JSON values), keys 1–128 characters. A widget callback gets the same limits; its description is
at most 4 MB with 256 layers nested 16 deep, its state at most 16 KB (keys 1–128 characters), and
its images 1 MB each, 2 MB together and 4096 pixels on a side.
Copying a byte across the guest-memory boundary costs one additional fuel, reserved before the
allocation or copy. This also applies when the configured per-call overhead is zero. A reply
read is charged for the bytes actually returned, not the requested buffer size. `calls` counts
every ABI import, including calls which only query the reply length.
## ABI (version 3)
Version 2 adds panels and settings to version 1, and version 3 adds widgets; a version 1 or 2

View File

@ -148,7 +148,8 @@ shared-mime-info file for `.figcraft`) and builds every format from it: `.tar.gz
`.rpm` with [nfpm](https://nfpm.goreleaser.com/) (`nfpm.yaml` lists the dlopen'd runtime
libraries as dependencies); and an `.AppImage` with update information and a `.zsync`, whose
`AppRun` registers the desktop entry and icons on launch so Wayland docks show the right icon
(`FIGCRAFT_NO_DESKTOP_INTEGRATION=1` opts out; covered by `apprun-test.sh`).
(`FIGCRAFT_NO_DESKTOP_INTEGRATION=1` opts out; covered by `apprun-test.sh`) and runs the bundled
`figcraft-cli` for `FigCraft.AppImage cli <args>`.
`flatpak-bundle.sh` repackages the tarball into a `.flatpak` with
`flatpak/ai.storyteller.figcraft.bundle.yml` (freedesktop 26.08). `flatpak/ai.storyteller.figcraft.yml`
is the from-source manifest for a Flathub submission (not built in CI; vendor crates with

View File

@ -124,7 +124,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
| Bound on other keys (documented conflict) | 1 | Run last plugin: the documentation also gives Ctrl+Alt+P to pixel preview on Windows; the plugin takes Ctrl+Alt+Shift+P there |
| Missing | 0 | moving the rotation origin (Alt+R, `transform.rotationOrigin`) and placing an object with Enter after choosing its tool (`tool.place`) were the last two |
| Documented keys a platform keeps | 2 | Ctrl+Space (Windows/Linux keyboard box selection) switches some CJK input methods; F6 goes to a browser's address bar in the web build |
| Undocumented bindings pinned | 48 | of which Cmd/Ctrl+W, Cmd/Ctrl+N and Ctrl+Tab are taken by browsers (web build) and Ctrl+Alt+T / Ctrl+Alt+L by some Linux desktops |
| Undocumented bindings pinned | 49 | of which Cmd/Ctrl+W, Cmd/Ctrl+N and Ctrl+Tab are taken by browsers (web build) and Ctrl+Alt+T / Ctrl+Alt+L by some Linux desktops |
- **Newly bound:** A for the Frame tool, Shift+' for the pixel grid (Cmd+' kept: the articles
disagree), Alt+Cmd+\ / Ctrl+Alt+\ for others' cursors (`view.multiplayerCursors`, new), Ctrl+F6
@ -165,6 +165,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | minor | Home screen: a Templates tab (eight starter templates as cards with thumbnails, search and a kind filter; click or Enter opens a new document) replaces the Community placeholder, the Drafts placeholder is removed, Recents offers four templates while there are no recent files and shows a Getting started card (closable; Help ▸ Getting started brings it back), File ▸ New from template…, and a first run opens on the home screen |
| 2026-10-13 | minor | Full screen (#227): View ▸ Full screen (`view.fullscreen {on?}`, F11 on Windows and Linux, Ctrl+Cmd+F on macOS) makes the window fill the screen, taskbar and title bar included, and leaves it again; FigCraft's own item (not in the incumbent's menu). Percentages and hours unchanged |
| 2026-10-12 | minor | Everyday editing partials: number fields (scrub a step a point, ×10 with Shift, ×0.1 with Alt, on past the screen edge where the platform reports it; arrow keys by the nudge preferences, opacity and rotation by 1 and 10; brackets and units in arithmetic; `50%` relative outside percentage fields; Tab commits and moves on; an untouched field commits nothing; relative edits and arrow keys on Mixed go to each layer in one undo step) done; outline mode's Include hidden layers and Include object bounds, drawn and clickable, done; Hide when publishing from the Assets panel done (the name's leading `.`); cursors per tool and modifier done (a table in the engine, drawn rotate, pen, eyedropper and comment cursors, not-allowed on refused edits; guides and handles keep their cursors); resizable panels done (limits, double-click reset, kept per window and in the preferences, the canvas keeps its room in a small window); a file or page made in the dark theme has a #1E1E1E page (the light theme and sessions without an interface: #F5F5F5) and text drawn on a dark page is white; Enter on a text selects all of it (test added). Menu items: View ▸ Outlines ▸ Include hidden layers / Include object bounds, View ▸ Panels ▸ Reset panel widths, Libraries ▸ Hide when publishing |
| 2026-10-12 | minor | Drag threshold and double-clicks: the second press of a double-click began a move or resize that was never finished, so double-clicking text inside a group often didn't start editing (from the middle of the text) or dragged and resized it when the hand moved a pixel or two; a move or resize now starts only after 3 px of travel on screen and a double-click drops the press's gesture first |
| 2026-10-12 | minor | Rotation origin and placing with Enter: Alt+R shows a rotation origin handle to drag (snapping to the box's corners, edge middles and centre; double-click resets; Escape hides; kept while the selection lasts, not saved), which the corner handles, the rotation field, Rotate 90°/180° and `transform.rotate` turn about (`transform.rotationOrigin`, `transform.setRotationOrigin`); Enter with a shape or frame tool chosen, on the canvas or the toolbar's tool, places a click-sized one in the middle of the view (`tool.place`). Documented shortcuts: 128 bound, 0 missing. The keyboard box selection's toast and screen-reader text name Cmd/Ctrl+arrows too; the shortcuts panel cuts a label that doesn't fit before its keycaps with "…" (measured in the font it's drawn in; long labels ran into the keycaps) and shows it in full on hover |

View File

@ -8,6 +8,9 @@
# (and whenever the AppImage's path changes) install the .desktop entry and the
# hicolor icons into $XDG_DATA_HOME, then launch. Every failure here must leave
# the launch untouched. Opt out with FIGCRAFT_NO_DESKTOP_INTEGRATION=1.
#
# `FigCraft.AppImage cli <args>` runs the bundled headless figcraft-cli with <args>
# (e.g. `cli mcp`) instead of the app, without touching the desktop.
set -u
@ -68,5 +71,17 @@ integrate() {
done
}
# The CLI is headless and its stdout may be a protocol stream (`cli mcp`): no
# desktop integration, straight to the binary.
if [ "${1:-}" = cli ]; then
shift
cli="$APPDIR/usr/bin/figcraft-cli"
if [ ! -x "$cli" ]; then
echo "figcraft: this AppImage was built without figcraft-cli" >&2
exit 127
fi
exec "$cli" "$@"
fi
integrate
exec "$APPDIR/usr/bin/figcraft" "$@"

View File

@ -1,7 +1,7 @@
#!/usr/bin/env bash
# Smoke test for packaging/linux/AppRun: the AppImage's desktop integration writes
# and refreshes the host .desktop entry and icons, is idempotent, honours the opt-out
# and an unwritable HOME, and always launches the app.
# and an unwritable HOME, and always launches the app; `cli` runs figcraft-cli instead.
#
# Runs in seconds with no build (CI: .github/workflows/packaging-lint.yml).
set -euo pipefail
@ -95,4 +95,19 @@ out="$(APPIMAGE="$odd" "$APPDIR/AppRun" x)" || fail "launch with an odd path exi
[ "$out" = "stub: x" ] || fail "odd path: $out"
[ ! -e "$XDG_DATA_HOME" ] || fail "integrated an AppImage path that needs escaping"
# 10. `cli <args>` runs figcraft-cli with the remaining arguments and integrates nothing.
rm -rf "$XDG_DATA_HOME"
out="$("$APPDIR/AppRun" cli mcp --attach 7979 2>&1)" && fail "cli ran without figcraft-cli in the AppDir"
[ "$out" = "figcraft: this AppImage was built without figcraft-cli" ] || fail "missing cli: $out"
printf '#!/bin/sh\necho "cli stub: $*"\n' >"$APPDIR/usr/bin/figcraft-cli"
chmod +x "$APPDIR/usr/bin/figcraft-cli"
out="$("$APPDIR/AppRun" cli mcp --attach 7979)" || fail "cli launch exited non-zero"
[ "$out" = "cli stub: mcp --attach 7979" ] || fail "cli arguments not forwarded: $out"
out="$("$APPDIR/AppRun" cli)" || fail "bare cli launch exited non-zero"
[ "$out" = "cli stub: " ] || fail "bare cli output: $out"
[ ! -e "$XDG_DATA_HOME" ] || fail "cli launch integrated the desktop"
# Only the first argument selects the CLI.
out="$("$APPDIR/AppRun" file.figcraft cli)" || fail "app launch with a later cli argument exited non-zero"
[ "$out" = "stub: file.figcraft cli" ] || fail "later cli argument hijacked the launch: $out"
echo "AppRun desktop integration ok"