Compare commits
24 Commits
feat/plugi
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
5e90592e80 | |
|
|
ef2c6cb30e | |
|
|
24c5617230 | |
|
|
fb8ace5bf4 | |
|
|
ce84d1f27f | |
|
|
8e12e5eeda | |
|
|
118ca7c60b | |
|
|
d61ccc633a | |
|
|
badcb6ec21 | |
|
|
7a9a67a8c3 | |
|
|
dd3d4d326c | |
|
|
6d735653a2 | |
|
|
ac65168417 | |
|
|
f7e726714a | |
|
|
7d589c4456 | |
|
|
79a0e58a3c | |
|
|
db55720c93 | |
|
|
0efbd55564 | |
|
|
5b52eeeb36 | |
|
|
8b7435890e | |
|
|
33dcb1aef6 | |
|
|
e8d7dc24f2 | |
|
|
d30255bf62 | |
|
|
01904ce2fd |
|
|
@ -1667,6 +1667,8 @@ dependencies = [
|
|||
"figcraft-pathops",
|
||||
"figcraft-render",
|
||||
"figcraft-text",
|
||||
"flate2",
|
||||
"thiserror 2.0.21",
|
||||
"usvg",
|
||||
]
|
||||
|
||||
|
|
@ -3923,9 +3925,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "ruzstd"
|
||||
version = "0.9.0"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a252f5e20f038fe7b4ea53e073e65398d652c864cc162fc77c56c2f13717b888"
|
||||
checksum = "69e4ce4a039f42bf25920b95667e4d789e98749ab11bbb8d2c2936f788421bb5"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ linesweeper = "0.4"
|
|||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
# `.fig` import: raw deflate chunks (already built for zip) and zstd chunks (pure Rust, MIT, wasm-safe).
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
ruzstd = { version = "0.9", default-features = false, features = ["std"] }
|
||||
ruzstd = { version = "0.9.1", default-features = false, features = ["std"] }
|
||||
sha2 = "0.10"
|
||||
base64 = "0.22"
|
||||
crc32fast = "1"
|
||||
|
|
|
|||
|
|
@ -191,7 +191,7 @@ exposes to agents, and exported at 2x by FigCraft's own renderer. The source fil
|
|||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<h3>Prototype and hand off</h3>
|
||||
Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like <code>count + 1</code>), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, SwiftUI, Compose) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
|
||||
Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like <code>count + 1</code>), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, Leptos, SwiftUI, Compose) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
|
@ -251,6 +251,8 @@ Fonts for the UI and the Text tool come from [craft-fonts](https://github.com/st
|
|||
git clone https://github.com/storytold/craft-fonts ../craft-fonts
|
||||
```
|
||||
|
||||
If you clone it after a first build, run `cargo clean -p figcraft-text` once so the next build embeds the fonts.
|
||||
|
||||
**Start from a template.** The home screen's Templates tab has eight starter files (a mobile app, a landing page, a dashboard, a design system starter, a wireframe kit, social post sizes, presentation slides and an icon set starter), also under File ▸ New from template…. They are built in code from original, synthetic content, so scripts and agents can open them too: `file.newFromTemplate {template}` ([`docs/mcp.md`](docs/mcp.md#starter-templates)).
|
||||
|
||||
For the web build, install [trunk](https://trunkrs.dev/) and run `cargo xtask wasm`.
|
||||
|
|
|
|||
12
ROADMAP.md
12
ROADMAP.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
|
||||
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
|
||||
|
|
@ -131,6 +131,11 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
|||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-14** · SVG/PDF export depth (M0.9, #208): visible descendants deeper than 256 levels below an exported root are refused before bounds or drawing, including masks and raster fallbacks. SVG has a checked API; the engine and CLI report the limit and suggest selecting an inner layer. Existing destination files survive refusal. Boundary, inner-root, hidden-subtree, multi-root/page and command/CLI regressions cover the change. Numbers unchanged.
|
||||
- **2026-10-13** · MCP/control transport hardening (#201): 32 MiB incoming frames are bounded while reading; oversized frames return an error and close without draining. The opt-in loopback control listener caps active workers at 8, applies 60 s socket read/write inactivity timeouts and caps both request queues at 8, discarding timed-out requests including those deferred to hidden windows. Reader, queue, idle-client and blocked-writer regressions cover the limits. Numbers unchanged.
|
||||
- **2026-10-13** · Attached MCP (M0.9, #194): a transport failure no longer replays a request that may already have edited the document. The caller gets an outcome-unknown error and must inspect before retrying; the next explicit call reconnects. Loopback tests cover a lost mutation reply, a single applied edit, reconnect and a connection failure before sending. Numbers unchanged.
|
||||
- **2026-10-13** · Constrain proportions in the Layout panel (M1, #220): with the W/H link on, typing a width or height scales the other side by the layer's own ratio; the property edit dropped the ratio, so the link only worked while dragging resize handles. Both sides in one edit stay as typed, the `w`/`h` aliases work the same, each layer of a multi-selection keeps its own ratio, and a zero side has no ratio to keep. Regression test in the transform behaviour suite. Numbers unchanged.
|
||||
- **2026-10-13** · Repeat last command (M1, #181): repeated edits keep the underlying command and parameters, so repeated nudges accumulate with one undo step each. A stored self-reference is refused instead of recursing. Regression tests cover three repeats, their undo steps, and missing or self-referencing commands. Numbers unchanged.
|
||||
- **2026-10-13** · Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged.
|
||||
- **2026-10-13** · 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** · Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged.
|
||||
|
|
@ -305,6 +310,11 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | minor | SVG/PDF export depth (#208): explicit preflight refusal beyond 256 descendant levels, propagated through public APIs, commands and CLI, with boundary/content and destination-preservation regressions. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | MCP/control transport hardening (#201): frame limits during reads, bounded connections and both UI request queues, socket inactivity timeouts; percentages and estimates unchanged |
|
||||
| 2026-10-13 | minor | Plugin log limits (#191): cap each UTF-8 line at 8 KiB, total log bytes at 1 MiB and lines at 1,000 per run/callback before copying guest memory or forwarding to the host. Over-budget runs fail and roll back document edits; callbacks get fresh budgets. Added sandbox boundary/forwarding tests and an engine rollback regression. Percentages and hours unchanged. |
|
||||
| 2026-10-13 | minor | Live-session revert guard (M9, #198): `file.revert` is refused while its document belongs to a live session, including publishing, reconnecting and hosting before the welcome. The document, previews, branch tabs and session stay intact; leave the session before reverting a local save. Offline revert is unchanged. Scripted access and sync regressions in `live_access_tests`. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | Repeat last command (M1, #181): keep the underlying command and parameters across repeats, reject self-reference, and retain one undo step per repeated edit. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged. |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
//! Windows: give the main thread the stack Linux and macOS give it (see `main_thread_stack`).
|
||||
fn main() {
|
||||
main_thread_stack();
|
||||
}
|
||||
|
||||
/// Windows starts the main thread with 1 MB of stack, Linux and macOS with 8 MB. Layers nest up
|
||||
/// to 256 deep in an export, and that recursion needs the same room everywhere.
|
||||
fn main_thread_stack() {
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
|
||||
return;
|
||||
}
|
||||
let msvc = std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc");
|
||||
println!("cargo::rustc-link-arg-bins={}", if msvc { "/STACK:8388608" } else { "-Wl,--stack,8388608" });
|
||||
}
|
||||
|
|
@ -102,8 +102,8 @@ pub fn write_doc(doc: &Document, page: NodeId, out: &str) -> CliResult<()> {
|
|||
let v = figcraft_format::to_json(doc).map_err(|e| CliError::Failed(e.to_string()))?;
|
||||
serde_json::to_vec_pretty(&v).map_err(|e| CliError::Failed(e.to_string()))?
|
||||
}
|
||||
"svg" => figcraft_svg::export_svg(&doc.in_srgb(), &children(doc, page), &Default::default())
|
||||
.ok_or_else(|| CliError::Failed("the page is empty: nothing to export".into()))?
|
||||
"svg" => figcraft_svg::try_export_svg(&doc.in_srgb(), &children(doc, page), &Default::default())
|
||||
.map_err(|e| CliError::Failed(e.to_string()))?
|
||||
.into_bytes(),
|
||||
"png" | "jpg" | "jpeg" | "webp" => encode(doc, &render_page(doc, page, 1.0)?, &e)?,
|
||||
"pdf" => page_pdf(doc, page, &PrintSettings::default())?,
|
||||
|
|
@ -379,4 +379,31 @@ mod tests {
|
|||
assert_eq!(sanitize("../.."), "_");
|
||||
assert_eq!(sanitize(""), "layer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_depth_errors_preserve_conversion_destinations() {
|
||||
let mut doc = Document::new("export depth");
|
||||
let page = doc.first_page().unwrap();
|
||||
let root = doc.create(page, figcraft_doc::NodeKind::Frame, "root", 0.0, 0.0, 32.0, 32.0).unwrap();
|
||||
let mut parent = root;
|
||||
for _ in 1..257 {
|
||||
parent = doc.create(parent, figcraft_doc::NodeKind::Group, "group", 0.0, 0.0, 32.0, 32.0).unwrap();
|
||||
}
|
||||
doc.create(parent, figcraft_doc::NodeKind::Rectangle, "leaf", 0.0, 0.0, 8.0, 8.0).unwrap();
|
||||
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-cli-export-depth-{}-{stamp}", std::process::id()));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
for ext in ["svg", "pdf"] {
|
||||
let path = dir.join(format!("previous.{ext}"));
|
||||
std::fs::write(&path, b"previous export").unwrap();
|
||||
let CliError::Failed(error) = write_doc(&doc, page, path.to_str().unwrap()).unwrap_err() else {
|
||||
panic!("depth overflow must be an operation failure");
|
||||
};
|
||||
assert!(error.contains("maximum export depth of 256"), "{ext}: {error}");
|
||||
assert!(error.contains("select an inner layer"), "{ext}: {error}");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"previous export");
|
||||
}
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
//! Depth refusal through the actual CLI, including its exit status and destination writes.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use figcraft_doc::color::{Color, Paint};
|
||||
use figcraft_doc::{Document, NodeKind};
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn export_depth_cli_refuses_partial_files_and_accepts_an_inner_root() {
|
||||
let mut doc = Document::new("export depth");
|
||||
let root = doc.create(doc.first_page().unwrap(), NodeKind::Frame, "root", 0.0, 0.0, 32.0, 32.0).unwrap();
|
||||
let mut parent = root;
|
||||
for depth in 1..257 {
|
||||
let name = if depth == 1 { "inner-root" } else { "group" };
|
||||
parent = doc.create(parent, NodeKind::Group, name, 0.0, 0.0, 32.0, 32.0).unwrap();
|
||||
}
|
||||
let leaf = doc.create(parent, NodeKind::Rectangle, "leaf", 3.0, 5.0, 13.0, 17.0).unwrap();
|
||||
doc.get_mut(leaf).unwrap().fills = vec![Paint::solid(Color::from_hex("FF0000").unwrap())];
|
||||
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-cli-depth-process-{}-{stamp}", std::process::id()));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
let input = dir.join("deep.figcraft");
|
||||
std::fs::write(&input, figcraft_format::save_bytes(&doc, None).unwrap()).unwrap();
|
||||
for ext in ["svg", "pdf"] {
|
||||
let path = dir.join(format!("previous.{ext}"));
|
||||
std::fs::write(&path, b"previous export").unwrap();
|
||||
for verb in ["convert", "export"] {
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_figcraft-cli"));
|
||||
command.arg(verb).arg(&input);
|
||||
if verb == "export" {
|
||||
command.arg("-o");
|
||||
}
|
||||
let output = command.arg(&path).arg("--no-system-fonts").output().unwrap();
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
assert_eq!(output.status.code(), Some(1), "{verb} {ext}: {error}");
|
||||
assert!(error.contains("maximum export depth of 256"), "{verb} {ext}: {error}");
|
||||
assert!(error.contains("select an inner layer"), "{verb} {ext}: {error}");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"previous export");
|
||||
}
|
||||
let inner = dir.join(format!("inner.{ext}"));
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_figcraft-cli"))
|
||||
.arg("export")
|
||||
.arg(&input)
|
||||
.arg("-o")
|
||||
.arg(&inner)
|
||||
.args(["--node", "inner-root", "--no-system-fonts"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
let bytes = std::fs::read(inner).unwrap();
|
||||
if ext == "svg" {
|
||||
assert!(String::from_utf8_lossy(&bytes).contains("#FF0000"));
|
||||
} else {
|
||||
assert!(bytes.starts_with(b"%PDF"));
|
||||
}
|
||||
}
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 5, "no staging files after refusal");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
|
@ -235,6 +235,21 @@ fn a_file_cant_grow_past_its_cap_through_batches() {
|
|||
// Its snapshot holds the document and the recent batches kept for reconnecting clients (at
|
||||
// most a quarter of the cap), and the last batch let in may finish what the cap allows.
|
||||
assert!(on_disk <= max + max / 4 + 210_000, "{on_disk} bytes on disk");
|
||||
drop(raw);
|
||||
server.stop();
|
||||
// Everything confirmed opens again with the same settings, after compacting on the way out.
|
||||
let kept = client.document().nodes().count();
|
||||
let mut server = crate::start(&cfg).unwrap();
|
||||
let mut raw = Raw::connect(server.addr());
|
||||
raw.send(&Client::hello(&file.id, &edit.token, "Grower", ""));
|
||||
let welcome = loop {
|
||||
match raw.next() {
|
||||
w @ ServerMsg::Welcome { .. } => break w,
|
||||
ServerMsg::Refused { reason } => panic!("the file doesn't open again: {reason}"),
|
||||
_ => {}
|
||||
}
|
||||
};
|
||||
assert_eq!(Client::join(welcome).unwrap().document().nodes().count(), kept);
|
||||
server.stop();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -426,7 +426,7 @@ impl Store {
|
|||
let meta: Meta = serde_json::from_slice(&disk::read_capped(&path.join(META), 1 << 20)?).map_err(|e| format!("{META} is damaged ({e})"))?;
|
||||
let links: Vec<Link> =
|
||||
serde_json::from_slice(&disk::read_capped(&path.join(LINKS), 16 << 20)?).map_err(|e| format!("{LINKS} is damaged ({e})"))?;
|
||||
let generation = disk::check_snapshot(&disk::read_capped(&path.join(SNAPSHOT), self.cfg.max_file_bytes)?)?;
|
||||
let generation = disk::check_snapshot(&disk::read_capped(&path.join(SNAPSHOT), self.snapshot_cap())?)?;
|
||||
// A damaged log only loses its end; it is reported when the file opens.
|
||||
if let Ok(bytes) = disk::read_capped(&path.join(LOG), self.cfg.max_file_bytes)
|
||||
&& let Ok(log) = disk::read_log(&bytes)
|
||||
|
|
@ -1086,7 +1086,7 @@ impl Store {
|
|||
fn load(&mut self, file: &str) -> Result<Server, String> {
|
||||
self.touch(file);
|
||||
let dir = self.dir(file);
|
||||
let (generation, state) = disk::decode_snapshot(&disk::read_capped(&dir.join(SNAPSHOT), self.cfg.max_file_bytes)?)?;
|
||||
let (generation, state) = disk::decode_snapshot(&disk::read_capped(&dir.join(SNAPSHOT), self.snapshot_cap())?)?;
|
||||
let mut server = Server::restore(state, "", self.cfg.core).map_err(|e| format!("the snapshot can't be used: {e}"))?;
|
||||
let mut issues = Vec::new();
|
||||
let mut fresh_log = true;
|
||||
|
|
@ -1130,11 +1130,22 @@ impl Store {
|
|||
Ok(server)
|
||||
}
|
||||
|
||||
/// The largest snapshot read back: a file's cap plus room for what its state keeps beside the
|
||||
/// document (recent batches for reconnecting clients, deletions), which may take it over.
|
||||
fn snapshot_cap(&self) -> u64 {
|
||||
self.cfg.max_file_bytes.saturating_mul(2)
|
||||
}
|
||||
|
||||
/// Write `server`'s state as snapshot `generation` and start an empty log of it.
|
||||
fn rewrite(&mut self, file: &str, generation: u64, server: &Server) -> Result<Log, String> {
|
||||
let dir = self.dir(file);
|
||||
let state = server.state().map_err(|e| e.to_string())?;
|
||||
let bytes = disk::encode_snapshot(generation, &state)?;
|
||||
// Never replace a readable snapshot with one that can't be opened again: the old snapshot
|
||||
// and log still hold everything.
|
||||
if bytes.len() as u64 > self.snapshot_cap() {
|
||||
return Err(format!("the snapshot would be {} MiB, more than can be opened again", bytes.len() >> 20));
|
||||
}
|
||||
if bytes.len() as u64 > self.cfg.max_file_bytes {
|
||||
log::warn!("file {file} is {} MiB, over the {} MiB cap (kept anyway)", bytes.len() >> 20, self.cfg.max_file_bytes >> 20);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
//! Windows: embed the icon and version info in figcraft.exe (no-op elsewhere).
|
||||
//! Windows: embed the icon and version info in figcraft.exe and size its main thread's stack
|
||||
//! (no-op elsewhere).
|
||||
fn main() {
|
||||
println!("cargo::rerun-if-changed=../../assets/app-icon/figcraft.ico");
|
||||
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
|
||||
|
|
@ -14,6 +15,10 @@ fn main() {
|
|||
if let Err(e) = res.compile() {
|
||||
println!("cargo::warning=winresource: {e}");
|
||||
}
|
||||
// Windows starts the main thread with 1 MB of stack, Linux and macOS with 8 MB. Layers
|
||||
// nest up to 256 deep in an export, and that recursion needs the same room everywhere.
|
||||
let msvc = std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc");
|
||||
println!("cargo::rustc-link-arg-bins={}", if msvc { "/STACK:8388608" } else { "-Wl,--stack,8388608" });
|
||||
}
|
||||
let sha = std::env::var("FIGCRAFT_BUILD_SHA").unwrap_or_default();
|
||||
println!("cargo::rustc-env=FIGCRAFT_BUILD_SHA={sha}");
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
//! Localhost JSON-lines control server: each line `{"id", "method", "params"}` is queued for the UI
|
||||
//! thread; the reply line `{"id", "ok", "result" | "error"}` is written when the frame handled it.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
|
@ -12,6 +12,20 @@ use serde_json::{Value, json};
|
|||
type Queue = Arc<Mutex<Vec<ControlRequest>>>;
|
||||
type CtxSlot = Arc<Mutex<Option<egui::Context>>>;
|
||||
|
||||
const MAX_LINE: usize = 32 * 1024 * 1024;
|
||||
const MAX_CONNECTIONS: usize = 8;
|
||||
const MAX_QUEUED_REQUESTS: usize = 8;
|
||||
const TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Deferred requests for a hidden window must not make the UI's queue grow without a bound.
|
||||
pub fn drain_queue(queue: &Queue, target: &mut Vec<ControlRequest>) {
|
||||
target.retain(|request| !request.is_cancelled());
|
||||
let mut queue = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
queue.retain(|request| !request.is_cancelled());
|
||||
let count = queue.len().min(MAX_QUEUED_REQUESTS.saturating_sub(target.len()));
|
||||
target.extend(queue.drain(..count));
|
||||
}
|
||||
|
||||
pub fn start(port: u16, queue: Queue, ctx: CtxSlot) {
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => l,
|
||||
|
|
@ -23,9 +37,9 @@ pub fn start(port: u16, queue: Queue, ctx: CtxSlot) {
|
|||
};
|
||||
eprintln!("FigCraft control server listening on 127.0.0.1:{port}");
|
||||
let spawned = std::thread::Builder::new().name("figcraft-control".into()).spawn(move || {
|
||||
let mut workers = Vec::new();
|
||||
for stream in listener.incoming().flatten() {
|
||||
let (q, c) = (queue.clone(), ctx.clone());
|
||||
let _ = std::thread::Builder::new().name("figcraft-control-conn".into()).spawn(move || serve(stream, q, c));
|
||||
spawn_connection(stream, queue.clone(), ctx.clone(), &mut workers, TIMEOUT);
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
|
|
@ -33,37 +47,77 @@ pub fn start(port: u16, queue: Queue, ctx: CtxSlot) {
|
|||
}
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, queue: Queue, ctx: CtxSlot) {
|
||||
let Ok(read_half) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
let reader = BufReader::new(read_half);
|
||||
for line in reader.lines() {
|
||||
let Ok(line) = line else { break };
|
||||
fn spawn_connection(stream: TcpStream, queue: Queue, ctx: CtxSlot, workers: &mut Vec<std::thread::JoinHandle<()>>, timeout: Duration) {
|
||||
workers.retain(|worker| !worker.is_finished());
|
||||
if workers.len() >= MAX_CONNECTIONS {
|
||||
return;
|
||||
}
|
||||
match std::thread::Builder::new().name("figcraft-control-conn".into()).spawn(move || {
|
||||
let _ = serve(stream, queue, ctx, timeout);
|
||||
}) {
|
||||
Ok(worker) => workers.push(worker),
|
||||
Err(e) => log::error!("control connection thread: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> std::io::Result<()> {
|
||||
out.set_read_timeout(Some(timeout))?;
|
||||
out.set_write_timeout(Some(timeout))?;
|
||||
let mut reader = BufReader::new(out.try_clone()?);
|
||||
let mut line = Vec::new();
|
||||
loop {
|
||||
line.clear();
|
||||
if (&mut reader).take((MAX_LINE + 1) as u64).read_until(b'\n', &mut line)? == 0 {
|
||||
break;
|
||||
}
|
||||
if line.last() == Some(&b'\n') {
|
||||
line.pop();
|
||||
}
|
||||
if line.len() > MAX_LINE {
|
||||
writeln!(out, "{}", json!({"id": null, "ok": false, "error": "message too large"}))?;
|
||||
break;
|
||||
}
|
||||
let Ok(line) = std::str::from_utf8(&line) else { break };
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(&line) {
|
||||
let reply = match serde_json::from_str::<Value>(line) {
|
||||
Ok(v) => {
|
||||
let id = v.get("id").cloned().unwrap_or(Value::Null);
|
||||
let method = v.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let params = v.get("params").cloned().unwrap_or(json!({}));
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(ControlRequest { id: id.clone(), method, params, reply: tx });
|
||||
let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let mut pending = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
pending.retain(|request| !request.is_cancelled());
|
||||
if pending.len() >= MAX_QUEUED_REQUESTS {
|
||||
drop(pending);
|
||||
writeln!(out, "{}", json!({"id": id, "ok": false, "error": "control request queue is full"}))?;
|
||||
continue;
|
||||
}
|
||||
pending.push(ControlRequest { id: id.clone(), method, params, reply: tx, cancelled: cancelled.clone() });
|
||||
drop(pending);
|
||||
if let Some(c) = ctx.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
|
||||
c.request_repaint();
|
||||
}
|
||||
match rx.recv_timeout(Duration::from_secs(60)) {
|
||||
match rx.recv_timeout(timeout) {
|
||||
Ok(r) => match r.result {
|
||||
Ok(res) => json!({"id": r.id, "ok": true, "result": res}),
|
||||
Err(e) => json!({"id": r.id, "ok": false, "error": e}),
|
||||
},
|
||||
Err(_) => json!({"id": id, "ok": false, "error": "timed out waiting for the UI (is the window visible?)"}),
|
||||
Err(_) => {
|
||||
cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
json!({"id": id, "ok": false, "error": "timed out waiting for the UI (is the window visible?)"})
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => json!({"id": null, "ok": false, "error": format!("bad JSON: {e}")}),
|
||||
};
|
||||
if writeln!(out, "{reply}").is_err() {
|
||||
break;
|
||||
}
|
||||
writeln!(out, "{reply}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "control_server_tests.rs"]
|
||||
mod tests;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,262 @@
|
|||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
use std::io::Read;
|
||||
use std::net::Shutdown;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Instant;
|
||||
|
||||
fn pair() -> (TcpStream, TcpStream) {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let client = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
|
||||
client.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
client.set_write_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
(client, listener.accept().unwrap().0)
|
||||
}
|
||||
|
||||
fn queue() -> Queue {
|
||||
Arc::new(Mutex::new(Vec::new()))
|
||||
}
|
||||
|
||||
fn context() -> CtxSlot {
|
||||
Arc::new(Mutex::new(None))
|
||||
}
|
||||
|
||||
fn reply(reader: &mut BufReader<TcpStream>) -> Value {
|
||||
let mut line = String::new();
|
||||
assert!(reader.read_line(&mut line).unwrap() > 0);
|
||||
serde_json::from_str(&line).unwrap()
|
||||
}
|
||||
|
||||
fn request() -> ControlRequest {
|
||||
ControlRequest { id: json!(0), method: "ui.inspect".into(), params: json!({}), reply: mpsc::channel().0, cancelled: Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_an_oversized_unterminated_frame_without_dispatch() {
|
||||
let (mut client, server) = pair();
|
||||
let pending = queue();
|
||||
let q = pending.clone();
|
||||
let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));
|
||||
// Generate bytes as they are written; no giant input string and no terminating newline.
|
||||
std::io::copy(&mut std::io::repeat(b' ').take((MAX_LINE + 1) as u64), &mut client).unwrap();
|
||||
let mut reader = BufReader::new(client);
|
||||
assert_eq!(reply(&mut reader), json!({"id": null, "ok": false, "error": "message too large"}));
|
||||
assert_eq!(reader.read(&mut [0]).unwrap(), 0);
|
||||
worker.join().unwrap().unwrap();
|
||||
assert!(pending.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_frames_keep_their_order_ids_and_parameters() {
|
||||
let (mut client, server) = pair();
|
||||
let pending = queue();
|
||||
let q = pending.clone();
|
||||
let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));
|
||||
client.write_all(b"\r\n{bad JSON}\n{\"id\":\"test\",\"method\":\"engine.execute\",\"params\":{\"command\":\"shape.rectangle\"}}\r\n").unwrap();
|
||||
let mut reader = BufReader::new(client);
|
||||
assert_eq!(reply(&mut reader)["ok"], false);
|
||||
let start = Instant::now();
|
||||
let r = loop {
|
||||
if let Some(r) = pending.lock().unwrap().pop() {
|
||||
break r;
|
||||
}
|
||||
assert!(start.elapsed() < Duration::from_secs(5));
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
};
|
||||
assert_eq!(r.id, "test");
|
||||
assert_eq!(r.method, "engine.execute");
|
||||
assert_eq!(r.params, json!({"command": "shape.rectangle"}));
|
||||
r.reply.send(figcraft_ui_egui::ControlReply { id: r.id, result: Ok(json!({"id": 42})) }).unwrap();
|
||||
assert_eq!(reply(&mut reader), json!({"id": "test", "ok": true, "result": {"id": 42}}));
|
||||
reader.get_ref().shutdown(Shutdown::Write).unwrap();
|
||||
worker.join().unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_queue_rejects_before_dispatch_and_ui_queue_stays_bounded() {
|
||||
let (mut client, server) = pair();
|
||||
let pending = queue();
|
||||
pending.lock().unwrap().extend((0..MAX_QUEUED_REQUESTS).map(|_| request()));
|
||||
let mut ui_queue = (0..MAX_QUEUED_REQUESTS - 1).map(|_| request()).collect();
|
||||
drain_queue(&pending, &mut ui_queue);
|
||||
assert_eq!(ui_queue.len(), MAX_QUEUED_REQUESTS);
|
||||
assert_eq!(pending.lock().unwrap().len(), MAX_QUEUED_REQUESTS - 1);
|
||||
pending.lock().unwrap().push(request());
|
||||
drain_queue(&pending, &mut ui_queue);
|
||||
assert_eq!(pending.lock().unwrap().len(), MAX_QUEUED_REQUESTS);
|
||||
let q = pending.clone();
|
||||
let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));
|
||||
client.write_all(b"{\"id\":1,\"method\":\"engine.execute\"}\n").unwrap();
|
||||
let mut reader = BufReader::new(client);
|
||||
assert_eq!(reply(&mut reader), json!({"id": 1, "ok": false, "error": "control request queue is full"}));
|
||||
assert_eq!(pending.lock().unwrap().len(), MAX_QUEUED_REQUESTS);
|
||||
ui_queue.clear();
|
||||
drain_queue(&pending, &mut ui_queue);
|
||||
assert_eq!(ui_queue.len(), MAX_QUEUED_REQUESTS);
|
||||
assert!(pending.lock().unwrap().is_empty());
|
||||
reader.get_ref().shutdown(Shutdown::Write).unwrap();
|
||||
worker.join().unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_and_partial_frame_connections_expire() {
|
||||
for bytes in [b"".as_slice(), b"{\"id\":"] {
|
||||
let (mut client, server) = pair();
|
||||
let (done, finished) = mpsc::channel();
|
||||
let pending = queue();
|
||||
let q = pending.clone();
|
||||
let worker = std::thread::spawn(move || done.send(serve(server, q, context(), Duration::from_millis(50))));
|
||||
client.write_all(bytes).unwrap();
|
||||
let err = finished.recv_timeout(Duration::from_secs(5)).unwrap().unwrap_err();
|
||||
assert!(matches!(err.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut));
|
||||
assert!(pending.lock().unwrap().is_empty());
|
||||
worker.join().unwrap().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_limit_rejects_excess_clients_and_reuses_finished_slots() {
|
||||
let pending = queue();
|
||||
let ctx = context();
|
||||
let mut workers = Vec::new();
|
||||
let mut clients = Vec::new();
|
||||
for _ in 0..MAX_CONNECTIONS {
|
||||
let (client, server) = pair();
|
||||
spawn_connection(server, pending.clone(), ctx.clone(), &mut workers, Duration::from_secs(5));
|
||||
clients.push(client);
|
||||
}
|
||||
assert_eq!(workers.len(), MAX_CONNECTIONS);
|
||||
let (mut extra, server) = pair();
|
||||
spawn_connection(server, pending.clone(), ctx.clone(), &mut workers, Duration::from_secs(5));
|
||||
assert_eq!(extra.read(&mut [0]).unwrap(), 0);
|
||||
assert_eq!(workers.len(), MAX_CONNECTIONS);
|
||||
for client in clients {
|
||||
client.shutdown(Shutdown::Both).unwrap();
|
||||
}
|
||||
let start = Instant::now();
|
||||
while workers.iter().any(|worker| !worker.is_finished()) {
|
||||
assert!(start.elapsed() < Duration::from_secs(5));
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
let (mut client, server) = pair();
|
||||
spawn_connection(server, pending, ctx, &mut workers, Duration::from_secs(5));
|
||||
assert_eq!(workers.len(), 1);
|
||||
client.write_all(b"invalid\n").unwrap();
|
||||
assert_eq!(reply(&mut BufReader::new(client.try_clone().unwrap()))["ok"], false);
|
||||
client.shutdown(Shutdown::Both).unwrap();
|
||||
workers.pop().unwrap().join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_client_that_stops_reading_cannot_hold_a_worker() {
|
||||
let (mut client, server) = pair();
|
||||
let pending = queue();
|
||||
let q = pending.clone();
|
||||
let (done, finished) = mpsc::channel();
|
||||
let worker = std::thread::spawn(move || done.send(serve(server, q, context(), Duration::from_millis(100))));
|
||||
client.write_all(b"{\"id\":1,\"method\":\"document.inspect\"}\n").unwrap();
|
||||
let start = Instant::now();
|
||||
let r = loop {
|
||||
if let Some(r) = pending.lock().unwrap().pop() {
|
||||
break r;
|
||||
}
|
||||
assert!(start.elapsed() < Duration::from_secs(5));
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
};
|
||||
r.reply.send(figcraft_ui_egui::ControlReply { id: r.id, result: Ok(json!("x".repeat(8 * 1024 * 1024))) }).unwrap();
|
||||
let err = finished.recv_timeout(Duration::from_secs(5)).unwrap().unwrap_err();
|
||||
assert!(matches!(err.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut));
|
||||
worker.join().unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_limit_sized_frame_is_accepted_at_eof() {
|
||||
let (mut client, server) = pair();
|
||||
let pending = queue();
|
||||
let q = pending.clone();
|
||||
let worker = std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5)));
|
||||
let message = b"{\"id\":1,\"method\":\"ui.inspect\"}";
|
||||
client.write_all(message).unwrap();
|
||||
std::io::copy(&mut std::io::repeat(b' ').take((MAX_LINE - message.len()) as u64), &mut client).unwrap();
|
||||
client.shutdown(Shutdown::Write).unwrap();
|
||||
let start = Instant::now();
|
||||
let r = loop {
|
||||
if let Some(r) = pending.lock().unwrap().pop() {
|
||||
break r;
|
||||
}
|
||||
assert!(start.elapsed() < Duration::from_secs(5));
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
};
|
||||
assert_eq!(r.id, 1);
|
||||
assert_eq!(r.method, "ui.inspect");
|
||||
r.reply.send(figcraft_ui_egui::ControlReply { id: r.id, result: Ok(json!({})) }).unwrap();
|
||||
assert_eq!(reply(&mut BufReader::new(client)), json!({"id": 1, "ok": true, "result": {}}));
|
||||
worker.join().unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timed_out_requests_release_a_full_deferred_ui_queue() {
|
||||
use figcraft_ui_egui::{FigApp, NoServices, control, windows};
|
||||
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_embed_viewports(false);
|
||||
let mut app = FigApp::new(figcraft_engine::Session::new(), Box::new(NoServices));
|
||||
let window = windows::open(&mut app, &ctx, Some(0), None, None).unwrap();
|
||||
let pending = queue();
|
||||
let mut clients = Vec::new();
|
||||
let mut workers = Vec::new();
|
||||
for id in 0..MAX_QUEUED_REQUESTS {
|
||||
let (mut client, server) = pair();
|
||||
let q = pending.clone();
|
||||
workers.push(std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(1))));
|
||||
writeln!(client, "{}", json!({"id": id, "method": "engine.execute", "params": {"window": window, "command": "file.new"}})).unwrap();
|
||||
clients.push(BufReader::new(client));
|
||||
}
|
||||
let start = Instant::now();
|
||||
while pending.lock().unwrap().len() < MAX_QUEUED_REQUESTS {
|
||||
assert!(start.elapsed() < Duration::from_secs(5));
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
drain_queue(&pending, &mut app.control_queue);
|
||||
control::drain(&mut app, &ctx);
|
||||
assert_eq!(app.control_queue.len(), MAX_QUEUED_REQUESTS, "the other window has not drawn");
|
||||
for client in &mut clients {
|
||||
assert!(reply(client)["error"].as_str().unwrap().contains("timed out"));
|
||||
client.get_ref().shutdown(Shutdown::Write).unwrap();
|
||||
}
|
||||
assert!(app.control_queue.iter().all(ControlRequest::is_cancelled));
|
||||
let expired = app.control_queue.pop().unwrap();
|
||||
// Include expired requests in the socket queue, too: admission must reclaim their slots.
|
||||
pending.lock().unwrap().extend((0..MAX_QUEUED_REQUESTS).map(|_| {
|
||||
let r = request();
|
||||
r.cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
r
|
||||
}));
|
||||
let (mut client, server) = pair();
|
||||
let q = pending.clone();
|
||||
workers.push(std::thread::spawn(move || serve(server, q, context(), Duration::from_secs(5))));
|
||||
client.write_all(b"{\"id\":100,\"method\":\"window.list\",\"params\":{\"window\":0}}\n").unwrap();
|
||||
let start = Instant::now();
|
||||
while !pending.lock().unwrap().iter().any(|r| r.id == 100) {
|
||||
assert!(start.elapsed() < Duration::from_secs(5));
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
drain_queue(&pending, &mut app.control_queue);
|
||||
assert_eq!(app.control_queue.len(), 1, "expired UI requests no longer occupy the cap");
|
||||
control::drain(&mut app, &ctx);
|
||||
let mut client = BufReader::new(client);
|
||||
assert_eq!(reply(&mut client)["ok"], true);
|
||||
client.get_ref().shutdown(Shutdown::Write).unwrap();
|
||||
assert!(app.control_queue.is_empty());
|
||||
// A window that finally draws must discard its expired command before dispatch, too.
|
||||
let documents = app.session.docs.len();
|
||||
app.windows.current = window;
|
||||
app.control_queue.push(expired);
|
||||
control::drain(&mut app, &ctx);
|
||||
assert!(app.control_queue.is_empty());
|
||||
assert_eq!(app.session.docs.len(), documents);
|
||||
for worker in workers {
|
||||
worker.join().unwrap().unwrap();
|
||||
}
|
||||
}
|
||||
|
|
@ -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 control_server;
|
||||
mod gamepad;
|
||||
mod graphics;
|
||||
mod recovery;
|
||||
mod services;
|
||||
|
||||
#[cfg(test)]
|
||||
mod args_tests;
|
||||
#[cfg(test)]
|
||||
mod graphics_tests;
|
||||
#[cfg(test)]
|
||||
mod recovery_online_tests;
|
||||
#[cfg(test)]
|
||||
mod recovery_tests;
|
||||
|
|
@ -51,9 +54,7 @@ impl eframe::App for App {
|
|||
}
|
||||
}
|
||||
let title = with_app(|fig| {
|
||||
let mut q = self.queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
fig.control_queue.extend(q.drain(..));
|
||||
drop(q);
|
||||
control_server::drain_queue(&self.queue, &mut fig.control_queue);
|
||||
// Closing the window asks about unsaved changes (and keeps the app running while other
|
||||
// windows are open).
|
||||
figcraft_ui_egui::windows::on_close_request(fig, ctx);
|
||||
|
|
@ -184,7 +185,8 @@ fn main() -> eframe::Result {
|
|||
if let Some(icon) = icon {
|
||||
viewport = viewport.with_icon(icon);
|
||||
}
|
||||
let options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() };
|
||||
let mut options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() };
|
||||
graphics::configure(&mut options.wgpu_options);
|
||||
figcraft_ui_egui::windows::install(fig);
|
||||
eframe::run_native("FigCraft", options, Box::new(move |_cc| Ok(Box::new(App { queue, ctx_slot, title: String::new(), recovery }))))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -141,17 +141,31 @@ pub fn save_state(app: &FigApp) {
|
|||
}
|
||||
|
||||
/// Write `prefs` to `dir` atomically (a temporary file renamed over the old one: a crash leaves
|
||||
/// either version whole).
|
||||
fn write_prefs(dir: &Path, prefs: &Prefs) {
|
||||
/// either version whole). They hold online owner tokens, so on Unix only the owner can read them.
|
||||
pub(crate) fn write_prefs(dir: &Path, prefs: &Prefs) {
|
||||
let tmp = dir.join("state.json.tmp");
|
||||
let written = std::fs::create_dir_all(dir)
|
||||
.and_then(|()| std::fs::write(&tmp, json!({"prefs": prefs}).to_string()))
|
||||
.and_then(|()| write_private(&tmp, json!({"prefs": prefs}).to_string().as_bytes()))
|
||||
.and_then(|()| std::fs::rename(&tmp, dir.join(STATE)));
|
||||
if let Err(e) = written {
|
||||
log::warn!("preferences: couldn't save them in {}: {e}", dir.display());
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `bytes` to `path`, owner read/write only on Unix from before the first byte (a leftover
|
||||
/// file from a crash is narrowed too: the mode given at open only applies to new files).
|
||||
fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
let mut o = std::fs::OpenOptions::new();
|
||||
o.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::OpenOptionsExt::mode(&mut o, 0o600);
|
||||
let mut f = o.open(path)?;
|
||||
#[cfg(unix)]
|
||||
f.set_permissions(std::os::unix::fs::PermissionsExt::from_mode(0o600))?;
|
||||
f.write_all(bytes)
|
||||
}
|
||||
|
||||
impl Services for Desktop {
|
||||
fn pick_open(&mut self, title: &str, exts: &[&str]) -> Option<PathBuf> {
|
||||
rfd::FileDialog::new().set_title(title).add_filter("Supported files", exts).pick_file()
|
||||
|
|
|
|||
|
|
@ -66,3 +66,19 @@ fn plugins_are_kept_when_the_app_is_killed() {
|
|||
assert!(!dir.join("state.json.tmp").exists(), "no temporary file is left behind");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn preferences_are_readable_by_their_owner_only() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = root("private");
|
||||
let mode = |p: &std::path::Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
|
||||
// An older world-readable file and a leftover temporary one from a crash.
|
||||
for name in ["state.json", "state.json.tmp"] {
|
||||
std::fs::write(dir.join(name), "{}").unwrap();
|
||||
std::fs::set_permissions(dir.join(name), std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
}
|
||||
crate::services::write_prefs(&dir, &figcraft_engine::Prefs::default());
|
||||
assert_eq!(mode(&dir.join("state.json")), 0o600);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -939,7 +939,11 @@ impl Document {
|
|||
if n.id != *id {
|
||||
issues.push(format!("node key {id} has id {}", n.id));
|
||||
}
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for c in &n.children {
|
||||
if !seen.insert(*c) {
|
||||
issues.push(format!("child {c} listed twice in {id}"));
|
||||
}
|
||||
match self.node(*c) {
|
||||
Some(cn) if cn.parent == Some(*id) => {}
|
||||
Some(_) => issues.push(format!("child {c} of {id} has another parent")),
|
||||
|
|
@ -956,10 +960,32 @@ impl Document {
|
|||
if n.is_page() != listed {
|
||||
issues.push(format!("page list mismatch for {id}"));
|
||||
}
|
||||
if n.is_page() && n.parent.is_some() {
|
||||
issues.push(format!("page {id} has a parent"));
|
||||
}
|
||||
}
|
||||
let reachable = self.reachable();
|
||||
for id in self.nodes.keys().filter(|k| !reachable.contains(*k)) {
|
||||
issues.push(format!("{id} is not under a page (cycle or detached)"));
|
||||
}
|
||||
issues
|
||||
}
|
||||
|
||||
/// Every node reachable from a page through reciprocal parent/child links, each once (cycles
|
||||
/// and repeated children are walked once, so this is linear in the node count).
|
||||
fn reachable(&self) -> std::collections::BTreeSet<NodeId> {
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let mut stack: Vec<NodeId> = self.nodes.iter().filter(|(_, n)| n.is_page() && n.parent.is_none()).map(|(k, _)| *k).collect();
|
||||
while let Some(id) = stack.pop() {
|
||||
if !seen.insert(id) {
|
||||
continue;
|
||||
}
|
||||
let Some(n) = self.nodes.get(&id) else { continue };
|
||||
stack.extend(n.children.iter().filter(|c| !seen.contains(*c) && self.nodes.get(*c).is_some_and(|cn| cn.parent == Some(id))));
|
||||
}
|
||||
seen
|
||||
}
|
||||
|
||||
/// Repair what [`validate`](Self::validate) reports (lenient loading of untrusted files).
|
||||
pub fn repair(&mut self) {
|
||||
let ids: Vec<NodeId> = self.nodes.keys().copied().collect();
|
||||
|
|
@ -967,34 +993,30 @@ impl Document {
|
|||
if let Some(n) = self.nodes.get_mut(id) {
|
||||
let n = Arc::make_mut(n);
|
||||
n.id = *id;
|
||||
// Pages are roots.
|
||||
if n.is_page() {
|
||||
n.parent = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Drop dangling children and children claimed by another parent.
|
||||
// Drop dangling children, children claimed by another parent and repeats.
|
||||
let parents: BTreeMap<NodeId, Option<NodeId>> = self.nodes.iter().map(|(k, n)| (*k, n.parent)).collect();
|
||||
for id in &ids {
|
||||
let Some(n) = self.nodes.get(id) else { continue };
|
||||
let keep: Vec<NodeId> = n.children.iter().copied().filter(|c| parents.get(c).copied().flatten() == Some(*id)).collect();
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let keep: Vec<NodeId> =
|
||||
n.children.iter().copied().filter(|c| parents.get(c).copied().flatten() == Some(*id) && seen.insert(*c)).collect();
|
||||
if keep != n.children
|
||||
&& let Some(n) = self.nodes.get_mut(id)
|
||||
{
|
||||
Arc::make_mut(n).children = keep;
|
||||
}
|
||||
}
|
||||
// Orphans (parent missing or not listing them) are removed.
|
||||
loop {
|
||||
let orphan = self.nodes.iter().find_map(|(k, n)| {
|
||||
if n.is_page() {
|
||||
return None;
|
||||
}
|
||||
match n.parent {
|
||||
Some(p) if self.nodes.get(&p).is_some_and(|pn| pn.children.contains(k)) => None,
|
||||
_ => Some(*k),
|
||||
}
|
||||
});
|
||||
let Some(o) = orphan else { break };
|
||||
for d in self.subtree(o) {
|
||||
self.nodes.remove(&d);
|
||||
}
|
||||
// Nodes not under a page (orphans, detached subtrees, cycles) are removed.
|
||||
let reachable = self.reachable();
|
||||
let gone: Vec<NodeId> = self.nodes.keys().filter(|k| !reachable.contains(*k)).copied().collect();
|
||||
for k in gone {
|
||||
self.nodes.remove(&k);
|
||||
}
|
||||
// Snapshots of layers that are gone or no longer changed since marked ready for dev, or that
|
||||
// don't start with their layer, are dropped.
|
||||
|
|
|
|||
|
|
@ -281,6 +281,30 @@ fn serde_roundtrip_and_repair() {
|
|||
assert!(bad.validate().is_empty(), "{:?}", bad.validate());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_drops_cycles_and_repeated_children() {
|
||||
let mut d = Document::new("x");
|
||||
let p = page(&d);
|
||||
let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
let g = d.create(f, NodeKind::Frame, "G", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
let r = d.create(p, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
let mut v = serde_json::to_value(&d).unwrap();
|
||||
// F and G each other's parent and child (G listing F twice), and R listed twice by the page.
|
||||
v["nodes"][f.to_string()]["parent"] = serde_json::json!(g);
|
||||
v["nodes"][g.to_string()]["children"] = serde_json::json!([f, f]);
|
||||
v["nodes"][p.to_string()]["children"].as_array_mut().unwrap().push(serde_json::json!(r));
|
||||
let mut bad: Document = serde_json::from_value(v).unwrap();
|
||||
let issues = bad.validate();
|
||||
for want in ["listed twice", "not under a page"] {
|
||||
assert!(issues.iter().any(|i| i.contains(want)), "{want}: {issues:?}");
|
||||
}
|
||||
bad.repair();
|
||||
assert!(bad.validate().is_empty(), "{:?}", bad.validate());
|
||||
assert!(bad.node(f).is_none() && bad.node(g).is_none());
|
||||
assert_eq!(bad.node(p).unwrap().children, vec![r]);
|
||||
assert_eq!(bad.subtree(p), vec![p, r]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_layout_loads_files_without_grid_tracks() {
|
||||
// Saved before grid tracks, cells and spans existed.
|
||||
|
|
|
|||
|
|
@ -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", SEP, ""),
|
||||
m!("View/Panels", "Show/Hide UI", "view.toggleUi"),
|
||||
// FigCraft's own: the window fills the screen (F11), for setups that hide the taskbar then.
|
||||
m!("View", "Full screen", "view.fullscreen"),
|
||||
m!("View/Panels", "Reset panel widths", "view.panelWidths"),
|
||||
m!("View/Panels", "Layers", "view.layers"),
|
||||
m!("View/Panels", "Assets", "view.assets"),
|
||||
|
|
|
|||
|
|
@ -340,7 +340,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
Ok(Value::Array(s.batch(&label, &cmds)?))
|
||||
}),
|
||||
spec!("edit.repeat", "Repeat last command", "{}", true, |s, _| {
|
||||
let Some((id, p)) = s.last_command.clone() else { return Err(EngineError::Disabled("edit.repeat".into(), "nothing to repeat".into())) };
|
||||
let Some((id, p)) = s.last_command.clone().filter(|(id, _)| id != "edit.repeat") else {
|
||||
return Err(EngineError::Disabled("edit.repeat".into(), "nothing to repeat".into()));
|
||||
};
|
||||
let spec = crate::find_command(&id).ok_or_else(|| EngineError::UnknownCommand(id.clone()))?;
|
||||
(spec.run)(s, &p)
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
s.active = s.docs.len() - 1;
|
||||
Ok(json!({"index": s.active}))
|
||||
}),
|
||||
spec!("file.revert", "Revert to saved", "{}", false, |s, _| {
|
||||
spec!("file.revert", "Revert to saved", "{} leave the live session first, if any", false, |s, _| {
|
||||
let path = s.ds()?.path.clone().ok_or_else(|| EngineError::Disabled("file.revert".into(), "the file was never saved".into()))?;
|
||||
let (loaded, _) = load_path(&path, None)?;
|
||||
let i = s.active;
|
||||
|
|
@ -662,6 +662,13 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
remember(s, &path);
|
||||
let native = !rest && matches!(ext(&path).as_str(), "figcraft" | "json");
|
||||
let warnings = rest || ext(&path) == "svg";
|
||||
// A native file that lost something (a damaged image, say): the app says so, not only the result.
|
||||
if native && !loaded.issues.is_empty() {
|
||||
s.message = Some(match loaded.issues.as_slice() {
|
||||
[one] => format!("{}: {one}", file_stem(&path)),
|
||||
more => format!("{}: {} problems were repaired or left out while opening it, e.g. {}", file_stem(&path), more.len(), more[0]),
|
||||
});
|
||||
}
|
||||
s.docs.push(DocState::new(loaded.doc, native.then_some(path)).with_versions(loaded.versions).with_branches(loaded.branches));
|
||||
s.active = s.docs.len() - 1;
|
||||
let mut out = json!({"index": s.active});
|
||||
|
|
@ -782,8 +789,11 @@ pub fn export_bytes_with(doc: &Document, ids: &[NodeId], setting: &ExportSetting
|
|||
if ids.is_empty() {
|
||||
return Err(nothing());
|
||||
}
|
||||
let area = export_area(doc, ids, setting).ok_or_else(nothing)?;
|
||||
let drawn = drawn_layers(doc, ids, setting);
|
||||
if matches!(setting.format, ExportFormat::Svg | ExportFormat::Pdf) {
|
||||
figcraft_render::check_export_depth(doc, &drawn).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
}
|
||||
let area = export_area(doc, ids, setting).ok_or_else(nothing)?;
|
||||
// The layers' own area unless an option or a slice says otherwise.
|
||||
let own_area = setting.contents_only
|
||||
&& !setting.include_bounding_box
|
||||
|
|
@ -797,7 +807,10 @@ pub fn export_bytes_with(doc: &Document, ids: &[NodeId], setting: &ExportSetting
|
|||
simplify_stroke: setting.svg_simplify_stroke,
|
||||
bounds: Some(area),
|
||||
};
|
||||
figcraft_svg::export_svg(&doc.in_srgb(), &drawn, &opts).map(String::into_bytes).ok_or_else(nothing)
|
||||
figcraft_svg::try_export_svg(&doc.in_srgb(), &drawn, &opts).map(String::into_bytes).map_err(|e| match e {
|
||||
figcraft_svg::SvgError::Empty => nothing(),
|
||||
other => EngineError::Other(other.to_string()),
|
||||
})
|
||||
}
|
||||
ExportFormat::Pdf => {
|
||||
let doc = &*doc.in_srgb();
|
||||
|
|
@ -877,6 +890,11 @@ pub fn fig_bytes(doc: &Document) -> Result<(Vec<u8>, Vec<String>)> {
|
|||
|
||||
/// The scale that gives `ids` (their export area together) `setting`'s export size.
|
||||
pub fn export_scale(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> f64 {
|
||||
// Size-based exports ask for bounds before encoding. Let encoding report the depth error
|
||||
// without entering a recursive bounds walk here; SVG/PDF do not use this raster scale.
|
||||
if matches!(setting.format, ExportFormat::Svg | ExportFormat::Pdf) && figcraft_render::check_export_depth(doc, ids).is_err() {
|
||||
return 1.0;
|
||||
}
|
||||
export_area(doc, ids, setting).map_or(1.0, |b| setting.size.scale_for(b.width(), b.height()))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -293,16 +293,26 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
|||
}
|
||||
"width" | "height" | "w" | "h" => {
|
||||
let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0);
|
||||
let (ow, oh, keep) = {
|
||||
let is_w = k.starts_with('w');
|
||||
// With the proportions locked (the Layout panel's link) and only one side set, the
|
||||
// other side follows the node's ratio; both sides in one `node.set` stay as typed.
|
||||
let (nw, nh) = {
|
||||
let n = doc.get(id)?;
|
||||
(n.width, n.height, n.constrain_proportions && !props.contains_key("width") || !props.contains_key("height"))
|
||||
let (ow, oh) = (n.width, n.height);
|
||||
let (other, other_short) = if is_w { ("height", "h") } else { ("width", "w") };
|
||||
let linked = n.constrain_proportions && !props.contains_key(other) && !props.contains_key(other_short) && ow > 1e-9 && oh > 1e-9;
|
||||
let derived = if is_w { oh * val / ow } else { ow * val / oh };
|
||||
match (is_w, linked && derived.is_finite()) {
|
||||
(true, true) => (val, derived),
|
||||
(false, true) => (derived, val),
|
||||
(true, false) => (val, oh),
|
||||
(false, false) => (ow, val),
|
||||
}
|
||||
};
|
||||
let _ = keep;
|
||||
let (nw, nh) = if k.starts_with('w') { (val, oh) } else { (ow, val) };
|
||||
resize_node(doc, id, nw, nh)?;
|
||||
// Explicit size makes text fixed in that dimension.
|
||||
if let Some(t) = doc.get_mut(id)?.kind.text_mut() {
|
||||
t.auto_resize = if k.starts_with('w') {
|
||||
t.auto_resize = if is_w {
|
||||
if t.auto_resize == TextAutoResize::WidthAndHeight { TextAutoResize::Height } else { t.auto_resize }
|
||||
} else {
|
||||
TextAutoResize::None
|
||||
|
|
@ -310,7 +320,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
|||
}
|
||||
// Explicit width/height makes hugging/filling fixed (like typing into W/H).
|
||||
let n = doc.get_mut(id)?;
|
||||
if k.starts_with('w') {
|
||||
if is_w {
|
||||
n.child_layout.sizing_h = Sizing::Fixed;
|
||||
} else {
|
||||
n.child_layout.sizing_v = Sizing::Fixed;
|
||||
|
|
|
|||
|
|
@ -268,6 +268,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
spec!("view.snapObjects", "Snap to objects", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_objects)),
|
||||
spec!("view.snapPixel", "Snap to pixel grid", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_pixel)),
|
||||
spec!("view.toggleUi", "Show/Hide UI", "{on?}", false, |s, p| flag(s, p, |v| &mut v.ui_hidden)),
|
||||
spec!("view.fullscreen", "Full screen", "{on?} the window fills the screen (the desktop app; a no-op headless)", false, |s, p| {
|
||||
s.request_ui("window.fullscreen", json!({"on": boolean(p, "on")}));
|
||||
ok()
|
||||
}),
|
||||
spec!("view.layers", "Layers panel", "{}", false, |s, _| ui(s, "panel.layers")),
|
||||
spec!("view.assets", "Assets panel", "{}", false, |s, _| ui(s, "panel.assets")),
|
||||
spec!("view.design", "Design panel", "{}", false, |s, _| ui(s, "panel.design")),
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ const MAX_WARNINGS: usize = 50;
|
|||
|
||||
/// The languages a component can be connected in.
|
||||
pub fn language(s: &str) -> Option<CodeLang> {
|
||||
CodeLang::from_id(s).filter(|l| matches!(l, CodeLang::React | CodeLang::SwiftUi | CodeLang::Compose))
|
||||
CodeLang::from_id(s).filter(|l| matches!(l, CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose))
|
||||
}
|
||||
|
||||
/// The layer `id`'s links are stored on: its component set, else its component (an instance's
|
||||
|
|
@ -65,6 +65,7 @@ pub fn import_line(link: &CodeLink, lang: CodeLang) -> String {
|
|||
match lang {
|
||||
_ if path.is_empty() => String::new(),
|
||||
CodeLang::React => format!("import {{ {} }} from {};", link.name.split('.').next().unwrap_or_default(), js_string(path)),
|
||||
CodeLang::Leptos => format!("use {path}::{};", link.name.split('.').next().unwrap_or_default()),
|
||||
_ => format!("import {path}"),
|
||||
}
|
||||
}
|
||||
|
|
@ -172,6 +173,7 @@ fn expr(doc: &Document, c: &Component, p: &Param, v: &Value, lang: CodeLang) ->
|
|||
let swapped = (p.kind == ParamKind::InstanceSwap).then(|| id_of(v)).flatten().and_then(|i| link(doc, i, lang));
|
||||
match (swapped, lang) {
|
||||
(Some(l), CodeLang::React) => format!("<{} />", l.name),
|
||||
(Some(l), CodeLang::Leptos) => format!("|| view! {{ <{} /> }}", l.name),
|
||||
(Some(l), CodeLang::SwiftUi) => format!("{}()", l.name),
|
||||
(Some(l), _) => format!("{{ {}() }}", l.name),
|
||||
(None, _) => value_expr(doc, c, p, v, lang),
|
||||
|
|
@ -191,9 +193,10 @@ pub(crate) fn render(doc: &Document, c: &Component, values: &[(String, Value)],
|
|||
Piece::Value { name, inner } => match find(c, values, name) {
|
||||
Some((p, v)) => {
|
||||
let e = expr(doc, c, p, v, lang);
|
||||
// JSX: a plain string reads best as an attribute value, anything else is an
|
||||
// expression in braces (JSX attribute strings have no escapes).
|
||||
if lang != CodeLang::React || (out.ends_with('=') && e.starts_with('"') && !e.contains('\\')) {
|
||||
// JSX and `view!`: a plain string reads best as an attribute value, anything
|
||||
// else is an expression in braces (JSX attribute strings have no escapes).
|
||||
let markup = matches!(lang, CodeLang::React | CodeLang::Leptos);
|
||||
if !markup || (out.ends_with('=') && e.starts_with('"') && !e.contains('\\')) {
|
||||
out.push_str(&e);
|
||||
} else {
|
||||
out.push_str(&format!("{{{e}}}"));
|
||||
|
|
@ -215,6 +218,9 @@ fn default_template(c: &Component, lang: CodeLang, name: &str) -> String {
|
|||
let params: Vec<&str> = c.params.iter().filter(|p| p.kind != ParamKind::Slot).map(|p| p.ident.as_str()).collect();
|
||||
match lang {
|
||||
CodeLang::React => format!("<{name}{} />", params.iter().map(|p| format!(" {p}={{{p}}}")).collect::<String>()),
|
||||
CodeLang::Leptos => {
|
||||
format!("<{name}{} />", params.iter().map(|p| format!(" {}={{{p}}}", crate::component_codegen::rust_ident(p))).collect::<String>())
|
||||
}
|
||||
CodeLang::SwiftUi => format!("{name}({})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::<Vec<_>>().join(", ")),
|
||||
_ => format!("{name}({})", params.iter().map(|p| format!("{p} = {{{p}}}")).collect::<Vec<_>>().join(", ")),
|
||||
}
|
||||
|
|
@ -234,11 +240,13 @@ pub fn check(c: &Component, link: &CodeLink, lang: CodeLang) -> std::result::Res
|
|||
let path = &link.import_path;
|
||||
let path_ok = match lang {
|
||||
CodeLang::React => !path.chars().any(|ch| ch.is_control() || ch == '"' || ch == '\\'),
|
||||
CodeLang::Leptos => path.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == ':'),
|
||||
_ => path.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '.'),
|
||||
};
|
||||
if path.chars().count() > MAX_IMPORT || !path_ok {
|
||||
return Err(match lang {
|
||||
CodeLang::React => format!("importPath: a module path without quotes, up to {MAX_IMPORT} characters"),
|
||||
CodeLang::Leptos => format!("importPath: a Rust module path (letters, digits, _ and ::), up to {MAX_IMPORT} characters"),
|
||||
_ => format!("importPath: a module or package name (letters, digits, _ and .), up to {MAX_IMPORT} characters"),
|
||||
});
|
||||
}
|
||||
|
|
@ -274,7 +282,7 @@ fn text(cmd: &str, p: &Value, key: &str) -> Result<Option<String>> {
|
|||
/// The `language` parameter (default React).
|
||||
fn lang_param(cmd: &str, p: &Value) -> Result<CodeLang> {
|
||||
match text(cmd, p, "language")? {
|
||||
Some(l) => language(&l).ok_or_else(|| bad(cmd, "language: react|swiftui|compose")),
|
||||
Some(l) => language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|swiftui|compose")),
|
||||
None => Ok(CodeLang::React),
|
||||
}
|
||||
}
|
||||
|
|
@ -304,7 +312,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
spec!(
|
||||
"codeConnect.set",
|
||||
"Connect to code",
|
||||
"{id? (component, set or instance; default the selection), language?: react|swiftui|compose (default react), importPath?, name? (default the generated name), template? (default a call passing every property)} links the component (its set, for a variant) to its code component; fields left out keep their current value. Placeholders: {property} (its name or parameter name) and {property ? \"a\" : \"b\"}; other {…} stay as written → {component, language, code (the snippet for the layer), unknown (placeholder names that aren't properties)}",
|
||||
"{id? (component, set or instance; default the selection), language?: react|leptos|swiftui|compose (default react), importPath?, name? (default the generated name), template? (default a call passing every property)} links the component (its set, for a variant) to its code component; fields left out keep their current value. Placeholders: {property} (its name or parameter name) and {property ? \"a\" : \"b\"}; other {…} stay as written → {component, language, code (the snippet for the layer), unknown (placeholder names that aren't properties)}",
|
||||
true,
|
||||
|s, p| {
|
||||
let cmd = "codeConnect.set";
|
||||
|
|
@ -419,7 +427,7 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let (Some(component), Some(lang), Some(name), Some(template)) =
|
||||
(field("component"), field("language").and_then(language), field("name"), e.get("template").and_then(Value::as_str))
|
||||
else {
|
||||
warnings.push(format!("link {}: needs component, language (react|swiftui|compose), name and template strings", i + 1));
|
||||
warnings.push(format!("link {}: needs component, language (react|leptos|swiftui|compose), name and template strings", i + 1));
|
||||
continue;
|
||||
};
|
||||
let link = CodeLink {
|
||||
|
|
|
|||
|
|
@ -65,6 +65,15 @@ fn connected_instances_show_the_real_snippet() {
|
|||
);
|
||||
assert_eq!(code(&mut s, i, "swiftui"), "import AcmeUI\n\nCard(title: \"Say \\\"hi\\\"\", style: .plain)\n");
|
||||
assert!(code(&mut s, i, "compose").contains("@Composable"), "other languages stay generated");
|
||||
// Leptos: a `use` line and `view!` markup (strings with escapes in braces).
|
||||
ex(
|
||||
&mut s,
|
||||
"codeConnect.set",
|
||||
json!({"id": c, "language": "leptos", "importPath": "crate::ui", "template": "<Card title={title} dotted={Show dot} />"}),
|
||||
);
|
||||
assert_eq!(code(&mut s, i, "leptos"), "use crate::ui::Card;\n\n<Card title={\"Say \\\"hi\\\"\"} dotted={false} />\n");
|
||||
assert!(s.execute("codeConnect.set", &json!({"id": c, "language": "leptos", "importPath": "crate/ui"})).is_err(), "a Rust path");
|
||||
ex(&mut s, "codeConnect.remove", json!({"id": c, "language": "leptos"}));
|
||||
// inspect.componentCode: the snippet as the usage, and the link.
|
||||
let v = ex(&mut s, "inspect.componentCode", json!({"id": i, "language": "swiftui"}));
|
||||
assert_eq!(v["usage"], "Card(title: \"Say \\\"hi\\\"\", style: .plain)\n");
|
||||
|
|
|
|||
|
|
@ -18,13 +18,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
vec![spec!(
|
||||
"inspect.code",
|
||||
"Code for layer",
|
||||
"{id?, language?: css|html|react|swiftui|compose (default css; html and react are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead), unit?: px|rem (CSS, HTML and React; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}",
|
||||
"{id?, language?: css|html|react|leptos|swiftui|compose (default css; html, react and leptos are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead), unit?: px|rem (CSS, HTML, React and Leptos; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}",
|
||||
false,
|
||||
|s, p| {
|
||||
let cmd = "inspect.code";
|
||||
let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer"))?;
|
||||
let lang = match string(p, "language") {
|
||||
Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|swiftui|compose"))?,
|
||||
Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|swiftui|compose"))?,
|
||||
None => CodeLang::Css,
|
||||
};
|
||||
let rem = match string(p, "unit").map(|u| u.to_ascii_lowercase()).as_deref() {
|
||||
|
|
@ -42,7 +42,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
let connected = crate::code_connect::link(doc, id, lang).is_some();
|
||||
let code = code_for(doc, id, lang);
|
||||
let code = match rem {
|
||||
Some(root) if matches!(lang, CodeLang::Css | CodeLang::Html | CodeLang::React) => px_to_rem(&code, root),
|
||||
Some(root) if lang.is_web() => px_to_rem(&code, root),
|
||||
_ => code,
|
||||
};
|
||||
Ok(json!({"code": code, "language": lang.id(), "connected": connected}))
|
||||
|
|
@ -59,17 +59,20 @@ pub enum CodeLang {
|
|||
Html,
|
||||
/// A React function component (TSX) with its stylesheet.
|
||||
React,
|
||||
/// A Leptos component (`view!` markup) with its stylesheet.
|
||||
Leptos,
|
||||
SwiftUi,
|
||||
Compose,
|
||||
}
|
||||
|
||||
impl CodeLang {
|
||||
pub const ALL: [CodeLang; 5] = [CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::SwiftUi, CodeLang::Compose];
|
||||
pub const ALL: [CodeLang; 6] = [CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::Leptos, CodeLang::SwiftUi, CodeLang::Compose];
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
CodeLang::Css => "css",
|
||||
CodeLang::Html => "html",
|
||||
CodeLang::React => "react",
|
||||
CodeLang::Leptos => "leptos",
|
||||
CodeLang::SwiftUi => "swiftui",
|
||||
CodeLang::Compose => "compose",
|
||||
}
|
||||
|
|
@ -79,10 +82,15 @@ impl CodeLang {
|
|||
CodeLang::Css => "CSS",
|
||||
CodeLang::Html => "HTML + CSS",
|
||||
CodeLang::React => "React",
|
||||
CodeLang::Leptos => "Leptos",
|
||||
CodeLang::SwiftUi => "SwiftUI",
|
||||
CodeLang::Compose => "Compose",
|
||||
}
|
||||
}
|
||||
/// Languages whose lengths are CSS pixels (they can be shown in rem).
|
||||
pub fn is_web(self) -> bool {
|
||||
matches!(self, CodeLang::Css | CodeLang::Html | CodeLang::React | CodeLang::Leptos)
|
||||
}
|
||||
pub fn from_id(s: &str) -> Option<CodeLang> {
|
||||
if s.eq_ignore_ascii_case("tsx") || s.eq_ignore_ascii_case("jsx") {
|
||||
return Some(CodeLang::React);
|
||||
|
|
@ -136,7 +144,7 @@ pub fn code_for(doc: &Document, id: NodeId, lang: CodeLang) -> String {
|
|||
match lang {
|
||||
CodeLang::Css => css(doc, id),
|
||||
CodeLang::Html => html(doc, id),
|
||||
CodeLang::React | CodeLang::SwiftUi | CodeLang::Compose => crate::component_codegen::code(doc, id, lang),
|
||||
CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose => crate::component_codegen::code(doc, id, lang),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -561,16 +569,18 @@ fn jsx_text(s: &str) -> String {
|
|||
/// flexboxes, other children are placed absolutely in their parent, text becomes paragraphs.
|
||||
fn html(doc: &Document, id: NodeId) -> String {
|
||||
let ctx = Ctx::new(doc, id);
|
||||
let mut out = Markup::new(&ctx, false);
|
||||
let mut out = Markup::new(&ctx, CodeLang::Html);
|
||||
out.node(id, None, 0, "");
|
||||
format!("{}\n<style>\n{}{}</style>\n", out.markup, out.tokens.root_rule(), out.styles)
|
||||
}
|
||||
|
||||
/// Markup being built for a layer tree: HTML (`class`) or React JSX (`className`, component
|
||||
/// parameters, instances as calls), with its stylesheet.
|
||||
/// Markup being built for a layer tree: HTML (`class`), React JSX (`className`) or Leptos `view!`
|
||||
/// markup (text as Rust string literals); the last two with component parameters and instances
|
||||
/// as calls. With its stylesheet.
|
||||
pub(crate) struct Markup<'c, 'a> {
|
||||
ctx: &'c Ctx<'a>,
|
||||
jsx: bool,
|
||||
/// Html, React or Leptos.
|
||||
lang: CodeLang,
|
||||
used: Vec<String>,
|
||||
pub markup: String,
|
||||
pub styles: String,
|
||||
|
|
@ -584,10 +594,10 @@ pub(crate) struct Markup<'c, 'a> {
|
|||
}
|
||||
|
||||
impl<'c, 'a> Markup<'c, 'a> {
|
||||
pub fn new(ctx: &'c Ctx<'a>, jsx: bool) -> Self {
|
||||
pub fn new(ctx: &'c Ctx<'a>, lang: CodeLang) -> Self {
|
||||
Markup {
|
||||
ctx,
|
||||
jsx,
|
||||
lang,
|
||||
used: Vec::new(),
|
||||
markup: String::new(),
|
||||
styles: String::new(),
|
||||
|
|
@ -598,22 +608,34 @@ impl<'c, 'a> Markup<'c, 'a> {
|
|||
}
|
||||
}
|
||||
|
||||
/// JSX indents by two spaces, HTML by four.
|
||||
/// JSX indents by two spaces, HTML and Leptos by four.
|
||||
fn pad(&self, depth: usize) -> String {
|
||||
if self.jsx { " ".repeat(depth) } else { indent(depth) }
|
||||
if self.lang == CodeLang::React { " ".repeat(depth) } else { indent(depth) }
|
||||
}
|
||||
|
||||
/// A layer, shown only while its boolean parameter is on (JSX).
|
||||
/// Component code (React or Leptos): parameters and instances as calls.
|
||||
fn code(&self) -> bool {
|
||||
self.lang != CodeLang::Html
|
||||
}
|
||||
|
||||
/// A parameter's name in the generated code.
|
||||
fn ident(&self, p: &Param) -> String {
|
||||
if self.lang == CodeLang::Leptos { crate::component_codegen::rust_ident(&p.ident) } else { p.ident.clone() }
|
||||
}
|
||||
|
||||
/// A layer, shown only while its boolean parameter is on (component code).
|
||||
pub fn node(&mut self, id: NodeId, parent: Option<&Node>, depth: usize, path: &str) {
|
||||
let Some(n) = self.ctx.doc.node(id) else { return };
|
||||
if self.jsx
|
||||
if self.code()
|
||||
&& id != self.ctx.root
|
||||
&& let Some(p) = self.ctx.param(n, "visible")
|
||||
{
|
||||
let pad = self.pad(depth);
|
||||
self.markup.push_str(&format!("{pad}{{{} && (\n", p.ident));
|
||||
let (pad, p) = (self.pad(depth), self.ident(p));
|
||||
let (open, close) =
|
||||
if self.lang == CodeLang::Leptos { (format!("{{{p}.then(|| view! {{"), "})}") } else { (format!("{{{p} && ("), ")}") };
|
||||
self.markup.push_str(&format!("{pad}{open}\n"));
|
||||
self.element(n, parent, depth + 1, path);
|
||||
self.markup.push_str(&format!("{pad})}}\n"));
|
||||
self.markup.push_str(&format!("{pad}{close}\n"));
|
||||
return;
|
||||
}
|
||||
self.element(n, parent, depth, path);
|
||||
|
|
@ -639,7 +661,7 @@ impl<'c, 'a> Markup<'c, 'a> {
|
|||
None => class_name(&n.name, &mut self.used),
|
||||
};
|
||||
let pad = self.pad(depth);
|
||||
let attr = if self.jsx { "className" } else { "class" };
|
||||
let attr = if self.lang == CodeLang::React { "className" } else { "class" };
|
||||
// How it sits in its parent: in the flex flow, or placed at its position.
|
||||
let mut decls = String::new();
|
||||
let in_flow = parent.is_some_and(|p| p.layout.is_some()) && !n.child_layout.absolute;
|
||||
|
|
@ -663,12 +685,13 @@ impl<'c, 'a> Markup<'c, 'a> {
|
|||
decls.push_str(&format!("transform: rotate({}deg);\ntransform-origin: 0 0;\n", num(-rot)));
|
||||
}
|
||||
}
|
||||
// Nested instances are calls (JSX), in a wrapper that places them when needed.
|
||||
if self.jsx && !root && matches!(n.kind, NodeKind::Instance { .. }) {
|
||||
// Nested instances are calls (component code), in a wrapper that places them when needed.
|
||||
if self.code() && !root && matches!(n.kind, NodeKind::Instance { .. }) {
|
||||
let at = depth + usize::from(!decls.is_empty());
|
||||
let run = if self.lang == CodeLang::Leptos { ".run()" } else { "" };
|
||||
let inner = match self.ctx.param(n, "mainComponent") {
|
||||
Some(p) => Some(format!("{}{{{}}}\n", self.pad(at), p.ident)),
|
||||
None => self.ctx.call(n, CodeLang::React, at),
|
||||
Some(p) => Some(format!("{}{{{}{run}}}\n", self.pad(at), self.ident(p))),
|
||||
None => self.ctx.call(n, self.lang, at),
|
||||
};
|
||||
if let Some(inner) = inner {
|
||||
if decls.is_empty() {
|
||||
|
|
@ -692,19 +715,26 @@ impl<'c, 'a> Markup<'c, 'a> {
|
|||
let open = |tag: &str| format!("{pad}<{tag} {attr}=\"{class}\"{attrs}>");
|
||||
match &n.kind {
|
||||
NodeKind::Text(t) => {
|
||||
let body = match self.ctx.param(n, "characters").filter(|_| self.jsx) {
|
||||
Some(p) => format!("{{{}}}", p.ident),
|
||||
None if self.jsx => jsx_text(&t.characters),
|
||||
None => html_escape(&t.characters),
|
||||
let body = match (self.ctx.param(n, "characters").filter(|_| self.code()), self.lang) {
|
||||
(Some(p), CodeLang::Leptos) => format!("{{{}.clone()}}", self.ident(p)),
|
||||
(Some(p), _) => format!("{{{}}}", p.ident),
|
||||
(None, CodeLang::React) => jsx_text(&t.characters),
|
||||
// `view!` takes text as string literals and void elements self-closed.
|
||||
(None, CodeLang::Leptos) => {
|
||||
t.characters.chars().take(10_000).collect::<String>().lines().map(|l| format!("{l:?}")).collect::<Vec<_>>().join("<br/>")
|
||||
}
|
||||
(None, _) => html_escape(&t.characters),
|
||||
};
|
||||
self.markup.push_str(&format!("{}{body}</p>\n", open("p")));
|
||||
}
|
||||
_ => {
|
||||
let kids = self.ctx.kids(n);
|
||||
let slot = self.ctx.param(n, "slot").filter(|_| self.jsx);
|
||||
let slot = self.ctx.param(n, "slot").filter(|_| self.code());
|
||||
let leptos = self.lang == CodeLang::Leptos;
|
||||
let open = open("div");
|
||||
if let Some(p) = slot.filter(|_| kids.is_empty()) {
|
||||
self.markup.push_str(&format!("{open}{{{}}}</div>\n", p.ident));
|
||||
let content = if leptos { format!("{}.as_ref().map(|c| c.run())", self.ident(p)) } else { p.ident.clone() };
|
||||
self.markup.push_str(&format!("{open}{{{content}}}</div>\n"));
|
||||
} else if kids.is_empty() || self.ctx.too_deep(depth) {
|
||||
self.markup.push_str(&format!("{open}</div>\n"));
|
||||
} else {
|
||||
|
|
@ -712,6 +742,10 @@ impl<'c, 'a> Markup<'c, 'a> {
|
|||
// A slot shows the instance's content, or else the main's.
|
||||
let (p1, p2) = (self.pad(depth + 1), self.pad(depth + 2));
|
||||
let inner = match slot {
|
||||
Some(p) if leptos => {
|
||||
self.markup.push_str(&format!("{p1}{{match &{} {{ Some(c) => c.run(), None => view! {{\n", self.ident(p)));
|
||||
depth + 2
|
||||
}
|
||||
Some(p) => {
|
||||
self.markup.push_str(&format!("{p1}{{{} ?? (\n{p2}<>\n", p.ident));
|
||||
depth + 3
|
||||
|
|
@ -726,8 +760,10 @@ impl<'c, 'a> Markup<'c, 'a> {
|
|||
seen.push(&kn.name);
|
||||
self.node(k, Some(n), inner, &format!("{path}/{}#{count}", kn.name));
|
||||
}
|
||||
if slot.is_some() {
|
||||
self.markup.push_str(&format!("{p2}</>\n{p1})}}\n"));
|
||||
match slot {
|
||||
Some(_) if leptos => self.markup.push_str(&format!("{p1}}}.into_any() }}}}\n")),
|
||||
Some(_) => self.markup.push_str(&format!("{p2}</>\n{p1})}}\n")),
|
||||
None => {}
|
||||
}
|
||||
self.markup.push_str(&format!("{pad}</div>\n"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
//! Component code: a component (or component set) becomes a React, SwiftUI or Jetpack Compose
|
||||
//! component whose properties are its parameters (a variant property → a union or enum, boolean →
|
||||
//! Component code: a component (or component set) becomes a React, Leptos, SwiftUI or Jetpack
|
||||
//! Compose component whose properties are its parameters (a variant property → a union or enum, boolean →
|
||||
//! a flag showing the layers bound to it, text → a string, instance swap → a view parameter, slot →
|
||||
//! optional content), and an instance becomes a call passing the values it sets. Variants of a set
|
||||
//! share one definition (the set's default variant); in React their differences become CSS rules on
|
||||
//! `data-*` attributes. Returned by `inspect.componentCode` and used by `inspect.code`. A component
|
||||
//! share one definition (the set's default variant); in React and Leptos their differences become CSS
|
||||
//! rules on `data-*` attributes. Returned by `inspect.componentCode` and used by `inspect.code`. A component
|
||||
//! connected to code (see [`crate::code_connect`]) shows its real snippet instead.
|
||||
|
||||
use figcraft_doc::*;
|
||||
|
|
@ -67,14 +67,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
vec![spec!(
|
||||
"inspect.componentCode",
|
||||
"Component code",
|
||||
"{id? (component, set or instance; default the selection), language?: react|swiftui|compose (default react)} → {component, name, set, properties: [{name, param, type, default, value? (instances)}], definition, usage? (instances; the connected snippet when the component is connected to code), connected: {importPath, name, template, import, code} | null}",
|
||||
"{id? (component, set or instance; default the selection), language?: react|leptos|swiftui|compose (default react)} → {component, name, set, properties: [{name, param, type, default, value? (instances)}], definition, usage? (instances; the connected snippet when the component is connected to code), connected: {importPath, name, template, import, code} | null}",
|
||||
false,
|
||||
|s, p| {
|
||||
let cmd = "inspect.componentCode";
|
||||
let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component or instance"))?;
|
||||
let lang = match string(p, "language").map(|l| CodeLang::from_id(&l)) {
|
||||
Some(Some(l @ (CodeLang::React | CodeLang::SwiftUi | CodeLang::Compose))) => l,
|
||||
Some(_) => return Err(bad(cmd, "language: react|swiftui|compose")),
|
||||
Some(Some(l @ (CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose))) => l,
|
||||
Some(_) => return Err(bad(cmd, "language: react|leptos|swiftui|compose")),
|
||||
None => CodeLang::React,
|
||||
};
|
||||
let doc = s.doc()?;
|
||||
|
|
@ -107,9 +107,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
)]
|
||||
}
|
||||
|
||||
/// Code for any layer in React, SwiftUI or Compose: an instance's call and its component's
|
||||
/// definition, a component's (or set's) definition, or the layer tree (React wraps it in a
|
||||
/// function component).
|
||||
/// Code for any layer in React, Leptos, SwiftUI or Compose: an instance's call and its component's
|
||||
/// definition, a component's (or set's) definition, or the layer tree (React and Leptos wrap it
|
||||
/// in a function component).
|
||||
pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String {
|
||||
let Some(n) = doc.node(id) else { return String::new() };
|
||||
if let Some(code) = crate::code_connect::link(doc, id, lang).and_then(|l| crate::code_connect::connected_code(doc, id, lang, l)) {
|
||||
|
|
@ -120,7 +120,10 @@ pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String {
|
|||
(NodeKind::Instance { .. }, Some(c)) => format!("{}\n{}", usage(doc, id, lang, 0).unwrap_or_default(), definition(doc, &c, lang)),
|
||||
(NodeKind::Component { .. } | NodeKind::ComponentSet { .. }, Some(c)) => definition(doc, &c, lang),
|
||||
_ => match lang {
|
||||
CodeLang::React => react(doc, &Component { comp: id, set: None, type_name: component_type_name(&n.name), params: Vec::new() }),
|
||||
CodeLang::React | CodeLang::Leptos => {
|
||||
let c = Component { comp: id, set: None, type_name: component_type_name(&n.name), params: Vec::new() };
|
||||
if lang == CodeLang::Leptos { leptos(doc, &c) } else { react(doc, &c) }
|
||||
}
|
||||
CodeLang::SwiftUi => {
|
||||
let mut out = String::new();
|
||||
swift(&Ctx::new(doc, id), id, 0, &mut out, &[]);
|
||||
|
|
@ -358,6 +361,52 @@ fn ident(name: &str, used: &mut Vec<String>) -> String {
|
|||
out
|
||||
}
|
||||
|
||||
/// Rust's keywords, and `children` (Leptos passes a component's children by that name).
|
||||
const RUST_RESERVED: &[&str] = &[
|
||||
"abstract", "as", "async", "await", "become", "box", "break", "children", "const", "continue", "crate", "do", "dyn", "else", "enum", "extern",
|
||||
"false", "final", "fn", "for", "gen", "if", "impl", "in", "let", "loop", "macro", "match", "mod", "move", "mut", "override", "priv", "pub",
|
||||
"ref", "return", "self", "static", "struct", "super", "trait", "true", "try", "type", "typeof", "unsafe", "unsized", "use", "virtual", "where",
|
||||
"while", "yield",
|
||||
];
|
||||
|
||||
/// A parameter's Rust name: its camelCase name in snake_case (`showIcon` → `show_icon`), with a
|
||||
/// trailing `_` when Rust reserves it (`type_`; camelCase names have no `_`, so it stays unique).
|
||||
pub(crate) fn rust_ident(ident: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for (i, ch) in ident.chars().enumerate() {
|
||||
if ch.is_ascii_uppercase() {
|
||||
if i > 0 {
|
||||
out.push('_');
|
||||
}
|
||||
out.push(ch.to_ascii_lowercase());
|
||||
} else {
|
||||
out.push(ch);
|
||||
}
|
||||
}
|
||||
if RUST_RESERVED.contains(&out.as_str()) {
|
||||
out.push('_');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A Rust type name: `Self` is taken.
|
||||
fn rust_type(t: &str) -> String {
|
||||
if t == "Self" { "SelfComponent".into() } else { t.to_string() }
|
||||
}
|
||||
|
||||
/// A Leptos variant enum's name: the component's name and the property's, never the props struct
|
||||
/// `#[component]` makes (`ButtonProps`).
|
||||
fn leptos_enum(c: &Component, p: &Param) -> String {
|
||||
let t = rust_type(&c.type_name);
|
||||
let name = format!("{t}{}", p.enum_name);
|
||||
if name == format!("{t}Props") { format!("{name}Variant") } else { name }
|
||||
}
|
||||
|
||||
/// Rust enum variants for variant values: [`cases`] in PascalCase, `Self` taken.
|
||||
fn rust_cases(values: &[String]) -> Vec<String> {
|
||||
cases(values, false).into_iter().map(|c| if c == "Self" { "SelfValue".into() } else { c }).collect()
|
||||
}
|
||||
|
||||
/// Enum case names for variant values, unique: Swift's lowerCamel (`.small`, `` .`default` ``),
|
||||
/// Kotlin's PascalCase (`Small`).
|
||||
fn cases(values: &[String], swift: bool) -> Vec<String> {
|
||||
|
|
@ -383,6 +432,11 @@ fn cases(values: &[String], swift: bool) -> Vec<String> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// A Rust string literal.
|
||||
pub(crate) fn rust_string(s: &str) -> String {
|
||||
format!("{:?}", s.chars().take(10_000).collect::<String>())
|
||||
}
|
||||
|
||||
/// A JavaScript string literal.
|
||||
pub(crate) fn js_string(s: &str) -> String {
|
||||
let s: String = s.chars().take(10_000).collect();
|
||||
|
|
@ -405,6 +459,11 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value
|
|||
match (&p.kind, lang) {
|
||||
(ParamKind::Boolean, _) => value.as_bool().unwrap_or(false).to_string(),
|
||||
(ParamKind::Text, CodeLang::React) => js_string(value.as_str().unwrap_or("")),
|
||||
(ParamKind::Text, CodeLang::Leptos) => rust_string(value.as_str().unwrap_or("")),
|
||||
(ParamKind::Variant(opts), CodeLang::Leptos) => {
|
||||
let i = opts.iter().position(|o| Some(o.as_str()) == value.as_str()).unwrap_or(0);
|
||||
format!("{}::{}", leptos_enum(c, p), rust_cases(opts).get(i).cloned().unwrap_or_else(|| "Unknown".into()))
|
||||
}
|
||||
(ParamKind::Text, l) => literal(value.as_str().unwrap_or(""), l == CodeLang::Compose),
|
||||
(ParamKind::Variant(opts), l) => {
|
||||
let v = value.as_str().unwrap_or("");
|
||||
|
|
@ -420,6 +479,8 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value
|
|||
let t = id_of(value).and_then(|i| component_type(doc, i));
|
||||
match (t, l) {
|
||||
(Some(t), CodeLang::React) => format!("<{t} />"),
|
||||
(Some(t), CodeLang::Leptos) => format!("|| view! {{ <{} /> }}", rust_type(&t)),
|
||||
(None, CodeLang::Leptos) => "|| ()".into(),
|
||||
(Some(t), CodeLang::SwiftUi) => format!("AnyView({t}())"),
|
||||
(Some(t), _) => format!("{{ {t}() }}"),
|
||||
(None, CodeLang::React) => "null".into(),
|
||||
|
|
@ -486,10 +547,17 @@ pub fn usage(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option
|
|||
}
|
||||
let t = &c.type_name;
|
||||
Some(match lang {
|
||||
CodeLang::React => {
|
||||
CodeLang::React | CodeLang::Leptos => {
|
||||
let leptos = lang == CodeLang::Leptos;
|
||||
// Plain strings read best as attribute values.
|
||||
let attrs: String =
|
||||
args.iter().map(|(name, expr, plain)| if *plain { format!(" {name}={expr}") } else { format!(" {name}={{{expr}}}") }).collect();
|
||||
let attrs: String = args
|
||||
.iter()
|
||||
.map(|(name, expr, plain)| {
|
||||
let name = if leptos { rust_ident(name) } else { name.to_string() };
|
||||
if *plain { format!(" {name}={expr}") } else { format!(" {name}={{{expr}}}") }
|
||||
})
|
||||
.collect();
|
||||
let t = if leptos { rust_type(t) } else { t.clone() };
|
||||
format!("{pad}<{t}{attrs} />\n")
|
||||
}
|
||||
CodeLang::SwiftUi => format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::<Vec<_>>().join(", ")),
|
||||
|
|
@ -504,13 +572,20 @@ fn slot_content(doc: &Document, slot: NodeId, lang: CodeLang, depth: usize) -> S
|
|||
let kids = ctx.kids(n);
|
||||
match lang {
|
||||
CodeLang::React => {
|
||||
let mut m = Markup::new(&ctx, true);
|
||||
let mut m = Markup::new(&ctx, CodeLang::React);
|
||||
for k in kids {
|
||||
m.node(k, Some(n), depth + 2, "");
|
||||
}
|
||||
let (p1, p2) = (" ".repeat(depth), " ".repeat(depth + 1));
|
||||
format!("(\n{p2}<>\n{}{p2}</>\n{p1})", m.markup)
|
||||
}
|
||||
CodeLang::Leptos => {
|
||||
let mut m = Markup::new(&ctx, CodeLang::Leptos);
|
||||
for k in kids {
|
||||
m.node(k, Some(n), depth + 1, "");
|
||||
}
|
||||
format!("|| view! {{\n{}{}}}", m.markup, indent(depth))
|
||||
}
|
||||
CodeLang::SwiftUi => {
|
||||
let mut out = String::new();
|
||||
for k in kids {
|
||||
|
|
@ -535,6 +610,7 @@ pub fn definition(doc: &Document, c: &Component, lang: CodeLang) -> String {
|
|||
match lang {
|
||||
CodeLang::SwiftUi => swift_definition(doc, c),
|
||||
CodeLang::Compose => compose_definition(doc, c),
|
||||
CodeLang::Leptos => leptos(doc, c),
|
||||
_ => react(doc, c),
|
||||
}
|
||||
}
|
||||
|
|
@ -545,23 +621,29 @@ fn data_attr(p: &Param) -> String {
|
|||
format!("data-{}", class_name(&p.prop, &mut used))
|
||||
}
|
||||
|
||||
/// A TSX function component with its props interface, and its stylesheet (variables as custom
|
||||
/// properties, other variants as rules on their `data-*` attributes).
|
||||
fn react(doc: &Document, c: &Component) -> String {
|
||||
/// `c`'s markup in React or Leptos (two levels in) and its stylesheet (variables as custom
|
||||
/// properties, other variants as rules on their `data-*` attributes): (root class, markup, CSS).
|
||||
fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String) {
|
||||
let ctx = Ctx { doc, root: c.comp, comp: Some(c), base: 2 };
|
||||
let root_name = c.set.and_then(|s| doc.node(s)).or_else(|| doc.node(c.comp)).map(|n| n.name.clone()).unwrap_or_default();
|
||||
let root_class = class_name(&root_name, &mut Vec::new());
|
||||
let variants: Vec<&Param> = c.params.iter().filter(|p| matches!(p.kind, ParamKind::Variant(_))).collect();
|
||||
let pass = |root: NodeId| {
|
||||
let vctx = Ctx { doc, root, comp: Some(c), base: 2 };
|
||||
let mut m = Markup::new(&vctx, true);
|
||||
let mut m = Markup::new(&vctx, lang);
|
||||
m.root_class = Some(root_class.clone());
|
||||
m.node(root, None, 2, "");
|
||||
m.rules
|
||||
};
|
||||
let mut m = Markup::new(&ctx, true);
|
||||
let mut m = Markup::new(&ctx, lang);
|
||||
m.root_class = Some(root_class.clone());
|
||||
m.root_attrs = variants.iter().map(|p| format!(" {}={{{}}}", data_attr(p), p.ident)).collect();
|
||||
m.root_attrs = variants
|
||||
.iter()
|
||||
.map(|p| match lang {
|
||||
CodeLang::Leptos => format!(" {}={{{}.as_str()}}", data_attr(p), rust_ident(&p.ident)),
|
||||
_ => format!(" {}={{{}}}", data_attr(p), p.ident),
|
||||
})
|
||||
.collect();
|
||||
m.node(c.comp, None, 2, "");
|
||||
// Other variants: what differs from this one, layer by layer.
|
||||
let mut variant_rules = String::new();
|
||||
|
|
@ -584,6 +666,13 @@ fn react(doc: &Document, c: &Component) -> String {
|
|||
variant_rules.push_str(&format!("{sel} {{\n{}}}\n", diff.iter().map(|l| format!(" {l}\n")).collect::<String>()));
|
||||
}
|
||||
}
|
||||
let css = format!("{}{}{variant_rules}", m.tokens.root_rule(), m.styles);
|
||||
(root_class, m.markup, css)
|
||||
}
|
||||
|
||||
/// A TSX function component with its props interface, and its stylesheet.
|
||||
fn react(doc: &Document, c: &Component) -> String {
|
||||
let (root_class, markup, css) = web(doc, c, CodeLang::React);
|
||||
let t = &c.type_name;
|
||||
let mut out = format!("import \"./{root_class}.css\";\n\n");
|
||||
if c.params.is_empty() {
|
||||
|
|
@ -610,7 +699,46 @@ fn react(doc: &Document, c: &Component) -> String {
|
|||
.collect();
|
||||
out.push_str(&format!("export function {t}({{\n{}}}: {t}Props) {{\n", args.iter().map(|a| format!(" {a},\n")).collect::<String>()));
|
||||
}
|
||||
out.push_str(&format!(" return (\n{} );\n}}\n\n/* {root_class}.css */\n{}{}{variant_rules}", m.markup, m.tokens.root_rule(), m.styles));
|
||||
out.push_str(&format!(" return (\n{markup} );\n}}\n\n/* {root_class}.css */\n{css}"));
|
||||
out
|
||||
}
|
||||
|
||||
/// A Leptos `#[component]` with its variant enums and props (defaults as `#[prop]` attributes),
|
||||
/// and its stylesheet.
|
||||
fn leptos(doc: &Document, c: &Component) -> String {
|
||||
let (root_class, markup, css) = web(doc, c, CodeLang::Leptos);
|
||||
let mut out = String::from("use leptos::prelude::*;\n\n");
|
||||
for p in &c.params {
|
||||
let ParamKind::Variant(opts) = &p.kind else { continue };
|
||||
let (e, cases) = (leptos_enum(c, p), rust_cases(opts));
|
||||
let default = opts.iter().position(|o| Some(o.as_str()) == p.default.as_str()).unwrap_or(0);
|
||||
out.push_str(&format!("#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]\npub enum {e} {{\n"));
|
||||
for (i, case) in cases.iter().enumerate() {
|
||||
out.push_str(&format!("{} {case},\n", if i == default { " #[default]\n" } else { "" }));
|
||||
}
|
||||
out.push_str(&format!("}}\n\nimpl {e} {{\n /// The variant's name, as its `data-*` attribute.\n pub fn as_str(self) -> &'static str {{\n match self {{\n"));
|
||||
for (o, case) in opts.iter().zip(&cases) {
|
||||
out.push_str(&format!(" Self::{case} => {},\n", rust_string(o)));
|
||||
}
|
||||
out.push_str(" }\n }\n}\n\n");
|
||||
}
|
||||
let t = rust_type(&c.type_name);
|
||||
out.push_str(&format!("#[component]\npub fn {t}("));
|
||||
if !c.params.is_empty() {
|
||||
out.push('\n');
|
||||
for p in &c.params {
|
||||
let default = value_expr(doc, c, p, &p.default, CodeLang::Leptos);
|
||||
let (attr, ty) = match &p.kind {
|
||||
ParamKind::Variant(_) => ("optional".to_string(), leptos_enum(c, p)),
|
||||
ParamKind::Boolean => (format!("default = {default}"), "bool".into()),
|
||||
ParamKind::Text => (format!("into, default = {default}.to_string()"), "String".into()),
|
||||
ParamKind::InstanceSwap => (format!("into, default = ({default}).into()"), "ViewFn".into()),
|
||||
ParamKind::Slot => ("optional, into".to_string(), "Option<ViewFn>".into()),
|
||||
};
|
||||
out.push_str(&format!(" #[prop({attr})] {}: {ty},\n", rust_ident(&p.ident)));
|
||||
}
|
||||
}
|
||||
out.push_str(&format!(") -> impl IntoView {{\n view! {{\n{markup} }}\n}}\n\n/* {root_class}.css */\n{css}"));
|
||||
out
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//! Component code: components become React / SwiftUI / Compose components whose properties are
|
||||
//! parameters, and instances become calls passing the values they set.
|
||||
//! Component code: components become React / Leptos / SwiftUI / Compose components whose
|
||||
//! properties are parameters, and instances become calls passing the values they set.
|
||||
|
||||
use crate::Session;
|
||||
use crate::codegen::{CodeLang, code_for};
|
||||
|
|
@ -71,6 +71,18 @@ fn components_take_their_properties_as_parameters() {
|
|||
for want in ["@Composable\nfun Card(", " showDot: Boolean = true,", " title: String = \"Hello\",", "if (showDot) {", "text = title,"] {
|
||||
assert!(kt.contains(want), "Compose has {want:?}:\n{kt}");
|
||||
}
|
||||
let leptos = code(&mut s, c, "leptos");
|
||||
for want in [
|
||||
"use leptos::prelude::*;\n\n#[component]\npub fn Card(\n",
|
||||
" #[prop(default = true)] show_dot: bool,\n",
|
||||
" #[prop(into, default = \"Hello\".to_string())] title: String,\n",
|
||||
") -> impl IntoView {\n view! {\n",
|
||||
"{show_dot.then(|| view! {",
|
||||
">{title.clone()}</p>",
|
||||
"/* card.css */",
|
||||
] {
|
||||
assert!(leptos.contains(want), "Leptos has {want:?}:\n{leptos}");
|
||||
}
|
||||
// The command describes the properties.
|
||||
let v = component_code(&mut s, c, "react");
|
||||
assert_eq!(v["name"], "Card");
|
||||
|
|
@ -105,11 +117,13 @@ fn instances_are_calls_passing_the_values_they_set() {
|
|||
assert!(react.contains("export function Card({"));
|
||||
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Card(showDot: false, title: \"Buy\")\n");
|
||||
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Buy\")\n");
|
||||
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "<Card show_dot={false} title=\"Buy\" />\n");
|
||||
// Text that needs escaping is an expression in JSX and escaped per language.
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Say \"hi\" $5\nnow"}}));
|
||||
assert_eq!(component_code(&mut s, i, "react")["usage"], "<Card showDot={false} title={\"Say \\\"hi\\\" $5\\nnow\"} />\n");
|
||||
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Card(showDot: false, title: \"Say \\\"hi\\\" $5\\nnow\")\n");
|
||||
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Say \\\"hi\\\" \\$5\\nnow\")\n");
|
||||
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "<Card show_dot={false} title={\"Say \\\"hi\\\" $5\\nnow\"} />\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -125,6 +139,9 @@ fn nested_instances_in_a_layer_tree_are_calls() {
|
|||
assert!(!react.contains("Hello"), "the instance's layers aren't spelled out: {react}");
|
||||
assert!(code(&mut s, screen, "swiftui").contains("Card(title: \"Inbox\")"));
|
||||
assert!(code(&mut s, screen, "compose").contains("Card(title = \"Inbox\")"));
|
||||
let leptos = code(&mut s, screen, "leptos");
|
||||
assert!(leptos.contains("pub fn HomeScreen() -> impl IntoView {") && leptos.contains("<Card title=\"Inbox\" />"), "{leptos}");
|
||||
assert!(!leptos.contains("Hello"), "{leptos}");
|
||||
// HTML and CSS stay plain markup.
|
||||
assert!(code(&mut s, screen, "html").contains("Inbox"));
|
||||
}
|
||||
|
|
@ -156,12 +173,23 @@ fn variant_sets_share_one_definition() {
|
|||
let kt = code(&mut s, set, "compose");
|
||||
assert!(kt.contains("enum class ButtonState { Default, Hover }"), "{kt}");
|
||||
assert!(kt.contains(" state: ButtonState = ButtonState.Default,"), "{kt}");
|
||||
let leptos = code(&mut s, set, "leptos");
|
||||
for want in [
|
||||
"#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]\npub enum ButtonState {\n #[default]\n Default,\n Hover,\n}",
|
||||
" Self::Hover => \"Hover\",",
|
||||
" #[prop(optional)] state: ButtonState,",
|
||||
"data-state={state.as_str()}",
|
||||
".button[data-state=\"Hover\"] .bg {\n background: #0000FF;",
|
||||
] {
|
||||
assert!(leptos.contains(want), "Leptos has {want:?}:\n{leptos}");
|
||||
}
|
||||
// An instance showing the hover variant passes it.
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 600})));
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"State": "Hover"}}));
|
||||
assert_eq!(component_code(&mut s, i, "react")["usage"], "<Button state=\"Hover\" />\n");
|
||||
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Button(state: .hover)\n");
|
||||
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Button(state = ButtonState.Hover)\n");
|
||||
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "<Button state=ButtonState::Hover />\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -184,11 +212,15 @@ fn instance_swap_properties_take_views() {
|
|||
assert!(swift.contains(" var icon: AnyView = AnyView(HeartIcon())"), "{swift}");
|
||||
let kt = code(&mut s, chip, "compose");
|
||||
assert!(kt.contains(" icon: @Composable () -> Unit = { HeartIcon() },") && kt.contains("icon()"), "{kt}");
|
||||
let leptos = code(&mut s, chip, "leptos");
|
||||
assert!(leptos.contains(" #[prop(into, default = (|| view! { <HeartIcon /> }).into())] icon: ViewFn,"), "{leptos}");
|
||||
assert!(leptos.contains("{icon.run()}"), "{leptos}");
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": chip, "x": 0, "y": 100})));
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Icon": star}}));
|
||||
assert_eq!(component_code(&mut s, i, "react")["usage"], "<Chip icon={<StarIcon />} />\n");
|
||||
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Chip(icon: AnyView(StarIcon()))\n");
|
||||
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Chip(icon = { StarIcon() })\n");
|
||||
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "<Chip icon={|| view! { <StarIcon /> }} />\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -203,6 +235,9 @@ fn slots_take_optional_content() {
|
|||
assert!(react.contains(" content?: React.ReactNode;") && react.contains("{content ?? ("), "{react}");
|
||||
assert!(code(&mut s, panel, "swiftui").contains("var content: AnyView? = nil"));
|
||||
assert!(code(&mut s, panel, "compose").contains("content: (@Composable () -> Unit)? = null,"));
|
||||
let leptos = code(&mut s, panel, "leptos");
|
||||
assert!(leptos.contains(" #[prop(optional, into)] content: Option<ViewFn>,"), "{leptos}");
|
||||
assert!(leptos.contains("{match &content { Some(c) => c.run(), None => view! {\n") && leptos.contains("}.into_any() }}\n"), "{leptos}");
|
||||
// An instance filling the slot passes its content.
|
||||
let g = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 400, "width": 40, "height": 20, "name": "Badge"})));
|
||||
let badge = idv(&ex(&mut s, "component.create", json!({"ids": [g]})));
|
||||
|
|
@ -211,6 +246,8 @@ fn slots_take_optional_content() {
|
|||
ex(&mut s, "instance.slotInsert", json!({"id": i, "slot": "Content", "component": badge, "replace": true}));
|
||||
let usage = component_code(&mut s, i, "react")["usage"].as_str().unwrap_or_default().to_string();
|
||||
assert!(usage.starts_with("<Panel content={(\n") && usage.contains("<Badge />"), "{usage}");
|
||||
let usage = component_code(&mut s, i, "leptos")["usage"].as_str().unwrap_or_default().to_string();
|
||||
assert!(usage.starts_with("<Panel content={|| view! {\n") && usage.contains("<Badge />") && usage.ends_with("}} />\n"), "{usage}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -225,6 +262,8 @@ fn identifiers_are_escaped_and_unique() {
|
|||
("label", "text", json!("b")),
|
||||
("2 columns", "boolean", json!(false)),
|
||||
("Color", "variant", json!("Red")),
|
||||
("type", "text", json!("c")),
|
||||
("Props", "variant", json!("Self")),
|
||||
] {
|
||||
ex(&mut s, "component.addProperty", json!({"id": c, "name": name, "type": ty, "default": default}));
|
||||
}
|
||||
|
|
@ -242,6 +281,20 @@ fn identifiers_are_escaped_and_unique() {
|
|||
assert_eq!(color.as_deref(), Some("ColorVariant"), "never shadows SwiftUI's Color");
|
||||
let swift = code_for(doc, c, CodeLang::SwiftUi);
|
||||
assert!(swift.contains("struct TextComponent: View {") && swift.contains("enum ColorVariant: String, CaseIterable {"), "{swift}");
|
||||
// Rust: snake_case, keywords suffixed, never the props struct's name or `Self`.
|
||||
let leptos = code_for(doc, c, CodeLang::Leptos);
|
||||
for want in [
|
||||
"pub fn TextComponent(",
|
||||
"] default_value: bool,",
|
||||
"] show_icon: bool,",
|
||||
"] p2_columns: bool,",
|
||||
"] type_: String,",
|
||||
"pub enum TextComponentPropsVariant {",
|
||||
" SelfValue,",
|
||||
"Self::SelfValue => \"Self\",",
|
||||
] {
|
||||
assert!(leptos.contains(want), "Leptos has {want:?}:\n{leptos}");
|
||||
}
|
||||
// Errors.
|
||||
let r = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})));
|
||||
assert!(s.execute("inspect.componentCode", &json!({"id": r})).is_err(), "not a component");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
use crate::Session;
|
||||
use crate::cmd::file::{export_bytes, export_bytes_with};
|
||||
use figcraft_doc::{ExportFormat, ExportSetting, NodeId, NodeKind};
|
||||
use serde_json::json;
|
||||
|
||||
fn nested(depth: usize) -> (Session, NodeId) {
|
||||
let mut s = Session::new();
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let root = doc.create(doc.first_page().unwrap(), NodeKind::Frame, "deep", 0.0, 0.0, 32.0, 32.0).unwrap();
|
||||
let mut parent = root;
|
||||
for _ in 1..depth {
|
||||
parent = doc.create(parent, NodeKind::Group, "group", 0.0, 0.0, 32.0, 32.0).unwrap();
|
||||
}
|
||||
doc.create(parent, NodeKind::Rectangle, "leaf", 3.0, 5.0, 13.0, 17.0).unwrap();
|
||||
(s, root)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_depth_errors_reach_commands_and_preserve_existing_files() {
|
||||
let (mut s, root) = nested(257);
|
||||
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-export-depth-{}-{stamp}", std::process::id()));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
for format in [ExportFormat::Svg, ExportFormat::Pdf] {
|
||||
let path = dir.join(format!("deep.{}", format.ext()));
|
||||
std::fs::write(&path, b"previous export").unwrap();
|
||||
s.doc_mut().unwrap().get_mut(root).unwrap().exports = vec![ExportSetting { format, ..Default::default() }];
|
||||
let mut commands = vec![
|
||||
("file.export", json!({"ids": [root], "format": format.ext(), "size": "512w", "path": path})),
|
||||
("file.exportLayers", json!({"ids": [root], "dir": dir})),
|
||||
];
|
||||
if format == ExportFormat::Pdf {
|
||||
commands.push(("file.exportFramesPdf", json!({"path": path})));
|
||||
}
|
||||
for (command, params) in commands {
|
||||
let error = s.execute(command, ¶ms).unwrap_err().to_string();
|
||||
assert!(error.contains("maximum export depth of 256"), "{command}: {error}");
|
||||
assert!(error.contains("select an inner layer"), "{command}: {error}");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"previous export");
|
||||
}
|
||||
}
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2, "no staging or partial export files");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_depth_is_relative_to_drawn_roots_including_slice_contents() {
|
||||
let (mut s, root) = nested(257);
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let page = doc.first_page().unwrap();
|
||||
let shallow = doc.create(page, NodeKind::Rectangle, "shallow", 0.0, 0.0, 8.0, 8.0).unwrap();
|
||||
let slice = doc.create(page, NodeKind::Slice, "slice", 0.0, 0.0, 32.0, 32.0).unwrap();
|
||||
let inner = doc.node(root).unwrap().children[0];
|
||||
for format in [ExportFormat::Svg, ExportFormat::Pdf] {
|
||||
assert!(export_bytes(doc, &[shallow], format, 1.0).is_ok());
|
||||
assert!(export_bytes(doc, &[inner], format, 1.0).is_ok());
|
||||
assert!(export_bytes(doc, &[slice], format, 1.0).unwrap_err().to_string().contains("maximum export depth"));
|
||||
let setting = ExportSetting { format, contents_only: false, ..Default::default() };
|
||||
assert!(export_bytes_with(doc, &[shallow], &setting, 1.0).unwrap_err().to_string().contains("maximum export depth"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_depth_is_checked_before_recursive_bounds() {
|
||||
let (mut s, root) = nested(2048);
|
||||
// Unclipped frames require recursive descendant bounds unless preflight refuses them first.
|
||||
s.doc_mut().unwrap().get_mut(root).unwrap().clip = false;
|
||||
for format in [ExportFormat::Svg, ExportFormat::Pdf] {
|
||||
assert!(export_bytes(s.doc().unwrap(), &[root], format, 1.0).unwrap_err().to_string().contains("maximum export depth"));
|
||||
}
|
||||
}
|
||||
|
|
@ -57,3 +57,23 @@ fn class_names_are_valid_and_deep_trees_are_bounded() {
|
|||
// Nesting stops at the depth limit instead of running away.
|
||||
assert!(code.matches("<div").count() <= 14, "{}", code.matches("<div").count());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leptos_wraps_the_markup_in_a_component_with_text_as_string_literals() {
|
||||
let mut s = Session::new();
|
||||
let card = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 120})).unwrap());
|
||||
s.execute("node.set", &json!({"id": card, "props": {"name": "Card"}})).unwrap();
|
||||
let t = id(s.execute("text.create", &json!({"text": "Say \"hi\" {x}\nسلام", "x": 0, "y": 60})).unwrap());
|
||||
s.execute("node.reparent", &json!({"ids": [t], "parent": card})).unwrap();
|
||||
|
||||
let code = code_for(s.doc().unwrap(), card, CodeLang::Leptos);
|
||||
assert!(
|
||||
code.starts_with("use leptos::prelude::*;\n\n#[component]\npub fn Card() -> impl IntoView {\n view! {\n <div class=\"card\">"),
|
||||
"{code}"
|
||||
);
|
||||
// Quotes and braces stay inside Rust string literals; lines break with a self-closed tag.
|
||||
assert!(code.contains(r#">"Say \"hi\" {x}"<br/>"سلام"</p>"#), "{code}");
|
||||
assert!(code.contains("/* card.css */\n") && code.contains(".card {\n position: relative;\n"), "{code}");
|
||||
let out = s.execute("inspect.code", &json!({"id": card, "language": "leptos", "unit": "rem"})).unwrap();
|
||||
assert!(out["code"].as_str().unwrap().contains("width: 12.5rem;"), "{}", out["code"]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -162,6 +162,7 @@ pub static KEYS: &[Binding] = &[
|
|||
k(Essential, "app.actions", &["Cmd+K", "Cmd+/"]).label("Actions…"),
|
||||
k(Essential, "help.shortcuts", &["Ctrl+Shift+?"]).both(),
|
||||
k(Essential, "view.toggleUi", &["Cmd+\\"]),
|
||||
k(Essential, "view.fullscreen", &["Ctrl+Cmd+F"]).win(&["F11"]),
|
||||
k(Essential, "edit.pickColor", &["I", "Ctrl+C"]).win(&["I"]),
|
||||
k(Essential, "app.preferences", &["Cmd+,"]),
|
||||
k(Essential, "file.new", &["Cmd+N"]),
|
||||
|
|
|
|||
|
|
@ -660,7 +660,7 @@ pub struct Session {
|
|||
pub journal: Vec<(String, Value)>,
|
||||
/// Recently used colours.
|
||||
pub recent_colors: Vec<figcraft_color::Color>,
|
||||
/// The last command executed through [`Session::execute`] (for "Repeat").
|
||||
/// The last undoable command executed through [`Session::execute`], excluding "Repeat".
|
||||
pub last_command: Option<(String, Value)>,
|
||||
/// Status / toast message for the UI.
|
||||
pub message: Option<String>,
|
||||
|
|
@ -789,6 +789,10 @@ impl Session {
|
|||
/// as it was.
|
||||
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
// Revert replaces the document's uid: refuse before ending previews or syncing branches.
|
||||
if id == "file.revert" && self.live().doc_uid.is_some() {
|
||||
return Err(EngineError::Disabled(id.into(), "leave the live session before reverting to the saved file".into()));
|
||||
}
|
||||
branches::sync(self);
|
||||
if textpreview::COMMANDS.contains(&id) {
|
||||
// A live preview is no edit: no undo step, normalisation or rollback here.
|
||||
|
|
@ -813,7 +817,7 @@ impl Session {
|
|||
match &result {
|
||||
Ok(_) => {
|
||||
self.after_command(spec, before);
|
||||
if spec.history {
|
||||
if spec.history && id != "edit.repeat" {
|
||||
self.last_command = Some((id.to_string(), params.clone()));
|
||||
}
|
||||
self.journal.push((id.to_string(), params.clone()));
|
||||
|
|
@ -1062,24 +1066,57 @@ impl Session {
|
|||
}
|
||||
|
||||
/// Run `f` as one undo step called `label`: the commands it executes push no steps of their
|
||||
/// own, and when it fails the document, selection, page and history are put back.
|
||||
/// own, and when it fails the documents stay at their group-start state.
|
||||
pub fn grouped<T>(&mut self, label: &str, f: impl FnOnce(&mut Self) -> Result<T>) -> Result<T> {
|
||||
let ds = self.ds_mut()?;
|
||||
let (before, sel, page, saved_len, uid) = (ds.doc.clone(), ds.selection.clone(), ds.page, ds.history.undo.len(), ds.uid);
|
||||
self.ds()?;
|
||||
let before: Vec<_> =
|
||||
self.docs.iter().map(|ds| (ds.uid, ds.doc.clone(), ds.selection.clone(), ds.page, ds.history.undo.len(), ds.history.limit)).collect();
|
||||
let active = self.active;
|
||||
let journal_len = self.journal.len();
|
||||
|
||||
// The steps inside are dropped again below, so they mustn't push the oldest ones out.
|
||||
let limit = std::mem::replace(&mut ds.history.limit, usize::MAX);
|
||||
self.live_hold(uid, true);
|
||||
for ds in &mut self.docs {
|
||||
ds.history.limit = usize::MAX;
|
||||
}
|
||||
for (uid, _, _, _, _, _) in &before {
|
||||
self.live_hold(*uid, true);
|
||||
}
|
||||
let result = f(self);
|
||||
self.live_hold(uid, false);
|
||||
let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) else { return result };
|
||||
ds.history.limit = limit;
|
||||
ds.history.undo.truncate(saved_len);
|
||||
for (uid, _, _, _, _, _) in &before {
|
||||
self.live_hold(*uid, false);
|
||||
}
|
||||
|
||||
if result.is_err() {
|
||||
ds.doc = before;
|
||||
ds.selection = sel;
|
||||
ds.page = page;
|
||||
} else if !Arc::ptr_eq(&before, &ds.doc) {
|
||||
self.record(uid, Some(label), before, sel, page);
|
||||
self.docs.retain(|ds| before.iter().any(|(uid, _, _, _, _, _)| *uid == ds.uid));
|
||||
for (uid, doc, selection, page, undo_len, limit) in &before {
|
||||
if let Some(ds) = self.docs.iter_mut().find(|ds| ds.uid == *uid) {
|
||||
ds.doc = doc.clone();
|
||||
ds.selection = selection.clone();
|
||||
ds.page = *page;
|
||||
ds.history.undo.truncate(*undo_len);
|
||||
ds.history.limit = *limit;
|
||||
}
|
||||
}
|
||||
self.active = active;
|
||||
self.journal.truncate(journal_len);
|
||||
return result;
|
||||
}
|
||||
|
||||
let changed: Vec<_> = before
|
||||
.iter()
|
||||
.filter_map(|(uid, doc, selection, page, _, _)| {
|
||||
let ds = self.docs.iter().find(|d| d.uid == *uid)?;
|
||||
(!Arc::ptr_eq(doc, &ds.doc) && ds.interaction.is_none()).then(|| (*uid, doc.clone(), selection.clone(), *page))
|
||||
})
|
||||
.collect();
|
||||
for ds in &mut self.docs {
|
||||
if let Some((_, _, _, _, undo_len, limit)) = before.iter().find(|before| before.0 == ds.uid) {
|
||||
ds.history.limit = *limit;
|
||||
ds.history.undo.truncate(*undo_len);
|
||||
}
|
||||
}
|
||||
for (uid, doc, selection, page) in changed {
|
||||
self.record(uid, Some(label), doc, selection, page);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
|
@ -1343,6 +1380,8 @@ mod arc_handles_tests;
|
|||
#[cfg(test)]
|
||||
mod autosave_tests;
|
||||
#[cfg(test)]
|
||||
mod batch_rollback_tests;
|
||||
#[cfg(test)]
|
||||
mod binding_lists_tests;
|
||||
#[cfg(test)]
|
||||
mod booleans_tests;
|
||||
|
|
@ -1395,6 +1434,8 @@ mod effects_tests;
|
|||
#[cfg(test)]
|
||||
mod export_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod export_depth_tests;
|
||||
#[cfg(test)]
|
||||
mod extended_chain_tests;
|
||||
#[cfg(test)]
|
||||
mod fig_export_assets_tests;
|
||||
|
|
@ -1453,6 +1494,8 @@ mod nested_override_tests;
|
|||
#[cfg(test)]
|
||||
mod online_tests;
|
||||
#[cfg(test)]
|
||||
mod open_issues_tests;
|
||||
#[cfg(test)]
|
||||
mod outline_options_tests;
|
||||
#[cfg(test)]
|
||||
mod paint_reorder_tests;
|
||||
|
|
@ -1471,7 +1514,7 @@ mod plugin_directory_tests;
|
|||
#[cfg(test)]
|
||||
mod plugin_panels_tests;
|
||||
#[cfg(test)]
|
||||
mod plugins_tests;
|
||||
pub(crate) mod plugins_tests;
|
||||
#[cfg(test)]
|
||||
mod progressive_blur_tests;
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -4,12 +4,14 @@
|
|||
//! made outside a command are all refused or put back; others' edits still arrive; other tabs stay
|
||||
//! editable). Leaving keeps the file open as your copy.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use figcraft_collab::message::{ClientMsg, ServerMsg};
|
||||
use figcraft_collab::{Client, NoRecompute, Outgoing};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::live::{Access, Event, PUBLISHING, Published, Role, VIEW_ONLY};
|
||||
use crate::live_tests::{info, push, relay, serve_as, session};
|
||||
use crate::live::{Access, Event, PUBLISHING, Published, Role, Status, VIEW_ONLY};
|
||||
use crate::live_tests::{Script, info, push, relay, serve_as, session};
|
||||
use crate::tools::ToolId;
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
|
|
@ -133,3 +135,149 @@ fn a_view_link_makes_the_file_read_only() {
|
|||
assert_eq!(s.active_read_only(), None);
|
||||
assert!(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).is_ok());
|
||||
}
|
||||
|
||||
fn saved_file(s: &mut Session) -> std::path::PathBuf {
|
||||
let path = std::env::temp_dir().join(format!("figcraft-live-revert-{}-{}.figcraft", std::process::id(), s.ds().unwrap().uid));
|
||||
s.execute("file.saveVersion", &json!({"name": "Saved"})).unwrap();
|
||||
s.execute("file.save", &json!({"path": path})).unwrap();
|
||||
s.execute("file.rename", &json!({"name": "Unsaved"})).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn revert_refused_unchanged(s: &mut Session, script: &Arc<Mutex<Script>>) {
|
||||
let uid = s.ds().unwrap().uid;
|
||||
let session = s.live().id;
|
||||
let before = format!("{:?}", s.docs);
|
||||
let live = s.live_summary();
|
||||
let journal = s.journal.clone();
|
||||
let last = s.last_command.clone();
|
||||
let message = s.message.clone();
|
||||
let ui = s.ui_requests.clone();
|
||||
let active = s.active;
|
||||
let script_before = {
|
||||
let sc = script.lock().unwrap();
|
||||
(sc.msgs.len(), sc.sent.len(), sc.left)
|
||||
};
|
||||
let result = s.execute("file.revert", &json!({}));
|
||||
assert!(
|
||||
matches!(result, Err(EngineError::Disabled(ref cmd, _)) if cmd == "file.revert"),
|
||||
"expected refusal, got {result:?}; document UID {uid} -> {}, live session {session} -> {}",
|
||||
s.ds().unwrap().uid,
|
||||
s.live().id
|
||||
);
|
||||
assert_eq!(format!("{:?}", s.docs), before, "all document state, including previews and branch tabs, stays intact");
|
||||
assert_eq!(s.live_summary(), live);
|
||||
assert_eq!((s.active, &s.journal, &s.last_command, &s.message, &s.ui_requests), (active, &journal, &last, &message, &ui));
|
||||
let sc = script.lock().unwrap();
|
||||
assert_eq!((sc.msgs.len(), sc.sent.len(), sc.left), script_before, "no sync, presence or leave sent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revert_refuses_live_documents_without_losing_access_or_sync() {
|
||||
for state in ["editing", "viewing", "publishing", "reconnecting", "hosting"] {
|
||||
let (mut s, script) = session();
|
||||
let path = saved_file(&mut s);
|
||||
let uid = s.ds().unwrap().uid;
|
||||
if state == "hosting" {
|
||||
s.execute("collab.host", &json!({})).unwrap();
|
||||
} else {
|
||||
s.execute("collab.publish", &json!({"server": "ws://127.0.0.1:47900/"})).unwrap();
|
||||
}
|
||||
let access = if state == "viewing" { Access::View } else { Access::Edit };
|
||||
let (mut server, me, welcome) = serve_as(s.doc().unwrap(), access);
|
||||
if !matches!(state, "publishing" | "hosting") {
|
||||
let mut i = info(Role::Guest, me);
|
||||
i.access = access;
|
||||
push(&script, vec![Event::Welcome { info: i, peers: vec![] }, welcome.clone()]);
|
||||
s.live_poll();
|
||||
}
|
||||
if state == "reconnecting" {
|
||||
push(&script, vec![Event::Status(Status::Reconnecting { attempt: 1 })]);
|
||||
s.live_poll();
|
||||
}
|
||||
let read_only = match state {
|
||||
"viewing" => Some(VIEW_ONLY),
|
||||
"publishing" => Some(PUBLISHING),
|
||||
_ => None,
|
||||
};
|
||||
assert_eq!(s.active_read_only(), read_only, "{state}");
|
||||
revert_refused_unchanged(&mut s, &script);
|
||||
assert_eq!(s.live().doc_uid, Some(uid), "{state}");
|
||||
assert_eq!(s.active_read_only(), read_only, "{state}");
|
||||
if let Some(why) = read_only {
|
||||
refused(&mut s, "file.rename", json!({"name": "Refused"}), why);
|
||||
}
|
||||
if matches!(state, "publishing" | "hosting") {
|
||||
if state == "publishing" {
|
||||
push(&script, vec![Event::Welcome { info: info(Role::Guest, me), peers: vec![] }]);
|
||||
}
|
||||
push(&script, vec![welcome]);
|
||||
s.live_poll();
|
||||
assert_eq!(s.live().doc_uid, Some(uid), "the welcome still finds its document");
|
||||
}
|
||||
if access == Access::Edit && state != "reconnecting" {
|
||||
s.execute("file.rename", &json!({"name": "Still syncing"})).unwrap();
|
||||
let answers = relay(&script, &mut server, me);
|
||||
assert_eq!(server.document().name, "Still syncing", "{state}");
|
||||
push(&script, answers);
|
||||
s.live_poll();
|
||||
}
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revert_refusal_preserves_previews_and_branch_tabs() {
|
||||
let (mut s, script) = session();
|
||||
let text = s.execute("text.create", &json!({"text": "Preview"})).unwrap()["id"].clone();
|
||||
let path = saved_file(&mut s);
|
||||
s.execute("branch.create", &json!({"name": "Working"})).unwrap();
|
||||
s.execute("doc.switch", &json!({"index": 0})).unwrap();
|
||||
s.execute("collab.host", &json!({})).unwrap();
|
||||
let (_, _, welcome) = serve_as(s.doc().unwrap(), Access::Edit);
|
||||
push(&script, vec![welcome]);
|
||||
s.live_poll();
|
||||
let main_uid = s.ds().unwrap().uid;
|
||||
// A branch has no local save path; refusal keeps its live parent associated.
|
||||
s.execute("doc.switch", &json!({"index": 1})).unwrap();
|
||||
assert!(s.ds().unwrap().path.is_none());
|
||||
revert_refused_unchanged(&mut s, &script);
|
||||
assert_eq!(s.live_of(main_uid).doc_uid, Some(main_uid));
|
||||
s.execute("doc.switch", &json!({"index": 0})).unwrap();
|
||||
s.execute("version.preview", &json!({"index": 0})).unwrap();
|
||||
assert!(s.ds().unwrap().preview.is_some());
|
||||
// A gesture on a branch has not yet been copied back into its main file.
|
||||
s.docs[1].doc_mut().name = "Branch edits".into();
|
||||
revert_refused_unchanged(&mut s, &script);
|
||||
s.execute("version.exitPreview", &json!({})).unwrap();
|
||||
s.execute("text.preview", &json!({"command": "text.style", "params": {"ids": [text], "fontSize": 32}})).unwrap();
|
||||
assert!(s.ds().unwrap().text_preview.is_some());
|
||||
revert_refused_unchanged(&mut s, &script);
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revert_offline_file_works_while_another_file_is_live_and_after_leaving() {
|
||||
let (mut s, script) = session();
|
||||
let path = saved_file(&mut s);
|
||||
let uid = s.ds().unwrap().uid;
|
||||
s.execute("collab.host", &json!({})).unwrap();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let offline_path = saved_file(&mut s);
|
||||
s.execute("file.revert", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().name, "Untitled");
|
||||
assert!(!s.ds().unwrap().is_dirty());
|
||||
assert_eq!(s.ds().unwrap().versions.len(), 1);
|
||||
assert_eq!(s.live_of(uid).doc_uid, Some(uid));
|
||||
assert_eq!(script.lock().unwrap().left, 0);
|
||||
s.execute("doc.switch", &json!({"index": 0})).unwrap();
|
||||
s.execute("collab.leave", &json!({})).unwrap();
|
||||
s.execute("file.revert", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().name, "Untitled");
|
||||
assert!(!s.ds().unwrap().is_dirty());
|
||||
assert_eq!(s.ds().unwrap().versions.len(), 1);
|
||||
assert_eq!(s.live().doc_uid, None);
|
||||
assert_eq!(script.lock().unwrap().left, 1);
|
||||
std::fs::remove_file(path).unwrap();
|
||||
std::fs::remove_file(offline_path).unwrap();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/// the refusal as its output.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum OnRefusal {
|
||||
pub(crate) enum OnRefusal {
|
||||
Fail,
|
||||
Report,
|
||||
}
|
||||
|
|
@ -26,7 +26,7 @@ pub(crate) fn lit(s: &str) -> String {
|
|||
|
||||
/// A module that reads the selection, makes `calls` in order and outputs the selection it read
|
||||
/// (or the first refusal). `tail` runs after the calls (an endless loop, a trap…).
|
||||
fn script(calls: &[(&str, Value)], on_refusal: OnRefusal, tail: &str) -> Vec<u8> {
|
||||
pub(crate) fn script(calls: &[(&str, Value)], on_refusal: OnRefusal, tail: &str) -> Vec<u8> {
|
||||
let mut data = String::new();
|
||||
let mut body = String::from("(local.set $sel (call $reply_read (i32.const 60000) (call $selection)))\n");
|
||||
let mut at = 0usize;
|
||||
|
|
@ -51,6 +51,7 @@ fn script(calls: &[(&str, Value)], on_refusal: OnRefusal, tail: &str) -> Vec<u8>
|
|||
(import "figcraft" "reply_read" (func $reply_read (param i32 i32) (result i32)))
|
||||
(import "figcraft" "set_output" (func $set_output (param i32 i32)))
|
||||
(import "figcraft" "notify" (func $notify (param i32 i32)))
|
||||
(import "figcraft" "log" (func $log (param i32 i32)))
|
||||
(memory (export "memory") 2)
|
||||
{data}
|
||||
(func (export "figcraft_run") (param $n i32) (result i32) (local $sel i32)
|
||||
|
|
@ -113,7 +114,7 @@ fn a_run_reads_the_selection_and_its_edits_are_one_undo_step() {
|
|||
let (layers, undo) = (layer_count(&s), undo_len(&s));
|
||||
let out = ex(&mut s, "plugin.run", json!({"id": "org.test.tidy"}));
|
||||
assert_eq!(out["output"], json!([a.0]), "the plugin read the selection");
|
||||
assert_eq!(out["calls"], json!(4));
|
||||
assert_eq!(out["calls"], json!(6));
|
||||
assert_eq!(node(&s, a).name, "Tidy");
|
||||
assert_eq!(layer_count(&s), layers + 1);
|
||||
assert_eq!(undo_len(&s), undo + 1, "one undo step for the whole run");
|
||||
|
|
@ -196,9 +197,11 @@ fn traps_endless_loops_and_memory_hogs_roll_back() {
|
|||
let a = rect(&mut s, 0.0);
|
||||
ex(&mut s, "select.set", json!({"ids": [a]}));
|
||||
let edit = [("shape.rectangle", json!({"x": 90, "y": 0, "width": 5, "height": 5})), ("object.rename", json!({"name": "Half done"}))];
|
||||
let bulk_output = "(call $set_output (i32.const 0) (i32.const 65536))".repeat(32);
|
||||
let cases = [
|
||||
("org.test.trap", "unreachable", "crashed"),
|
||||
("org.test.loop", "(loop $l (br $l))", "instruction budget"),
|
||||
("org.test.bulk", bulk_output.as_str(), "instruction budget"),
|
||||
("org.test.grow", "(if (i32.eq (memory.grow (i32.const 2000)) (i32.const -1)) (then unreachable))", "crashed"),
|
||||
];
|
||||
for (id, tail, want) in cases {
|
||||
|
|
@ -218,6 +221,26 @@ fn traps_endless_loops_and_memory_hogs_roll_back() {
|
|||
assert!(run_err(&mut s, "org.test.hog").contains("memory limit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exceeding_a_log_limit_rolls_back_the_plugins_edits() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0.0);
|
||||
ex(&mut s, "select.set", json!({"ids": [a]}));
|
||||
let edits = [("object.rename", json!({"name": "Half done"})), ("shape.rectangle", json!({"x": 90, "y": 0, "width": 5, "height": 5}))];
|
||||
let wasm = script(&edits, OnRefusal::Fail, "(call $log (i32.const 80000) (i32.const 8193))");
|
||||
let id = "org.test.log-limit";
|
||||
install(&mut s, manifest(id, "write", &[]), &wasm);
|
||||
let ds = s.ds().unwrap();
|
||||
let (doc, page, selection, undo) = (ds.doc.clone(), ds.page, s.selection(), undo_len(&s));
|
||||
let error = run_err(&mut s, id);
|
||||
assert!(error.contains("log line") && error.contains("8192"), "{error}");
|
||||
assert_eq!(s.doc().unwrap(), doc.as_ref());
|
||||
assert_eq!(s.ds().unwrap().page, page);
|
||||
assert_eq!(s.selection(), selection);
|
||||
assert_eq!(undo_len(&s), undo);
|
||||
assert_eq!(node(&s, a).name, "Rectangle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameters_defaults_and_notifications_reach_the_plugin() {
|
||||
let mut s = Session::new();
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ static ROWS: &[Row] = &[
|
|||
doc(Essential, "Save to version history", &["Alt+Cmd+S"], &["Ctrl+Alt+S"], Bound("file.saveVersion"), ""),
|
||||
doc(Essential, "Run the last plugin", &["Alt+Cmd+P"], &["Ctrl+Alt+P"], Moved("plugins.runLast", &["Alt+Cmd+P"], &["Ctrl+Alt+Shift+P"]),
|
||||
"conflict: the documentation also gives Ctrl+Alt+P to pixel preview on Windows; pixel preview keeps it, the plugin takes Ctrl+Alt+Shift+P"),
|
||||
pin(Essential, "view.fullscreen", &["Ctrl+Cmd+F"], &["F11"]),
|
||||
pin(Essential, "app.preferences", &["Cmd+,"], &[]),
|
||||
pin(Essential, "file.new", &["Cmd+N"], &[]).reserved(BROWSER_TAB),
|
||||
pin(Essential, "file.open", &["Cmd+O"], &[]),
|
||||
|
|
@ -397,7 +398,7 @@ fn counts_match_the_docs() {
|
|||
let reserved = documented.iter().filter(|r| !r.reserved.is_empty()).count();
|
||||
let pinned = ROWS.len() - documented.len();
|
||||
// docs/ui-parity.md quotes these.
|
||||
assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (129, 128, 1, 0, 2, 48));
|
||||
assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (129, 128, 1, 0, 2, 49));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -24,6 +24,42 @@ fn create_select_undo_redo() {
|
|||
assert!(s.doc().unwrap().contains(rid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeat_preserves_command_and_undo_steps() {
|
||||
let mut s = sess();
|
||||
let rid = id(&s.execute("shape.rectangle", &json!({"x": 10, "y": 20, "width": 10, "height": 10})).unwrap());
|
||||
let params = json!({"ids": [rid], "dx": 2, "dy": 3});
|
||||
s.execute("transform.nudge", ¶ms).unwrap();
|
||||
let repeated = Some(("transform.nudge".to_string(), params));
|
||||
for count in 2..=4 {
|
||||
s.execute("edit.repeat", &json!({})).unwrap();
|
||||
assert_eq!(s.last_command, repeated);
|
||||
let bounds = s.doc().unwrap().world_bounds(rid).unwrap();
|
||||
assert_eq!((bounds.x0, bounds.y0), (10.0 + 2.0 * count as f64, 20.0 + 3.0 * count as f64));
|
||||
assert_eq!(s.undo_depth(), (count + 1, 0));
|
||||
}
|
||||
for count in (0..4).rev() {
|
||||
assert!(s.undo().unwrap());
|
||||
let bounds = s.doc().unwrap().world_bounds(rid).unwrap();
|
||||
assert_eq!((bounds.x0, bounds.y0), (10.0 + 2.0 * count as f64, 20.0 + 3.0 * count as f64));
|
||||
}
|
||||
assert_eq!(s.undo_depth(), (1, 4));
|
||||
assert!(s.undo().unwrap());
|
||||
assert!(!s.doc().unwrap().contains(rid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeat_rejects_missing_command_and_self_reference() {
|
||||
for last in [None, Some(("edit.repeat".to_string(), json!({})))] {
|
||||
let mut s = sess();
|
||||
s.last_command = last.clone();
|
||||
assert!(matches!(s.execute("edit.repeat", &json!({})), Err(EngineError::Disabled(cmd, _)) if cmd == "edit.repeat"));
|
||||
assert_eq!(s.last_command, last);
|
||||
assert_eq!(s.undo_depth(), (0, 0));
|
||||
assert!(s.journal.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_set_and_errors_roll_back() {
|
||||
let mut s = sess();
|
||||
|
|
@ -40,6 +76,36 @@ fn node_set_and_errors_roll_back() {
|
|||
assert!(s.execute("no.such", &json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_batch_restores_all_documents_and_history() {
|
||||
let mut s = sess();
|
||||
let original = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
|
||||
s.execute("file.new", &json!({"name": "Second"})).unwrap();
|
||||
s.execute("doc.nextTab", &json!({})).unwrap();
|
||||
let before_journal = s.journal.clone();
|
||||
let before_history: Vec<_> = s.docs.iter().map(|d| d.history.undo.len()).collect();
|
||||
|
||||
let result = s.execute(
|
||||
"command.batch",
|
||||
&json!({
|
||||
"label": "Broken batch",
|
||||
"commands": [
|
||||
{"command": "doc.nextTab"},
|
||||
{"command": "shape.rectangle", "params": {"x": 20, "y": 20, "width": 10, "height": 10}},
|
||||
{"command": "node.set", "params": {"id": 999999, "props": {"name": "must fail"}}}
|
||||
]
|
||||
}),
|
||||
);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(s.active, 0, "the active document is part of the transaction");
|
||||
assert_eq!(s.docs.len(), 2, "a failed batch cannot leave a newly opened tab behind");
|
||||
assert_eq!(s.doc().unwrap().node(original).unwrap().name, "Rectangle");
|
||||
assert_eq!(s.docs[1].doc.node(s.docs[1].page).map_or(0, |page| page.children.len()), 0);
|
||||
assert_eq!(s.docs.iter().map(|d| d.history.undo.len()).collect::<Vec<_>>(), before_history);
|
||||
assert_eq!(s.journal, before_journal, "failed inner commands are not journaled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_ungroup_and_frame() {
|
||||
let mut s = sess();
|
||||
|
|
|
|||
|
|
@ -248,6 +248,35 @@ fn design_panel_edits_reach_every_selected_layer() {
|
|||
assert!(near(rotation(&s, a), 30.0) && flipped(&s, a), "{}", rotation(&s, a));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constrain_proportions_keeps_the_other_side_in_ratio() {
|
||||
let mut s = session();
|
||||
// The Layout panel's link is a per-layer property: typing W or H scales the other side by the
|
||||
// node's own ratio; both sides in one edit stay as typed, and so does an unlinked edit.
|
||||
let r = rect(&mut s, 0.0, 0.0, 100.0, 50.0, None);
|
||||
set(&mut s, &[r], json!({"constrainProportions": true}));
|
||||
set(&mut s, &[r], json!({"width": 200}));
|
||||
assert_eq!(size(&s, r), (200.0, 100.0), "W ×2 makes H ×2");
|
||||
set(&mut s, &[r], json!({"height": 25}));
|
||||
assert_eq!(size(&s, r), (50.0, 25.0), "H 25 makes W follow the ratio");
|
||||
set(&mut s, &[r], json!({"width": 30, "height": 40}));
|
||||
assert_eq!(size(&s, r), (30.0, 40.0), "both sides in one edit stay as typed");
|
||||
set(&mut s, &[r], json!({"constrainProportions": false}));
|
||||
set(&mut s, &[r], json!({"width": 60}));
|
||||
assert_eq!(size(&s, r), (60.0, 40.0), "unlinked: the other side stays put");
|
||||
// The short aliases work the same, and each layer of a selection follows its own ratio.
|
||||
let a = rect(&mut s, 0.0, 0.0, 20.0, 10.0, None);
|
||||
let b = rect(&mut s, 100.0, 0.0, 30.0, 10.0, None);
|
||||
set(&mut s, &[a, b], json!({"constrainProportions": true}));
|
||||
set(&mut s, &[a, b], json!({"h": 20}));
|
||||
assert_eq!((size(&s, a), size(&s, b)), ((40.0, 20.0), (60.0, 20.0)), "each layer keeps its own ratio");
|
||||
// A zero side has no ratio to keep: the edit applies to the side typed only.
|
||||
let z = rect(&mut s, 0.0, 0.0, 100.0, 50.0, None);
|
||||
set(&mut s, &[z], json!({"constrainProportions": true, "height": 0}));
|
||||
set(&mut s, &[z], json!({"width": 20}));
|
||||
assert_eq!(size(&s, z), (20.0, 0.0), "a zero height leaves nothing to scale");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_resize_with_constraints_and_cmd_ignores_them() {
|
||||
let mut s = session();
|
||||
|
|
|
|||
|
|
@ -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 names: Vec<String> = z.file_names().filter(|n| n.starts_with("images/")).map(str::to_string).collect();
|
||||
let mut images = BTreeMap::new();
|
||||
let mut unread = Vec::new();
|
||||
for name in names {
|
||||
let Ok(data) = read_member(&mut z, &name, MAX_MEMBER) else { continue };
|
||||
let hash = name.trim_start_matches("images/").to_string();
|
||||
images.insert(hash, image_data(data));
|
||||
match read_member(&mut z, &name, MAX_MEMBER) {
|
||||
Ok(data) => {
|
||||
images.insert(hash, image_data(data));
|
||||
}
|
||||
Err(e) => unread.push((hash, e)),
|
||||
}
|
||||
}
|
||||
report_lost_images(&mut issues, &unread, own_images.as_ref(), &images);
|
||||
match library::read(&mut z, &images, u64::MAX) {
|
||||
Ok(lib) => doc.published = lib.map(Arc::new),
|
||||
Err(e) => issues.push(format!("the published library was skipped: {e}")),
|
||||
|
|
@ -490,6 +496,42 @@ pub fn load_bytes_with_versions(bytes: &[u8]) -> Result<Loaded, FormatError> {
|
|||
Ok(Loaded { doc, issues, versions, branches })
|
||||
}
|
||||
|
||||
/// Most lost images reported one by one (the rest as a count).
|
||||
const MAX_IMAGE_ISSUES: usize = 20;
|
||||
|
||||
/// Report the images a file lost on loading: members that couldn't be read (damaged, too large)
|
||||
/// and images its document lists but the archive doesn't hold. They are left out, so saving the
|
||||
/// file again would drop them for good.
|
||||
fn report_lost_images(
|
||||
issues: &mut Vec<String>,
|
||||
unread: &[(String, FormatError)],
|
||||
listed: Option<&BTreeSet<String>>,
|
||||
images: &BTreeMap<String, ImageData>,
|
||||
) {
|
||||
let mut lost: Vec<String> = unread
|
||||
.iter()
|
||||
.map(|(hash, e)| {
|
||||
let why = match e {
|
||||
FormatError::Corrupt(m) | FormatError::Io(m) => m.clone(),
|
||||
FormatError::Newer(v) => format!("format version {v}"),
|
||||
};
|
||||
format!("image {hash} couldn't be read ({why}): it is left out, and saving this file again drops it")
|
||||
})
|
||||
.collect();
|
||||
let unread_hashes: BTreeSet<&str> = unread.iter().map(|(h, _)| h.as_str()).collect();
|
||||
for hash in listed.into_iter().flatten() {
|
||||
if !images.contains_key(hash) && !unread_hashes.contains(hash.as_str()) {
|
||||
lost.push(format!("image {hash} is used by the file but its data is missing: it is left out, and saving this file again drops it"));
|
||||
}
|
||||
}
|
||||
let more = lost.len().saturating_sub(MAX_IMAGE_ISSUES);
|
||||
lost.truncate(MAX_IMAGE_ISSUES);
|
||||
issues.extend(lost);
|
||||
if more > 0 {
|
||||
issues.push(format!("{more} more images couldn't be loaded"));
|
||||
}
|
||||
}
|
||||
|
||||
/// The version history kept under `dir` in an archive (none when it has no index), reading at
|
||||
/// most `budget` bytes of snapshots. Damaged entries are skipped and reported in `issues`; nothing
|
||||
/// here fails the load.
|
||||
|
|
@ -630,6 +672,8 @@ pub fn load_file(path: &std::path::Path) -> Result<(Document, Vec<String>), Form
|
|||
#[cfg(test)]
|
||||
mod branch_tests;
|
||||
#[cfg(test)]
|
||||
mod image_tests;
|
||||
#[cfg(test)]
|
||||
mod large_tests;
|
||||
#[cfg(test)]
|
||||
mod version_tests;
|
||||
|
|
|
|||
|
|
@ -88,9 +88,25 @@ impl Polyline {
|
|||
/// Every subpath of `path` flattened. `None` when the path has invalid coordinates or is too
|
||||
/// long to work with.
|
||||
pub fn polylines(path: &BezPath) -> Option<Vec<Polyline>> {
|
||||
polylines_with_limit(path, MAX_SAMPLES)
|
||||
}
|
||||
|
||||
/// [`polylines`] with an explicit sample budget, so tests can use a small one.
|
||||
///
|
||||
/// The budget is enforced *while* flattening, not after it (issue #204): the input element
|
||||
/// count is checked up front, each curve is flattened on its own, and flattening stops as
|
||||
/// soon as the budget is exceeded. An over-budget path therefore costs at most one extra
|
||||
/// segment of flattening work and never materialises more than `limit` retained points,
|
||||
/// instead of building the complete flattened result only to throw it away.
|
||||
fn polylines_with_limit(path: &BezPath, limit: usize) -> Option<Vec<Polyline>> {
|
||||
if !finite_path(path) {
|
||||
return None;
|
||||
}
|
||||
// Every element contributes at least one flattened sample, so a path with more
|
||||
// elements than the budget can be refused without flattening anything at all.
|
||||
if path.elements().len() > limit {
|
||||
return None;
|
||||
}
|
||||
fn finish(cur: &mut Polyline, out: &mut Vec<Polyline>) {
|
||||
let done = std::mem::take(cur);
|
||||
if done.points.len() >= 2 {
|
||||
|
|
@ -100,29 +116,128 @@ pub fn polylines(path: &BezPath) -> Option<Vec<Polyline>> {
|
|||
let mut out: Vec<Polyline> = Vec::new();
|
||||
let mut cur = Polyline::default();
|
||||
let mut start = Point::ZERO;
|
||||
let mut pen = Point::ZERO;
|
||||
let mut count = 0usize;
|
||||
kurbo::flatten(path.iter(), FLATTEN_TOL, |el| {
|
||||
count += 1;
|
||||
match el {
|
||||
for el in path.elements() {
|
||||
if count > limit {
|
||||
return None;
|
||||
}
|
||||
match *el {
|
||||
PathEl::MoveTo(p) => {
|
||||
count += 1;
|
||||
finish(&mut cur, &mut out);
|
||||
start = p;
|
||||
pen = p;
|
||||
cur.push(p);
|
||||
}
|
||||
PathEl::LineTo(p) => {
|
||||
count += 1;
|
||||
if cur.points.is_empty() {
|
||||
cur.push(start);
|
||||
}
|
||||
cur.push(p);
|
||||
pen = p;
|
||||
}
|
||||
PathEl::QuadTo(_, end) | PathEl::CurveTo(_, _, end) => {
|
||||
// Flatten this one segment on its own, so an over-budget curve aborts
|
||||
// here instead of after the whole path has been flattened.
|
||||
let seg_start = pen;
|
||||
let mut over = false;
|
||||
kurbo::flatten([PathEl::MoveTo(seg_start), *el], FLATTEN_TOL, |fel| {
|
||||
if over {
|
||||
return;
|
||||
}
|
||||
if let PathEl::LineTo(p) = fel {
|
||||
count += 1;
|
||||
if count > limit {
|
||||
over = true;
|
||||
return;
|
||||
}
|
||||
if cur.points.is_empty() {
|
||||
cur.push(seg_start);
|
||||
}
|
||||
cur.push(p);
|
||||
}
|
||||
});
|
||||
if over {
|
||||
return None;
|
||||
}
|
||||
pen = end;
|
||||
}
|
||||
PathEl::ClosePath => {
|
||||
count += 1;
|
||||
cur.push(start);
|
||||
cur.closed = cur.points.len() >= 3;
|
||||
pen = start;
|
||||
finish(&mut cur, &mut out);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
finish(&mut cur, &mut out);
|
||||
(count <= MAX_SAMPLES).then_some(out)
|
||||
(count <= limit).then_some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn line_path(n: usize) -> BezPath {
|
||||
let mut p = BezPath::new();
|
||||
p.move_to((0.0, 0.0));
|
||||
for i in 1..=n {
|
||||
p.line_to((i as f64, 0.0));
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_open_and_closed_paths_are_preserved() {
|
||||
let open = polylines_with_limit(&line_path(3), 100).expect("under budget");
|
||||
assert_eq!(open.len(), 1);
|
||||
assert_eq!(open[0].points.len(), 4);
|
||||
assert!((open[0].length() - 3.0).abs() < 1e-9);
|
||||
assert!(!open[0].closed);
|
||||
|
||||
let mut square = BezPath::new();
|
||||
square.move_to((0.0, 0.0));
|
||||
square.line_to((10.0, 0.0));
|
||||
square.line_to((10.0, 10.0));
|
||||
square.line_to((0.0, 10.0));
|
||||
square.close_path();
|
||||
let closed = polylines_with_limit(&square, 100).expect("under budget");
|
||||
assert_eq!(closed.len(), 1);
|
||||
assert!(closed[0].closed);
|
||||
assert!((closed[0].length() - 40.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_budget_line_path_is_refused() {
|
||||
// 51 elements against a budget of 10: refused by the up-front element check.
|
||||
assert!(polylines_with_limit(&line_path(50), 10).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_budget_curve_path_is_refused_early() {
|
||||
// One big cubic flattens to far more than 8 samples at FLATTEN_TOL; the
|
||||
// per-segment flattening must trip the budget and refuse the path.
|
||||
let mut p = BezPath::new();
|
||||
p.move_to((0.0, 0.0));
|
||||
p.curve_to((0.0, 1_000.0), (1_000.0, 1_000.0), (1_000.0, 0.0));
|
||||
assert!(polylines_with_limit(&p, 8).is_none());
|
||||
// The same curve is accepted with the production budget.
|
||||
assert!(polylines(&p).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curve_endpoints_and_length_still_measure() {
|
||||
let mut p = BezPath::new();
|
||||
p.move_to((0.0, 0.0));
|
||||
p.curve_to((0.0, 100.0), (100.0, 100.0), (100.0, 0.0));
|
||||
let lines = polylines(&p).expect("valid curve");
|
||||
assert_eq!(lines.len(), 1);
|
||||
assert_eq!(lines[0].points.first().copied(), Some(Point::new(0.0, 0.0)));
|
||||
assert_eq!(lines[0].points.last().copied(), Some(Point::new(100.0, 0.0)));
|
||||
// The flattened curve is longer than its chord and shorter than its control polygon.
|
||||
assert!(lines[0].length() > 100.0 && lines[0].length() < 300.0);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,10 +133,20 @@ impl Attached {
|
|||
Err(last)
|
||||
}
|
||||
|
||||
fn roundtrip(&mut self, line: &str) -> std::io::Result<String> {
|
||||
if self.conn.is_none() {
|
||||
self.reconnect()?;
|
||||
/// The app already closed this connection (it drops ones idle past its socket timeout), or
|
||||
/// left bytes on it nobody asked for. Checked before a request is written, when replacing the
|
||||
/// connection can't repeat anything.
|
||||
fn stale(&mut self) -> bool {
|
||||
let Some((reader, writer)) = self.conn.as_mut() else { return false };
|
||||
if !reader.buffer().is_empty() || writer.set_nonblocking(true).is_err() {
|
||||
return true;
|
||||
}
|
||||
// Nothing to read on a healthy idle connection: the peek would block.
|
||||
let idle = matches!(writer.peek(&mut [0]), Err(e) if e.kind() == std::io::ErrorKind::WouldBlock);
|
||||
writer.set_nonblocking(false).is_err() || !idle
|
||||
}
|
||||
|
||||
fn roundtrip(&mut self, line: &str) -> std::io::Result<String> {
|
||||
let Some((reader, writer)) = self.conn.as_mut() else {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::NotConnected, "not connected to the app"));
|
||||
};
|
||||
|
|
@ -156,17 +166,19 @@ impl Backend for Attached {
|
|||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
let line = json!({"id": id, "method": method, "params": params}).to_string();
|
||||
// One retry with a fresh connection (the app may have restarted).
|
||||
let reply = match self.roundtrip(&line) {
|
||||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
self.conn = None;
|
||||
self.roundtrip(&line).map_err(|e| {
|
||||
self.conn = None;
|
||||
format!("FigCraft app at {} is not reachable: {e} (start it with `figcraft --control PORT`)", self.addr)
|
||||
})?
|
||||
}
|
||||
};
|
||||
if self.conn.is_none() || self.stale() {
|
||||
self.reconnect().map_err(|e| {
|
||||
format!("FigCraft app at {} is not reachable: {e} (request was not sent; start it with `figcraft --control PORT`)", self.addr)
|
||||
})?;
|
||||
}
|
||||
// A lost reply does not mean the app failed to apply the request.
|
||||
let reply = self.roundtrip(&line).map_err(|e| {
|
||||
self.conn = None;
|
||||
format!(
|
||||
"FigCraft control call `{method}` at {} failed: {e}; the operation may have completed. Inspect the document before retrying.",
|
||||
self.addr
|
||||
)
|
||||
})?;
|
||||
let v: Value = serde_json::from_str(reply.trim()).map_err(|e| format!("bad reply from the app: {e}"))?;
|
||||
if v.get("ok").and_then(Value::as_bool) == Some(true) {
|
||||
Ok(v.get("result").cloned().unwrap_or(Value::Null))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
//! JSON-RPC 2.0 framing and the MCP lifecycle / tools / resources / prompts methods.
|
||||
|
||||
use std::io::{BufRead, Write};
|
||||
use std::io::{BufRead, Read, Write};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
|
|
@ -76,10 +76,22 @@ impl Server {
|
|||
}
|
||||
|
||||
/// Serve newline-delimited JSON-RPC until `input` closes. stdout is the protocol stream; logs
|
||||
/// go to stderr.
|
||||
pub fn serve(&mut self, input: impl BufRead, mut output: impl Write) -> std::io::Result<()> {
|
||||
for line in input.split(b'\n') {
|
||||
let line = line?;
|
||||
/// go to stderr. An oversized frame gets an error and closes the stream without draining it.
|
||||
pub fn serve(&mut self, mut input: impl BufRead, mut output: impl Write) -> std::io::Result<()> {
|
||||
let mut line = Vec::new();
|
||||
loop {
|
||||
line.clear();
|
||||
if (&mut input).take((MAX_LINE + 1) as u64).read_until(b'\n', &mut line)? == 0 {
|
||||
break;
|
||||
}
|
||||
if line.last() == Some(&b'\n') {
|
||||
line.pop();
|
||||
}
|
||||
if line.len() > MAX_LINE {
|
||||
writeln!(output, "{}", error(Value::Null, INVALID_REQUEST, "message too large"))?;
|
||||
output.flush()?;
|
||||
break;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&line);
|
||||
if let Some(reply) = self.handle_line(&text) {
|
||||
output.write_all(reply.as_bytes())?;
|
||||
|
|
@ -92,13 +104,13 @@ impl Server {
|
|||
|
||||
/// Handle one line; returns the reply line (None for notifications and blank lines).
|
||||
pub fn handle_line(&mut self, line: &str) -> Option<String> {
|
||||
if line.len() > MAX_LINE {
|
||||
return Some(error(Value::Null, INVALID_REQUEST, "message too large").to_string());
|
||||
}
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if line.len() > MAX_LINE {
|
||||
return Some(error(Value::Null, INVALID_REQUEST, "message too large").to_string());
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(line) {
|
||||
Ok(Value::Array(batch)) => {
|
||||
// Batches were removed in 2025-06-18; still answer older clients sensibly.
|
||||
|
|
|
|||
|
|
@ -70,3 +70,80 @@ fn attached_to_nothing_fails_cleanly() {
|
|||
let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
|
||||
assert!(Attached::port(port).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lost_mutation_reply_is_not_replayed_and_next_call_reconnects() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let app = std::thread::spawn(move || {
|
||||
let mut h = Headless::new();
|
||||
let mut methods = Vec::new();
|
||||
for lose_reply in [true, false] {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
stream.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap();
|
||||
let mut line = String::new();
|
||||
BufReader::new(stream.try_clone().unwrap()).read_line(&mut line).unwrap();
|
||||
let request: Value = serde_json::from_str(&line).unwrap();
|
||||
let method = request["method"].as_str().unwrap();
|
||||
methods.push(method.to_string());
|
||||
let result = h.call(method, request["params"].clone()).unwrap();
|
||||
if !lose_reply {
|
||||
writeln!(stream, "{}", json!({"id": request["id"], "ok": true, "result": result})).unwrap();
|
||||
}
|
||||
}
|
||||
methods
|
||||
});
|
||||
let mut backend = Attached::port(port).unwrap();
|
||||
let result = backend.call("engine.execute", json!({"command": "shape.rectangle", "params": {"width": 64, "height": 32}}));
|
||||
assert!(result.is_err(), "a lost reply must not turn into a successful replay: {result:?}");
|
||||
let error = result.unwrap_err();
|
||||
assert!(error.contains("may have completed"), "{error}");
|
||||
assert!(error.contains("before retrying"), "{error}");
|
||||
let doc = backend.call("document.inspect", json!({"depth": 1})).unwrap();
|
||||
assert_eq!(doc["pages"][0]["children"].as_array().unwrap().len(), 1, "{doc}");
|
||||
assert_eq!(app.join().unwrap(), ["engine.execute", "document.inspect"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_connection_the_app_closed_while_idle_is_replaced_before_sending() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let (closed_tx, closed_rx) = std::sync::mpsc::channel();
|
||||
let app = std::thread::spawn(move || {
|
||||
let mut h = Headless::new();
|
||||
let mut methods = Vec::new();
|
||||
// Each connection answers one request, then the app drops it as it does an idle one.
|
||||
for _ in 0..2 {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
stream.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap();
|
||||
let mut line = String::new();
|
||||
BufReader::new(stream.try_clone().unwrap()).read_line(&mut line).unwrap();
|
||||
let request: Value = serde_json::from_str(&line).unwrap();
|
||||
let method = request["method"].as_str().unwrap();
|
||||
methods.push(method.to_string());
|
||||
let result = h.call(method, request["params"].clone()).unwrap();
|
||||
writeln!(stream, "{}", json!({"id": request["id"], "ok": true, "result": result})).unwrap();
|
||||
drop(stream);
|
||||
closed_tx.send(()).unwrap();
|
||||
}
|
||||
methods
|
||||
});
|
||||
let mut backend = Attached::port(port).unwrap();
|
||||
backend.call("engine.execute", json!({"command": "shape.rectangle", "params": {"width": 64, "height": 32}})).unwrap();
|
||||
closed_rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
// No error, nothing sent twice: the request goes out once, on a fresh connection.
|
||||
let doc = backend.call("document.inspect", json!({"depth": 1})).unwrap();
|
||||
assert_eq!(doc["pages"][0]["children"].as_array().unwrap().len(), 1, "{doc}");
|
||||
assert_eq!(app.join().unwrap(), ["engine.execute", "document.inspect"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconnect_failure_reports_request_not_sent() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let mut backend = Attached::port(listener.local_addr().unwrap().port()).unwrap();
|
||||
drop(listener);
|
||||
assert!(backend.call("document.inspect", json!({})).is_err());
|
||||
let error = backend.call("document.inspect", json!({})).unwrap_err();
|
||||
assert!(error.contains("request was not sent"), "{error}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -172,3 +172,39 @@ fn serve_over_streams() {
|
|||
assert_eq!(lines[1]["error"]["code"], -32700);
|
||||
assert_eq!(lines[2]["id"], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serve_rejects_oversized_frames_before_consuming_the_stream() {
|
||||
use std::io::{BufReader, Read};
|
||||
|
||||
const LIMIT: u64 = 32 * 1024 * 1024;
|
||||
for terminated in [false, true] {
|
||||
let mut oversized = std::io::repeat(b' ').take(LIMIT + 8192);
|
||||
let tail: &[u8] = if terminated { b"\n{\"method\":\"notifications/initialized\"}\n" } else { b"" };
|
||||
let mut input = BufReader::with_capacity(256, (&mut oversized).chain(tail));
|
||||
let mut output = Vec::new();
|
||||
let mut server = Server::headless();
|
||||
server.serve(&mut input, &mut output).unwrap();
|
||||
assert!(!server.is_initialized(), "no input following the oversized frame is dispatched");
|
||||
assert!(oversized.limit() >= 8192 - 256, "stop after the limit plus one read buffer");
|
||||
let reply: Value = serde_json::from_slice(&output).unwrap();
|
||||
assert_eq!(reply["error"]["code"], -32600);
|
||||
assert_eq!(reply["error"]["message"], "message too large");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serve_accepts_a_limit_sized_frame_and_a_final_unterminated_frame() {
|
||||
use std::io::{BufReader, Read};
|
||||
|
||||
const LIMIT: u64 = 32 * 1024 * 1024;
|
||||
let ping = b"{\"id\":1,\"method\":\"ping\"}";
|
||||
let padding = std::io::repeat(b' ').take(LIMIT - ping.len() as u64);
|
||||
let input = (&ping[..]).chain(padding).chain(&b"\n\r\n{\"id\":2,\"method\":\"ping\"}"[..]);
|
||||
let mut output = Vec::new();
|
||||
Server::headless().serve(BufReader::new(input), &mut output).unwrap();
|
||||
let lines: Vec<Value> = String::from_utf8(output).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
assert_eq!(lines.len(), 2);
|
||||
assert_eq!(lines[0]["result"], json!({}));
|
||||
assert_eq!(lines[1]["id"], 2);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
use super::*;
|
||||
use crate::tests::inflated;
|
||||
use figcraft_color::{Color, Paint};
|
||||
use figcraft_doc::NodeKind;
|
||||
|
||||
fn nested(depth: usize) -> (Document, NodeId, NodeId) {
|
||||
let mut doc = Document::new("export depth");
|
||||
let page = doc.first_page().unwrap();
|
||||
let root = doc.create(page, NodeKind::Frame, "root", 0.0, 0.0, 32.0, 32.0).unwrap();
|
||||
let mut parent = root;
|
||||
for _ in 1..depth {
|
||||
parent = doc.create(parent, NodeKind::Group, "group", 0.0, 0.0, 32.0, 32.0).unwrap();
|
||||
}
|
||||
let leaf = doc.create(parent, NodeKind::Rectangle, "deep-leaf", 3.0, 5.0, 13.0, 17.0).unwrap();
|
||||
doc.get_mut(leaf).unwrap().fills = vec![Paint::solid(Color::from_hex("FF0000").unwrap())];
|
||||
(doc, root, leaf)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_depth_boundary_keeps_the_leaf() {
|
||||
for depth in [255, 256] {
|
||||
let (doc, root, _) = nested(depth);
|
||||
let pdf = export_pdf(&doc, &[root], &PdfOptions::default()).unwrap();
|
||||
assert!(inflated(&pdf).contains("1 0 0 rg"), "the red leaf at depth {depth} was omitted");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_depth_overflow_is_refused_by_every_public_entry_point() {
|
||||
let (doc, root, leaf) = nested(257);
|
||||
let opts = PdfOptions::default();
|
||||
let error = || PdfError::Depth(figcraft_render::ExportDepthError { root, node: leaf });
|
||||
assert_eq!(export_pdf(&doc, &[root], &opts), Err(error()));
|
||||
assert_eq!(export_nodes_pdf(&doc, &[root]), Err(error()));
|
||||
assert_eq!(export_groups(&doc, &[vec![root]], &opts), Err(error()));
|
||||
let inner = doc.node(root).unwrap().children[0];
|
||||
assert!(inflated(&export_pdf(&doc, &[inner], &opts).unwrap()).contains("1 0 0 rg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_depth_checks_every_page_and_explicit_area() {
|
||||
let (mut doc, root, leaf) = nested(257);
|
||||
let shallow = doc.create(doc.first_page().unwrap(), NodeKind::Rectangle, "shallow", 0.0, 0.0, 8.0, 8.0).unwrap();
|
||||
let opts = PdfOptions { area: Some(Rect::new(0.0, 0.0, 32.0, 32.0)), ..Default::default() };
|
||||
assert!(export_pdf(&doc, &[shallow], &opts).is_ok());
|
||||
assert_eq!(
|
||||
export_groups(&doc, &[vec![shallow], vec![root]], &opts),
|
||||
Err(PdfError::Depth(figcraft_render::ExportDepthError { root, node: leaf }))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_depth_ignores_hidden_subtrees_and_checks_masks_and_raster_fallbacks() {
|
||||
let (mut doc, root, _) = nested(257);
|
||||
let inner = doc.node(root).unwrap().children[0];
|
||||
doc.get_mut(inner).unwrap().visible = false;
|
||||
assert!(export_pdf(&doc, &[root], &PdfOptions::default()).is_ok());
|
||||
doc.get_mut(inner).unwrap().visible = true;
|
||||
doc.get_mut(inner).unwrap().is_mask = true;
|
||||
assert!(matches!(export_pdf(&doc, &[root], &PdfOptions::default()), Err(PdfError::Depth(_))));
|
||||
doc.get_mut(inner).unwrap().is_mask = false;
|
||||
doc.get_mut(root).unwrap().effects =
|
||||
vec![figcraft_doc::Effect { kind: figcraft_doc::EffectKind::LayerBlur(figcraft_doc::Blur::uniform(1.0)), visible: true }];
|
||||
assert!(matches!(export_pdf(&doc, &[root], &PdfOptions::default()), Err(PdfError::Depth(_))));
|
||||
}
|
||||
|
|
@ -18,7 +18,7 @@ use crate::PdfOptions;
|
|||
use crate::print::Ink;
|
||||
|
||||
/// Deepest node nesting drawn (the renderer's limit).
|
||||
pub(crate) const MAX_DEPTH: usize = 256;
|
||||
pub(crate) const MAX_DEPTH: usize = figcraft_render::MAX_EXPORT_DEPTH;
|
||||
/// Largest side of any raster made for the PDF, in pixels.
|
||||
pub(crate) const MAX_RASTER_SIDE: f64 = 4096.0;
|
||||
|
||||
|
|
|
|||
|
|
@ -66,6 +66,8 @@ impl Default for PdfOptions {
|
|||
pub enum PdfError {
|
||||
#[error("nothing to export")]
|
||||
Empty,
|
||||
#[error(transparent)]
|
||||
Depth(#[from] figcraft_render::ExportDepthError),
|
||||
#[error("no such node {0}")]
|
||||
NoNode(NodeId),
|
||||
#[error("node {0} has no exportable area")]
|
||||
|
|
@ -93,6 +95,10 @@ pub fn export_nodes_pdf(doc: &Document, ids: &[NodeId]) -> Result<Vec<u8>, PdfEr
|
|||
/// One page per group of ids. For print, a page of one frame is trimmed to the frame's box (its
|
||||
/// shadows and outside strokes go into the bleed).
|
||||
pub fn export_groups(doc: &Document, groups: &[Vec<NodeId>], opts: &PdfOptions) -> Result<Vec<u8>, PdfError> {
|
||||
// All pages are checked before bounds, raster fallbacks or PDF writing start.
|
||||
for group in groups {
|
||||
figcraft_render::check_export_depth(doc, group)?;
|
||||
}
|
||||
let mut pages: Vec<(Vec<NodeId>, Rect)> = Vec::new();
|
||||
for g in groups.iter().filter(|g| !g.is_empty()) {
|
||||
let mut bounds: Option<Rect> = None;
|
||||
|
|
@ -208,6 +214,8 @@ fn write(doc: &Document, pages: &[(Vec<NodeId>, Rect)], opts: &PdfOptions) -> Re
|
|||
#[cfg(test)]
|
||||
mod backdrop_tests;
|
||||
#[cfg(test)]
|
||||
mod depth_tests;
|
||||
#[cfg(test)]
|
||||
mod effect_order_tests;
|
||||
#[cfg(test)]
|
||||
mod export_tests;
|
||||
|
|
|
|||
|
|
@ -240,14 +240,15 @@ fn hostile_inputs_error_or_skip() {
|
|||
if let NodeKind::Text(td) = &mut d.get_mut(t).unwrap().kind {
|
||||
td.style.font_size = f64::NAN;
|
||||
}
|
||||
// Deep nesting beyond the depth limit.
|
||||
let pdf = export_pdf(&d, &[f], &PdfOptions { raster_scale: f64::NAN, ..Default::default() }).unwrap();
|
||||
assert!(pdf.starts_with(b"%PDF"));
|
||||
// Deep nesting beyond the depth limit must refuse the export, not return partial artwork.
|
||||
let mut parent = f;
|
||||
for _ in 0..400 {
|
||||
parent = d.create(parent, NodeKind::Group, "g", 0.0, 0.0, 1.0, 1.0).unwrap();
|
||||
}
|
||||
d.create(parent, NodeKind::Rectangle, "deep", 0.0, 0.0, 5.0, 5.0).unwrap();
|
||||
let pdf = export_pdf(&d, &[f], &PdfOptions { raster_scale: f64::NAN, ..Default::default() }).unwrap();
|
||||
assert!(pdf.starts_with(b"%PDF"));
|
||||
assert!(matches!(export_pdf(&d, &[f], &PdfOptions::default()), Err(PdfError::Depth(_))));
|
||||
// A zero-size node still exports (as a tiny page); a NaN-sized frame errors or exports.
|
||||
let z = d.create(p, NodeKind::Rectangle, "Zero", 0.0, 0.0, 0.0, 0.0).unwrap();
|
||||
assert!(export_pdf(&d, &[z], &PdfOptions::default()).is_ok());
|
||||
|
|
|
|||
|
|
@ -63,7 +63,8 @@ const IMPORTS: &[(&str, usize, usize)] = &[
|
|||
/// What a run may use.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Limits {
|
||||
/// Instruction budget (wasmi fuel). Each host call costs [`Limits::call_fuel`] more.
|
||||
/// Instruction budget (wasmi fuel). Each host call costs [`Limits::call_fuel`] more,
|
||||
/// plus one fuel per byte copied out of or into guest memory.
|
||||
pub fuel: u64,
|
||||
pub call_fuel: u64,
|
||||
/// Linear memory cap in bytes.
|
||||
|
|
@ -74,8 +75,12 @@ pub struct Limits {
|
|||
pub max_calls: u32,
|
||||
/// Largest string passed either way (command params, results, logs).
|
||||
pub max_message: usize,
|
||||
/// Log lines kept per run.
|
||||
/// Log lines kept and forwarded per run. Exceeding any log limit fails the run.
|
||||
pub max_logs: usize,
|
||||
/// Largest log line in UTF-8 bytes, also subject to [`Limits::max_message`].
|
||||
pub max_log_line_bytes: usize,
|
||||
/// Total log bytes kept and forwarded per run (excluding string/vector overhead).
|
||||
pub max_log_bytes: usize,
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
|
|
@ -88,6 +93,8 @@ impl Default for Limits {
|
|||
max_calls: 100_000,
|
||||
max_message: 16 * 1024 * 1024,
|
||||
max_logs: 1000,
|
||||
max_log_line_bytes: 8 * 1024,
|
||||
max_log_bytes: 1024 * 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -118,7 +125,8 @@ pub trait Host {
|
|||
fn selection(&mut self) -> String;
|
||||
/// A message for the user.
|
||||
fn notify(&mut self, message: &str);
|
||||
/// A line of the run's log, as the plugin writes it (it's also in [`RunOutput::logs`]).
|
||||
/// An admitted line of the run's log (also in [`RunOutput::logs`]). Log limits apply before
|
||||
/// this is called, including on runs that later fail.
|
||||
fn log(&mut self, _line: &str) {}
|
||||
/// The plugin's setting `key` (JSON text).
|
||||
fn setting(&mut self, _key: &str) -> Option<String> {
|
||||
|
|
@ -171,6 +179,7 @@ struct State {
|
|||
store_limits: StoreLimits,
|
||||
reply: Vec<u8>,
|
||||
out: RunOutput,
|
||||
log_bytes: usize,
|
||||
/// A limit the host tripped (reported instead of the trap it causes).
|
||||
abort: Option<PluginError>,
|
||||
request: Option<Request>,
|
||||
|
|
@ -200,9 +209,7 @@ impl State {
|
|||
Request::Selection => Some(self.set_reply(host.selection().into_bytes())),
|
||||
Request::Log(line) => {
|
||||
host.log(&line);
|
||||
if self.out.logs.len() < self.limits.max_logs {
|
||||
self.out.logs.push(line);
|
||||
}
|
||||
self.out.logs.push(line);
|
||||
None
|
||||
}
|
||||
Request::Notify(msg) => {
|
||||
|
|
@ -275,10 +282,10 @@ fn compile(engine: &Engine, bytes: &[u8], limits: &Limits) -> Result<Module, Plu
|
|||
}
|
||||
}
|
||||
let export = |name: &str| module.exports().find(|e| e.name() == name).map(|e| e.ty().clone());
|
||||
if !export("memory").is_some_and(|t| t.memory().is_some()) {
|
||||
if export("memory").is_none_or(|t| t.memory().is_none()) {
|
||||
return Err(PluginError::InvalidModule("it doesn't export its `memory`".into()));
|
||||
}
|
||||
if !export(ENTRY).is_some_and(|t| t.func() == Some(&func_type(1, 1))) {
|
||||
if export(ENTRY).is_none_or(|t| t.func() != Some(&func_type(1, 1))) {
|
||||
return Err(PluginError::InvalidModule(format!("it doesn't export `{ENTRY}(input_len: i32) -> i32`")));
|
||||
}
|
||||
Ok(module)
|
||||
|
|
@ -308,7 +315,7 @@ impl Instance {
|
|||
let module = compile(&engine, bytes, limits)?;
|
||||
let store_limits =
|
||||
StoreLimitsBuilder::new().memory_size(limits.memory_bytes).memories(1).tables(4).table_elements(100_000).instances(1).build();
|
||||
let state = State { limits: *limits, store_limits, reply: Vec::new(), out: RunOutput::default(), abort: None, request: None };
|
||||
let state = State { limits: *limits, store_limits, reply: Vec::new(), out: RunOutput::default(), log_bytes: 0, abort: None, request: None };
|
||||
let mut store = Store::new(&engine, state);
|
||||
store.limiter(|s| &mut s.store_limits);
|
||||
store.set_fuel(limits.fuel).map_err(|e| PluginError::Trap(e.to_string()))?;
|
||||
|
|
@ -327,6 +334,7 @@ impl Instance {
|
|||
let st = self.store.data_mut();
|
||||
st.reply = input.as_bytes().to_vec();
|
||||
st.out = RunOutput::default();
|
||||
st.log_bytes = 0;
|
||||
st.abort = None;
|
||||
st.request = None;
|
||||
self.store.set_fuel(limits.fuel).map_err(|e| PluginError::Trap(e.to_string()))?;
|
||||
|
|
@ -392,11 +400,7 @@ fn ask(caller: &mut Ctx<'_>, request: Request) -> wasmi::Error {
|
|||
/// Charge a host call: fuel and the call count.
|
||||
fn charge(caller: &mut Ctx<'_>) -> Result<(), wasmi::Error> {
|
||||
let cost = caller.data().limits.call_fuel;
|
||||
let left = caller.get_fuel()?;
|
||||
if left < cost {
|
||||
return Err(abort(caller, PluginError::OutOfFuel));
|
||||
}
|
||||
caller.set_fuel(left - cost)?;
|
||||
spend_fuel(caller, cost)?;
|
||||
let st = caller.data_mut();
|
||||
st.out.calls = st.out.calls.saturating_add(1);
|
||||
if st.out.calls > st.limits.max_calls {
|
||||
|
|
@ -406,12 +410,21 @@ fn charge(caller: &mut Ctx<'_>) -> Result<(), wasmi::Error> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Reserve work before copying or allocating, including when call overhead is disabled.
|
||||
fn spend_fuel(caller: &mut Ctx<'_>, cost: u64) -> Result<(), wasmi::Error> {
|
||||
let Some(left) = caller.get_fuel()?.checked_sub(cost) else {
|
||||
return Err(abort(caller, PluginError::OutOfFuel));
|
||||
};
|
||||
caller.set_fuel(left)
|
||||
}
|
||||
|
||||
/// The bytes at `(ptr, len)` in the plugin's memory.
|
||||
fn read_bytes(caller: &mut Ctx<'_>, ptr: i32, len: i32) -> Result<Vec<u8>, wasmi::Error> {
|
||||
let (ptr, len) = (ptr as u32 as usize, len as u32 as usize);
|
||||
if len > caller.data().limits.max_message {
|
||||
return Err(abort(caller, PluginError::Limit(format!("a {len}-byte string is too long"))));
|
||||
}
|
||||
spend_fuel(caller, len as u64)?;
|
||||
let Some(mem) = caller.get_export("memory").and_then(Extern::into_memory) else {
|
||||
return Err(abort(caller, PluginError::InvalidModule("no memory".into())));
|
||||
};
|
||||
|
|
@ -427,6 +440,28 @@ fn read_str(caller: &mut Ctx<'_>, ptr: i32, len: i32) -> Result<String, wasmi::E
|
|||
String::from_utf8(bytes).map_err(|_| abort(caller, PluginError::Trap("a string isn't valid UTF-8".into())))
|
||||
}
|
||||
|
||||
/// Admit a log before copying guest memory. The same budgets bound retained logs and `Host::log`,
|
||||
/// so dropping a retained line cannot leave unbounded forwarding to the host logger.
|
||||
fn read_log(caller: &mut Ctx<'_>, ptr: i32, len: i32) -> Result<String, wasmi::Error> {
|
||||
let bytes = len as u32 as usize;
|
||||
let limits = caller.data().limits;
|
||||
if bytes > limits.max_log_line_bytes {
|
||||
return Err(abort(caller, PluginError::Limit(format!("a {bytes}-byte log line is too long (limit: {} bytes)", limits.max_log_line_bytes))));
|
||||
}
|
||||
if caller.data().out.logs.len() >= limits.max_logs {
|
||||
return Err(abort(caller, PluginError::Limit(format!("more than {} log lines", limits.max_logs))));
|
||||
}
|
||||
let total = caller
|
||||
.data()
|
||||
.log_bytes
|
||||
.checked_add(bytes)
|
||||
.filter(|total| *total <= limits.max_log_bytes)
|
||||
.ok_or_else(|| abort(caller, PluginError::Limit(format!("more than {} log bytes", limits.max_log_bytes))))?;
|
||||
let line = read_str(caller, ptr, len)?;
|
||||
caller.data_mut().log_bytes = total;
|
||||
Ok(line)
|
||||
}
|
||||
|
||||
fn linker(engine: &Engine) -> Result<Linker<State>, PluginError> {
|
||||
let mut l = <Linker<State>>::new(engine);
|
||||
let err = |e: wasmi::errors::LinkerError| PluginError::InvalidModule(e.to_string());
|
||||
|
|
@ -443,14 +478,20 @@ fn linker(engine: &Engine) -> Result<Linker<State>, PluginError> {
|
|||
Err(ask(&mut c, Request::Selection))
|
||||
})
|
||||
.map_err(err)?;
|
||||
l.func_wrap(IMPORT_MODULE, "reply_len", |c: Ctx<'_>| -> i32 { i32::try_from(c.data().reply.len()).unwrap_or(i32::MAX) }).map_err(err)?;
|
||||
l.func_wrap(IMPORT_MODULE, "reply_len", |mut c: Ctx<'_>| -> Result<i32, wasmi::Error> {
|
||||
charge(&mut c)?;
|
||||
Ok(i32::try_from(c.data().reply.len()).unwrap_or(i32::MAX))
|
||||
})
|
||||
.map_err(err)?;
|
||||
l.func_wrap(IMPORT_MODULE, "reply_read", |mut c: Ctx<'_>, ptr: i32, len: i32| -> Result<i32, wasmi::Error> {
|
||||
charge(&mut c)?;
|
||||
let (ptr, want) = (ptr as u32 as usize, len as u32 as usize);
|
||||
let n = want.min(c.data().reply.len());
|
||||
spend_fuel(&mut c, n as u64)?;
|
||||
let Some(mem) = c.get_export("memory").and_then(Extern::into_memory) else {
|
||||
return Err(abort(&mut c, PluginError::InvalidModule("no memory".into())));
|
||||
};
|
||||
let (data, st) = mem.data_and_store_mut(&mut c);
|
||||
let n = want.min(st.reply.len());
|
||||
let src = st.reply.get(..n).ok_or(TrapCode::MemoryOutOfBounds)?;
|
||||
let dst = ptr.checked_add(n).and_then(|end| data.get_mut(ptr..end)).ok_or(TrapCode::MemoryOutOfBounds)?;
|
||||
dst.copy_from_slice(src);
|
||||
|
|
@ -459,7 +500,7 @@ fn linker(engine: &Engine) -> Result<Linker<State>, PluginError> {
|
|||
.map_err(err)?;
|
||||
l.func_wrap(IMPORT_MODULE, "log", |mut c: Ctx<'_>, ptr: i32, len: i32| -> Result<(), wasmi::Error> {
|
||||
charge(&mut c)?;
|
||||
let line = read_str(&mut c, ptr, len)?;
|
||||
let line = read_log(&mut c, ptr, len)?;
|
||||
Err(ask(&mut c, Request::Log(line)))
|
||||
})
|
||||
.map_err(err)?;
|
||||
|
|
@ -471,6 +512,7 @@ fn linker(engine: &Engine) -> Result<Linker<State>, PluginError> {
|
|||
})
|
||||
.map_err(err)?;
|
||||
l.func_wrap(IMPORT_MODULE, "set_output", |mut c: Ctx<'_>, ptr: i32, len: i32| -> Result<(), wasmi::Error> {
|
||||
charge(&mut c)?;
|
||||
let out = read_str(&mut c, ptr, len)?;
|
||||
c.data_mut().out.output = out;
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ use crate::{Access, Host, Limits, Manifest, PluginError, check_module, run};
|
|||
struct Mock {
|
||||
calls: Vec<(String, String)>,
|
||||
notes: Vec<String>,
|
||||
logs: Vec<String>,
|
||||
refuse: bool,
|
||||
}
|
||||
|
||||
|
|
@ -23,6 +24,9 @@ impl Host for Mock {
|
|||
fn notify(&mut self, message: &str) {
|
||||
self.notes.push(message.into());
|
||||
}
|
||||
fn log(&mut self, line: &str) {
|
||||
self.logs.push(line.into());
|
||||
}
|
||||
}
|
||||
|
||||
const IMPORTS: &str = r#"
|
||||
|
|
@ -74,9 +78,10 @@ fn echoes_input_calls_commands_and_reads_replies() {
|
|||
let out = r.unwrap();
|
||||
assert_eq!(out.output, r#"{"ran":"shape.rectangle"}"#);
|
||||
assert_eq!(out.logs, vec![r#"{"command":"run"}"#.to_string()]);
|
||||
assert_eq!(host.logs, out.logs);
|
||||
assert_eq!(host.calls, vec![("shape.rectangle".into(), r#"{"x":1}"#.into())]);
|
||||
assert_eq!(host.notes, vec!["made it".to_string()]);
|
||||
assert_eq!(out.calls, 3);
|
||||
assert_eq!(out.calls, 7);
|
||||
assert!(out.fuel > 0);
|
||||
}
|
||||
|
||||
|
|
@ -118,6 +123,174 @@ fn an_endless_loop_runs_out_of_fuel() {
|
|||
assert!(matches!(run(&m, "{}", &limits, &mut host), Err(PluginError::Limit(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logs_over_the_default_line_budget_fail_before_forwarding() {
|
||||
// One byte over 8 KiB, all valid UTF-8 in the module's zero-filled memory.
|
||||
let m = module("", "(call $log (i32.const 0) (i32.const 8193)) (i32.const 0)");
|
||||
let (result, host) = go(&m, "{}");
|
||||
assert!(matches!(result, Err(PluginError::Limit(_))), "an oversized log line must fail the run");
|
||||
assert!(host.logs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logs_over_the_default_byte_budget_fail_before_forwarding() {
|
||||
let m = module(
|
||||
"",
|
||||
"(local $i i32)
|
||||
(loop $logs
|
||||
(call $log (i32.const 0) (i32.const 8192))
|
||||
(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)",
|
||||
);
|
||||
// The default fuel: copying a megabyte of log bytes out of the plugin is itself charged.
|
||||
let mut host = Mock::default();
|
||||
let result = run(&m, "{}", &Limits::default(), &mut host);
|
||||
assert!(matches!(result, Err(PluginError::Limit(_))), "logs over 1 MiB must fail the run");
|
||||
assert_eq!(host.logs.len(), 128);
|
||||
assert_eq!(host.logs.iter().map(String::len).sum::<usize>(), 1024 * 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logs_over_the_count_budget_fail_before_forwarding() {
|
||||
let m = module(
|
||||
"",
|
||||
"(call $log (i32.const 0) (i32.const 0))
|
||||
(call $log (i32.const 0) (i32.const 0))
|
||||
(call $log (i32.const 0) (i32.const 0)) (i32.const 0)",
|
||||
);
|
||||
let limits = Limits { max_logs: 2, ..small() };
|
||||
let mut host = Mock::default();
|
||||
assert_eq!(run(&m, "{}", &limits, &mut host), Err(PluginError::Limit("more than 2 log lines".into())));
|
||||
assert_eq!(host.logs, vec![String::new(), String::new()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logs_check_the_line_budget_before_reading_guest_memory() {
|
||||
let limits = Limits { max_log_line_bytes: 4, ..small() };
|
||||
let m = module(r#"(data (i32.const 0) "line")"#, "(call $log (i32.const 0) (i32.const 4)) (i32.const 0)");
|
||||
let mut host = Mock::default();
|
||||
let out = run(&m, "{}", &limits, &mut host).unwrap();
|
||||
assert_eq!(out.logs, vec!["line".to_string()]);
|
||||
assert_eq!(host.logs, out.logs);
|
||||
// Both an ordinary oversize line and invalid pointers/lengths are rejected by the byte
|
||||
// budget, before an out-of-bounds read or a guest-to-host allocation can happen.
|
||||
for (ptr, len) in [(0, 5), (65535, 5), (-1, 5), (0, -1)] {
|
||||
let m = module("", &format!("(call $log (i32.const {ptr}) (i32.const {len})) (i32.const 0)"));
|
||||
let mut host = Mock::default();
|
||||
assert!(matches!(run(&m, "{}", &limits, &mut host), Err(PluginError::Limit(m)) if m.contains("log line")));
|
||||
assert!(host.logs.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logs_count_utf8_bytes_and_check_the_total_before_reading_memory() {
|
||||
let data = r#"(data (i32.const 0) "\c3\a9")"#;
|
||||
let two_lines = "(call $log (i32.const 0) (i32.const 2)) (call $log (i32.const 0) (i32.const 2))";
|
||||
let limits = Limits { max_log_line_bytes: 2, max_log_bytes: 4, ..small() };
|
||||
let m = module(data, &format!("{two_lines} (i32.const 0)"));
|
||||
let mut host = Mock::default();
|
||||
let out = run(&m, "{}", &limits, &mut host).unwrap();
|
||||
assert_eq!(out.logs, vec!["é".to_string(), "é".to_string()]);
|
||||
assert_eq!(host.logs, out.logs);
|
||||
assert_eq!(out.logs.iter().map(String::len).sum::<usize>(), 4);
|
||||
// The third line exceeds the total; its invalid pointer must never be read or forwarded.
|
||||
let m = module(data, &format!("{two_lines} (call $log (i32.const 65535) (i32.const 2)) (i32.const 0)"));
|
||||
let mut host = Mock::default();
|
||||
assert_eq!(run(&m, "{}", &limits, &mut host), Err(PluginError::Limit("more than 4 log bytes".into())));
|
||||
assert_eq!(host.logs, out.logs);
|
||||
let limits = Limits { max_log_bytes: 3, ..limits };
|
||||
let m = module(data, &format!("{two_lines} (i32.const 0)"));
|
||||
let mut host = Mock::default();
|
||||
assert_eq!(run(&m, "{}", &limits, &mut host), Err(PluginError::Limit("more than 3 log bytes".into())));
|
||||
assert_eq!(host.logs, vec!["é".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logs_observe_zero_budgets_and_the_exact_line_count() {
|
||||
let empty = "(call $log (i32.const 0) (i32.const 0))";
|
||||
let limits = Limits { max_logs: 2, max_log_line_bytes: 0, max_log_bytes: 0, ..small() };
|
||||
let m = module("", &format!("{empty} {empty} (i32.const 0)"));
|
||||
let mut host = Mock::default();
|
||||
let out = run(&m, "{}", &limits, &mut host).unwrap();
|
||||
assert_eq!(out.logs, vec![String::new(), String::new()]);
|
||||
assert_eq!(host.logs, out.logs);
|
||||
for limits in [Limits { max_logs: 0, ..limits }, Limits { max_log_line_bytes: 1, ..limits }] {
|
||||
let bytes = if limits.max_logs == 0 { 0 } else { 1 };
|
||||
let m = module("", &format!("(call $log (i32.const 0) (i32.const {bytes})) (i32.const 0)"));
|
||||
let mut host = Mock::default();
|
||||
assert!(matches!(run(&m, "{}", &limits, &mut host), Err(PluginError::Limit(_))));
|
||||
assert!(host.logs.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logs_get_fresh_budgets_on_callbacks_after_success_and_failure() {
|
||||
let m = module_with(
|
||||
r#"(data (i32.const 0) "x")"#,
|
||||
"(call $log (i32.const 0) (i32.const 1))
|
||||
(call $log (i32.const 0) (i32.const 1))
|
||||
(if (i32.eqz (global.get $called))
|
||||
(then (global.set $called (i32.const 1)) (call $log (i32.const 0) (i32.const 1)))) (i32.const 0)",
|
||||
"(memory (export \"memory\") 1)",
|
||||
"(global $called (mut i32) (i32.const 0))",
|
||||
);
|
||||
let limits = Limits { max_logs: 2, max_log_line_bytes: 1, max_log_bytes: 2, ..small() };
|
||||
let mut instance = crate::Instance::new(&m, &limits).unwrap();
|
||||
let mut host = Mock::default();
|
||||
assert!(matches!(instance.call("{}", &mut host), Err(PluginError::Limit(_))));
|
||||
assert_eq!(host.logs, vec!["x".to_string(), "x".to_string()]);
|
||||
for _ in 0..2 {
|
||||
let mut host = Mock::default();
|
||||
let out = instance.call("{}", &mut host).unwrap();
|
||||
assert_eq!(out.logs, vec!["x".to_string(), "x".to_string()]);
|
||||
assert_eq!(host.logs, out.logs);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logs_still_observe_the_general_message_limit() {
|
||||
let limits = Limits { max_message: 3, max_log_line_bytes: 4, ..small() };
|
||||
let m = module(r#"(data (i32.const 0) "line")"#, "(call $log (i32.const 0) (i32.const 4)) (i32.const 0)");
|
||||
let mut host = Mock::default();
|
||||
assert_eq!(run(&m, "{}", &limits, &mut host), Err(PluginError::Limit("a 4-byte string is too long".into())));
|
||||
assert!(host.logs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reply_and_output_imports_obey_host_call_limit() {
|
||||
for call in
|
||||
["(drop (call $reply_len))", "(drop (call $reply_read (i32.const 0) (i32.const 2)))", "(call $set_output (i32.const 0) (i32.const 2))"]
|
||||
{
|
||||
let m = module(r#"(data (i32.const 0) "ok")"#, &format!("{call} {call} (i32.const 0)"));
|
||||
let limits = Limits { max_calls: 1, ..small() };
|
||||
assert!(matches!(run(&m, "{}", &limits, &mut Mock::default()), Err(PluginError::Limit(_))), "{call}");
|
||||
let limits = Limits { max_calls: 2, ..small() };
|
||||
assert_eq!(run(&m, "{}", &limits, &mut Mock::default()).unwrap().calls, 2, "{call}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_bulk_imports_spend_fuel_for_bytes_even_without_call_overhead() {
|
||||
for call in ["(drop (call $reply_read (i32.const 0) (i32.const 4096)))", "(call $set_output (i32.const 0) (i32.const 4096))"] {
|
||||
let m = module("", &format!("{} (i32.const 0)", call.repeat(16)));
|
||||
let limits = Limits { fuel: 40_000, call_fuel: 0, ..small() };
|
||||
assert_eq!(run(&m, &"x".repeat(4096), &limits, &mut Mock::default()), Err(PluginError::OutOfFuel), "{call}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reply_copy_charges_actual_bytes_and_each_callback_gets_fresh_limits() {
|
||||
let m = module("", "(call $set_output (i32.const 0) (call $reply_read (i32.const 0) (i32.const 2147483647))) (i32.const 0)");
|
||||
let limits = Limits { max_calls: 2, fuel: 1000, call_fuel: 0, ..small() };
|
||||
let mut instance = crate::Instance::new(&m, &limits).unwrap();
|
||||
assert_eq!(instance.call(&"x".repeat(2048), &mut Mock::default()), Err(PluginError::OutOfFuel));
|
||||
for input in ["hello", "again"] {
|
||||
let out = instance.call(input, &mut Mock::default()).unwrap();
|
||||
assert_eq!(out.output, input);
|
||||
assert_eq!(out.calls, 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_is_capped() {
|
||||
// Declared larger than the cap: refused at instantiation.
|
||||
|
|
|
|||
|
|
@ -133,3 +133,23 @@ fn one_sided_widths_paint_one_side() {
|
|||
let b = d.world_render_bounds(l).unwrap();
|
||||
assert!(b.y1 >= 120.0 - 1e-6, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_boolean_nesting_stops_at_the_outline_depth_limit() {
|
||||
// Booleans directly inside booleans used to restart the depth count at every level.
|
||||
let mut d = Document::new("t");
|
||||
let mut parent = d.first_page().unwrap();
|
||||
let top = d.create(parent, NodeKind::BooleanOp { op: BoolOp::Union }, "B", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
parent = top;
|
||||
for _ in 0..20_000 {
|
||||
parent = d.create(parent, NodeKind::BooleanOp { op: BoolOp::Union }, "B", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
}
|
||||
d.create(parent, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
// Too deep: no stack overflow, and the unreachable square contributes nothing.
|
||||
assert!(outline(&d, top).area().abs() < 1e-9);
|
||||
// Within the limit, nested booleans still combine.
|
||||
let (mut d, _, b, _) = row_of_squares(BoolOp::Union, 1);
|
||||
let inner = d.create(b, NodeKind::BooleanOp { op: BoolOp::Union }, "B", 0.0, 0.0, 0.0, 0.0).unwrap();
|
||||
d.create(inner, NodeKind::Rectangle, "R", 40.0, 10.0, 20.0, 20.0).unwrap();
|
||||
assert!((outline(&d, b).area().abs() - 2.0 * 20.0 * 20.0).abs() < 0.5);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -203,6 +203,11 @@ pub(crate) fn stroke_rule(n: &Node, closed: bool) -> FillRule {
|
|||
/// The geometry a node fills, in local space: shapes, booleans (computed from children) and text
|
||||
/// (glyph outlines). Groups and pages have none.
|
||||
pub fn node_outline(doc: &Document, n: &Node) -> Option<(BezPath, FillRule)> {
|
||||
node_outline_at(doc, n, 0)
|
||||
}
|
||||
|
||||
/// [`node_outline`] `depth` levels into a boolean or group nesting (one budget for both).
|
||||
fn node_outline_at(doc: &Document, n: &Node, depth: usize) -> Option<(BezPath, FillRule)> {
|
||||
match &n.kind {
|
||||
NodeKind::BooleanOp { op } => {
|
||||
let operands: Vec<(BezPath, figcraft_doc::BoolOp)> = n
|
||||
|
|
@ -211,7 +216,7 @@ pub fn node_outline(doc: &Document, n: &Node) -> Option<(BezPath, FillRule)> {
|
|||
.filter_map(|c| doc.node(*c))
|
||||
.filter(|c| c.visible)
|
||||
.filter_map(|c| {
|
||||
let (mut p, _) = subtree_outline(doc, c, 0)?;
|
||||
let (mut p, _) = subtree_outline(doc, c, depth + 1)?;
|
||||
p.apply_affine(c.transform);
|
||||
Some((p, c.bool_op.unwrap_or(*op)))
|
||||
})
|
||||
|
|
@ -258,7 +263,7 @@ pub fn subtree_outline(doc: &Document, n: &Node, depth: usize) -> Option<(BezPat
|
|||
}
|
||||
Some((out, FillRule::Winding))
|
||||
}
|
||||
_ => node_outline(doc, n),
|
||||
_ => node_outline_at(doc, n, depth),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -362,7 +367,7 @@ const MAX_EFFECT_MARGIN_PX: f64 = 2048.0;
|
|||
|
||||
/// Draw node `id` onto `pix`, where `parent_xf` maps the parent's space to `pix` pixels.
|
||||
pub fn draw_node(ctx: &Ctx, pix: &mut Pixmap, id: NodeId, parent_xf: Affine, depth: usize) {
|
||||
if depth > 256 || ctx.skip.contains(&id) {
|
||||
if depth > crate::MAX_EXPORT_DEPTH || ctx.skip.contains(&id) {
|
||||
return;
|
||||
}
|
||||
let Some(n) = ctx.doc.node(id) else { return };
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
//! Preflight for vector exports, including subtrees rendered into embedded images.
|
||||
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
|
||||
/// Maximum descendant depth for SVG/PDF export; each selected root is at depth zero.
|
||||
pub const MAX_EXPORT_DEPTH: usize = 256;
|
||||
|
||||
/// Exporting this visible subtree would silently omit content at the traversal limit.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ExportDepthError {
|
||||
pub root: NodeId,
|
||||
pub node: NodeId,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ExportDepthError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"cannot export layer {}: layer {} exceeds the maximum export depth of {MAX_EXPORT_DEPTH}; select an inner layer or reduce nesting",
|
||||
self.root, self.node
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ExportDepthError {}
|
||||
|
||||
/// Refuse a visible descendant deeper than [`MAX_EXPORT_DEPTH`] before computing bounds or
|
||||
/// drawing. Hidden subtrees are not exported. Masks, boolean operands and rasterised effects
|
||||
/// still depend on their descendants, so they participate in the same preflight.
|
||||
///
|
||||
/// The walk uses one iterator per ancestor (bounded auxiliary memory), rather than recursion or
|
||||
/// collecting a whole subtree. A cycle also reaches the depth limit and is refused.
|
||||
pub fn check_export_depth(doc: &Document, roots: &[NodeId]) -> Result<(), ExportDepthError> {
|
||||
for root in roots {
|
||||
let mut stack = vec![(std::slice::from_ref(root).iter(), 0)];
|
||||
while let Some((siblings, depth)) = stack.last_mut() {
|
||||
let Some(&id) = siblings.next() else {
|
||||
stack.pop();
|
||||
continue;
|
||||
};
|
||||
let depth = *depth;
|
||||
let Some(node) = doc.node(id) else { continue };
|
||||
if !node.visible {
|
||||
continue;
|
||||
}
|
||||
if depth > MAX_EXPORT_DEPTH {
|
||||
return Err(ExportDepthError { root: *root, node: id });
|
||||
}
|
||||
if !node.children.is_empty() {
|
||||
stack.push((node.children.iter(), depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use figcraft_doc::NodeKind;
|
||||
|
||||
#[test]
|
||||
fn export_depth_preflight_refuses_a_cyclic_subtree() {
|
||||
let mut doc = Document::new("cycle");
|
||||
let root = doc.create(doc.first_page().unwrap(), NodeKind::Group, "root", 0.0, 0.0, 1.0, 1.0).unwrap();
|
||||
doc.get_mut(root).unwrap().children.push(root);
|
||||
assert_eq!(check_export_depth(&doc, &[root]), Err(ExportDepthError { root, node: root }));
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ pub mod backdrop;
|
|||
pub mod blur;
|
||||
pub mod boolean;
|
||||
pub mod draw;
|
||||
mod export_depth;
|
||||
pub mod fx;
|
||||
pub mod glass;
|
||||
pub mod grain;
|
||||
|
|
@ -21,6 +22,7 @@ pub mod retained;
|
|||
pub mod shadow;
|
||||
|
||||
pub use draw::{node_outline, paint_bounds, stroke_fill, subtree_outline, ts_path};
|
||||
pub use export_depth::{ExportDepthError, MAX_EXPORT_DEPTH, check_export_depth};
|
||||
pub use retained::{Retained, Work};
|
||||
pub use tiny_skia::{FillRule, Pixmap};
|
||||
|
||||
|
|
|
|||
|
|
@ -17,3 +17,5 @@ figcraft-text = { workspace = true }
|
|||
figcraft-pathops = { workspace = true }
|
||||
usvg = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
use super::*;
|
||||
use figcraft_color::{Color, Paint};
|
||||
|
||||
fn nested(depth: usize) -> (Document, NodeId, NodeId) {
|
||||
let mut doc = Document::new("export depth");
|
||||
let page = doc.first_page().unwrap();
|
||||
let root = doc.create(page, NodeKind::Frame, "root", 0.0, 0.0, 32.0, 32.0).unwrap();
|
||||
let mut parent = root;
|
||||
for _ in 1..depth {
|
||||
parent = doc.create(parent, NodeKind::Group, "group", 0.0, 0.0, 32.0, 32.0).unwrap();
|
||||
}
|
||||
let leaf = doc.create(parent, NodeKind::Rectangle, "deep-leaf", 3.0, 5.0, 13.0, 17.0).unwrap();
|
||||
doc.get_mut(leaf).unwrap().fills = vec![Paint::solid(Color::from_hex("FF0000").unwrap())];
|
||||
(doc, root, leaf)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_depth_boundary_keeps_the_leaf() {
|
||||
let opts = SvgOptions { include_ids: true, ..Default::default() };
|
||||
for depth in [255, 256] {
|
||||
let (doc, root, _) = nested(depth);
|
||||
let svg = export_svg(&doc, &[root], &opts).unwrap();
|
||||
assert!(svg.contains("id=\"deep-leaf\""), "the leaf at depth {depth} was omitted");
|
||||
assert!(svg.contains("#FF0000"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_depth_overflow_is_refused_and_an_inner_root_still_exports() {
|
||||
let (doc, root, leaf) = nested(257);
|
||||
let opts = SvgOptions { include_ids: true, ..Default::default() };
|
||||
assert!(export_svg(&doc, &[root], &opts).is_none(), "an incomplete SVG must not report success");
|
||||
assert_eq!(try_export_svg(&doc, &[root], &opts), Err(SvgError::Depth(ExportDepthError { root, node: leaf })));
|
||||
let inner = doc.node(root).unwrap().children[0];
|
||||
assert!(export_svg(&doc, &[inner], &opts).unwrap().contains("id=\"deep-leaf\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_depth_checks_all_roots_and_explicit_bounds() {
|
||||
let (mut doc, root, leaf) = nested(257);
|
||||
let shallow = doc.create(doc.first_page().unwrap(), NodeKind::Rectangle, "shallow", 0.0, 0.0, 8.0, 8.0).unwrap();
|
||||
let opts = SvgOptions { bounds: Some(Rect::new(0.0, 0.0, 32.0, 32.0)), ..Default::default() };
|
||||
assert!(try_export_svg(&doc, &[shallow], &opts).is_ok());
|
||||
assert_eq!(try_export_svg(&doc, &[shallow, root], &opts), Err(SvgError::Depth(ExportDepthError { root, node: leaf })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_depth_ignores_hidden_subtrees_and_checks_masks_and_raster_fallbacks() {
|
||||
let (mut doc, root, _) = nested(257);
|
||||
let inner = doc.node(root).unwrap().children[0];
|
||||
doc.get_mut(inner).unwrap().visible = false;
|
||||
assert!(try_export_svg(&doc, &[root], &SvgOptions::default()).is_ok());
|
||||
doc.get_mut(inner).unwrap().visible = true;
|
||||
doc.get_mut(inner).unwrap().is_mask = true;
|
||||
assert!(matches!(try_export_svg(&doc, &[root], &SvgOptions::default()), Err(SvgError::Depth(_))));
|
||||
doc.get_mut(inner).unwrap().is_mask = false;
|
||||
doc.get_mut(root).unwrap().effects = vec![figcraft_doc::Effect { kind: EffectKind::LayerBlur(figcraft_doc::Blur::uniform(1.0)), visible: true }];
|
||||
assert!(matches!(try_export_svg(&doc, &[root], &SvgOptions::default()), Err(SvgError::Depth(_))));
|
||||
}
|
||||
|
|
@ -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,
|
||||
//! nested clip paths) is left out and reported in the import notes. Linked files are never read: only data URIs.
|
||||
|
||||
mod budget;
|
||||
mod fx;
|
||||
mod paint;
|
||||
mod shape;
|
||||
mod text;
|
||||
|
||||
#[cfg(test)]
|
||||
mod budget_tests;
|
||||
#[cfg(test)]
|
||||
mod fx_tests;
|
||||
#[cfg(test)]
|
||||
|
|
@ -592,10 +595,10 @@ pub(crate) fn nesting(data: &[u8], limit: usize) -> usize {
|
|||
max
|
||||
}
|
||||
|
||||
fn options(data: &[u8]) -> usvg::Options<'static> {
|
||||
let mut o = usvg::Options::default();
|
||||
// Only data URIs: an opened or pasted SVG never pulls other files off the disk.
|
||||
o.image_href_resolver.resolve_string = Box::new(|_, _| None);
|
||||
fn options(data: &[u8], limits: budget::Limits) -> usvg::Options<'static> {
|
||||
// Only data URIs, within budgets: an opened or pasted SVG never pulls other files off the disk
|
||||
// and never expands without limit.
|
||||
let mut o = usvg::Options { image_href_resolver: budget::resolver(limits), ..Default::default() };
|
||||
if data.windows(4).any(|w| w == b"text") {
|
||||
text::prepare(&mut o);
|
||||
}
|
||||
|
|
@ -634,10 +637,24 @@ pub fn import_svg(doc: &mut Document, data: &[u8], parent: NodeId, x: f64, y: f6
|
|||
|
||||
/// [`import_svg`], with notes on what couldn't be represented.
|
||||
pub fn import_svg_with_notes(doc: &mut Document, data: &[u8], parent: NodeId, x: f64, y: f64, name: &str) -> Result<Imported, ImportError> {
|
||||
if nesting(data, MAX_NESTING) > MAX_NESTING {
|
||||
import_within(doc, data, parent, x, y, name, budget::Limits::default())
|
||||
}
|
||||
|
||||
fn import_within(
|
||||
doc: &mut Document,
|
||||
data: &[u8],
|
||||
parent: NodeId,
|
||||
x: f64,
|
||||
y: f64,
|
||||
name: &str,
|
||||
limits: budget::Limits,
|
||||
) -> Result<Imported, ImportError> {
|
||||
// SVGZ is unpacked here, within a budget, so every check below sees the real document.
|
||||
let data = budget::document(data, &limits).map_err(ImportError)?;
|
||||
if nesting(&data, MAX_NESTING) > MAX_NESTING {
|
||||
return Err(ImportError(format!("elements nested more than {MAX_NESTING} deep")));
|
||||
}
|
||||
let tree = usvg::Tree::from_data(data, &options(data)).map_err(|e| ImportError(e.to_string()))?;
|
||||
let tree = usvg::Tree::from_data(&data, &options(&data, limits)).map_err(|e| ImportError(e.to_string()))?;
|
||||
let size = tree.size();
|
||||
let frame =
|
||||
doc.create(parent, NodeKind::Frame, name, x, y, f64::from(size.width()), f64::from(size.height())).map_err(|e| ImportError(e.to_string()))?;
|
||||
|
|
|
|||
|
|
@ -27,7 +27,15 @@ use figcraft_color::{Color, GradientKind, Paint, PaintKind};
|
|||
use figcraft_doc::{Document, EffectKind, Node, NodeId, NodeKind, SPREAD_MITER_LIMIT, Shadow};
|
||||
use figcraft_geom::{Affine, BezPath, Rect};
|
||||
use figcraft_render::shadow::Outline;
|
||||
use figcraft_render::{Alone, Pixmap, View, backdrop};
|
||||
use figcraft_render::{Alone, ExportDepthError, MAX_EXPORT_DEPTH, Pixmap, View, backdrop, check_export_depth};
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq)]
|
||||
pub enum SvgError {
|
||||
#[error("nothing to export")]
|
||||
Empty,
|
||||
#[error(transparent)]
|
||||
Depth(#[from] ExportDepthError),
|
||||
}
|
||||
|
||||
/// Device pixels per page unit of embedded renders (blurs, glass, texture).
|
||||
const RASTER_SCALE: f64 = 2.0;
|
||||
|
|
@ -362,7 +370,7 @@ impl W<'_> {
|
|||
}
|
||||
|
||||
fn node(&mut self, id: NodeId, xf: Affine, depth: usize) {
|
||||
if depth > 256 {
|
||||
if depth > MAX_EXPORT_DEPTH {
|
||||
return;
|
||||
}
|
||||
let Some(n) = self.doc.node(id) else { return };
|
||||
|
|
@ -920,10 +928,18 @@ fn sniff_mime(b: &[u8]) -> &'static str {
|
|||
}
|
||||
|
||||
/// Export `ids` (in z-order) as one SVG document covering their export bounds.
|
||||
/// Returns `None` for an empty or over-depth export. Use [`try_export_svg`] for the reason.
|
||||
pub fn export_svg(doc: &Document, ids: &[NodeId], opts: &SvgOptions) -> Option<String> {
|
||||
try_export_svg(doc, ids, opts).ok()
|
||||
}
|
||||
|
||||
/// Like [`export_svg`], with an actionable error when the visible subtree is too deep. No partial
|
||||
/// SVG is returned; the depth limit is relative to each exported root, not the document's page.
|
||||
pub fn try_export_svg(doc: &Document, ids: &[NodeId], opts: &SvgOptions) -> Result<String, SvgError> {
|
||||
check_export_depth(doc, ids)?;
|
||||
let bounds: Rect = match opts.bounds {
|
||||
Some(b) if [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) => b,
|
||||
_ => ids.iter().filter_map(|i| figcraft_render::export_bounds(doc, *i)).reduce(|a, b| a.union(b))?,
|
||||
_ => ids.iter().filter_map(|i| figcraft_render::export_bounds(doc, *i)).reduce(|a, b| a.union(b)).ok_or(SvgError::Empty)?,
|
||||
};
|
||||
let origin = Affine::translate((-bounds.x0, -bounds.y0));
|
||||
let mut w = W {
|
||||
|
|
@ -944,7 +960,7 @@ pub fn export_svg(doc: &Document, ids: &[NodeId], opts: &SvgOptions) -> Option<S
|
|||
}
|
||||
let (bw, bh) = (bounds.width().max(0.0), bounds.height().max(0.0));
|
||||
let defs = if w.defs.is_empty() { String::new() } else { format!("<defs>{}</defs>", w.defs) };
|
||||
Some(format!(
|
||||
Ok(format!(
|
||||
"<svg width=\"{}\" height=\"{}\" viewBox=\"0 0 {} {}\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">{}{}</svg>\n",
|
||||
num(bw),
|
||||
num(bh),
|
||||
|
|
@ -955,6 +971,8 @@ pub fn export_svg(doc: &Document, ids: &[NodeId], opts: &SvgOptions) -> Option<S
|
|||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod depth_tests;
|
||||
#[cfg(test)]
|
||||
mod effects_tests;
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ fn main() {
|
|||
}
|
||||
}
|
||||
if !ok {
|
||||
println!("cargo::warning=building without craft-fonts");
|
||||
println!("cargo::warning=building without craft-fonts (once it is there, `cargo clean -p figcraft-text` makes the next build use it)");
|
||||
}
|
||||
}
|
||||
src.push_str("];\n");
|
||||
|
|
@ -35,9 +35,23 @@ fn main() {
|
|||
}
|
||||
}
|
||||
|
||||
/// Rebuild when `manifest` changes or, while it is missing, when it is added to its folder.
|
||||
///
|
||||
/// Cargo takes a watched path that doesn't exist as changed on every build, and then rebuilds this
|
||||
/// crate and every crate that depends on it each time. So a missing manifest is never watched
|
||||
/// itself: its folder is, when there is one (a craft-fonts checkout without the Latin manifest).
|
||||
/// With no craft-fonts at all there is nothing to watch, so the build can't notice it arriving:
|
||||
/// the warning in `main` says how to pick it up.
|
||||
fn watch(manifest: &std::path::Path) {
|
||||
let watched = if manifest.exists() { Some(manifest) } else { manifest.parent().filter(|folder| folder.is_dir()) };
|
||||
if let Some(path) = watched {
|
||||
println!("cargo::rerun-if-changed={}", path.display());
|
||||
}
|
||||
}
|
||||
|
||||
fn craft_fonts(dir: &std::path::Path, manifest_rel: &str) -> Result<String, String> {
|
||||
let manifest = dir.join(manifest_rel);
|
||||
println!("cargo::rerun-if-changed={}", manifest.display());
|
||||
watch(&manifest);
|
||||
let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?;
|
||||
let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32");
|
||||
let mut out = String::new();
|
||||
|
|
|
|||
|
|
@ -211,7 +211,7 @@ status = "draft — needs native review"
|
|||
"Changes on main" = "Änderungen in der Hauptdatei"
|
||||
"Changes on the branch" = "Änderungen im Branch"
|
||||
"Changes unknown" = "Änderungen unbekannt"
|
||||
"Choose React, SwiftUI or Compose above to connect this component to its code." = "Wählen Sie oben React, SwiftUI oder Compose, um diese Komponente mit ihrem Code zu verbinden."
|
||||
"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Wählen Sie oben React, Leptos, SwiftUI oder Compose, um diese Komponente mit ihrem Code zu verbinden."
|
||||
"Choose another profile to change it." = "Wählen Sie ein anderes Profil, um es zu ändern."
|
||||
"Choose collection" = "Sammlung wählen"
|
||||
"Choose image" = "Bild wählen"
|
||||
|
|
@ -2034,6 +2034,7 @@ status = "draft — needs native review"
|
|||
"view.prevPage" = "Vorherige Seite"
|
||||
"view.previousRegion" = "Vorherigen Bereich fokussieren"
|
||||
"view.proofSrgb" = "Softproof sRGB"
|
||||
"view.fullscreen" = "Vollbild"
|
||||
"view.prototype" = "Bereich Prototyp"
|
||||
"view.rulers" = "Lineale"
|
||||
"view.set" = "Ansicht festlegen"
|
||||
|
|
|
|||
|
|
@ -216,7 +216,7 @@ name = "English"
|
|||
"Changes on main" = "Changes on main"
|
||||
"Changes on the branch" = "Changes on the branch"
|
||||
"Changes unknown" = "Changes unknown"
|
||||
"Choose React, SwiftUI or Compose above to connect this component to its code." = "Choose React, SwiftUI or Compose above to connect this component to its code."
|
||||
"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Choose React, Leptos, SwiftUI or Compose above to connect this component to its code."
|
||||
"Choose another profile to change it." = "Choose another profile to change it."
|
||||
"Choose collection" = "Choose collection"
|
||||
"Choose image" = "Choose image"
|
||||
|
|
|
|||
|
|
@ -217,7 +217,7 @@ status = "draft — needs native review"
|
|||
"Changes on main" = "Cambios en la principal"
|
||||
"Changes on the branch" = "Cambios en la rama"
|
||||
"Changes unknown" = "Cambios desconocidos"
|
||||
"Choose React, SwiftUI or Compose above to connect this component to its code." = "Elige React, SwiftUI o Compose arriba para conectar este componente con su código."
|
||||
"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Elige React, Leptos, SwiftUI o Compose arriba para conectar este componente con su código."
|
||||
"Choose another profile to change it." = "Elige otro perfil para cambiarlo."
|
||||
"Choose collection" = "Elegir colección"
|
||||
"Choose image" = "Elegir imagen"
|
||||
|
|
@ -2087,6 +2087,7 @@ status = "draft — needs native review"
|
|||
"view.prevPage" = "Página anterior"
|
||||
"view.previousRegion" = "Enfocar la región anterior"
|
||||
"view.proofSrgb" = "Prueba en pantalla sRGB"
|
||||
"view.fullscreen" = "Pantalla completa"
|
||||
"view.prototype" = "Panel de prototipo"
|
||||
"view.rulers" = "Reglas"
|
||||
"view.set" = "Establecer vista"
|
||||
|
|
|
|||
|
|
@ -211,7 +211,7 @@ status = "draft — needs native review"
|
|||
"Changes on main" = "Modifications dans le fichier principal"
|
||||
"Changes on the branch" = "Modifications dans la branche"
|
||||
"Changes unknown" = "Modifications inconnues"
|
||||
"Choose React, SwiftUI or Compose above to connect this component to its code." = "Choisissez React, SwiftUI ou Compose ci-dessus pour relier ce composant à son code."
|
||||
"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Choisissez React, Leptos, SwiftUI ou Compose ci-dessus pour relier ce composant à son code."
|
||||
"Choose another profile to change it." = "Choisissez un autre profil pour le changer."
|
||||
"Choose collection" = "Choisir une collection"
|
||||
"Choose image" = "Choisir une image"
|
||||
|
|
@ -2034,6 +2034,7 @@ status = "draft — needs native review"
|
|||
"view.prevPage" = "Page précédente"
|
||||
"view.previousRegion" = "Activer la région précédente"
|
||||
"view.proofSrgb" = "Épreuve écran sRGB"
|
||||
"view.fullscreen" = "Plein écran"
|
||||
"view.prototype" = "Panneau Prototype"
|
||||
"view.rulers" = "Règles"
|
||||
"view.set" = "Définir l’affichage"
|
||||
|
|
|
|||
|
|
@ -213,7 +213,7 @@ status = "draft — needs native review"
|
|||
"Changes on main" = "メインの変更"
|
||||
"Changes on the branch" = "ブランチの変更"
|
||||
"Changes unknown" = "変更内容は不明"
|
||||
"Choose React, SwiftUI or Compose above to connect this component to its code." = "上で React、SwiftUI、Compose のいずれかを選び、このコンポーネントをコードに接続。"
|
||||
"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "上で React、Leptos、SwiftUI、Compose のいずれかを選び、このコンポーネントをコードに接続。"
|
||||
"Choose another profile to change it." = "変更するには別のプロファイルを選択。"
|
||||
"Choose collection" = "コレクションを選択"
|
||||
"Choose image" = "画像を選択"
|
||||
|
|
@ -2036,6 +2036,7 @@ status = "draft — needs native review"
|
|||
"view.prevPage" = "前のページ"
|
||||
"view.previousRegion" = "前の領域にフォーカス"
|
||||
"view.proofSrgb" = "sRGB でソフトプルーフ"
|
||||
"view.fullscreen" = "全画面表示"
|
||||
"view.prototype" = "プロトタイプパネル"
|
||||
"view.rulers" = "定規"
|
||||
"view.set" = "ビューを設定"
|
||||
|
|
|
|||
|
|
@ -212,7 +212,7 @@ status = "draft — needs native review"
|
|||
"Changes on main" = "메인의 변경 사항"
|
||||
"Changes on the branch" = "브랜치의 변경 사항"
|
||||
"Changes unknown" = "변경 내용 알 수 없음"
|
||||
"Choose React, SwiftUI or Compose above to connect this component to its code." = "위에서 React, SwiftUI 또는 Compose를 선택해 이 컴포넌트를 코드에 연결하세요."
|
||||
"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "위에서 React, Leptos, SwiftUI 또는 Compose를 선택해 이 컴포넌트를 코드에 연결하세요."
|
||||
"Choose another profile to change it." = "변경하려면 다른 프로필을 선택하세요."
|
||||
"Choose collection" = "컬렉션 선택"
|
||||
"Choose image" = "이미지 선택"
|
||||
|
|
@ -2035,6 +2035,7 @@ status = "draft — needs native review"
|
|||
"view.prevPage" = "이전 페이지"
|
||||
"view.previousRegion" = "이전 영역에 포커스"
|
||||
"view.proofSrgb" = "sRGB 교정 미리 보기"
|
||||
"view.fullscreen" = "전체 화면"
|
||||
"view.prototype" = "프로토타입 패널"
|
||||
"view.rulers" = "눈금자"
|
||||
"view.set" = "보기 설정"
|
||||
|
|
|
|||
|
|
@ -210,7 +210,7 @@ status = "draft — needs native review"
|
|||
"Changes on main" = "Alterações no principal"
|
||||
"Changes on the branch" = "Alterações na branch"
|
||||
"Changes unknown" = "Alterações desconhecidas"
|
||||
"Choose React, SwiftUI or Compose above to connect this component to its code." = "Escolha React, SwiftUI ou Compose acima para conectar este componente ao código dele."
|
||||
"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Escolha React, Leptos, SwiftUI ou Compose acima para conectar este componente ao código dele."
|
||||
"Choose another profile to change it." = "Escolha outro perfil para alterá-lo."
|
||||
"Choose collection" = "Escolher coleção"
|
||||
"Choose image" = "Escolher imagem"
|
||||
|
|
@ -2033,6 +2033,7 @@ status = "draft — needs native review"
|
|||
"view.prevPage" = "Página anterior"
|
||||
"view.previousRegion" = "Focar a região anterior"
|
||||
"view.proofSrgb" = "Prova em tela sRGB"
|
||||
"view.fullscreen" = "Tela cheia"
|
||||
"view.prototype" = "Painel de protótipo"
|
||||
"view.rulers" = "Réguas"
|
||||
"view.set" = "Definir visualização"
|
||||
|
|
|
|||
|
|
@ -213,7 +213,7 @@ status = "draft — needs native review"
|
|||
"Changes on main" = "主文件上的更改"
|
||||
"Changes on the branch" = "分支上的更改"
|
||||
"Changes unknown" = "更改未知"
|
||||
"Choose React, SwiftUI or Compose above to connect this component to its code." = "在上方选择 React、SwiftUI 或 Compose,将此组件关联到其代码。"
|
||||
"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "在上方选择 React、Leptos、SwiftUI 或 Compose,将此组件关联到其代码。"
|
||||
"Choose another profile to change it." = "选择其他配置文件以更改。"
|
||||
"Choose collection" = "选择集合"
|
||||
"Choose image" = "选择图片"
|
||||
|
|
@ -2036,6 +2036,7 @@ status = "draft — needs native review"
|
|||
"view.prevPage" = "上一页"
|
||||
"view.previousRegion" = "聚焦上一个区域"
|
||||
"view.proofSrgb" = "sRGB 软打样"
|
||||
"view.fullscreen" = "全屏"
|
||||
"view.prototype" = "原型面板"
|
||||
"view.rulers" = "标尺"
|
||||
"view.set" = "设置视图"
|
||||
|
|
|
|||
|
|
@ -782,8 +782,8 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
|||
ui.add_space(16.0);
|
||||
widgets::label(ui, tr("On this page"), t.text_secondary);
|
||||
});
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.add_space(16.0);
|
||||
// Inset on both sides: `add_space` would only indent the first row of a wrap.
|
||||
inset(ui, |ui| {
|
||||
for c in &doc_colors {
|
||||
if widgets::swatch(ui, &Paint::solid(*c), 14.0).on_hover_text(c.to_hex()).clicked() {
|
||||
set_color(app, &target, &mut paint, *c, sel_stop_id, ui);
|
||||
|
|
@ -798,8 +798,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
|||
ui.add_space(16.0);
|
||||
widgets::label(ui, tr("Recent"), t.text_secondary);
|
||||
});
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.add_space(16.0);
|
||||
inset(ui, |ui| {
|
||||
for c in recent.iter().take(14) {
|
||||
if widgets::swatch(ui, &Paint::solid(*c), 14.0).clicked() {
|
||||
set_color(app, &target, &mut paint, *c, sel_stop_id, ui);
|
||||
|
|
@ -816,6 +815,11 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
|||
let _ = Pos2::ZERO;
|
||||
}
|
||||
|
||||
/// Swatches wrapped in rows inset 16 points from both sides of the picker.
|
||||
fn inset(ui: &mut Ui, add: impl FnOnce(&mut Ui)) {
|
||||
egui::Frame::NONE.inner_margin(egui::Margin::symmetric(16, 0)).show(ui, |ui| ui.horizontal_wrapped(add));
|
||||
}
|
||||
|
||||
/// The colour picker's note under the fields for a colour outside sRGB.
|
||||
fn gamut_warning(ui: &mut Ui, t: &Tokens) {
|
||||
use crate::a11y::Tip;
|
||||
|
|
|
|||
|
|
@ -42,6 +42,14 @@ pub struct ControlRequest {
|
|||
pub method: String,
|
||||
pub params: Value,
|
||||
pub reply: std::sync::mpsc::Sender<ControlReply>,
|
||||
/// The transport stops waiting after its timeout; discard requests still queued then.
|
||||
pub cancelled: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||
}
|
||||
|
||||
impl ControlRequest {
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.cancelled.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
/// Pending screenshot replies.
|
||||
|
|
@ -87,6 +95,9 @@ pub fn drain(app: &mut FigApp, ctx: &Context) {
|
|||
}
|
||||
let reqs = std::mem::take(&mut app.control_queue);
|
||||
for r in reqs {
|
||||
if r.is_cancelled() {
|
||||
continue;
|
||||
}
|
||||
// Requests for another window wait for its frame.
|
||||
let target = r.params.get("window").and_then(Value::as_u64).unwrap_or(app.windows.focused);
|
||||
if target != app.windows.current {
|
||||
|
|
|
|||
|
|
@ -396,7 +396,7 @@ pub fn connect_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId
|
|||
let text = if connectable {
|
||||
tr("Link this component to its code component: instances then show your real snippet with their values.")
|
||||
} else {
|
||||
tr("Choose React, SwiftUI or Compose above to connect this component to its code.")
|
||||
tr("Choose React, Leptos, SwiftUI or Compose above to connect this component to its code.")
|
||||
};
|
||||
widgets::note(ui, text, t.text_secondary);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -227,14 +227,20 @@ fn control_requests_and_screenshots_wake_an_idle_ui() {
|
|||
h.settle();
|
||||
// A request is answered in the next frame, which asks for one more to show its effect.
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
h.app.control_queue.push(ControlRequest { id: json!(1), method: "ui.inspect".into(), params: json!({}), reply: tx.clone() });
|
||||
h.app.control_queue.push(ControlRequest {
|
||||
id: json!(1),
|
||||
method: "ui.inspect".into(),
|
||||
params: json!({}),
|
||||
reply: tx.clone(),
|
||||
cancelled: Default::default(),
|
||||
});
|
||||
assert_eq!(h.frame(vec![]), Duration::ZERO);
|
||||
assert!(rx.try_recv().unwrap().result.is_ok());
|
||||
h.assert_idle("after a control request");
|
||||
// A screenshot keeps frames coming until the window delivers it, then the UI rests again.
|
||||
let path = std::env::temp_dir().join(format!("figcraft-idle-shot-{}.png", std::process::id()));
|
||||
let params = json!({"path": path.to_string_lossy()});
|
||||
h.app.control_queue.push(ControlRequest { id: json!(2), method: "ui.screenshot".into(), params, reply: tx });
|
||||
h.app.control_queue.push(ControlRequest { id: json!(2), method: "ui.screenshot".into(), params, reply: tx, cancelled: Default::default() });
|
||||
for _ in 0..5 {
|
||||
assert_eq!(h.frame(vec![]), Duration::ZERO, "waiting for the screenshot");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,6 +113,8 @@ mod live_text_tests;
|
|||
#[cfg(test)]
|
||||
mod online_tests;
|
||||
#[cfg(test)]
|
||||
mod open_issues_tests;
|
||||
#[cfg(test)]
|
||||
mod panel_reorder_tests;
|
||||
#[cfg(test)]
|
||||
mod panel_width_tests;
|
||||
|
|
@ -735,7 +737,7 @@ impl FigApp {
|
|||
"dialog.unsaved" => self.toast(i18n::tr("This file has unsaved changes")),
|
||||
"dialog.plugins" => self.ui.plugins_window = true,
|
||||
"dialog.cursorChat" => live::open_chat(self),
|
||||
"window.new" | "window.focus" => self.window_requests.push(r),
|
||||
"window.new" | "window.focus" | "window.fullscreen" => self.window_requests.push(r),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
// Pixels or rem (of a 16 px root) for the web languages.
|
||||
let rem_id = Id::new("inspect_rem");
|
||||
let web = matches!(lang, CodeLang::Css | CodeLang::Html | CodeLang::React);
|
||||
let web = lang.is_web();
|
||||
let rem = web && ui.data(|d| d.get_temp::<bool>(rem_id)).unwrap_or(false);
|
||||
row(ui, 32.0, |ui| {
|
||||
let resp = widgets::dropdown(ui, lang.label(), 120.0, Some(Icon::Code));
|
||||
|
|
|
|||
|
|
@ -189,3 +189,25 @@ fn enter_with_a_shape_tool_places_one_in_the_middle_of_the_view() {
|
|||
press(&mut u, Key::Enter, Modifiers::NONE);
|
||||
assert!(u.app.session.ds().unwrap().vector_edit.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_screen_key_and_command_toggle_the_window() {
|
||||
let mut u = Ui::new(Session::new());
|
||||
let fullscreen = |out: &egui::FullOutput| -> Vec<ViewportCommand> {
|
||||
out.viewport_output.get(&ViewportId::ROOT).map(|v| v.commands.clone()).unwrap_or_default()
|
||||
};
|
||||
// F11 (Windows and Linux) or Ctrl+Cmd+F (macOS) asks the window to fill the screen.
|
||||
let out = if crate::shortcuts::platform() == Platform::Mac {
|
||||
press(&mut u, Key::F, Modifiers::MAC_CMD | Modifiers::COMMAND | Modifiers::CTRL)
|
||||
} else {
|
||||
press(&mut u, Key::F11, Modifiers::NONE)
|
||||
};
|
||||
assert!(fullscreen(&out).contains(&ViewportCommand::Fullscreen(true)), "full screen key, {:?}", fullscreen(&out));
|
||||
// `on: false` leaves it, whatever the window reports.
|
||||
u.app.run("view.fullscreen", json!({"on": false}));
|
||||
let out = u.frame_output(vec![]);
|
||||
assert!(fullscreen(&out).contains(&ViewportCommand::Fullscreen(false)), "{:?}", fullscreen(&out));
|
||||
// Junk parameters are ignored, not fatal.
|
||||
u.app.run("view.fullscreen", json!({"on": "yes"}));
|
||||
u.frame(vec![]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -242,6 +242,12 @@ pub fn focus(app: &mut FigApp, ctx: &Context, id: u64) -> Result<Value, String>
|
|||
pub(crate) fn requests(app: &mut FigApp, ctx: &Context) {
|
||||
for r in std::mem::take(&mut app.window_requests) {
|
||||
let n = |k: &str| r.data.get(k).and_then(Value::as_u64);
|
||||
if r.kind == "window.fullscreen" {
|
||||
let full = ctx.input(|i| i.viewport().fullscreen.unwrap_or(false));
|
||||
let on = r.data.get("on").and_then(Value::as_bool).unwrap_or(!full);
|
||||
ctx.send_viewport_cmd(ViewportCommand::Fullscreen(on));
|
||||
continue;
|
||||
}
|
||||
let res = if r.kind == "window.new" {
|
||||
open(app, ctx, n("index").map(|i| i as usize), r.data.get("path").and_then(Value::as_str), None).map(|_| ())
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -53,6 +53,9 @@ page 1 "Screens": 380 nodes, 4 top-level
|
|||
data…) is printed as `note:` lines.
|
||||
- The format comes from `--format`, else `<out>`'s extension, else png. `--scale` (default 1, max 64)
|
||||
applies to raster formats.
|
||||
- SVG/PDF exports with visible descendants more than 256 levels below an exported root fail with
|
||||
an error instead of writing an incomplete file. Select an inner layer with `--node` or reduce
|
||||
nesting. An existing destination file is kept on this failure.
|
||||
- A `.pdf` `<out>` without `--node` holds the page's top-level frames, one PDF page each in reading
|
||||
order, each the frame's size in points (a page without frames goes on one page). Shapes, text
|
||||
and gradients stay vector, text is real (selectable, with subset fonts) and its links clickable;
|
||||
|
|
|
|||
|
|
@ -632,7 +632,10 @@ resuming with its offline edits, and leaving the session going back to snapshot
|
|||
`Client::receive` with the engine's recompute hook, and the resulting document is installed with
|
||||
`Session::apply_remote`, which keeps your selection, page and view and never adds an undo step.
|
||||
Batches that arrive during a gesture wait for it to end. New layers take ids in the client's
|
||||
space (`client_id << 32 | counter`). Closing the session's document leaves the session.
|
||||
space (`client_id << 32 | counter`). Closing the session's document leaves the session. Revert to
|
||||
saved (`file.revert`) is refused while the document belongs to a session, including publishing,
|
||||
reconnecting and hosting before the welcome. Refusal keeps the document, previews and session
|
||||
unchanged; leave the session first to revert the local file.
|
||||
- Each applied batch wakes the window (the inbox calls `request_repaint`); nothing runs while
|
||||
nobody edits or moves.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Collaboration parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version
|
||||
history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model,
|
||||
|
|
@ -12,7 +12,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
|
|||
|
||||
| Feature | Figma | FigCraft | Hours |
|
||||
|---|---|---|---|
|
||||
| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Left: server archives beyond Linux | 25–55 |
|
||||
| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Left: server archives beyond Linux | 25–55 |
|
||||
| Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors, selection outlines, avatars, follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 |
|
||||
| Cursor chat, audio, high-fives | yes | **partial, in live sessions**: cursor chat (`/` opens a bubble at your pointer; everyone sees it as you type, it fades a few seconds after the last change, Enter sends and Escape takes it back, 100 characters; `collab.cursorChat` for agents); sent as ephemeral presence the host checks and rate-limits, never stored. No audio, high-fives or emote reactions | 2–5 |
|
||||
| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The list is per computer: no accounts, teams or projects | 8–17 |
|
||||
|
|
@ -26,6 +26,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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-12 | minor | Your online files (M9): a file put on a server without its key stays its owner's after its tab closes or the app quits. The app records each one with the preferences (server, id, name, owner token, the owner's links, the codes others join with; at most 200; the token never in a command result, `ui.inspect`, a log or debug output) and Home ▸ **Online** lists them: open one as its owner (a new tab, or its tab comes to the front), Start / Stop sharing, Copy code / Copy link, Rename on the server, Remove from server (confirmed), Forget (the file stays on the server). Nothing is asked of a server until the section opens; then one request per server (`Admin owned`, a compatible addition within protocol version 2) says which files are still there, and a server that is offline or older leaves the list as recorded. Tabs of files in a live session carry a mark (shared, or open only to you), toasts about a file that isn't in front name it, and after a crash a recorded file reopens from its server as its owner, its recovery copy kept until its unsent changes are back on the server (or it is opened beside the file). `collab.files`, `collab.openFile`, `collab.forgetFile`; `collab.manageFile` takes its server and token from the record and gains `share` / `unshare`. Files are known per computer, not per person (no accounts): percentages and hours unchanged |
|
||||
| 2026-10-12 | minor | Sharing with a join code (M9, from the owner's use: sharing wasn't intuitive): the Share popover is one flow, **Online** (the file is published to the server in Preferences, built in `wss://collab.craftapps.dev/`, and the server names its edit and view links with nine-symbol codes like `K7M-2QX-9TD`) or **Local network** (the app hosts; a 20-symbol code carries its IPv4 address, port and a 44-bit secret, with check bits), one Start sharing button, the code large with Copy code and Copy link, who is here, can edit / can view online, and Stop sharing (online: the others' links and codes are revoked and they are disconnected, while the file stays on the server with its owner in it and can be shared again with new codes; removing it is a separate, confirmed step); **Join with a code** is one field (popover, home screen, Join dialog) for a code of either kind or a link; addresses, keys and ports moved under Advanced and Preferences ▸ Server for online sharing (reset, or empty to turn online sharing off). Protocol additions within version 2 (`Resolve` / `Resolved`, `Admin code`): the server keeps a stretched hash and the wrapped token, never the code, and limits wrong codes (10 a minute per address, a 15-minute lockout after 10 wrong, 1,000 wrong an hour for everyone); codes go with their link and file. Older servers leave the links in place of a code; older apps join by link. `collab.share`, `collab.join {code}`, `code` and `share` in `collab.peers`; the browser joins by code. Usability and hosting rather than a new capability: percentages and hours unchanged |
|
||||
| 2026-10-12 | minor | Live sessions are per document (a fix from real use: after hosting or joining in one tab, a new or other file showed that session, and starting one there silently replaced it). Every open file has its own session or none; several run at once and keep syncing behind the tab in front; the Share popover, the people, the cursors, cursor chat, spotlight, follow and the read-only reason are the file in front's (each window its own); the `collab.*` commands act on the active file's session and `collab.peers` lists all of them (`sessions`); joining opens the session's file in its own tab without touching other sessions; closing a file ends its session only. One link carries the sessions, each under its own id (desktop, browser and tests). Percentages and hours unchanged |
|
||||
|
|
|
|||
|
|
@ -21,6 +21,20 @@ real UI state. A request that the UI doesn't answer within 60 s gets a timeout e
|
|||
not JSON gets `{"id": null, "ok": false, "error": "bad JSON: …"}`. The implementation is in
|
||||
`crates/ui-egui/src/control.rs` (methods) and `apps/figcraft/src/control_server.rs` (socket).
|
||||
|
||||
A request line is limited to **32 MiB before the newline**, including whitespace. The reader stops
|
||||
at the first excess byte, replies `{"id": null, "ok": false, "error": "message too large"}`, then
|
||||
closes the connection without consuming the rest of the frame. The server accepts at most **8
|
||||
active connections**; additional connections are closed immediately. Socket reads and writes each
|
||||
have a **60 s inactivity timeout**, so idle clients and clients that stop reading release their
|
||||
worker. This bounds blocked socket operations, not the total duration of a frame arriving slowly.
|
||||
Clients can reconnect after an idle timeout (the attached MCP backend already does).
|
||||
|
||||
Both the socket queue and the UI queue hold at most **8 requests** each, including requests waiting
|
||||
for another window's frame. A full socket queue replies with `"control request queue is full"`
|
||||
instead of dispatching the new request. These limits apply to the opt-in loopback control channel;
|
||||
requests still queued after the UI timeout are discarded, including requests deferred to a hidden
|
||||
window. A command already running is not interrupted.
|
||||
|
||||
Coordinates are page units, y down. `x`/`y` in `node.set`/`node.create` are relative to the parent
|
||||
frame (groups have no coordinate space of their own).
|
||||
|
||||
|
|
|
|||
|
|
@ -10,13 +10,13 @@ inside generated layer trees. Remaining: 0–1 h.
|
|||
|---|---|---|
|
||||
| Dev Mode toggle (Shift+D), Inspect panel | done | |
|
||||
| Properties with their styles and variables; redlines on hover | done | positions from the frame around the layer (groups aren't frames), rotation, radius (CSS corner order), opacity, blend; colours in hex, RGB, CSS, HSL or HSB (`inspect.properties {colorFormat}`, the Inspect tab's Color format) |
|
||||
| Code: CSS, iOS (SwiftUI), Android (Compose) | done | plus HTML and React (ours); components take properties as parameters, instances are calls; CSS in px or rem (`inspect.code {unit, rootFontSize}`), with rotation, blend modes, gradient directions and kinds (linear, radial, conic), text alignment, case and decoration |
|
||||
| Code: CSS, iOS (SwiftUI), Android (Compose) | done | plus HTML, React and Leptos (ours; Leptos: `#[component]` functions with `#[prop]` defaults, variant enums and `view!` markup); components take properties as parameters, instances are calls; CSS in px or rem (`inspect.code {unit, rootFontSize}`), with rotation, blend modes, gradient directions and kinds (linear, radial, conic), text alignment, case and decoration |
|
||||
| Assets list with export | done | layers with export settings, icons (small, vector shapes only: SVG) and images (PNG); the icon rule is ours |
|
||||
| Annotations: pinned properties, categories, coloured callouts, filter | done | |
|
||||
| Ready for dev list, statuses, "Changed" after edits | done | |
|
||||
| Focus view (one frame, previous / next) | done | |
|
||||
| Compare changes (before/after side by side or overlaid, property diffs) | done | only against the snapshot taken when a frame was marked ready; not against any named version |
|
||||
| Code connect (map components to repository code) | done | per language (React, SwiftUI, Compose): import path, name and a template with `{property}` and `{property ? "a" : "b"}` placeholders; instances (also inside generated layer trees) show the snippet with their values; edited in the Inspect panel with a live preview; `figcraft.connect.json` export/import. Inside a generated layer tree, connected instances' imports aren't collected |
|
||||
| Code connect (map components to repository code) | done | per language (React, Leptos, SwiftUI, Compose): import path, name and a template with `{property}` and `{property ? "a" : "b"}` placeholders; instances (also inside generated layer trees) show the snippet with their values; edited in the Inspect panel with a live preview; `figcraft.connect.json` export/import. Inside a generated layer tree, connected instances' imports aren't collected |
|
||||
| Plugins in Dev Mode (Jira, GitHub, etc.) | partial | FigCraft's own WASM plugins that only read run from the Inspect tab; no plugin UI at run time, no third-party integrations |
|
||||
| VS Code extension | out of scope | FigCraft's MCP server and control channel already serve design data, code and connected snippets to agents and editors |
|
||||
| Dev resources links on layers | done | a URL and a name per link on any layer (`devResource.*`, saved, synced, undoable); instances list their main component's (and its set's) read-only; URLs checked as http/https and never fetched (no page titles: a link without a name is named after its URL); the Inspect tab lists, adds, edits and removes them; `get_node` returns them |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# File format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (`.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of
|
||||
`/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's
|
||||
|
|
@ -40,12 +40,19 @@ and prototype interactions are still unchecked there.
|
|||
|
||||
## Export
|
||||
|
||||
SVG and PDF refuse an export whose visible layer tree exceeds 256 descendant levels from any
|
||||
exported root (the root is depth zero). The error identifies the root and offending layer and
|
||||
suggests exporting an inner layer or reducing nesting. Hidden subtrees do not count; masks and
|
||||
subtrees rendered into embedded images do. Refusal returns no partial SVG/PDF and leaves the
|
||||
existing destination file intact. Selecting an inner root starts a new depth count. This is an
|
||||
export limit, not a native document nesting limit.
|
||||
|
||||
| Format | Figma | FigCraft | Notes | Hours |
|
||||
|---|---|---|---|---|
|
||||
| PNG | yes, scales/suffixes | yes | export settings per layer, presets (`2x`, `512w`, `512h`), preview, "Export N layers"; contents only (off: every layer showing in the area) and include bounding box (the layer's own box); slashes in layer names export into folders. The exported area is what the layer paints: shadows towards their offset, outside and centre strokes, unclipped children, a mask group's mask, nothing for hidden layers; slices export everything under them; sides round up to whole pixels | — |
|
||||
| JPG | yes | yes | white where transparent; fixed quality (90) | — |
|
||||
| SVG | yes (outline text option, ids) | yes | alpha/luminance masks kept; noise, texture, progressive and background blur, glass embedded as images; outline text (off: `<text>` with one `<tspan>` per line, one style per layer), unique id attributes from layer names, simplified strokes (stroke attributes for centred strokes and for inside and outside strokes on boxes and ellipses) | — |
|
||||
| PDF | yes | yes | one page per frame, real selectable text in subset fonts, links, vectors and gradients as vectors, effects as images; bleed, crop/bleed marks, page boxes, naive CMYK; checked by rendering the PDF back against PNG exports | CMYK output intent 2–4 |
|
||||
| SVG | yes (outline text option, ids) | yes | alpha/luminance masks kept; noise, texture, progressive and background blur, glass embedded as images; outline text (off: `<text>` with one `<tspan>` per line, one style per layer), unique id attributes from layer names, explicit depth-limit refusal, simplified strokes (stroke attributes for centred strokes and for inside and outside strokes on boxes and ellipses) | — |
|
||||
| PDF | yes | yes | one page per frame, real selectable text in subset fonts, links, vectors and gradients as vectors, effects as images; bleed, crop/bleed marks, page boxes, naive CMYK; checked by rendering the PDF back against PNG exports; explicit depth-limit refusal | CMYK output intent 2–4 |
|
||||
| WebP | — | yes (ours) | | — |
|
||||
| Copy as CSS / SVG / PNG / text / link | yes | yes | Copy as PNG: the whole selection at 2x | — |
|
||||
| Dev Mode code (CSS, iOS SwiftUI, Android Compose) | yes | yes, plus HTML and React | see [dev-mode-parity](dev-mode-parity.md) | — |
|
||||
|
|
@ -155,6 +162,7 @@ each.
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | minor | SVG/PDF depth overflow (#208) is checked before bounds and drawing; public checked APIs return the offending root/layer, commands and CLI report it, and no incomplete export replaces an existing file. Tested at depths 255/256/257, with inner roots, hidden branches, masks, raster fallbacks and multiple roots/pages. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | `.fig` export (gap 1): instances write what they change from their main as an override addressed by the main's id (their size, name and paints were lost elsewhere without it), what follows for their layers as derived data, and a scale factor when scaled as a whole; layers of a nested instance swapped for another component keep their overrides; variable collections, modes, variables and their bindings, the modes frames and pages set, shared styles with their links and variable-width strokes are written, and the import reads all of it back (style definitions and unused styles, variable order and scopes, bindings on paint lists, gradient stops, effects and layout grids, page modes, text field bindings, which a `.fig` import used to drop, and width profiles). Opened `.fig` and file JSON documents link nested instances of variants (and any nested instance under a name of its own) to their main's layers; they stayed unlinked, so a second export lost what they changed. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged |
|
||||
| 2026-10-12 | minor | `.fig` export made to open elsewhere (gap 1): the document and its pages say they are visible and opaque (left out, nothing was drawn), the schema in the file uses the format's own names, kinds, field ids and enum values, rectangles are rounded rectangles with four radii, frames and shapes carry their fill outline and plain strokes their stroke outline, text carries its layout settings, font styles use their standard names, line heights that are a share of the font size are raw multipliers, masks keep their type (read back too); the import reads per-side stroke weights whose zero sides are left out. Written files were checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; effects such as glass and noise, images and prototype interactions are still unchecked there, and variables are not written. Numbers unchanged |
|
||||
| 2026-10-12 | minor | Experimental `.fig` export (gap 1): `file.export {format: "fig"}`, File ▸ Export… ▸ .fig (experimental), the CLI and MCP write the whole document (layer tree, geometry, paints with image bytes, strokes, effects, text with styled runs, vector networks, auto layout, constraints, components, sets, instances with their overrides and property values, prototype interactions) with a kiwi encoder that mirrors the decoder and the schema of the fields written; what's left out is a warning. The import gained ellipse arcs, boolean operation types and component set descriptions, and reads durations as whole milliseconds. Verified only by round trips through our own importer. Summary ~59% → ~60%; `.fig` write 15–30 h → 5–15 h (checks against real files) |
|
||||
|
|
|
|||
52
docs/mcp.md
52
docs/mcp.md
|
|
@ -5,6 +5,11 @@ design with it: create and edit layers, run any menu command, inspect the result
|
|||
it as a PNG. It speaks newline-delimited JSON-RPC 2.0 over stdio (MCP protocol `2025-06-18`;
|
||||
`2025-03-26` and `2024-11-05` clients are accepted too).
|
||||
|
||||
Incoming lines are limited to **32 MiB before the newline**, including whitespace. The limit is
|
||||
enforced while reading: an oversized or unterminated frame stops at the first excess byte, returns
|
||||
JSON-RPC error `-32600` (`message too large`, null id), and closes the stdio session without reading
|
||||
the rest or dispatching later requests. Lines within the limit keep the same JSON-RPC behavior.
|
||||
|
||||
The server lives in `crates/mcp` (`figcraft-mcp`) and is started by the CLI:
|
||||
|
||||
| Command | Backend |
|
||||
|
|
@ -14,6 +19,13 @@ The server lives in `crates/mcp` (`figcraft-mcp`) and is started by the CLI:
|
|||
|
||||
`--attach` also takes `host:port`. The control channel only listens on 127.0.0.1.
|
||||
|
||||
Attached calls are never automatically replayed after a transport failure: the app may have
|
||||
applied the request before its reply was lost. The error reports that the operation may have
|
||||
completed; inspect the document before retrying an edit. The next explicit call reconnects.
|
||||
If that connection cannot be established, the error says the request was not sent.
|
||||
A connection the app closed while it sat idle is noticed before the next request is written and
|
||||
replaced then, so a pause between calls is not an error.
|
||||
|
||||
### Fonts
|
||||
|
||||
The headless server lays text out with the same fonts as the app: the fonts built into FigCraft
|
||||
|
|
@ -28,6 +40,9 @@ built-in fonts. Attached, the app's own fonts apply. The browser build has no sy
|
|||
Build the CLI first (`cargo build --release -p figcraft-cli`) and use the absolute path to
|
||||
`target/release/figcraft-cli`.
|
||||
|
||||
The Linux AppImage bundles the CLI: `FigCraft.AppImage cli <args>` runs `figcraft-cli <args>`, so
|
||||
`/path/to/FigCraft.AppImage cli mcp` works wherever `/path/to/figcraft-cli mcp` appears below.
|
||||
|
||||
### Claude Code
|
||||
|
||||
```sh
|
||||
|
|
@ -203,8 +218,8 @@ Or in a project's `.mcp.json`:
|
|||
| Tool | What it does |
|
||||
|---|---|
|
||||
| `list_commands {filter?}` | Every command: id, label, menu, shortcut, undoable, params doc. |
|
||||
| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `component.makeSlot` / `instance.slotInsert` / `instance.resetSlot` (slots, below), `inspect.code` (CSS, HTML + CSS for a whole frame, React, SwiftUI or Jetpack Compose for a layer; components take their properties as parameters and instances are calls, see Dev Mode below), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names; `colorFormat`: hex, rgb, css, hsl or hsb), `inspect.assets` (layers under the selection with export settings, icons and images, each with its `kind`), `inspect.code` takes `unit: rem` and `rootFontSize` for CSS, HTML and React, `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}`, `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`, `codeConnect.set` (components linked to real code, see Dev Mode below)). |
|
||||
| `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`); all-or-nothing. |
|
||||
| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `component.makeSlot` / `instance.slotInsert` / `instance.resetSlot` (slots, below), `inspect.code` (CSS, HTML + CSS for a whole frame, React, Leptos, SwiftUI or Jetpack Compose for a layer; components take their properties as parameters and instances are calls, see Dev Mode below), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names; `colorFormat`: hex, rgb, css, hsl or hsb), `inspect.assets` (layers under the selection with export settings, icons and images, each with its `kind`), `inspect.code` takes `unit: rem` and `rootFontSize` for CSS, HTML, React and Leptos, `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}`, `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`, `codeConnect.set` (components linked to real code, see Dev Mode below)). |
|
||||
| `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`); all-or-nothing for open documents and their document state. Failed inner commands are removed from the journal; preferences, clipboard, tool state and UI requests are not rolled back. |
|
||||
| `create_node {type, x, y, width, height, parent, index, name, text, path, props}` | Create a frame, rectangle, ellipse, polygon, star, line, arrow, text, vector, section, component or slice; returns `{id, node}`. |
|
||||
| `set_properties {ids, props}` | `node.set` on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, masks (`isMask`, `maskType: alpha\|vector\|luminance`), handoff status (`devStatus: none\|readyForDev\|completed`; marking again clears "Changed"), a section's `sectionContentsHidden`, text…); returns the updated layers. |
|
||||
| `inspect_document {depth, allPages}` | The document as JSON (layer tree, styles, selection, history). |
|
||||
|
|
@ -1156,27 +1171,30 @@ permissions: without it nothing is downloaded); `plugin.directory.publish {path
|
|||
|
||||
### Dev Mode
|
||||
|
||||
- **Component code:** `inspect.code {id, language: react|swiftui|compose}` on a component, a
|
||||
component set or a variant writes the component as a reusable unit: React (TSX) with a props
|
||||
interface, SwiftUI with a `View` struct, Compose with a `@Composable` function. Its component
|
||||
properties are the parameters, with their defaults: variant properties become string unions
|
||||
(React) or enums, booleans become flags around the layers they show, text properties strings,
|
||||
instance swaps views (`React.ReactNode`, `AnyView`, a composable lambda) and slots optional
|
||||
content. Every variant of a set shares one definition; in React the other variants' differences
|
||||
become CSS rules on `data-*` attributes. On an instance (or a layer tree containing instances)
|
||||
each instance is a call passing only the values it changes, e.g. `<Button state="Hover"
|
||||
label="Buy" />`, `Button(state: .hover, label: "Buy")`, `Button(state = ButtonState.Hover, label =
|
||||
"Buy")`. Names are escaped and unique (keywords get a suffix, `default` → `defaultValue`; names
|
||||
that would shadow framework types get one too, `Text` → `TextComponent`). `inspect.componentCode
|
||||
- **Component code:** `inspect.code {id, language: react|leptos|swiftui|compose}` on a component,
|
||||
a component set or a variant writes the component as a reusable unit: React (TSX) with a props
|
||||
interface, Leptos with a `#[component]` function (`#[prop]` defaults, `view!` markup), SwiftUI
|
||||
with a `View` struct, Compose with a `@Composable` function. Its component properties are the
|
||||
parameters, with their defaults: variant properties become string unions (React) or enums,
|
||||
booleans become flags around the layers they show, text properties strings, instance swaps views
|
||||
(`React.ReactNode`, Leptos `ViewFn`, `AnyView`, a composable lambda) and slots optional content.
|
||||
Every variant of a set shares one definition; in React and Leptos the other variants'
|
||||
differences become CSS rules on `data-*` attributes. On an instance (or a layer tree containing
|
||||
instances) each instance is a call passing only the values it changes, e.g. `<Button
|
||||
state="Hover" label="Buy" />`, `<Button state=ButtonState::Hover label="Buy" />`, `Button(state:
|
||||
.hover, label: "Buy")`, `Button(state = ButtonState.Hover, label = "Buy")`. Names are escaped and
|
||||
unique (keywords get a suffix, `default` → `defaultValue`, Rust's `type` → `type_`; Leptos names
|
||||
are snake_case; names that would shadow framework types get one too, `Text` → `TextComponent`). `inspect.componentCode
|
||||
{id?, language?}` returns the parts: `{component, name, set, properties: [{name, param, type,
|
||||
default, value? (instances)}], definition, usage? (instances)}`.
|
||||
- **Code connect:** link a component (a variant links its set) to the code component it is built
|
||||
as, per language (`react`, `swiftui`, `compose`): `codeConnect.set {id?, language?, importPath?,
|
||||
as, per language (`react`, `leptos`, `swiftui`, `compose`): `codeConnect.set {id?, language?, importPath?,
|
||||
name?, template?}` (undoable; fields left out keep their value; the defaults are the generated
|
||||
name and a call passing every property) → `{component, language, code, unknown}`. Templates are
|
||||
text, never executed: `{property}` (its name or parameter name) is the instance's value escaped
|
||||
for the language (React keeps JSX's braces, so `size={size}` gives `size="Large"` and
|
||||
`disabled={disabled}` gives `disabled={true}`), `{property ? "a" : "b"}` inserts one of two
|
||||
for the language (React and Leptos keep the markup's braces, so `size={size}` gives
|
||||
`size="Large"` and `disabled={disabled}` gives `disabled={true}`; Leptos imports with `use
|
||||
<importPath>::<name>;`), `{property ? "a" : "b"}` inserts one of two
|
||||
strings, any other `{…}` stays as written (`unknown` lists placeholder names that aren't
|
||||
properties). Then `inspect.code` on an instance or the component returns the import line and the
|
||||
snippet instead of generated code (`connected: true`), connected instances inside a generated
|
||||
|
|
|
|||
|
|
@ -26,6 +26,27 @@ permissions its manifest asks for, and nothing more.
|
|||
updated when a newer version is published (see [Plugin directories](#plugin-directories)). No
|
||||
third-party service is involved.
|
||||
|
||||
## Logging limits
|
||||
|
||||
Logs have separate limits from the plugin's WebAssembly memory and general message limits. By
|
||||
default, each run or panel/widget callback may log at most:
|
||||
|
||||
| Limit | Default |
|
||||
|---|---|
|
||||
| UTF-8 bytes per log line | 8 KiB |
|
||||
| Total UTF-8 log bytes | 1 MiB |
|
||||
| Log lines (including empty lines) | 1,000 |
|
||||
|
||||
The sandbox checks these limits before copying a line from plugin memory or forwarding it to the
|
||||
application logger. Accepted lines are kept in the run's `logs` result and forwarded once; the
|
||||
same byte and count budgets apply to both. The byte budget counts the strings' contents, excluding
|
||||
their container overhead. The general message limit also applies to log lines.
|
||||
|
||||
Exceeding a log limit fails the run with a limit error and rolls back its document edits, selection
|
||||
and page, just like other sandbox failures. Earlier accepted lines may already have reached the
|
||||
application log. Each callback starts with fresh budgets. Keep log messages short and summarize
|
||||
large data instead of logging it in full.
|
||||
|
||||
## Using plugins
|
||||
|
||||
- **Plugins ▸ Development ▸ Import plugin from manifest…** picks a `figcraft-plugin.json`. The
|
||||
|
|
@ -230,12 +251,17 @@ it can handle):
|
|||
|
||||
Limits per run, and again per panel callback: 1,000,000,000 fuel (about a second or two of plain
|
||||
WebAssembly in a debug build, far less in a release build), 64 MB of memory, 100,000 host calls
|
||||
(each also costs 10,000 fuel), 16 MB per string either way, 1,000 log lines, and modules up to
|
||||
(each also costs 10,000 fuel, including `reply_len`, `reply_read` and `set_output`), 16 MB per string either way, 1,000 log lines, and modules up to
|
||||
16 MB. A panel spec is at most 64 KB with 64 controls; settings are at most 64 KB per plugin (keys
|
||||
and JSON values), keys 1–128 characters. A widget callback gets the same limits; its description is
|
||||
at most 4 MB with 256 layers nested 16 deep, its state at most 16 KB (keys 1–128 characters), and
|
||||
its images 1 MB each, 2 MB together and 4096 pixels on a side.
|
||||
|
||||
Copying a byte across the guest-memory boundary costs one additional fuel, reserved before the
|
||||
allocation or copy. This also applies when the configured per-call overhead is zero. A reply
|
||||
read is charged for the bytes actually returned, not the requested buffer size. `calls` counts
|
||||
every ABI import, including calls which only query the reply length.
|
||||
|
||||
## ABI (version 3)
|
||||
|
||||
Version 2 adds panels and settings to version 1, and version 3 adds widgets; a version 1 or 2
|
||||
|
|
|
|||
|
|
@ -148,7 +148,8 @@ shared-mime-info file for `.figcraft`) and builds every format from it: `.tar.gz
|
|||
`.rpm` with [nfpm](https://nfpm.goreleaser.com/) (`nfpm.yaml` lists the dlopen'd runtime
|
||||
libraries as dependencies); and an `.AppImage` with update information and a `.zsync`, whose
|
||||
`AppRun` registers the desktop entry and icons on launch so Wayland docks show the right icon
|
||||
(`FIGCRAFT_NO_DESKTOP_INTEGRATION=1` opts out; covered by `apprun-test.sh`).
|
||||
(`FIGCRAFT_NO_DESKTOP_INTEGRATION=1` opts out; covered by `apprun-test.sh`) and runs the bundled
|
||||
`figcraft-cli` for `FigCraft.AppImage cli <args>`.
|
||||
`flatpak-bundle.sh` repackages the tarball into a `.flatpak` with
|
||||
`flatpak/ai.storyteller.figcraft.bundle.yml` (freedesktop 26.08). `flatpak/ai.storyteller.figcraft.yml`
|
||||
is the from-source manifest for a Flathub submission (not built in CI; vendor crates with
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# FigCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (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; `.fig` import, best effort; 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 ~79%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **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; 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; `.fig` import, best effort; 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 ~79%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and
|
||||
[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours
|
||||
|
|
@ -48,7 +48,7 @@ blockers.
|
|||
|---|---|---|---|
|
||||
| M0 Foundation: workspace, model, renderer, engine, UI shell, app, control channel | done (2026-10-09) | — | — |
|
||||
| M0.9 CLI + MCP, web build, release packaging, PDF export | done (v0.2.0 released 2026-10-10 for every platform) | — | — |
|
||||
| M1 Core editing completeness (tools, panels, menus, clipboard, files) | ~77% | interaction pass (Open recent, the outline mode variants and keyboard box selection landed 2026-10-11) | 6–12 + pass |
|
||||
| M1 Core editing completeness (tools, panels, menus, clipboard, files) | ~77% | interaction pass (Open recent, the outline mode variants and keyboard box selection landed 2026-10-11; repeat commands preserve the underlying edit and undo steps, #181; the Layout panel's constrain-proportions link scales the other side, #220) | 6–12 + pass |
|
||||
| M2 Layout depth | ~80% | grid `fr` rows, real-file QA | 6–12 |
|
||||
| M3 Components & styles depth | ~78% (extended collections, variables import/export, gradient stop and variant bindings landed 2026-10-11) | real libraries, component playground | 7–14 |
|
||||
| M4 Vector & text depth | ~80% (live booleans, arc fields, shape builder on strokes, boolean hit testing on the result, one-sided widths, text on a path, pattern fills, brushes and pen pressure landed 2026-10-11) | spell check, Figma Draw multi-edit | 7–18 |
|
||||
|
|
@ -73,6 +73,8 @@ blockers.
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | M1, #220: The Layout panel's constrain-proportions link keeps the ratio on a W or H edit; the property edit dropped it, 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 (transform behaviour suite). Estimates unchanged. |
|
||||
| 2026-10-13 | minor | M1, #181: Repeat last command keeps the underlying command and parameters instead of recording itself; a stored self-reference is refused. Three cumulative repeats and their individual undo steps are covered by regression tests. Estimates unchanged. |
|
||||
| 2026-10-12 | minor | Simplified Chinese draft catalog (gap 5): `zh-Hans.toml` translates every UI string (1,371 keys; plurals give only `other`, and `plural_one` never picks "one"), command label (524) and menu wording (29), machine-drafted in a concise UI style with full-width punctuation and marked as a draft awaiting native review; Preferences ▸ Language lists 简体中文, System maps zh, zh-CN, zh-SG and zh-Hans to it, and Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and stays English (or the next OS language), never Simplified. A Chinese UI draws Han in a Chinese face first (Microsoft YaHei, PingFang SC, Noto Sans CJK SC), ahead of the craft-fonts build's Japanese BIZ UDPGothic, and changing the UI language lays the CJK faces out again (switching from Japanese to Chinese no longer keeps the Japanese face in front). String counts re-measured: 1,371 UI keys, 524 command labels and 29 menu wordings (1,924 entries); the French, German, Brazilian Portuguese, Japanese and Korean drafts have 1,899 of them (the 25 newer strings read English). Focus item 4 adds the zh-Hans draft's review |
|
||||
| 2026-10-12 | minor | Latin American Spanish overlay (gap 5): `es-419.toml` lists only the 87 entries (61 UI strings, 26 command labels) whose wording differs in Latin American usage (agregar for añadir, presionar for pulsar, configuración for settings, Enter for the Intro key, control for a gamepad, resorte for a spring) and reads `es.toml`, then English, for the rest (a regional catalog overlays its language's); same tú register as Spanish; a draft awaiting native review. Preferences ▸ Language lists Español (Latinoamérica); System maps es-419, es-MX, es-AR, es-CO and the other Latin American and US Spanish tags to it, es-ES and es to Spanish. Focus item 4 no longer lists Latin American Spanish |
|
||||
| 2026-10-12 | minor | M5: prototyping tail: controller buttons on key/gamepad triggers (Windows and the browser), widgets reacting in prototypes, `proto.evaluate`; M5's tail 3–6 h → 2–5 h |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# FigCraft parity with Figma
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
|
||||
|
||||
The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md)
|
||||
summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is
|
||||
|
|
@ -111,7 +111,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
|
|||
| 15 | Dev Mode / inspect | 2 | 96% | 72% | Import lines for connected instances inside generated layer trees; an editor extension is left to the MCP server (inspect behaviour suite with colour formats, rem and icon and image assets, code connect, plugins in Dev Mode and dev resources links landed) ([dev-mode-parity](dev-mode-parity.md)) | 0–1 |
|
||||
| 16 | Comments, versions, find, misc | 3 | 87% | 63% | Comments are local only; comparing two versions; branches in live sessions; text runs and other lists besides fills, strokes and effects merge whole (branches with review and merge, their own version history and per-item list merges, and a comments and version history behaviour suite from the documentation landed) | 1–3 |
|
||||
| 17 | Multiplayer & sharing | 8 | 17% | 5% | Core landed (`crates/collab`: ids, change sets, server ordering, convergence tested); live sessions (preview): WebSocket transport, hosting from the desktop app, joining from the app or the browser, invite links that open the app, cursors, selections and follow, cursor chat and spotlight, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view, which can be open to the public (owner tokens, limits per address, expiry, moderation). Left: accounts and teams, audio ([collaboration-parity](collaboration-parity.md)) | 42–84 |
|
||||
| 18 | Plugins & widgets | 4 | 60% | 35% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK), plugin panels called back per button or changed value and per-plugin settings (ABI v2), then widgets (API 3: plugin-drawn layers on the canvas with state kept in the document, redrawn on clicks; a Counter example), panels side by side and a colour picker for plugin colour fields, then a plugin directory on `figcraft-server` (publish with the server key, browse, install after a permissions confirmation, hash-checked downloads, updates by version). Left: a public catalogue, publisher accounts and signatures, ratings, background update checks, the directory in the browser; widgets take clicks only (no text input, property menus, hover or timers) and draw frames, text, shapes and images only; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 8–26 |
|
||||
| 18 | Plugins & widgets | 4 | 60% | 35% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK), with log byte/count limits checked before allocation and host forwarding ([logging limits](plugins.md#logging-limits)), plugin panels called back per button or changed value and per-plugin settings (ABI v2), then widgets (API 3: plugin-drawn layers on the canvas with state kept in the document, redrawn on clicks; a Counter example), panels side by side and a colour picker for plugin colour fields, then a plugin directory on `figcraft-server` (publish with the server key, browse, install after a permissions confirmation, hash-checked downloads, updates by version). Left: a public catalogue, publisher accounts and signatures, ratings, background update checks, the directory in the browser; widgets take clicks only (no text input, property menus, hover or timers) and draw frames, text, shapes and images only; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 8–26 |
|
||||
| 19 | Team libraries & organization | 2 | 70% | 40% | Libraries kept on `figcraft-server` under their file and enabled by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names. Left: team-wide library lists, accounts and permissions, analytics over time, pushed update notices | 4–8 |
|
||||
| 20 | Figma Draw extras | 2 | 88% | 60% | Have: variable width, lasso, shape builder, noise/texture, text on a path, pattern fills, stretch and scatter brushes, pen pressure (Windows pens, browser styluses), multi-edit (several vector layers in edit mode at once). Missing: pressure from macOS/Linux tablets | 1–3 |
|
||||
| 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 |
|
||||
|
|
@ -625,6 +625,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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 | 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 | Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged. |
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
|
|||
| Bound on other keys (documented conflict) | 1 | Run last plugin: the documentation also gives Ctrl+Alt+P to pixel preview on Windows; the plugin takes Ctrl+Alt+Shift+P there |
|
||||
| Missing | 0 | moving the rotation origin (Alt+R, `transform.rotationOrigin`) and placing an object with Enter after choosing its tool (`tool.place`) were the last two |
|
||||
| Documented keys a platform keeps | 2 | Ctrl+Space (Windows/Linux keyboard box selection) switches some CJK input methods; F6 goes to a browser's address bar in the web build |
|
||||
| Undocumented bindings pinned | 48 | of which Cmd/Ctrl+W, Cmd/Ctrl+N and Ctrl+Tab are taken by browsers (web build) and Ctrl+Alt+T / Ctrl+Alt+L by some Linux desktops |
|
||||
| Undocumented bindings pinned | 49 | of which Cmd/Ctrl+W, Cmd/Ctrl+N and Ctrl+Tab are taken by browsers (web build) and Ctrl+Alt+T / Ctrl+Alt+L by some Linux desktops |
|
||||
|
||||
- **Newly bound:** A for the Frame tool, Shift+' for the pixel grid (Cmd+' kept: the articles
|
||||
disagree), Alt+Cmd+\ / Ctrl+Alt+\ for others' cursors (`view.multiplayerCursors`, new), Ctrl+F6
|
||||
|
|
@ -165,6 +165,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
|
|||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | Home screen: a Templates tab (eight starter templates as cards with thumbnails, search and a kind filter; click or Enter opens a new document) replaces the Community placeholder, the Drafts placeholder is removed, Recents offers four templates while there are no recent files and shows a Getting started card (closable; Help ▸ Getting started brings it back), File ▸ New from template…, and a first run opens on the home screen |
|
||||
| 2026-10-13 | minor | Full screen (#227): View ▸ Full screen (`view.fullscreen {on?}`, F11 on Windows and Linux, Ctrl+Cmd+F on macOS) makes the window fill the screen, taskbar and title bar included, and leaves it again; FigCraft's own item (not in the incumbent's menu). Percentages and hours unchanged |
|
||||
| 2026-10-12 | minor | Everyday editing partials: number fields (scrub a step a point, ×10 with Shift, ×0.1 with Alt, on past the screen edge where the platform reports it; arrow keys by the nudge preferences, opacity and rotation by 1 and 10; brackets and units in arithmetic; `50%` relative outside percentage fields; Tab commits and moves on; an untouched field commits nothing; relative edits and arrow keys on Mixed go to each layer in one undo step) done; outline mode's Include hidden layers and Include object bounds, drawn and clickable, done; Hide when publishing from the Assets panel done (the name's leading `.`); cursors per tool and modifier done (a table in the engine, drawn rotate, pen, eyedropper and comment cursors, not-allowed on refused edits; guides and handles keep their cursors); resizable panels done (limits, double-click reset, kept per window and in the preferences, the canvas keeps its room in a small window); a file or page made in the dark theme has a #1E1E1E page (the light theme and sessions without an interface: #F5F5F5) and text drawn on a dark page is white; Enter on a text selects all of it (test added). Menu items: View ▸ Outlines ▸ Include hidden layers / Include object bounds, View ▸ Panels ▸ Reset panel widths, Libraries ▸ Hide when publishing |
|
||||
| 2026-10-12 | minor | Drag threshold and double-clicks: the second press of a double-click began a move or resize that was never finished, so double-clicking text inside a group often didn't start editing (from the middle of the text) or dragged and resized it when the hand moved a pixel or two; a move or resize now starts only after 3 px of travel on screen and a double-click drops the press's gesture first |
|
||||
| 2026-10-12 | minor | Rotation origin and placing with Enter: Alt+R shows a rotation origin handle to drag (snapping to the box's corners, edge middles and centre; double-click resets; Escape hides; kept while the selection lasts, not saved), which the corner handles, the rotation field, Rotate 90°/180° and `transform.rotate` turn about (`transform.rotationOrigin`, `transform.setRotationOrigin`); Enter with a shape or frame tool chosen, on the canvas or the toolbar's tool, places a click-sized one in the middle of the view (`tool.place`). Documented shortcuts: 128 bound, 0 missing. The keyboard box selection's toast and screen-reader text name Cmd/Ctrl+arrows too; the shortcuts panel cuts a label that doesn't fit before its keycaps with "…" (measured in the font it's drawn in; long labels ran into the keycaps) and shows it in full on hover |
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@
|
|||
# (and whenever the AppImage's path changes) install the .desktop entry and the
|
||||
# hicolor icons into $XDG_DATA_HOME, then launch. Every failure here must leave
|
||||
# the launch untouched. Opt out with FIGCRAFT_NO_DESKTOP_INTEGRATION=1.
|
||||
#
|
||||
# `FigCraft.AppImage cli <args>` runs the bundled headless figcraft-cli with <args>
|
||||
# (e.g. `cli mcp`) instead of the app, without touching the desktop.
|
||||
|
||||
set -u
|
||||
|
||||
|
|
@ -68,5 +71,17 @@ integrate() {
|
|||
done
|
||||
}
|
||||
|
||||
# The CLI is headless and its stdout may be a protocol stream (`cli mcp`): no
|
||||
# desktop integration, straight to the binary.
|
||||
if [ "${1:-}" = cli ]; then
|
||||
shift
|
||||
cli="$APPDIR/usr/bin/figcraft-cli"
|
||||
if [ ! -x "$cli" ]; then
|
||||
echo "figcraft: this AppImage was built without figcraft-cli" >&2
|
||||
exit 127
|
||||
fi
|
||||
exec "$cli" "$@"
|
||||
fi
|
||||
|
||||
integrate
|
||||
exec "$APPDIR/usr/bin/figcraft" "$@"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
#!/usr/bin/env bash
|
||||
# Smoke test for packaging/linux/AppRun: the AppImage's desktop integration writes
|
||||
# and refreshes the host .desktop entry and icons, is idempotent, honours the opt-out
|
||||
# and an unwritable HOME, and always launches the app.
|
||||
# and an unwritable HOME, and always launches the app; `cli` runs figcraft-cli instead.
|
||||
#
|
||||
# Runs in seconds with no build (CI: .github/workflows/packaging-lint.yml).
|
||||
set -euo pipefail
|
||||
|
|
@ -95,4 +95,19 @@ out="$(APPIMAGE="$odd" "$APPDIR/AppRun" x)" || fail "launch with an odd path exi
|
|||
[ "$out" = "stub: x" ] || fail "odd path: $out"
|
||||
[ ! -e "$XDG_DATA_HOME" ] || fail "integrated an AppImage path that needs escaping"
|
||||
|
||||
# 10. `cli <args>` runs figcraft-cli with the remaining arguments and integrates nothing.
|
||||
rm -rf "$XDG_DATA_HOME"
|
||||
out="$("$APPDIR/AppRun" cli mcp --attach 7979 2>&1)" && fail "cli ran without figcraft-cli in the AppDir"
|
||||
[ "$out" = "figcraft: this AppImage was built without figcraft-cli" ] || fail "missing cli: $out"
|
||||
printf '#!/bin/sh\necho "cli stub: $*"\n' >"$APPDIR/usr/bin/figcraft-cli"
|
||||
chmod +x "$APPDIR/usr/bin/figcraft-cli"
|
||||
out="$("$APPDIR/AppRun" cli mcp --attach 7979)" || fail "cli launch exited non-zero"
|
||||
[ "$out" = "cli stub: mcp --attach 7979" ] || fail "cli arguments not forwarded: $out"
|
||||
out="$("$APPDIR/AppRun" cli)" || fail "bare cli launch exited non-zero"
|
||||
[ "$out" = "cli stub: " ] || fail "bare cli output: $out"
|
||||
[ ! -e "$XDG_DATA_HOME" ] || fail "cli launch integrated the desktop"
|
||||
# Only the first argument selects the CLI.
|
||||
out="$("$APPDIR/AppRun" file.figcraft cli)" || fail "app launch with a later cli argument exited non-zero"
|
||||
[ "$out" = "stub: file.figcraft cli" ] || fail "later cli argument hijacked the launch: $out"
|
||||
|
||||
echo "AppRun desktop integration ok"
|
||||
|
|
|
|||
Loading…
Reference in New Issue