Compare commits
16 Commits
docs/commu
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
5e90592e80 | |
|
|
ef2c6cb30e | |
|
|
24c5617230 | |
|
|
fb8ace5bf4 | |
|
|
ce84d1f27f | |
|
|
8e12e5eeda | |
|
|
118ca7c60b | |
|
|
d61ccc633a | |
|
|
badcb6ec21 | |
|
|
7a9a67a8c3 | |
|
|
dd3d4d326c | |
|
|
6d735653a2 | |
|
|
ac65168417 | |
|
|
f7e726714a | |
|
|
7d589c4456 | |
|
|
79a0e58a3c |
|
|
@ -1667,6 +1667,7 @@ dependencies = [
|
||||||
"figcraft-pathops",
|
"figcraft-pathops",
|
||||||
"figcraft-render",
|
"figcraft-render",
|
||||||
"figcraft-text",
|
"figcraft-text",
|
||||||
|
"flate2",
|
||||||
"thiserror 2.0.21",
|
"thiserror 2.0.21",
|
||||||
"usvg",
|
"usvg",
|
||||||
]
|
]
|
||||||
|
|
@ -3924,9 +3925,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruzstd"
|
name = "ruzstd"
|
||||||
version = "0.9.0"
|
version = "0.9.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a252f5e20f038fe7b4ea53e073e65398d652c864cc162fc77c56c2f13717b888"
|
checksum = "69e4ce4a039f42bf25920b95667e4d789e98749ab11bbb8d2c2936f788421bb5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ryu"
|
name = "ryu"
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ linesweeper = "0.4"
|
||||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
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).
|
# `.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"] }
|
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"
|
sha2 = "0.10"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
crc32fast = "1"
|
crc32fast = "1"
|
||||||
|
|
|
||||||
|
|
@ -191,7 +191,7 @@ exposes to agents, and exported at 2x by FigCraft's own renderer. The source fil
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" valign="top">
|
<td width="50%" valign="top">
|
||||||
<h3>Prototype and hand off</h3>
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</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
|
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)).
|
**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`.
|
For the web build, install [trunk](https://trunkrs.dev/) and run `cargo xtask wasm`.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
**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,
|
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
|
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
|
## 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** · 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** · 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.
|
- **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 |
|
| 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 | 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 | 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. |
|
| 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. |
|
||||||
|
|
|
||||||
|
|
@ -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
|
// 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.
|
// 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");
|
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();
|
server.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 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> =
|
let links: Vec<Link> =
|
||||||
serde_json::from_slice(&disk::read_capped(&path.join(LINKS), 16 << 20)?).map_err(|e| format!("{LINKS} is damaged ({e})"))?;
|
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.
|
// 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)
|
if let Ok(bytes) = disk::read_capped(&path.join(LOG), self.cfg.max_file_bytes)
|
||||||
&& let Ok(log) = disk::read_log(&bytes)
|
&& let Ok(log) = disk::read_log(&bytes)
|
||||||
|
|
@ -1086,7 +1086,7 @@ impl Store {
|
||||||
fn load(&mut self, file: &str) -> Result<Server, String> {
|
fn load(&mut self, file: &str) -> Result<Server, String> {
|
||||||
self.touch(file);
|
self.touch(file);
|
||||||
let dir = self.dir(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 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 issues = Vec::new();
|
||||||
let mut fresh_log = true;
|
let mut fresh_log = true;
|
||||||
|
|
@ -1130,11 +1130,22 @@ impl Store {
|
||||||
Ok(server)
|
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.
|
/// 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> {
|
fn rewrite(&mut self, file: &str, generation: u64, server: &Server) -> Result<Log, String> {
|
||||||
let dir = self.dir(file);
|
let dir = self.dir(file);
|
||||||
let state = server.state().map_err(|e| e.to_string())?;
|
let state = server.state().map_err(|e| e.to_string())?;
|
||||||
let bytes = disk::encode_snapshot(generation, &state)?;
|
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 {
|
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);
|
log::warn!("file {file} is {} MiB, over the {} MiB cap (kept anyway)", bytes.len() >> 20, self.cfg.max_file_bytes >> 20);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -12,12 +12,15 @@
|
||||||
mod args;
|
mod args;
|
||||||
mod control_server;
|
mod control_server;
|
||||||
mod gamepad;
|
mod gamepad;
|
||||||
|
mod graphics;
|
||||||
mod recovery;
|
mod recovery;
|
||||||
mod services;
|
mod services;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod args_tests;
|
mod args_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
mod graphics_tests;
|
||||||
|
#[cfg(test)]
|
||||||
mod recovery_online_tests;
|
mod recovery_online_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod recovery_tests;
|
mod recovery_tests;
|
||||||
|
|
@ -182,7 +185,8 @@ fn main() -> eframe::Result {
|
||||||
if let Some(icon) = icon {
|
if let Some(icon) = icon {
|
||||||
viewport = viewport.with_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);
|
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 }))))
|
eframe::run_native("FigCraft", options, Box::new(move |_cc| Ok(Box::new(App { queue, ctx_slot, title: String::new(), recovery }))))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
/// Write `prefs` to `dir` atomically (a temporary file renamed over the old one: a crash leaves
|
||||||
/// either version whole).
|
/// either version whole). They hold online owner tokens, so on Unix only the owner can read them.
|
||||||
fn write_prefs(dir: &Path, prefs: &Prefs) {
|
pub(crate) fn write_prefs(dir: &Path, prefs: &Prefs) {
|
||||||
let tmp = dir.join("state.json.tmp");
|
let tmp = dir.join("state.json.tmp");
|
||||||
let written = std::fs::create_dir_all(dir)
|
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)));
|
.and_then(|()| std::fs::rename(&tmp, dir.join(STATE)));
|
||||||
if let Err(e) = written {
|
if let Err(e) = written {
|
||||||
log::warn!("preferences: couldn't save them in {}: {e}", dir.display());
|
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 {
|
impl Services for Desktop {
|
||||||
fn pick_open(&mut self, title: &str, exts: &[&str]) -> Option<PathBuf> {
|
fn pick_open(&mut self, title: &str, exts: &[&str]) -> Option<PathBuf> {
|
||||||
rfd::FileDialog::new().set_title(title).add_filter("Supported files", exts).pick_file()
|
rfd::FileDialog::new().set_title(title).add_filter("Supported files", exts).pick_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");
|
assert!(!dir.join("state.json.tmp").exists(), "no temporary file is left behind");
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -939,7 +939,11 @@ impl Document {
|
||||||
if n.id != *id {
|
if n.id != *id {
|
||||||
issues.push(format!("node key {id} has id {}", n.id));
|
issues.push(format!("node key {id} has id {}", n.id));
|
||||||
}
|
}
|
||||||
|
let mut seen = std::collections::BTreeSet::new();
|
||||||
for c in &n.children {
|
for c in &n.children {
|
||||||
|
if !seen.insert(*c) {
|
||||||
|
issues.push(format!("child {c} listed twice in {id}"));
|
||||||
|
}
|
||||||
match self.node(*c) {
|
match self.node(*c) {
|
||||||
Some(cn) if cn.parent == Some(*id) => {}
|
Some(cn) if cn.parent == Some(*id) => {}
|
||||||
Some(_) => issues.push(format!("child {c} of {id} has another parent")),
|
Some(_) => issues.push(format!("child {c} of {id} has another parent")),
|
||||||
|
|
@ -956,10 +960,32 @@ impl Document {
|
||||||
if n.is_page() != listed {
|
if n.is_page() != listed {
|
||||||
issues.push(format!("page list mismatch for {id}"));
|
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
|
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).
|
/// Repair what [`validate`](Self::validate) reports (lenient loading of untrusted files).
|
||||||
pub fn repair(&mut self) {
|
pub fn repair(&mut self) {
|
||||||
let ids: Vec<NodeId> = self.nodes.keys().copied().collect();
|
let ids: Vec<NodeId> = self.nodes.keys().copied().collect();
|
||||||
|
|
@ -967,34 +993,30 @@ impl Document {
|
||||||
if let Some(n) = self.nodes.get_mut(id) {
|
if let Some(n) = self.nodes.get_mut(id) {
|
||||||
let n = Arc::make_mut(n);
|
let n = Arc::make_mut(n);
|
||||||
n.id = *id;
|
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();
|
let parents: BTreeMap<NodeId, Option<NodeId>> = self.nodes.iter().map(|(k, n)| (*k, n.parent)).collect();
|
||||||
for id in &ids {
|
for id in &ids {
|
||||||
let Some(n) = self.nodes.get(id) else { continue };
|
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
|
if keep != n.children
|
||||||
&& let Some(n) = self.nodes.get_mut(id)
|
&& let Some(n) = self.nodes.get_mut(id)
|
||||||
{
|
{
|
||||||
Arc::make_mut(n).children = keep;
|
Arc::make_mut(n).children = keep;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Orphans (parent missing or not listing them) are removed.
|
// Nodes not under a page (orphans, detached subtrees, cycles) are removed.
|
||||||
loop {
|
let reachable = self.reachable();
|
||||||
let orphan = self.nodes.iter().find_map(|(k, n)| {
|
let gone: Vec<NodeId> = self.nodes.keys().filter(|k| !reachable.contains(*k)).copied().collect();
|
||||||
if n.is_page() {
|
for k in gone {
|
||||||
return None;
|
self.nodes.remove(&k);
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Snapshots of layers that are gone or no longer changed since marked ready for dev, or that
|
// 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.
|
// don't start with their layer, are dropped.
|
||||||
|
|
|
||||||
|
|
@ -281,6 +281,30 @@ fn serde_roundtrip_and_repair() {
|
||||||
assert!(bad.validate().is_empty(), "{:?}", bad.validate());
|
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]
|
#[test]
|
||||||
fn auto_layout_loads_files_without_grid_tracks() {
|
fn auto_layout_loads_files_without_grid_tracks() {
|
||||||
// Saved before grid tracks, cells and spans existed.
|
// Saved before grid tracks, cells and spans existed.
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -104,6 +104,8 @@ pub static MENU: &[MenuItem] = &[
|
||||||
m!("View", "Soft-proof sRGB", "view.proofSrgb"),
|
m!("View", "Soft-proof sRGB", "view.proofSrgb"),
|
||||||
m!("View", SEP, ""),
|
m!("View", SEP, ""),
|
||||||
m!("View/Panels", "Show/Hide UI", "view.toggleUi"),
|
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", "Reset panel widths", "view.panelWidths"),
|
||||||
m!("View/Panels", "Layers", "view.layers"),
|
m!("View/Panels", "Layers", "view.layers"),
|
||||||
m!("View/Panels", "Assets", "view.assets"),
|
m!("View/Panels", "Assets", "view.assets"),
|
||||||
|
|
|
||||||
|
|
@ -662,6 +662,13 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
remember(s, &path);
|
remember(s, &path);
|
||||||
let native = !rest && matches!(ext(&path).as_str(), "figcraft" | "json");
|
let native = !rest && matches!(ext(&path).as_str(), "figcraft" | "json");
|
||||||
let warnings = rest || ext(&path) == "svg";
|
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.docs.push(DocState::new(loaded.doc, native.then_some(path)).with_versions(loaded.versions).with_branches(loaded.branches));
|
||||||
s.active = s.docs.len() - 1;
|
s.active = s.docs.len() - 1;
|
||||||
let mut out = json!({"index": s.active});
|
let mut out = json!({"index": s.active});
|
||||||
|
|
|
||||||
|
|
@ -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.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.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.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.layers", "Layers panel", "{}", false, |s, _| ui(s, "panel.layers")),
|
||||||
spec!("view.assets", "Assets panel", "{}", false, |s, _| ui(s, "panel.assets")),
|
spec!("view.assets", "Assets panel", "{}", false, |s, _| ui(s, "panel.assets")),
|
||||||
spec!("view.design", "Design panel", "{}", false, |s, _| ui(s, "panel.design")),
|
spec!("view.design", "Design panel", "{}", false, |s, _| ui(s, "panel.design")),
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ const MAX_WARNINGS: usize = 50;
|
||||||
|
|
||||||
/// The languages a component can be connected in.
|
/// The languages a component can be connected in.
|
||||||
pub fn language(s: &str) -> Option<CodeLang> {
|
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
|
/// 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 {
|
match lang {
|
||||||
_ if path.is_empty() => String::new(),
|
_ if path.is_empty() => String::new(),
|
||||||
CodeLang::React => format!("import {{ {} }} from {};", link.name.split('.').next().unwrap_or_default(), js_string(path)),
|
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}"),
|
_ => 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));
|
let swapped = (p.kind == ParamKind::InstanceSwap).then(|| id_of(v)).flatten().and_then(|i| link(doc, i, lang));
|
||||||
match (swapped, lang) {
|
match (swapped, lang) {
|
||||||
(Some(l), CodeLang::React) => format!("<{} />", l.name),
|
(Some(l), CodeLang::React) => format!("<{} />", l.name),
|
||||||
|
(Some(l), CodeLang::Leptos) => format!("|| view! {{ <{} /> }}", l.name),
|
||||||
(Some(l), CodeLang::SwiftUi) => format!("{}()", l.name),
|
(Some(l), CodeLang::SwiftUi) => format!("{}()", l.name),
|
||||||
(Some(l), _) => format!("{{ {}() }}", l.name),
|
(Some(l), _) => format!("{{ {}() }}", l.name),
|
||||||
(None, _) => value_expr(doc, c, p, v, lang),
|
(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) {
|
Piece::Value { name, inner } => match find(c, values, name) {
|
||||||
Some((p, v)) => {
|
Some((p, v)) => {
|
||||||
let e = expr(doc, c, p, v, lang);
|
let e = expr(doc, c, p, v, lang);
|
||||||
// JSX: a plain string reads best as an attribute value, anything else is an
|
// JSX and `view!`: a plain string reads best as an attribute value, anything
|
||||||
// expression in braces (JSX attribute strings have no escapes).
|
// else is an expression in braces (JSX attribute strings have no escapes).
|
||||||
if lang != CodeLang::React || (out.ends_with('=') && e.starts_with('"') && !e.contains('\\')) {
|
let markup = matches!(lang, CodeLang::React | CodeLang::Leptos);
|
||||||
|
if !markup || (out.ends_with('=') && e.starts_with('"') && !e.contains('\\')) {
|
||||||
out.push_str(&e);
|
out.push_str(&e);
|
||||||
} else {
|
} else {
|
||||||
out.push_str(&format!("{{{e}}}"));
|
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();
|
let params: Vec<&str> = c.params.iter().filter(|p| p.kind != ParamKind::Slot).map(|p| p.ident.as_str()).collect();
|
||||||
match lang {
|
match lang {
|
||||||
CodeLang::React => format!("<{name}{} />", params.iter().map(|p| format!(" {p}={{{p}}}")).collect::<String>()),
|
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(", ")),
|
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(", ")),
|
_ => 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 = &link.import_path;
|
||||||
let path_ok = match lang {
|
let path_ok = match lang {
|
||||||
CodeLang::React => !path.chars().any(|ch| ch.is_control() || ch == '"' || ch == '\\'),
|
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 == '.'),
|
_ => path.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '.'),
|
||||||
};
|
};
|
||||||
if path.chars().count() > MAX_IMPORT || !path_ok {
|
if path.chars().count() > MAX_IMPORT || !path_ok {
|
||||||
return Err(match lang {
|
return Err(match lang {
|
||||||
CodeLang::React => format!("importPath: a module path without quotes, up to {MAX_IMPORT} characters"),
|
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"),
|
_ => 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).
|
/// The `language` parameter (default React).
|
||||||
fn lang_param(cmd: &str, p: &Value) -> Result<CodeLang> {
|
fn lang_param(cmd: &str, p: &Value) -> Result<CodeLang> {
|
||||||
match text(cmd, p, "language")? {
|
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),
|
None => Ok(CodeLang::React),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -304,7 +312,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||||
spec!(
|
spec!(
|
||||||
"codeConnect.set",
|
"codeConnect.set",
|
||||||
"Connect to code",
|
"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,
|
true,
|
||||||
|s, p| {
|
|s, p| {
|
||||||
let cmd = "codeConnect.set";
|
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)) =
|
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))
|
(field("component"), field("language").and_then(language), field("name"), e.get("template").and_then(Value::as_str))
|
||||||
else {
|
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;
|
continue;
|
||||||
};
|
};
|
||||||
let link = CodeLink {
|
let link = CodeLink {
|
||||||
|
|
|
||||||
|
|
@ -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_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");
|
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.
|
// inspect.componentCode: the snippet as the usage, and the link.
|
||||||
let v = ex(&mut s, "inspect.componentCode", json!({"id": i, "language": "swiftui"}));
|
let v = ex(&mut s, "inspect.componentCode", json!({"id": i, "language": "swiftui"}));
|
||||||
assert_eq!(v["usage"], "Card(title: \"Say \\\"hi\\\"\", style: .plain)\n");
|
assert_eq!(v["usage"], "Card(title: \"Say \\\"hi\\\"\", style: .plain)\n");
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||||
vec![spec!(
|
vec![spec!(
|
||||||
"inspect.code",
|
"inspect.code",
|
||||||
"Code for layer",
|
"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,
|
false,
|
||||||
|s, p| {
|
|s, p| {
|
||||||
let cmd = "inspect.code";
|
let cmd = "inspect.code";
|
||||||
let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer"))?;
|
let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer"))?;
|
||||||
let lang = match string(p, "language") {
|
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,
|
None => CodeLang::Css,
|
||||||
};
|
};
|
||||||
let rem = match string(p, "unit").map(|u| u.to_ascii_lowercase()).as_deref() {
|
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 connected = crate::code_connect::link(doc, id, lang).is_some();
|
||||||
let code = code_for(doc, id, lang);
|
let code = code_for(doc, id, lang);
|
||||||
let code = match rem {
|
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,
|
_ => code,
|
||||||
};
|
};
|
||||||
Ok(json!({"code": code, "language": lang.id(), "connected": connected}))
|
Ok(json!({"code": code, "language": lang.id(), "connected": connected}))
|
||||||
|
|
@ -59,17 +59,20 @@ pub enum CodeLang {
|
||||||
Html,
|
Html,
|
||||||
/// A React function component (TSX) with its stylesheet.
|
/// A React function component (TSX) with its stylesheet.
|
||||||
React,
|
React,
|
||||||
|
/// A Leptos component (`view!` markup) with its stylesheet.
|
||||||
|
Leptos,
|
||||||
SwiftUi,
|
SwiftUi,
|
||||||
Compose,
|
Compose,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CodeLang {
|
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 {
|
pub fn id(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
CodeLang::Css => "css",
|
CodeLang::Css => "css",
|
||||||
CodeLang::Html => "html",
|
CodeLang::Html => "html",
|
||||||
CodeLang::React => "react",
|
CodeLang::React => "react",
|
||||||
|
CodeLang::Leptos => "leptos",
|
||||||
CodeLang::SwiftUi => "swiftui",
|
CodeLang::SwiftUi => "swiftui",
|
||||||
CodeLang::Compose => "compose",
|
CodeLang::Compose => "compose",
|
||||||
}
|
}
|
||||||
|
|
@ -79,10 +82,15 @@ impl CodeLang {
|
||||||
CodeLang::Css => "CSS",
|
CodeLang::Css => "CSS",
|
||||||
CodeLang::Html => "HTML + CSS",
|
CodeLang::Html => "HTML + CSS",
|
||||||
CodeLang::React => "React",
|
CodeLang::React => "React",
|
||||||
|
CodeLang::Leptos => "Leptos",
|
||||||
CodeLang::SwiftUi => "SwiftUI",
|
CodeLang::SwiftUi => "SwiftUI",
|
||||||
CodeLang::Compose => "Compose",
|
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> {
|
pub fn from_id(s: &str) -> Option<CodeLang> {
|
||||||
if s.eq_ignore_ascii_case("tsx") || s.eq_ignore_ascii_case("jsx") {
|
if s.eq_ignore_ascii_case("tsx") || s.eq_ignore_ascii_case("jsx") {
|
||||||
return Some(CodeLang::React);
|
return Some(CodeLang::React);
|
||||||
|
|
@ -136,7 +144,7 @@ pub fn code_for(doc: &Document, id: NodeId, lang: CodeLang) -> String {
|
||||||
match lang {
|
match lang {
|
||||||
CodeLang::Css => css(doc, id),
|
CodeLang::Css => css(doc, id),
|
||||||
CodeLang::Html => html(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.
|
/// flexboxes, other children are placed absolutely in their parent, text becomes paragraphs.
|
||||||
fn html(doc: &Document, id: NodeId) -> String {
|
fn html(doc: &Document, id: NodeId) -> String {
|
||||||
let ctx = Ctx::new(doc, id);
|
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, "");
|
out.node(id, None, 0, "");
|
||||||
format!("{}\n<style>\n{}{}</style>\n", out.markup, out.tokens.root_rule(), out.styles)
|
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
|
/// Markup being built for a layer tree: HTML (`class`), React JSX (`className`) or Leptos `view!`
|
||||||
/// parameters, instances as calls), with its stylesheet.
|
/// 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> {
|
pub(crate) struct Markup<'c, 'a> {
|
||||||
ctx: &'c Ctx<'a>,
|
ctx: &'c Ctx<'a>,
|
||||||
jsx: bool,
|
/// Html, React or Leptos.
|
||||||
|
lang: CodeLang,
|
||||||
used: Vec<String>,
|
used: Vec<String>,
|
||||||
pub markup: String,
|
pub markup: String,
|
||||||
pub styles: String,
|
pub styles: String,
|
||||||
|
|
@ -584,10 +594,10 @@ pub(crate) struct Markup<'c, 'a> {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'c, 'a> 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 {
|
Markup {
|
||||||
ctx,
|
ctx,
|
||||||
jsx,
|
lang,
|
||||||
used: Vec::new(),
|
used: Vec::new(),
|
||||||
markup: String::new(),
|
markup: String::new(),
|
||||||
styles: 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 {
|
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) {
|
pub fn node(&mut self, id: NodeId, parent: Option<&Node>, depth: usize, path: &str) {
|
||||||
let Some(n) = self.ctx.doc.node(id) else { return };
|
let Some(n) = self.ctx.doc.node(id) else { return };
|
||||||
if self.jsx
|
if self.code()
|
||||||
&& id != self.ctx.root
|
&& id != self.ctx.root
|
||||||
&& let Some(p) = self.ctx.param(n, "visible")
|
&& let Some(p) = self.ctx.param(n, "visible")
|
||||||
{
|
{
|
||||||
let pad = self.pad(depth);
|
let (pad, p) = (self.pad(depth), self.ident(p));
|
||||||
self.markup.push_str(&format!("{pad}{{{} && (\n", p.ident));
|
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.element(n, parent, depth + 1, path);
|
||||||
self.markup.push_str(&format!("{pad})}}\n"));
|
self.markup.push_str(&format!("{pad}{close}\n"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.element(n, parent, depth, path);
|
self.element(n, parent, depth, path);
|
||||||
|
|
@ -639,7 +661,7 @@ impl<'c, 'a> Markup<'c, 'a> {
|
||||||
None => class_name(&n.name, &mut self.used),
|
None => class_name(&n.name, &mut self.used),
|
||||||
};
|
};
|
||||||
let pad = self.pad(depth);
|
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.
|
// How it sits in its parent: in the flex flow, or placed at its position.
|
||||||
let mut decls = String::new();
|
let mut decls = String::new();
|
||||||
let in_flow = parent.is_some_and(|p| p.layout.is_some()) && !n.child_layout.absolute;
|
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)));
|
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.
|
// Nested instances are calls (component code), in a wrapper that places them when needed.
|
||||||
if self.jsx && !root && matches!(n.kind, NodeKind::Instance { .. }) {
|
if self.code() && !root && matches!(n.kind, NodeKind::Instance { .. }) {
|
||||||
let at = depth + usize::from(!decls.is_empty());
|
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") {
|
let inner = match self.ctx.param(n, "mainComponent") {
|
||||||
Some(p) => Some(format!("{}{{{}}}\n", self.pad(at), p.ident)),
|
Some(p) => Some(format!("{}{{{}{run}}}\n", self.pad(at), self.ident(p))),
|
||||||
None => self.ctx.call(n, CodeLang::React, at),
|
None => self.ctx.call(n, self.lang, at),
|
||||||
};
|
};
|
||||||
if let Some(inner) = inner {
|
if let Some(inner) = inner {
|
||||||
if decls.is_empty() {
|
if decls.is_empty() {
|
||||||
|
|
@ -692,19 +715,26 @@ impl<'c, 'a> Markup<'c, 'a> {
|
||||||
let open = |tag: &str| format!("{pad}<{tag} {attr}=\"{class}\"{attrs}>");
|
let open = |tag: &str| format!("{pad}<{tag} {attr}=\"{class}\"{attrs}>");
|
||||||
match &n.kind {
|
match &n.kind {
|
||||||
NodeKind::Text(t) => {
|
NodeKind::Text(t) => {
|
||||||
let body = match self.ctx.param(n, "characters").filter(|_| self.jsx) {
|
let body = match (self.ctx.param(n, "characters").filter(|_| self.code()), self.lang) {
|
||||||
Some(p) => format!("{{{}}}", p.ident),
|
(Some(p), CodeLang::Leptos) => format!("{{{}.clone()}}", self.ident(p)),
|
||||||
None if self.jsx => jsx_text(&t.characters),
|
(Some(p), _) => format!("{{{}}}", p.ident),
|
||||||
None => html_escape(&t.characters),
|
(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")));
|
self.markup.push_str(&format!("{}{body}</p>\n", open("p")));
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
let kids = self.ctx.kids(n);
|
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");
|
let open = open("div");
|
||||||
if let Some(p) = slot.filter(|_| kids.is_empty()) {
|
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) {
|
} else if kids.is_empty() || self.ctx.too_deep(depth) {
|
||||||
self.markup.push_str(&format!("{open}</div>\n"));
|
self.markup.push_str(&format!("{open}</div>\n"));
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -712,6 +742,10 @@ impl<'c, 'a> Markup<'c, 'a> {
|
||||||
// A slot shows the instance's content, or else the main's.
|
// A slot shows the instance's content, or else the main's.
|
||||||
let (p1, p2) = (self.pad(depth + 1), self.pad(depth + 2));
|
let (p1, p2) = (self.pad(depth + 1), self.pad(depth + 2));
|
||||||
let inner = match slot {
|
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) => {
|
Some(p) => {
|
||||||
self.markup.push_str(&format!("{p1}{{{} ?? (\n{p2}<>\n", p.ident));
|
self.markup.push_str(&format!("{p1}{{{} ?? (\n{p2}<>\n", p.ident));
|
||||||
depth + 3
|
depth + 3
|
||||||
|
|
@ -726,8 +760,10 @@ impl<'c, 'a> Markup<'c, 'a> {
|
||||||
seen.push(&kn.name);
|
seen.push(&kn.name);
|
||||||
self.node(k, Some(n), inner, &format!("{path}/{}#{count}", kn.name));
|
self.node(k, Some(n), inner, &format!("{path}/{}#{count}", kn.name));
|
||||||
}
|
}
|
||||||
if slot.is_some() {
|
match slot {
|
||||||
self.markup.push_str(&format!("{p2}</>\n{p1})}}\n"));
|
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"));
|
self.markup.push_str(&format!("{pad}</div>\n"));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
//! Component code: a component (or component set) becomes a React, SwiftUI or Jetpack Compose
|
//! Component code: a component (or component set) becomes a React, Leptos, SwiftUI or Jetpack
|
||||||
//! component whose properties are its parameters (a variant property → a union or enum, boolean →
|
//! 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 →
|
//! 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
|
//! 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
|
//! share one definition (the set's default variant); in React and Leptos their differences become CSS
|
||||||
//! `data-*` attributes. Returned by `inspect.componentCode` and used by `inspect.code`. A component
|
//! 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.
|
//! connected to code (see [`crate::code_connect`]) shows its real snippet instead.
|
||||||
|
|
||||||
use figcraft_doc::*;
|
use figcraft_doc::*;
|
||||||
|
|
@ -67,14 +67,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||||
vec![spec!(
|
vec![spec!(
|
||||||
"inspect.componentCode",
|
"inspect.componentCode",
|
||||||
"Component code",
|
"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,
|
false,
|
||||||
|s, p| {
|
|s, p| {
|
||||||
let cmd = "inspect.componentCode";
|
let cmd = "inspect.componentCode";
|
||||||
let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component or instance"))?;
|
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)) {
|
let lang = match string(p, "language").map(|l| CodeLang::from_id(&l)) {
|
||||||
Some(Some(l @ (CodeLang::React | CodeLang::SwiftUi | CodeLang::Compose))) => l,
|
Some(Some(l @ (CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose))) => l,
|
||||||
Some(_) => return Err(bad(cmd, "language: react|swiftui|compose")),
|
Some(_) => return Err(bad(cmd, "language: react|leptos|swiftui|compose")),
|
||||||
None => CodeLang::React,
|
None => CodeLang::React,
|
||||||
};
|
};
|
||||||
let doc = s.doc()?;
|
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
|
/// 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 wraps it in a
|
/// definition, a component's (or set's) definition, or the layer tree (React and Leptos wrap it
|
||||||
/// function component).
|
/// in a function component).
|
||||||
pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String {
|
pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String {
|
||||||
let Some(n) = doc.node(id) else { return String::new() };
|
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)) {
|
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::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),
|
(NodeKind::Component { .. } | NodeKind::ComponentSet { .. }, Some(c)) => definition(doc, &c, lang),
|
||||||
_ => match 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 => {
|
CodeLang::SwiftUi => {
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
swift(&Ctx::new(doc, id), id, 0, &mut out, &[]);
|
swift(&Ctx::new(doc, id), id, 0, &mut out, &[]);
|
||||||
|
|
@ -358,6 +361,52 @@ fn ident(name: &str, used: &mut Vec<String>) -> String {
|
||||||
out
|
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` ``),
|
/// Enum case names for variant values, unique: Swift's lowerCamel (`.small`, `` .`default` ``),
|
||||||
/// Kotlin's PascalCase (`Small`).
|
/// Kotlin's PascalCase (`Small`).
|
||||||
fn cases(values: &[String], swift: bool) -> Vec<String> {
|
fn cases(values: &[String], swift: bool) -> Vec<String> {
|
||||||
|
|
@ -383,6 +432,11 @@ fn cases(values: &[String], swift: bool) -> Vec<String> {
|
||||||
.collect()
|
.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.
|
/// A JavaScript string literal.
|
||||||
pub(crate) fn js_string(s: &str) -> String {
|
pub(crate) fn js_string(s: &str) -> String {
|
||||||
let s: String = s.chars().take(10_000).collect();
|
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) {
|
match (&p.kind, lang) {
|
||||||
(ParamKind::Boolean, _) => value.as_bool().unwrap_or(false).to_string(),
|
(ParamKind::Boolean, _) => value.as_bool().unwrap_or(false).to_string(),
|
||||||
(ParamKind::Text, CodeLang::React) => js_string(value.as_str().unwrap_or("")),
|
(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::Text, l) => literal(value.as_str().unwrap_or(""), l == CodeLang::Compose),
|
||||||
(ParamKind::Variant(opts), l) => {
|
(ParamKind::Variant(opts), l) => {
|
||||||
let v = value.as_str().unwrap_or("");
|
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));
|
let t = id_of(value).and_then(|i| component_type(doc, i));
|
||||||
match (t, l) {
|
match (t, l) {
|
||||||
(Some(t), CodeLang::React) => format!("<{t} />"),
|
(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), CodeLang::SwiftUi) => format!("AnyView({t}())"),
|
||||||
(Some(t), _) => format!("{{ {t}() }}"),
|
(Some(t), _) => format!("{{ {t}() }}"),
|
||||||
(None, CodeLang::React) => "null".into(),
|
(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;
|
let t = &c.type_name;
|
||||||
Some(match lang {
|
Some(match lang {
|
||||||
CodeLang::React => {
|
CodeLang::React | CodeLang::Leptos => {
|
||||||
|
let leptos = lang == CodeLang::Leptos;
|
||||||
// Plain strings read best as attribute values.
|
// Plain strings read best as attribute values.
|
||||||
let attrs: String =
|
let attrs: String = args
|
||||||
args.iter().map(|(name, expr, plain)| if *plain { format!(" {name}={expr}") } else { format!(" {name}={{{expr}}}") }).collect();
|
.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")
|
format!("{pad}<{t}{attrs} />\n")
|
||||||
}
|
}
|
||||||
CodeLang::SwiftUi => format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::<Vec<_>>().join(", ")),
|
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);
|
let kids = ctx.kids(n);
|
||||||
match lang {
|
match lang {
|
||||||
CodeLang::React => {
|
CodeLang::React => {
|
||||||
let mut m = Markup::new(&ctx, true);
|
let mut m = Markup::new(&ctx, CodeLang::React);
|
||||||
for k in kids {
|
for k in kids {
|
||||||
m.node(k, Some(n), depth + 2, "");
|
m.node(k, Some(n), depth + 2, "");
|
||||||
}
|
}
|
||||||
let (p1, p2) = (" ".repeat(depth), " ".repeat(depth + 1));
|
let (p1, p2) = (" ".repeat(depth), " ".repeat(depth + 1));
|
||||||
format!("(\n{p2}<>\n{}{p2}</>\n{p1})", m.markup)
|
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 => {
|
CodeLang::SwiftUi => {
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
for k in kids {
|
for k in kids {
|
||||||
|
|
@ -535,6 +610,7 @@ pub fn definition(doc: &Document, c: &Component, lang: CodeLang) -> String {
|
||||||
match lang {
|
match lang {
|
||||||
CodeLang::SwiftUi => swift_definition(doc, c),
|
CodeLang::SwiftUi => swift_definition(doc, c),
|
||||||
CodeLang::Compose => compose_definition(doc, c),
|
CodeLang::Compose => compose_definition(doc, c),
|
||||||
|
CodeLang::Leptos => leptos(doc, c),
|
||||||
_ => react(doc, c),
|
_ => react(doc, c),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -545,23 +621,29 @@ fn data_attr(p: &Param) -> String {
|
||||||
format!("data-{}", class_name(&p.prop, &mut used))
|
format!("data-{}", class_name(&p.prop, &mut used))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A TSX function component with its props interface, and its stylesheet (variables as custom
|
/// `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).
|
/// properties, other variants as rules on their `data-*` attributes): (root class, markup, CSS).
|
||||||
fn react(doc: &Document, c: &Component) -> String {
|
fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String) {
|
||||||
let ctx = Ctx { doc, root: c.comp, comp: Some(c), base: 2 };
|
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_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 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 variants: Vec<&Param> = c.params.iter().filter(|p| matches!(p.kind, ParamKind::Variant(_))).collect();
|
||||||
let pass = |root: NodeId| {
|
let pass = |root: NodeId| {
|
||||||
let vctx = Ctx { doc, root, comp: Some(c), base: 2 };
|
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.root_class = Some(root_class.clone());
|
||||||
m.node(root, None, 2, "");
|
m.node(root, None, 2, "");
|
||||||
m.rules
|
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_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, "");
|
m.node(c.comp, None, 2, "");
|
||||||
// Other variants: what differs from this one, layer by layer.
|
// Other variants: what differs from this one, layer by layer.
|
||||||
let mut variant_rules = String::new();
|
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>()));
|
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 t = &c.type_name;
|
||||||
let mut out = format!("import \"./{root_class}.css\";\n\n");
|
let mut out = format!("import \"./{root_class}.css\";\n\n");
|
||||||
if c.params.is_empty() {
|
if c.params.is_empty() {
|
||||||
|
|
@ -610,7 +699,46 @@ fn react(doc: &Document, c: &Component) -> String {
|
||||||
.collect();
|
.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!("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
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
//! Component code: components become React / SwiftUI / Compose components whose properties are
|
//! Component code: components become React / Leptos / SwiftUI / Compose components whose
|
||||||
//! parameters, and instances become calls passing the values they set.
|
//! properties are parameters, and instances become calls passing the values they set.
|
||||||
|
|
||||||
use crate::Session;
|
use crate::Session;
|
||||||
use crate::codegen::{CodeLang, code_for};
|
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,"] {
|
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}");
|
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.
|
// The command describes the properties.
|
||||||
let v = component_code(&mut s, c, "react");
|
let v = component_code(&mut s, c, "react");
|
||||||
assert_eq!(v["name"], "Card");
|
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!(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, "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, "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.
|
// 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"}}));
|
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, "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, "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, "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]
|
#[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!(!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, "swiftui").contains("Card(title: \"Inbox\")"));
|
||||||
assert!(code(&mut s, screen, "compose").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.
|
// HTML and CSS stay plain markup.
|
||||||
assert!(code(&mut s, screen, "html").contains("Inbox"));
|
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");
|
let kt = code(&mut s, set, "compose");
|
||||||
assert!(kt.contains("enum class ButtonState { Default, Hover }"), "{kt}");
|
assert!(kt.contains("enum class ButtonState { Default, Hover }"), "{kt}");
|
||||||
assert!(kt.contains(" state: ButtonState = ButtonState.Default,"), "{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.
|
// An instance showing the hover variant passes it.
|
||||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 600})));
|
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"}}));
|
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, "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, "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, "compose")["usage"], "Button(state = ButtonState.Hover)\n");
|
||||||
|
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "<Button state=ButtonState::Hover />\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -184,11 +212,15 @@ fn instance_swap_properties_take_views() {
|
||||||
assert!(swift.contains(" var icon: AnyView = AnyView(HeartIcon())"), "{swift}");
|
assert!(swift.contains(" var icon: AnyView = AnyView(HeartIcon())"), "{swift}");
|
||||||
let kt = code(&mut s, chip, "compose");
|
let kt = code(&mut s, chip, "compose");
|
||||||
assert!(kt.contains(" icon: @Composable () -> Unit = { HeartIcon() },") && kt.contains("icon()"), "{kt}");
|
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})));
|
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}}));
|
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, "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, "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, "compose")["usage"], "Chip(icon = { StarIcon() })\n");
|
||||||
|
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "<Chip icon={|| view! { <StarIcon /> }} />\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -203,6 +235,9 @@ fn slots_take_optional_content() {
|
||||||
assert!(react.contains(" content?: React.ReactNode;") && react.contains("{content ?? ("), "{react}");
|
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, "swiftui").contains("var content: AnyView? = nil"));
|
||||||
assert!(code(&mut s, panel, "compose").contains("content: (@Composable () -> Unit)? = null,"));
|
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.
|
// 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 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]})));
|
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}));
|
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();
|
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}");
|
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]
|
#[test]
|
||||||
|
|
@ -225,6 +262,8 @@ fn identifiers_are_escaped_and_unique() {
|
||||||
("label", "text", json!("b")),
|
("label", "text", json!("b")),
|
||||||
("2 columns", "boolean", json!(false)),
|
("2 columns", "boolean", json!(false)),
|
||||||
("Color", "variant", json!("Red")),
|
("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}));
|
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");
|
assert_eq!(color.as_deref(), Some("ColorVariant"), "never shadows SwiftUI's Color");
|
||||||
let swift = code_for(doc, c, CodeLang::SwiftUi);
|
let swift = code_for(doc, c, CodeLang::SwiftUi);
|
||||||
assert!(swift.contains("struct TextComponent: View {") && swift.contains("enum ColorVariant: String, CaseIterable {"), "{swift}");
|
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.
|
// Errors.
|
||||||
let r = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})));
|
let r = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})));
|
||||||
assert!(s.execute("inspect.componentCode", &json!({"id": r})).is_err(), "not a component");
|
assert!(s.execute("inspect.componentCode", &json!({"id": r})).is_err(), "not a component");
|
||||||
|
|
|
||||||
|
|
@ -57,3 +57,23 @@ fn class_names_are_valid_and_deep_trees_are_bounded() {
|
||||||
// Nesting stops at the depth limit instead of running away.
|
// Nesting stops at the depth limit instead of running away.
|
||||||
assert!(code.matches("<div").count() <= 14, "{}", code.matches("<div").count());
|
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"]);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,7 @@ pub static KEYS: &[Binding] = &[
|
||||||
k(Essential, "app.actions", &["Cmd+K", "Cmd+/"]).label("Actions…"),
|
k(Essential, "app.actions", &["Cmd+K", "Cmd+/"]).label("Actions…"),
|
||||||
k(Essential, "help.shortcuts", &["Ctrl+Shift+?"]).both(),
|
k(Essential, "help.shortcuts", &["Ctrl+Shift+?"]).both(),
|
||||||
k(Essential, "view.toggleUi", &["Cmd+\\"]),
|
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, "edit.pickColor", &["I", "Ctrl+C"]).win(&["I"]),
|
||||||
k(Essential, "app.preferences", &["Cmd+,"]),
|
k(Essential, "app.preferences", &["Cmd+,"]),
|
||||||
k(Essential, "file.new", &["Cmd+N"]),
|
k(Essential, "file.new", &["Cmd+N"]),
|
||||||
|
|
|
||||||
|
|
@ -1066,24 +1066,57 @@ impl Session {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run `f` as one undo step called `label`: the commands it executes push no steps of their
|
/// 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> {
|
pub fn grouped<T>(&mut self, label: &str, f: impl FnOnce(&mut Self) -> Result<T>) -> Result<T> {
|
||||||
let ds = self.ds_mut()?;
|
self.ds()?;
|
||||||
let (before, sel, page, saved_len, uid) = (ds.doc.clone(), ds.selection.clone(), ds.page, ds.history.undo.len(), ds.uid);
|
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.
|
// 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);
|
for ds in &mut self.docs {
|
||||||
self.live_hold(uid, true);
|
ds.history.limit = usize::MAX;
|
||||||
|
}
|
||||||
|
for (uid, _, _, _, _, _) in &before {
|
||||||
|
self.live_hold(*uid, true);
|
||||||
|
}
|
||||||
let result = f(self);
|
let result = f(self);
|
||||||
self.live_hold(uid, false);
|
for (uid, _, _, _, _, _) in &before {
|
||||||
let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) else { return result };
|
self.live_hold(*uid, false);
|
||||||
ds.history.limit = limit;
|
}
|
||||||
ds.history.undo.truncate(saved_len);
|
|
||||||
if result.is_err() {
|
if result.is_err() {
|
||||||
ds.doc = before;
|
self.docs.retain(|ds| before.iter().any(|(uid, _, _, _, _, _)| *uid == ds.uid));
|
||||||
ds.selection = sel;
|
for (uid, doc, selection, page, undo_len, limit) in &before {
|
||||||
ds.page = page;
|
if let Some(ds) = self.docs.iter_mut().find(|ds| ds.uid == *uid) {
|
||||||
} else if !Arc::ptr_eq(&before, &ds.doc) {
|
ds.doc = doc.clone();
|
||||||
self.record(uid, Some(label), before, sel, page);
|
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
|
result
|
||||||
}
|
}
|
||||||
|
|
@ -1347,6 +1380,8 @@ mod arc_handles_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod autosave_tests;
|
mod autosave_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
mod batch_rollback_tests;
|
||||||
|
#[cfg(test)]
|
||||||
mod binding_lists_tests;
|
mod binding_lists_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod booleans_tests;
|
mod booleans_tests;
|
||||||
|
|
@ -1459,6 +1494,8 @@ mod nested_override_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod online_tests;
|
mod online_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
mod open_issues_tests;
|
||||||
|
#[cfg(test)]
|
||||||
mod outline_options_tests;
|
mod outline_options_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod paint_reorder_tests;
|
mod paint_reorder_tests;
|
||||||
|
|
@ -1477,7 +1514,7 @@ mod plugin_directory_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod plugin_panels_tests;
|
mod plugin_panels_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod plugins_tests;
|
pub(crate) mod plugins_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod progressive_blur_tests;
|
mod progressive_blur_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
|
@ -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
|
/// 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.
|
/// the refusal as its output.
|
||||||
#[derive(Clone, Copy, PartialEq)]
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
enum OnRefusal {
|
pub(crate) enum OnRefusal {
|
||||||
Fail,
|
Fail,
|
||||||
Report,
|
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
|
/// 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…).
|
/// (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 data = String::new();
|
||||||
let mut body = String::from("(local.set $sel (call $reply_read (i32.const 60000) (call $selection)))\n");
|
let mut body = String::from("(local.set $sel (call $reply_read (i32.const 60000) (call $selection)))\n");
|
||||||
let mut at = 0usize;
|
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 (layers, undo) = (layer_count(&s), undo_len(&s));
|
||||||
let out = ex(&mut s, "plugin.run", json!({"id": "org.test.tidy"}));
|
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["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!(node(&s, a).name, "Tidy");
|
||||||
assert_eq!(layer_count(&s), layers + 1);
|
assert_eq!(layer_count(&s), layers + 1);
|
||||||
assert_eq!(undo_len(&s), undo + 1, "one undo step for the whole run");
|
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);
|
let a = rect(&mut s, 0.0);
|
||||||
ex(&mut s, "select.set", json!({"ids": [a]}));
|
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 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 = [
|
let cases = [
|
||||||
("org.test.trap", "unreachable", "crashed"),
|
("org.test.trap", "unreachable", "crashed"),
|
||||||
("org.test.loop", "(loop $l (br $l))", "instruction budget"),
|
("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"),
|
("org.test.grow", "(if (i32.eq (memory.grow (i32.const 2000)) (i32.const -1)) (then unreachable))", "crashed"),
|
||||||
];
|
];
|
||||||
for (id, tail, want) in cases {
|
for (id, tail, want) in cases {
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,7 @@ static ROWS: &[Row] = &[
|
||||||
doc(Essential, "Save to version history", &["Alt+Cmd+S"], &["Ctrl+Alt+S"], Bound("file.saveVersion"), ""),
|
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"]),
|
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"),
|
"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, "app.preferences", &["Cmd+,"], &[]),
|
||||||
pin(Essential, "file.new", &["Cmd+N"], &[]).reserved(BROWSER_TAB),
|
pin(Essential, "file.new", &["Cmd+N"], &[]).reserved(BROWSER_TAB),
|
||||||
pin(Essential, "file.open", &["Cmd+O"], &[]),
|
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 reserved = documented.iter().filter(|r| !r.reserved.is_empty()).count();
|
||||||
let pinned = ROWS.len() - documented.len();
|
let pinned = ROWS.len() - documented.len();
|
||||||
// docs/ui-parity.md quotes these.
|
// 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]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,36 @@ fn node_set_and_errors_roll_back() {
|
||||||
assert!(s.execute("no.such", &json!({})).is_err());
|
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]
|
#[test]
|
||||||
fn group_ungroup_and_frame() {
|
fn group_ungroup_and_frame() {
|
||||||
let mut s = sess();
|
let mut s = sess();
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
||||||
|
}
|
||||||
|
|
@ -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 (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 names: Vec<String> = z.file_names().filter(|n| n.starts_with("images/")).map(str::to_string).collect();
|
||||||
let mut images = BTreeMap::new();
|
let mut images = BTreeMap::new();
|
||||||
|
let mut unread = Vec::new();
|
||||||
for name in names {
|
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();
|
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) {
|
match library::read(&mut z, &images, u64::MAX) {
|
||||||
Ok(lib) => doc.published = lib.map(Arc::new),
|
Ok(lib) => doc.published = lib.map(Arc::new),
|
||||||
Err(e) => issues.push(format!("the published library was skipped: {e}")),
|
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 })
|
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
|
/// 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
|
/// most `budget` bytes of snapshots. Damaged entries are skipped and reported in `issues`; nothing
|
||||||
/// here fails the load.
|
/// here fails the load.
|
||||||
|
|
@ -630,6 +672,8 @@ pub fn load_file(path: &std::path::Path) -> Result<(Document, Vec<String>), Form
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod branch_tests;
|
mod branch_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
mod image_tests;
|
||||||
|
#[cfg(test)]
|
||||||
mod large_tests;
|
mod large_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod version_tests;
|
mod version_tests;
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,8 @@ const IMPORTS: &[(&str, usize, usize)] = &[
|
||||||
/// What a run may use.
|
/// What a run may use.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub struct Limits {
|
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 fuel: u64,
|
||||||
pub call_fuel: u64,
|
pub call_fuel: u64,
|
||||||
/// Linear memory cap in bytes.
|
/// 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());
|
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()));
|
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`")));
|
return Err(PluginError::InvalidModule(format!("it doesn't export `{ENTRY}(input_len: i32) -> i32`")));
|
||||||
}
|
}
|
||||||
Ok(module)
|
Ok(module)
|
||||||
|
|
@ -399,11 +400,7 @@ fn ask(caller: &mut Ctx<'_>, request: Request) -> wasmi::Error {
|
||||||
/// Charge a host call: fuel and the call count.
|
/// Charge a host call: fuel and the call count.
|
||||||
fn charge(caller: &mut Ctx<'_>) -> Result<(), wasmi::Error> {
|
fn charge(caller: &mut Ctx<'_>) -> Result<(), wasmi::Error> {
|
||||||
let cost = caller.data().limits.call_fuel;
|
let cost = caller.data().limits.call_fuel;
|
||||||
let left = caller.get_fuel()?;
|
spend_fuel(caller, cost)?;
|
||||||
if left < cost {
|
|
||||||
return Err(abort(caller, PluginError::OutOfFuel));
|
|
||||||
}
|
|
||||||
caller.set_fuel(left - cost)?;
|
|
||||||
let st = caller.data_mut();
|
let st = caller.data_mut();
|
||||||
st.out.calls = st.out.calls.saturating_add(1);
|
st.out.calls = st.out.calls.saturating_add(1);
|
||||||
if st.out.calls > st.limits.max_calls {
|
if st.out.calls > st.limits.max_calls {
|
||||||
|
|
@ -413,12 +410,21 @@ fn charge(caller: &mut Ctx<'_>) -> Result<(), wasmi::Error> {
|
||||||
Ok(())
|
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.
|
/// 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> {
|
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);
|
let (ptr, len) = (ptr as u32 as usize, len as u32 as usize);
|
||||||
if len > caller.data().limits.max_message {
|
if len > caller.data().limits.max_message {
|
||||||
return Err(abort(caller, PluginError::Limit(format!("a {len}-byte string is too long"))));
|
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 {
|
let Some(mem) = caller.get_export("memory").and_then(Extern::into_memory) else {
|
||||||
return Err(abort(caller, PluginError::InvalidModule("no memory".into())));
|
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))
|
Err(ask(&mut c, Request::Selection))
|
||||||
})
|
})
|
||||||
.map_err(err)?;
|
.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> {
|
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 (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 {
|
let Some(mem) = c.get_export("memory").and_then(Extern::into_memory) else {
|
||||||
return Err(abort(&mut c, PluginError::InvalidModule("no memory".into())));
|
return Err(abort(&mut c, PluginError::InvalidModule("no memory".into())));
|
||||||
};
|
};
|
||||||
let (data, st) = mem.data_and_store_mut(&mut c);
|
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 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)?;
|
let dst = ptr.checked_add(n).and_then(|end| data.get_mut(ptr..end)).ok_or(TrapCode::MemoryOutOfBounds)?;
|
||||||
dst.copy_from_slice(src);
|
dst.copy_from_slice(src);
|
||||||
|
|
@ -500,6 +512,7 @@ fn linker(engine: &Engine) -> Result<Linker<State>, PluginError> {
|
||||||
})
|
})
|
||||||
.map_err(err)?;
|
.map_err(err)?;
|
||||||
l.func_wrap(IMPORT_MODULE, "set_output", |mut c: Ctx<'_>, ptr: i32, len: i32| -> Result<(), wasmi::Error> {
|
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)?;
|
let out = read_str(&mut c, ptr, len)?;
|
||||||
c.data_mut().out.output = out;
|
c.data_mut().out.output = out;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@ fn echoes_input_calls_commands_and_reads_replies() {
|
||||||
assert_eq!(host.logs, out.logs);
|
assert_eq!(host.logs, out.logs);
|
||||||
assert_eq!(host.calls, vec![("shape.rectangle".into(), r#"{"x":1}"#.into())]);
|
assert_eq!(host.calls, vec![("shape.rectangle".into(), r#"{"x":1}"#.into())]);
|
||||||
assert_eq!(host.notes, vec!["made it".to_string()]);
|
assert_eq!(host.notes, vec!["made it".to_string()]);
|
||||||
assert_eq!(out.calls, 3);
|
assert_eq!(out.calls, 7);
|
||||||
assert!(out.fuel > 0);
|
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)))
|
(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)",
|
(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!(matches!(result, Err(PluginError::Limit(_))), "logs over 1 MiB must fail the run");
|
||||||
assert_eq!(host.logs.len(), 128);
|
assert_eq!(host.logs.len(), 128);
|
||||||
assert_eq!(host.logs.iter().map(String::len).sum::<usize>(), 1024 * 1024);
|
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());
|
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]
|
#[test]
|
||||||
fn memory_is_capped() {
|
fn memory_is_capped() {
|
||||||
// Declared larger than the cap: refused at instantiation.
|
// Declared larger than the cap: refused at instantiation.
|
||||||
|
|
|
||||||
|
|
@ -133,3 +133,23 @@ fn one_sided_widths_paint_one_side() {
|
||||||
let b = d.world_render_bounds(l).unwrap();
|
let b = d.world_render_bounds(l).unwrap();
|
||||||
assert!(b.y1 >= 120.0 - 1e-6, "{b:?}");
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
/// The geometry a node fills, in local space: shapes, booleans (computed from children) and text
|
||||||
/// (glyph outlines). Groups and pages have none.
|
/// (glyph outlines). Groups and pages have none.
|
||||||
pub fn node_outline(doc: &Document, n: &Node) -> Option<(BezPath, FillRule)> {
|
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 {
|
match &n.kind {
|
||||||
NodeKind::BooleanOp { op } => {
|
NodeKind::BooleanOp { op } => {
|
||||||
let operands: Vec<(BezPath, figcraft_doc::BoolOp)> = n
|
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_map(|c| doc.node(*c))
|
||||||
.filter(|c| c.visible)
|
.filter(|c| c.visible)
|
||||||
.filter_map(|c| {
|
.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);
|
p.apply_affine(c.transform);
|
||||||
Some((p, c.bool_op.unwrap_or(*op)))
|
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))
|
Some((out, FillRule::Winding))
|
||||||
}
|
}
|
||||||
_ => node_outline(doc, n),
|
_ => node_outline_at(doc, n, depth),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,4 +17,5 @@ figcraft-text = { workspace = true }
|
||||||
figcraft-pathops = { workspace = true }
|
figcraft-pathops = { workspace = true }
|
||||||
usvg = { workspace = true }
|
usvg = { workspace = true }
|
||||||
base64 = { workspace = true }
|
base64 = { workspace = true }
|
||||||
|
flate2 = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
|
@ -16,11 +16,14 @@
|
||||||
//! What can't be represented (filters other than shadows and blurs, rotated repeating patterns,
|
//! 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.
|
//! 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 fx;
|
||||||
mod paint;
|
mod paint;
|
||||||
mod shape;
|
mod shape;
|
||||||
mod text;
|
mod text;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod budget_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod fx_tests;
|
mod fx_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -592,10 +595,10 @@ pub(crate) fn nesting(data: &[u8], limit: usize) -> usize {
|
||||||
max
|
max
|
||||||
}
|
}
|
||||||
|
|
||||||
fn options(data: &[u8]) -> usvg::Options<'static> {
|
fn options(data: &[u8], limits: budget::Limits) -> usvg::Options<'static> {
|
||||||
let mut o = usvg::Options::default();
|
// Only data URIs, within budgets: an opened or pasted SVG never pulls other files off the disk
|
||||||
// Only data URIs: an opened or pasted SVG never pulls other files off the disk.
|
// and never expands without limit.
|
||||||
o.image_href_resolver.resolve_string = Box::new(|_, _| None);
|
let mut o = usvg::Options { image_href_resolver: budget::resolver(limits), ..Default::default() };
|
||||||
if data.windows(4).any(|w| w == b"text") {
|
if data.windows(4).any(|w| w == b"text") {
|
||||||
text::prepare(&mut o);
|
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.
|
/// [`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> {
|
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")));
|
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 size = tree.size();
|
||||||
let frame =
|
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()))?;
|
doc.create(parent, NodeKind::Frame, name, x, y, f64::from(size.width()), f64::from(size.height())).map_err(|e| ImportError(e.to_string()))?;
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ fn main() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !ok {
|
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");
|
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> {
|
fn craft_fonts(dir: &std::path::Path, manifest_rel: &str) -> Result<String, String> {
|
||||||
let manifest = dir.join(manifest_rel);
|
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 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 wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32");
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
|
|
|
||||||
|
|
@ -211,7 +211,7 @@ status = "draft — needs native review"
|
||||||
"Changes on main" = "Änderungen in der Hauptdatei"
|
"Changes on main" = "Änderungen in der Hauptdatei"
|
||||||
"Changes on the branch" = "Änderungen im Branch"
|
"Changes on the branch" = "Änderungen im Branch"
|
||||||
"Changes unknown" = "Änderungen unbekannt"
|
"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 another profile to change it." = "Wählen Sie ein anderes Profil, um es zu ändern."
|
||||||
"Choose collection" = "Sammlung wählen"
|
"Choose collection" = "Sammlung wählen"
|
||||||
"Choose image" = "Bild wählen"
|
"Choose image" = "Bild wählen"
|
||||||
|
|
@ -2034,6 +2034,7 @@ status = "draft — needs native review"
|
||||||
"view.prevPage" = "Vorherige Seite"
|
"view.prevPage" = "Vorherige Seite"
|
||||||
"view.previousRegion" = "Vorherigen Bereich fokussieren"
|
"view.previousRegion" = "Vorherigen Bereich fokussieren"
|
||||||
"view.proofSrgb" = "Softproof sRGB"
|
"view.proofSrgb" = "Softproof sRGB"
|
||||||
|
"view.fullscreen" = "Vollbild"
|
||||||
"view.prototype" = "Bereich Prototyp"
|
"view.prototype" = "Bereich Prototyp"
|
||||||
"view.rulers" = "Lineale"
|
"view.rulers" = "Lineale"
|
||||||
"view.set" = "Ansicht festlegen"
|
"view.set" = "Ansicht festlegen"
|
||||||
|
|
|
||||||
|
|
@ -216,7 +216,7 @@ name = "English"
|
||||||
"Changes on main" = "Changes on main"
|
"Changes on main" = "Changes on main"
|
||||||
"Changes on the branch" = "Changes on the branch"
|
"Changes on the branch" = "Changes on the branch"
|
||||||
"Changes unknown" = "Changes unknown"
|
"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 another profile to change it." = "Choose another profile to change it."
|
||||||
"Choose collection" = "Choose collection"
|
"Choose collection" = "Choose collection"
|
||||||
"Choose image" = "Choose image"
|
"Choose image" = "Choose image"
|
||||||
|
|
|
||||||
|
|
@ -217,7 +217,7 @@ status = "draft — needs native review"
|
||||||
"Changes on main" = "Cambios en la principal"
|
"Changes on main" = "Cambios en la principal"
|
||||||
"Changes on the branch" = "Cambios en la rama"
|
"Changes on the branch" = "Cambios en la rama"
|
||||||
"Changes unknown" = "Cambios desconocidos"
|
"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 another profile to change it." = "Elige otro perfil para cambiarlo."
|
||||||
"Choose collection" = "Elegir colección"
|
"Choose collection" = "Elegir colección"
|
||||||
"Choose image" = "Elegir imagen"
|
"Choose image" = "Elegir imagen"
|
||||||
|
|
@ -2087,6 +2087,7 @@ status = "draft — needs native review"
|
||||||
"view.prevPage" = "Página anterior"
|
"view.prevPage" = "Página anterior"
|
||||||
"view.previousRegion" = "Enfocar la región anterior"
|
"view.previousRegion" = "Enfocar la región anterior"
|
||||||
"view.proofSrgb" = "Prueba en pantalla sRGB"
|
"view.proofSrgb" = "Prueba en pantalla sRGB"
|
||||||
|
"view.fullscreen" = "Pantalla completa"
|
||||||
"view.prototype" = "Panel de prototipo"
|
"view.prototype" = "Panel de prototipo"
|
||||||
"view.rulers" = "Reglas"
|
"view.rulers" = "Reglas"
|
||||||
"view.set" = "Establecer vista"
|
"view.set" = "Establecer vista"
|
||||||
|
|
|
||||||
|
|
@ -211,7 +211,7 @@ status = "draft — needs native review"
|
||||||
"Changes on main" = "Modifications dans le fichier principal"
|
"Changes on main" = "Modifications dans le fichier principal"
|
||||||
"Changes on the branch" = "Modifications dans la branche"
|
"Changes on the branch" = "Modifications dans la branche"
|
||||||
"Changes unknown" = "Modifications inconnues"
|
"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 another profile to change it." = "Choisissez un autre profil pour le changer."
|
||||||
"Choose collection" = "Choisir une collection"
|
"Choose collection" = "Choisir une collection"
|
||||||
"Choose image" = "Choisir une image"
|
"Choose image" = "Choisir une image"
|
||||||
|
|
@ -2034,6 +2034,7 @@ status = "draft — needs native review"
|
||||||
"view.prevPage" = "Page précédente"
|
"view.prevPage" = "Page précédente"
|
||||||
"view.previousRegion" = "Activer la région précédente"
|
"view.previousRegion" = "Activer la région précédente"
|
||||||
"view.proofSrgb" = "Épreuve écran sRGB"
|
"view.proofSrgb" = "Épreuve écran sRGB"
|
||||||
|
"view.fullscreen" = "Plein écran"
|
||||||
"view.prototype" = "Panneau Prototype"
|
"view.prototype" = "Panneau Prototype"
|
||||||
"view.rulers" = "Règles"
|
"view.rulers" = "Règles"
|
||||||
"view.set" = "Définir l’affichage"
|
"view.set" = "Définir l’affichage"
|
||||||
|
|
|
||||||
|
|
@ -213,7 +213,7 @@ status = "draft — needs native review"
|
||||||
"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." = "上で 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 another profile to change it." = "変更するには別のプロファイルを選択。"
|
||||||
"Choose collection" = "コレクションを選択"
|
"Choose collection" = "コレクションを選択"
|
||||||
"Choose image" = "画像を選択"
|
"Choose image" = "画像を選択"
|
||||||
|
|
@ -2036,6 +2036,7 @@ status = "draft — needs native review"
|
||||||
"view.prevPage" = "前のページ"
|
"view.prevPage" = "前のページ"
|
||||||
"view.previousRegion" = "前の領域にフォーカス"
|
"view.previousRegion" = "前の領域にフォーカス"
|
||||||
"view.proofSrgb" = "sRGB でソフトプルーフ"
|
"view.proofSrgb" = "sRGB でソフトプルーフ"
|
||||||
|
"view.fullscreen" = "全画面表示"
|
||||||
"view.prototype" = "プロトタイプパネル"
|
"view.prototype" = "プロトタイプパネル"
|
||||||
"view.rulers" = "定規"
|
"view.rulers" = "定規"
|
||||||
"view.set" = "ビューを設定"
|
"view.set" = "ビューを設定"
|
||||||
|
|
|
||||||
|
|
@ -212,7 +212,7 @@ status = "draft — needs native review"
|
||||||
"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." = "위에서 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 another profile to change it." = "변경하려면 다른 프로필을 선택하세요."
|
||||||
"Choose collection" = "컬렉션 선택"
|
"Choose collection" = "컬렉션 선택"
|
||||||
"Choose image" = "이미지 선택"
|
"Choose image" = "이미지 선택"
|
||||||
|
|
@ -2035,6 +2035,7 @@ status = "draft — needs native review"
|
||||||
"view.prevPage" = "이전 페이지"
|
"view.prevPage" = "이전 페이지"
|
||||||
"view.previousRegion" = "이전 영역에 포커스"
|
"view.previousRegion" = "이전 영역에 포커스"
|
||||||
"view.proofSrgb" = "sRGB 교정 미리 보기"
|
"view.proofSrgb" = "sRGB 교정 미리 보기"
|
||||||
|
"view.fullscreen" = "전체 화면"
|
||||||
"view.prototype" = "프로토타입 패널"
|
"view.prototype" = "프로토타입 패널"
|
||||||
"view.rulers" = "눈금자"
|
"view.rulers" = "눈금자"
|
||||||
"view.set" = "보기 설정"
|
"view.set" = "보기 설정"
|
||||||
|
|
|
||||||
|
|
@ -210,7 +210,7 @@ status = "draft — needs native review"
|
||||||
"Changes on main" = "Alterações no principal"
|
"Changes on main" = "Alterações no principal"
|
||||||
"Changes on the branch" = "Alterações na branch"
|
"Changes on the branch" = "Alterações na branch"
|
||||||
"Changes unknown" = "Alterações desconhecidas"
|
"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 another profile to change it." = "Escolha outro perfil para alterá-lo."
|
||||||
"Choose collection" = "Escolher coleção"
|
"Choose collection" = "Escolher coleção"
|
||||||
"Choose image" = "Escolher imagem"
|
"Choose image" = "Escolher imagem"
|
||||||
|
|
@ -2033,6 +2033,7 @@ status = "draft — needs native review"
|
||||||
"view.prevPage" = "Página anterior"
|
"view.prevPage" = "Página anterior"
|
||||||
"view.previousRegion" = "Focar a região anterior"
|
"view.previousRegion" = "Focar a região anterior"
|
||||||
"view.proofSrgb" = "Prova em tela sRGB"
|
"view.proofSrgb" = "Prova em tela sRGB"
|
||||||
|
"view.fullscreen" = "Tela cheia"
|
||||||
"view.prototype" = "Painel de protótipo"
|
"view.prototype" = "Painel de protótipo"
|
||||||
"view.rulers" = "Réguas"
|
"view.rulers" = "Réguas"
|
||||||
"view.set" = "Definir visualização"
|
"view.set" = "Definir visualização"
|
||||||
|
|
|
||||||
|
|
@ -213,7 +213,7 @@ status = "draft — needs native review"
|
||||||
"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." = "在上方选择 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 another profile to change it." = "选择其他配置文件以更改。"
|
||||||
"Choose collection" = "选择集合"
|
"Choose collection" = "选择集合"
|
||||||
"Choose image" = "选择图片"
|
"Choose image" = "选择图片"
|
||||||
|
|
@ -2036,6 +2036,7 @@ status = "draft — needs native review"
|
||||||
"view.prevPage" = "上一页"
|
"view.prevPage" = "上一页"
|
||||||
"view.previousRegion" = "聚焦上一个区域"
|
"view.previousRegion" = "聚焦上一个区域"
|
||||||
"view.proofSrgb" = "sRGB 软打样"
|
"view.proofSrgb" = "sRGB 软打样"
|
||||||
|
"view.fullscreen" = "全屏"
|
||||||
"view.prototype" = "原型面板"
|
"view.prototype" = "原型面板"
|
||||||
"view.rulers" = "标尺"
|
"view.rulers" = "标尺"
|
||||||
"view.set" = "设置视图"
|
"view.set" = "设置视图"
|
||||||
|
|
|
||||||
|
|
@ -782,8 +782,8 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
||||||
ui.add_space(16.0);
|
ui.add_space(16.0);
|
||||||
widgets::label(ui, tr("On this page"), t.text_secondary);
|
widgets::label(ui, tr("On this page"), t.text_secondary);
|
||||||
});
|
});
|
||||||
ui.horizontal_wrapped(|ui| {
|
// Inset on both sides: `add_space` would only indent the first row of a wrap.
|
||||||
ui.add_space(16.0);
|
inset(ui, |ui| {
|
||||||
for c in &doc_colors {
|
for c in &doc_colors {
|
||||||
if widgets::swatch(ui, &Paint::solid(*c), 14.0).on_hover_text(c.to_hex()).clicked() {
|
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);
|
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);
|
ui.add_space(16.0);
|
||||||
widgets::label(ui, tr("Recent"), t.text_secondary);
|
widgets::label(ui, tr("Recent"), t.text_secondary);
|
||||||
});
|
});
|
||||||
ui.horizontal_wrapped(|ui| {
|
inset(ui, |ui| {
|
||||||
ui.add_space(16.0);
|
|
||||||
for c in recent.iter().take(14) {
|
for c in recent.iter().take(14) {
|
||||||
if widgets::swatch(ui, &Paint::solid(*c), 14.0).clicked() {
|
if widgets::swatch(ui, &Paint::solid(*c), 14.0).clicked() {
|
||||||
set_color(app, &target, &mut paint, *c, sel_stop_id, ui);
|
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;
|
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.
|
/// The colour picker's note under the fields for a colour outside sRGB.
|
||||||
fn gamut_warning(ui: &mut Ui, t: &Tokens) {
|
fn gamut_warning(ui: &mut Ui, t: &Tokens) {
|
||||||
use crate::a11y::Tip;
|
use crate::a11y::Tip;
|
||||||
|
|
|
||||||
|
|
@ -396,7 +396,7 @@ pub fn connect_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId
|
||||||
let text = if connectable {
|
let text = if connectable {
|
||||||
tr("Link this component to its code component: instances then show your real snippet with their values.")
|
tr("Link this component to its code component: instances then show your real snippet with their values.")
|
||||||
} else {
|
} 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);
|
widgets::note(ui, text, t.text_secondary);
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,8 @@ mod live_text_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod online_tests;
|
mod online_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
mod open_issues_tests;
|
||||||
|
#[cfg(test)]
|
||||||
mod panel_reorder_tests;
|
mod panel_reorder_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod panel_width_tests;
|
mod panel_width_tests;
|
||||||
|
|
@ -735,7 +737,7 @@ impl FigApp {
|
||||||
"dialog.unsaved" => self.toast(i18n::tr("This file has unsaved changes")),
|
"dialog.unsaved" => self.toast(i18n::tr("This file has unsaved changes")),
|
||||||
"dialog.plugins" => self.ui.plugins_window = true,
|
"dialog.plugins" => self.ui.plugins_window = true,
|
||||||
"dialog.cursorChat" => live::open_chat(self),
|
"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),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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();
|
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.
|
// Pixels or rem (of a 16 px root) for the web languages.
|
||||||
let rem_id = Id::new("inspect_rem");
|
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);
|
let rem = web && ui.data(|d| d.get_temp::<bool>(rem_id)).unwrap_or(false);
|
||||||
row(ui, 32.0, |ui| {
|
row(ui, 32.0, |ui| {
|
||||||
let resp = widgets::dropdown(ui, lang.label(), 120.0, Some(Icon::Code));
|
let resp = widgets::dropdown(ui, lang.label(), 120.0, Some(Icon::Code));
|
||||||
|
|
|
||||||
|
|
@ -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);
|
press(&mut u, Key::Enter, Modifiers::NONE);
|
||||||
assert!(u.app.session.ds().unwrap().vector_edit.is_some());
|
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![]);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
pub(crate) fn requests(app: &mut FigApp, ctx: &Context) {
|
||||||
for r in std::mem::take(&mut app.window_requests) {
|
for r in std::mem::take(&mut app.window_requests) {
|
||||||
let n = |k: &str| r.data.get(k).and_then(Value::as_u64);
|
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" {
|
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(|_| ())
|
open(app, ctx, n("index").map(|i| i as usize), r.data.get("path").and_then(Value::as_str), None).map(|_| ())
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,13 @@ inside generated layer trees. Remaining: 0–1 h.
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Dev Mode toggle (Shift+D), Inspect panel | done | |
|
| 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) |
|
| 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 |
|
| 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 | |
|
| Annotations: pinned properties, categories, coloured callouts, filter | done | |
|
||||||
| Ready for dev list, statuses, "Changed" after edits | done | |
|
| Ready for dev list, statuses, "Changed" after edits | done | |
|
||||||
| Focus view (one frame, previous / next) | 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
||||||
|
|
|
||||||
40
docs/mcp.md
40
docs/mcp.md
|
|
@ -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
|
Build the CLI first (`cargo build --release -p figcraft-cli`) and use the absolute path to
|
||||||
`target/release/figcraft-cli`.
|
`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
|
### Claude Code
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|
@ -215,8 +218,8 @@ Or in a project's `.mcp.json`:
|
||||||
| Tool | What it does |
|
| Tool | What it does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `list_commands {filter?}` | Every command: id, label, menu, shortcut, undoable, params doc. |
|
| `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)). |
|
| `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. |
|
| `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}`. |
|
| `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. |
|
| `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). |
|
| `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
|
### Dev Mode
|
||||||
|
|
||||||
- **Component code:** `inspect.code {id, language: react|swiftui|compose}` on a component, a
|
- **Component code:** `inspect.code {id, language: react|leptos|swiftui|compose}` on a component,
|
||||||
component set or a variant writes the component as a reusable unit: React (TSX) with a props
|
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
|
interface, Leptos with a `#[component]` function (`#[prop]` defaults, `view!` markup), SwiftUI
|
||||||
properties are the parameters, with their defaults: variant properties become string unions
|
with a `View` struct, Compose with a `@Composable` function. Its component properties are the
|
||||||
(React) or enums, booleans become flags around the layers they show, text properties strings,
|
parameters, with their defaults: variant properties become string unions (React) or enums,
|
||||||
instance swaps views (`React.ReactNode`, `AnyView`, a composable lambda) and slots optional
|
booleans become flags around the layers they show, text properties strings, instance swaps views
|
||||||
content. Every variant of a set shares one definition; in React the other variants' differences
|
(`React.ReactNode`, Leptos `ViewFn`, `AnyView`, a composable lambda) and slots optional content.
|
||||||
become CSS rules on `data-*` attributes. On an instance (or a layer tree containing instances)
|
Every variant of a set shares one definition; in React and Leptos the other variants'
|
||||||
each instance is a call passing only the values it changes, e.g. `<Button state="Hover"
|
differences become CSS rules on `data-*` attributes. On an instance (or a layer tree containing
|
||||||
label="Buy" />`, `Button(state: .hover, label: "Buy")`, `Button(state = ButtonState.Hover, label =
|
instances) each instance is a call passing only the values it changes, e.g. `<Button
|
||||||
"Buy")`. Names are escaped and unique (keywords get a suffix, `default` → `defaultValue`; names
|
state="Hover" label="Buy" />`, `<Button state=ButtonState::Hover label="Buy" />`, `Button(state:
|
||||||
that would shadow framework types get one too, `Text` → `TextComponent`). `inspect.componentCode
|
.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,
|
{id?, language?}` returns the parts: `{component, name, set, properties: [{name, param, type,
|
||||||
default, value? (instances)}], definition, usage? (instances)}`.
|
default, value? (instances)}], definition, usage? (instances)}`.
|
||||||
- **Code connect:** link a component (a variant links its set) to the code component it is built
|
- **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?, 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
|
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
|
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
|
for the language (React and Leptos keep the markup's braces, so `size={size}` gives
|
||||||
`disabled={disabled}` gives `disabled={true}`), `{property ? "a" : "b"}` inserts one of two
|
`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
|
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
|
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
|
snippet instead of generated code (`connected: true`), connected instances inside a generated
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
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
|
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
|
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
|
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.
|
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)
|
## ABI (version 3)
|
||||||
|
|
||||||
Version 2 adds panels and settings to version 1, and version 3 adds widgets; a version 1 or 2
|
Version 2 adds panels and settings to version 1, and version 3 adds widgets; a version 1 or 2
|
||||||
|
|
|
||||||
|
|
@ -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
|
`.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
|
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
|
`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-bundle.sh` repackages the tarball into a `.flatpak` with
|
||||||
`flatpak/ai.storyteller.figcraft.bundle.yml` (freedesktop 26.08). `flatpak/ai.storyteller.figcraft.yml`
|
`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
|
is the from-source manifest for a Flathub submission (not built in CI; vendor crates with
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
| 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 |
|
| 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 |
|
| 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
|
- **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
|
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 |
|
| 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 | 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 | 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 | 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 |
|
| 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 |
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@
|
||||||
# (and whenever the AppImage's path changes) install the .desktop entry and the
|
# (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
|
# hicolor icons into $XDG_DATA_HOME, then launch. Every failure here must leave
|
||||||
# the launch untouched. Opt out with FIGCRAFT_NO_DESKTOP_INTEGRATION=1.
|
# 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
|
set -u
|
||||||
|
|
||||||
|
|
@ -68,5 +71,17 @@ integrate() {
|
||||||
done
|
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
|
integrate
|
||||||
exec "$APPDIR/usr/bin/figcraft" "$@"
|
exec "$APPDIR/usr/bin/figcraft" "$@"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Smoke test for packaging/linux/AppRun: the AppImage's desktop integration writes
|
# 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 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).
|
# Runs in seconds with no build (CI: .github/workflows/packaging-lint.yml).
|
||||||
set -euo pipefail
|
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"
|
[ "$out" = "stub: x" ] || fail "odd path: $out"
|
||||||
[ ! -e "$XDG_DATA_HOME" ] || fail "integrated an AppImage path that needs escaping"
|
[ ! -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"
|
echo "AppRun desktop integration ok"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue