Compare commits
24 Commits
feat/sessi
...
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`.
|
||||
|
|
|
|||
16
ROADMAP.md
16
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 (session restore: the app comes back the way it was left, numbers unchanged; 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,9 +131,11 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
|||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-13** · Session restore: the windows and tabs open when the app was left come back at the next start, each
|
||||
window where it was (or on a screen that is still there) with its panels, tabs, pages and views; kept with the
|
||||
preferences as it changes, guarded against a crash while restoring, off with a preference or `--fresh`
|
||||
- **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.
|
||||
|
|
@ -308,7 +310,11 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | Session restore (M12): the app comes back the way it was left. Each window's place, size, maximized or full-screen state and screen, its panels, its tabs in order with the one in front, and per tab the saved file (or your online file, joined again as its owner through its record), the page and the view are kept with the preferences, written atomically whenever they change (at once, then at most once a second; nothing while idle), so a crash or a forced quit has them too. At the next start they open again: a window off every screen moves onto one, files that are gone are skipped with one message, a file or invite link given on the command line opens in front; unsaved documents stay the recovery copies' job (a normal quit keeps none). A mark written before restoring and cleared once the windows have settled stops a crash while restoring from repeating: the next run starts without it and the home screen offers Restore last session. Preferences ▸ Reopen windows and tabs from last time (`pref.restoreSession`), `--fresh`, `session.restore`; `window.list` reports each window's rectangle and state and `ui.inspect` the kept session. The saved-state reader is fuzzed (junk, cut-off, huge, wrong types). Several windows: positions are restored; the screen-reader tree in other windows stays blocked upstream. Headline numbers unchanged |
|
||||
| 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,5 +1,4 @@
|
|||
//! The command line: `figcraft [--control <port>] [--fresh] [files…] [figcraft://join?…]`.
|
||||
//! `--fresh` starts without reopening the windows and tabs from last time. An invite link
|
||||
//! The command line: `figcraft [--control <port>] [files…] [figcraft://join?…]`. An invite link
|
||||
//! (what the system passes when a `figcraft://` link is opened) joins that session once the window
|
||||
//! is up; everything else that isn't a flag is a file to open, as a path or a `file://` URL (the
|
||||
//! Linux desktop entry passes URLs, `%U`, so that it can also hand over invite links).
|
||||
|
|
@ -9,8 +8,6 @@
|
|||
pub struct Args {
|
||||
/// `--control <port>`: the JSON-lines control server.
|
||||
pub control: Option<u16>,
|
||||
/// `--fresh`: start without restoring the last session.
|
||||
pub fresh: bool,
|
||||
pub files: Vec<String>,
|
||||
/// The last invite link given.
|
||||
pub join: Option<String>,
|
||||
|
|
@ -54,8 +51,6 @@ pub fn parse(args: &[String]) -> Args {
|
|||
while let Some(a) = it.next() {
|
||||
if a == "--control" {
|
||||
out.control = it.next().and_then(|p| p.parse().ok());
|
||||
} else if a == "--fresh" {
|
||||
out.fresh = true;
|
||||
} else if is_invite(a) {
|
||||
out.join = Some(a.clone());
|
||||
} else if let Some(path) = file_url_path(a) {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ fn args(a: &[&str]) -> Vec<String> {
|
|||
fn files_and_the_control_port() {
|
||||
assert_eq!(parse(&args(&[])), Args::default());
|
||||
let a = parse(&args(&["--control", "4242", "a.figcraft", "--verbose", "b.svg"]));
|
||||
assert_eq!(a, Args { control: Some(4242), files: args(&["a.figcraft", "b.svg"]), join: None, fresh: false });
|
||||
assert_eq!(a, Args { control: Some(4242), files: args(&["a.figcraft", "b.svg"]), join: None });
|
||||
// A missing or bad port is ignored, not taken as a file.
|
||||
assert_eq!(parse(&args(&["--control"])), Args::default());
|
||||
assert_eq!(parse(&args(&["--control", "nope"])).control, None);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -1,10 +1,7 @@
|
|||
//! FigCraft desktop app.
|
||||
//!
|
||||
//! Usage: `figcraft [--control <port>] [--fresh] [files…] [figcraft://join?…]` (an invite link
|
||||
//! joins that live session; the app registers itself as the opener of such links:
|
||||
//! `collab.registerLinks`). The windows and tabs from last time open again (Preferences ▸ Reopen
|
||||
//! windows and tabs from last time; `--fresh` starts without them), and files and links given
|
||||
//! here open in front of them.
|
||||
//! Usage: `figcraft [--control <port>] [files…] [figcraft://join?…]` (an invite link joins that
|
||||
//! live session; the app registers itself as the opener of such links: `collab.registerLinks`).
|
||||
//!
|
||||
//! `--control <port>` (or `FIGCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
|
||||
//! `{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":0,"y":0,"width":100,"height":100}}}`
|
||||
|
|
@ -15,18 +12,19 @@
|
|||
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;
|
||||
#[cfg(test)]
|
||||
mod restore_tests;
|
||||
#[cfg(test)]
|
||||
mod services_tests;
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
|
@ -45,37 +43,10 @@ struct App {
|
|||
title: String,
|
||||
/// Shared with the services, which write the copies after a panic.
|
||||
recovery: recovery::Shared,
|
||||
/// When (UI clock) the screens are looked up next.
|
||||
monitors_at: f64,
|
||||
}
|
||||
|
||||
/// How often the screens are looked up, in frames drawn anyway (seconds).
|
||||
const MONITORS_EVERY: f64 = 2.0;
|
||||
|
||||
/// The screens there are, `[x, y, width, height]` in the window's points.
|
||||
// shortcut: every screen is measured with this window's scale, so on screens of different
|
||||
// scales a window can come back a little off; upgrade when a per-screen scale is needed.
|
||||
fn monitors(frame: &eframe::Frame, pixels_per_point: f32) -> Vec<[f32; 4]> {
|
||||
let Some(window) = frame.winit_window().filter(|_| pixels_per_point.is_finite() && pixels_per_point > 0.0) else { return Vec::new() };
|
||||
let points = |px: f64| (px / f64::from(pixels_per_point)) as f32;
|
||||
window
|
||||
.available_monitors()
|
||||
.map(|m| {
|
||||
let (at, size) = (m.position(), m.size());
|
||||
[points(f64::from(at.x)), points(f64::from(at.y)), points(f64::from(size.width)), points(f64::from(size.height))]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
|
||||
let now = ctx.input(|i| i.time);
|
||||
// Before the first frame draws: restored windows open on the screens there are.
|
||||
if now >= self.monitors_at {
|
||||
self.monitors_at = now + MONITORS_EVERY;
|
||||
let list = monitors(frame, ctx.pixels_per_point());
|
||||
with_app(|fig| fig.monitors = list);
|
||||
}
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
{
|
||||
let mut slot = self.ctx_slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if slot.is_none() {
|
||||
|
|
@ -83,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);
|
||||
|
|
@ -98,6 +67,7 @@ impl eframe::App for App {
|
|||
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone()));
|
||||
self.title = title;
|
||||
}
|
||||
let now = ctx.input(|i| i.time);
|
||||
if let Some(wait) = with_app(|fig| recovery::lock(&self.recovery).as_mut().and_then(|r| r.tick(&fig.session, now))).flatten() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait));
|
||||
}
|
||||
|
|
@ -155,6 +125,11 @@ fn main() -> eframe::Result {
|
|||
c.request_repaint();
|
||||
}
|
||||
}))));
|
||||
for f in &args.files {
|
||||
if let Err(e) = session.execute("file.open", &json!({"path": f})) {
|
||||
log::error!("{f}: {e}");
|
||||
}
|
||||
}
|
||||
// Documents a crashed or killed run left unsaved come back; this run keeps its own copies.
|
||||
let recovery_root = services::config_dir().map(|d| d.join("Recovery"));
|
||||
// The preferences first: they hold the online files a recovered document may be one of.
|
||||
|
|
@ -170,15 +145,6 @@ fn main() -> eframe::Result {
|
|||
r.adopt(recovered.waiting);
|
||||
}
|
||||
*recovery::lock(&recovery) = copies;
|
||||
// The windows and tabs from last time, then what this run was started with, in front.
|
||||
let config = services::config_dir();
|
||||
let saved_main = session.prefs.session.windows.first().cloned();
|
||||
let start = services::start_session(config.as_deref(), &mut session, args.fresh);
|
||||
for f in &args.files {
|
||||
if let Err(e) = session.execute("file.open", &json!({"path": f})) {
|
||||
log::error!("{f}: {e}");
|
||||
}
|
||||
}
|
||||
// Plugins installed from a server's plugin directory live with the user's settings.
|
||||
session.plugins.install_dir = services::config_dir().map(|d| d.join("Plugins"));
|
||||
// Invite links open this app (Windows registers per user at run time; installed builds by
|
||||
|
|
@ -188,16 +154,11 @@ fn main() -> eframe::Result {
|
|||
// A first run (no settings kept yet, nothing to open) starts on the home screen, with its
|
||||
// tips and templates; later runs start in a new file.
|
||||
let first_run = svc.prefs.is_none() && args.join.is_none();
|
||||
// The home screen offers the last session after a run that didn't get through restoring it;
|
||||
// a restored online file on its way from its server is this run's file.
|
||||
let offered = start == services::Start::Guarded;
|
||||
if session.docs.is_empty() && !first_run && !offered && session.restore.joining().next().is_none() {
|
||||
if session.docs.is_empty() && !first_run {
|
||||
let _ = session.execute("file.new", &json!({}));
|
||||
}
|
||||
let mut fig = FigApp::new(session, Box::new(svc));
|
||||
fig.ui.home = offered;
|
||||
// What restoring asked of the interface (its windows, files that are gone).
|
||||
fig.handle_ui_requests();
|
||||
fig.ui.home = false;
|
||||
fig.note_recovered(recovered.copies);
|
||||
if let Some(link) = &args.join {
|
||||
figcraft_ui_egui::live::open_invite(&mut fig, link);
|
||||
|
|
@ -212,13 +173,6 @@ fn main() -> eframe::Result {
|
|||
.with_inner_size([1440.0, 900.0])
|
||||
.with_min_inner_size([800.0, 500.0])
|
||||
.with_drag_and_drop(true);
|
||||
// The main window opens where it was (the first frame moves it onto a screen if none shows it).
|
||||
if let Some(w) = saved_main.filter(|_| start == services::Start::Restore) {
|
||||
if let Some([x, y, width, height]) = w.rect {
|
||||
viewport = viewport.with_position([x, y]).with_inner_size([width, height]);
|
||||
}
|
||||
viewport = viewport.with_maximized(w.maximized).with_fullscreen(w.fullscreen);
|
||||
}
|
||||
// The tab strip is the title bar: macOS keeps its window buttons inset into it; elsewhere the
|
||||
// window is frameless and FigCraft draws the buttons and resize edges.
|
||||
if cfg!(target_os = "macos") {
|
||||
|
|
@ -231,11 +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, monitors_at: 0.0 }))),
|
||||
)
|
||||
eframe::run_native("FigCraft", options, Box::new(move |_cc| Ok(Box::new(App { queue, ctx_slot, title: String::new(), recovery }))))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,224 +0,0 @@
|
|||
//! Starting the app with the last session: the windows and tabs kept in the preferences file
|
||||
//! open again, with the restoring mark up; a mark left by a run that never got through restoring
|
||||
//! starts this one without them, on offer; the preference off and `--fresh` start without them
|
||||
//! too; a file given on the command line opens in front of them; a preferences file that is
|
||||
//! junk, cut off or huge never stops the app starting; and after a crash the unsaved documents
|
||||
//! come back from their recovery copies beside the restored tabs, while a normal quit keeps none.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_ui_egui::FigApp;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::recovery::{Recovery, recover};
|
||||
use crate::services::{Desktop, Start, mark_restoring, start_session};
|
||||
|
||||
fn root(name: &str) -> PathBuf {
|
||||
let r = std::env::temp_dir().join(format!("figcraft-session-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&r);
|
||||
std::fs::create_dir_all(&r).unwrap();
|
||||
r
|
||||
}
|
||||
|
||||
/// A run of the app with its preferences in `dir`: files `names` saved there and open, drawn
|
||||
/// until the session is kept, then gone without a word (a crash or a kill; a quit writes the
|
||||
/// same preferences). Returns the files' paths.
|
||||
fn left_open(dir: &Path, names: &[&str]) -> Vec<String> {
|
||||
let mut s = Session::empty();
|
||||
let mut paths = Vec::new();
|
||||
for n in names {
|
||||
let path = dir.join(format!("{n}.figcraft")).to_string_lossy().into_owned();
|
||||
s.execute("file.new", &json!({"name": n})).unwrap();
|
||||
s.execute("file.saveAs", &json!({"path": path})).unwrap();
|
||||
paths.push(path);
|
||||
}
|
||||
s.execute("doc.switch", &json!({"index": 0})).unwrap();
|
||||
let mut app = FigApp::new(s, Box::new(Desktop::load_from(Some(dir.to_path_buf()), Arc::new(Mutex::new(None)))));
|
||||
let ctx = egui::Context::default();
|
||||
for _ in 0..3 {
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| app.ui(ui)).textures_delta.clear();
|
||||
}
|
||||
paths
|
||||
}
|
||||
|
||||
/// The next run's session, with the preferences in `dir` loaded.
|
||||
fn next(dir: &Path) -> Session {
|
||||
let svc = Desktop::load_from(Some(dir.to_path_buf()), Arc::new(Mutex::new(None)));
|
||||
let mut s = Session::empty();
|
||||
s.prefs = svc.prefs.clone().expect("the preferences were kept");
|
||||
s
|
||||
}
|
||||
|
||||
fn names(s: &Session) -> Vec<String> {
|
||||
s.docs.iter().map(|d| d.doc.name.clone()).collect()
|
||||
}
|
||||
|
||||
fn marked(dir: &Path) -> bool {
|
||||
dir.join("restoring").exists()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tabs_left_open_come_back_and_a_command_line_file_opens_in_front() {
|
||||
let dir = root("restart");
|
||||
let paths = left_open(&dir, &["A", "B", "C"]);
|
||||
let extra = left_open(&root("restart-extra"), &["Extra"]).remove(0);
|
||||
let mut s = next(&dir);
|
||||
assert_eq!(s.prefs.session.tabs(), 3, "kept while the app ran, not only when it quits");
|
||||
assert_eq!(start_session(Some(&dir), &mut s, false), Start::Restore);
|
||||
assert!(marked(&dir), "the mark is up until the interface says the restore settled");
|
||||
assert_eq!((names(&s), s.ds().unwrap().doc.name.as_str()), (vec!["A".to_string(), "B".into(), "C".into()], "A"));
|
||||
assert_eq!(s.docs.iter().map(|d| d.path.clone().unwrap()).collect::<Vec<_>>(), paths);
|
||||
// What the app was started with opens in addition, in front.
|
||||
s.execute("file.open", &json!({"path": extra})).unwrap();
|
||||
let mut app = FigApp::new(s, Box::new(Desktop::load_from(Some(dir.clone()), Arc::new(Mutex::new(None)))));
|
||||
app.handle_ui_requests();
|
||||
let ctx = egui::Context::default();
|
||||
for i in 0..4 {
|
||||
let input = egui::RawInput { time: Some(f64::from(i)), ..Default::default() };
|
||||
ctx.run_ui(input, |ui| app.ui(ui)).textures_delta.clear();
|
||||
}
|
||||
assert_eq!(
|
||||
(names(&app.session), app.session.ds().unwrap().doc.name.as_str()),
|
||||
(vec!["A".to_string(), "B".into(), "C".into(), "Extra".into()], "Extra")
|
||||
);
|
||||
assert!(!marked(&dir), "settled: the mark is down");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_that_never_got_through_restoring_is_not_tried_again() {
|
||||
let dir = root("guard");
|
||||
left_open(&dir, &["A", "B"]);
|
||||
// The last run put the mark up and never took it down.
|
||||
mark_restoring(&dir, true);
|
||||
let mut s = next(&dir);
|
||||
assert_eq!(start_session(Some(&dir), &mut s, false), Start::Guarded);
|
||||
assert!(s.docs.is_empty() && !marked(&dir));
|
||||
assert_eq!(s.restore.last.as_ref().map(figcraft_engine::restore::SavedSession::tabs), Some(2), "on offer instead");
|
||||
// The home screen's button restores it, with the mark up meanwhile; a crash then is
|
||||
// guarded the same way.
|
||||
let mut app = FigApp::new(s, Box::new(Desktop::load_from(Some(dir.clone()), Arc::new(Mutex::new(None)))));
|
||||
assert!(app.restore_offered());
|
||||
app.restore_last();
|
||||
assert!(marked(&dir));
|
||||
assert_eq!(names(&app.session), ["A", "B"]);
|
||||
let mut s = next(&dir);
|
||||
assert_eq!(start_session(Some(&dir), &mut s, false), Start::Guarded);
|
||||
// The run after that one starts as any other.
|
||||
let mut s = next(&dir);
|
||||
assert_eq!(start_session(Some(&dir), &mut s, false), Start::Restore);
|
||||
assert_eq!(names(&s), ["A", "B"]);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_preference_off_and_fresh_start_without_restoring() {
|
||||
let given: Vec<String> = ["--fresh", "a.figcraft"].iter().map(|a| a.to_string()).collect();
|
||||
let args = crate::args::parse(&given);
|
||||
assert!(args.fresh && args.files == ["a.figcraft"] && !crate::args::parse(&given[1..]).fresh);
|
||||
let dir = root("off");
|
||||
left_open(&dir, &["A"]);
|
||||
let mut s = next(&dir);
|
||||
assert_eq!(start_session(Some(&dir), &mut s, true), Start::Fresh, "--fresh");
|
||||
assert!(s.docs.is_empty() && !marked(&dir));
|
||||
assert!(s.restore.last.is_some(), "`session.restore` still brings it back when asked");
|
||||
let mut s = next(&dir);
|
||||
s.execute("pref.restoreSession", &json!({"on": false})).unwrap();
|
||||
assert_eq!(start_session(Some(&dir), &mut s, false), Start::Fresh, "the preference");
|
||||
assert!(s.docs.is_empty() && !marked(&dir));
|
||||
s.execute("session.restore", &json!({})).unwrap();
|
||||
assert_eq!(names(&s), ["A"]);
|
||||
// Nothing kept (a first run): nothing to restore, nothing offered.
|
||||
let mut s = Session::empty();
|
||||
assert_eq!(start_session(None, &mut s, false), Start::Fresh);
|
||||
assert!(s.restore.last.is_none());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preferences_file_that_is_junk_never_stops_the_app_starting() {
|
||||
let dir = root("junk");
|
||||
let paths = left_open(&dir, &["A"]);
|
||||
let state = dir.join("state.json");
|
||||
let whole = std::fs::read_to_string(&state).unwrap();
|
||||
let load = || Desktop::load_from(Some(dir.clone()), Arc::new(Mutex::new(None))).prefs;
|
||||
// Cut off anywhere: the preferences are the defaults, or whole.
|
||||
for cut in (0..whole.len()).step_by((whole.len() / 60).max(1)).filter(|c| whole.is_char_boundary(*c)) {
|
||||
std::fs::write(&state, &whole[..cut]).unwrap();
|
||||
assert!(load().is_none_or(|p| p.session.tabs() <= 1));
|
||||
}
|
||||
// The session as anything at all: the other preferences are still read.
|
||||
let nan_rect = "[1e999, 0, 800, 600]";
|
||||
for session in
|
||||
["null", "7", "\"x\"", "[]", "{\"windows\": 4}", "{\"windows\": [[], 3, {\"tabs\": [{\"path\": 9}, 4], \"rect\": \"wide\"}]}", nan_rect]
|
||||
{
|
||||
std::fs::write(&state, format!("{{\"prefs\": {{\"theme\": \"dark\", \"session\": {session}}}}}")).unwrap();
|
||||
let mut s = Session::empty();
|
||||
if let Some(p) = load() {
|
||||
assert_eq!((p.theme.as_str(), p.session.tabs()), ("dark", 0), "{session}");
|
||||
s.prefs = p;
|
||||
}
|
||||
// (A window without tabs still comes back where it was.)
|
||||
assert_ne!(start_session(Some(&dir), &mut s, false), Start::Guarded, "{session}");
|
||||
assert!(s.docs.is_empty(), "{session}");
|
||||
mark_restoring(&dir, false);
|
||||
}
|
||||
// Wrong types beside usable tabs, absurd rectangles: what is usable comes back.
|
||||
let session = json!({"windows": [{"rect": [0, 0, 0, 0], "maximized": "no", "tabs": [{"path": paths[0], "page": "first", "view": [1, 2]}, {"path": 5}], "front": 99}]});
|
||||
std::fs::write(&state, json!({"prefs": {"session": session}}).to_string()).unwrap();
|
||||
let mut s = next(&dir);
|
||||
assert_eq!(s.prefs.session.windows[0].rect, None);
|
||||
assert_eq!(start_session(Some(&dir), &mut s, false), Start::Restore);
|
||||
assert_eq!(names(&s), ["A"]);
|
||||
mark_restoring(&dir, false);
|
||||
// Huge: thousands of windows and tabs are cut to the limits; a file too large to be ours
|
||||
// isn't read at all.
|
||||
let tabs: Vec<_> = (0..2000).map(|i| json!({"path": format!("{i}.figcraft")})).collect();
|
||||
let windows: Vec<_> = (0..100).map(|_| json!({"tabs": tabs})).collect();
|
||||
std::fs::write(&state, json!({"prefs": {"session": {"windows": windows}}}).to_string()).unwrap();
|
||||
let p = load().unwrap();
|
||||
assert_eq!(
|
||||
(p.session.windows.len(), p.session.windows[0].tabs.len()),
|
||||
(figcraft_engine::restore::MAX_WINDOWS, figcraft_engine::restore::MAX_TABS)
|
||||
);
|
||||
let file = std::fs::File::create(&state).unwrap();
|
||||
file.set_len(80 << 20).unwrap();
|
||||
drop(file);
|
||||
assert!(load().is_none());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn after_a_crash_unsaved_documents_come_back_beside_the_restored_tabs_and_a_quit_keeps_none() {
|
||||
let dir = root("crash");
|
||||
let recovery = dir.join("Recovery");
|
||||
let paths = left_open(&dir, &["A"]);
|
||||
// The run had A open with a change that wasn't saved, and a document never saved.
|
||||
let mut s = next(&dir);
|
||||
s.execute("file.open", &json!({"path": paths[0]})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
s.execute("file.new", &json!({"name": "Scratch"})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
let mut r = Recovery::start(&recovery).unwrap();
|
||||
r.write(&s, true);
|
||||
// Killed: the lock goes with the process. The next start gets both back, and the tab.
|
||||
drop(r);
|
||||
let mut killed = next(&dir);
|
||||
assert_eq!(recover(&recovery, &mut killed), 2);
|
||||
assert_eq!(start_session(Some(&dir), &mut killed, false), Start::Restore);
|
||||
assert_eq!(killed.docs.iter().filter(|d| d.is_dirty() && d.path.is_none()).count(), 2, "the recovered copies, unsaved");
|
||||
assert_eq!(killed.docs.iter().filter(|d| d.path.as_deref() == Some(paths[0].as_str()) && !d.is_dirty()).count(), 1, "the tab, as saved");
|
||||
mark_restoring(&dir, false);
|
||||
// A normal quit instead (Don't save was the answer, or there was nothing to ask): the
|
||||
// copies go, and the saved file is all that comes back.
|
||||
let mut r = Recovery::start(&recovery).unwrap();
|
||||
r.write(&s, true);
|
||||
r.finish();
|
||||
let mut quit = next(&dir);
|
||||
assert_eq!(recover(&recovery, &mut quit), 0);
|
||||
assert_eq!(start_session(Some(&dir), &mut quit, false), Start::Restore);
|
||||
assert_eq!((names(&quit), quit.ds().unwrap().is_dirty()), (vec!["A".to_string()], false));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
|
@ -38,13 +38,8 @@ impl Desktop {
|
|||
|
||||
/// With the preferences kept in `dir`.
|
||||
pub fn load_from(dir: Option<PathBuf>, recovery: crate::recovery::Shared) -> Self {
|
||||
// A preferences file this large isn't one the app wrote: starting never waits on it.
|
||||
let read = |d: &PathBuf| {
|
||||
let path = d.join(STATE);
|
||||
std::fs::metadata(&path).ok().filter(|m| m.len() <= MAX_STATE)?;
|
||||
std::fs::read_to_string(path).ok()
|
||||
};
|
||||
let state: Value = dir.as_ref().and_then(read).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}));
|
||||
let state: Value =
|
||||
dir.as_ref().and_then(|d| std::fs::read_to_string(d.join(STATE)).ok()).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}));
|
||||
let prefs = state.get("prefs").and_then(|p| serde_json::from_value::<Prefs>(p.clone()).ok()).map(|mut p| {
|
||||
// Recent files lived next to the preferences before they moved into them.
|
||||
if p.recent_files.is_empty()
|
||||
|
|
@ -136,84 +131,41 @@ fn command_says(program: &str, args: &[&str], want: &str) -> bool {
|
|||
.is_some_and(|o| String::from_utf8_lossy(&o.stdout).trim() == want)
|
||||
}
|
||||
|
||||
/// The preferences' file in the config folder, and the most it holds.
|
||||
/// The preferences' file in the config folder.
|
||||
const STATE: &str = "state.json";
|
||||
const MAX_STATE: u64 = 64 << 20;
|
||||
|
||||
/// The mark kept in the config folder while the last session is being restored: one left behind
|
||||
/// means the run that wrote it never got through restoring (it crashed, or was killed).
|
||||
const RESTORING: &str = "restoring";
|
||||
|
||||
/// Put up or take down the restoring mark in `dir`.
|
||||
pub fn mark_restoring(dir: &Path, on: bool) {
|
||||
let mark = dir.join(RESTORING);
|
||||
let done = if on { std::fs::create_dir_all(dir).and_then(|()| std::fs::write(&mark, b"")) } else { std::fs::remove_file(&mark) };
|
||||
if let Err(e) = done
|
||||
&& e.kind() != std::io::ErrorKind::NotFound
|
||||
{
|
||||
log::warn!("session restore: couldn't update {}: {e}", mark.display());
|
||||
}
|
||||
}
|
||||
|
||||
/// How this run starts.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Start {
|
||||
/// The windows and tabs from last time open again.
|
||||
Restore,
|
||||
/// Without them: the preference is off, `--fresh`, or there were none.
|
||||
Fresh,
|
||||
/// Without them, on the home screen, which offers them: the last run didn't get through
|
||||
/// restoring.
|
||||
Guarded,
|
||||
}
|
||||
|
||||
/// Decide how to start and get the session ready for it: the last run's windows and tabs open
|
||||
/// again (`session.restore`, with the restoring mark up until the interface says it settled),
|
||||
/// or stay on offer (`session.restore` later, the home screen's button) when this run starts
|
||||
/// without them. A mark left by the last run means it never got through: this run doesn't try
|
||||
/// again by itself.
|
||||
pub fn start_session(dir: Option<&Path>, session: &mut Session, fresh: bool) -> Start {
|
||||
let crashed = dir.is_some_and(|d| d.join(RESTORING).exists());
|
||||
if let Some(d) = dir.filter(|_| crashed) {
|
||||
mark_restoring(d, false);
|
||||
}
|
||||
let saved = session.prefs.session.clone();
|
||||
if saved.windows.is_empty() {
|
||||
return Start::Fresh;
|
||||
}
|
||||
if crashed || fresh || !session.prefs.restore_session {
|
||||
session.restore.last = Some(saved);
|
||||
return if crashed { Start::Guarded } else { Start::Fresh };
|
||||
}
|
||||
if let Some(d) = dir {
|
||||
mark_restoring(d, true);
|
||||
}
|
||||
if let Err(e) = session.execute("session.restore", &json!({})) {
|
||||
log::error!("session restore: {e}");
|
||||
}
|
||||
Start::Restore
|
||||
}
|
||||
|
||||
pub fn save_state(app: &FigApp) {
|
||||
if let Some(dir) = config_dir() {
|
||||
write_prefs(&dir, &app.session.prefs);
|
||||
// A normal quit: whatever was being restored, this run got through it.
|
||||
mark_restoring(&dir, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()
|
||||
|
|
@ -254,11 +206,6 @@ impl Services for Desktop {
|
|||
write_prefs(dir, prefs);
|
||||
}
|
||||
}
|
||||
fn restoring(&mut self, on: bool) {
|
||||
if let Some(dir) = &self.dir {
|
||||
mark_restoring(dir, on);
|
||||
}
|
||||
}
|
||||
fn emergency_save(&mut self, session: &Session) {
|
||||
if let Some(r) = crate::recovery::lock(&self.recovery).as_mut() {
|
||||
r.write(session, true);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
//! A branch's history says where it came from: it starts with an entry for its creation and gets
|
||||
//! one each time it is updated from main, on its tab or closed; the entries are versions like any
|
||||
//! other (viewed, restored, saved with the file) and stay out of the main file's history.
|
||||
|
||||
use figcraft_doc::{NodeId, NodeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::branches::{CREATED, UPDATED};
|
||||
use crate::collab_tests::base;
|
||||
|
||||
fn ok(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
|
||||
}
|
||||
|
||||
fn names(s: &mut Session) -> Vec<String> {
|
||||
ok(s, "version.list", json!({})).as_array().unwrap().iter().map(|v| v["name"].as_str().unwrap().to_string()).collect()
|
||||
}
|
||||
|
||||
fn layer_name(s: &Session, id: NodeId) -> String {
|
||||
s.doc().unwrap().node(id).unwrap().name.clone()
|
||||
}
|
||||
|
||||
/// The base file with a branch "Explore" shown: (session, branch id, a rectangle).
|
||||
fn branched() -> (Session, String, NodeId) {
|
||||
let mut s = Session::with_document(base());
|
||||
let r = s.doc().unwrap().nodes().find(|n| matches!(n.kind, NodeKind::Rectangle)).unwrap().id;
|
||||
let id = ok(&mut s, "branch.create", json!({"name": "Explore"}))["id"].as_str().unwrap().to_string();
|
||||
(s, id, r)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_branch_history_starts_with_its_creation_and_notes_each_update_from_main() {
|
||||
let (mut s, id, r) = branched();
|
||||
let original = layer_name(&s, r);
|
||||
assert_eq!(names(&mut s), [CREATED]);
|
||||
let created = ok(&mut s, "version.list", json!({}))[0].clone();
|
||||
assert!(
|
||||
created.get("auto").is_none() && created["time"].as_u64().is_some_and(|t| t > 0),
|
||||
"kept like a named version, never pruned as an autosave"
|
||||
);
|
||||
// Main moves on; the branch takes it: an entry of its own, the branch as it was right after.
|
||||
ok(&mut s, "node.set", json!({"id": r, "props": {"name": "On the branch"}}));
|
||||
ok(&mut s, "doc.switch", json!({"index": 0}));
|
||||
assert!(names(&mut s).is_empty(), "the main file's history has none of it");
|
||||
ok(&mut s, "shape.rectangle", json!({"x": 900, "y": 900, "width": 10, "height": 10, "props": {"name": "From main"}}));
|
||||
ok(&mut s, "doc.switch", json!({"index": 1}));
|
||||
assert_eq!(ok(&mut s, "branch.updateFromMain", json!({}))["updated"], true);
|
||||
assert_eq!(names(&mut s), [CREATED, UPDATED]);
|
||||
// Again, after a version saved by hand: in order.
|
||||
ok(&mut s, "file.saveVersion", json!({"name": "Mine"}));
|
||||
ok(&mut s, "doc.switch", json!({"index": 0}));
|
||||
ok(&mut s, "shape.ellipse", json!({"x": 950, "y": 900, "width": 10, "height": 10}));
|
||||
ok(&mut s, "doc.switch", json!({"index": 1}));
|
||||
ok(&mut s, "branch.updateFromMain", json!({}));
|
||||
assert_eq!(names(&mut s), [CREATED, UPDATED, "Mine", UPDATED]);
|
||||
// They are versions like any other: the creation shows the branch as it began, and restoring
|
||||
// it is one undo step.
|
||||
let first = created["id"].clone();
|
||||
ok(&mut s, "version.restore", json!({"id": first}));
|
||||
assert_eq!(layer_name(&s, r), original);
|
||||
assert!(!s.doc().unwrap().nodes().any(|n| n.name == "From main"));
|
||||
ok(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(layer_name(&s, r), "On the branch");
|
||||
assert!(s.doc().unwrap().nodes().any(|n| n.name == "From main"));
|
||||
// The update's entry is the branch with main's change in it.
|
||||
let update = ok(&mut s, "version.list", json!({}))[1]["id"].clone();
|
||||
ok(&mut s, "version.preview", json!({"id": update}));
|
||||
assert!(s.doc().unwrap().nodes().any(|n| n.name == "From main") && layer_name(&s, r) == "On the branch");
|
||||
ok(&mut s, "version.exitPreview", json!({}));
|
||||
let _ = id;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closed_branch_gets_its_entry_too_and_the_history_saves_with_the_file() {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-branch-history-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("file.figcraft").to_string_lossy().to_string();
|
||||
let (mut s, id, _) = branched();
|
||||
// Its tab closes; main changes; the branch is updated from the main file's tab.
|
||||
ok(&mut s, "file.close", json!({"index": 1, "force": true}));
|
||||
ok(&mut s, "shape.rectangle", json!({"x": 900, "y": 900, "width": 10, "height": 10, "props": {"name": "From main"}}));
|
||||
assert_eq!(ok(&mut s, "branch.updateFromMain", json!({"id": id}))["updated"], true);
|
||||
assert!(names(&mut s).is_empty(), "nothing in the main file's history");
|
||||
ok(&mut s, "file.saveAs", json!({"path": path}));
|
||||
// Opened again from the file: the branch's history has both entries.
|
||||
let mut t = Session::empty();
|
||||
ok(&mut t, "file.open", json!({"path": path}));
|
||||
ok(&mut t, "branch.open", json!({"id": id}));
|
||||
assert_eq!(names(&mut t), [CREATED, UPDATED]);
|
||||
assert!(t.doc().unwrap().nodes().any(|n| n.name == "From main"));
|
||||
// Merging leaves them with the branch; main gets its own entry for the merge.
|
||||
ok(&mut t, "branch.merge", json!({}));
|
||||
assert_eq!(names(&mut t).last().map(String::as_str), Some("Merged “Explore”"));
|
||||
ok(&mut t, "branch.open", json!({"id": id}));
|
||||
assert_eq!(names(&mut t), [CREATED, UPDATED]);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
|
@ -35,12 +35,12 @@ fn branched() -> (Session, String, NodeId) {
|
|||
fn versions_saved_on_a_branch_are_the_branchs_own() {
|
||||
let (mut s, _, r) = branched();
|
||||
let original = layer_name(&s, r);
|
||||
// A new branch's history starts with its creation: the main file's versions stay with it.
|
||||
assert_eq!(names(&mut s), ["Branch created"]);
|
||||
// A new branch's history starts empty: the main file's versions stay with it.
|
||||
assert!(names(&mut s).is_empty());
|
||||
ok(&mut s, "node.set", json!({"id": r, "props": {"name": "Before"}}));
|
||||
let v = ok(&mut s, "file.saveVersion", json!({"name": "Branch v1"}))["id"].clone();
|
||||
ok(&mut s, "node.set", json!({"id": r, "props": {"name": "After"}}));
|
||||
assert_eq!(names(&mut s), ["Branch created", "Branch v1"]);
|
||||
assert_eq!(names(&mut s), ["Branch v1"]);
|
||||
// Viewing a branch version shows it read-only; going back shows the branch as it is.
|
||||
ok(&mut s, "version.preview", json!({"id": v}));
|
||||
assert_eq!(layer_name(&s, r), "Before");
|
||||
|
|
@ -77,13 +77,13 @@ fn branch_versions_save_with_the_file_and_survive_merging() {
|
|||
ok(&mut t, "file.open", json!({"path": path}));
|
||||
assert_eq!(names(&mut t), ["Main v1"]);
|
||||
ok(&mut t, "branch.open", json!({"id": id}));
|
||||
assert_eq!(names(&mut t), ["Branch created", "Branch v1", "Autosave"]);
|
||||
assert_eq!(names(&mut t), ["Branch v1", "Autosave"]);
|
||||
// Merging adds a version to the main file's history; the branch keeps its own.
|
||||
ok(&mut t, "branch.merge", json!({}));
|
||||
assert_eq!(names(&mut t), ["Main v1", "Autosave", "Merged “Explore”"]);
|
||||
assert_eq!(layer_name(&t, r), "On the branch, later");
|
||||
ok(&mut t, "branch.open", json!({"id": id}));
|
||||
assert_eq!(names(&mut t), ["Branch created", "Branch v1", "Autosave"]);
|
||||
assert_eq!(names(&mut t), ["Branch v1", "Autosave"]);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
|
|
@ -94,7 +94,7 @@ fn archiving_keeps_the_branch_history() {
|
|||
ok(&mut s, "branch.archive", json!({}));
|
||||
assert_eq!(names(&mut s), ["Main v1"]);
|
||||
ok(&mut s, "branch.open", json!({"id": id}));
|
||||
assert_eq!(names(&mut s), ["Branch created", "Branch v1"]);
|
||||
assert_eq!(names(&mut s), ["Branch v1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -244,18 +244,9 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
versions: Vec::new(),
|
||||
});
|
||||
let index = open_tab(s, uid, &id)?;
|
||||
// The branch's own history starts with how it was made.
|
||||
if let Some(tab) = s.docs.get_mut(index) {
|
||||
crate::cmd::file::add_version(tab, CREATED, "", false);
|
||||
}
|
||||
Ok(json!({"id": id, "name": name, "index": index}))
|
||||
}
|
||||
|
||||
/// The entries a branch's version history gets by itself: when it was made, and each time it took
|
||||
/// main's changes (the branch as it was right after).
|
||||
pub const CREATED: &str = "Branch created";
|
||||
pub const UPDATED: &str = "Updated from main";
|
||||
|
||||
/// Open branch `id` of the file in the tab with uid `main` in a tab, or switch to its tab.
|
||||
fn open_tab(s: &mut Session, main: u64, id: &str) -> Result<usize> {
|
||||
if let Some(i) = tab_of(s, main, id) {
|
||||
|
|
@ -366,23 +357,15 @@ fn combine(s: &mut Session, p: &Value, from: Side) -> Result<Value> {
|
|||
out["merged"] = json!(true);
|
||||
out["index"] = json!(s.active);
|
||||
} else {
|
||||
let tab = tab_of(s, main_uid, &id).and_then(|t| s.docs.get_mut(t));
|
||||
let open = tab.is_some();
|
||||
if let Some(t) = tab {
|
||||
if let Some(t) = tab_of(s, main_uid, &id).and_then(|t| s.docs.get_mut(t)) {
|
||||
show(t, doc.clone());
|
||||
// Undo can't step back past the new base: that would undo main's changes on the branch.
|
||||
t.history.undo.clear();
|
||||
t.history.redo.clear();
|
||||
// In the branch's history (its tab's, while it is open).
|
||||
crate::cmd::file::add_version(t, UPDATED, "", false);
|
||||
}
|
||||
if let Some(b) = s.docs.get_mut(mi).and_then(|m| m.branches_mut().get_mut(bi)) {
|
||||
b.base = main;
|
||||
b.head = doc.clone();
|
||||
if !open && b.versions.len() < figcraft_format::MAX_VERSIONS {
|
||||
let id = crate::cmd::file::unique_id(|id| b.versions.iter().any(|v| v.id == id));
|
||||
b.versions.push(figcraft_format::Version::new(id, UPDATED, "", crate::cmd::comment::now_secs(), doc));
|
||||
}
|
||||
b.head = doc;
|
||||
}
|
||||
out["updated"] = json!(true);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -649,7 +649,7 @@ fn open_recent(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!({"files": files}))
|
||||
}
|
||||
|
||||
pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = req_string("file.open", p, "path")?;
|
||||
let variables = variables_param("file.open", p)?;
|
||||
// Already open: switch to it.
|
||||
|
|
@ -662,6 +662,13 @@ pub(crate) 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()))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -107,7 +107,6 @@ fn registry() -> &'static Registry {
|
|||
list.extend(crate::online::specs());
|
||||
list.extend(crate::invite_links::specs());
|
||||
list.extend(crate::templates::specs());
|
||||
list.extend(crate::restore::specs());
|
||||
let mut index = HashMap::new();
|
||||
for (i, s) in list.iter().enumerate() {
|
||||
if index.insert(s.id, i).is_some() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -59,34 +59,15 @@ pub fn link(doc: &Document, id: NodeId, lang: CodeLang) -> Option<&CodeLink> {
|
|||
doc.node(owner(doc, id)?)?.code_links.iter().find(|l| l.language == lang.id())
|
||||
}
|
||||
|
||||
/// What a link's import brings in: `Card` for the code component `Card.Header`.
|
||||
fn imported(link: &CodeLink) -> &str {
|
||||
link.name.split('.').next().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The line importing `names` from `path`.
|
||||
fn import_of(path: &str, names: &[&str], lang: CodeLang) -> String {
|
||||
match lang {
|
||||
CodeLang::React => format!("import {{ {} }} from {};", names.join(", "), js_string(path)),
|
||||
_ => format!("import {path}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The import line of `link` ("" without an import path).
|
||||
pub fn import_line(link: &CodeLink, lang: CodeLang) -> String {
|
||||
if link.import_path.is_empty() { String::new() } else { import_of(&link.import_path, &[imported(link)], lang) }
|
||||
}
|
||||
|
||||
/// The import lines of `links` (the connected instances in a generated layer tree): one line per
|
||||
/// import path, in path order, each naming what it brings in once, in name order. "" when none
|
||||
/// has an import path.
|
||||
pub fn import_lines(links: &[CodeLink], lang: CodeLang) -> String {
|
||||
let mut by_path: std::collections::BTreeMap<&str, std::collections::BTreeSet<&str>> = std::collections::BTreeMap::new();
|
||||
for l in links.iter().filter(|l| !l.import_path.is_empty()) {
|
||||
by_path.entry(&l.import_path).or_default().insert(imported(l));
|
||||
let path = &link.import_path;
|
||||
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}"),
|
||||
}
|
||||
let lines: Vec<String> = by_path.iter().map(|(path, names)| import_of(path, &names.iter().copied().collect::<Vec<_>>(), lang)).collect();
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
/// Layer `id`'s code with `link`: the import line and the snippet (an instance's values, else the
|
||||
|
|
@ -192,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),
|
||||
|
|
@ -211,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}}}"));
|
||||
|
|
@ -235,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(", ")),
|
||||
}
|
||||
|
|
@ -254,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"),
|
||||
});
|
||||
}
|
||||
|
|
@ -294,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),
|
||||
}
|
||||
}
|
||||
|
|
@ -324,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";
|
||||
|
|
@ -439,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");
|
||||
|
|
|
|||
|
|
@ -1,90 +0,0 @@
|
|||
//! Import lines in generated layer trees: the code for a frame (or a component, or an instance
|
||||
//! of a component that isn't connected) leads with the imports of every connected instance
|
||||
//! written into it, one line per import path, names in order and once each, per language;
|
||||
//! instances that aren't connected, and connected ones the code doesn't reach, bring none.
|
||||
|
||||
use crate::Session;
|
||||
use crate::components::tests::{ex, idv};
|
||||
use figcraft_doc::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn code(s: &mut Session, id: NodeId, lang: &str) -> String {
|
||||
ex(s, "inspect.code", json!({"id": id, "language": lang}))["code"].as_str().unwrap_or_default().to_string()
|
||||
}
|
||||
|
||||
/// A component named `name` at `y`, connected in `lang` to `code_name` from `path` ("" for none).
|
||||
fn component(s: &mut Session, name: &str, y: f64, links: &[(&str, &str, &str)]) -> NodeId {
|
||||
let f = idv(&ex(s, "frame.create", json!({"x": 600, "y": y, "width": 80, "height": 40, "name": name})));
|
||||
let c = idv(&ex(s, "component.create", json!({"ids": [f]})));
|
||||
for (lang, path, code_name) in links {
|
||||
ex(s, "codeConnect.set", json!({"id": c, "language": lang, "importPath": path, "name": code_name, "template": format!("<{code_name} />")}));
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
fn instance(s: &mut Session, component: NodeId, parent: NodeId, x: f64) -> NodeId {
|
||||
idv(&ex(s, "instance.create", json!({"component": component, "x": x, "y": 10, "parent": parent})))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_trees_code_leads_with_the_imports_of_its_connected_instances() {
|
||||
let mut s = Session::new();
|
||||
let tag = component(&mut s, "Tag", 0.0, &[("react", "@acme/ui", "Tag"), ("swiftui", "AcmeUI", "Tag"), ("compose", "com.acme.ui.Tag", "Tag")]);
|
||||
let avatar = component(&mut s, "Avatar", 60.0, &[("react", "@acme/ui", "Avatar"), ("swiftui", "AcmeUI", "Avatar")]);
|
||||
let icon = component(&mut s, "Icon", 120.0, &[("react", "@acme/icons", "Icons.Star"), ("compose", "com.acme.icons.Star", "Star")]);
|
||||
let local = component(&mut s, "Local", 180.0, &[("react", "", "Local")]);
|
||||
let plain = component(&mut s, "Plain", 240.0, &[]);
|
||||
let unused = component(&mut s, "Unused", 300.0, &[("react", "@acme/unused", "Unused")]);
|
||||
let frame = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 500, "height": 200, "name": "Header"})));
|
||||
let group = idv(&ex(&mut s, "frame.create", json!({"x": 300, "y": 0, "width": 180, "height": 100, "name": "Actions", "parent": frame})));
|
||||
// Two tags, an icon, an avatar deeper down, and instances without a link or an import path.
|
||||
for (c, parent, x) in
|
||||
[(tag, frame, 0.0), (icon, frame, 90.0), (tag, frame, 180.0), (avatar, group, 0.0), (local, group, 90.0), (plain, frame, 270.0)]
|
||||
{
|
||||
instance(&mut s, c, parent, x);
|
||||
}
|
||||
let react = code(&mut s, frame, "react");
|
||||
let want = "import { Icons } from \"@acme/icons\";\nimport { Avatar, Tag } from \"@acme/ui\";\nimport \"./header.css\";\n\n";
|
||||
assert!(react.starts_with(want), "{react}");
|
||||
assert_eq!(react.matches("@acme/ui").count(), 1, "once, however many instances");
|
||||
assert_eq!((react.matches("<Tag />").count(), react.matches("<Avatar />").count(), react.matches("<Icons.Star />").count()), (2, 1, 1));
|
||||
assert!(!react.contains("@acme/unused") && !react.contains("import { Local }") && !react.contains("import { Plain }"));
|
||||
// Per language: a module or a package each, in order, once.
|
||||
let swift = code(&mut s, frame, "swiftui");
|
||||
assert!(swift.starts_with("import AcmeUI\n\n") && swift.matches("import ").count() == 1, "{swift}");
|
||||
let compose = code(&mut s, frame, "compose");
|
||||
assert!(compose.starts_with("import com.acme.icons.Star\nimport com.acme.ui.Tag\n\n") && compose.matches("import ").count() == 2, "{compose}");
|
||||
// The part of the tree asked for: only what is in it.
|
||||
let part = code(&mut s, group, "react");
|
||||
assert!(part.starts_with("import { Avatar } from \"@acme/ui\";\nimport \"./actions.css\";\n\n"), "{part}");
|
||||
// CSS and HTML have no imports.
|
||||
assert!(!code(&mut s, frame, "css").contains("import") && !code(&mut s, frame, "html").contains("@acme"));
|
||||
// A layer tree without connected instances is as it was.
|
||||
let bare = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 400, "width": 100, "height": 100, "name": "Bare"})));
|
||||
instance(&mut s, plain, bare, 0.0);
|
||||
assert!(code(&mut s, bare, "react").starts_with("import \"./bare.css\";\n\n"));
|
||||
assert!(!code(&mut s, bare, "swiftui").contains("import "));
|
||||
let _ = unused;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_components_definition_brings_the_imports_of_what_it_is_made_of() {
|
||||
let mut s = Session::new();
|
||||
let tag = component(&mut s, "Tag", 0.0, &[("react", "@acme/ui", "Tag")]);
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 100, "name": "Card"})));
|
||||
instance(&mut s, tag, f, 10.0);
|
||||
let card = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
|
||||
// The component, and an instance of it (its call, then the definition): the import leads both.
|
||||
let want = "import { Tag } from \"@acme/ui\";\n";
|
||||
assert!(code(&mut s, card, "react").starts_with(want));
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": card, "x": 0, "y": 300})));
|
||||
let of_instance = code(&mut s, i, "react");
|
||||
assert!(of_instance.starts_with(&format!("{want}\n<Card />")), "{of_instance}");
|
||||
assert_eq!(of_instance.matches("@acme/ui").count(), 1);
|
||||
// A connected layer's own code is its import and snippet, as before.
|
||||
let t = instance(&mut s, tag, f, 100.0);
|
||||
assert_eq!(code(&mut s, t, "react"), "import { Tag } from \"@acme/ui\";\n\n<Tag />\n");
|
||||
// Nothing is left collecting: the next layer's code starts clean.
|
||||
let empty = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 600, "width": 50, "height": 50, "name": "Empty"})));
|
||||
assert!(code(&mut s, empty, "react").starts_with("import \"./empty.css\";"));
|
||||
}
|
||||
|
|
@ -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,45 +107,23 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
)]
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// The links of the connected instances written into the code [`code`] is generating
|
||||
/// (`None`: nothing is collecting them).
|
||||
static CONNECTED: std::cell::RefCell<Option<Vec<CodeLink>>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Start (`Some`) or stop collecting the links of connected instances as their snippets are
|
||||
/// written; what was collected until now.
|
||||
fn collect_connected(next: Option<Vec<CodeLink>>) -> Vec<CodeLink> {
|
||||
CONNECTED.with(|c| c.try_borrow_mut().map(|mut c| std::mem::replace(&mut *c, next)).ok().flatten()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 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). The import lines of the connected instances in that code lead it.
|
||||
/// 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)) {
|
||||
return code;
|
||||
}
|
||||
collect_connected(Some(Vec::new()));
|
||||
let code = generated(doc, id, lang);
|
||||
let imports = crate::code_connect::import_lines(&collect_connected(None), lang);
|
||||
match (imports.is_empty(), code.starts_with("import ")) {
|
||||
(true, _) => code,
|
||||
// With the imports the code has already.
|
||||
(false, true) => format!("{imports}\n{code}"),
|
||||
(false, false) => format!("{imports}\n\n{code}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`code`] without the import lines of connected instances.
|
||||
fn generated(doc: &Document, id: NodeId, lang: CodeLang) -> String {
|
||||
let Some(n) = doc.node(id) else { return String::new() };
|
||||
let comp = main_of(doc, id).and_then(|m| describe(doc, m));
|
||||
match (&n.kind, comp) {
|
||||
(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, &[]);
|
||||
|
|
@ -383,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> {
|
||||
|
|
@ -408,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();
|
||||
|
|
@ -430,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("");
|
||||
|
|
@ -445,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(),
|
||||
|
|
@ -485,18 +521,9 @@ pub fn usage(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option
|
|||
let c = main_of(doc, id).and_then(|m| describe(doc, m))?;
|
||||
let values = instance_values(doc, id, &c)?;
|
||||
let pad = if lang == CodeLang::React { " ".repeat(depth) } else { indent(depth) };
|
||||
// A connected component: its real snippet; its import goes to the top of the code it is in.
|
||||
// A connected component: its real snippet. shortcut: inside a layer tree it brings no import
|
||||
// line; collect them at the top of the tree's code when people ask for it.
|
||||
if let Some(link) = crate::code_connect::link(doc, id, lang) {
|
||||
CONNECTED.with(|list| {
|
||||
if let Ok(mut list) = list.try_borrow_mut()
|
||||
&& let Some(list) = list.as_mut()
|
||||
{
|
||||
// Once each: a tree can hold thousands of instances of one component.
|
||||
if !list.contains(link) {
|
||||
list.push(link.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
return Some(crate::code_connect::render(doc, &c, &values, link, lang, &pad));
|
||||
}
|
||||
let slots = slots_of(doc, id);
|
||||
|
|
@ -520,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(", ")),
|
||||
|
|
@ -538,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 {
|
||||
|
|
@ -569,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),
|
||||
}
|
||||
}
|
||||
|
|
@ -579,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();
|
||||
|
|
@ -618,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() {
|
||||
|
|
@ -644,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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -547,11 +547,7 @@ fn branches_and_the_version_history() {
|
|||
// On the branch tab.
|
||||
// A branch keeps its own version history: a version saved on its tab is listed there only.
|
||||
ex(&mut s, "file.saveVersion", json!({"name": "On the branch"}));
|
||||
assert_eq!(
|
||||
versions(&mut s).iter().map(|v| v["name"].clone()).collect::<Vec<_>>(),
|
||||
[json!("Branch created"), json!("On the branch")],
|
||||
"a branch's own history"
|
||||
);
|
||||
assert_eq!(versions(&mut s).iter().map(|v| v["name"].clone()).collect::<Vec<_>>(), [json!("On the branch")], "a branch's own history");
|
||||
rect(&mut s, 900.0, 900.0, None);
|
||||
ex(&mut s, "doc.switch", json!({"index": 0}));
|
||||
let made_on_main = rect(&mut s, -500.0, 0.0, None);
|
||||
|
|
|
|||
|
|
@ -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"]),
|
||||
|
|
|
|||
|
|
@ -42,7 +42,6 @@ pub mod plugin_directory;
|
|||
pub mod plugins;
|
||||
pub mod proto_run;
|
||||
pub mod rename;
|
||||
pub mod restore;
|
||||
pub mod rotation_origin;
|
||||
pub mod shape_handles;
|
||||
pub mod shapebuilder;
|
||||
|
|
@ -592,10 +591,6 @@ pub struct Prefs {
|
|||
/// The Getting started tips on the home screen were closed (Help ▸ Getting started shows them
|
||||
/// again).
|
||||
pub getting_started_dismissed: bool,
|
||||
/// Reopen windows and tabs from last time when the app starts (`pref.restoreSession`).
|
||||
pub restore_session: bool,
|
||||
/// The windows and tabs open now, kept as they change ([`restore`]).
|
||||
pub session: restore::SavedSession,
|
||||
}
|
||||
|
||||
impl Default for Prefs {
|
||||
|
|
@ -621,8 +616,6 @@ impl Default for Prefs {
|
|||
recent_files: Vec::new(),
|
||||
online_files: Vec::new(),
|
||||
getting_started_dismissed: false,
|
||||
restore_session: true,
|
||||
session: restore::SavedSession::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -667,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>,
|
||||
|
|
@ -680,8 +673,6 @@ pub struct Session {
|
|||
pub live: live::Lives,
|
||||
/// Commands the UI runs without waiting on a server ([`Session::start_background`]).
|
||||
pub background: background::Background,
|
||||
/// Session restore: the last run's session while it is on offer, online files on their way.
|
||||
pub restore: restore::State,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
|
|
@ -716,7 +707,6 @@ impl Session {
|
|||
plugins: plugins::Runtime::default(),
|
||||
live: live::Lives::default(),
|
||||
background: background::Background::default(),
|
||||
restore: restore::State::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -799,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.
|
||||
|
|
@ -823,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()));
|
||||
|
|
@ -1072,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
|
||||
}
|
||||
|
|
@ -1353,12 +1380,12 @@ 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;
|
||||
#[cfg(test)]
|
||||
mod branch_history_tests;
|
||||
#[cfg(test)]
|
||||
mod branch_tests;
|
||||
#[cfg(test)]
|
||||
mod branch_versions_tests;
|
||||
|
|
@ -1369,8 +1396,6 @@ mod canvas_fixes_tests;
|
|||
#[cfg(test)]
|
||||
mod code_connect_tests;
|
||||
#[cfg(test)]
|
||||
mod code_imports_tests;
|
||||
#[cfg(test)]
|
||||
mod collab_ids_tests;
|
||||
#[cfg(test)]
|
||||
mod collab_live_tests;
|
||||
|
|
@ -1409,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;
|
||||
|
|
@ -1467,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;
|
||||
|
|
@ -1485,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)]
|
||||
|
|
@ -1507,8 +1536,6 @@ mod proto_run_tests;
|
|||
#[cfg(test)]
|
||||
mod proto_tail_tests;
|
||||
#[cfg(test)]
|
||||
mod restore_tests;
|
||||
#[cfg(test)]
|
||||
mod retained_walk_tests;
|
||||
#[cfg(test)]
|
||||
mod rotated_guides_tests;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -461,7 +461,7 @@ fn named(s: &Session, cmd: &str, p: &Value) -> Result<usize> {
|
|||
s.online_named(cmd, file.trim(), string(p, "server").as_deref().map(str::trim))
|
||||
}
|
||||
|
||||
pub(crate) fn open_file(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn open_file(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "collab.openFile";
|
||||
let i = named(s, CMD, p)?;
|
||||
let Some(f) = s.prefs.online_files.get(i).cloned() else { return Err(bad(CMD, "no such file")) };
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
//! Opening a native file that lost something (a damaged image): the session says so, with the
|
||||
//! reason, and the file still opens.
|
||||
|
||||
use figcraft_doc::Document;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// A 1×1 PNG.
|
||||
const PNG: [u8; 67] = [
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x60, 0x00, 0x00, 0x02,
|
||||
0x00, 0x00, 0x01, 0xe2, 0x21, 0xbc, 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
||||
];
|
||||
|
||||
fn file(name: &str, damage: bool) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-open-issues-{}-{name}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut d = Document::new("Pics");
|
||||
figcraft_format::add_image(&mut d, PNG.to_vec()).unwrap();
|
||||
let mut bytes = figcraft_format::save_bytes(&d, None).unwrap();
|
||||
if damage {
|
||||
// Images are stored as they are: change one byte of the PNG, not its checksum.
|
||||
let at = bytes.windows(PNG.len()).position(|w| w == PNG).unwrap();
|
||||
bytes[at + 30] ^= 0xff;
|
||||
}
|
||||
let path = dir.join("pics.figcraft");
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_with_a_damaged_image_opens_and_says_what_was_lost() {
|
||||
let path = file("damaged", true);
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert!(r["repairs"].as_array().is_some_and(|a| a.len() == 1), "{r}");
|
||||
let m = s.message.take().expect("the session says so");
|
||||
assert!(m.starts_with("pics: image ") && m.contains("couldn't be read") && m.contains("drops it"), "{m}");
|
||||
let _ = std::fs::remove_dir_all(path.parent().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_intact_file_opens_without_a_message() {
|
||||
let path = file("intact", false);
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert!(r["repairs"].as_array().is_some_and(Vec::is_empty), "{r}");
|
||||
assert!(s.message.is_none());
|
||||
let _ = std::fs::remove_dir_all(path.parent().unwrap());
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -1,398 +0,0 @@
|
|||
//! Session restore (milestone M12): the app comes back the way it was left.
|
||||
//!
|
||||
//! The interface keeps a [`SavedSession`] with the preferences ([`Prefs::session`](crate::Prefs)),
|
||||
//! written whenever it changes: each window's place on screen and panels, its tabs in order and
|
||||
//! the one in front. A tab is a saved file (its path) or one of your online files (its server and
|
||||
//! id, never a token: it is joined again through the record, `collab.openFile`). Documents that
|
||||
//! were never saved aren't in it: unsaved work is the recovery copies' job.
|
||||
//!
|
||||
//! `session.restore` opens the tabs again and asks the interface to put them in their windows.
|
||||
//! Anything may be in a preferences file: [`SavedSession::read`] keeps what is usable, within
|
||||
//! limits, and never fails.
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cmd::CommandSpec;
|
||||
use crate::{DocState, Result, Session, ViewState, bad, spec};
|
||||
|
||||
/// Most windows and most tabs in a window a saved session holds.
|
||||
pub const MAX_WINDOWS: usize = 16;
|
||||
pub const MAX_TABS: usize = 100;
|
||||
|
||||
/// A window's sides, in screen points: anything outside isn't a window anyone left.
|
||||
const SIDE: std::ops::RangeInclusive<f32> = 100.0..=16_384.0;
|
||||
const FAR: f32 = 1_000_000.0;
|
||||
|
||||
/// The windows the app had open, the main window first.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
pub struct SavedSession {
|
||||
pub windows: Vec<SavedWindow>,
|
||||
}
|
||||
|
||||
/// A window: where it was (the interface checks it against the screens there are now), its
|
||||
/// panels and its tabs.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SavedWindow {
|
||||
/// `[x, y, width, height]` while it was neither maximized nor full screen (screen points).
|
||||
pub rect: Option<[f32; 4]>,
|
||||
pub maximized: bool,
|
||||
pub fullscreen: bool,
|
||||
/// The screen it was on, `[x, y, width, height]`.
|
||||
pub monitor: Option<[f32; 4]>,
|
||||
pub tabs: Vec<SavedTab>,
|
||||
/// Which of `tabs` was in front.
|
||||
pub front: usize,
|
||||
/// The side panels' tabs (the interface's own names) and widths.
|
||||
pub left_tab: String,
|
||||
pub right_tab: String,
|
||||
pub left_collapsed: bool,
|
||||
pub panel_widths: Option<[f32; 2]>,
|
||||
}
|
||||
|
||||
/// A tab: a saved file, or one of your online files.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
pub struct SavedTab {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
/// An online file's server (`wss://host/`, never with a key) and id.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub server: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub file: Option<String>,
|
||||
/// The page shown (its place among the file's pages).
|
||||
pub page: usize,
|
||||
/// The view: the page point at the centre, and the zoom.
|
||||
pub view: Option<[f64; 3]>,
|
||||
}
|
||||
|
||||
/// `n` finite numbers.
|
||||
fn numbers<const N: usize>(v: Option<&Value>) -> Option<[f64; N]> {
|
||||
let list = v?.as_array().filter(|a| a.len() == N)?;
|
||||
let mut out = [0.0; N];
|
||||
for (slot, n) in out.iter_mut().zip(list) {
|
||||
*slot = n.as_f64().filter(|n| n.is_finite())?;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// A rectangle a window or a screen can have: on a sane part of the plane, neither empty nor huge.
|
||||
fn rect(v: Option<&Value>) -> Option<[f32; 4]> {
|
||||
let r = numbers::<4>(v)?.map(|n| n as f32);
|
||||
(r[0].abs() <= FAR && r[1].abs() <= FAR && SIDE.contains(&r[2]) && SIDE.contains(&r[3])).then_some(r)
|
||||
}
|
||||
|
||||
/// A short text without control characters (`None`: not one, or longer than `max`).
|
||||
fn text(v: Option<&Value>, max: usize) -> Option<String> {
|
||||
v?.as_str().filter(|s| !s.is_empty() && s.len() <= max && !s.contains(char::is_control)).map(str::to_string)
|
||||
}
|
||||
|
||||
impl SavedTab {
|
||||
fn read(v: &Value) -> Option<Self> {
|
||||
let path = text(v.get("path"), 4096);
|
||||
let server = text(v.get("server"), 512).filter(|s| s.starts_with("ws://") || s.starts_with("wss://"));
|
||||
let file = text(v.get("file"), 64).filter(|f| f.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'));
|
||||
let (server, file) = server.zip(file).unzip();
|
||||
if path.is_none() && file.is_none() {
|
||||
return None;
|
||||
}
|
||||
let page = v.get("page").and_then(Value::as_u64).map_or(0, |p| p.min(100_000) as usize);
|
||||
let far = |n: f64| n.clamp(-1e9, 1e9);
|
||||
let view = numbers::<3>(v.get("view")).map(|[x, y, z]| [far(x), far(y), z.clamp(ViewState::MIN_ZOOM, ViewState::MAX_ZOOM)]);
|
||||
Some(Self { path, server, file, page, view })
|
||||
}
|
||||
|
||||
/// The same file as `other`.
|
||||
fn is(&self, other: &SavedTab) -> bool {
|
||||
match (&self.path, &other.path) {
|
||||
(Some(a), Some(b)) => a == b,
|
||||
(None, None) => self.file == other.file && self.server == other.server,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The file's name, for a message.
|
||||
fn name(&self) -> String {
|
||||
let path =
|
||||
self.path.as_deref().map(|p| std::path::Path::new(p).file_name().map_or_else(|| p.to_string(), |f| f.to_string_lossy().into_owned()));
|
||||
path.or_else(|| self.file.clone()).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl SavedWindow {
|
||||
fn read(v: &Value) -> Option<Self> {
|
||||
v.as_object()?;
|
||||
let flag = |k: &str| v.get(k).and_then(Value::as_bool).unwrap_or(false);
|
||||
let mut tabs: Vec<SavedTab> = Vec::new();
|
||||
let listed = v.get("tabs").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
|
||||
// The tab in front keeps its place when tabs before it are dropped.
|
||||
let asked = v.get("front").and_then(Value::as_u64).unwrap_or(0);
|
||||
let mut front = 0;
|
||||
for (i, tab) in listed.iter().take(4 * MAX_TABS).enumerate() {
|
||||
let Some(tab) = SavedTab::read(tab).filter(|t| tabs.len() < MAX_TABS && !tabs.iter().any(|o| o.is(t))) else { continue };
|
||||
if i as u64 <= asked {
|
||||
front = tabs.len();
|
||||
}
|
||||
tabs.push(tab);
|
||||
}
|
||||
Some(Self {
|
||||
rect: rect(v.get("rect")),
|
||||
maximized: flag("maximized"),
|
||||
fullscreen: flag("fullscreen"),
|
||||
monitor: rect(v.get("monitor")),
|
||||
tabs,
|
||||
front,
|
||||
left_tab: text(v.get("leftTab"), 32).unwrap_or_default(),
|
||||
right_tab: text(v.get("rightTab"), 32).unwrap_or_default(),
|
||||
left_collapsed: flag("leftCollapsed"),
|
||||
panel_widths: numbers::<2>(v.get("panelWidths")).map(|w| w.map(|w| (w as f32).clamp(0.0, 10_000.0))),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl SavedSession {
|
||||
/// What of `v` is a usable saved session (nothing, for anything else).
|
||||
pub fn read(v: &Value) -> Self {
|
||||
let listed = v.get("windows").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
|
||||
Self { windows: listed.iter().take(4 * MAX_WINDOWS).filter_map(SavedWindow::read).take(MAX_WINDOWS).collect() }
|
||||
}
|
||||
|
||||
/// How many tabs it would open.
|
||||
pub fn tabs(&self) -> usize {
|
||||
self.windows.iter().map(|w| w.tabs.len()).sum()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for SavedSession {
|
||||
/// Junk here never costs the other preferences.
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
|
||||
Ok(Value::deserialize(d).map(|v| Self::read(&v)).unwrap_or_default())
|
||||
}
|
||||
}
|
||||
|
||||
/// One of your online files on its way back into a restored window.
|
||||
struct Pending {
|
||||
/// The live session joining it.
|
||||
session: u64,
|
||||
/// Which of the restored windows it belongs to, and whether it is the tab in front there.
|
||||
window: usize,
|
||||
front: bool,
|
||||
tab: SavedTab,
|
||||
/// The tab before it in its window (`None`: it leads).
|
||||
after: Option<u64>,
|
||||
}
|
||||
|
||||
/// A restored online file that arrived from its server ([`Session::restore_arrivals`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Arrival {
|
||||
/// The open document.
|
||||
pub uid: u64,
|
||||
/// Which of the restored windows it belongs to (as `session.restore` listed them).
|
||||
pub window: usize,
|
||||
/// It was the tab in front there.
|
||||
pub front: bool,
|
||||
}
|
||||
|
||||
/// A restored window for the interface to place: what was saved of it, its tabs that are open
|
||||
/// (documents, in order) and the one in front.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Placed {
|
||||
pub saved: SavedWindow,
|
||||
pub docs: Vec<u64>,
|
||||
pub front: Option<u64>,
|
||||
}
|
||||
|
||||
/// What a session knows about restoring.
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
/// The session of the last run, when this run started without restoring it: what
|
||||
/// `session.restore` brings back (once).
|
||||
pub last: Option<SavedSession>,
|
||||
/// The windows `session.restore` opened the tabs of, until the interface places them, and
|
||||
/// how many files were gone.
|
||||
pub placing: Option<(Vec<Placed>, usize)>,
|
||||
pending: Vec<Pending>,
|
||||
}
|
||||
|
||||
impl State {
|
||||
/// The online files still on their way, with the restored window each belongs to: a session
|
||||
/// saved meanwhile keeps them.
|
||||
pub fn joining(&self) -> impl Iterator<Item = (usize, &SavedTab)> {
|
||||
self.pending.iter().map(|p| (p.window, &p.tab))
|
||||
}
|
||||
}
|
||||
|
||||
/// Show `tab`'s page and view in its document.
|
||||
fn show(ds: &mut DocState, tab: &SavedTab) {
|
||||
if let Some(page) = ds.doc.pages.get(tab.page).copied().filter(|p| *p != ds.page && !ds.doc.is_divider(*p)) {
|
||||
ds.page = page;
|
||||
ds.selection.clear();
|
||||
}
|
||||
if let Some([cx, cy, zoom]) = tab.view {
|
||||
(ds.view.cx, ds.view.cy, ds.view.zoom) = (cx, cy, zoom);
|
||||
// The interface fits a document to its window the first time it sizes its view (one
|
||||
// still exactly at the default size): this one has its view already.
|
||||
ds.view.vw += 1e-6;
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Open document `d` as a tab of a saved session (`None`: one that was never saved, or a
|
||||
/// branch's tab, which opens from its file).
|
||||
pub fn saved_tab(&self, d: &DocState) -> Option<SavedTab> {
|
||||
if d.branch_of.is_some() {
|
||||
return None;
|
||||
}
|
||||
let online = || self.online_of(d.uid).map(|(server, file, _)| (server, file));
|
||||
let (server, file) = if d.path.is_some() { (None, None) } else { online().unzip() };
|
||||
if d.path.is_none() && file.is_none() {
|
||||
return None;
|
||||
}
|
||||
let page = d.doc.pages.iter().position(|p| *p == d.page).unwrap_or(0);
|
||||
Some(SavedTab { path: d.path.clone(), server, file, page, view: Some([d.view.cx, d.view.cy, d.view.zoom]) })
|
||||
}
|
||||
|
||||
/// Move open document `uid` to just after document `after` (`None`: before every other).
|
||||
fn tab_after(&mut self, uid: u64, after: Option<u64>) {
|
||||
let active = self.docs.get(self.active).map(|d| d.uid);
|
||||
let Some(from) = self.docs.iter().position(|d| d.uid == uid) else { return };
|
||||
let d = self.docs.remove(from);
|
||||
let to = match after {
|
||||
Some(a) => self.docs.iter().position(|d| d.uid == a).map_or(self.docs.len(), |i| i + 1),
|
||||
None => 0,
|
||||
};
|
||||
self.docs.insert(to, d);
|
||||
self.active = self.docs.iter().position(|d| Some(d.uid) == active).unwrap_or(self.active);
|
||||
}
|
||||
|
||||
/// The restored online files that arrived from their servers since the last call, each in
|
||||
/// its place among the tabs with its page and view; one that couldn't be joined is skipped
|
||||
/// with a message. The interface puts them in their windows.
|
||||
pub fn restore_arrivals(&mut self) -> Vec<Arrival> {
|
||||
let mut out = Vec::new();
|
||||
for p in std::mem::take(&mut self.restore.pending) {
|
||||
let live = self.live.get(p.session).map(|l| (l.doc_uid, l.status.clone()));
|
||||
match live {
|
||||
Some((Some(uid), _)) if self.docs.iter().any(|d| d.uid == uid) => {
|
||||
self.tab_after(uid, p.after);
|
||||
if let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) {
|
||||
show(ds, &p.tab);
|
||||
}
|
||||
// The tabs that follow it there come after it.
|
||||
for later in self.restore.pending.iter_mut().filter(|l| l.window == p.window && l.after == p.after) {
|
||||
later.after = Some(uid);
|
||||
}
|
||||
out.push(Arrival { uid, window: p.window, front: p.front });
|
||||
}
|
||||
Some((_, status)) if status.active() => self.restore.pending.push(p),
|
||||
gone => {
|
||||
let why = gone.map(|(_, status)| format!(": {}", status.label())).unwrap_or_default();
|
||||
self.message = Some(format!("{} couldn't be reopened from its server{why}", p.tab.name()));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// How `tab` came back.
|
||||
enum Opened {
|
||||
Doc(u64),
|
||||
Joining(u64),
|
||||
Missing,
|
||||
}
|
||||
|
||||
fn open(s: &mut Session, tab: &SavedTab) -> Opened {
|
||||
let uid_at =
|
||||
|s: &Session, r: &Value| r.get("index").or(r.get("document")).and_then(Value::as_u64).and_then(|i| s.docs.get(i as usize)).map(|d| d.uid);
|
||||
if let Some(path) = &tab.path {
|
||||
return match crate::cmd::file::open(s, &json!({"path": path})) {
|
||||
Ok(r) => uid_at(s, &r).map_or(Opened::Missing, Opened::Doc),
|
||||
Err(e) => {
|
||||
log::info!("session restore: {e}");
|
||||
Opened::Missing
|
||||
}
|
||||
};
|
||||
}
|
||||
match crate::online::open_file(s, &json!({"file": tab.file, "server": tab.server})) {
|
||||
Ok(r) => match (uid_at(s, &r), r.get("session").and_then(Value::as_u64)) {
|
||||
(Some(uid), _) => Opened::Doc(uid),
|
||||
(None, Some(session)) => Opened::Joining(session),
|
||||
(None, None) => Opened::Missing,
|
||||
},
|
||||
Err(e) => {
|
||||
log::info!("session restore: {e}");
|
||||
Opened::Missing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn restore(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "session.restore";
|
||||
let saved = match p.get("session") {
|
||||
None | Some(Value::Null) => s.restore.last.take().unwrap_or_else(|| s.prefs.session.clone()),
|
||||
Some(v) if v.is_object() => SavedSession::read(v),
|
||||
Some(_) => return Err(bad(CMD, "`session` must be a saved session: {windows: [{tabs: [{path} | {server, file}]}]}")),
|
||||
};
|
||||
let was_open: Vec<u64> = s.docs.iter().map(|d| d.uid).collect();
|
||||
let (mut windows, mut missing, mut joining, mut opened) = (Vec::new(), Vec::new(), 0, 0);
|
||||
// The tabs take the saved order: each after the one placed before it.
|
||||
let (mut front_doc, mut last) = (None, None);
|
||||
for (k, w) in saved.windows.iter().enumerate() {
|
||||
let (mut docs, mut front) = (Vec::new(), None);
|
||||
for (i, tab) in w.tabs.iter().enumerate() {
|
||||
match open(s, tab) {
|
||||
// A file is a tab of one window: the first that lists it.
|
||||
Opened::Doc(uid) if docs.contains(&uid) || windows.iter().any(|w: &Placed| w.docs.contains(&uid)) => {}
|
||||
Opened::Doc(uid) => {
|
||||
if !was_open.contains(&uid) {
|
||||
opened += 1;
|
||||
if let Some(ds) = s.docs.iter_mut().find(|d| d.uid == uid) {
|
||||
show(ds, tab);
|
||||
}
|
||||
}
|
||||
s.tab_after(uid, last);
|
||||
docs.push(uid);
|
||||
last = Some(uid);
|
||||
if i == w.front {
|
||||
front = Some(uid);
|
||||
}
|
||||
}
|
||||
Opened::Joining(session) => {
|
||||
joining += 1;
|
||||
s.restore.pending.retain(|p| p.session != session);
|
||||
s.restore.pending.push(Pending { session, window: k, front: i == w.front, tab: tab.clone(), after: docs.last().copied() });
|
||||
}
|
||||
Opened::Missing => missing.push(tab.name()),
|
||||
}
|
||||
}
|
||||
front_doc = front_doc.or(front).or(docs.first().copied());
|
||||
windows.push(Placed { saved: w.clone(), docs, front });
|
||||
}
|
||||
if let Some(i) = front_doc.and_then(|u| s.docs.iter().position(|d| d.uid == u)) {
|
||||
s.active = i;
|
||||
}
|
||||
let out = json!({"windows": windows.len(), "opened": opened, "joining": joining, "missing": missing});
|
||||
if !windows.is_empty() {
|
||||
s.restore.placing = Some((windows, missing.len()));
|
||||
s.request_ui(CMD, json!({}));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!(
|
||||
"session.restore",
|
||||
"Restore last session",
|
||||
"{session? (a saved session, as the control channel's `ui.inspect` gives it under `session.saved`: {windows: [{tabs: [{path} | {server, file}, page?, view?: [cx, cy, zoom]], front?, rect?: [x, y, width, height], maximized?, fullscreen?}]}; default: the one kept with the preferences, or the last run's when this run started without it)} → {windows, opened (tabs opened now), joining (your online files on their way from their servers), missing: [names of files that are gone]}: opens the saved windows' tabs again in order, with their page and view (a file that is open already is used as it is), and the app puts them in their windows where they were (a window that would be off every screen moves onto one). Headless (CLI, MCP): the tabs only",
|
||||
false,
|
||||
restore
|
||||
),
|
||||
spec!("pref.restoreSession", "Reopen windows and tabs from last time", "{on?}", false, |s, p| {
|
||||
s.prefs.restore_session = crate::params::boolean(p, "on").unwrap_or(!s.prefs.restore_session);
|
||||
Ok(json!({"on": s.prefs.restore_session}))
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
|
@ -1,367 +0,0 @@
|
|||
//! Session restore: a saved session survives the preferences file (and junk in it never costs the
|
||||
//! other preferences, nor a panic); `session.restore` opens the tabs again in their order with
|
||||
//! their page and view and the one in front, skips files that are gone and says which, joins
|
||||
//! your online files again through their records (a scripted link) and puts each in its place
|
||||
//! when it arrives; windows keep their own tabs; the last run's session on offer is restored
|
||||
//! once; the preference toggles; junk parameters are refused or ignored.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use figcraft_collab::message::{Admin, AdminReply, ClientMsg, HostInfo};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::live::{Access, Event, HostOptions, LiveLink, Presence, PublishOptions, Role, SessionInfo, Shared, Status};
|
||||
use crate::restore::{Arrival, MAX_TABS, MAX_WINDOWS, SavedSession, SavedTab, SavedWindow};
|
||||
use crate::{Prefs, Session};
|
||||
|
||||
const SERVER: &str = "wss://share.example/";
|
||||
|
||||
/// A folder of saved files named `names`, and their paths.
|
||||
fn files(test: &str, names: &[&str]) -> (std::path::PathBuf, Vec<String>) {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-restore-{test}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut s = Session::empty();
|
||||
let paths = names
|
||||
.iter()
|
||||
.map(|n| {
|
||||
let path = dir.join(format!("{n}.figcraft")).to_string_lossy().into_owned();
|
||||
s.execute("file.new", &json!({"name": n})).unwrap();
|
||||
s.execute("page.add", &json!({"name": "Second"})).unwrap();
|
||||
s.execute("page.select", &json!({"index": 0})).unwrap();
|
||||
s.execute("file.saveAs", &json!({"path": path})).unwrap();
|
||||
path
|
||||
})
|
||||
.collect();
|
||||
(dir, paths)
|
||||
}
|
||||
|
||||
fn tab(path: &str) -> SavedTab {
|
||||
SavedTab { path: Some(path.into()), ..SavedTab::default() }
|
||||
}
|
||||
|
||||
fn window(tabs: Vec<SavedTab>, front: usize) -> SavedWindow {
|
||||
SavedWindow { tabs, front, ..SavedWindow::default() }
|
||||
}
|
||||
|
||||
fn names(s: &Session) -> Vec<String> {
|
||||
s.docs.iter().map(|d| d.doc.name.clone()).collect()
|
||||
}
|
||||
|
||||
/// A session with `saved` kept in its preferences, as a new run has it.
|
||||
fn run_with(saved: SavedSession) -> Session {
|
||||
let mut s = Session::empty();
|
||||
s.prefs.session = saved;
|
||||
s.prefs = serde_json::from_value(serde_json::to_value(&s.prefs).unwrap()).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_session_survives_the_preferences_file() {
|
||||
let saved = SavedSession {
|
||||
windows: vec![
|
||||
SavedWindow {
|
||||
rect: Some([10.0, 20.0, 1200.0, 800.0]),
|
||||
maximized: true,
|
||||
fullscreen: false,
|
||||
monitor: Some([0.0, 0.0, 1920.0, 1080.0]),
|
||||
tabs: vec![
|
||||
SavedTab { path: Some("C:/work/a.figcraft".into()), page: 2, view: Some([10.5, -4.0, 2.0]), ..SavedTab::default() },
|
||||
SavedTab { server: Some(SERVER.into()), file: Some("f1".into()), ..SavedTab::default() },
|
||||
],
|
||||
front: 1,
|
||||
left_tab: "assets".into(),
|
||||
right_tab: "inspect".into(),
|
||||
left_collapsed: true,
|
||||
panel_widths: Some([300.0, 280.0]),
|
||||
},
|
||||
window(vec![tab("/home/me/b.figcraft")], 0),
|
||||
],
|
||||
};
|
||||
let s = run_with(saved.clone());
|
||||
assert_eq!(s.prefs.session, saved);
|
||||
assert_eq!(s.prefs.session.tabs(), 3);
|
||||
assert!(s.prefs.restore_session, "on unless turned off");
|
||||
// No token is ever in it.
|
||||
assert!(!serde_json::to_string(&s.prefs.session).unwrap().contains("owner"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_in_a_saved_session_never_panics_or_costs_the_other_preferences() {
|
||||
let deep = (0..200).fold(json!(1), |v, _| json!([v]));
|
||||
let junk = [
|
||||
json!(null),
|
||||
json!(7),
|
||||
json!("windows"),
|
||||
json!([]),
|
||||
json!({"windows": 3}),
|
||||
json!({"windows": {"0": {}}}),
|
||||
json!({"windows": [null, 1, "w", [], {"tabs": "none"}, {"tabs": [null, 2, {}, {"path": 5}, {"path": ""}, {"file": "f"}, {"server": "http://x/", "file": "f"}]}]}),
|
||||
json!({"windows": [{"rect": [0, 0, 0, 0], "tabs": [{"path": "a"}]}, {"rect": [1e30, 0, 800, 600]}, {"rect": ["a", null, {}, []]}, {"rect": [0, 0, 1e9, 1e9]}, {"rect": [0, 0, 800]}]}),
|
||||
json!({"windows": [{"front": -4, "maximized": "yes", "fullscreen": 1, "leftTab": 9, "rightTab": "x".repeat(5000), "panelWidths": [1e300, -1e300], "tabs": [{"path": "a", "page": -1, "view": [0, 0]}, {"path": "b", "page": 1e99, "view": [1e308, -1e308, 0]}]}]}),
|
||||
json!({"windows": [{"tabs": [{"path": "a\u{0}b"}, {"path": "p".repeat(100_000)}, {"server": "wss://x/", "file": "bad id"}, {"server": "wss://x/", "file": "i".repeat(65)}]}]}),
|
||||
json!({"windows": deep}),
|
||||
];
|
||||
for j in &junk {
|
||||
let read = SavedSession::read(j);
|
||||
assert!(read.windows.len() <= MAX_WINDOWS);
|
||||
for w in &read.windows {
|
||||
assert!(w.tabs.is_empty() || w.front < w.tabs.len(), "{j}");
|
||||
assert!(w.rect.is_none_or(|r| r.iter().all(|n| n.is_finite()) && r[2] >= 100.0 && r[3] >= 100.0 && r[2] <= 16_384.0), "{j}");
|
||||
for t in &w.tabs {
|
||||
assert!(t.path.is_some() || (t.server.is_some() && t.file.is_some()), "{j}");
|
||||
assert!(t.view.is_none_or(|v| v.iter().all(|n| n.is_finite()) && v[2] > 0.0), "{j}");
|
||||
}
|
||||
}
|
||||
// The other preferences load whatever the session is.
|
||||
let prefs: Prefs = serde_json::from_value(json!({"theme": "dark", "session": j, "restore_session": false})).unwrap();
|
||||
assert_eq!((prefs.theme.as_str(), prefs.restore_session, &prefs.session), ("dark", false, &read));
|
||||
}
|
||||
// Zero-sized and absurd rectangles are dropped, their windows kept.
|
||||
let read = SavedSession::read(&junk[7]);
|
||||
assert_eq!(read.windows.len(), 5);
|
||||
assert!(read.windows.iter().all(|w| w.rect.is_none()));
|
||||
// Unusable tabs are skipped; the tab in front keeps its place among the ones kept.
|
||||
let read = SavedSession::read(&json!({"windows": [{"front": 3, "tabs": [{"path": "a"}, {}, {"path": "a"}, {"path": "b"}, {"path": "c"}]}]}));
|
||||
assert_eq!((read.windows[0].tabs.len(), read.windows[0].front), (3, 1));
|
||||
// Huge lists are cut to the limits.
|
||||
let many: Vec<Value> = (0..600).map(|i| json!({"path": format!("f{i}")})).collect();
|
||||
let windows: Vec<Value> = (0..200).map(|_| json!({"tabs": many})).collect();
|
||||
let read = SavedSession::read(&json!({"windows": windows}));
|
||||
assert_eq!((read.windows.len(), read.windows[0].tabs.len()), (MAX_WINDOWS, MAX_TABS));
|
||||
// A preferences file cut off anywhere is an error to read, never a panic.
|
||||
let whole = serde_json::to_string(&json!({"prefs": run_with(read).prefs})).unwrap();
|
||||
for cut in (0..whole.len().min(4000)).step_by(7).filter(|c| whole.is_char_boundary(*c)) {
|
||||
let _ = serde_json::from_str::<Value>(&whole[..cut]).map(|v| serde_json::from_value::<Prefs>(v["prefs"].clone()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tabs_come_back_in_order_with_their_page_view_and_the_one_in_front() {
|
||||
let (dir, paths) = files("order", &["A", "B", "C"]);
|
||||
let saved = SavedSession {
|
||||
windows: vec![window(vec![tab(&paths[2]), SavedTab { page: 1, view: Some([40.0, -30.0, 3.0]), ..tab(&paths[0]) }, tab(&paths[1])], 1)],
|
||||
};
|
||||
let mut s = run_with(saved);
|
||||
let r = s.execute("session.restore", &json!({})).unwrap();
|
||||
assert_eq!(r, json!({"windows": 1, "opened": 3, "joining": 0, "missing": []}));
|
||||
assert_eq!(names(&s), ["C", "A", "B"]);
|
||||
let front = s.ds().unwrap();
|
||||
assert_eq!(front.doc.name, "A", "the tab in front");
|
||||
assert_eq!(front.doc.pages.iter().position(|p| *p == front.page), Some(1), "its page");
|
||||
assert_eq!((front.view.cx, front.view.cy, front.view.zoom), (40.0, -30.0, 3.0), "its view");
|
||||
assert!(!front.is_dirty());
|
||||
// What the interface places: the window with its documents and the one in front.
|
||||
let (placed, missing) = s.restore.placing.take().unwrap();
|
||||
assert_eq!((placed.len(), missing), (1, 0));
|
||||
assert_eq!(placed[0].docs, s.docs.iter().map(|d| d.uid).collect::<Vec<_>>());
|
||||
assert_eq!(placed[0].front, Some(s.docs[1].uid));
|
||||
assert!(s.ui_requests.iter().any(|u| u.kind == "session.restore"));
|
||||
// The open tabs as a saved session again.
|
||||
let tabs: Vec<SavedTab> = s.docs.iter().filter_map(|d| s.saved_tab(d)).collect();
|
||||
assert_eq!(tabs.iter().map(|t| t.path.clone().unwrap()).collect::<Vec<_>>(), [paths[2].clone(), paths[0].clone(), paths[1].clone()]);
|
||||
assert_eq!((tabs[1].page, tabs[1].view), (1, Some([40.0, -30.0, 3.0])));
|
||||
// Restoring again opens nothing twice; files open already are put in order.
|
||||
s.execute("doc.switch", &json!({"index": 2})).unwrap();
|
||||
let again = json!({"windows": [{"tabs": [{"path": paths[1]}, {"path": paths[0]}], "front": 0}]});
|
||||
let r = s.execute("session.restore", &json!({"session": again})).unwrap();
|
||||
assert_eq!((&r["opened"], names(&s)), (&json!(0), vec!["B".to_string(), "A".into(), "C".into()]));
|
||||
assert_eq!(s.ds().unwrap().doc.name, "B");
|
||||
// A document that was never saved isn't a tab of a saved session.
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
assert_eq!(s.saved_tab(s.ds().unwrap()), None);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_that_are_gone_are_skipped_and_named() {
|
||||
let (dir, paths) = files("missing", &["A", "B", "C"]);
|
||||
std::fs::remove_file(&paths[0]).unwrap();
|
||||
std::fs::write(&paths[2], b"not a file of ours").unwrap();
|
||||
let saved = SavedSession { windows: vec![window(paths.iter().map(|p| tab(p)).collect(), 0)] };
|
||||
let mut s = run_with(saved);
|
||||
let r = s.execute("session.restore", &json!({})).unwrap();
|
||||
assert_eq!(r, json!({"windows": 1, "opened": 1, "joining": 0, "missing": ["A.figcraft", "C.figcraft"]}));
|
||||
assert_eq!(names(&s), ["B"]);
|
||||
assert_eq!(s.restore.placing.as_ref().map(|p| p.1), Some(2), "the interface says how many");
|
||||
// The tab in front was gone: the first one that opened shows.
|
||||
assert_eq!(s.ds().unwrap().doc.name, "B");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_keep_their_own_tabs() {
|
||||
let (dir, paths) = files("windows", &["A", "B", "C"]);
|
||||
let saved = SavedSession {
|
||||
windows: vec![
|
||||
SavedWindow { rect: Some([0.0, 0.0, 1000.0, 700.0]), ..window(vec![tab(&paths[0])], 0) },
|
||||
// (A file listed in two windows is a tab of the first.)
|
||||
SavedWindow {
|
||||
rect: Some([200.0, 100.0, 900.0, 600.0]),
|
||||
maximized: true,
|
||||
..window(vec![tab(&paths[1]), tab(&paths[2]), tab(&paths[0])], 1)
|
||||
},
|
||||
],
|
||||
};
|
||||
let mut s = run_with(saved.clone());
|
||||
let r = s.execute("session.restore", &json!({})).unwrap();
|
||||
assert_eq!((&r["windows"], &r["opened"]), (&json!(2), &json!(3)));
|
||||
let (placed, _) = s.restore.placing.take().unwrap();
|
||||
let uid = |n: &str| s.docs.iter().find(|d| d.doc.name == n).unwrap().uid;
|
||||
assert_eq!((placed[0].docs.clone(), placed[0].front), (vec![uid("A")], Some(uid("A"))));
|
||||
assert_eq!((placed[1].docs.clone(), placed[1].front), (vec![uid("B"), uid("C")], Some(uid("C"))));
|
||||
assert_eq!(placed[1].saved, saved.windows[1]);
|
||||
assert_eq!(s.ds().unwrap().doc.name, "A", "the main window's tab in front is the session's");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_last_runs_session_on_offer_is_restored_once() {
|
||||
let (dir, paths) = files("offer", &["A", "B"]);
|
||||
// This run started without restoring: its own session is what the preferences hold now.
|
||||
let mut s = run_with(SavedSession { windows: vec![window(vec![tab(&paths[1])], 0)] });
|
||||
s.restore.last = Some(SavedSession { windows: vec![window(vec![tab(&paths[0])], 0)] });
|
||||
s.execute("session.restore", &json!({})).unwrap();
|
||||
assert_eq!(names(&s), ["A"]);
|
||||
assert!(s.restore.last.is_none());
|
||||
s.execute("session.restore", &json!({})).unwrap();
|
||||
assert_eq!(names(&s), ["B", "A"], "then the one kept with the preferences");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_preference_toggles_and_is_kept() {
|
||||
let mut s = Session::empty();
|
||||
assert_eq!(s.execute("pref.restoreSession", &json!({"on": false})).unwrap(), json!({"on": false}));
|
||||
let prefs: Prefs = serde_json::from_value(serde_json::to_value(&s.prefs).unwrap()).unwrap();
|
||||
assert!(!prefs.restore_session);
|
||||
assert_eq!(s.execute("pref.restoreSession", &json!({})).unwrap(), json!({"on": true}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_parameters_are_refused_or_ignored() {
|
||||
let (dir, paths) = files("sweep", &["A"]);
|
||||
let mut s = run_with(SavedSession { windows: vec![window(vec![tab(&paths[0])], 0)] });
|
||||
for j in [json!(7), json!("all"), json!([1, 2]), json!(true)] {
|
||||
assert!(s.execute("session.restore", &json!({"session": j})).unwrap_err().to_string().contains("saved session"));
|
||||
assert!(s.execute("pref.restoreSession", &json!({"on": j})).is_ok());
|
||||
}
|
||||
assert!(s.docs.is_empty() && s.restore.placing.is_none());
|
||||
for j in [json!({}), json!({"windows": 5}), json!({"windows": [{"tabs": [{"path": 3}, {"path": "no/such/file.figcraft"}]}]})] {
|
||||
let r = s.execute("session.restore", &json!({"session": j})).unwrap();
|
||||
assert_eq!(r["opened"], 0, "{j}");
|
||||
}
|
||||
assert!(s.docs.is_empty());
|
||||
// Nothing saved: nothing to do, and nothing asked of the interface.
|
||||
let mut empty = Session::empty();
|
||||
assert_eq!(empty.execute("session.restore", &json!(null)).unwrap(), json!({"windows": 0, "opened": 0, "joining": 0, "missing": []}));
|
||||
assert!(empty.ui_requests.is_empty());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// What the scripted server was asked to join and the events its link hands out next.
|
||||
#[derive(Default)]
|
||||
struct Script {
|
||||
joined: Vec<(u64, String)>,
|
||||
events: Vec<(u64, Event)>,
|
||||
}
|
||||
|
||||
struct Link(Arc<Mutex<Script>>);
|
||||
|
||||
impl LiveLink for Link {
|
||||
fn host(&mut self, _: u64, _: HostOptions) -> Result<SessionInfo, String> {
|
||||
Err("not in this test".into())
|
||||
}
|
||||
fn join(&mut self, session: u64, link: &str, _: &str) -> Result<(), String> {
|
||||
self.0.lock().unwrap().joined.push((session, link.to_string()));
|
||||
Ok(())
|
||||
}
|
||||
fn publish(&mut self, _: u64, _: PublishOptions) -> Result<(), String> {
|
||||
Err("not in this test".into())
|
||||
}
|
||||
fn hosting(&mut self, _: &str, _: &str, _: &str, _: Option<Admin>) -> Result<(HostInfo, Option<AdminReply>), String> {
|
||||
Err("not in this test".into())
|
||||
}
|
||||
fn share(&mut self, _: &str, _: &str, _: &str, _: &[String], _: bool) -> Result<Option<Shared>, String> {
|
||||
Err("not in this test".into())
|
||||
}
|
||||
fn leave(&mut self, _: u64) {}
|
||||
fn presence(&mut self, _: u64, _: &Presence) {}
|
||||
fn send(&mut self, _: u64, _: ClientMsg) {}
|
||||
fn poll(&mut self) -> Vec<(u64, Event)> {
|
||||
std::mem::take(&mut self.0.lock().unwrap().events)
|
||||
}
|
||||
}
|
||||
|
||||
fn edit_link(file: &str) -> String {
|
||||
format!("figcraft://join?server={SERVER}&file={file}&token=EditSecretEditSecret00")
|
||||
}
|
||||
|
||||
/// The record of online file `file`, as the preferences file holds it.
|
||||
fn record(file: &str) -> Value {
|
||||
json!({"server": SERVER, "file": file, "name": format!("Online {file}"), "owner": "OwnerSecretOwnerSecret", "edit": edit_link(file), "view": "", "created": 1, "opened": 1})
|
||||
}
|
||||
|
||||
/// The file of session `session` arrives from the scripted server.
|
||||
fn arrive(script: &Arc<Mutex<Script>>, session: u64, file: &str) {
|
||||
let info = SessionInfo {
|
||||
role: Role::Guest,
|
||||
me: 1,
|
||||
name: "Me".into(),
|
||||
color: "#0D99FF".into(),
|
||||
file_name: format!("Online {file}"),
|
||||
pages: vec![],
|
||||
link: edit_link(file),
|
||||
url: String::new(),
|
||||
address: SERVER.into(),
|
||||
network: true,
|
||||
access: Access::Edit,
|
||||
};
|
||||
let (_, _, sync) = crate::live_tests::serve(&Session::new().doc().unwrap().clone());
|
||||
script.lock().unwrap().events.extend([(session, Event::Welcome { info, peers: vec![] }), (session, sync)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn online_files_are_joined_again_and_take_their_place_when_they_arrive() {
|
||||
let (dir, paths) = files("online", &["A", "B"]);
|
||||
let online =
|
||||
|file: &str| SavedTab { server: Some(SERVER.into()), file: Some(file.into()), page: 0, view: Some([5.0, 6.0, 1.5]), ..SavedTab::default() };
|
||||
let saved =
|
||||
SavedSession { windows: vec![window(vec![tab(&paths[0]), online("f1"), tab(&paths[1])], 1), window(vec![online("f2"), online("gone")], 0)] };
|
||||
let mut s = run_with(saved);
|
||||
s.prefs.online_files = serde_json::from_value::<Prefs>(json!({"online_files": [record("f1"), record("f2")]})).unwrap().online_files;
|
||||
let script = Arc::new(Mutex::new(Script::default()));
|
||||
s.live.install(Box::new(Link(script.clone())));
|
||||
let r = s.execute("session.restore", &json!({})).unwrap();
|
||||
// The one no longer recorded on this computer counts as gone.
|
||||
assert_eq!(r, json!({"windows": 2, "opened": 2, "joining": 2, "missing": ["gone"]}));
|
||||
assert!(!r.to_string().contains("Secret"));
|
||||
let joined = script.lock().unwrap().joined.clone();
|
||||
assert_eq!(joined.iter().map(|j| j.1.clone()).collect::<Vec<_>>(), [edit_link("f1"), edit_link("f2")], "as their owner, with the recorded links");
|
||||
assert_eq!(names(&s), ["A", "B"]);
|
||||
// Until they arrive they are still tabs of their windows.
|
||||
let waiting: Vec<(usize, Option<String>)> = s.restore.joining().map(|(k, t)| (k, t.file.clone())).collect();
|
||||
assert_eq!(waiting, [(0, Some("f1".into())), (1, Some("f2".into()))]);
|
||||
assert!(s.restore_arrivals().is_empty());
|
||||
|
||||
// The first arrives: between A and B, in front of its window, with its view.
|
||||
arrive(&script, joined[0].0, "f1");
|
||||
s.live_poll();
|
||||
let arrived = s.restore_arrivals();
|
||||
let uid = s.docs.iter().find(|d| d.path.is_none()).unwrap().uid;
|
||||
assert_eq!(arrived, [Arrival { uid, window: 0, front: true }]);
|
||||
assert_eq!(s.docs.iter().map(|d| d.path.is_some()).collect::<Vec<_>>(), [true, false, true]);
|
||||
let ds = s.docs.iter().find(|d| d.uid == uid).unwrap();
|
||||
assert_eq!((ds.view.cx, ds.view.cy, ds.view.zoom), (5.0, 6.0, 1.5));
|
||||
assert_eq!(s.saved_tab(ds).map(|t| (t.server, t.file)), Some((Some(SERVER.into()), Some("f1".into()))), "a tab of the next saved session");
|
||||
assert_eq!(s.restore.joining().count(), 1);
|
||||
|
||||
// The other's server can't be reached: it is skipped, and the message says so.
|
||||
script.lock().unwrap().events.push((joined[1].0, Event::Status(Status::Failed { reason: "no answer from the server (timed out)".into() })));
|
||||
s.live_poll();
|
||||
assert!(s.restore_arrivals().is_empty());
|
||||
assert_eq!(s.restore.joining().count(), 0);
|
||||
assert_eq!(s.message.as_deref(), Some("f2 couldn't be reopened from its server: no answer from the server (timed out)"));
|
||||
assert_eq!(s.docs.len(), 3);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -1011,8 +1011,6 @@ status = "draft — needs native review"
|
|||
"Rename on the server…" = "Auf dem Server umbenennen…"
|
||||
"Rename page" = "Seite umbenennen"
|
||||
"Rename to" = "Umbenennen in"
|
||||
"Reopen its windows and tabs" = "Fenster und Tabs wieder öffnen"
|
||||
"Reopen windows and tabs from last time" = "Fenster und Tabs vom letzten Mal erneut öffnen"
|
||||
"Replace all" = "Alle ersetzen"
|
||||
"Replace mixed export settings" = "Gemischte Exporteinstellungen ersetzen"
|
||||
"Replace with" = "Ersetzen durch"
|
||||
|
|
@ -1039,7 +1037,6 @@ status = "draft — needs native review"
|
|||
"Responsive" = "Responsiv"
|
||||
"Restart" = "Neu starten"
|
||||
"Restore" = "Wiederherstellen"
|
||||
"Restore last session" = "Letzte Sitzung wiederherstellen"
|
||||
"Restore main component" = "Hauptkomponente wiederherstellen"
|
||||
"Restore this version" = "Diese Version wiederherstellen"
|
||||
"Restored “{name}”" = "„{name}“ wiederhergestellt"
|
||||
|
|
@ -1471,7 +1468,6 @@ status = "draft — needs native review"
|
|||
"{n} conflicts to resolve" = { one = "{n} Konflikt zu lösen", other = "{n} Konflikte zu lösen" }
|
||||
"{n} days ago" = { one = "vor {n} Tag", other = "vor {n} Tagen" }
|
||||
"{n} files" = { one = "{n} Datei", other = "{n} Dateien" }
|
||||
"{n} files couldn't be reopened" = { one = "{n} Datei konnte nicht wieder geöffnet werden", other = "{n} Dateien konnten nicht wieder geöffnet werden" }
|
||||
"{n} hours ago" = { one = "vor {n} Stunde", other = "vor {n} Stunden" }
|
||||
"{n} instances" = { one = "{n} Instanz", other = "{n} Instanzen" }
|
||||
"{n} layers" = { one = "{n} Ebene", other = "{n} Ebenen" }
|
||||
|
|
@ -1796,7 +1792,6 @@ status = "draft — needs native review"
|
|||
"pref.publishServer" = "Server für das Online-Teilen"
|
||||
"pref.reduceMotion" = "Bewegung reduzieren"
|
||||
"pref.renameDuplicates" = "Duplizierte Ebenen umbenennen"
|
||||
"pref.restoreSession" = "Fenster und Tabs vom letzten Mal erneut öffnen"
|
||||
"pref.showDimensions" = "Abmessungen an Objekten anzeigen"
|
||||
"pref.userName" = "Ihr Name in Kommentaren"
|
||||
"proto.action.add" = "Prototyp-Aktion hinzufügen"
|
||||
|
|
@ -1841,7 +1836,6 @@ status = "draft — needs native review"
|
|||
"select.sameStroke" = "Gleiche Kontur auswählen"
|
||||
"select.sameText" = "Gleiche Texteigenschaften auswählen"
|
||||
"select.set" = "Auswählen"
|
||||
"session.restore" = "Letzte Sitzung wiederherstellen"
|
||||
"shape.arrow" = "Pfeil"
|
||||
"shape.ellipse" = "Ellipse"
|
||||
"shape.line" = "Linie"
|
||||
|
|
@ -2040,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"
|
||||
|
|
@ -1037,8 +1037,6 @@ name = "English"
|
|||
"Rename on the server…" = "Rename on the server…"
|
||||
"Rename page" = "Rename page"
|
||||
"Rename to" = "Rename to"
|
||||
"Reopen its windows and tabs" = "Reopen its windows and tabs"
|
||||
"Reopen windows and tabs from last time" = "Reopen windows and tabs from last time"
|
||||
"Replace all" = "Replace all"
|
||||
"Replace mixed export settings" = "Replace mixed export settings"
|
||||
"Replace with" = "Replace with"
|
||||
|
|
@ -1065,7 +1063,6 @@ name = "English"
|
|||
"Responsive" = "Responsive"
|
||||
"Restart" = "Restart"
|
||||
"Restore" = "Restore"
|
||||
"Restore last session" = "Restore last session"
|
||||
"Restore main component" = "Restore main component"
|
||||
"Restore this version" = "Restore this version"
|
||||
"Restored “{name}”" = "Restored “{name}”"
|
||||
|
|
@ -1513,7 +1510,6 @@ name = "English"
|
|||
"{n} conflicts to resolve" = { one = "{n} conflict to resolve", other = "{n} conflicts to resolve" }
|
||||
"{n} days ago" = { one = "{n} day ago", other = "{n} days ago" }
|
||||
"{n} files" = { one = "{n} file", other = "{n} files" }
|
||||
"{n} files couldn't be reopened" = { one = "{n} file couldn't be reopened", other = "{n} files couldn't be reopened" }
|
||||
"{n} hours ago" = { one = "{n} hour ago", other = "{n} hours ago" }
|
||||
"{n} instances" = { one = "{n} instance", other = "{n} instances" }
|
||||
"{n} layers" = { one = "{n} layer", other = "{n} layers" }
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -1038,8 +1038,6 @@ status = "draft — needs native review"
|
|||
"Rename on the server…" = "Cambiar el nombre en el servidor…"
|
||||
"Rename page" = "Cambiar nombre de página"
|
||||
"Rename to" = "Nuevo nombre"
|
||||
"Reopen its windows and tabs" = "Reabre sus ventanas y pestañas"
|
||||
"Reopen windows and tabs from last time" = "Volver a abrir ventanas y pestañas de la última vez"
|
||||
"Replace all" = "Reemplazar todo"
|
||||
"Replace mixed export settings" = "Reemplazar ajustes de exportación mixtos"
|
||||
"Replace with" = "Reemplazar con"
|
||||
|
|
@ -1066,7 +1064,6 @@ status = "draft — needs native review"
|
|||
"Responsive" = "Adaptable"
|
||||
"Restart" = "Reiniciar"
|
||||
"Restore" = "Restaurar"
|
||||
"Restore last session" = "Restaurar la última sesión"
|
||||
"Restore main component" = "Restaurar componente principal"
|
||||
"Restore this version" = "Restaurar esta versión"
|
||||
"Restored “{name}”" = "“{name}” restaurada"
|
||||
|
|
@ -1514,7 +1511,6 @@ status = "draft — needs native review"
|
|||
"{n} conflicts to resolve" = { one = "{n} conflicto por resolver", other = "{n} conflictos por resolver" }
|
||||
"{n} days ago" = { one = "hace {n} día", other = "hace {n} días" }
|
||||
"{n} files" = { one = "{n} archivo", other = "{n} archivos" }
|
||||
"{n} files couldn't be reopened" = { one = "No se pudo volver a abrir {n} archivo", other = "No se pudieron volver a abrir {n} archivos" }
|
||||
"{n} hours ago" = { one = "hace {n} hora", other = "hace {n} horas" }
|
||||
"{n} instances" = { one = "{n} instancia", other = "{n} instancias" }
|
||||
"{n} layers" = { one = "{n} capa", other = "{n} capas" }
|
||||
|
|
@ -1845,7 +1841,6 @@ status = "draft — needs native review"
|
|||
"pref.publishServer" = "Servidor para compartir en línea"
|
||||
"pref.reduceMotion" = "Reducir movimiento"
|
||||
"pref.renameDuplicates" = "Renombrar capas duplicadas"
|
||||
"pref.restoreSession" = "Volver a abrir ventanas y pestañas de la última vez"
|
||||
"pref.showDimensions" = "Mostrar dimensiones en los objetos"
|
||||
"pref.userName" = "Tu nombre en los comentarios"
|
||||
"proto.action.add" = "Añadir acción de prototipo"
|
||||
|
|
@ -1890,7 +1885,6 @@ status = "draft — needs native review"
|
|||
"select.sameStroke" = "Seleccionar mismo trazo"
|
||||
"select.sameText" = "Seleccionar mismas propiedades de texto"
|
||||
"select.set" = "Seleccionar"
|
||||
"session.restore" = "Restaurar la última sesión"
|
||||
"shape.arrow" = "Flecha"
|
||||
"shape.ellipse" = "Elipse"
|
||||
"shape.line" = "Línea"
|
||||
|
|
@ -2093,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"
|
||||
|
|
@ -1011,8 +1011,6 @@ status = "draft — needs native review"
|
|||
"Rename on the server…" = "Renommer sur le serveur…"
|
||||
"Rename page" = "Renommer la page"
|
||||
"Rename to" = "Renommer en"
|
||||
"Reopen its windows and tabs" = "Rouvrir ses fenêtres et onglets"
|
||||
"Reopen windows and tabs from last time" = "Rouvrir les fenêtres et onglets de la dernière fois"
|
||||
"Replace all" = "Tout remplacer"
|
||||
"Replace mixed export settings" = "Remplacer les réglages d’exportation mixtes"
|
||||
"Replace with" = "Remplacer par"
|
||||
|
|
@ -1039,7 +1037,6 @@ status = "draft — needs native review"
|
|||
"Responsive" = "Adaptatif"
|
||||
"Restart" = "Recommencer"
|
||||
"Restore" = "Restaurer"
|
||||
"Restore last session" = "Restaurer la dernière session"
|
||||
"Restore main component" = "Restaurer le composant principal"
|
||||
"Restore this version" = "Restaurer cette version"
|
||||
"Restored “{name}”" = "« {name} » restaurée"
|
||||
|
|
@ -1471,7 +1468,6 @@ status = "draft — needs native review"
|
|||
"{n} conflicts to resolve" = { one = "{n} conflit à résoudre", other = "{n} conflits à résoudre" }
|
||||
"{n} days ago" = { one = "il y a {n} jour", other = "il y a {n} jours" }
|
||||
"{n} files" = { one = "{n} fichier", other = "{n} fichiers" }
|
||||
"{n} files couldn't be reopened" = { one = "{n} fichier n’a pas pu être rouvert", other = "{n} fichiers n’ont pas pu être rouverts" }
|
||||
"{n} hours ago" = { one = "il y a {n} heure", other = "il y a {n} heures" }
|
||||
"{n} instances" = { one = "{n} instance", other = "{n} instances" }
|
||||
"{n} layers" = { one = "{n} calque", other = "{n} calques" }
|
||||
|
|
@ -1796,7 +1792,6 @@ status = "draft — needs native review"
|
|||
"pref.publishServer" = "Serveur pour le partage en ligne"
|
||||
"pref.reduceMotion" = "Réduire les animations"
|
||||
"pref.renameDuplicates" = "Renommer les calques dupliqués"
|
||||
"pref.restoreSession" = "Rouvrir les fenêtres et onglets de la dernière fois"
|
||||
"pref.showDimensions" = "Afficher les dimensions sur les objets"
|
||||
"pref.userName" = "Votre nom dans les commentaires"
|
||||
"proto.action.add" = "Ajouter une action de prototype"
|
||||
|
|
@ -1841,7 +1836,6 @@ status = "draft — needs native review"
|
|||
"select.sameStroke" = "Sélectionner le même contour"
|
||||
"select.sameText" = "Sélectionner les mêmes propriétés de texte"
|
||||
"select.set" = "Sélectionner"
|
||||
"session.restore" = "Restaurer la dernière session"
|
||||
"shape.arrow" = "Flèche"
|
||||
"shape.ellipse" = "Ellipse"
|
||||
"shape.line" = "Ligne"
|
||||
|
|
@ -2040,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" = "画像を選択"
|
||||
|
|
@ -1013,8 +1013,6 @@ status = "draft — needs native review"
|
|||
"Rename on the server…" = "サーバー上の名前を変更…"
|
||||
"Rename page" = "ページ名を変更"
|
||||
"Rename to" = "新しい名前"
|
||||
"Reopen its windows and tabs" = "ウィンドウとタブをもう一度開く"
|
||||
"Reopen windows and tabs from last time" = "前回のウィンドウとタブを再度開く"
|
||||
"Replace all" = "すべて置換"
|
||||
"Replace mixed export settings" = "混在した書き出し設定を置き換え"
|
||||
"Replace with" = "置換後の文字列"
|
||||
|
|
@ -1041,7 +1039,6 @@ status = "draft — needs native review"
|
|||
"Responsive" = "レスポンシブ"
|
||||
"Restart" = "再起動"
|
||||
"Restore" = "復元"
|
||||
"Restore last session" = "前回のセッションを復元"
|
||||
"Restore main component" = "メインコンポーネントを復元"
|
||||
"Restore this version" = "このバージョンを復元"
|
||||
"Restored “{name}”" = "「{name}」を復元済み"
|
||||
|
|
@ -1473,7 +1470,6 @@ status = "draft — needs native review"
|
|||
"{n} conflicts to resolve" = { other = "解決が必要な競合 {n} 件" }
|
||||
"{n} days ago" = { other = "{n} 日前" }
|
||||
"{n} files" = { other = "{n} 個のファイル" }
|
||||
"{n} files couldn't be reopened" = { other = "{n} 個のファイルを再度開けませんでした" }
|
||||
"{n} hours ago" = { other = "{n} 時間前" }
|
||||
"{n} instances" = { other = "{n} 個のインスタンス" }
|
||||
"{n} layers" = { other = "{n} 個のレイヤー" }
|
||||
|
|
@ -1798,7 +1794,6 @@ status = "draft — needs native review"
|
|||
"pref.publishServer" = "オンライン共有用のサーバー"
|
||||
"pref.reduceMotion" = "動きを減らす"
|
||||
"pref.renameDuplicates" = "複製したレイヤーの名前を変更"
|
||||
"pref.restoreSession" = "前回のウィンドウとタブを再度開く"
|
||||
"pref.showDimensions" = "オブジェクトに寸法を表示"
|
||||
"pref.userName" = "コメントに表示する名前"
|
||||
"proto.action.add" = "プロトタイプのアクションを追加"
|
||||
|
|
@ -1843,7 +1838,6 @@ status = "draft — needs native review"
|
|||
"select.sameStroke" = "同じ線を選択"
|
||||
"select.sameText" = "同じテキストプロパティを選択"
|
||||
"select.set" = "選択"
|
||||
"session.restore" = "前回のセッションを復元"
|
||||
"shape.arrow" = "矢印"
|
||||
"shape.ellipse" = "楕円"
|
||||
"shape.line" = "線"
|
||||
|
|
@ -2042,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" = "이미지 선택"
|
||||
|
|
@ -1012,8 +1012,6 @@ status = "draft — needs native review"
|
|||
"Rename on the server…" = "서버에서 이름 바꾸기…"
|
||||
"Rename page" = "페이지 이름 바꾸기"
|
||||
"Rename to" = "새 이름"
|
||||
"Reopen its windows and tabs" = "창과 탭 다시 열기"
|
||||
"Reopen windows and tabs from last time" = "지난번 창과 탭 다시 열기"
|
||||
"Replace all" = "모두 바꾸기"
|
||||
"Replace mixed export settings" = "혼합된 내보내기 설정 바꾸기"
|
||||
"Replace with" = "바꿀 내용"
|
||||
|
|
@ -1040,7 +1038,6 @@ status = "draft — needs native review"
|
|||
"Responsive" = "반응형"
|
||||
"Restart" = "다시 시작"
|
||||
"Restore" = "복원"
|
||||
"Restore last session" = "마지막 세션 복원"
|
||||
"Restore main component" = "메인 컴포넌트 복원"
|
||||
"Restore this version" = "이 버전 복원"
|
||||
"Restored “{name}”" = "\"{name}\" 복원됨"
|
||||
|
|
@ -1472,7 +1469,6 @@ status = "draft — needs native review"
|
|||
"{n} conflicts to resolve" = { other = "해결할 충돌 {n}개" }
|
||||
"{n} days ago" = { other = "{n}일 전" }
|
||||
"{n} files" = { other = "파일 {n}개" }
|
||||
"{n} files couldn't be reopened" = { other = "파일 {n}개를 다시 열 수 없음" }
|
||||
"{n} hours ago" = { other = "{n}시간 전" }
|
||||
"{n} instances" = { other = "인스턴스 {n}개" }
|
||||
"{n} layers" = { other = "레이어 {n}개" }
|
||||
|
|
@ -1797,7 +1793,6 @@ status = "draft — needs native review"
|
|||
"pref.publishServer" = "온라인 공유용 서버"
|
||||
"pref.reduceMotion" = "동작 줄이기"
|
||||
"pref.renameDuplicates" = "복제한 레이어 이름 바꾸기"
|
||||
"pref.restoreSession" = "지난번 창과 탭 다시 열기"
|
||||
"pref.showDimensions" = "개체에 치수 표시"
|
||||
"pref.userName" = "댓글에 표시할 이름"
|
||||
"proto.action.add" = "프로토타입 액션 추가"
|
||||
|
|
@ -1842,7 +1837,6 @@ status = "draft — needs native review"
|
|||
"select.sameStroke" = "같은 선 선택"
|
||||
"select.sameText" = "같은 텍스트 속성 선택"
|
||||
"select.set" = "선택"
|
||||
"session.restore" = "마지막 세션 복원"
|
||||
"shape.arrow" = "화살표"
|
||||
"shape.ellipse" = "타원"
|
||||
"shape.line" = "선"
|
||||
|
|
@ -2041,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"
|
||||
|
|
@ -1010,8 +1010,6 @@ status = "draft — needs native review"
|
|||
"Rename on the server…" = "Renomear no servidor…"
|
||||
"Rename page" = "Renomear página"
|
||||
"Rename to" = "Renomear para"
|
||||
"Reopen its windows and tabs" = "Reabra as janelas e abas"
|
||||
"Reopen windows and tabs from last time" = "Reabrir janelas e abas da última vez"
|
||||
"Replace all" = "Substituir tudo"
|
||||
"Replace mixed export settings" = "Substituir configurações de exportação mistas"
|
||||
"Replace with" = "Substituir por"
|
||||
|
|
@ -1038,7 +1036,6 @@ status = "draft — needs native review"
|
|||
"Responsive" = "Responsivo"
|
||||
"Restart" = "Reiniciar"
|
||||
"Restore" = "Restaurar"
|
||||
"Restore last session" = "Restaurar a última sessão"
|
||||
"Restore main component" = "Restaurar componente principal"
|
||||
"Restore this version" = "Restaurar esta versão"
|
||||
"Restored “{name}”" = "“{name}” restaurada"
|
||||
|
|
@ -1470,7 +1467,6 @@ status = "draft — needs native review"
|
|||
"{n} conflicts to resolve" = { one = "{n} conflito para resolver", other = "{n} conflitos para resolver" }
|
||||
"{n} days ago" = { one = "há {n} dia", other = "há {n} dias" }
|
||||
"{n} files" = { one = "{n} arquivo", other = "{n} arquivos" }
|
||||
"{n} files couldn't be reopened" = { one = "{n} arquivo não pôde ser reaberto", other = "{n} arquivos não puderam ser reabertos" }
|
||||
"{n} hours ago" = { one = "há {n} hora", other = "há {n} horas" }
|
||||
"{n} instances" = { one = "{n} instância", other = "{n} instâncias" }
|
||||
"{n} layers" = { one = "{n} camada", other = "{n} camadas" }
|
||||
|
|
@ -1795,7 +1791,6 @@ status = "draft — needs native review"
|
|||
"pref.publishServer" = "Servidor para compartilhamento online"
|
||||
"pref.reduceMotion" = "Reduzir movimento"
|
||||
"pref.renameDuplicates" = "Renomear camadas duplicadas"
|
||||
"pref.restoreSession" = "Reabrir janelas e abas da última vez"
|
||||
"pref.showDimensions" = "Mostrar dimensões nos objetos"
|
||||
"pref.userName" = "Seu nome nos comentários"
|
||||
"proto.action.add" = "Adicionar ação de protótipo"
|
||||
|
|
@ -1840,7 +1835,6 @@ status = "draft — needs native review"
|
|||
"select.sameStroke" = "Selecionar mesmo traço"
|
||||
"select.sameText" = "Selecionar mesmas propriedades de texto"
|
||||
"select.set" = "Selecionar"
|
||||
"session.restore" = "Restaurar a última sessão"
|
||||
"shape.arrow" = "Seta"
|
||||
"shape.ellipse" = "Elipse"
|
||||
"shape.line" = "Linha"
|
||||
|
|
@ -2039,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" = "选择图片"
|
||||
|
|
@ -1013,8 +1013,6 @@ status = "draft — needs native review"
|
|||
"Rename on the server…" = "在服务器上重命名…"
|
||||
"Rename page" = "重命名页面"
|
||||
"Rename to" = "重命名为"
|
||||
"Reopen its windows and tabs" = "重新打开其窗口和标签页"
|
||||
"Reopen windows and tabs from last time" = "重新打开上次的窗口与标签页"
|
||||
"Replace all" = "全部替换"
|
||||
"Replace mixed export settings" = "替换混合的导出设置"
|
||||
"Replace with" = "替换为"
|
||||
|
|
@ -1041,7 +1039,6 @@ status = "draft — needs native review"
|
|||
"Responsive" = "响应式"
|
||||
"Restart" = "重新开始"
|
||||
"Restore" = "恢复"
|
||||
"Restore last session" = "恢复上次会话"
|
||||
"Restore main component" = "恢复主组件"
|
||||
"Restore this version" = "恢复此版本"
|
||||
"Restored “{name}”" = "已恢复“{name}”"
|
||||
|
|
@ -1473,7 +1470,6 @@ status = "draft — needs native review"
|
|||
"{n} conflicts to resolve" = { other = "{n} 个冲突待解决" }
|
||||
"{n} days ago" = { other = "{n} 天前" }
|
||||
"{n} files" = { other = "{n} 个文件" }
|
||||
"{n} files couldn't be reopened" = { other = "{n} 个文件无法重新打开" }
|
||||
"{n} hours ago" = { other = "{n} 小时前" }
|
||||
"{n} instances" = { other = "{n} 个实例" }
|
||||
"{n} layers" = { other = "{n} 个图层" }
|
||||
|
|
@ -1798,7 +1794,6 @@ status = "draft — needs native review"
|
|||
"pref.publishServer" = "在线分享所用的服务器"
|
||||
"pref.reduceMotion" = "减少动态效果"
|
||||
"pref.renameDuplicates" = "重命名副本图层"
|
||||
"pref.restoreSession" = "重新打开上次的窗口与标签页"
|
||||
"pref.showDimensions" = "在对象上显示尺寸"
|
||||
"pref.userName" = "评论中显示的名字"
|
||||
"proto.action.add" = "添加原型操作"
|
||||
|
|
@ -1843,7 +1838,6 @@ status = "draft — needs native review"
|
|||
"select.sameStroke" = "选择相同描边"
|
||||
"select.sameText" = "选择相同文本属性"
|
||||
"select.set" = "选择"
|
||||
"session.restore" = "恢复上次会话"
|
||||
"shape.arrow" = "箭头"
|
||||
"shape.ellipse" = "椭圆"
|
||||
"shape.line" = "直线"
|
||||
|
|
@ -2042,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;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
//! - `engine.execute {command, params}` — run any command (no dialogs open)
|
||||
//! - `engine.commands {}` — every command with its params doc
|
||||
//! - `document.inspect {depth?, allPages?}` / `node.get {id, depth?}`
|
||||
//! - `ui.inspect {}` — panel/tab/tool state, canvas rect, timings, the kept session (`session`)
|
||||
//! - `ui.inspect {}` — panel/tab/tool state, canvas rect, timings
|
||||
//! - `ui.pointer {kind: down|move|up|click|double|hover|drag, x, y (page) | sx, sy (screen), to?, shift?, alt?, cmd?}`
|
||||
//! - `ui.key {key, shift?, cmd?, alt?}` — a shortcut as if typed on the canvas
|
||||
//! - `ui.type {text}` — type into the text being edited
|
||||
|
|
@ -18,8 +18,8 @@
|
|||
//! - `ui.set {rightTab?, leftTab?, theme?, home?, homeSection?, palette?, focusRegion?, preview?, presentScroll?, …}`
|
||||
//! - `ui.screenshot {path}` — PNG of the window (needs a visible window)
|
||||
//! - `ui.render {path, scale?, ids?}` — PNG of the document (no UI chrome; works without a window)
|
||||
//! - `window.new {path?, index?}` → `{window}` / `window.list {}` (each window's files, rectangle
|
||||
//! and state) / `window.focus {id}` — several windows (desktop)
|
||||
//! - `window.new {path?, index?}` → `{window}` / `window.list {}` / `window.focus {id}` — several
|
||||
//! windows (desktop)
|
||||
//! - `app.quit {}`
|
||||
//!
|
||||
//! Any method takes `window` (an id from `window.list`) to run in that window, with its tabs and
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -155,7 +155,6 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
|
|||
(tr("Show dimensions on objects"), prefs.show_dimensions, "pref.showDimensions"),
|
||||
(tr("Use number keys for opacity"), prefs.number_keys_opacity, "pref.numberKeysOpacity"),
|
||||
(tr("Reduce motion"), prefs.reduce_motion, "pref.reduceMotion"),
|
||||
(tr("Reopen windows and tabs from last time"), prefs.restore_session, "pref.restoreSession"),
|
||||
] {
|
||||
if crate::widgets::checkbox(ui, val, label) {
|
||||
toggles.push(cmd);
|
||||
|
|
|
|||
|
|
@ -208,18 +208,11 @@ fn action_card(ui: &mut Ui, t: &Tokens, icon: Icon, title: &str, sub: &str) -> b
|
|||
|
||||
fn recents(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
||||
heading(ui, t, tr("Recents"));
|
||||
let (mut new_file, mut open, mut restore) = (false, false, false);
|
||||
let (mut new_file, mut open) = (false, false);
|
||||
ui.horizontal(|ui| {
|
||||
new_file = action_card(ui, t, Icon::Plus, tr("New design file"), tr("Start from a blank canvas"));
|
||||
open = action_card(ui, t, Icon::Page, tr("Open file"), tr("Open .figcraft, .svg or images"));
|
||||
// This run started without the last one's windows and tabs.
|
||||
if app.restore_offered() {
|
||||
restore = action_card(ui, t, Icon::WinRestore, tr("Restore last session"), tr("Reopen its windows and tabs"));
|
||||
}
|
||||
});
|
||||
if restore {
|
||||
app.restore_last();
|
||||
}
|
||||
if new_file {
|
||||
app.run("file.new", json!({}));
|
||||
app.ui.home = false;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,6 @@ pub mod proto_canvas;
|
|||
pub mod proto_editor;
|
||||
pub mod proto_panel;
|
||||
pub mod render_worker;
|
||||
pub mod restore;
|
||||
pub mod right;
|
||||
pub mod scale_panel;
|
||||
pub mod section_panel;
|
||||
|
|
@ -114,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;
|
||||
|
|
@ -136,8 +137,6 @@ mod proto_reorder_tests;
|
|||
#[cfg(test)]
|
||||
mod proto_responsive_tests;
|
||||
#[cfg(test)]
|
||||
mod restore_tests;
|
||||
#[cfg(test)]
|
||||
mod shortcut_keys_tests;
|
||||
#[cfg(test)]
|
||||
mod shortcuts_tests;
|
||||
|
|
@ -192,9 +191,6 @@ pub trait Services {
|
|||
/// Keep the preferences now (the installed plugins or their settings changed), as the app
|
||||
/// does when it quits.
|
||||
fn save_prefs(&mut self, _prefs: &figcraft_engine::Prefs) {}
|
||||
/// A restore of the last session begins (`on`) or has settled: the app keeps a mark meanwhile,
|
||||
/// so a crash on the way starts the next run without restoring.
|
||||
fn restoring(&mut self, _on: bool) {}
|
||||
/// The OS or browser language as a tag ("es-ES"), for the "system" language preference.
|
||||
fn system_language(&self) -> Option<String> {
|
||||
None
|
||||
|
|
@ -501,11 +497,6 @@ pub struct FigApp {
|
|||
window_requests: Vec<figcraft_engine::UiRequest>,
|
||||
/// Commands waiting for a server ([`FigApp::run_background`]) and what to do with their results.
|
||||
waiting: Vec<(String, Done)>,
|
||||
/// The screens there are, `[x, y, width, height]` in screen points (the app says; none known
|
||||
/// in the browser and in tests): restored windows open on them.
|
||||
pub monitors: Vec<[f32; 4]>,
|
||||
/// Keeping and restoring the session.
|
||||
restore: restore::Restore,
|
||||
}
|
||||
|
||||
/// What to do with the result of a command run in the background.
|
||||
|
|
@ -533,8 +524,6 @@ impl FigApp {
|
|||
windows: windows::Windows::default(),
|
||||
window_requests: Vec::new(),
|
||||
waiting: Vec::new(),
|
||||
monitors: Vec::new(),
|
||||
restore: restore::Restore::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -748,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" | "session.restore" => self.window_requests.push(r),
|
||||
"window.new" | "window.focus" | "window.fullscreen" => self.window_requests.push(r),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
@ -955,7 +944,6 @@ impl FigApp {
|
|||
windows::repaint_others(self, &ctx);
|
||||
}
|
||||
windows::show(self, &ctx);
|
||||
restore::keep(self, &ctx);
|
||||
// Plugins installed, removed or set up are kept at once: a crash doesn't lose them.
|
||||
// So are the owner tokens of files put online.
|
||||
if std::mem::take(&mut self.session.plugins.unsaved) | std::mem::take(&mut self.session.live.unsaved) {
|
||||
|
|
@ -1190,7 +1178,6 @@ impl FigApp {
|
|||
"live": live::inspect(self),
|
||||
"window": self.windows.current,
|
||||
"windows": windows::list(self),
|
||||
"session": self.inspect_session(),
|
||||
"tool": self.session.tool_info(),
|
||||
"cursor": self.canvas.cursor.map(cursors::Shown::name),
|
||||
"panels": {"left": self.ui.left_width, "right": self.ui.right_width, "leftCollapsed": self.ui.left_collapsed},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -1,344 +0,0 @@
|
|||
//! Session restore in the interface (the engine's [`figcraft_engine::restore`] has the tabs): each
|
||||
//! window's place on screen, state and panels are kept with the preferences as they change, and
|
||||
//! put back when `session.restore` opened the tabs again. A window comes back where it was only
|
||||
//! where a screen still shows it ([`place`]).
|
||||
|
||||
use egui::{Context, Pos2, Vec2, ViewportCommand, ViewportId};
|
||||
use figcraft_engine::restore::{Placed, SavedSession, SavedTab, SavedWindow};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{FigApp, LeftTab, RightTab, UiState};
|
||||
|
||||
/// How much of a window's top strip (where it is grabbed to move it) a screen must show for the
|
||||
/// window to count as on it: this high, and this wide (or the whole window, when narrower).
|
||||
const STRIP: f32 = 32.0;
|
||||
const HOLD: f32 = 120.0;
|
||||
|
||||
/// The seconds at most between two writes of a session that keeps changing, and how long after
|
||||
/// windows were put back a crash still counts as one during the restore.
|
||||
const WRITE_EVERY: f64 = 1.0;
|
||||
const SETTLED_AFTER: f64 = 1.0;
|
||||
|
||||
/// Where a window is: its outer position and inner size while it is neither maximized nor full
|
||||
/// screen (screen points), and those states.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Geometry {
|
||||
pub pos: Option<Pos2>,
|
||||
pub size: Vec2,
|
||||
pub maximized: bool,
|
||||
pub fullscreen: bool,
|
||||
}
|
||||
|
||||
impl Default for Geometry {
|
||||
fn default() -> Self {
|
||||
Self { pos: None, size: Vec2::new(1280.0, 800.0), maximized: false, fullscreen: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Geometry {
|
||||
/// Follow the window drawing now. A minimized window says nothing about where it is.
|
||||
pub(crate) fn track(&mut self, ctx: &Context) {
|
||||
ctx.input(|i| {
|
||||
let v = i.viewport();
|
||||
self.maximized = v.maximized.unwrap_or(self.maximized);
|
||||
self.fullscreen = v.fullscreen.unwrap_or(self.fullscreen);
|
||||
if v.minimized == Some(true) || self.maximized || self.fullscreen {
|
||||
return;
|
||||
}
|
||||
self.pos = v.outer_rect.map(|r| r.min).or(self.pos);
|
||||
self.size = v.inner_rect.map_or(self.size, |r| r.size());
|
||||
});
|
||||
}
|
||||
|
||||
fn rect(&self) -> Option<[f32; 4]> {
|
||||
self.pos.map(|p| [p.x, p.y, self.size.x, self.size.y])
|
||||
}
|
||||
|
||||
/// As saved, fitted to the screens there are now.
|
||||
fn of(saved: &SavedWindow, monitors: &[[f32; 4]]) -> Self {
|
||||
let rect = saved.rect.map(|r| place(r, saved.monitor, monitors));
|
||||
Self {
|
||||
pos: rect.map(|r| Pos2::new(r[0], r[1])),
|
||||
size: rect.map_or(Self::default().size, |r| Vec2::new(r[2], r[3])),
|
||||
maximized: saved.maximized,
|
||||
fullscreen: saved.fullscreen,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn json(&self, monitors: &[[f32; 4]]) -> Value {
|
||||
json!({"rect": self.rect(), "maximized": self.maximized, "fullscreen": self.fullscreen, "monitor": self.rect().and_then(|r| monitor_of(r, monitors))})
|
||||
}
|
||||
}
|
||||
|
||||
/// How much of `[x, y, width, height]` rectangles `a` and `b` share: width and height.
|
||||
fn shared(a: [f32; 4], b: [f32; 4]) -> (f32, f32) {
|
||||
let side = |a0: f32, al: f32, b0: f32, bl: f32| ((a0 + al).min(b0 + bl) - a0.max(b0)).max(0.0);
|
||||
(side(a[0], a[2], b[0], b[2]), side(a[1], a[3], b[1], b[3]))
|
||||
}
|
||||
|
||||
/// The screen showing most of `rect`.
|
||||
pub fn monitor_of(rect: [f32; 4], monitors: &[[f32; 4]]) -> Option<[f32; 4]> {
|
||||
let area = |m: &[f32; 4]| {
|
||||
let (w, h) = shared(rect, *m);
|
||||
w * h
|
||||
};
|
||||
monitors.iter().copied().filter(|m| area(m) > 0.0).max_by(|a, b| area(a).total_cmp(&area(b)))
|
||||
}
|
||||
|
||||
/// Where a window saved at `rect` (`[x, y, width, height]`) on screen `monitor` opens on the
|
||||
/// screens there are now: where it was while a screen shows enough of its top strip to grab;
|
||||
/// otherwise moved onto its screen if that is still there, else the nearest one, and no larger
|
||||
/// than it. With no screens known it stays as saved.
|
||||
pub fn place(rect: [f32; 4], monitor: Option<[f32; 4]>, monitors: &[[f32; 4]]) -> [f32; 4] {
|
||||
let [x, y, w, h] = rect;
|
||||
let sane = |m: &&[f32; 4]| m.iter().all(|n| n.is_finite()) && m[2] >= 1.0 && m[3] >= 1.0;
|
||||
let monitors: Vec<[f32; 4]> = monitors.iter().filter(sane).copied().collect();
|
||||
if monitors.is_empty() || !rect.iter().all(|n| n.is_finite()) {
|
||||
return rect;
|
||||
}
|
||||
let grabbable = |m: &[f32; 4]| {
|
||||
let (sw, sh) = shared([x, y, w, STRIP.min(h)], *m);
|
||||
sw >= HOLD.min(w) && sh >= (STRIP / 2.0).min(h)
|
||||
};
|
||||
if monitors.iter().any(grabbable) {
|
||||
return rect;
|
||||
}
|
||||
let (cx, cy) = (x + w / 2.0, y + h / 2.0);
|
||||
let away = |m: &[f32; 4]| (m[0] + m[2] / 2.0 - cx).hypot(m[1] + m[3] / 2.0 - cy);
|
||||
let same = |m: &&[f32; 4]| monitor.is_some_and(|s| m.iter().zip(s).all(|(a, b)| (a - b).abs() < 1.0));
|
||||
let Some(m) = monitors.iter().find(same).or_else(|| monitors.iter().min_by(|a, b| away(a).total_cmp(&away(b)))) else { return rect };
|
||||
let (w, h) = (w.min(m[2]), h.min(m[3]));
|
||||
[x.clamp(m[0], m[0] + m[2] - w), y.clamp(m[1], m[1] + m[3] - h), w, h]
|
||||
}
|
||||
|
||||
fn left_id(t: LeftTab) -> &'static str {
|
||||
match t {
|
||||
LeftTab::File => "file",
|
||||
LeftTab::Assets => "assets",
|
||||
}
|
||||
}
|
||||
|
||||
fn right_id(t: RightTab) -> &'static str {
|
||||
match t {
|
||||
RightTab::Design => "design",
|
||||
RightTab::Prototype => "prototype",
|
||||
RightTab::Inspect => "inspect",
|
||||
}
|
||||
}
|
||||
|
||||
/// Put a saved window's panels back into `ui`.
|
||||
fn panels(ui: &mut UiState, saved: &SavedWindow) {
|
||||
ui.left_tab = if saved.left_tab == "assets" { LeftTab::Assets } else { LeftTab::File };
|
||||
ui.right_tab = match saved.right_tab.as_str() {
|
||||
"prototype" => RightTab::Prototype,
|
||||
"inspect" => RightTab::Inspect,
|
||||
_ => RightTab::Design,
|
||||
};
|
||||
ui.left_collapsed = saved.left_collapsed;
|
||||
if let Some([left, right]) = saved.panel_widths {
|
||||
let within = |w: f32, range: std::ops::RangeInclusive<f32>| w.clamp(*range.start(), *range.end());
|
||||
(ui.left_width, ui.right_width) = (within(left, crate::LEFT_PANEL), within(right, crate::RIGHT_PANEL));
|
||||
}
|
||||
}
|
||||
|
||||
/// The windows and tabs open now (the main window draws: `app.ui` is its own).
|
||||
pub(crate) fn snapshot(app: &FigApp) -> SavedSession {
|
||||
let wins = &app.windows;
|
||||
// Online files still on their way back stay tabs of their windows (of the main window, when
|
||||
// theirs is gone).
|
||||
let joining = |id: u64| app.session.restore.joining().filter(move |(k, _)| wins.restored(*k) == id).map(|(_, tab)| tab.clone());
|
||||
let window = |id: u64, geo: &Geometry, ui: &UiState, active: Option<u64>| {
|
||||
let open = app.session.docs.iter().filter(|d| wins.of(d.uid) == id).filter_map(|d| Some((d.uid, app.session.saved_tab(d)?)));
|
||||
let (uids, mut tabs): (Vec<u64>, Vec<SavedTab>) = open.unzip();
|
||||
tabs.extend(joining(id));
|
||||
tabs.truncate(figcraft_engine::restore::MAX_TABS);
|
||||
SavedWindow {
|
||||
rect: geo.rect(),
|
||||
maximized: geo.maximized,
|
||||
fullscreen: geo.fullscreen,
|
||||
monitor: geo.rect().and_then(|r| monitor_of(r, &app.monitors)),
|
||||
front: uids.iter().position(|u| Some(*u) == active).unwrap_or(0),
|
||||
tabs,
|
||||
left_tab: left_id(ui.left_tab).into(),
|
||||
right_tab: right_id(ui.right_tab).into(),
|
||||
left_collapsed: ui.left_collapsed,
|
||||
panel_widths: Some([ui.left_width, ui.right_width]),
|
||||
}
|
||||
};
|
||||
let main = window(0, &wins.main, &app.ui, app.session.ds().ok().map(|d| d.uid));
|
||||
let others = wins.list.iter().map(|w| window(w.id, &w.geo, &w.pane.ui, w.active)).filter(|w| !w.tabs.is_empty());
|
||||
SavedSession { windows: std::iter::once(main).chain(others).take(figcraft_engine::restore::MAX_WINDOWS).collect() }
|
||||
}
|
||||
|
||||
/// After the main window drew: keep the session with the preferences when it changed (at once,
|
||||
/// then at most every [`WRITE_EVERY`] seconds while it keeps changing; nothing while it doesn't),
|
||||
/// and tell the app once a restore has settled.
|
||||
pub(crate) fn keep(app: &mut FigApp, ctx: &Context) {
|
||||
// The browser keeps no preferences: nothing to keep a session in.
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return;
|
||||
}
|
||||
let now = ctx.input(|i| i.time);
|
||||
if let Some(at) = app.restore.settles {
|
||||
if now >= at {
|
||||
app.restore.settles = None;
|
||||
app.services.restoring(false);
|
||||
} else {
|
||||
crate::repaint_in(ctx, at - now);
|
||||
}
|
||||
}
|
||||
// A window on its way out isn't how the app was left.
|
||||
if app.ui.quit || app.session.restore.placing.is_some() {
|
||||
return;
|
||||
}
|
||||
let session = snapshot(app);
|
||||
if session != app.session.prefs.session {
|
||||
app.session.prefs.session = session;
|
||||
app.restore.unsaved = true;
|
||||
}
|
||||
if !app.restore.unsaved {
|
||||
return;
|
||||
}
|
||||
// Not while something is dragged (a panel's edge): the frame it is let go in writes.
|
||||
if ctx.input(|i| i.pointer.any_down()) {
|
||||
return;
|
||||
}
|
||||
if now < app.restore.next_write {
|
||||
crate::repaint_in(ctx, app.restore.next_write - now);
|
||||
return;
|
||||
}
|
||||
app.restore.unsaved = false;
|
||||
app.restore.next_write = now + WRITE_EVERY;
|
||||
app.services.save_prefs(&app.session.prefs);
|
||||
}
|
||||
|
||||
/// What the interface remembers between frames about keeping and restoring the session.
|
||||
#[derive(Default)]
|
||||
pub struct Restore {
|
||||
/// The saved session changed and isn't written yet; when (UI clock) it may be written next.
|
||||
unsaved: bool,
|
||||
next_write: f64,
|
||||
/// When (UI clock) the restore under way counts as done (the app's crash guard goes then).
|
||||
settles: Option<f64>,
|
||||
}
|
||||
|
||||
impl FigApp {
|
||||
/// Restore the session on offer (the home screen's button): the app's crash guard is up
|
||||
/// first, so a crash on the way starts the next run without restoring.
|
||||
pub fn restore_last(&mut self) {
|
||||
self.services.restoring(true);
|
||||
self.run("session.restore", json!({}));
|
||||
self.ui.home = !self.has_files();
|
||||
}
|
||||
|
||||
/// The last run's session is on offer (this run started without it).
|
||||
pub fn restore_offered(&self) -> bool {
|
||||
self.session.restore.last.as_ref().is_some_and(|s| s.tabs() > 0)
|
||||
}
|
||||
|
||||
/// The saved session and what restoring knows (`ui.inspect`'s `session`).
|
||||
pub(crate) fn inspect_session(&self) -> Value {
|
||||
json!({
|
||||
"saved": self.session.prefs.session,
|
||||
"restore": self.session.prefs.restore_session,
|
||||
"offered": self.restore_offered(),
|
||||
"joining": self.session.restore.joining().count(),
|
||||
"monitors": self.monitors,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The main window's frame begins: put the windows `session.restore` opened the tabs of where
|
||||
/// they were, and restored online files that arrived since into theirs.
|
||||
pub(crate) fn begin(app: &mut FigApp, ctx: &Context) {
|
||||
if let Some((placed, missing)) = app.session.restore.placing.take() {
|
||||
apply(app, ctx, placed);
|
||||
if missing > 0 {
|
||||
// With what is waiting to be said (documents recovered after a crash), not instead.
|
||||
let said = app
|
||||
.ui
|
||||
.toast
|
||||
.take()
|
||||
.filter(|(_, shown)| shown.is_none())
|
||||
.map(|(m, _)| {
|
||||
format!(
|
||||
"{m}
|
||||
"
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
app.toast(said + &crate::i18n::trn("{n} files couldn't be reopened", missing, &[]));
|
||||
}
|
||||
app.restore.settles = Some(ctx.input(|i| i.time) + SETTLED_AFTER);
|
||||
}
|
||||
if app.session.restore.joining().next().is_none() {
|
||||
return;
|
||||
}
|
||||
for a in app.session.restore_arrivals() {
|
||||
let id = app.windows.restored(a.window);
|
||||
crate::windows::adopt(app, id, &[a.uid], a.front.then_some(a.uid));
|
||||
ctx.request_repaint_of(crate::windows::viewport(id));
|
||||
}
|
||||
// One that couldn't be joined says so.
|
||||
if let Some(m) = app.session.message.take() {
|
||||
app.toast(m);
|
||||
}
|
||||
}
|
||||
|
||||
fn apply(app: &mut FigApp, ctx: &Context, placed: Vec<Placed>) {
|
||||
// One window (the browser, a screen reader running): every tab stays here.
|
||||
let one = ctx.embed_viewports() || crate::a11y::active(ctx);
|
||||
let mut any = false;
|
||||
// A file opened since (one the app was started with) stays in front.
|
||||
let active = app.session.ds().ok().map(|d| d.uid);
|
||||
let keep_front = !placed.iter().any(|p| active.is_some_and(|a| p.docs.contains(&a)));
|
||||
for w in &mut app.windows.list {
|
||||
w.restored = None;
|
||||
}
|
||||
for (k, p) in placed.iter().enumerate() {
|
||||
any |= !p.docs.is_empty();
|
||||
let geo = Geometry::of(&p.saved, &app.monitors);
|
||||
if k == 0 {
|
||||
let (root, now) = (ViewportId::ROOT, app.windows.main);
|
||||
let send = |cmd| ctx.send_viewport_cmd_to(root, cmd);
|
||||
// A window is placed while it is neither maximized nor full screen: it leaves a
|
||||
// state first and enters one last, and one it stays in keeps the place under it.
|
||||
if now.fullscreen && !geo.fullscreen {
|
||||
send(ViewportCommand::Fullscreen(false));
|
||||
}
|
||||
if now.maximized && !geo.maximized {
|
||||
send(ViewportCommand::Maximized(false));
|
||||
}
|
||||
let stays = (now.maximized && geo.maximized) || (now.fullscreen && geo.fullscreen);
|
||||
if let Some(pos) = geo.pos.filter(|_| !stays) {
|
||||
send(ViewportCommand::OuterPosition(pos));
|
||||
send(ViewportCommand::InnerSize(geo.size));
|
||||
}
|
||||
if geo.maximized && !now.maximized {
|
||||
send(ViewportCommand::Maximized(true));
|
||||
}
|
||||
if geo.fullscreen && !now.fullscreen {
|
||||
send(ViewportCommand::Fullscreen(true));
|
||||
}
|
||||
app.windows.main = Geometry { pos: geo.pos.or(app.windows.main.pos), ..geo };
|
||||
panels(&mut app.ui, &p.saved);
|
||||
app.session.prefs.panel_widths = (app.ui.left_width, app.ui.right_width);
|
||||
crate::windows::adopt(app, 0, &p.docs, p.front.filter(|_| !keep_front));
|
||||
} else if one || (p.docs.is_empty() && !app.session.restore.joining().any(|(w, _)| w == k)) {
|
||||
// Its tabs (none open yet) are the main window's.
|
||||
crate::windows::adopt(app, 0, &p.docs, None);
|
||||
} else {
|
||||
let mut ui = UiState { title_bar: app.ui.title_bar, ..UiState::default() };
|
||||
panels(&mut ui, &p.saved);
|
||||
let id = crate::windows::push(app, ui, geo);
|
||||
if let Some(w) = app.windows.list.last_mut() {
|
||||
w.restored = Some(k);
|
||||
}
|
||||
crate::windows::adopt(app, id, &p.docs, p.front);
|
||||
}
|
||||
}
|
||||
app.windows.focused = 0;
|
||||
if any {
|
||||
app.ui.home = false;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,391 +0,0 @@
|
|||
//! Session restore in the interface, driven through egui headless: a window comes back where it
|
||||
//! was only where a screen shows it; the windows and tabs open now are kept with the preferences
|
||||
//! as they change (at once, then at most once a second, never while nothing changes) and come
|
||||
//! back in their windows with their panels, place and state; a window off every screen moves
|
||||
//! onto one; a build with one window keeps every tab; files that are gone get one message; a
|
||||
//! file opened since stays in front; the home screen offers the last session after a run that
|
||||
//! started without it, with the app's crash guard up while it is restored.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use egui::{Pos2, RawInput, Rect, ViewportId, ViewportInfo, vec2};
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_engine::restore::{SavedSession, SavedTab, SavedWindow};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::restore::{monitor_of, place};
|
||||
use crate::{FigApp, RightTab, Services};
|
||||
|
||||
const A: [f32; 4] = [0.0, 0.0, 1920.0, 1080.0];
|
||||
const B: [f32; 4] = [1920.0, 0.0, 1280.0, 1024.0];
|
||||
|
||||
type Screen = [f32; 4];
|
||||
/// What the case is, the saved rectangle, the screen it was on, the screens now, where it opens.
|
||||
type Case = (&'static str, [f32; 4], Option<Screen>, &'static [Screen], [f32; 4]);
|
||||
|
||||
#[test]
|
||||
fn a_window_comes_back_where_a_screen_shows_it() {
|
||||
let nan = f32::NAN;
|
||||
let table: &[Case] = &[
|
||||
("on its screen", [100.0, 80.0, 1200.0, 800.0], Some(A), &[A, B], [100.0, 80.0, 1200.0, 800.0]),
|
||||
("across two screens", [1500.0, 50.0, 900.0, 700.0], Some(A), &[A, B], [1500.0, 50.0, 900.0, 700.0]),
|
||||
("on the second screen", [2000.0, 100.0, 1000.0, 700.0], Some(B), &[A, B], [2000.0, 100.0, 1000.0, 700.0]),
|
||||
("its screen is gone", [2000.0, 100.0, 1000.0, 700.0], Some(B), &[A], [920.0, 100.0, 1000.0, 700.0]),
|
||||
("far off every screen", [-5000.0, -5000.0, 800.0, 600.0], None, &[A, B], [0.0, 0.0, 800.0, 600.0]),
|
||||
("only a sliver shows", [1900.0, 100.0, 800.0, 600.0], Some(A), &[A], [1120.0, 100.0, 800.0, 600.0]),
|
||||
("its top strip is above the screen", [300.0, -200.0, 800.0, 600.0], Some(A), &[A], [300.0, 0.0, 800.0, 600.0]),
|
||||
("its top strip is below the screen", [300.0, 1075.0, 800.0, 600.0], Some(A), &[A], [300.0, 480.0, 800.0, 600.0]),
|
||||
("the bottom hangs off: it can still be grabbed", [300.0, 900.0, 800.0, 600.0], Some(A), &[A], [300.0, 900.0, 800.0, 600.0]),
|
||||
("larger than the screen", [5000.0, 5000.0, 4000.0, 3000.0], None, &[A], [0.0, 0.0, 1920.0, 1080.0]),
|
||||
("off screen: back to its own screen, not the nearest", [-900.0, 100.0, 800.0, 600.0], Some(B), &[A, B], [1920.0, 100.0, 800.0, 600.0]),
|
||||
(
|
||||
"off screen: the nearest when its own is gone",
|
||||
[3300.0, 100.0, 800.0, 600.0],
|
||||
Some([3200.0, 0.0, 1000.0, 1000.0]),
|
||||
&[A, B],
|
||||
[2400.0, 100.0, 800.0, 600.0],
|
||||
),
|
||||
("narrower than the strip to grab", [1850.0, 100.0, 100.0, 100.0], Some(A), &[A], [1820.0, 100.0, 100.0, 100.0]),
|
||||
("no screens known", [-5000.0, 0.0, 800.0, 600.0], None, &[], [-5000.0, 0.0, 800.0, 600.0]),
|
||||
(
|
||||
"screens that aren't",
|
||||
[-5000.0, 0.0, 800.0, 600.0],
|
||||
None,
|
||||
&[[0.0, 0.0, 0.0, 0.0], [f32::NAN, 0.0, 100.0, 100.0]],
|
||||
[-5000.0, 0.0, 800.0, 600.0],
|
||||
),
|
||||
];
|
||||
for (what, rect, monitor, monitors, want) in table {
|
||||
assert_eq!(place(*rect, *monitor, monitors), *want, "{what}");
|
||||
// Where it opens, a screen shows its top strip.
|
||||
if let Some(m) = monitor_of(*want, monitors) {
|
||||
assert!(want[1] >= m[1] - 0.5 && want[1] < m[1] + m[3], "{what}");
|
||||
}
|
||||
}
|
||||
assert!(place([nan, 0.0, 800.0, 600.0], None, &[A])[0].is_nan(), "never a panic on a rectangle that isn't one");
|
||||
assert_eq!(monitor_of([1500.0, 50.0, 900.0, 700.0], &[A, B]), Some(B), "the screen showing most of it");
|
||||
assert_eq!(monitor_of([-5000.0, 0.0, 800.0, 600.0], &[A, B]), None);
|
||||
}
|
||||
|
||||
/// What the app's services were asked: the sessions kept, and the restoring mark put up or down.
|
||||
#[derive(Default)]
|
||||
struct Asked {
|
||||
kept: Vec<SavedSession>,
|
||||
marks: Vec<bool>,
|
||||
}
|
||||
|
||||
struct Keeps(Arc<Mutex<Asked>>);
|
||||
impl Services for Keeps {
|
||||
fn save_prefs(&mut self, prefs: &figcraft_engine::Prefs) {
|
||||
self.0.lock().unwrap().kept.push(prefs.session.clone());
|
||||
}
|
||||
fn restoring(&mut self, on: bool) {
|
||||
self.0.lock().unwrap().marks.push(on);
|
||||
}
|
||||
}
|
||||
|
||||
/// The app with egui's context and clock, as the desktop app runs it (windows are native).
|
||||
struct Run {
|
||||
ctx: egui::Context,
|
||||
app: FigApp,
|
||||
asked: Arc<Mutex<Asked>>,
|
||||
time: f64,
|
||||
/// Where the system says the main window is: `[x, y, width, height]`, maximized.
|
||||
main: Option<([f32; 4], bool)>,
|
||||
/// Where the main window was told to go and what state to take (the commands' names), in order.
|
||||
told: Vec<String>,
|
||||
}
|
||||
|
||||
impl Run {
|
||||
fn new(s: Session) -> Self {
|
||||
let asked = Arc::new(Mutex::new(Asked::default()));
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_embed_viewports(false);
|
||||
Self { ctx, app: FigApp::new(s, Box::new(Keeps(asked.clone()))), asked, time: 0.0, main: None, told: Vec::new() }
|
||||
}
|
||||
|
||||
/// One main-window frame `secs` later.
|
||||
fn frame(&mut self, secs: f64) {
|
||||
self.time += secs;
|
||||
let mut input = RawInput { time: Some(self.time), ..Default::default() };
|
||||
if let Some(([x, y, w, h], maximized)) = self.main {
|
||||
let rect = Rect::from_min_size(Pos2::new(x, y), vec2(w, h));
|
||||
let info = ViewportInfo { outer_rect: Some(rect), inner_rect: Some(rect), maximized: Some(maximized), ..Default::default() };
|
||||
input.viewports.insert(ViewportId::ROOT, info);
|
||||
}
|
||||
let app = &mut self.app;
|
||||
let mut out = self.ctx.run_ui(input, |ui| app.ui(ui));
|
||||
out.textures_delta.clear();
|
||||
let commands = out.viewport_output.get(&ViewportId::ROOT).map(|v| v.commands.as_slice()).unwrap_or_default();
|
||||
let name = |c: &egui::ViewportCommand| format!("{c:?}").split('(').next().unwrap_or_default().to_string();
|
||||
self.told.extend(commands.iter().map(name).filter(|c| ["OuterPosition", "InnerSize", "Maximized", "Fullscreen"].contains(&c.as_str())));
|
||||
}
|
||||
|
||||
/// Frames until the interface is up (the first one only installs the fonts).
|
||||
fn up(mut self) -> Self {
|
||||
for _ in 0..3 {
|
||||
self.frame(0.01);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
fn kept(&self) -> usize {
|
||||
self.asked.lock().unwrap().kept.len()
|
||||
}
|
||||
|
||||
fn windows(&self) -> Value {
|
||||
crate::windows::list(&self.app)
|
||||
}
|
||||
}
|
||||
|
||||
/// A folder of saved files named `names`, and a session with them open.
|
||||
fn open_files(test: &str, names: &[&str]) -> (std::path::PathBuf, Session, Vec<String>) {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-ui-restore-{test}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut s = Session::empty();
|
||||
let mut paths = Vec::new();
|
||||
for n in names {
|
||||
let path = dir.join(format!("{n}.figcraft")).to_string_lossy().into_owned();
|
||||
s.execute("file.new", &json!({"name": n})).unwrap();
|
||||
s.execute("file.saveAs", &json!({"path": path})).unwrap();
|
||||
paths.push(path);
|
||||
}
|
||||
(dir, s, paths)
|
||||
}
|
||||
|
||||
/// The next run: nothing open, the preferences as they were kept.
|
||||
fn next_run(prefs: &figcraft_engine::Prefs) -> Run {
|
||||
let mut s = Session::empty();
|
||||
s.prefs = serde_json::from_value(serde_json::to_value(prefs).unwrap()).unwrap();
|
||||
Run::new(s)
|
||||
}
|
||||
|
||||
fn tab(path: &str) -> SavedTab {
|
||||
SavedTab { path: Some(path.into()), ..SavedTab::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_session_is_kept_as_it_changes_and_not_while_it_doesnt() {
|
||||
let (dir, mut s, paths) = open_files("kept", &["A", "B"]);
|
||||
s.execute("file.new", &json!({"name": "Scratch"})).unwrap();
|
||||
s.execute("doc.switch", &json!({"index": 0})).unwrap();
|
||||
let mut r = Run::new(s);
|
||||
r.main = Some(([40.0, 30.0, 1300.0, 820.0], false));
|
||||
r = r.up();
|
||||
assert_eq!(r.kept(), 1, "kept at once");
|
||||
let saved = r.app.session.prefs.session.clone();
|
||||
assert_eq!(saved.windows.len(), 1);
|
||||
let w = &saved.windows[0];
|
||||
assert_eq!(
|
||||
w.tabs.iter().map(|t| t.path.clone().unwrap()).collect::<Vec<_>>(),
|
||||
paths,
|
||||
"saved files only: unsaved work is the recovery copies' job"
|
||||
);
|
||||
assert_eq!((w.front, w.rect, w.maximized), (0, Some([40.0, 30.0, 1300.0, 820.0]), false));
|
||||
assert_eq!((w.left_tab.as_str(), w.right_tab.as_str(), w.panel_widths), ("file", "design", Some([240.0, 240.0])));
|
||||
// Nothing changes: nothing is written, and nothing asks for another frame to write.
|
||||
for _ in 0..30 {
|
||||
r.frame(0.5);
|
||||
}
|
||||
assert_eq!(r.kept(), 1);
|
||||
// The tab in front, the panel and the window's place change in quick succession: one more
|
||||
// write once a second has passed since the last, with all of it.
|
||||
r.frame(5.0);
|
||||
r.app.session.execute("doc.switch", &json!({"index": 1})).unwrap();
|
||||
r.frame(0.1);
|
||||
assert_eq!(r.kept(), 2);
|
||||
r.app.ui.right_tab = RightTab::Inspect;
|
||||
r.frame(0.1);
|
||||
r.main = Some(([300.0, 200.0, 1000.0, 700.0], false));
|
||||
r.frame(0.1);
|
||||
assert_eq!(r.kept(), 2, "not on every change");
|
||||
r.frame(1.0);
|
||||
assert_eq!(r.kept(), 3);
|
||||
let last = r.asked.lock().unwrap().kept.last().cloned().unwrap();
|
||||
assert_eq!(last, r.app.session.prefs.session);
|
||||
let w = &last.windows[0];
|
||||
assert_eq!((w.front, w.right_tab.as_str(), w.rect), (1, "inspect", Some([300.0, 200.0, 1000.0, 700.0])));
|
||||
// Maximized: the state is kept, and the place it goes back to.
|
||||
r.main = Some(([0.0, 0.0, 1920.0, 1080.0], true));
|
||||
r.frame(2.0);
|
||||
let w = r.app.session.prefs.session.windows[0].clone();
|
||||
assert_eq!((w.maximized, w.rect), (true, Some([300.0, 200.0, 1000.0, 700.0])));
|
||||
assert_eq!(crate::control::handle(&mut r.app, &r.ctx, "ui.inspect", &json!({})).unwrap()["session"]["saved"]["windows"][0]["maximized"], true);
|
||||
// Quitting: the window on its way out isn't how the app was left.
|
||||
let before = r.kept();
|
||||
r.app.ui.quit = true;
|
||||
r.app.session.execute("file.close", &json!({"index": 0, "force": true})).unwrap();
|
||||
r.frame(2.0);
|
||||
assert_eq!(r.kept(), before);
|
||||
assert_eq!(r.app.session.prefs.session.windows[0].tabs.len(), 2);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_and_tabs_come_back_as_they_were_left() {
|
||||
let (dir, s, _) = open_files("windows", &["A", "B", "C"]);
|
||||
let mut r = Run::new(s).up();
|
||||
// B and C move to a second window, which shows C; the main window is left on Dev Mode.
|
||||
let w = crate::control::handle(&mut r.app, &r.ctx, "window.new", &json!({"index": 1})).unwrap()["window"].as_u64().unwrap();
|
||||
r.frame(0.1);
|
||||
let c = r.app.session.docs[2].uid;
|
||||
crate::windows::adopt(&mut r.app, w, &[c], Some(c));
|
||||
r.app.ui.right_tab = RightTab::Inspect;
|
||||
// A is zoomed in on a corner.
|
||||
r.frame(0.1);
|
||||
(r.app.session.docs[0].view.cx, r.app.session.docs[0].view.zoom) = (77.0, 3.0);
|
||||
r.app.set_panel_widths(300.0, 320.0);
|
||||
r.main = Some(([10.0, 20.0, 1100.0, 760.0], false));
|
||||
r.frame(2.0);
|
||||
let mut prefs = r.app.session.prefs.clone();
|
||||
assert_eq!(prefs.session.windows.iter().map(|w| w.tabs.len()).collect::<Vec<_>>(), [1, 2]);
|
||||
assert_eq!(prefs.session.windows[1].front, 1);
|
||||
// The second window was left maximized, over this place.
|
||||
prefs.session.windows[1].rect = Some([200.0, 150.0, 1000.0, 720.0]);
|
||||
prefs.session.windows[1].maximized = true;
|
||||
prefs.session.windows[1].right_tab = "prototype".into();
|
||||
let left = prefs.session.clone();
|
||||
|
||||
// The app quits and starts again.
|
||||
let mut r = next_run(&prefs);
|
||||
let out = r.app.run("session.restore", json!({})).unwrap();
|
||||
assert_eq!((&out["windows"], &out["opened"]), (&json!(2), &json!(3)));
|
||||
r = r.up();
|
||||
let list = r.windows();
|
||||
assert_eq!((&list[0]["files"], &list[0]["active"], &list[0]["rect"]), (&json!(["A"]), &json!("A"), &json!([10.0, 20.0, 1100.0, 760.0])));
|
||||
assert_eq!((&list[1]["files"], &list[1]["active"]), (&json!(["B", "C"]), &json!("C")));
|
||||
assert_eq!(
|
||||
(&list[1]["rect"], &list[1]["maximized"], &list[1]["fullscreen"]),
|
||||
(&json!([200.0, 150.0, 1000.0, 720.0]), &json!(true), &json!(false))
|
||||
);
|
||||
assert_eq!((r.app.ui.right_tab, r.app.ui.left_width, r.app.ui.right_width), (RightTab::Inspect, 300.0, 320.0), "the main window's panels");
|
||||
assert_eq!(r.app.windows.list[0].pane.ui.right_tab, RightTab::Prototype, "the other window's own");
|
||||
assert!(!r.app.ui.home && r.app.session.ds().unwrap().doc.name == "A");
|
||||
let view = &r.app.session.ds().unwrap().view;
|
||||
assert_eq!((view.cx, view.zoom), (77.0, 3.0), "drawn, and still where it was left (not fitted to the window again)");
|
||||
assert_eq!(r.app.session.prefs.session, left, "what is kept now is what was left");
|
||||
// The crash guard goes once the restore has settled, not before.
|
||||
assert_eq!(r.asked.lock().unwrap().marks, Vec::<bool>::new());
|
||||
r.frame(1.5);
|
||||
assert_eq!(r.asked.lock().unwrap().marks, [false]);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_off_every_screen_moves_onto_one_and_one_window_keeps_every_tab() {
|
||||
let (dir, s, paths) = open_files("screens", &["A", "B"]);
|
||||
let mut prefs = s.prefs.clone();
|
||||
prefs.session = SavedSession {
|
||||
windows: vec![
|
||||
SavedWindow {
|
||||
rect: Some([-4000.0, 100.0, 1200.0, 800.0]),
|
||||
monitor: Some([-3840.0, 0.0, 1920.0, 1080.0]),
|
||||
tabs: vec![tab(&paths[0])],
|
||||
..SavedWindow::default()
|
||||
},
|
||||
SavedWindow { rect: Some([2500.0, 300.0, 5000.0, 4000.0]), tabs: vec![tab(&paths[1])], ..SavedWindow::default() },
|
||||
],
|
||||
};
|
||||
let mut r = next_run(&prefs);
|
||||
r.app.monitors = vec![A];
|
||||
r.app.run("session.restore", json!({}));
|
||||
r = r.up();
|
||||
let list = r.windows();
|
||||
assert_eq!((&list[0]["rect"], &list[0]["monitor"]), (&json!([0.0, 100.0, 1200.0, 800.0]), &json!(A)));
|
||||
assert_eq!((&list[1]["rect"], &list[1]["files"]), (&json!(A), &json!(["B"])));
|
||||
// A build with one window (the browser): the tabs of every window, in this one.
|
||||
let mut r = next_run(&prefs);
|
||||
r.ctx.set_embed_viewports(true);
|
||||
r.app.run("session.restore", json!({}));
|
||||
r = r.up();
|
||||
let list = r.windows();
|
||||
assert_eq!((list.as_array().map(Vec::len), &list[0]["files"]), (Some(1), &json!(["A", "B"])));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gone_files_get_one_message_and_a_file_opened_since_stays_in_front() {
|
||||
let (dir, s, paths) = open_files("gone", &["A", "B", "C", "D"]);
|
||||
let mut prefs = s.prefs.clone();
|
||||
prefs.session =
|
||||
SavedSession { windows: vec![SavedWindow { tabs: paths[..3].iter().map(|p| tab(p)).collect(), front: 1, ..SavedWindow::default() }] };
|
||||
std::fs::remove_file(&paths[0]).unwrap();
|
||||
std::fs::remove_file(&paths[2]).unwrap();
|
||||
let mut r = next_run(&prefs);
|
||||
r.app.run("session.restore", json!({}));
|
||||
// The file the app was started with opens after, in front; documents recovered after a
|
||||
// crash are still announced.
|
||||
r.app.session.execute("file.open", &json!({"path": paths[3]})).unwrap();
|
||||
r.app.note_recovered(1);
|
||||
r = r.up();
|
||||
let toast = r.app.ui.toast.clone().unwrap().0;
|
||||
assert!(toast.starts_with("Recovered an unsaved file") && toast.ends_with("\n2 files couldn't be reopened"), "{toast}");
|
||||
assert_eq!(r.windows()[0]["files"], json!(["B", "D"]));
|
||||
assert_eq!(r.app.session.ds().unwrap().doc.name, "D");
|
||||
// The files that are gone stay in Open recent (greyed there), and leave the kept session.
|
||||
assert!(r.app.session.prefs.recent_files.contains(&paths[0]));
|
||||
assert_eq!(r.app.session.prefs.session.windows[0].tabs.len(), 2);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_home_screen_offers_the_last_session_with_the_crash_guard_up() {
|
||||
let (dir, s, paths) = open_files("offer", &["A", "B"]);
|
||||
let mut prefs = s.prefs.clone();
|
||||
prefs.session = SavedSession { windows: vec![SavedWindow { tabs: vec![tab(&paths[0]), tab(&paths[1])], front: 1, ..SavedWindow::default() }] };
|
||||
// A run that starts without restoring: the last session is on offer, on the home screen.
|
||||
let mut r = next_run(&prefs);
|
||||
r.app.session.restore.last = Some(r.app.session.prefs.session.clone());
|
||||
r.app.ui.home = true;
|
||||
r = r.up();
|
||||
assert!(r.app.restore_offered());
|
||||
let inspect = r.app.inspect();
|
||||
assert_eq!((&inspect["session"]["offered"], &inspect["home"]), (&json!(true), &json!(true)));
|
||||
assert!(r.app.session.prefs.session.tabs() == 0, "this run's own session is what is kept now");
|
||||
// The button: the guard is up before anything opens, and down once it settled.
|
||||
r.app.restore_last();
|
||||
assert_eq!(r.asked.lock().unwrap().marks, [true]);
|
||||
r.frame(0.1);
|
||||
assert_eq!((r.windows()[0]["files"].clone(), r.windows()[0]["active"].clone()), (json!(["A", "B"]), json!("B")));
|
||||
assert!(!r.app.ui.home && !r.app.restore_offered());
|
||||
r.frame(1.5);
|
||||
assert_eq!(r.asked.lock().unwrap().marks, [true, false]);
|
||||
assert_eq!(r.app.session.prefs.session.tabs(), 2);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_left_maximized_comes_back_maximized_over_its_place() {
|
||||
let (dir, s, paths) = open_files("maximized", &["A"]);
|
||||
let mut prefs = s.prefs.clone();
|
||||
let rect = [200.0, 120.0, 1300.0, 850.0];
|
||||
prefs.session =
|
||||
SavedSession { windows: vec![SavedWindow { rect: Some(rect), maximized: true, tabs: vec![tab(&paths[0])], ..SavedWindow::default() }] };
|
||||
// The app starts: its window opens maximized (the app asked for that), and placing it again
|
||||
// would take it out of that state.
|
||||
let mut r = next_run(&prefs);
|
||||
r.main = Some(([0.0, 0.0, 1920.0, 1040.0], true));
|
||||
r.app.run("session.restore", json!({}));
|
||||
r = r.up();
|
||||
assert_eq!(r.told, Vec::<String>::new());
|
||||
let w = r.app.session.prefs.session.windows[0].clone();
|
||||
assert_eq!((w.maximized, w.rect), (true, Some(rect)), "the place under it is still the one to go back to");
|
||||
// Restored while the app runs, from a window that isn't maximized: placed first, then maximized.
|
||||
let mut r = next_run(&prefs);
|
||||
r.main = Some(([50.0, 50.0, 1000.0, 700.0], false));
|
||||
r = r.up();
|
||||
r.app.run("session.restore", json!({"session": serde_json::to_value(&prefs.session).unwrap()}));
|
||||
r.frame(0.1);
|
||||
assert_eq!(r.told, ["OuterPosition", "InnerSize", "Maximized"]);
|
||||
// And back: a maximized window leaves that state before it is placed.
|
||||
let mut normal = prefs.session.clone();
|
||||
normal.windows[0].maximized = false;
|
||||
r.main = Some(([0.0, 0.0, 1920.0, 1040.0], true));
|
||||
r.frame(0.1);
|
||||
r.told.clear();
|
||||
r.app.run("session.restore", json!({"session": serde_json::to_value(&normal).unwrap()}));
|
||||
r.frame(0.1);
|
||||
assert_eq!(r.told, ["Maximized", "OuterPosition", "InnerSize"]);
|
||||
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![]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ use serde_json::{Value, json};
|
|||
use crate::canvas::CanvasState;
|
||||
use crate::i18n::{tr, trf};
|
||||
use crate::render_worker::Worker;
|
||||
use crate::restore::Geometry;
|
||||
use crate::{FigApp, TitleBar, UiState};
|
||||
|
||||
/// Another window: its tabs and the UI state it swaps into the app while it draws.
|
||||
|
|
@ -26,19 +25,17 @@ pub struct Window {
|
|||
/// Its open files (document uids; the tabs keep the session's order).
|
||||
pub docs: Vec<u64>,
|
||||
/// The file it shows.
|
||||
pub(crate) active: Option<u64>,
|
||||
pub(crate) pane: Pane,
|
||||
/// Where it opens, then where it is.
|
||||
pub(crate) geo: Geometry,
|
||||
/// Which window of the restored session it is: online files still on their way open in it
|
||||
/// (and keep it open until they arrive, or can't).
|
||||
pub(crate) restored: Option<usize>,
|
||||
active: Option<u64>,
|
||||
pane: Pane,
|
||||
/// Where it opens and its size, then where it is (screen points).
|
||||
pos: Option<Pos2>,
|
||||
size: Vec2,
|
||||
}
|
||||
|
||||
/// A window's own UI state.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Pane {
|
||||
pub(crate) ui: UiState,
|
||||
struct Pane {
|
||||
ui: UiState,
|
||||
canvas: CanvasState,
|
||||
worker: Option<Worker>,
|
||||
field_focus: bool,
|
||||
|
|
@ -53,8 +50,6 @@ pub struct Windows {
|
|||
pub current: u64,
|
||||
/// The window last in front: control requests without a `window` go to it.
|
||||
pub focused: u64,
|
||||
/// Where the main window is.
|
||||
pub(crate) main: Geometry,
|
||||
/// The main window's file while another window draws.
|
||||
main_active: Option<u64>,
|
||||
/// Windows to close (their files close with them), at the main window's next frame.
|
||||
|
|
@ -68,12 +63,6 @@ impl Windows {
|
|||
self.list.iter().find(|w| w.docs.contains(&uid)).map_or(0, |w| w.id)
|
||||
}
|
||||
|
||||
/// The window that is window `k` of the restored session (the main window when it is gone,
|
||||
/// or never opened).
|
||||
pub(crate) fn restored(&self, k: usize) -> u64 {
|
||||
self.list.iter().find(|w| w.restored == Some(k)).map_or(0, |w| w.id)
|
||||
}
|
||||
|
||||
pub fn exists(&self, id: u64) -> bool {
|
||||
id == 0 || self.list.iter().any(|w| w.id == id)
|
||||
}
|
||||
|
|
@ -208,51 +197,36 @@ pub fn open(app: &mut FigApp, ctx: &Context, index: Option<usize>, path: Option<
|
|||
if let Some(why) = one {
|
||||
return Err(why.into());
|
||||
}
|
||||
for w in &mut app.windows.list {
|
||||
w.docs.retain(|u| *u != uid);
|
||||
}
|
||||
let (outer, inner) = ctx.input(|i| (i.viewport().outer_rect, i.viewport().inner_rect));
|
||||
// A new window opens like the one it comes from: the same tabs and panel widths.
|
||||
let ui = UiState {
|
||||
title_bar: app.ui.title_bar,
|
||||
right_tab: app.ui.right_tab,
|
||||
left_tab: app.ui.left_tab,
|
||||
left_width: app.ui.left_width,
|
||||
right_width: app.ui.right_width,
|
||||
..UiState::default()
|
||||
};
|
||||
let geo = Geometry {
|
||||
app.windows.last_id += 1;
|
||||
let id = app.windows.last_id;
|
||||
app.windows.list.push(Window {
|
||||
id,
|
||||
docs: vec![uid],
|
||||
active: Some(uid),
|
||||
pane: Pane {
|
||||
// A new window opens like the one it comes from: the same tabs and panel widths.
|
||||
ui: UiState {
|
||||
title_bar: app.ui.title_bar,
|
||||
right_tab: app.ui.right_tab,
|
||||
left_tab: app.ui.left_tab,
|
||||
left_width: app.ui.left_width,
|
||||
right_width: app.ui.right_width,
|
||||
..UiState::default()
|
||||
},
|
||||
..Pane::default()
|
||||
},
|
||||
pos: at.or_else(|| outer.map(|r| r.min + Vec2::splat(32.0))),
|
||||
size: inner.map_or(Geometry::default().size, |r| r.size()),
|
||||
..Geometry::default()
|
||||
};
|
||||
let id = push(app, ui, geo);
|
||||
adopt(app, id, &[uid], Some(uid));
|
||||
size: inner.map_or(Vec2::new(1280.0, 800.0), |r| r.size()),
|
||||
});
|
||||
app.windows.focused = id;
|
||||
ctx.request_repaint_of(ViewportId::ROOT);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// A new window with its panels as in `ui`, at `geo`, without files yet ([`adopt`]). Returns its id.
|
||||
pub(crate) fn push(app: &mut FigApp, ui: UiState, geo: Geometry) -> u64 {
|
||||
app.windows.last_id += 1;
|
||||
let id = app.windows.last_id;
|
||||
app.windows.list.push(Window { id, docs: Vec::new(), active: None, pane: Pane { ui, ..Pane::default() }, geo, restored: None });
|
||||
id
|
||||
}
|
||||
|
||||
/// Open files `docs` become tabs of window `id` (0: the main window), which shows `front`.
|
||||
pub(crate) fn adopt(app: &mut FigApp, id: u64, docs: &[u64], front: Option<u64>) {
|
||||
for w in &mut app.windows.list {
|
||||
w.docs.retain(|u| !docs.contains(u));
|
||||
if w.id == id {
|
||||
w.docs.extend(docs);
|
||||
w.active = front.or(w.active);
|
||||
}
|
||||
}
|
||||
// The main window draws now: it shows the session's file.
|
||||
if let Some(i) = front.filter(|_| id == 0).and_then(|u| app.session.docs.iter().position(|d| d.uid == u)) {
|
||||
app.session.active = i;
|
||||
}
|
||||
}
|
||||
|
||||
/// Bring window `id` to the front; control requests go to it from now on.
|
||||
pub fn focus(app: &mut FigApp, ctx: &Context, id: u64) -> Result<Value, String> {
|
||||
if !app.windows.exists(id) {
|
||||
|
|
@ -268,9 +242,10 @@ 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);
|
||||
// The main window places restored windows when its frame begins.
|
||||
if r.kind == "session.restore" {
|
||||
ctx.request_repaint_of(ViewportId::ROOT);
|
||||
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" {
|
||||
|
|
@ -284,9 +259,7 @@ pub(crate) fn requests(app: &mut FigApp, ctx: &Context) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Every window: id, title, its files and the one it shows, where it is on screen (`rect`:
|
||||
/// `[x, y, width, height]` while neither maximized nor full screen) and the screen it is on
|
||||
/// (`window.list`).
|
||||
/// Every window: id, title, its files and the one it shows (`window.list`).
|
||||
pub fn list(app: &FigApp) -> Value {
|
||||
let active_of = |id: u64| -> Option<u64> {
|
||||
if id == app.windows.current {
|
||||
|
|
@ -302,19 +275,14 @@ pub fn list(app: &FigApp) -> Value {
|
|||
ids.map(|id| {
|
||||
let mine = |d: &&DocState| app.windows.of(d.uid) == id;
|
||||
let shown = app.session.docs.iter().filter(mine).find(|d| Some(d.uid) == active_of(id));
|
||||
let geo = app.windows.list.iter().find(|w| w.id == id).map_or(&app.windows.main, |w| &w.geo);
|
||||
let mut out = json!({
|
||||
json!({
|
||||
"id": id,
|
||||
"main": id == 0,
|
||||
"title": title_of(shown),
|
||||
"files": app.session.docs.iter().filter(mine).map(|d| d.doc.name.as_str()).collect::<Vec<_>>(),
|
||||
"active": shown.map(|d| d.doc.name.as_str()),
|
||||
"focused": id == app.windows.focused,
|
||||
});
|
||||
if let (Some(out), Value::Object(geo)) = (out.as_object_mut(), geo.json(&app.monitors)) {
|
||||
out.extend(geo);
|
||||
}
|
||||
out
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
|
|
@ -376,8 +344,6 @@ pub(crate) fn confirm_close(app: &mut FigApp, ctx: &Context) {
|
|||
/// (a window left with none closes) and the main window shows one of its own files.
|
||||
pub(crate) fn begin(app: &mut FigApp, ctx: &Context) {
|
||||
app.windows.current = 0;
|
||||
app.windows.main.track(ctx);
|
||||
crate::restore::begin(app, ctx);
|
||||
for id in std::mem::take(&mut app.windows.closing) {
|
||||
if id == 0 && app.windows.list.is_empty() {
|
||||
app.ui.quit = true;
|
||||
|
|
@ -397,10 +363,10 @@ pub(crate) fn begin(app: &mut FigApp, ctx: &Context) {
|
|||
// The main window takes over another window: its files, panels and place on screen.
|
||||
let mut w = app.windows.list.remove(0);
|
||||
exchange(app, &mut w.pane);
|
||||
if let Some(p) = w.geo.pos {
|
||||
if let Some(p) = w.pos {
|
||||
ctx.send_viewport_cmd_to(ViewportId::ROOT, ViewportCommand::OuterPosition(p));
|
||||
}
|
||||
ctx.send_viewport_cmd_to(ViewportId::ROOT, ViewportCommand::InnerSize(w.geo.size));
|
||||
ctx.send_viewport_cmd_to(ViewportId::ROOT, ViewportCommand::InnerSize(w.size));
|
||||
app.windows.focused = 0;
|
||||
show_own(app, w.active);
|
||||
}
|
||||
|
|
@ -408,9 +374,7 @@ pub(crate) fn begin(app: &mut FigApp, ctx: &Context) {
|
|||
for w in &mut app.windows.list {
|
||||
w.docs.retain(|u| open.contains(u));
|
||||
}
|
||||
// A restored window whose online files are still on their way waits for them.
|
||||
let waiting: Vec<usize> = app.session.restore.joining().map(|(k, _)| k).collect();
|
||||
app.windows.list.retain(|w| !w.docs.is_empty() || w.restored.is_some_and(|k| waiting.contains(&k)));
|
||||
app.windows.list.retain(|w| !w.docs.is_empty());
|
||||
if !app.windows.exists(app.windows.focused) {
|
||||
app.windows.focused = 0;
|
||||
}
|
||||
|
|
@ -450,14 +414,8 @@ pub(crate) fn repaint_others(app: &FigApp, ctx: &Context) {
|
|||
pub(crate) fn show(app: &FigApp, ctx: &Context) {
|
||||
for w in &app.windows.list {
|
||||
let title = title_of(app.session.docs.iter().find(|d| Some(d.uid) == w.active));
|
||||
let mut b = ViewportBuilder::default()
|
||||
.with_title(title)
|
||||
.with_inner_size(w.geo.size)
|
||||
.with_min_inner_size([800.0, 500.0])
|
||||
.with_maximized(w.geo.maximized)
|
||||
.with_fullscreen(w.geo.fullscreen)
|
||||
.with_drag_and_drop(true);
|
||||
if let Some(p) = w.geo.pos {
|
||||
let mut b = ViewportBuilder::default().with_title(title).with_inner_size(w.size).with_min_inner_size([800.0, 500.0]).with_drag_and_drop(true);
|
||||
if let Some(p) = w.pos {
|
||||
b = b.with_position(p);
|
||||
}
|
||||
b = match w.pane.ui.title_bar {
|
||||
|
|
@ -507,10 +465,12 @@ fn draw(app: &mut FigApp, id: u64, ui: &mut egui::Ui) {
|
|||
repaint_others(app, &ctx);
|
||||
}
|
||||
let shown = app.session.ds().ok().map(|d| d.uid).filter(|u| app.windows.of(*u) == id);
|
||||
let (outer, inner) = ctx.input(|i| (i.viewport().outer_rect, i.viewport().inner_rect));
|
||||
exchange(app, &mut pane);
|
||||
if let Some(w) = app.windows.list.iter_mut().find(|w| w.id == id) {
|
||||
w.active = shown.or(w.active);
|
||||
w.geo.track(&ctx);
|
||||
w.pos = outer.map(|r| r.min).or(w.pos);
|
||||
w.size = inner.map_or(w.size, |r| r.size());
|
||||
w.pane = pane;
|
||||
}
|
||||
app.windows.current = 0;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -133,12 +136,6 @@ live sessions (`collab.*`: without a key on a server open to the public, with `c
|
|||
`collab.manageFile` for the owner token and `collab.report`) and use plugin directories
|
||||
(`plugin.directory.*`); the one-shot `run` can't (no network link).
|
||||
|
||||
The desktop app's own command line is `figcraft [--control PORT] [--fresh] [files…]
|
||||
[figcraft://join?…]`: files and an invite link open in front of the windows and tabs from last
|
||||
time, which open again by themselves (Preferences ▸ Reopen windows and tabs from last time);
|
||||
`--fresh` starts without them once, leaving the preference as it is (`session.restore` still
|
||||
brings them back in that run).
|
||||
|
||||
### `plugin publish <folder> --server ws://host:port/[?key=KEY]`
|
||||
|
||||
Publishes a plugin folder (`figcraft-plugin.json`, the module its `main` names, an optional `assets/`
|
||||
|
|
|
|||
|
|
@ -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-13 · **Change:** minor (branch history entries for a branch's creation and updates from main; 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,13 +12,13 @@ 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 |
|
||||
| Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences; a behaviour suite from the documentation (pinning and following layers, threads, own edits and deletes, mentions, reactions, C and Shift+C, stepping, filters, saving, copy and paste); restoring a version keeps them. In a live session threads sync and anyone in it can reply, react and resolve (no accounts: a name is who you are) | (shared once multiplayer lands) |
|
||||
| Version history: named versions, autosave checkpoints, view before restore, restore, duplicate | yes (cloud) | **done**, saved in the `.figcraft` file: restoring keeps the replaced design as an autosave first, a titled autosave becomes a named version, merging a branch records the file before and after. Versions are per person in a live session (a shared history needs accounts); no comparing two versions (Dev Mode compares a ready-for-dev layer with its state when marked) | — |
|
||||
| Branching and merging | yes (paid plans) | **done, local**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived), which starts with an entry for its creation and gets one each time it is updated from main; fills, strokes and effects merge item by item. Left: branches in live sessions; other lists (text runs, export settings) conflict as a whole | 1–2 |
|
||||
| Branching and merging | yes (paid plans) | **done, local**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived); fills, strokes and effects merge item by item. Left: branches in live sessions; other lists (text runs, export settings) conflict as a whole | 1–2 |
|
||||
| Team / organization libraries | yes | **partial**: a file's library is published to `figcraft-server` under the file and enabled in other files by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names ([components-parity](components-parity.md), [server.md](server.md#team-libraries)). No team-wide library list, accounts or analytics over time | 4–8 |
|
||||
| Notifications, activity | yes | no | with sharing |
|
||||
|
||||
|
|
@ -26,7 +26,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | Branch history entries (gap 17): a branch's version history starts with Branch created (the branch as it began) and gets Updated from main each time it takes main's changes (the branch right after, also when its tab is closed); they are versions like any other (viewed, restored, saved with the file, never pruned as autosaves) and stay out of the main file's history. Numbers 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-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 |
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue