mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-30 05:16:56 -06:00
Merge main into #1460
This commit is contained in:
@@ -40,3 +40,9 @@ egui's default fonts (compiled into the `egui`/`epaint` crates) keep their own l
|
||||
| Asset | Author | Source | Licence | Use |
|
||||
|---|---|---|---|---|
|
||||
| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 6d518fc7](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset, included by default_fonts independently of monochrome_emoji_fonts; emoji-icon-font.ttf is the full monochrome fallback. Dependency assets unmodified here; no font file copied into this repository. |
|
||||
|
||||
## Arabic interface catalog
|
||||
|
||||
| Path | Contributor | Source | Licence | Review |
|
||||
|---|---|---|---|---|
|
||||
| `crates/ui-egui/locales/ar.toml` | @hussain-hz with OpenAI Codex (AI-assisted) | Original Arabic wording from FigCraft's English catalog and engine command labels; no proprietary localisation consulted | MIT OR Apache-2.0 | Draft; native-speaker review and maintainer approval pending |
|
||||
|
||||
@@ -62,6 +62,7 @@ code.
|
||||
- No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!` in shipped code
|
||||
(workspace clippy lints deny them; tests may use them). Return `Result`/`Option`; use `.get()` for
|
||||
data-derived indexes; slice strings only at char boundaries; check floats are finite; cap sizes.
|
||||
- New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes before modules or other crate items; crate-level `//!` documentation may precede them.
|
||||
- `figcraft_engine::guard` turns a panicking command into `EngineError::Internal` and the document
|
||||
is rolled back; the desktop app also guards each UI frame and writes recovery copies. These are
|
||||
safety nets, not substitutes for `Result`.
|
||||
|
||||
Generated
+103
-30
@@ -253,7 +253,7 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
@@ -972,6 +972,40 @@ dependencies = [
|
||||
"writeable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-keymap"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-menu"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"craft-keymap",
|
||||
"craft-quit",
|
||||
"egui",
|
||||
"muda",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-quit"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"dispatch2",
|
||||
"egui",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
@@ -1034,6 +1068,15 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.8"
|
||||
@@ -1526,7 +1569,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1593,9 +1636,10 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
|
||||
|
||||
[[package]]
|
||||
name = "figcraft"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"craft-menu",
|
||||
"craft-update",
|
||||
"craft-window",
|
||||
"eframe",
|
||||
@@ -1621,7 +1665,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-cli"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-engine",
|
||||
@@ -1635,7 +1679,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-collab"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
@@ -1646,12 +1690,13 @@ dependencies = [
|
||||
"postcard",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-collab-net"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-collab",
|
||||
@@ -1674,7 +1719,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-color"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1683,7 +1728,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-doc"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-geom",
|
||||
@@ -1695,7 +1740,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-engine"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"craft-docpath",
|
||||
@@ -1727,7 +1772,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-format"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-pathops",
|
||||
@@ -1743,7 +1788,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-geom"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1751,7 +1796,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-layout"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1759,7 +1804,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-mcp"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-collab-net",
|
||||
@@ -1775,7 +1820,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-pathops"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1784,7 +1829,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-pdf"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-doc",
|
||||
@@ -1801,7 +1846,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-plugin"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"serde",
|
||||
@@ -1814,7 +1859,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-render"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"figcraft-color",
|
||||
@@ -1832,7 +1877,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-server"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"ctrlc",
|
||||
@@ -1856,7 +1901,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-svg"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-color",
|
||||
@@ -1872,7 +1917,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-text"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"core-text",
|
||||
"figcraft-doc",
|
||||
@@ -1888,7 +1933,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-ui-egui"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
@@ -1916,7 +1961,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-vectordrawable"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-doc",
|
||||
@@ -1930,7 +1975,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-web"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"eframe",
|
||||
@@ -2865,6 +2910,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyboard-types"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "khronos-egl"
|
||||
version = "6.0.0"
|
||||
@@ -3077,6 +3132,24 @@ dependencies = [
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "muda"
|
||||
version = "0.21.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"dpi",
|
||||
"keyboard-types",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation 0.3.2",
|
||||
"png",
|
||||
"thiserror 2.0.21",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "naga"
|
||||
version = "30.0.1"
|
||||
@@ -3575,7 +3648,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4249,7 +4322,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4306,7 +4379,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4869,10 +4942,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5992,7 +6065,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6422,7 +6495,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"figcraft-engine",
|
||||
"serde_json",
|
||||
|
||||
+8
-2
@@ -6,7 +6,7 @@ exclude = ["plugins"]
|
||||
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -44,6 +44,9 @@ craft-window = { git = "https://github.com/storytold/craft-libs", rev = "92d392c
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
|
||||
# Shared dropdown menus: a combo box whose menu grows back after shrinking (craft-libs).
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
# The macOS app menu whose Quit (⌘Q) asks the app before quitting, like the window's close button
|
||||
# (craft-libs; the menu it replaces quits straight through `terminate:` and loses unsaved work).
|
||||
craft-menu = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
|
||||
# foundation
|
||||
kurbo = { version = "0.13", features = ["serde"] }
|
||||
@@ -102,6 +105,9 @@ webbrowser = "1"
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
cognitive_complexity = "deny"
|
||||
too_many_arguments = "deny"
|
||||
wildcard_imports = "deny"
|
||||
dbg_macro = "warn"
|
||||
# Style lints from newer toolchains that fight index-heavy pixel code.
|
||||
needless_range_loop = "allow"
|
||||
@@ -130,7 +136,7 @@ lto = "thin"
|
||||
codegen-units = 4
|
||||
|
||||
# The browser build only (apps/figcraft-web/index.html: data-cargo-profile). Optimised for size:
|
||||
# packaging/web/package.sh fails a .wasm over 24 MiB (Cloudflare's per-file cap is 25 MiB).
|
||||
# packaging/web/package.sh fails a .wasm over 25 MiB - 512 KiB (Cloudflare's per-file cap is 25 MiB).
|
||||
# wasm32-unknown-unknown always aborts on panic, so panic = "abort" changes nothing there.
|
||||
[profile.web-release]
|
||||
inherits = "release"
|
||||
|
||||
+12
-5
@@ -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-26 · **Change:** minor (colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
|
||||
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
|
||||
@@ -63,7 +63,7 @@ native speakers' reviews of the nine language drafts need people.
|
||||
| Stability | 55% | 20–40 | [gaps](docs/gaps.md) |
|
||||
| Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) |
|
||||
| Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) |
|
||||
| Localization | 39% (layer; English; Russian and Turkish reviewed by native speakers; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) |
|
||||
| Localization | 39% (layer; English; Russian and Turkish reviewed by native speakers; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; CJK UI fonts); Arabic draft: 2,508 entries, native review and panel mirroring pending | 29–51 | [localization-parity](docs/localization-parity.md) |
|
||||
| Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) |
|
||||
| Ecosystem / plugins | 40% (own WASM plugins with panels, settings and widgets that take clicks, text and a property menu; a self-hosted plugin directory) | 6–22 | [gaps](docs/gaps.md) |
|
||||
| AI features | 0% | 30–60 | [gaps](docs/gaps.md) |
|
||||
@@ -103,7 +103,7 @@ native speakers' reviews of the nine language drafts need people.
|
||||
| Simplified Chinese | zh-Hans | 95% | draft (needs native review) |
|
||||
| Spanish | es, es-419 | 100% (es-419: an overlay of 101 entries) | draft (needs native review) |
|
||||
| Hindi | hi | 0% | none |
|
||||
| Arabic | ar | 0% | none |
|
||||
| Arabic | ar | 2,508 / 2,508 entries (100%, counted 2026-10-28) | draft; native review and panel mirroring pending |
|
||||
| French | fr | 95% | draft (needs native review) |
|
||||
| Portuguese (Brazil) | pt-BR | 95% | draft (needs native review) |
|
||||
| Indonesian | id | 99% | draft (needs native review) |
|
||||
@@ -114,7 +114,7 @@ native speakers' reviews of the nine language drafts need people.
|
||||
|
||||
Percentages measured 2026-10-17 over 2,272 entries (strings added since a draft show English). Also shipped: Russian (`ru`, 99%, reviewed by VanTan), Swedish (`sv`, 99.6%, command labels since 2026-10-17; native review welcome), Turkish (`tr`, 99%) and Czech (`cs`, 99%, draft). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
|
||||
(Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America),
|
||||
French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Vietnamese drafts (Preferences ▸
|
||||
French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian, Vietnamese and Arabic drafts (Preferences ▸
|
||||
Language; Traditional Chinese has no catalog; CJK UI
|
||||
text uses an installed CJK font, loaded only when needed; adding a language:
|
||||
[`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping,
|
||||
@@ -127,11 +127,14 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
1. `.fig` import's tail: fixes from real files, connector labels, table cells, filled areas with their own fills (the best-effort import, editable vector networks, the other layer kinds and variable prototype actions landed 2026-10-11): 5–10 h; then checking the rest of the experimental `.fig` export (landed 2026-10-12; written files open and draw in other software) on effects, images, prototypes and real files: 5–15 h.
|
||||
2. Real-file corpus and measured fidelity: 15–30 h.
|
||||
3. Tool-by-tool interaction pass against Figma: 40–60 h.
|
||||
4. Languages: native reviews of the es (with es-419), fr, de, pt-BR, ja, ko, zh-Hans, id and cs drafts (people), the entries added since each catalog was written, then Hindi, Vietnamese and Arabic ([localization-parity](docs/localization-parity.md)).
|
||||
4. Languages: native reviews of the es (with es-419), fr, de, pt-BR, ja, ko, zh-Hans, id and cs drafts (people), the entries added since each catalog was written, then Hindi and Vietnamese; Arabic native review and panel mirroring ([localization-parity](docs/localization-parity.md)).
|
||||
5. Multiplayer (M9): accounts and teams on top of the live sessions and the standalone server (transport, hosting, invite links that open the app, document sync, presence with remote text carets, cursor chat and spotlight, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, the browser client, `figcraft-server` with persistence and share links to edit or view, team libraries on the server landed): 42–84 h.
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-28** · M3, #922: an instance scaled with the Scale tool (or `transform.scale`) keeps its layers' scaled size and points when its main is rebuilt. The scaled layers' new size and place were read as what constraints give (an ancestor resized in the same edit), so no override was kept, and adding a layer to the main put a 14 px icon's 24 px triangle back. The Scale tool now records them as `size` and `position` overrides on layers inside instances; strokes already kept theirs. The report's own sequence (clearing effects on the component holding the icon) no longer reproduces on main. Regressions for a scaled instance, one scaled with the tool (with undo) and a scaled icon nested in a component and its instances. Numbers unchanged.
|
||||
- **2026-10-28** · M6, #1059: `.fig` import copies each image carried inside the message once per blob, shared by every hash that names it, instead of once per hash: a file pointing many hashes at one large blob no longer multiplies it in memory (100 hashes on a 250 MB blob were 25 GB). Regression test. Numbers unchanged.
|
||||
- **2026-10-28** · M1, #1476: page ruler guides survive save and reopen. A page kept its guides in the page kind while the layer's own `guides` field (frame guides, #70) took that key when a file was read, so a reopened page's guides vanished, and saving it wrote `guides` twice, which then refused to open. Every layer now keeps its guides in the one field, and a page with two `guides` lists opens with both. Regressions in the doc and format crates. Numbers unchanged.
|
||||
- **2026-10-26** · Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` runs the name through `deunicode` before its ASCII filter, so 로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` in the HTML/CSS/React/Leptos class names, component roots and `data-*` variant attributes (SwiftUI/Compose type names unchanged); a name left with nothing (symbols only) takes the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. New dependency: deunicode (pure Rust tables, zero deps, BSD-3-Clause, the `slug` crate's base). `html_codegen_tests`. Numbers unchanged.
|
||||
- **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged.
|
||||
- **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change.
|
||||
@@ -348,6 +351,10 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-28 | minor | Arabic draft: 1,857 UI strings, 620 command labels, 31 menu wordings; count-neutral plurals and web font gate. Native review and panel mirroring remain open; grade and estimates unchanged. Evidence: `crates/ui-egui/locales/ar.toml`, i18n catalog tests and [translation notes](docs/translating.md#arabic-draft) |
|
||||
| 2026-10-28 | minor | M3, #922: instances scaled with the Scale tool keep their layers' scaled size and points when their main is rebuilt (`size`/`position` overrides recorded on scale). Numbers unchanged. |
|
||||
| 2026-10-28 | minor | M6, #1059: `.fig` import copies embedded image blobs once per blob, not once per hash naming them. Numbers unchanged. |
|
||||
| 2026-10-28 | minor | M1, #1476: page ruler guides survive save and reopen; files written with a page's `guides` key twice open again with both lists. Numbers unchanged. |
|
||||
| 2026-10-26 | minor | Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` transliterates through the new `deunicode` dependency (pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`) in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes; symbols-only names take the node's kind (`.text`) instead of `layer-`, and the 40-character cut lands on a word boundary. `html_codegen_tests` updated and extended. Numbers unchanged |
|
||||
| 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. |
|
||||
| 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged |
|
||||
|
||||
@@ -64,6 +64,16 @@ fn is_code(shown: &str) -> bool {
|
||||
shown.len() == 11 && matches!(code::parse(shown), Ok(code::Code::Online(_)))
|
||||
}
|
||||
|
||||
/// The links file keeps hashes only, never the codes or the token they wrap.
|
||||
fn only_hashes_on_disk(dir: &std::path::Path, file: &str, wrapped: &str, edit: &str, view: &str) {
|
||||
let links = std::fs::read_to_string(dir.join("files").join(file).join("links.json")).unwrap();
|
||||
for secret in [edit, view, &edit.replace('-', ""), &view.replace('-', "")] {
|
||||
assert!(!links.contains(secret), "{links}");
|
||||
}
|
||||
assert!(!links.contains(wrapped.rsplit('=').next().unwrap()), "the wrapped token isn't the token");
|
||||
assert_eq!(links.matches("\"code\"").count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_shared_online_is_joined_by_its_codes() {
|
||||
let dir = TempDir::new("codes-share");
|
||||
@@ -90,12 +100,7 @@ fn a_file_shared_online_is_joined_by_its_codes() {
|
||||
let before_stop = ids(dir.path());
|
||||
assert_eq!(before_stop.len(), 4);
|
||||
// On disk: hashes only, of the codes as of the links' tokens.
|
||||
let links = std::fs::read_to_string(dir.path().join("files").join(&p.file).join("links.json")).unwrap();
|
||||
for secret in [edit.as_str(), view.as_str(), &edit.replace('-', ""), &view.replace('-', "")] {
|
||||
assert!(!links.contains(secret), "{links}");
|
||||
}
|
||||
assert!(!links.contains(p.edit.rsplit('=').next().unwrap()), "the wrapped token isn't the token");
|
||||
assert_eq!(links.matches("\"code\"").count(), 2);
|
||||
only_hashes_on_disk(dir.path(), &p.file, &p.edit, &edit, &view);
|
||||
// Someone types the edit code, the way people type.
|
||||
let mut b = app("Bo", &url);
|
||||
assert_eq!(ex(&mut b, "collab.join", json!({"code": edit.to_lowercase().replace('-', " ")}))["status"], "connecting");
|
||||
|
||||
@@ -41,6 +41,36 @@ fn answer(raw: &mut Raw) -> LibraryReply {
|
||||
}
|
||||
}
|
||||
|
||||
/// Junk from an edit link is refused and the library unharmed; a wrong token isn't let in.
|
||||
fn junk_is_refused(addr: std::net::SocketAddr, edit: &Invite) {
|
||||
let mut raw = Raw::connect(addr);
|
||||
raw.send(&open(edit));
|
||||
assert_eq!(answer(&mut raw), LibraryReply::Ready { access: Access::Edit });
|
||||
raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: None, usage: None }));
|
||||
let LibraryReply::Library { version: 2, archive } = answer(&mut raw) else { panic!("no library") };
|
||||
let mut rng = Rng::new(5);
|
||||
for round in 0..12 {
|
||||
let mut junk = if round % 3 == 0 { vec![0x5A; 1 + rng.below(4096)] } else { archive.clone() };
|
||||
for _ in 0..1 + rng.below(8) {
|
||||
let i = rng.below(junk.len());
|
||||
junk[i] = rng.next_u64() as u8;
|
||||
}
|
||||
raw.send(&ClientMsg::Library(LibraryReq::Publish { archive: junk }));
|
||||
assert!(matches!(answer(&mut raw), LibraryReply::Failed { .. }), "round {round}");
|
||||
}
|
||||
let flood = Usage { file: "\u{7}Spam\n".repeat(100), components: (0..5_000).map(|i| (NodeId(i), 1)).collect() };
|
||||
raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: Some(2), usage: Some(flood) }));
|
||||
assert_eq!(answer(&mut raw), LibraryReply::Unchanged { version: 2 });
|
||||
raw.send(&ClientMsg::Library(LibraryReq::Usage));
|
||||
let LibraryReply::Usage(files) = answer(&mut raw) else { panic!("no usage") };
|
||||
let spam = files.iter().find(|f| f.usage.file.starts_with("Spam")).unwrap();
|
||||
assert!(spam.usage.file.chars().count() <= 256 && !spam.usage.file.contains('\n'), "names are cleaned and capped");
|
||||
assert_eq!(spam.usage.components.len(), 1_000, "reports are capped");
|
||||
let mut raw = Raw::connect(addr);
|
||||
raw.send(&open(&Invite { token: "nope".into(), ..edit.clone() }));
|
||||
assert!(matches!(raw.next(), ServerMsg::Refused { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_enable_update_and_usage() {
|
||||
let dir = TempDir::new("library");
|
||||
@@ -106,34 +136,8 @@ fn publish_enable_update_and_usage() {
|
||||
raw.send(&ClientMsg::Library(LibraryReq::Publish { archive: vec![1, 2, 3] }));
|
||||
assert!(matches!(answer(&mut raw), LibraryReply::Failed { reason } if reason.contains("only view")));
|
||||
|
||||
// Junk from an edit link: refused, the library unharmed. A wrong token isn't let in.
|
||||
let edit = Invite::parse(&p.edit).unwrap();
|
||||
let mut raw = Raw::connect(addr);
|
||||
raw.send(&open(&edit));
|
||||
assert_eq!(answer(&mut raw), LibraryReply::Ready { access: Access::Edit });
|
||||
raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: None, usage: None }));
|
||||
let LibraryReply::Library { version: 2, archive } = answer(&mut raw) else { panic!("no library") };
|
||||
let mut rng = Rng::new(5);
|
||||
for round in 0..12 {
|
||||
let mut junk = if round % 3 == 0 { vec![0x5A; 1 + rng.below(4096)] } else { archive.clone() };
|
||||
for _ in 0..1 + rng.below(8) {
|
||||
let i = rng.below(junk.len());
|
||||
junk[i] = rng.next_u64() as u8;
|
||||
}
|
||||
raw.send(&ClientMsg::Library(LibraryReq::Publish { archive: junk }));
|
||||
assert!(matches!(answer(&mut raw), LibraryReply::Failed { .. }), "round {round}");
|
||||
}
|
||||
let flood = Usage { file: "\u{7}Spam\n".repeat(100), components: (0..5_000).map(|i| (NodeId(i), 1)).collect() };
|
||||
raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: Some(2), usage: Some(flood) }));
|
||||
assert_eq!(answer(&mut raw), LibraryReply::Unchanged { version: 2 });
|
||||
raw.send(&ClientMsg::Library(LibraryReq::Usage));
|
||||
let LibraryReply::Usage(files) = answer(&mut raw) else { panic!("no usage") };
|
||||
let spam = files.iter().find(|f| f.usage.file.starts_with("Spam")).unwrap();
|
||||
assert!(spam.usage.file.chars().count() <= 256 && !spam.usage.file.contains('\n'), "names are cleaned and capped");
|
||||
assert_eq!(spam.usage.components.len(), 1_000, "reports are capped");
|
||||
let mut raw = Raw::connect(addr);
|
||||
raw.send(&open(&Invite { token: "nope".into(), ..edit.clone() }));
|
||||
assert!(matches!(raw.next(), ServerMsg::Refused { .. }));
|
||||
junk_is_refused(addr, &edit);
|
||||
assert_eq!(ex(&mut b, "library.list", json!({}))["libraries"][0]["version"], 2);
|
||||
|
||||
// Kept on disk: the library is there after the server stops.
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::journal::flush_log;
|
||||
use super::*;
|
||||
use super::{
|
||||
Access, Action, Admin, AdminReply, Admission, BUSY_DOWNLOADS, Backend, ClientMsg, Closing, Handled, HostInfo, Instant, IpAddr, LibraryReply,
|
||||
LibraryReq, OpenFile, PluginReply, PluginReq, Server, Store, disk, now, same_secret,
|
||||
};
|
||||
|
||||
impl Backend for Store {
|
||||
fn admit(&mut self, file: &str, token: &str) -> Result<Admission, String> {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Access, Action, AdminReply, BLOCKED, Change, Entry, FILES, FileInfo, FileRow, HostInfo, Instant, IpAddr, KEY_ONLY, LINKS, LOG, LOG_HEADER,
|
||||
MAX_BLOCKED, MAX_OWNED, MAX_REPORTS, META, Meta, NewLink, OpenFile, OwnedFile, REPORTS, Replica, ReportInfo, SNAPSHOT, SWEEP_EVERY, Server,
|
||||
Store, clean_name, clean_reason, disk, fs, hash, ip_blocks, ip_key, new_link, now, same_secret, token, write_json,
|
||||
};
|
||||
|
||||
impl Store {
|
||||
/// Keep a document (its snapshot) as a new file with an edit link and a view link.
|
||||
@@ -222,7 +226,7 @@ impl Store {
|
||||
pub fn break_log(&mut self, file: &str) {
|
||||
let path = self.dir(file).join(LOG);
|
||||
if let Some(log) = self.files.get_mut(file).and_then(|e| e.log.as_mut())
|
||||
&& let Ok(f) = File::open(path)
|
||||
&& let Ok(f) = std::fs::File::open(path)
|
||||
{
|
||||
log.file = f;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
FORCED_COMPACTION_EVERY, Instant, LOG, LOG_HEADER, Log, MAX_PROBLEMS, META, OpenOptions, RewriteError, SNAPSHOT, Server, Store, Write, disk, fs,
|
||||
now, write_json,
|
||||
};
|
||||
|
||||
impl Store {
|
||||
/// Whether `file` has room for about `n` more bytes: its cap, and for a file published
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Action, BUSY_DOWNLOADS, Cursor, FILES, FileUsage, Instant, IpAddr, LIBRARY, LibraryMeta, LibraryReply, MAX_FIRST_LIBRARY_VERSION, MAX_REPORTED,
|
||||
MAX_USAGE_FILE, MAX_USERS, META, NO_PUBLIC_LIBRARY, Path, PathBuf, Store, USAGE, USAGE_WRITE_EVERY, Usage, UsageCache, clean_name, disk, fs, now,
|
||||
write_json,
|
||||
};
|
||||
|
||||
impl Store {
|
||||
/// A file's team library for `ip`, unless its version is `have`; within what one address and
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Access, Action, CODE_GONE, CODES_BUSY, Entry, FileInfo, Instant, IpAddr, LINKS, LinkInfo, MAX_GONE_CODES, NO_CODE, NewLink, OpenFile, Sealed,
|
||||
Store, clean_name, codes, fs, hash, new_link, now, same_secret, write_json,
|
||||
};
|
||||
|
||||
impl Store {
|
||||
/// The files kept.
|
||||
|
||||
@@ -48,6 +48,10 @@ zbus = { version = "5", default-features = false, features = ["async-io", "block
|
||||
# unsafe, which the workspace forbids). Pinned exactly: review the diff before bumping.
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
fmv-macos-events = "=0.1.0"
|
||||
# A macOS app menu whose Quit (⌘Q) asks the app first (sends the root window a close request, the
|
||||
# same guarded path as the close button); winit's default Quit ends the process through
|
||||
# `terminate:`, losing unsaved changes without a word (issue #857).
|
||||
craft-menu = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
wat = { workspace = true }
|
||||
|
||||
@@ -271,6 +271,15 @@ fn main() -> eframe::Result {
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
with_app(|fig| fig.services.set_egui_context(&cc.egui_ctx));
|
||||
// winit's own Quit (⌘Q) ends the process through `terminate:` without ever asking the
|
||||
// app, so unsaved changes are lost with no warning (issue #857). This menu's Quit
|
||||
// sends the root window a close request instead, and the unsaved-changes question
|
||||
// asks just as it does for the close button. Failure keeps winit's menu: the app
|
||||
// runs, but ⌘Q ends it without asking.
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Err(e) = craft_menu::mac::install_app_menu(&cc.egui_ctx, "FigCraft") {
|
||||
log::warn!("app menu: kept winit's Quit, which ends the app without asking: {e}");
|
||||
}
|
||||
Ok(Box::new(App {
|
||||
queue,
|
||||
ctx_slot,
|
||||
|
||||
@@ -7,3 +7,4 @@ too-many-arguments-threshold = 10
|
||||
disallowed-types = [
|
||||
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
|
||||
]
|
||||
cognitive-complexity-threshold = 30
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
//! target: a [`Driver`] reacts to socket events and to the clock the way the native guest's
|
||||
//! threads do — hello on open (resuming as the same client after a drop), presence held until the
|
||||
//! welcome and changes made for an earlier connection dropped (the sync client sends again what
|
||||
//! the server didn't get), presence throttled to about 30 a second (latest wins), a ping when quiet, a connection that goes silent counted as
|
||||
//! dropped, and reconnects with growing pauses after a live connection drops. The browser layer
|
||||
//! (`web.rs`) feeds it the WebSocket's events and wakes it at [`Driver::deadline`].
|
||||
//! the server didn't get), presence throttled to about 30 a second (latest wins), a ping every
|
||||
//! keep-alive interval, a connection that goes silent counted as dropped, and reconnects with
|
||||
//! growing pauses after a live connection drops. The browser layer (`web.rs`) feeds it the
|
||||
//! WebSocket's events and wakes it at [`Driver::deadline`].
|
||||
//!
|
||||
//! A join by code ([`GuestConfig::code`]) first asks the server which file the code opens on a
|
||||
//! connection of its own, then joins that file as with a link; a server that doesn't know the
|
||||
@@ -36,13 +37,15 @@ struct Conn {
|
||||
opened: bool,
|
||||
welcomed: bool,
|
||||
seen: Seen,
|
||||
last_sent: Instant,
|
||||
/// The last ping (or the connection's start): one goes out every keep-alive interval, whatever
|
||||
/// else is sent, since presence gets no answer and only what the host sends counts as heard.
|
||||
last_ping: Instant,
|
||||
last_heard: Instant,
|
||||
}
|
||||
|
||||
impl Conn {
|
||||
fn new(now: Instant) -> Self {
|
||||
Self { started: now, opened: false, welcomed: false, seen: Seen::default(), last_sent: now, last_heard: now }
|
||||
Self { started: now, opened: false, welcomed: false, seen: Seen::default(), last_ping: now, last_heard: now }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +109,7 @@ impl<S: Socket> Driver<S> {
|
||||
Some(code) => ClientMsg::Resolve(Resolve { protocol: PROTOCOL, client_version: message::client_version(), code: code.clone() }),
|
||||
None => hello(&self.cfg, &self.resume),
|
||||
};
|
||||
if !self.write(&hello, now) {
|
||||
if !self.write(&hello) {
|
||||
self.end(Outcome::Retry("the connection dropped".into()), now);
|
||||
}
|
||||
}
|
||||
@@ -173,7 +176,7 @@ impl<S: Socket> Driver<S> {
|
||||
return;
|
||||
}
|
||||
if let Some(p) = self.throttle.offer(p, now)
|
||||
&& !self.write(&ClientMsg::Presence(p), now)
|
||||
&& !self.write(&ClientMsg::Presence(p))
|
||||
{
|
||||
self.end(Outcome::Retry("the connection dropped".into()), now);
|
||||
}
|
||||
@@ -186,7 +189,7 @@ impl<S: Socket> Driver<S> {
|
||||
if !self.conn.as_ref().is_some_and(|c| c.welcomed) {
|
||||
return;
|
||||
}
|
||||
if !self.write(&msg, now) {
|
||||
if !self.write(&msg) {
|
||||
self.end(Outcome::Retry("the connection dropped".into()), now);
|
||||
}
|
||||
}
|
||||
@@ -204,7 +207,7 @@ impl<S: Socket> Driver<S> {
|
||||
let l = &self.cfg.limits;
|
||||
let conn = self.conn.as_ref().map(|c| {
|
||||
if c.opened {
|
||||
[Some(c.last_sent + l.keepalive), Some(c.last_heard + l.idle_timeout), if c.welcomed { self.throttle.due() } else { None }]
|
||||
[Some(c.last_ping + l.keepalive), Some(c.last_heard + l.idle_timeout), if c.welcomed { self.throttle.due() } else { None }]
|
||||
} else {
|
||||
[Some(c.started + self.cfg.connect_timeout), None, None]
|
||||
}
|
||||
@@ -223,7 +226,7 @@ impl<S: Socket> Driver<S> {
|
||||
}
|
||||
let l = self.cfg.limits;
|
||||
let Some(c) = self.conn.as_ref() else { return };
|
||||
let (opened, started, last_heard, last_sent) = (c.opened, c.started, c.last_heard, c.last_sent);
|
||||
let (opened, started, last_heard, last_ping) = (c.opened, c.started, c.last_heard, c.last_ping);
|
||||
if !opened {
|
||||
if now >= started + self.cfg.connect_timeout {
|
||||
self.end(Outcome::Retry(format!("no answer from {}", self.cfg.invite.server.display())), now);
|
||||
@@ -233,21 +236,23 @@ impl<S: Socket> Driver<S> {
|
||||
if now >= last_heard + l.idle_timeout {
|
||||
return self.end(Outcome::Retry("the host stopped answering".into()), now);
|
||||
}
|
||||
let ok = match self.throttle.take_due(now) {
|
||||
Some(p) => self.write(&ClientMsg::Presence(p), now),
|
||||
None if now >= last_sent + l.keepalive => self.write(&ClientMsg::Ping, now),
|
||||
None => true,
|
||||
};
|
||||
let ok = self.throttle.take_due(now).is_none_or(|p| self.write(&ClientMsg::Presence(p)));
|
||||
let ok = ok && (now < last_ping + l.keepalive || self.ping(now));
|
||||
if !ok {
|
||||
self.end(Outcome::Retry("the connection dropped".into()), now);
|
||||
}
|
||||
}
|
||||
|
||||
/// Send one message; false when it couldn't go.
|
||||
fn write(&mut self, msg: &ClientMsg, now: Instant) -> bool {
|
||||
/// Send a ping; false when it couldn't go.
|
||||
fn ping(&mut self, now: Instant) -> bool {
|
||||
if let Some(c) = self.conn.as_mut() {
|
||||
c.last_sent = now;
|
||||
c.last_ping = now;
|
||||
}
|
||||
self.write(&ClientMsg::Ping)
|
||||
}
|
||||
|
||||
/// Send one message; false when it couldn't go.
|
||||
fn write(&mut self, msg: &ClientMsg) -> bool {
|
||||
message::encode(msg).is_ok_and(|b| self.sock.send(&b))
|
||||
}
|
||||
|
||||
|
||||
@@ -142,9 +142,10 @@ fn presence_is_throttled_and_quiet_connections_ping() {
|
||||
d.tick(due);
|
||||
assert_eq!(d.sock.sent.last(), Some(&ClientMsg::Presence(cursor(3.0))));
|
||||
let n = d.sock.sent.len();
|
||||
// Quiet: a ping after the keep-alive interval (the host answers, which counts as traffic).
|
||||
// A ping every keep-alive interval from the connection's start, presence or not (the host
|
||||
// answers, which counts as traffic).
|
||||
let ping_at = d.deadline().unwrap();
|
||||
assert_eq!(ping_at, due + Duration::from_secs(15));
|
||||
assert_eq!(ping_at, t0 + Duration::from_secs(15));
|
||||
d.tick(ping_at - ms(1));
|
||||
assert_eq!(d.sock.sent.len(), n, "not yet");
|
||||
d.message(&encode(&ServerMsg::Pong), ping_at - ms(1));
|
||||
@@ -152,6 +153,37 @@ fn presence_is_throttled_and_quiet_connections_ping() {
|
||||
assert_eq!(d.sock.sent.last(), Some(&ClientMsg::Ping));
|
||||
}
|
||||
|
||||
/// A viewer moving the pointer sends presence all the time, which the host doesn't answer: the
|
||||
/// pings still go out, and the host's pongs keep the connection (before, each presence put the
|
||||
/// next ping off, nothing was heard for the idle timeout, and the client dropped the connection).
|
||||
#[test]
|
||||
fn pings_go_out_while_presence_does_and_the_pongs_keep_the_connection() {
|
||||
let t0 = Instant::now();
|
||||
let (mut d, inbox) = start(Script::default(), t0);
|
||||
d.opened(t0);
|
||||
d.message(&welcome(), t0);
|
||||
inbox.drain();
|
||||
let mut answered = 0;
|
||||
for i in 1..=900u32 {
|
||||
let now = t0 + ms(100 * u64::from(i));
|
||||
d.presence(cursor(f64::from(i)), now);
|
||||
while let Some(due) = d.deadline().filter(|t| *t <= now) {
|
||||
d.tick(due);
|
||||
}
|
||||
// The host answers every ping (and nothing else) at once.
|
||||
let pinged = d.sock.sent[answered..].contains(&ClientMsg::Ping);
|
||||
answered = d.sock.sent.len();
|
||||
if pinged {
|
||||
d.message(&encode(&ServerMsg::Pong), now);
|
||||
}
|
||||
}
|
||||
assert!(!d.done());
|
||||
assert_eq!(d.sock.closes, 0, "90 s of presence and pongs: still connected");
|
||||
assert!(statuses(&inbox.drain()).is_empty());
|
||||
let pings = d.sock.sent.iter().filter(|m| **m == ClientMsg::Ping).count();
|
||||
assert!(pings >= 5, "a ping every 15 s ({pings})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_silent_host_counts_as_dropped_and_the_client_reconnects() {
|
||||
let t0 = Instant::now();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Joining a session: the client end of the transport, with reconnect.
|
||||
//!
|
||||
//! A supervisor thread connects, says hello, then writes (presence throttled to about 30 a
|
||||
//! second, latest wins; a ping when quiet) while a reader thread takes in what the host sends.
|
||||
//! second, latest wins; a ping every keep-alive interval) while a reader thread takes in what the
|
||||
//! host sends.
|
||||
//! When the connection drops after having been live, it reconnects with growing pauses; a
|
||||
//! refusal or the host ending the session is final.
|
||||
//!
|
||||
@@ -280,13 +281,15 @@ fn session(
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut last_sent = Instant::now();
|
||||
// A ping every keep-alive interval, whatever else is sent: presence gets no answer, and the
|
||||
// reader counts only what the host sends.
|
||||
let mut last_ping = Instant::now();
|
||||
let hello = hello(cfg, resume);
|
||||
let mut welcomed = false;
|
||||
let mut throttle = Throttle::new(PRESENCE_INTERVAL);
|
||||
let outcome = if send(&mut writer, &hello, &mut last_sent) {
|
||||
let outcome = if send(&mut writer, &hello) {
|
||||
loop {
|
||||
let ping_at = last_sent + cfg.limits.keepalive;
|
||||
let ping_at = last_ping + cfg.limits.keepalive;
|
||||
let wake = earliest([throttle.due(), Some(ping_at)]).unwrap_or(ping_at);
|
||||
let mut out = None;
|
||||
let mut now_send = Vec::new();
|
||||
@@ -312,16 +315,17 @@ fn session(
|
||||
Ok(Cmd::Welcomed(_) | Cmd::Lost(..)) => {}
|
||||
Err(RecvTimeoutError::Timeout) => {}
|
||||
}
|
||||
if !now_send.iter().all(|m| send(&mut writer, m, &mut last_sent)) {
|
||||
if !now_send.iter().all(|m| send(&mut writer, m)) {
|
||||
break Outcome::Retry("the connection dropped".into());
|
||||
}
|
||||
let now = Instant::now();
|
||||
let out = out.or_else(|| throttle.take_due(now));
|
||||
let ok = match out {
|
||||
Some(p) => send(&mut writer, &ClientMsg::Presence(p), &mut last_sent),
|
||||
None if now >= last_sent + cfg.limits.keepalive => send(&mut writer, &ClientMsg::Ping, &mut last_sent),
|
||||
None => true,
|
||||
};
|
||||
let ok = out.is_none_or(|p| send(&mut writer, &ClientMsg::Presence(p)));
|
||||
let ok = ok
|
||||
&& (now < last_ping + cfg.limits.keepalive || {
|
||||
last_ping = now;
|
||||
send(&mut writer, &ClientMsg::Ping)
|
||||
});
|
||||
if !ok {
|
||||
break Outcome::Retry("the connection dropped".into());
|
||||
}
|
||||
@@ -335,8 +339,7 @@ fn session(
|
||||
}
|
||||
|
||||
/// Send one message; false when the connection is gone.
|
||||
fn send(ws: &mut WebSocket<Duplex>, msg: &ClientMsg, last_sent: &mut Instant) -> bool {
|
||||
*last_sent = Instant::now();
|
||||
fn send(ws: &mut WebSocket<Duplex>, msg: &ClientMsg) -> bool {
|
||||
message::encode(msg).is_ok_and(|b| ws.send(Message::Binary(Bytes::from(b))).is_ok())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::limits::Admission;
|
||||
use super::*;
|
||||
use super::{Access, Admin, AdminReply, ClientMsg, HostInfo, IpAddr, LibraryReply, LibraryReq, PluginReply, PluginReq, Server};
|
||||
|
||||
/// An admin request's reply, and what the hub must do about connections it affects.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
use super::hosting::{Open, spawn};
|
||||
use super::limits::{PerAddress, ip_key};
|
||||
use super::queue::{Blocks, HubIn, InFlight, Line, Out, wide_block};
|
||||
use super::*;
|
||||
use super::{
|
||||
Arc, AtomicBool, AtomicUsize, Bucket, Bytes, ClientMsg, CloseCode, CloseFrame, Duplex, Duration, ErrorResponse, Instant, IpAddr, Message, Meter,
|
||||
NetLimits, Ordering, Read, Readers, Receiver, RecvTimeoutError, Request, Response, Role, Sender, ServerMsg, SocketAddr, TcpListener, TcpStream,
|
||||
WebSocket, Write, config, io, message, shutdown, sync_channel,
|
||||
};
|
||||
|
||||
/// How long a closing connection waits for the peer to hang up.
|
||||
const LINGER: Duration = Duration::from_secs(2);
|
||||
|
||||
@@ -2,7 +2,10 @@ use super::backend::Backend;
|
||||
use super::conn::{Shared, accept};
|
||||
use super::limits::{PerAddress, loopback};
|
||||
use super::queue::{Blocks, HubIn, InFlight};
|
||||
use super::*;
|
||||
use super::{
|
||||
Arc, AtomicBool, AtomicUsize, ClientMsg, Duration, HashMap, Hub, Inbox, Instant, JoinHandle, MAX_NAME, NetLimits, Ordering, Presence, Readers,
|
||||
Room, Sender, Server, SocketAddr, TcpListener, TcpStream, channel, io,
|
||||
};
|
||||
|
||||
/// The person hosting an app session: in the file's session without a socket.
|
||||
pub(crate) struct Local {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Access, Arc, HashMap, IpAddr, Ipv6Addr, Mutex, PoisonError, SocketAddr};
|
||||
|
||||
/// What a link lets a hello do.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use super::limits::ip_blocks;
|
||||
use super::*;
|
||||
use super::{
|
||||
Arc, AtomicUsize, Bytes, ClientMsg, CloseCode, HashMap, IpAddr, Mutex, Ordering, PoisonError, Presence, SyncSender, TcpStream, TrySendError,
|
||||
};
|
||||
|
||||
/// A message for one connection's writer: bytes (with what they count against, released when
|
||||
/// they are sent or dropped), or the close.
|
||||
|
||||
@@ -30,7 +30,7 @@ pub struct NetLimits {
|
||||
/// answer, so this is longer than `idle_timeout` (a large project on a slow connection can
|
||||
/// easily need several minutes).
|
||||
pub request_reply_timeout: Duration,
|
||||
/// A quiet client pings this often (keeps it under `idle_timeout`).
|
||||
/// A client pings this often, whatever else it sends (keeps it under `idle_timeout`).
|
||||
pub keepalive: Duration,
|
||||
/// Messages queued for one client before it counts as stuck and is disconnected.
|
||||
pub queue: usize,
|
||||
|
||||
@@ -17,6 +17,7 @@ chrono = { workspace = true }
|
||||
imbl = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
postcard = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
figcraft-color = { workspace = true }
|
||||
|
||||
@@ -245,6 +245,10 @@ pub enum Entry {
|
||||
/// [`DevSnapshot`](figcraft_doc::DevSnapshot)).
|
||||
DevSnapshot(NodeId),
|
||||
/// An image by content hash (value: `{width, height, bytes}` with the encoded bytes in base64).
|
||||
/// A key with the shape of a content hash (64 hex chars) must be exactly the lowercase sha256
|
||||
/// hex `figcraft_format::image_hash` produces for the bytes; every key must be a plain
|
||||
/// archive member name ([`figcraft_doc::ImageData::is_valid_key`]), and dimensions and byte
|
||||
/// size are bounded as the entry applies (`image_of` in `docprops.rs`).
|
||||
Image(String),
|
||||
/// A voter's ballot in a board's vote (value: the layer ids voted on).
|
||||
Ballot(String),
|
||||
@@ -309,6 +313,13 @@ impl DocProp {
|
||||
}
|
||||
}
|
||||
|
||||
impl Change {
|
||||
/// Whether this puts or removes an image entry.
|
||||
pub(crate) fn is_image(&self) -> bool {
|
||||
matches!(self, Change::Put { entry: Entry::Image(_), .. } | Change::Remove { entry: Entry::Image(_) })
|
||||
}
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
/// The client id space the entry's id lies in ([`figcraft_doc::ids`]): ids made during a live
|
||||
/// session carry their creator's client id, ids from before and ids of other forms are in
|
||||
|
||||
@@ -8,6 +8,7 @@ use base64::Engine as _;
|
||||
use figcraft_doc::{Collection, Comment, DevSnapshot, Document, ImageData, Style, Variable};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest as _, Sha256};
|
||||
|
||||
use crate::Error;
|
||||
use crate::change::{Change, DocProp, Entry, Family, Json};
|
||||
@@ -164,13 +165,68 @@ struct ImageValue {
|
||||
bytes: String,
|
||||
}
|
||||
|
||||
/// Largest image side: JPEG and GIF size fields are 16-bit; PNG can claim more, but no
|
||||
/// image this app realistically opens reaches it — an entry claiming a bigger side is
|
||||
/// refused rather than trusted. Zero is allowed: the format crate stores 0×0 for bytes it
|
||||
/// couldn't probe (`image_data`).
|
||||
pub(crate) const MAX_IMAGE_SIDE: u32 = 65_535;
|
||||
|
||||
/// Lowercase sha256 hex, the same form `figcraft_format::image_hash` produces.
|
||||
fn sha256_hex(bytes: &[u8]) -> String {
|
||||
Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// The image an entry puts under `key`, which now holds `held`: [`image_of`], and a key that
|
||||
/// already holds an image can't be given other bytes. A key always names the same bytes
|
||||
/// (producers key images by their bytes; the REST importer's placeholder keys always hold the
|
||||
/// same grey picture), so nobody can swap the picture under a key someone else added.
|
||||
fn image_for(held: Option<&ImageData>, key: &str, value: &Json) -> Result<ImageData, Error> {
|
||||
let img = image_of(key, value)?;
|
||||
if held.is_some_and(|old| old.bytes != img.bytes) {
|
||||
return Err(Error::Invalid(format!("image: {key} already holds other bytes")));
|
||||
}
|
||||
Ok(img)
|
||||
}
|
||||
|
||||
/// The JSON value an image entry carries: its size and its encoded bytes in base64.
|
||||
fn image_json(img: &ImageData) -> Result<Json, Error> {
|
||||
Json::of(&ImageValue { width: img.width, height: img.height, bytes: base64::engine::general_purpose::STANDARD.encode(img.bytes.as_slice()) })
|
||||
}
|
||||
|
||||
fn image_of(value: &Json) -> Result<ImageData, Error> {
|
||||
/// The image a change carries, validated as a collaborator's claim (an invalid entry
|
||||
/// rejects its batch, leaving every replica's store clean): a key that can name an archive
|
||||
/// member ([`ImageData::is_valid_key`]: a saved file writes it as `images/<key>`), dimensions within
|
||||
/// [`MAX_IMAGE_SIDE`], decoded bytes within [`ImageData::MAX_BYTES`] (what the importers read;
|
||||
/// the encoded length is capped before decoding so hostile text can't force a large
|
||||
/// allocation), and a key that claims a content hash ([`ImageData::claims_content_hash`]) must
|
||||
/// be exactly the lowercase sha256 of these bytes, the form `figcraft_format::image_hash`
|
||||
/// writes — a poisoned content-addressed store would show the wrong image everywhere at once
|
||||
/// (#1086). Other keys (the svg importer's FNV-128 keys, the REST importer's placeholders)
|
||||
/// claim no content address, so only the name and size rules apply to them.
|
||||
fn image_of(key: &str, value: &Json) -> Result<ImageData, Error> {
|
||||
image_within(key, value, ImageData::MAX_BYTES)
|
||||
}
|
||||
|
||||
/// [`image_of`] with `max_bytes` for the decoded size.
|
||||
pub(crate) fn image_within(key: &str, value: &Json, max_bytes: u64) -> Result<ImageData, Error> {
|
||||
if !ImageData::is_valid_key(key) {
|
||||
return Err(Error::Invalid(format!("image: {key:?} can't name an image (1–128 letters, digits, - or _)")));
|
||||
}
|
||||
let v: ImageValue = value.read("image")?;
|
||||
if v.width > MAX_IMAGE_SIDE || v.height > MAX_IMAGE_SIDE {
|
||||
return Err(Error::Invalid(format!("image: {}×{} passes the {} a side may be", v.width, v.height, MAX_IMAGE_SIDE)));
|
||||
}
|
||||
let max_encoded = (max_bytes / 3).saturating_mul(4).saturating_add(4);
|
||||
if u64::try_from(v.bytes.len()).unwrap_or(u64::MAX) > max_encoded {
|
||||
return Err(Error::Invalid(format!("image: base64 text longer than {max_encoded} bytes for {max_bytes} of bytes")));
|
||||
}
|
||||
let bytes = base64::engine::general_purpose::STANDARD.decode(v.bytes.as_bytes()).map_err(|e| Error::Invalid(format!("image: {e}")))?;
|
||||
if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > max_bytes {
|
||||
return Err(Error::Invalid(format!("image: {} bytes pass the {max_bytes} an image may be", bytes.len())));
|
||||
}
|
||||
if ImageData::claims_content_hash(key) && key != sha256_hex(&bytes) {
|
||||
return Err(Error::Invalid(format!("image: the bytes aren't what their key {key} hashes to")));
|
||||
}
|
||||
Ok(ImageData { bytes: Arc::new(bytes), width: v.width, height: v.height })
|
||||
}
|
||||
|
||||
@@ -286,7 +342,7 @@ pub(crate) fn apply(doc: &mut Document, change: &Change) -> Result<bool, Error>
|
||||
doc.dev_snapshots.insert(*k, Arc::new(snap));
|
||||
}
|
||||
Entry::Image(k) => {
|
||||
let img = image_of(value)?;
|
||||
let img = image_for(doc.images.get(k), k, value)?;
|
||||
doc.images.insert(k.clone(), img);
|
||||
}
|
||||
Entry::Ballot(k) => {
|
||||
@@ -302,6 +358,11 @@ pub(crate) fn apply(doc: &mut Document, change: &Change) -> Result<bool, Error>
|
||||
doc.dev_snapshots.remove(k);
|
||||
}
|
||||
Entry::Image(k) => {
|
||||
// An image a layer or style shows stays: removing it would let other bytes go
|
||||
// under its key, swapping the picture that layer shows ([`image_for`]).
|
||||
if figcraft_doc::used_images(doc).contains(k) {
|
||||
return Err(Error::Invalid(format!("image: {k} is still in use")));
|
||||
}
|
||||
doc.images.remove(k);
|
||||
}
|
||||
Entry::Ballot(k) => {
|
||||
|
||||
@@ -311,17 +311,7 @@ impl Step {
|
||||
apply_all(&mut work, &mut out, deletes);
|
||||
// Changes that apply one by one can still fail the batch's checks together (rare): keep
|
||||
// only those that pass them in order.
|
||||
if view.clone().apply(&out, Mode::Strict).is_err() {
|
||||
let mut w = view.clone();
|
||||
out.retain(|c| {
|
||||
let mut t = w.clone();
|
||||
let ok = t.apply(std::slice::from_ref(c), Mode::Strict).is_ok();
|
||||
if ok {
|
||||
w = t;
|
||||
}
|
||||
ok
|
||||
});
|
||||
}
|
||||
view.clone().apply_kept(&mut out, |_| true)?;
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
//! Image entries are checked as they apply: bytes carried under a content-hash key must be
|
||||
//! exactly what that key hashes to (lowercase sha256, the form `figcraft_format::image_hash`
|
||||
//! produces), a side stays within the 65 535 px bound the implementation enforces and decoded
|
||||
//! bytes within its 32 MiB cap. Without this a collaborator could poison the shared
|
||||
//! content-addressed image store or balloon memory (#1086).
|
||||
|
||||
use base64::Engine as _;
|
||||
use figcraft_doc::Document;
|
||||
use serde_json::json;
|
||||
use sha2::{Digest as _, Sha256};
|
||||
|
||||
use crate::change::{Change, Entry, Json};
|
||||
use crate::docprops::apply as apply_doc;
|
||||
use crate::docprops::{MAX_IMAGE_SIDE, image_within};
|
||||
|
||||
/// The side bound the implementation enforces.
|
||||
const MAX_SIDE: u32 = MAX_IMAGE_SIDE;
|
||||
|
||||
/// sha256 hex, computed here independently of the implementation under test.
|
||||
pub(crate) fn sha256_hex(bytes: &[u8]) -> String {
|
||||
Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// An image entry putting `bytes` (`width` × `height`) under `key`.
|
||||
pub(crate) fn put(key: &str, width: u32, height: u32, bytes: &[u8]) -> Change {
|
||||
Change::Put {
|
||||
entry: Entry::Image(key.into()),
|
||||
value: Json(json!({
|
||||
"width": width,
|
||||
"height": height,
|
||||
"bytes": base64::engine::general_purpose::STANDARD.encode(bytes),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes a collaborator hashed into the key, and different bytes they send under it.
|
||||
pub(crate) fn forged() -> (String, &'static [u8]) {
|
||||
(sha256_hex(b"the image the key actually names"), b"someone else's bytes")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bytes_that_do_not_hash_to_their_content_hash_key_are_refused() {
|
||||
let (key, bytes) = forged();
|
||||
let mut doc = Document::new("t");
|
||||
let e = match apply_doc(&mut doc, &put(&key, 2, 1, bytes)) {
|
||||
Ok(_) => panic!("bytes that don't hash to their key must be refused"),
|
||||
Err(e) => e,
|
||||
};
|
||||
assert!(e.to_string().contains("image"), "not an image refusal: {e}");
|
||||
assert!(doc.images.is_empty(), "the image store was poisoned under key {key}");
|
||||
}
|
||||
|
||||
/// An uppercase key looks like a content hash but isn't one: producers store the lowercase
|
||||
/// form, so bytes matching under the uppercase form would file a second copy under a name
|
||||
/// no honest lookup reaches. Refused (#1086).
|
||||
#[test]
|
||||
fn an_uppercase_content_hash_shaped_key_is_refused() {
|
||||
let bytes = b"honestly keyed, uppercase claim".as_slice();
|
||||
let mut doc = Document::new("t");
|
||||
let upper = sha256_hex(bytes).to_uppercase();
|
||||
let e = match apply_doc(&mut doc, &put(&upper, 2, 1, bytes)) {
|
||||
Ok(_) => panic!("an uppercase key claims a content address it doesn't have"),
|
||||
Err(e) => e,
|
||||
};
|
||||
assert!(e.to_string().contains("image"), "not an image refusal: {e}");
|
||||
assert!(doc.images.is_empty(), "the store took an uppercase-keyed copy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_that_hashes_to_its_key_applies() {
|
||||
let bytes = b"a real image, honestly keyed".as_slice();
|
||||
let mut doc = Document::new("t");
|
||||
apply_doc(&mut doc, &put(&sha256_hex(bytes), 2, 1, bytes)).expect("an honest image must apply unchanged");
|
||||
let img = doc.images.get(&sha256_hex(bytes)).expect("the image is stored");
|
||||
assert_eq!((img.width, img.height), (2, 1));
|
||||
assert_eq!(img.bytes.as_slice(), bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absurd_dimensions_are_refused() {
|
||||
let bytes = b"honest bytes, dishonest size".as_slice();
|
||||
let key = sha256_hex(bytes);
|
||||
for (w, h) in [(MAX_SIDE + 1, 10), (10, MAX_SIDE + 1), (u32::MAX, u32::MAX)] {
|
||||
let mut doc = Document::new("t");
|
||||
apply_doc(&mut doc, &put(&key, w, h, bytes))
|
||||
.err()
|
||||
.unwrap_or_else(|| panic!("a {w}×{h} image must be refused, but it applied to {:?} keys", doc.images.len()));
|
||||
assert!(doc.images.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes past the cap are refused, and so is base64 text too long to decode within it (checked
|
||||
/// before decoding). The cap is `figcraft_doc::ImageData::MAX_BYTES` (256 MiB); a small one keeps
|
||||
/// the test light.
|
||||
#[test]
|
||||
fn oversized_image_bytes_are_refused() {
|
||||
let value = |bytes: &[u8]| json!({"width": 1, "height": 1, "bytes": base64::engine::general_purpose::STANDARD.encode(bytes)});
|
||||
let at = vec![0u8; 1024];
|
||||
image_within(&sha256_hex(&at), &Json(value(&at)), 1024).expect("exactly the cap applies");
|
||||
let past = vec![0u8; 1025];
|
||||
assert!(image_within(&sha256_hex(&past), &Json(value(&past)), 1024).is_err(), "one byte past the cap");
|
||||
let long_text = json!({"width": 1, "height": 1, "bytes": "A".repeat(4096)});
|
||||
let e = image_within("img1", &Json(long_text), 1024).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(e.contains("base64 text longer"), "refused before decoding: {e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_sized_image_still_applies() {
|
||||
// The format crate stores 0×0 for bytes it can't probe (`image_data` in `figcraft_format`),
|
||||
// so a shared document can legitimately carry one; the bytes still have to be honest.
|
||||
let bytes = b"undecodable bytes kept from a file load".as_slice();
|
||||
let mut doc = Document::new("t");
|
||||
apply_doc(&mut doc, &put(&sha256_hex(bytes), 0, 0, bytes)).expect("0×0 means the size is unknown, not absurd");
|
||||
assert!(doc.images.contains_key(&sha256_hex(bytes)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_that_is_not_a_content_hash_shape_still_applies() {
|
||||
// Legacy producers key images without sha256: the svg importer stores FNV-128 keys (32 hex
|
||||
// chars, re-keyed by sha256 on save), and the REST importer keeps a placeholder under the
|
||||
// imageRef itself. They are not content-addressed references, so no hash is claimed; the
|
||||
// dimension and size bounds still hold.
|
||||
for key in ["rest-image-missing", "hash", &"f".repeat(32), &"ab".repeat(20)] {
|
||||
let mut doc = Document::new("t");
|
||||
apply_doc(&mut doc, &put(key, 2, 1, b"placeholder bytes")).unwrap_or_else(|e| panic!("legacy key {key:?} refused: {e}"));
|
||||
assert!(doc.images.contains_key(key));
|
||||
let mut big = Document::new("t");
|
||||
assert!(apply_doc(&mut big, &put(key, MAX_SIDE + 1, 1, b"placeholder bytes")).is_err(), "a legacy key must still bound dimensions");
|
||||
}
|
||||
}
|
||||
|
||||
/// A key becomes an archive member name when the file is saved (`images/<key>`), so a key that
|
||||
/// isn't plain letters and digits is refused as it applies: a collaborator could otherwise write
|
||||
/// `images/../../x` into everyone's saved file, which the next load drops as damaged (#1086).
|
||||
#[test]
|
||||
fn a_key_that_cannot_name_an_archive_member_is_refused() {
|
||||
for key in ["../../evil", "a/b", "x.png", "", "with space", "\\server", &"a".repeat(129)] {
|
||||
let mut doc = Document::new("t");
|
||||
assert!(apply_doc(&mut doc, &put(key, 2, 1, b"placeholder bytes")).is_err(), "key {key:?} must be refused");
|
||||
assert!(doc.images.is_empty(), "key {key:?} reached the image store");
|
||||
}
|
||||
}
|
||||
|
||||
/// The cap matches what the importers read (`figcraft_doc::ImageData::MAX_BYTES`): an image a
|
||||
/// document took from a `.fig`, `.sketch` or REST import reaches everyone in a live session.
|
||||
#[test]
|
||||
fn an_image_as_large_as_an_import_takes_still_applies() {
|
||||
let bytes = vec![0u8; 40 << 20];
|
||||
let mut doc = Document::new("t");
|
||||
apply_doc(&mut doc, &put(&sha256_hex(&bytes), 0, 0, &bytes)).expect("a 40 MiB image is within what imports read");
|
||||
assert_eq!(doc.images.len(), 1);
|
||||
}
|
||||
|
||||
/// An image key is content-derived by every producer, so its bytes never change: a collaborator
|
||||
/// can't swap the picture under a key another one added.
|
||||
#[test]
|
||||
fn an_image_cannot_be_replaced_under_its_key() {
|
||||
let mut doc = Document::new("t");
|
||||
apply_doc(&mut doc, &put("img1", 2, 1, b"the first bytes")).unwrap();
|
||||
assert!(apply_doc(&mut doc, &put("img1", 2, 1, b"other bytes")).is_err(), "replaced under the same key");
|
||||
assert_eq!(doc.images.get("img1").map(|i| i.bytes.as_slice()), Some(b"the first bytes".as_slice()));
|
||||
apply_doc(&mut doc, &put("img1", 2, 1, b"the first bytes")).expect("the same image again is fine (undo, redo)");
|
||||
}
|
||||
|
||||
/// A snapshot written before these checks may hold an image they refuse: the session still
|
||||
/// opens, without that image, instead of not opening at all.
|
||||
#[test]
|
||||
fn a_snapshot_holding_an_image_the_checks_refuse_still_opens_without_it() {
|
||||
let mut snapshot = crate::Replica::new(crate::sim_tests::base()).snapshot().unwrap();
|
||||
let (key, bytes) = forged();
|
||||
snapshot.push(put(&key, 2, 1, bytes));
|
||||
let r = crate::Replica::from_snapshot(&snapshot).expect("the snapshot opens");
|
||||
assert!(!r.document().images.contains_key(&key));
|
||||
}
|
||||
|
||||
/// A server's log written before these checks may hold an image they refuse: a restart replays
|
||||
/// the edits after it instead of dropping them with the rest of the log.
|
||||
#[test]
|
||||
fn a_restart_keeps_the_edits_after_an_image_the_checks_refuse() {
|
||||
let s = crate::Server::new(crate::sim_tests::base(), "", crate::Limits::default());
|
||||
let mut r = crate::Server::restore(s.state().unwrap(), "", crate::Limits::default()).unwrap();
|
||||
let (key, bytes) = forged();
|
||||
let good = b"an honest image after it".as_slice();
|
||||
let accepted = |seq, changes| crate::Journal::Accepted(crate::message::Accepted { seq, author: 1, batch_id: seq, changes });
|
||||
for j in [crate::Journal::Issued { client: 1 }, accepted(1, vec![put(&key, 2, 1, bytes)]), accepted(2, vec![put(&sha256_hex(good), 2, 1, good)])]
|
||||
{
|
||||
r.replay(j).expect("the log replays");
|
||||
}
|
||||
assert_eq!(r.seq(), 2, "both batches count");
|
||||
assert!(!r.document().images.contains_key(&key), "the refused image is left out");
|
||||
assert!(r.document().images.contains_key(&sha256_hex(good)), "the edit after it is kept");
|
||||
}
|
||||
|
||||
/// A server's log written before these checks may give an image other bytes under its key (the
|
||||
/// apply step allowed it then): a restart replays past that batch, keeping the first bytes and
|
||||
/// every edit after it, instead of stopping there and dropping the rest of the log.
|
||||
#[test]
|
||||
fn a_restart_replays_past_a_logged_image_replacement() {
|
||||
let s = crate::Server::new(crate::sim_tests::base(), "", crate::Limits::default());
|
||||
let mut r = crate::Server::restore(s.state().unwrap(), "", crate::Limits::default()).unwrap();
|
||||
let good = b"an honest image after it".as_slice();
|
||||
let accepted = |seq, changes| crate::Journal::Accepted(crate::message::Accepted { seq, author: 1, batch_id: seq, changes });
|
||||
let journal = [
|
||||
crate::Journal::Issued { client: 1 },
|
||||
accepted(1, vec![put("img1", 2, 1, b"the first bytes")]),
|
||||
accepted(2, vec![put("img1", 2, 1, b"other bytes")]),
|
||||
accepted(3, vec![put(&sha256_hex(good), 2, 1, good)]),
|
||||
];
|
||||
for j in journal {
|
||||
r.replay(j).expect("the log replays");
|
||||
}
|
||||
assert_eq!(r.seq(), 3, "every batch counts");
|
||||
assert_eq!(r.document().images.get("img1").map(|i| i.bytes.as_slice()), Some(b"the first bytes".as_slice()), "the replacement is left out");
|
||||
assert!(r.document().images.contains_key(&sha256_hex(good)), "the edit after it is kept");
|
||||
}
|
||||
|
||||
/// A document whose rectangle shows `img1` (the first bytes), and that rectangle.
|
||||
fn in_use() -> (Document, figcraft_doc::NodeId) {
|
||||
let mut doc = Document::new("t");
|
||||
let page = doc.first_page().unwrap();
|
||||
let id = doc.create(page, figcraft_doc::NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
doc.get_mut(id).unwrap().fills = vec![figcraft_color::Paint::image("img1", figcraft_color::ImageScaleMode::Fill)];
|
||||
apply_doc(&mut doc, &put("img1", 2, 1, b"the first bytes")).unwrap();
|
||||
(doc, id)
|
||||
}
|
||||
|
||||
/// An image a layer or style still shows can't be removed: removing it and putting other bytes
|
||||
/// under its key would swap the picture that layer shows, which giving the key other bytes
|
||||
/// directly can't do (#1086).
|
||||
#[test]
|
||||
fn an_image_in_use_cannot_be_removed() {
|
||||
let (mut doc, _) = in_use();
|
||||
let remove = Change::Remove { entry: Entry::Image("img1".into()) };
|
||||
assert!(apply_doc(&mut doc, &remove).is_err(), "removed while a layer shows it");
|
||||
assert!(apply_doc(&mut doc, &put("img1", 2, 1, b"other bytes")).is_err());
|
||||
assert_eq!(doc.images.get("img1").map(|i| i.bytes.as_slice()), Some(b"the first bytes".as_slice()));
|
||||
}
|
||||
|
||||
/// A batch that first stops using an image, then removes it (the order a diff makes), applies.
|
||||
#[test]
|
||||
fn an_image_no_longer_in_use_can_be_removed() {
|
||||
let (doc, id) = in_use();
|
||||
let mut r = crate::Replica::new(doc);
|
||||
let batch = [Change::Delete { id }, Change::Remove { entry: Entry::Image("img1".into()) }];
|
||||
r.apply(&batch, crate::Mode::Strict).expect("the layer goes first, then its image");
|
||||
assert!(r.document().images.is_empty());
|
||||
}
|
||||
|
||||
/// Only image entries are left out of saved history: a snapshot whose other changes don't apply
|
||||
/// is still an error, not a document missing them.
|
||||
#[test]
|
||||
fn a_snapshot_with_a_broken_layer_change_still_fails_to_open() {
|
||||
let mut snapshot = crate::Replica::new(crate::sim_tests::base()).snapshot().unwrap();
|
||||
// A property of a layer that isn't there: an error when applied (unlike deleting it).
|
||||
let missing = figcraft_doc::NodeId(u64::MAX);
|
||||
snapshot.push(Change::Set { id: missing, prop: crate::change::Prop::Name, value: Json(json!("x")) });
|
||||
assert!(crate::Replica::from_snapshot(&snapshot).is_err());
|
||||
}
|
||||
@@ -174,3 +174,152 @@ fn a_journal_with_a_gap_is_refused() {
|
||||
assert_eq!(errors.len(), 1, "the batch after the gap");
|
||||
assert_eq!(r.seq(), 0);
|
||||
}
|
||||
|
||||
/// Restores `state` with one more batch from another client in its log (and in its snapshot,
|
||||
/// `in_snapshot`, as a server that accepted it saved it), then has `a` resume into it.
|
||||
fn resume_past(s: &Server, a: &mut Client, batch: Vec<crate::Change>, in_snapshot: bool) -> Server {
|
||||
let mut state = s.state().unwrap();
|
||||
if in_snapshot {
|
||||
state.snapshot.retain(|c| {
|
||||
!batch.iter().any(|b| matches!((b, c), (crate::Change::Put { entry: x, .. }, crate::Change::Put { entry: y, .. }) if x == y))
|
||||
});
|
||||
state.snapshot.extend(batch.iter().cloned());
|
||||
}
|
||||
state.seq += 1;
|
||||
state.log.push(message::Accepted { seq: state.seq, author: a.id() + 1, batch_id: 1, changes: batch });
|
||||
let mut r = Server::restore(state, "", Limits::default()).unwrap();
|
||||
a.disconnect();
|
||||
let ClientMsg::Hello(h) = a.reconnect(hello()) else { panic!("not a hello") };
|
||||
let (_, out) = r.join(h).unwrap();
|
||||
deliver(a, &out);
|
||||
r
|
||||
}
|
||||
|
||||
/// A saved log may hold an image a newer server refuses: a client resuming from before it still
|
||||
/// catches up, without that image (#1086).
|
||||
#[test]
|
||||
fn a_client_resuming_past_a_refused_image_in_the_saved_log_catches_up() {
|
||||
let mut s = Server::new(base(), "", Limits::default());
|
||||
let mut a = join_as(&mut s, Access::Edit);
|
||||
let (key, bytes) = crate::image_tests::forged();
|
||||
let r = resume_past(&s, &mut a, vec![crate::image_tests::put(&key, 2, 1, bytes)], false);
|
||||
assert!(!a.view().document().images.contains_key(&key));
|
||||
assert_eq!(a.view(), r.replica());
|
||||
}
|
||||
|
||||
/// A saved log may give an image other bytes under a key the resuming client already holds (an
|
||||
/// older server allowed it): the client still catches up, to what the server holds (#1086).
|
||||
#[test]
|
||||
fn a_client_resuming_past_a_replaced_image_in_the_saved_log_catches_up() {
|
||||
let mut s = Server::new(base(), "", Limits::default());
|
||||
let mut a = join_as(&mut s, Access::Edit);
|
||||
let m = edit(&mut a, |d| {
|
||||
d.images.insert("img1".into(), figcraft_doc::ImageData { bytes: std::sync::Arc::new(b"the first bytes".to_vec()), width: 2, height: 1 });
|
||||
});
|
||||
send(&mut s, a.id(), m, &mut [&mut a]);
|
||||
let r = resume_past(&s, &mut a, vec![crate::image_tests::put("img1", 2, 1, b"other bytes")], true);
|
||||
assert_eq!(a.view(), r.replica());
|
||||
}
|
||||
|
||||
/// `a` adds a rectangle showing image `img1`; returns the rectangle.
|
||||
fn add_image_layer(s: &mut Server, a: &mut Client) -> figcraft_doc::NodeId {
|
||||
let page = s.document().first_page().unwrap();
|
||||
let mut id = None;
|
||||
let m = edit(a, |d| {
|
||||
let r = d.create(page, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
d.get_mut(r).unwrap().fills = vec![figcraft_color::Paint::image("img1", figcraft_color::ImageScaleMode::Fill)];
|
||||
d.images.insert("img1".into(), figcraft_doc::ImageData { bytes: std::sync::Arc::new(b"the first bytes".to_vec()), width: 2, height: 1 });
|
||||
id = Some(r);
|
||||
});
|
||||
send(s, a.id(), m, &mut [a]);
|
||||
id.unwrap()
|
||||
}
|
||||
|
||||
/// Undoing an image's insertion still works with images in use protected from removal (#1086).
|
||||
#[test]
|
||||
fn undoing_an_image_layer_still_works() {
|
||||
let mut s = Server::new(base(), "", Limits::default());
|
||||
let mut a = join_as(&mut s, Access::Edit);
|
||||
let id = add_image_layer(&mut s, &mut a);
|
||||
for m in a.undo(&NoRecompute).unwrap() {
|
||||
send(&mut s, a.id(), m, &mut [&mut a]);
|
||||
}
|
||||
assert!(s.document().node(id).is_none(), "the layer is gone");
|
||||
assert_eq!(a.view(), s.replica());
|
||||
}
|
||||
|
||||
/// A server's log written before the image checks may remove an image a layer still shows: a
|
||||
/// restart replays past it, keeping the image and every edit after it (#1086).
|
||||
#[test]
|
||||
fn a_restart_replays_past_a_logged_removal_of_an_image_in_use() {
|
||||
let mut s = Server::new(base(), "", Limits::default());
|
||||
s.journaling(true);
|
||||
let mut a = join_as(&mut s, Access::Edit);
|
||||
add_image_layer(&mut s, &mut a);
|
||||
let saved = s.state().unwrap();
|
||||
let page = s.document().first_page().unwrap();
|
||||
let mut journal = s.take_journal();
|
||||
let base_seq = saved.seq;
|
||||
let next = |n: u64| base_seq + n;
|
||||
journal.push(Journal::Accepted(message::Accepted {
|
||||
seq: next(1),
|
||||
author: a.id(),
|
||||
batch_id: 90,
|
||||
changes: vec![crate::Change::Remove { entry: crate::change::Entry::Image("img1".into()) }],
|
||||
}));
|
||||
let rename = crate::Change::Set { id: page, prop: crate::change::Prop::Name, value: crate::change::Json(serde_json::json!("Renamed")) };
|
||||
journal.push(Journal::Accepted(message::Accepted { seq: next(2), author: a.id(), batch_id: 91, changes: vec![rename] }));
|
||||
let mut r = Server::restore(saved, "", Limits::default()).unwrap();
|
||||
for j in journal {
|
||||
r.replay(j).expect("the log replays");
|
||||
}
|
||||
assert_eq!(r.seq(), next(2));
|
||||
assert!(r.document().images.contains_key("img1"), "the image a layer shows stays");
|
||||
assert_eq!(r.document().node(page).unwrap().name, "Renamed", "the edit after it is kept");
|
||||
}
|
||||
|
||||
/// What a resuming client is told to catch up with.
|
||||
fn catch_up_of(r: &mut Server, a: &mut Client) -> message::CatchUp {
|
||||
a.disconnect();
|
||||
let ClientMsg::Hello(h) = a.reconnect(hello()) else { panic!("not a hello") };
|
||||
let (id, out) = r.join(h).unwrap();
|
||||
out.into_iter()
|
||||
.find_map(|o| match o {
|
||||
Outgoing::To(t, ServerMsg::Welcome { catch_up, .. }) if t == id => Some(catch_up),
|
||||
_ => None,
|
||||
})
|
||||
.expect("a welcome")
|
||||
}
|
||||
|
||||
/// A restart that left an image out of the saved history (an older server accepted it) catches
|
||||
/// every resuming client up with the snapshot, even one at the same sequence number: a client
|
||||
/// that kept the image from before would otherwise go on showing what the session no longer
|
||||
/// has (#1086).
|
||||
#[test]
|
||||
fn a_resume_after_history_was_left_out_catches_up_with_the_snapshot() {
|
||||
let mut s = Server::new(base(), "", Limits::default());
|
||||
let mut a = join_as(&mut s, Access::Edit);
|
||||
let mut state = s.state().unwrap();
|
||||
let (key, bytes) = crate::image_tests::forged();
|
||||
state.snapshot.push(crate::image_tests::put(&key, 2, 1, bytes));
|
||||
let mut r = Server::restore(state, "", Limits::default()).unwrap();
|
||||
assert!(matches!(catch_up_of(&mut r, &mut a), message::CatchUp::Snapshot(_)), "left out on restore");
|
||||
|
||||
// Left out on replay.
|
||||
let mut s = Server::new(base(), "", Limits::default());
|
||||
let mut a = join_as(&mut s, Access::Edit);
|
||||
let state = s.state().unwrap();
|
||||
let mut r = Server::restore(state.clone(), "", Limits::default()).unwrap();
|
||||
let batch = vec![crate::image_tests::put(&key, 2, 1, bytes)];
|
||||
r.replay(Journal::Accepted(message::Accepted { seq: state.seq + 1, author: a.id(), batch_id: 90, changes: batch })).unwrap();
|
||||
assert!(matches!(catch_up_of(&mut r, &mut a), message::CatchUp::Snapshot(_)), "left out on replay");
|
||||
}
|
||||
|
||||
/// A restart that left nothing out keeps resuming clients on the cheaper delta.
|
||||
#[test]
|
||||
fn a_resume_after_a_clean_restart_catches_up_with_the_log() {
|
||||
let mut s = Server::new(base(), "", Limits::default());
|
||||
let mut a = join_as(&mut s, Access::Edit);
|
||||
let mut r = Server::restore(s.state().unwrap(), "", Limits::default()).unwrap();
|
||||
assert!(matches!(catch_up_of(&mut r, &mut a), message::CatchUp::Delta(_)));
|
||||
}
|
||||
|
||||
@@ -136,6 +136,8 @@ mod entry_ids_tests;
|
||||
#[cfg(test)]
|
||||
mod frac_tests;
|
||||
#[cfg(test)]
|
||||
mod image_tests;
|
||||
#[cfg(test)]
|
||||
mod journal_tests;
|
||||
#[cfg(test)]
|
||||
mod merge_list_tests;
|
||||
|
||||
@@ -72,12 +72,47 @@ impl Replica {
|
||||
|
||||
/// Rebuild a replica from a [`snapshot`](Self::snapshot).
|
||||
pub fn from_snapshot(changes: &[Change]) -> Result<Replica, Error> {
|
||||
Self::from_snapshot_leaving_out(changes).map(|(r, _)| r)
|
||||
}
|
||||
|
||||
/// [`from_snapshot`](Self::from_snapshot), and how many image entries it left out: a
|
||||
/// snapshot saved before the image checks may hold one they refuse, and opens without it.
|
||||
pub(crate) fn from_snapshot_leaving_out(changes: &[Change]) -> Result<(Replica, usize), Error> {
|
||||
let mut r = Replica { doc: Document::without_pages(""), positions: Positions::new() };
|
||||
r.apply(changes, Mode::Strict)?;
|
||||
let mut changes = changes.to_vec();
|
||||
let left_out = r.apply_kept(&mut changes, Change::is_image)?;
|
||||
if r.doc.pages.is_empty() {
|
||||
return Err(Error::Invalid("a snapshot needs a page".into()));
|
||||
}
|
||||
Ok(r)
|
||||
Ok((r, left_out))
|
||||
}
|
||||
|
||||
/// Apply `changes` strictly. When they don't apply together — history written under older
|
||||
/// rules, or changes that apply one by one but fail the batch's checks together — apply those
|
||||
/// that do, one by one in order, and leave the rest out of `changes`, as long as `may_leave_out`
|
||||
/// allows it (otherwise that change's error). Returns how many were left out.
|
||||
pub(crate) fn apply_kept(&mut self, changes: &mut Vec<Change>, may_leave_out: impl Fn(&Change) -> bool) -> Result<usize, Error> {
|
||||
let mut all = self.clone();
|
||||
if all.apply(changes, Mode::Strict).is_ok() {
|
||||
*self = all;
|
||||
return Ok(0);
|
||||
}
|
||||
let given = changes.len();
|
||||
let mut kept = Vec::with_capacity(given);
|
||||
for c in changes.drain(..) {
|
||||
let mut t = self.clone();
|
||||
match t.apply(std::slice::from_ref(&c), Mode::Strict) {
|
||||
Ok(_) => {
|
||||
*self = t;
|
||||
kept.push(c);
|
||||
}
|
||||
Err(e) if !may_leave_out(&c) => return Err(e),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
let left_out = given - kept.len();
|
||||
*changes = kept;
|
||||
Ok(left_out)
|
||||
}
|
||||
|
||||
/// The whole state as changes that build it from nothing (pages and their trees in order,
|
||||
|
||||
@@ -77,6 +77,10 @@ pub struct Server {
|
||||
signals: BTreeSet<u32>,
|
||||
/// Who spotlights now (a newcomer is told).
|
||||
spotlight: Option<u32>,
|
||||
/// Saved history this server restored or replayed left image entries out (or may give a
|
||||
/// resuming client other bytes under a key it holds): every resuming client catches up with
|
||||
/// the snapshot, not the log.
|
||||
resync: bool,
|
||||
}
|
||||
|
||||
/// The most client ids a session remembers for reconnecting (the oldest disconnected ones go
|
||||
@@ -127,6 +131,7 @@ impl Server {
|
||||
journal: None,
|
||||
signals: BTreeSet::new(),
|
||||
spotlight: None,
|
||||
resync: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +153,7 @@ impl Server {
|
||||
/// reconnect with their ids). A log that doesn't lead up to the sequence number is dropped
|
||||
/// (clients then catch up with a snapshot).
|
||||
pub fn restore(state: SessionState, token: impl Into<String>, limits: Limits) -> Result<Server, Error> {
|
||||
let replica = Replica::from_snapshot(&state.snapshot)?;
|
||||
let (replica, left_out) = Replica::from_snapshot_leaving_out(&state.snapshot)?;
|
||||
let mut server = Server::new(replica.document().clone(), token, limits);
|
||||
server.state = replica;
|
||||
server.session = state.session;
|
||||
@@ -158,6 +163,12 @@ impl Server {
|
||||
server.deleted = state.deleted;
|
||||
server.removed = state.removed;
|
||||
let contiguous = state.log.windows(2).all(|w| matches!(w, [a, b] if a.seq + 1 == b.seq));
|
||||
// History saved before the image checks may hold image entries they refuse. The snapshot
|
||||
// opens without them, and a saved log putting images may give a resuming client other
|
||||
// bytes under a key it holds, which this state can't check: either way resuming clients
|
||||
// catch up with the snapshot.
|
||||
let puts_images = state.log.iter().flat_map(|a| &a.changes).any(Change::is_image);
|
||||
server.resync = left_out > 0 || puts_images;
|
||||
if contiguous && state.log.last().is_none_or(|a| a.seq == state.seq) {
|
||||
for a in state.log {
|
||||
server.remember(a);
|
||||
@@ -198,10 +209,16 @@ impl Server {
|
||||
Ok(())
|
||||
}
|
||||
Journal::Accepted(acc) if acc.seq <= self.seq => Ok(()),
|
||||
Journal::Accepted(acc) => {
|
||||
Journal::Accepted(mut acc) => {
|
||||
if acc.seq != self.seq + 1 {
|
||||
return Err(Error::Protocol(format!("batch {} after {}", acc.seq, self.seq)));
|
||||
}
|
||||
// A log written before the image checks may hold an image entry they refuse: the
|
||||
// batch replays without it, so the edits after it aren't dropped with the rest of
|
||||
// the log, and resuming clients catch up with the snapshot.
|
||||
if acc.changes.iter().any(Change::is_image) && self.state.clone().apply_kept(&mut acc.changes, Change::is_image)? > 0 {
|
||||
self.resync = true;
|
||||
}
|
||||
self.accept(acc.author, self.seq, &acc.changes)?;
|
||||
self.issued.entry(acc.author).or_default().note(acc.batch_id, false);
|
||||
self.next_client = self.next_client.max(acc.author.saturating_add(1));
|
||||
@@ -284,7 +301,9 @@ impl Server {
|
||||
// The log holds every batch after `floor`.
|
||||
let floor = self.log.front().map_or(self.seq, |a| a.seq.saturating_sub(1));
|
||||
let catch_up = match resume {
|
||||
Some(r) if r.seq <= self.seq && r.seq >= floor => CatchUp::Delta(self.log.iter().filter(|a| a.seq > r.seq).cloned().collect()),
|
||||
Some(r) if !self.resync && r.seq <= self.seq && r.seq >= floor => {
|
||||
CatchUp::Delta(self.log.iter().filter(|a| a.seq > r.seq).cloned().collect())
|
||||
}
|
||||
_ => CatchUp::Snapshot(self.state.snapshot().map_err(|e| refuse(&e.to_string()))?),
|
||||
};
|
||||
let (id, secret) = match resume {
|
||||
|
||||
@@ -104,13 +104,15 @@ fn place_keeps_the_local_transform_and_refuses_bad_moves() {
|
||||
assert!(d.validate().is_empty());
|
||||
}
|
||||
|
||||
/// A page-less document takes pages inserted with their own ids, refuses a duplicate or a layer
|
||||
/// that isn't a page, and repairs into a file with a page.
|
||||
#[test]
|
||||
fn pages_can_be_inserted_onto_a_page_less_base() {
|
||||
let mut d = Document::without_pages("snapshot");
|
||||
assert!(d.pages.is_empty() && d.node_count() == 0);
|
||||
let p = Node::new(
|
||||
NodeId(C3 | 1),
|
||||
NodeKind::Page { background: figcraft_color::Color::DEFAULT_PAGE, flows: vec![], prototype: Default::default(), guides: vec![] },
|
||||
NodeKind::Page { background: figcraft_color::Color::DEFAULT_PAGE, flows: vec![], prototype: Default::default() },
|
||||
"P",
|
||||
0.0,
|
||||
0.0,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! Ruler guides survive a save and reopen (#1476).
|
||||
|
||||
use crate::{Document, Guide, Node, NodeKind};
|
||||
|
||||
/// A guide: vertical (at an x offset) or horizontal (at a y offset).
|
||||
fn guide(vertical: bool, offset: f64) -> Guide {
|
||||
Guide { vertical, offset }
|
||||
}
|
||||
|
||||
/// A page's guides are written once and read back onto the page.
|
||||
#[test]
|
||||
fn page_guides_round_trip_through_json() {
|
||||
let mut d = Document::new("a");
|
||||
let page = d.first_page().unwrap();
|
||||
d.node_mut(page).unwrap().guides_mut().push(guide(true, 120.0));
|
||||
let json = serde_json::to_string(d.node(page).unwrap()).unwrap();
|
||||
assert_eq!(json.matches("\"guides\"").count(), 1, "{json}");
|
||||
let back: Node = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.guides(), &[guide(true, 120.0)]);
|
||||
assert_eq!(back, *d.node(page).unwrap());
|
||||
}
|
||||
|
||||
/// A frame's guides round trip too, and a layer without guides writes no `guides` key.
|
||||
#[test]
|
||||
fn frame_guides_round_trip_and_none_are_left_out() {
|
||||
let mut d = Document::new("a");
|
||||
let page = d.first_page().unwrap();
|
||||
let frame = d.create(page, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
for id in [page, frame] {
|
||||
let json = serde_json::to_value(d.node(id).unwrap()).unwrap();
|
||||
assert!(json.get("guides").is_none(), "{json}");
|
||||
}
|
||||
d.node_mut(frame).unwrap().guides_mut().push(guide(false, 40.0));
|
||||
let json = serde_json::to_string(d.node(frame).unwrap()).unwrap();
|
||||
assert_eq!(json.matches("\"guides\"").count(), 1, "{json}");
|
||||
let back: Node = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, *d.node(frame).unwrap());
|
||||
}
|
||||
|
||||
/// A page saved with two `guides` keys reads both lists, in file order, and writes them once.
|
||||
#[test]
|
||||
fn a_page_written_with_two_guides_lists_opens_with_both() {
|
||||
// How a page was saved after its file had been reopened: the page kind's list (guides added
|
||||
// since), then the layer's (the ones the file had).
|
||||
let json = r#"{"id": 1, "name": "Page 1", "type": "page", "guides": [{"vertical": true, "offset": 5.0}],
|
||||
"width": 0.0, "height": 0.0, "guides": [{"vertical": false, "offset": 7.0}]}"#;
|
||||
let n: Node = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(n.kind, NodeKind::Page { .. }));
|
||||
assert_eq!(n.guides(), &[guide(true, 5.0), guide(false, 7.0)]);
|
||||
let again: Node = serde_json::from_str(&serde_json::to_string(&n).unwrap()).unwrap();
|
||||
assert_eq!(again.guides(), n.guides());
|
||||
}
|
||||
|
||||
/// A `guides` value that isn't a list of guides is still refused.
|
||||
#[test]
|
||||
fn a_bad_guides_list_is_still_an_error() {
|
||||
for bad in [r#"3"#, r#"[{"vertical": "no"}]"#, r#"{"offset": 1}"#] {
|
||||
let json = format!(r#"{{"id": 1, "name": "F", "type": "frame", "width": 1.0, "height": 1.0, "guides": {bad}}}"#);
|
||||
assert!(serde_json::from_str::<Node>(&json).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
@@ -85,3 +85,23 @@ fn validate_is_linear_in_a_wide_parent() {
|
||||
doc.get_mut(page).unwrap().children.retain(|c| *c != kid);
|
||||
assert!(doc.validate().iter().any(|m| m.contains("not listed in parent")));
|
||||
}
|
||||
|
||||
/// Repair lists every unlisted page once, by set lookups rather than a scan of the page list per
|
||||
/// page (#1078: a file with many pages made repair quadratic).
|
||||
#[test]
|
||||
fn repair_relists_many_unlisted_pages_once_each() {
|
||||
let mut doc = Document::new("d");
|
||||
for i in 0..5_000 {
|
||||
doc.add_page(&format!("P{i}"));
|
||||
}
|
||||
let all: Vec<_> = doc.pages.clone();
|
||||
// Half the pages fall off the list.
|
||||
doc.pages.retain(|p| p.0 % 2 == 0);
|
||||
doc.repair();
|
||||
let mut listed = doc.pages.clone();
|
||||
listed.sort_unstable();
|
||||
let mut want = all;
|
||||
want.sort_unstable();
|
||||
assert_eq!(listed, want, "every page is listed again, once");
|
||||
assert!(doc.validate().is_empty());
|
||||
}
|
||||
|
||||
+27
-8
@@ -161,6 +161,26 @@ pub struct ImageData {
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
impl ImageData {
|
||||
/// Largest encoded image a document takes from outside: an image member of a `.fig` or
|
||||
/// `.sketch` file, an image file a REST import reads, an image entry from a live session.
|
||||
pub const MAX_BYTES: u64 = 256 * 1024 * 1024;
|
||||
|
||||
/// Whether `key` claims to be a content hash: 64 hex digits, the shape of the sha256 hex
|
||||
/// `figcraft_format::image_hash` makes. Bytes stored under such a key must hash to it.
|
||||
pub fn claims_content_hash(key: &str) -> bool {
|
||||
key.len() == 64 && key.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
/// Whether `key` can name an image: 1–128 ASCII letters, digits, `-` and `_` (content hashes,
|
||||
/// the svg importer's FNV keys, the REST importer's `rest-image-missing` placeholder). A key
|
||||
/// becomes an archive member name (`images/<key>`) when the file is saved, so only these are
|
||||
/// read back from a file or taken from a collaborator: never `/`, `.` or other path text.
|
||||
pub fn is_valid_key(key: &str) -> bool {
|
||||
(1..=128).contains(&key.len()) && key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|
||||
}
|
||||
}
|
||||
|
||||
/// A whole design file.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Document {
|
||||
@@ -407,13 +427,8 @@ impl Document {
|
||||
/// Add a page at the end.
|
||||
pub fn add_page(&mut self, name: &str) -> NodeId {
|
||||
let id = self.alloc_id();
|
||||
let node = Node::new(
|
||||
id,
|
||||
NodeKind::Page { background: Color::DEFAULT_PAGE, flows: Vec::new(), prototype: Default::default(), guides: Vec::new() },
|
||||
name,
|
||||
0.0,
|
||||
0.0,
|
||||
);
|
||||
let node =
|
||||
Node::new(id, NodeKind::Page { background: Color::DEFAULT_PAGE, flows: Vec::new(), prototype: Default::default() }, name, 0.0, 0.0);
|
||||
self.nodes.insert(id, Arc::new(node));
|
||||
self.dirty.touch(id);
|
||||
self.pages.push(id);
|
||||
@@ -1143,8 +1158,10 @@ impl Document {
|
||||
Arc::make_mut(&mut self.libraries).store = store;
|
||||
}
|
||||
self.pages.retain(|p| self.nodes.get(p).is_some_and(|n| n.is_page()) && Some(*p) != store);
|
||||
// Unlisted pages join the list; a set keeps many pages from making this quadratic (#1078).
|
||||
let mut listed: std::collections::BTreeSet<NodeId> = self.pages.iter().copied().collect();
|
||||
for (k, n) in self.nodes.iter() {
|
||||
if n.is_page() && !self.pages.contains(k) && Some(*k) != store {
|
||||
if n.is_page() && Some(*k) != store && listed.insert(*k) {
|
||||
self.pages.push(*k);
|
||||
}
|
||||
}
|
||||
@@ -1258,6 +1275,8 @@ mod diff_tests;
|
||||
#[cfg(test)]
|
||||
mod effects_tests;
|
||||
#[cfg(test)]
|
||||
mod guides_tests;
|
||||
#[cfg(test)]
|
||||
mod handoff_tests;
|
||||
#[cfg(test)]
|
||||
mod ids_tests;
|
||||
|
||||
@@ -260,7 +260,7 @@ impl Document {
|
||||
let id = self.alloc_id();
|
||||
let node = Node::new(
|
||||
id,
|
||||
NodeKind::Page { background: figcraft_color::Color::DEFAULT_PAGE, flows: Vec::new(), prototype: Default::default(), guides: Vec::new() },
|
||||
NodeKind::Page { background: figcraft_color::Color::DEFAULT_PAGE, flows: Vec::new(), prototype: Default::default() },
|
||||
STORE_NAME,
|
||||
0.0,
|
||||
0.0,
|
||||
|
||||
+83
-13
@@ -1,7 +1,10 @@
|
||||
//! Nodes: one struct with the properties every node can have, and a kind with per-type data.
|
||||
|
||||
use crate::NodeId;
|
||||
use crate::props::*;
|
||||
use crate::props::{
|
||||
AutoLayout, ChildLayout, Constraints, Corners, Effect, EffectKind, ExportSetting, LayoutGrid, SPREAD_MITER_LIMIT, StrokeAlign, StrokeCap,
|
||||
StrokeProps,
|
||||
};
|
||||
use crate::proto::{Interaction, Overflow, ScrollPosition};
|
||||
use crate::styles::{CollectionId, ModeId, StyleRefs, VariableId};
|
||||
use crate::text::TextData;
|
||||
@@ -63,6 +66,9 @@ pub enum PropDef {
|
||||
}
|
||||
|
||||
/// What a node is, with per-type data.
|
||||
// Text sets the size; the lint counts the gap to the next largest variant, which grew only because
|
||||
// Page's guides moved to the node (#1476).
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum NodeKind {
|
||||
@@ -73,8 +79,6 @@ pub enum NodeKind {
|
||||
flows: Vec<crate::proto::Flow>,
|
||||
#[serde(default)]
|
||||
prototype: crate::proto::PrototypeSettings,
|
||||
#[serde(default)]
|
||||
guides: Vec<Guide>,
|
||||
},
|
||||
Frame,
|
||||
Group,
|
||||
@@ -469,8 +473,8 @@ pub struct Node {
|
||||
pub mask_type: MaskType,
|
||||
#[serde(default, skip_serializing_if = "is_default")]
|
||||
pub grids: Vec<LayoutGrid>,
|
||||
/// A frame's ruler guides, in its own coordinates (a page's are in [`NodeKind::Page`]).
|
||||
#[serde(default, skip_serializing_if = "is_default")]
|
||||
/// A page's or frame's ruler guides, in its own coordinates (see [`guides_key`]).
|
||||
#[serde(flatten, with = "guides_key")]
|
||||
pub guides: Vec<Guide>,
|
||||
#[serde(default, skip_serializing_if = "is_default")]
|
||||
pub exports: Vec<ExportSetting>,
|
||||
@@ -575,6 +579,77 @@ fn is_identity(v: &Affine) -> bool {
|
||||
*v == identity()
|
||||
}
|
||||
|
||||
/// A layer's `guides` key: left out when there are none, and every `guides` list the layer has is
|
||||
/// read. Pages used to keep their guides in [`NodeKind::Page`] while the layer field took the
|
||||
/// `guides` key on reading, so a reopened page lost its guides, and saving it then wrote two
|
||||
/// `guides` keys, which a plain field refuses (#1476). Both lists are kept, in file order.
|
||||
mod guides_key {
|
||||
use super::Guide;
|
||||
use serde::de::{IgnoredAny, MapAccess, Visitor};
|
||||
use serde::ser::SerializeMap;
|
||||
use serde::{Deserialize, Deserializer, Serializer};
|
||||
use std::fmt;
|
||||
|
||||
const KEY: &str = "guides";
|
||||
|
||||
/// Write `guides` as the layer's `guides` key, or nothing when there are none.
|
||||
pub fn serialize<S: Serializer>(guides: &[Guide], s: S) -> Result<S::Ok, S::Error> {
|
||||
let mut map = s.serialize_map(None)?;
|
||||
if !guides.is_empty() {
|
||||
map.serialize_entry(KEY, guides)?;
|
||||
}
|
||||
map.end()
|
||||
}
|
||||
|
||||
/// Read every `guides` list among the layer's fields, joined in file order.
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<Guide>, D::Error> {
|
||||
d.deserialize_map(Lists)
|
||||
}
|
||||
|
||||
/// Reads the `guides` lists of a layer's fields and skips the other fields.
|
||||
struct Lists;
|
||||
|
||||
impl<'de> Visitor<'de> for Lists {
|
||||
type Value = Vec<Guide>;
|
||||
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
f.write_str("a layer's fields")
|
||||
}
|
||||
/// Every `guides` value joined, in order; a malformed one is an error.
|
||||
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Vec<Guide>, A::Error> {
|
||||
let mut guides = Vec::new();
|
||||
while let Some(key) = map.next_key::<Key>()? {
|
||||
if key.0 {
|
||||
guides.extend(map.next_value::<Vec<Guide>>()?);
|
||||
} else {
|
||||
map.next_value::<IgnoredAny>()?;
|
||||
}
|
||||
}
|
||||
Ok(guides)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a field name is `guides` (without copying the name).
|
||||
struct Key(bool);
|
||||
|
||||
impl<'de> Deserialize<'de> for Key {
|
||||
/// Compare a field name with `guides` as it is read.
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
struct Name;
|
||||
impl Visitor<'_> for Name {
|
||||
type Value = Key;
|
||||
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
f.write_str("a field name")
|
||||
}
|
||||
/// `guides` or another field.
|
||||
fn visit_str<E: serde::de::Error>(self, s: &str) -> Result<Key, E> {
|
||||
Ok(Key(s == KEY))
|
||||
}
|
||||
}
|
||||
d.deserialize_identifier(Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Node {
|
||||
/// A node with Figma's defaults for its kind.
|
||||
pub fn new(id: NodeId, kind: NodeKind, name: impl Into<String>, width: f64, height: f64) -> Self {
|
||||
@@ -709,17 +784,12 @@ impl Node {
|
||||
|
||||
/// The ruler guides on this page or frame, in its own coordinates.
|
||||
pub fn guides(&self) -> &[Guide] {
|
||||
match &self.kind {
|
||||
NodeKind::Page { guides, .. } => guides,
|
||||
_ => &self.guides,
|
||||
}
|
||||
&self.guides
|
||||
}
|
||||
|
||||
/// The ruler guides on this page or frame, to edit.
|
||||
pub fn guides_mut(&mut self) -> &mut Vec<Guide> {
|
||||
match &mut self.kind {
|
||||
NodeKind::Page { guides, .. } => guides,
|
||||
_ => &mut self.guides,
|
||||
}
|
||||
&mut self.guides
|
||||
}
|
||||
|
||||
/// Children in paint order, bottom to top: the child list, reversed in an auto layout frame
|
||||
|
||||
@@ -33,7 +33,9 @@
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{
|
||||
ChildLayout, ContentAlign, CrossAlign, DocError, Document, MainAlign, Node, NodeId, NodeKind, PropDef, Sizing, TextAutoResize, TextData,
|
||||
};
|
||||
use figcraft_geom::Affine;
|
||||
use serde_json::Value;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::instances::PROP_BINDING;
|
||||
use super::*;
|
||||
use super::{Document, Node, NodeId, NodeKind, PropDef, Sizing, TextAutoResize, TextData, inside_instance, owning_component, variant_set};
|
||||
|
||||
/// The property groups whose value layer `n` takes from the same variables as its main layer
|
||||
/// `src` (they differ only where the two resolve in different variable modes).
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use super::diff::geometry_derived;
|
||||
use super::rebase::rebase_instance;
|
||||
use super::variants::instance_issues;
|
||||
use super::*;
|
||||
use super::{
|
||||
ContentAlign, CrossAlign, Document, MainAlign, Node, NodeId, NodeKind, SLOT, Sizing, enclosing_instance, inside_instance, is_instance,
|
||||
owning_instance, structure_locked,
|
||||
};
|
||||
|
||||
/// Slot `slot` (inside an instance) takes the instance's own content: the layers it shows (the
|
||||
/// main's default content until now) stop mirroring the main, and instances among them stand
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use super::diff::{binding_group, bindings_of};
|
||||
use super::sync::{insert_linked, sync_instance_depth};
|
||||
use super::*;
|
||||
use super::{
|
||||
Affine, BTreeMap, ChildLayout, DocError, Document, Node, NodeId, NodeKind, Value, default_instance_name, default_props, holds_content, is_slot,
|
||||
slot_edited,
|
||||
};
|
||||
|
||||
/// Create an instance of `component` under `parent` at `(x, y)` (parent space).
|
||||
pub fn create_instance(doc: &mut Document, component: NodeId, parent: NodeId, x: f64, y: f64) -> Result<NodeId, DocError> {
|
||||
|
||||
@@ -2,7 +2,12 @@ use super::diff::{binding_group, bindings_of, diff_groups, follow_boolean_defaul
|
||||
use super::guard::{own_slot_content, relink_copy};
|
||||
use super::instances::{copy_group, field_group, sync_instance};
|
||||
use super::sync::insert_linked;
|
||||
use super::*;
|
||||
use super::{
|
||||
BTreeMap, BTreeSet, Document, MAX_DEPTH, Node, NodeId, NodeKind, default_instance_name, holds_content, inside_instance, owning_component,
|
||||
owning_instance, root_instance, scope, slot_edited, slot_scope,
|
||||
};
|
||||
|
||||
type OverrideGroups = (Vec<&'static str>, Vec<&'static str>, Vec<&'static str>);
|
||||
|
||||
/// After an edit: record overrides inside instances, then rebuild instances whose main changed,
|
||||
/// whose properties or main were switched, or whose overrides were reset.
|
||||
@@ -14,93 +19,9 @@ pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId]
|
||||
}
|
||||
}
|
||||
let changed = changed.as_slice();
|
||||
// 0. A slot whose layers were added, removed or reordered holds the instance's own content
|
||||
// from now on; copies of instance layers placed elsewhere stop mirroring their main.
|
||||
for &id in changed {
|
||||
let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue };
|
||||
if n.children == b.children || owning_instance(doc, id).is_none() {
|
||||
continue;
|
||||
}
|
||||
if let Some(slot) = slot_scope(doc, id)
|
||||
&& !slot_edited(doc, slot)
|
||||
{
|
||||
own_slot_content(doc, slot);
|
||||
}
|
||||
}
|
||||
for &id in changed {
|
||||
if !before.contains(id) {
|
||||
relink_copy(doc, id);
|
||||
}
|
||||
}
|
||||
adopt_slot_content(doc, before, changed);
|
||||
let mut resync: BTreeSet<NodeId> = BTreeSet::new();
|
||||
// 1. Overrides for layers edited inside instances.
|
||||
for &id in changed {
|
||||
let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue };
|
||||
let Some(inst) = owning_instance(doc, id) else { continue };
|
||||
let top_root = inst == id && !inside_instance(doc, id);
|
||||
if top_root
|
||||
&& let (NodeKind::Instance { component: c1, props: p1 }, NodeKind::Instance { component: c2, props: p2 }) = (&n.kind, &b.kind)
|
||||
&& (c1 != c2 || p1 != p2)
|
||||
{
|
||||
// Switching the main or a property value of an instance isn't an override.
|
||||
resync.insert(id);
|
||||
}
|
||||
let raw = diff_groups(b, n);
|
||||
let mut groups = raw.clone();
|
||||
// An instance's own place, resizing, export settings, interactions and variable modes are
|
||||
// its own, not overrides.
|
||||
if top_root {
|
||||
groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "resizing" | "exports" | "interactions" | "modes"));
|
||||
}
|
||||
if groups.iter().any(|g| matches!(*g, "size" | "position")) {
|
||||
groups.retain(|g| !matches!(*g, "size" | "position") || !geometry_derived(doc, before, id, g));
|
||||
}
|
||||
// A value equal to the main's isn't an override (setting it back clears the override).
|
||||
let mut cleared = Vec::new();
|
||||
if let Some(src) = n.source.and_then(|s| doc.node(s)) {
|
||||
let mut differs = diff_groups(src, n);
|
||||
if inst == id {
|
||||
differs.retain(|g| *g != "name");
|
||||
if n.name != default_instance_name(doc, src.id) {
|
||||
differs.push("name");
|
||||
}
|
||||
}
|
||||
// A value from the same variable as the main's (resolved in this layer's modes) isn't an
|
||||
// override either.
|
||||
let from_variables = variable_groups(n, src);
|
||||
groups.retain(|g| differs.contains(g) && !from_variables.contains(g));
|
||||
// Binding another variable (or none) where the main binds one is an override.
|
||||
for (k, _) in b.bound.iter().chain(n.bound.iter()) {
|
||||
let Some(g) = binding_group(k) else { continue };
|
||||
let rebound = b.bound.get(k) != n.bound.get(k) && !bindings_of(n, g).eq(bindings_of(src, g));
|
||||
if rebound && !groups.contains(&g) {
|
||||
groups.push(g);
|
||||
}
|
||||
}
|
||||
cleared =
|
||||
raw.iter().filter(|g| !matches!(**g, "size" | "position") && !differs.contains(g) && n.overrides.contains(**g)).copied().collect();
|
||||
if top_root {
|
||||
cleared.retain(|g| !matches!(*g, "component" | "props"));
|
||||
}
|
||||
}
|
||||
let lost = b.overrides.iter().any(|g| !n.overrides.contains(g));
|
||||
let nested_switch = !top_root && raw.iter().any(|g| matches!(*g, "component" | "props"));
|
||||
if (!groups.is_empty() || !cleared.is_empty())
|
||||
&& let Some(m) = doc.node_mut(id)
|
||||
{
|
||||
for g in groups {
|
||||
m.overrides.insert(g.to_string());
|
||||
}
|
||||
for g in cleared {
|
||||
m.overrides.remove(g);
|
||||
}
|
||||
}
|
||||
if (lost || nested_switch)
|
||||
&& let Some(r) = root_instance(doc, id)
|
||||
{
|
||||
resync.insert(r);
|
||||
}
|
||||
}
|
||||
record_overrides(doc, before, changed, &mut resync);
|
||||
// 2. Components whose layers changed, were added or removed. Value-only edits (paint, text,
|
||||
// visibility, …) are carried to the copies directly; anything else rebuilds the instances.
|
||||
let mut comps: BTreeSet<NodeId> = BTreeSet::new();
|
||||
@@ -172,6 +93,105 @@ pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId]
|
||||
rebuild_instances(doc, &comps);
|
||||
}
|
||||
|
||||
/// Slot content changes: layers added, removed or reordered in a slot become the instance's own.
|
||||
fn adopt_slot_content(doc: &mut Document, before: &Document, changed: &[NodeId]) {
|
||||
// 0. A slot whose layers were added, removed or reordered holds the instance's own content
|
||||
// from now on; copies of instance layers placed elsewhere stop mirroring their main.
|
||||
for &id in changed {
|
||||
let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue };
|
||||
if n.children == b.children || owning_instance(doc, id).is_none() {
|
||||
continue;
|
||||
}
|
||||
if let Some(slot) = slot_scope(doc, id)
|
||||
&& !slot_edited(doc, slot)
|
||||
{
|
||||
own_slot_content(doc, slot);
|
||||
}
|
||||
}
|
||||
for &id in changed {
|
||||
if !before.contains(id) {
|
||||
relink_copy(doc, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Overrides for layers edited inside instances; the instances to rebuild go into `resync`.
|
||||
fn record_overrides(doc: &mut Document, before: &Document, changed: &[NodeId], resync: &mut BTreeSet<NodeId>) {
|
||||
for &id in changed {
|
||||
let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue };
|
||||
let Some(inst) = owning_instance(doc, id) else { continue };
|
||||
let top_root = inst == id && !inside_instance(doc, id);
|
||||
if top_root
|
||||
&& let (NodeKind::Instance { component: c1, props: p1 }, NodeKind::Instance { component: c2, props: p2 }) = (&n.kind, &b.kind)
|
||||
&& (c1 != c2 || p1 != p2)
|
||||
{
|
||||
// Switching the main or a property value of an instance isn't an override.
|
||||
resync.insert(id);
|
||||
}
|
||||
let (raw, groups, cleared) = override_groups(doc, before, id, n, b, inst, top_root);
|
||||
let lost = b.overrides.iter().any(|g| !n.overrides.contains(g));
|
||||
let nested_switch = !top_root && raw.iter().any(|g| matches!(*g, "component" | "props"));
|
||||
if (!groups.is_empty() || !cleared.is_empty())
|
||||
&& let Some(m) = doc.node_mut(id)
|
||||
{
|
||||
for g in groups {
|
||||
m.overrides.insert(g.to_string());
|
||||
}
|
||||
for g in cleared {
|
||||
m.overrides.remove(g);
|
||||
}
|
||||
}
|
||||
if (lost || nested_switch)
|
||||
&& let Some(r) = root_instance(doc, id)
|
||||
{
|
||||
resync.insert(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `(raw, groups, cleared)` of layer `id` edited from `b` to `n`: the raw changed groups, the ones
|
||||
/// that are now overrides and the overrides that were set back to the main's value.
|
||||
fn override_groups(doc: &Document, before: &Document, id: NodeId, n: &Node, b: &Node, inst: NodeId, top_root: bool) -> OverrideGroups {
|
||||
let raw = diff_groups(b, n);
|
||||
let mut groups = raw.clone();
|
||||
// An instance's own place, resizing, export settings, interactions and variable modes are
|
||||
// its own, not overrides.
|
||||
if top_root {
|
||||
groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "resizing" | "exports" | "interactions" | "modes"));
|
||||
}
|
||||
if groups.iter().any(|g| matches!(*g, "size" | "position")) {
|
||||
groups.retain(|g| !matches!(*g, "size" | "position") || !geometry_derived(doc, before, id, g));
|
||||
}
|
||||
// A value equal to the main's isn't an override (setting it back clears the override).
|
||||
let mut cleared = Vec::new();
|
||||
if let Some(src) = n.source.and_then(|s| doc.node(s)) {
|
||||
let mut differs = diff_groups(src, n);
|
||||
if inst == id {
|
||||
differs.retain(|g| *g != "name");
|
||||
if n.name != default_instance_name(doc, src.id) {
|
||||
differs.push("name");
|
||||
}
|
||||
}
|
||||
// A value from the same variable as the main's (resolved in this layer's modes) isn't an
|
||||
// override either.
|
||||
let from_variables = variable_groups(n, src);
|
||||
groups.retain(|g| differs.contains(g) && !from_variables.contains(g));
|
||||
// Binding another variable (or none) where the main binds one is an override.
|
||||
for (k, _) in b.bound.iter().chain(n.bound.iter()) {
|
||||
let Some(g) = binding_group(k) else { continue };
|
||||
let rebound = b.bound.get(k) != n.bound.get(k) && !bindings_of(n, g).eq(bindings_of(src, g));
|
||||
if rebound && !groups.contains(&g) {
|
||||
groups.push(g);
|
||||
}
|
||||
}
|
||||
cleared = raw.iter().filter(|g| !matches!(**g, "size" | "position") && !differs.contains(g) && n.overrides.contains(**g)).copied().collect();
|
||||
if top_root {
|
||||
cleared.retain(|g| !matches!(*g, "component" | "props"));
|
||||
}
|
||||
}
|
||||
(raw, groups, cleared)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Tests: rebuild instances even for edits that could be patched (to compare both ways).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::diff::{diff_groups, variable_groups};
|
||||
use super::instances::{field_group, path_map};
|
||||
use super::*;
|
||||
use super::{BTreeMap, BTreeSet, Document, MAX_DEPTH, Node, NodeId, NodeKind, default_instance_name, holds_content};
|
||||
|
||||
/// Bounded source and parent lookups while a rebase reads an unchanged document.
|
||||
pub(super) struct RebaseSources<'a> {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use super::diff::diff_groups;
|
||||
use super::instances::{Carry, apply_prop, carry_overrides, carry_prop_bindings, copy_group, field_group, follow_variant_resizing, path_map};
|
||||
use super::*;
|
||||
use super::{
|
||||
Affine, BTreeMap, BTreeSet, DocError, Document, MAX_DEPTH, Node, NodeId, NodeKind, SLOT, Sizing, Value, default_instance_name, default_props,
|
||||
holds_content, inside_instance, is_slot, owning_component, variant_set,
|
||||
};
|
||||
|
||||
/// `spare`: layers the outer rebuild didn't reuse that were below `inst` (a swapped nested
|
||||
/// instance): its layers the outer main doesn't have, matched here so they keep ids and overrides.
|
||||
@@ -16,31 +19,10 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize
|
||||
// A missing main, or an instance inside its own main (would recurse forever).
|
||||
return;
|
||||
}
|
||||
let mut eff_props = default_props(&comp);
|
||||
for (k, v) in root_props {
|
||||
if eff_props.contains_key(k) {
|
||||
eff_props.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
let eff_props = effective_props(&comp, root_props);
|
||||
// Old layers (kept alive for their overrides), by (parent, source), by source alone (when only
|
||||
// one layer mirrors it) and, only when those don't match, by name path.
|
||||
let mut old_ids = old_layers(doc, inst);
|
||||
old_ids.extend(spare.keys().copied());
|
||||
old_ids.sort();
|
||||
old_ids.dedup();
|
||||
let mut old: BTreeMap<NodeId, std::sync::Arc<Node>> = spare;
|
||||
let mut by_source: BTreeMap<(NodeId, NodeId), NodeId> = BTreeMap::new();
|
||||
let mut by_src: BTreeMap<NodeId, Option<NodeId>> = BTreeMap::new();
|
||||
for i in &old_ids {
|
||||
let Some(n) = doc.node_arc(*i).or_else(|| old.get(i)).cloned() else { continue };
|
||||
if let Some(s) = n.source {
|
||||
if let Some(p) = n.parent {
|
||||
by_source.entry((p, s)).or_insert(*i);
|
||||
}
|
||||
by_src.entry(s).and_modify(|e| *e = None).or_insert(Some(*i));
|
||||
}
|
||||
old.insert(*i, n);
|
||||
}
|
||||
let (mut old, by_source, by_src) = index_old_layers(doc, inst, spare);
|
||||
let mut by_path: Option<BTreeMap<String, NodeId>> = None;
|
||||
let mut main_paths: Option<BTreeMap<NodeId, String>> = None;
|
||||
// Ids for the copies: reuse the matching old layer's id (a layer the main moved to another
|
||||
@@ -124,23 +106,7 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize
|
||||
// Component properties: the main layer's bindings use this instance's values; layers of
|
||||
// nested instances use the nested instance's values (one level per source hop).
|
||||
let enc = enclosing.get(&parent).cloned().unwrap_or_default();
|
||||
let mut chain: Vec<&Node> = vec![orig];
|
||||
while chain.len() < enc.len() {
|
||||
let Some(next) = chain.last().and_then(|c| c.source).and_then(|x| doc.node(x)) else { break };
|
||||
chain.push(next);
|
||||
}
|
||||
for (j, level) in chain.iter().enumerate().rev() {
|
||||
let Some(props) = enc.get(j).and_then(|e| inst_props.get(e)) else { continue };
|
||||
for (field, prop) in &level.prop_refs {
|
||||
let g = field_group(field);
|
||||
if n.overrides.contains(g) || (j > 0 && orig.overrides.contains(g)) {
|
||||
continue;
|
||||
}
|
||||
if let Some(val) = props.get(prop) {
|
||||
apply_prop(doc, &mut n, field, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
apply_enclosing_props(doc, &mut n, orig, &enc, &inst_props);
|
||||
if let NodeKind::Instance { component: c, props } = &n.kind {
|
||||
inst_props.insert(nid, props.clone());
|
||||
let mut e = enc;
|
||||
@@ -167,6 +133,155 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize
|
||||
p.children.retain(|c| *c != id);
|
||||
}
|
||||
}
|
||||
let fresh = fresh_root(doc, &mut root, &comp, inst, component, &map);
|
||||
if let Some(r) = doc.node_mut(inst) {
|
||||
*r = fresh;
|
||||
}
|
||||
// A resized instance: children follow their constraints (auto layout re-flows later).
|
||||
let (iw, ih) = doc.node(inst).map(|n| (n.width, n.height)).unwrap_or((comp.width, comp.height));
|
||||
if (iw - comp.width).abs() > 1e-9 || (ih - comp.height).abs() > 1e-9 {
|
||||
figcraft_layout::apply_constraints(doc, inst, comp.width, comp.height);
|
||||
remeasure = map.values().copied().collect();
|
||||
}
|
||||
follow_geometry_overrides(doc, &src, &map, &old, &mut remeasure);
|
||||
remeasure_text(doc, remeasure);
|
||||
rebuild_nested(doc, nested, &old, &used, depth);
|
||||
}
|
||||
|
||||
/// Geometry overrides of children win over constraints.
|
||||
fn follow_geometry_overrides(
|
||||
doc: &mut Document,
|
||||
src: &[NodeId],
|
||||
map: &BTreeMap<NodeId, NodeId>,
|
||||
old: &BTreeMap<NodeId, std::sync::Arc<Node>>,
|
||||
remeasure: &mut Vec<NodeId>,
|
||||
) {
|
||||
for s in src {
|
||||
let Some(nid) = map.get(s).copied() else { continue };
|
||||
let Some(o) = old.get(&nid) else { continue };
|
||||
// A frame that kept the size its own resizing gave it: its layers (copied at their main
|
||||
// layers' places) follow their constraints from its main layer's size.
|
||||
if o.overrides.contains("resizing")
|
||||
&& !o.overrides.contains("size")
|
||||
&& doc.node(nid).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none())
|
||||
&& let Some((mw, mh)) = doc.node(*s).map(|m| (m.width, m.height))
|
||||
{
|
||||
figcraft_layout::apply_constraints(doc, nid, mw, mh);
|
||||
}
|
||||
if !o.overrides.contains("size") && !o.overrides.contains("position") {
|
||||
continue;
|
||||
}
|
||||
remeasure.push(nid);
|
||||
let Some((ow, oh)) = doc.node(nid).map(|n| (n.width, n.height)) else { continue };
|
||||
if o.overrides.contains("size") {
|
||||
// Through set_size so vector networks scale with the box.
|
||||
figcraft_layout::set_size(doc, nid, o.width, o.height);
|
||||
if doc.node(nid).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none()) {
|
||||
figcraft_layout::apply_constraints(doc, nid, ow, oh);
|
||||
}
|
||||
}
|
||||
if let Some(n) = doc.node_mut(nid) {
|
||||
carry_overrides(o, n, Carry::Geometry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Nested instances showing another main than the main's copy (swapped): rebuild from it,
|
||||
/// with the old layers below them this rebuild didn't reuse.
|
||||
fn rebuild_nested(doc: &mut Document, nested: Vec<NodeId>, old: &BTreeMap<NodeId, std::sync::Arc<Node>>, used: &BTreeSet<NodeId>, depth: usize) {
|
||||
let unused: BTreeMap<NodeId, std::sync::Arc<Node>> =
|
||||
old.iter().filter(|(i, _)| !used.contains(*i) && !doc.contains(**i)).map(|(i, x)| (*i, x.clone())).collect();
|
||||
for n in nested {
|
||||
let mut below: BTreeMap<NodeId, std::sync::Arc<Node>> = unused
|
||||
.iter()
|
||||
.filter(|(_, x)| {
|
||||
let mut cur = x.parent;
|
||||
for _ in 0..=MAX_DEPTH * 4 {
|
||||
match cur {
|
||||
Some(p) if p == n => return true,
|
||||
Some(p) => cur = unused.get(&p).and_then(|a| a.parent),
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
false
|
||||
})
|
||||
.map(|(i, x)| (*i, x.clone()))
|
||||
.collect();
|
||||
take_placing_overrides(doc, n, old, &mut below);
|
||||
sync_instance_depth(doc, n, depth + 1, below);
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_enclosing_props(doc: &Document, n: &mut Node, orig: &Node, enc: &[NodeId], inst_props: &BTreeMap<NodeId, BTreeMap<String, Value>>) {
|
||||
let mut chain: Vec<&Node> = vec![orig];
|
||||
while chain.len() < enc.len() {
|
||||
let Some(next) = chain.last().and_then(|c| c.source).and_then(|x| doc.node(x)) else { break };
|
||||
chain.push(next);
|
||||
}
|
||||
for (j, level) in chain.iter().enumerate().rev() {
|
||||
let Some(props) = enc.get(j).and_then(|e| inst_props.get(e)) else { continue };
|
||||
for (field, prop) in &level.prop_refs {
|
||||
let g = field_group(field);
|
||||
if n.overrides.contains(g) || (j > 0 && orig.overrides.contains(g)) {
|
||||
continue;
|
||||
}
|
||||
if let Some(val) = props.get(prop) {
|
||||
apply_prop(doc, n, field, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Text whose characters or style differ from the main re-measures.
|
||||
fn remeasure_text(doc: &mut Document, remeasure: Vec<NodeId>) {
|
||||
for nid in remeasure {
|
||||
if let Some(n) = doc.node(nid)
|
||||
&& let Some((w, h)) = figcraft_text::auto_size(n)
|
||||
&& ((w - n.width).abs() > 1e-6 || (h - n.height).abs() > 1e-6)
|
||||
{
|
||||
figcraft_layout::set_size(doc, nid, w, h);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The component's property defaults with the instance's values for the ones it has.
|
||||
fn effective_props(comp: &Node, root_props: &BTreeMap<String, Value>) -> BTreeMap<String, Value> {
|
||||
let mut eff_props = default_props(comp);
|
||||
for (k, v) in root_props {
|
||||
if eff_props.contains_key(k) {
|
||||
eff_props.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
eff_props
|
||||
}
|
||||
|
||||
type OldIndex = (BTreeMap<NodeId, std::sync::Arc<Node>>, BTreeMap<(NodeId, NodeId), NodeId>, BTreeMap<NodeId, Option<NodeId>>);
|
||||
|
||||
/// The old layers of `inst` (and `spare`) by id, by (parent, source) and by source alone (`None`
|
||||
/// when several layers mirror it).
|
||||
fn index_old_layers(doc: &Document, inst: NodeId, spare: BTreeMap<NodeId, std::sync::Arc<Node>>) -> OldIndex {
|
||||
let mut old_ids = old_layers(doc, inst);
|
||||
old_ids.extend(spare.keys().copied());
|
||||
old_ids.sort();
|
||||
old_ids.dedup();
|
||||
let mut old = spare;
|
||||
let mut by_source: BTreeMap<(NodeId, NodeId), NodeId> = BTreeMap::new();
|
||||
let mut by_src: BTreeMap<NodeId, Option<NodeId>> = BTreeMap::new();
|
||||
for i in &old_ids {
|
||||
let Some(n) = doc.node_arc(*i).or_else(|| old.get(i)).cloned() else { continue };
|
||||
if let Some(s) = n.source {
|
||||
if let Some(p) = n.parent {
|
||||
by_source.entry((p, s)).or_insert(*i);
|
||||
}
|
||||
by_src.entry(s).and_modify(|e| *e = None).or_insert(Some(*i));
|
||||
}
|
||||
old.insert(*i, n);
|
||||
}
|
||||
(old, by_source, by_src)
|
||||
}
|
||||
|
||||
/// The rebuilt root of instance `inst` of `comp`.
|
||||
fn fresh_root(doc: &Document, root: &mut Node, comp: &Node, inst: NodeId, component: NodeId, map: &BTreeMap<NodeId, NodeId>) -> Node {
|
||||
// Root: follows the main except its own placement, name, size and overrides.
|
||||
let order: Vec<NodeId> = comp.children.iter().filter_map(|c| map.get(c).copied()).filter(|c| doc.contains(*c)).collect();
|
||||
let mut fresh = comp.clone();
|
||||
@@ -175,7 +290,7 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize
|
||||
fresh.children = order;
|
||||
fresh.transform = root.transform;
|
||||
fresh.kind = root.kind.clone();
|
||||
carry_prop_bindings(&root, &mut fresh);
|
||||
carry_prop_bindings(root, &mut fresh);
|
||||
// A nested instance keeps mirroring its layer of the outer main.
|
||||
let nested_copy = inside_instance(doc, inst);
|
||||
fresh.source = if nested_copy { root.source } else { Some(component) };
|
||||
@@ -212,81 +327,12 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize
|
||||
fresh.name = if renamed { root.name.clone() } else { default_instance_name(doc, component) };
|
||||
// Switched to another variant: its own overrides stay where both variants agreed.
|
||||
if let Some(prev) = root.source.and_then(|p| doc.node(p)).filter(|p| sibling_variants(doc, p.id, component)) {
|
||||
let differs = diff_groups(prev, &comp);
|
||||
let differs = diff_groups(prev, comp);
|
||||
root.overrides.retain(|g| g == "name" || !differs.contains(&g.as_str()));
|
||||
follow_variant_resizing(&mut fresh.child_layout, &prev.child_layout, &comp.child_layout);
|
||||
}
|
||||
carry_overrides(&root, &mut fresh, Carry::Root);
|
||||
if let Some(r) = doc.node_mut(inst) {
|
||||
*r = fresh;
|
||||
}
|
||||
// A resized instance: children follow their constraints (auto layout re-flows later).
|
||||
let (iw, ih) = doc.node(inst).map(|n| (n.width, n.height)).unwrap_or((comp.width, comp.height));
|
||||
if (iw - comp.width).abs() > 1e-9 || (ih - comp.height).abs() > 1e-9 {
|
||||
figcraft_layout::apply_constraints(doc, inst, comp.width, comp.height);
|
||||
remeasure = map.values().copied().collect();
|
||||
}
|
||||
// Geometry overrides of children win over constraints.
|
||||
for s in &src {
|
||||
let Some(nid) = map.get(s).copied() else { continue };
|
||||
let Some(o) = old.get(&nid) else { continue };
|
||||
// A frame that kept the size its own resizing gave it: its layers (copied at their main
|
||||
// layers' places) follow their constraints from its main layer's size.
|
||||
if o.overrides.contains("resizing")
|
||||
&& !o.overrides.contains("size")
|
||||
&& doc.node(nid).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none())
|
||||
&& let Some((mw, mh)) = doc.node(*s).map(|m| (m.width, m.height))
|
||||
{
|
||||
figcraft_layout::apply_constraints(doc, nid, mw, mh);
|
||||
}
|
||||
if !o.overrides.contains("size") && !o.overrides.contains("position") {
|
||||
continue;
|
||||
}
|
||||
remeasure.push(nid);
|
||||
let Some((ow, oh)) = doc.node(nid).map(|n| (n.width, n.height)) else { continue };
|
||||
if o.overrides.contains("size") {
|
||||
// Through set_size so vector networks scale with the box.
|
||||
figcraft_layout::set_size(doc, nid, o.width, o.height);
|
||||
if doc.node(nid).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none()) {
|
||||
figcraft_layout::apply_constraints(doc, nid, ow, oh);
|
||||
}
|
||||
}
|
||||
if let Some(n) = doc.node_mut(nid) {
|
||||
carry_overrides(o, n, Carry::Geometry);
|
||||
}
|
||||
}
|
||||
// Text whose characters or style differ from the main re-measures.
|
||||
for nid in remeasure {
|
||||
if let Some(n) = doc.node(nid)
|
||||
&& let Some((w, h)) = figcraft_text::auto_size(n)
|
||||
&& ((w - n.width).abs() > 1e-6 || (h - n.height).abs() > 1e-6)
|
||||
{
|
||||
figcraft_layout::set_size(doc, nid, w, h);
|
||||
}
|
||||
}
|
||||
// Nested instances showing another main than the main's copy (swapped): rebuild from it,
|
||||
// with the old layers below them this rebuild didn't reuse.
|
||||
let unused: BTreeMap<NodeId, std::sync::Arc<Node>> =
|
||||
old.iter().filter(|(i, _)| !used.contains(*i) && !doc.contains(**i)).map(|(i, x)| (*i, x.clone())).collect();
|
||||
for n in nested {
|
||||
let mut below: BTreeMap<NodeId, std::sync::Arc<Node>> = unused
|
||||
.iter()
|
||||
.filter(|(_, x)| {
|
||||
let mut cur = x.parent;
|
||||
for _ in 0..=MAX_DEPTH * 4 {
|
||||
match cur {
|
||||
Some(p) if p == n => return true,
|
||||
Some(p) => cur = unused.get(&p).and_then(|a| a.parent),
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
false
|
||||
})
|
||||
.map(|(i, x)| (*i, x.clone()))
|
||||
.collect();
|
||||
take_placing_overrides(doc, n, &old, &mut below);
|
||||
sync_instance_depth(doc, n, depth + 1, below);
|
||||
}
|
||||
carry_overrides(root, &mut fresh, Carry::Root);
|
||||
fresh
|
||||
}
|
||||
|
||||
/// Before swapped nested instance `n` is rebuilt from its new main: each of its old layers (the
|
||||
|
||||
@@ -383,6 +383,22 @@ fn resized_instances_follow_constraints_and_auto_layout() {
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
/// The tail of `component_properties_apply_to_instances`: unbind, delete, then a variant property.
|
||||
fn unbind_delete_and_vary(s: &mut Session, c: NodeId, i: NodeId, title: NodeId, dot: NodeId) {
|
||||
// Unbind and delete.
|
||||
ex(s, "component.bindProperty", json!({"layer": dot, "property": null, "field": "visible"}));
|
||||
assert!(node(s, copy_of(s, i, dot)).visible);
|
||||
ex(s, "component.deleteProperty", json!({"id": c, "name": "Heading"}));
|
||||
assert!(node(s, title).prop_refs.is_empty());
|
||||
assert!(matches!(&node(s, i).kind, NodeKind::Instance { props, .. } if !props.contains_key("Heading")));
|
||||
let desc = ex(s, "component.properties", json!({"id": i}));
|
||||
assert_eq!(desc["properties"].as_array().unwrap().len(), 1);
|
||||
// A variant property on a lone component makes it a component set.
|
||||
let set = idv(&ex(s, "component.addProperty", json!({"id": c, "name": "Theme", "type": "variant", "default": "Light"})));
|
||||
assert!(matches!(node(s, set).kind, NodeKind::ComponentSet { .. }));
|
||||
assert_eq!(node(s, c).name, "Theme=Light");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_properties_apply_to_instances() {
|
||||
let mut s = Session::new();
|
||||
@@ -414,18 +430,7 @@ fn component_properties_apply_to_instances() {
|
||||
assert!(s.execute("component.bindProperty", &json!({"layer": dot, "property": "Heading"})).is_err());
|
||||
assert!(s.execute("component.bindProperty", &json!({"layer": ititle, "property": "Heading"})).is_err());
|
||||
assert!(s.execute("component.addProperty", &json!({"id": c, "name": "Heading", "type": "text"})).is_err());
|
||||
// Unbind and delete.
|
||||
ex(&mut s, "component.bindProperty", json!({"layer": dot, "property": null, "field": "visible"}));
|
||||
assert!(node(&s, copy_of(&s, i, dot)).visible);
|
||||
ex(&mut s, "component.deleteProperty", json!({"id": c, "name": "Heading"}));
|
||||
assert!(node(&s, title).prop_refs.is_empty());
|
||||
assert!(matches!(&node(&s, i).kind, NodeKind::Instance { props, .. } if !props.contains_key("Heading")));
|
||||
let desc = ex(&mut s, "component.properties", json!({"id": i}));
|
||||
assert_eq!(desc["properties"].as_array().unwrap().len(), 1);
|
||||
// A variant property on a lone component makes it a component set.
|
||||
let set = idv(&ex(&mut s, "component.addProperty", json!({"id": c, "name": "Theme", "type": "variant", "default": "Light"})));
|
||||
assert!(matches!(node(&s, set).kind, NodeKind::ComponentSet { .. }));
|
||||
assert_eq!(node(&s, c).name, "Theme=Light");
|
||||
unbind_delete_and_vary(&mut s, c, i, title, dot);
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{BTreeMap, Document, MAX_DEPTH, Node, NodeId, NodeKind, holds_content, inside_instance};
|
||||
|
||||
/// A set's default variant: the one in its top-left corner (nearest to it, then the top-most, then
|
||||
/// the left-most).
|
||||
|
||||
@@ -15,7 +15,11 @@ use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_doc::proto::Action;
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{
|
||||
Collection, CollectionId, Document, ImportedComponent, ImportedRecord, ImportedStyle, LibraryAsset, LibraryCollection, LibraryLink,
|
||||
LibraryManifest, LibraryStyle, LibraryVariable, Node, NodeId, NodeKind, PropDef, PublishedLibrary, Style, StyleKind, Variable, VariableId,
|
||||
VariableValue, Variables, node_images, paint_images, used_images,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::components::inside_instance;
|
||||
|
||||
@@ -107,6 +107,49 @@ fn publish_to(s: &mut Session, path: &str) -> String {
|
||||
r["id"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
/// `publish_manifest_roundtrip`: the file saved and read back keeps the manifest.
|
||||
fn saved_and_read_back(doc: &Document, lib: &PublishedLibrary) {
|
||||
let m = &lib.manifest;
|
||||
// Saved and read back: the author's file and a reader of the library see the same manifest.
|
||||
let bytes = figcraft_format::save_bytes(doc, None).unwrap();
|
||||
let (back, issues) = figcraft_format::load_bytes(&bytes).unwrap();
|
||||
assert!(issues.is_empty(), "{issues:?}");
|
||||
assert_eq!(back.published.as_ref().map(|p| &p.manifest), Some(m));
|
||||
let read = figcraft_format::read_library(std::io::Cursor::new(&bytes)).unwrap().unwrap();
|
||||
assert_eq!(read.manifest, *m);
|
||||
assert_eq!(read.doc.node_count(), lib.doc.node_count());
|
||||
// Older files and files never published have no library; readers that don't know libraries
|
||||
// are unaffected (the entries are extra archive members).
|
||||
let plain = figcraft_format::save_bytes(&Document::new("x"), None).unwrap();
|
||||
assert!(figcraft_format::read_library(std::io::Cursor::new(&plain)).unwrap().is_none());
|
||||
assert!(figcraft_format::load_bytes(&plain).unwrap().0.published.is_none());
|
||||
}
|
||||
|
||||
/// `publish_manifest_roundtrip`: a republish bumps only what changed.
|
||||
fn republish_bumps_touched(s: &mut Session, k: &Kit) {
|
||||
// Nothing changed: refused. A change bumps only what it touches (and what uses it).
|
||||
let e = s.execute("library.publish", &json!({})).unwrap_err();
|
||||
assert!(e.to_string().contains("no changes"), "{e}");
|
||||
ex(s, "node.set", json!({"id": k.card, "props": {"fill": "#eeeeee"}}));
|
||||
let r = ex(s, "library.publish", json!({}));
|
||||
assert_eq!(r["version"], 2);
|
||||
let m = s.doc().unwrap().published.clone().unwrap().manifest.clone();
|
||||
assert_eq!(m.component(k.card).map(|a| a.version), Some(2));
|
||||
assert_eq!(m.component(k.button).map(|a| a.version), Some(1), "untouched assets keep their version");
|
||||
assert_eq!(m.description, "Our kit", "the description carries over");
|
||||
// Excluding an asset; a used component still comes along (not offered).
|
||||
let r = ex(s, "library.publish", json!({"exclude": ["Button"]}));
|
||||
assert_eq!(r["components"], 1);
|
||||
let m = s.doc().unwrap().published.clone().unwrap().manifest.clone();
|
||||
assert!(m.component(k.button).is_some_and(|a| !a.published));
|
||||
// Unknown assets are an error, not ignored.
|
||||
assert!(s.execute("library.publish", &json!({"include": ["Nope"]})).is_err());
|
||||
assert!(s.execute("library.publish", &json!({"include": "Button"})).is_err());
|
||||
// Undo takes the publish back.
|
||||
s.undo().unwrap();
|
||||
assert_eq!(s.doc().unwrap().published.as_ref().map(|p| p.manifest.version), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_manifest_roundtrip() {
|
||||
let mut s = Session::new();
|
||||
@@ -126,41 +169,74 @@ fn publish_manifest_roundtrip() {
|
||||
assert!(lib.doc.node(k.label).is_some());
|
||||
assert!(lib.doc.validate().is_empty());
|
||||
assert_eq!(lib.doc.styles.len(), 1);
|
||||
// Saved and read back: the author's file and a reader of the library see the same manifest.
|
||||
let bytes = figcraft_format::save_bytes(doc, None).unwrap();
|
||||
let (back, issues) = figcraft_format::load_bytes(&bytes).unwrap();
|
||||
assert!(issues.is_empty(), "{issues:?}");
|
||||
assert_eq!(back.published.as_ref().map(|p| &p.manifest), Some(m));
|
||||
let read = figcraft_format::read_library(std::io::Cursor::new(&bytes)).unwrap().unwrap();
|
||||
assert_eq!(read.manifest, *m);
|
||||
assert_eq!(read.doc.node_count(), lib.doc.node_count());
|
||||
// Older files and files never published have no library; readers that don't know libraries
|
||||
// are unaffected (the entries are extra archive members).
|
||||
let plain = figcraft_format::save_bytes(&Document::new("x"), None).unwrap();
|
||||
assert!(figcraft_format::read_library(std::io::Cursor::new(&plain)).unwrap().is_none());
|
||||
assert!(figcraft_format::load_bytes(&plain).unwrap().0.published.is_none());
|
||||
saved_and_read_back(doc, &lib);
|
||||
|
||||
// Nothing changed: refused. A change bumps only what it touches (and what uses it).
|
||||
let e = s.execute("library.publish", &json!({})).unwrap_err();
|
||||
assert!(e.to_string().contains("no changes"), "{e}");
|
||||
ex(&mut s, "node.set", json!({"id": k.card, "props": {"fill": "#eeeeee"}}));
|
||||
let r = ex(&mut s, "library.publish", json!({}));
|
||||
assert_eq!(r["version"], 2);
|
||||
let m = s.doc().unwrap().published.clone().unwrap().manifest.clone();
|
||||
assert_eq!(m.component(k.card).map(|a| a.version), Some(2));
|
||||
assert_eq!(m.component(k.button).map(|a| a.version), Some(1), "untouched assets keep their version");
|
||||
assert_eq!(m.description, "Our kit", "the description carries over");
|
||||
// Excluding an asset; a used component still comes along (not offered).
|
||||
let r = ex(&mut s, "library.publish", json!({"exclude": ["Button"]}));
|
||||
assert_eq!(r["components"], 1);
|
||||
let m = s.doc().unwrap().published.clone().unwrap().manifest.clone();
|
||||
assert!(m.component(k.button).is_some_and(|a| !a.published));
|
||||
// Unknown assets are an error, not ignored.
|
||||
assert!(s.execute("library.publish", &json!({"include": ["Nope"]})).is_err());
|
||||
assert!(s.execute("library.publish", &json!({"include": "Button"})).is_err());
|
||||
// Undo takes the publish back.
|
||||
s.undo().unwrap();
|
||||
assert_eq!(s.doc().unwrap().published.as_ref().map(|p| p.manifest.version), Some(2));
|
||||
republish_bumps_touched(&mut s, &k);
|
||||
}
|
||||
|
||||
/// `add_place_and_update_keep_overrides`: library copies refuse every edit and roll back.
|
||||
fn assert_remote_read_only(s: &mut Session, b: NodeId, remote_button: NodeId) {
|
||||
let unchanged = s.doc().unwrap().clone();
|
||||
let e = s.execute("node.set", &json!({"id": remote_button, "props": {"fill": "#ff0000"}})).unwrap_err();
|
||||
assert!(e.to_string().contains("can't be edited"), "{e}");
|
||||
assert_eq!(s.doc().unwrap(), &unchanged, "a refused remote edit rolls back");
|
||||
for (cmd, args) in
|
||||
[("node.reparent", json!({"ids": [remote_button], "parent": s.page().unwrap()})), ("edit.delete", json!({"ids": [remote_button]}))]
|
||||
{
|
||||
assert!(s.execute(cmd, &args).is_err(), "{cmd} must preserve read-only copies");
|
||||
assert_eq!(s.doc().unwrap(), &unchanged, "refused {cmd} rolls back");
|
||||
}
|
||||
let style = s.doc().unwrap().styles[0].id.clone();
|
||||
assert!(s.execute("style.update", &json!({"style": style, "name": "Mine"})).is_err());
|
||||
assert!(s.execute("style.delete", &json!({"style": style})).is_err());
|
||||
let e = s.execute("component.goToMain", &json!({"id": b})).unwrap_err();
|
||||
assert!(e.to_string().contains("library"), "{e}");
|
||||
assert!(s.execute("select.set", &json!({"ids": [remote_button]})).is_err(), "library copies can't be selected");
|
||||
assert!(s.doc().unwrap().pages.contains(&s.page().unwrap()));
|
||||
assert!(s.execute("component.pushChanges", &json!({"id": b})).is_err());
|
||||
assert_eq!(fill(s, remote_button), "0000FF");
|
||||
}
|
||||
|
||||
/// `add_place_and_update_keep_overrides`: the library changes and publishes; the copies follow. Returns the Card's instance.
|
||||
fn library_updates_flow(s: &mut Session, lib: &mut Session, k: &Kit, lid: &str, [b, b_label, remote_button, main]: [NodeId; 4]) -> NodeId {
|
||||
// The library changes the Button (fill via its style, and its label) and publishes again.
|
||||
ex(lib, "style.update", json!({"style": "Brand", "value": "#00ff00"}));
|
||||
ex(lib, "text.set", json!({"id": k.label, "characters": "Go"}));
|
||||
ex(lib, "library.publish", json!({}));
|
||||
ex(lib, "file.save", json!({}));
|
||||
|
||||
let u = ex(s, "library.updates", json!({}));
|
||||
assert_eq!(u["count"], 3, "Button, Card and the style: {u}");
|
||||
let label_ids: Vec<NodeId> = s.doc().unwrap().subtree(b);
|
||||
ex(s, "library.acceptUpdates", json!({}));
|
||||
assert_eq!(fill(s, b), "00FF00", "the instance follows the updated main");
|
||||
assert_eq!(chars(s, b_label), "Buy", "its override is kept");
|
||||
assert_eq!(s.doc().unwrap().subtree(b), label_ids, "layer ids are kept");
|
||||
assert_eq!(fill(s, remote_button), "00FF00");
|
||||
let style = s.doc().unwrap().styles[0].clone();
|
||||
assert!(matches!(&style.kind, StyleKind::Paint { paints } if paints[0].solid_color().unwrap().to_hex() == "00FF00"));
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(doc.libraries.link(lid).map(|l| l.version), Some(2));
|
||||
// The Card's nested Button follows too.
|
||||
let card_inst = doc.nodes().find(|n| matches!(n.kind, NodeKind::Instance { component, .. } if component == main)).map(|n| n.id).unwrap();
|
||||
let nested = node(s, card_inst).children[0];
|
||||
assert_eq!(fill(s, nested), "00FF00");
|
||||
assert_eq!(ex(s, "library.updates", json!({}))["count"], 0);
|
||||
healthy(s);
|
||||
// A structural change: the Button gains a layer (the Card's nested Button too).
|
||||
ex(lib, "shape.ellipse", json!({"x": 80, "y": 10, "width": 8, "height": 8, "parent": k.button, "props": {"name": "Dot"}}));
|
||||
ex(lib, "library.publish", json!({}));
|
||||
ex(lib, "file.save", json!({}));
|
||||
ex(s, "library.acceptUpdates", json!({"ids": [lid]}));
|
||||
assert_eq!(node(s, b).children.len(), 2);
|
||||
assert_eq!(node(s, nested).children.len(), 2);
|
||||
assert_eq!(chars(s, b_label), "Buy", "overrides survive a structural update");
|
||||
assert_eq!(s.doc().unwrap().libraries.link(lid).map(|l| l.version), Some(3));
|
||||
healthy(s);
|
||||
// Overrides still work after the update; resetting shows the library's text.
|
||||
ex(s, "component.resetInstance", json!({"ids": [b]}));
|
||||
assert_eq!(chars(s, node(s, b).children[0]), "Go");
|
||||
card_inst
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -211,63 +287,9 @@ fn add_place_and_update_keep_overrides() {
|
||||
NodeKind::Instance { component, .. } => component,
|
||||
_ => panic!("not an instance"),
|
||||
};
|
||||
let unchanged = s.doc().unwrap().clone();
|
||||
let e = s.execute("node.set", &json!({"id": remote_button, "props": {"fill": "#ff0000"}})).unwrap_err();
|
||||
assert!(e.to_string().contains("can't be edited"), "{e}");
|
||||
assert_eq!(s.doc().unwrap(), &unchanged, "a refused remote edit rolls back");
|
||||
for (cmd, args) in
|
||||
[("node.reparent", json!({"ids": [remote_button], "parent": s.page().unwrap()})), ("edit.delete", json!({"ids": [remote_button]}))]
|
||||
{
|
||||
assert!(s.execute(cmd, &args).is_err(), "{cmd} must preserve read-only copies");
|
||||
assert_eq!(s.doc().unwrap(), &unchanged, "refused {cmd} rolls back");
|
||||
}
|
||||
let style = s.doc().unwrap().styles[0].id.clone();
|
||||
assert!(s.execute("style.update", &json!({"style": style, "name": "Mine"})).is_err());
|
||||
assert!(s.execute("style.delete", &json!({"style": style})).is_err());
|
||||
let e = s.execute("component.goToMain", &json!({"id": b})).unwrap_err();
|
||||
assert!(e.to_string().contains("library"), "{e}");
|
||||
assert!(s.execute("select.set", &json!({"ids": [remote_button]})).is_err(), "library copies can't be selected");
|
||||
assert!(s.doc().unwrap().pages.contains(&s.page().unwrap()));
|
||||
assert!(s.execute("component.pushChanges", &json!({"id": b})).is_err());
|
||||
assert_eq!(fill(&s, remote_button), "0000FF");
|
||||
assert_remote_read_only(&mut s, b, remote_button);
|
||||
|
||||
// The library changes the Button (fill via its style, and its label) and publishes again.
|
||||
ex(&mut lib, "style.update", json!({"style": "Brand", "value": "#00ff00"}));
|
||||
ex(&mut lib, "text.set", json!({"id": k.label, "characters": "Go"}));
|
||||
ex(&mut lib, "library.publish", json!({}));
|
||||
ex(&mut lib, "file.save", json!({}));
|
||||
|
||||
let u = ex(&mut s, "library.updates", json!({}));
|
||||
assert_eq!(u["count"], 3, "Button, Card and the style: {u}");
|
||||
let label_ids: Vec<NodeId> = s.doc().unwrap().subtree(b);
|
||||
ex(&mut s, "library.acceptUpdates", json!({}));
|
||||
assert_eq!(fill(&s, b), "00FF00", "the instance follows the updated main");
|
||||
assert_eq!(chars(&s, b_label), "Buy", "its override is kept");
|
||||
assert_eq!(s.doc().unwrap().subtree(b), label_ids, "layer ids are kept");
|
||||
assert_eq!(fill(&s, remote_button), "00FF00");
|
||||
let style = s.doc().unwrap().styles[0].clone();
|
||||
assert!(matches!(&style.kind, StyleKind::Paint { paints } if paints[0].solid_color().unwrap().to_hex() == "00FF00"));
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(doc.libraries.link(&lid).map(|l| l.version), Some(2));
|
||||
// The Card's nested Button follows too.
|
||||
let card_inst = doc.nodes().find(|n| matches!(n.kind, NodeKind::Instance { component, .. } if component == main)).map(|n| n.id).unwrap();
|
||||
let nested = node(&s, card_inst).children[0];
|
||||
assert_eq!(fill(&s, nested), "00FF00");
|
||||
assert_eq!(ex(&mut s, "library.updates", json!({}))["count"], 0);
|
||||
healthy(&s);
|
||||
// A structural change: the Button gains a layer (the Card's nested Button too).
|
||||
ex(&mut lib, "shape.ellipse", json!({"x": 80, "y": 10, "width": 8, "height": 8, "parent": k.button, "props": {"name": "Dot"}}));
|
||||
ex(&mut lib, "library.publish", json!({}));
|
||||
ex(&mut lib, "file.save", json!({}));
|
||||
ex(&mut s, "library.acceptUpdates", json!({"ids": [lid]}));
|
||||
assert_eq!(node(&s, b).children.len(), 2);
|
||||
assert_eq!(node(&s, nested).children.len(), 2);
|
||||
assert_eq!(chars(&s, b_label), "Buy", "overrides survive a structural update");
|
||||
assert_eq!(s.doc().unwrap().libraries.link(&lid).map(|l| l.version), Some(3));
|
||||
healthy(&s);
|
||||
// Overrides still work after the update; resetting shows the library's text.
|
||||
ex(&mut s, "component.resetInstance", json!({"ids": [b]}));
|
||||
assert_eq!(chars(&s, node(&s, b).children[0]), "Go");
|
||||
let card_inst = library_updates_flow(&mut s, &mut lib, &k, &lid, [b, b_label, remote_button, main]);
|
||||
|
||||
// Saved and reopened, the document keeps its links, copies and records.
|
||||
let user_path = path_str(&d.join("app.figcraft"));
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{Document, ImportedComponent, ImportedRecord, LibraryManifest, NodeId, NodeKind, PublishedLibrary, VariableValue};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::cmd::component::{main_of, swap_instance};
|
||||
@@ -41,6 +41,35 @@ fn b_main(doc: &Document, a_main: NodeId, a_root: NodeId, b_root: NodeId) -> Opt
|
||||
}
|
||||
}
|
||||
|
||||
/// Local style id → the library's style id, matched by name and type (the rest go to `out.unmatched`).
|
||||
fn match_styles(doc: &Document, recs: &[ImportedRecord], m: &LibraryManifest, out: &mut Swapped) -> BTreeMap<String, String> {
|
||||
let mut keys = BTreeMap::new();
|
||||
for r in recs {
|
||||
let Some(st) = doc.style(&r.local) else { continue };
|
||||
let kind = st.kind.type_name();
|
||||
match m.styles.iter().find(|x| x.published && x.name == st.name && x.kind == kind) {
|
||||
Some(x) => {
|
||||
keys.insert(r.local.clone(), x.id.clone());
|
||||
}
|
||||
None => out.unmatched.push(format!("style {}", st.name)),
|
||||
}
|
||||
}
|
||||
keys
|
||||
}
|
||||
|
||||
/// (local component root, the library's component id) by name; the rest go to `out.unmatched`.
|
||||
fn match_components(doc: &Document, records: &[ImportedComponent], m: &LibraryManifest, out: &mut Swapped) -> Vec<(NodeId, NodeId)> {
|
||||
let mut pairs = Vec::new();
|
||||
for r in records {
|
||||
let name = doc.node(r.root).map(|n| n.name.clone()).unwrap_or_default();
|
||||
match m.components.iter().find(|a| a.published && a.name == name) {
|
||||
Some(a) => pairs.push((r.root, a.id)),
|
||||
None => out.unmatched.push(format!("component {name}")),
|
||||
}
|
||||
}
|
||||
pairs
|
||||
}
|
||||
|
||||
/// Swap the copies of library `from` in `doc` to library `to` by names (see the module docs).
|
||||
pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Result<Swapped> {
|
||||
let m = &to.manifest;
|
||||
@@ -64,14 +93,7 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re
|
||||
})
|
||||
.collect();
|
||||
let records: Vec<ImportedComponent> = doc.libraries.components.iter().filter(|r| r.library == from && shown.contains(&r.root)).cloned().collect();
|
||||
let mut pairs = Vec::new();
|
||||
for r in &records {
|
||||
let name = doc.node(r.root).map(|n| n.name.clone()).unwrap_or_default();
|
||||
match m.components.iter().find(|a| a.published && a.name == name) {
|
||||
Some(a) => pairs.push((r.root, a.id)),
|
||||
None => out.unmatched.push(format!("component {name}")),
|
||||
}
|
||||
}
|
||||
let pairs = match_components(doc, &records, m, &mut out);
|
||||
let keys: Vec<NodeId> = pairs.iter().map(|(_, k)| *k).collect();
|
||||
let map = if keys.is_empty() { BTreeMap::new() } else { import(doc, to, &keys, false)? };
|
||||
// A's mains (each variant) and roots → B's.
|
||||
@@ -121,17 +143,7 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re
|
||||
// Styles the document's own layers link to: by name and type.
|
||||
let linked: BTreeSet<String> = doc.nodes().filter(|n| outside(doc, n.id)).flat_map(|n| style_links(n).cloned()).collect();
|
||||
let style_recs: Vec<ImportedRecord> = doc.libraries.styles.iter().filter(|r| r.library == from && linked.contains(&r.local)).cloned().collect();
|
||||
let mut style_keys: BTreeMap<String, String> = BTreeMap::new();
|
||||
for r in &style_recs {
|
||||
let Some(st) = doc.style(&r.local) else { continue };
|
||||
let kind = st.kind.type_name();
|
||||
match m.styles.iter().find(|x| x.published && x.name == st.name && x.kind == kind) {
|
||||
Some(x) => {
|
||||
style_keys.insert(r.local.clone(), x.id.clone());
|
||||
}
|
||||
None => out.unmatched.push(format!("style {}", st.name)),
|
||||
}
|
||||
}
|
||||
let style_keys = match_styles(doc, &style_recs, m, &mut out);
|
||||
let imported = import_styles(doc, to, &style_keys.values().cloned().collect(), false);
|
||||
let styles: BTreeMap<String, String> = style_keys.iter().filter_map(|(a, k)| imported.get(k).map(|b| (a.clone(), b.clone()))).collect();
|
||||
out.styles = styles.len();
|
||||
|
||||
@@ -8,7 +8,7 @@ use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::cmd::CommandSpec;
|
||||
use crate::codegen::CodeLang;
|
||||
use crate::params::*;
|
||||
use crate::params::{get, id_of, num, string, targets};
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
|
||||
/// The component a playground for `target` shows: an instance's component, a component or a
|
||||
|
||||
@@ -8,7 +8,10 @@ use std::borrow::Cow;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use figcraft_color::PaintKind;
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{
|
||||
CollectionId, Document, EffectKind, LetterSpacing, LineHeight, ModeId, Node, NodeId, NodeKind, Style, StyleId, StyleKind, TextStyle, VariableId,
|
||||
VariableType, VariableValue, Variables,
|
||||
};
|
||||
|
||||
use crate::cmd::lists::{BoundList, list_binding, stop_binding};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Document inspection as JSON, so agents can verify their work without screenshots.
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{Document, Node, NodeId, NodeKind, StrokeJoin, TextData, TextDecoration};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
@@ -8,7 +8,6 @@ use crate::Session;
|
||||
/// A node and (to `depth`) its children, with the properties a designer would check.
|
||||
pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
|
||||
let Some(n) = doc.node(id) else { return Value::Null };
|
||||
let c = doc.frame_relative(id).as_coeffs();
|
||||
let mut v = json!({
|
||||
"id": n.id,
|
||||
"name": n.name,
|
||||
@@ -16,226 +15,259 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
|
||||
});
|
||||
let Some(o) = v.as_object_mut() else { return v };
|
||||
if !n.is_page() {
|
||||
o.insert("x".into(), json!(round(c[4])));
|
||||
o.insert("y".into(), json!(round(c[5])));
|
||||
o.insert("width".into(), json!(round(n.width)));
|
||||
o.insert("height".into(), json!(round(n.height)));
|
||||
let rot = figcraft_geom::rotation_degrees(n.transform);
|
||||
if rot.abs() > 1e-6 {
|
||||
o.insert("rotation".into(), json!(round(rot)));
|
||||
}
|
||||
if let Some(b) = doc.world_bounds(id) {
|
||||
o.insert("absoluteBounds".into(), json!({"x": round(b.x0), "y": round(b.y0), "width": round(b.width()), "height": round(b.height())}));
|
||||
}
|
||||
if !n.visible {
|
||||
o.insert("visible".into(), json!(false));
|
||||
}
|
||||
if n.locked {
|
||||
o.insert("locked".into(), json!(true));
|
||||
}
|
||||
if n.opacity < 1.0 {
|
||||
o.insert("opacity".into(), json!(n.opacity));
|
||||
}
|
||||
if !matches!(n.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) {
|
||||
o.insert("blend".into(), json!(n.blend));
|
||||
}
|
||||
if !n.fills.is_empty() {
|
||||
o.insert("fills".into(), paints(&n.fills));
|
||||
}
|
||||
if !n.strokes.is_empty() {
|
||||
o.insert("strokes".into(), paints(&n.strokes));
|
||||
o.insert("strokeWeight".into(), json!(n.stroke.weight));
|
||||
o.insert("strokeAlign".into(), json!(n.stroke.align));
|
||||
if n.stroke.sides.is_some() {
|
||||
o.insert("strokeSides".into(), json!(n.stroke.sides));
|
||||
}
|
||||
if !n.stroke.dashes.is_empty() {
|
||||
o.insert("dashes".into(), json!(n.stroke.dashes));
|
||||
}
|
||||
if n.stroke.join != StrokeJoin::Miter {
|
||||
o.insert("strokeJoin".into(), json!(n.stroke.join));
|
||||
} else if n.stroke.miter_limit != 4.0 {
|
||||
o.insert("miterAngle".into(), json!((n.stroke.miter_angle() * 100.0).round() / 100.0));
|
||||
}
|
||||
if let Some(p) = n.stroke.profile() {
|
||||
let points = crate::cmd::props::width_points_json(Some(p));
|
||||
o.insert("widthProfile".into(), json!({"preset": p.preset().map(|x| x.id()), "points": points}));
|
||||
}
|
||||
if let Some(b) = &n.stroke.brush {
|
||||
o.insert("brush".into(), camel(serde_json::to_value(b).unwrap_or(Value::Null)));
|
||||
}
|
||||
}
|
||||
if !n.effects.is_empty() {
|
||||
o.insert("effects".into(), camel(serde_json::to_value(&n.effects).unwrap_or(Value::Null)));
|
||||
}
|
||||
if n.corners.radii.iter().any(|r| *r > 0.0) {
|
||||
o.insert(
|
||||
"cornerRadius".into(),
|
||||
match n.corners.uniform() {
|
||||
Some(r) => json!(r),
|
||||
None => json!(n.corners.radii),
|
||||
},
|
||||
);
|
||||
if n.corners.smoothing > 0.0 {
|
||||
o.insert("cornerSmoothing".into(), json!(n.corners.smoothing));
|
||||
}
|
||||
}
|
||||
if let Some(l) = &n.layout {
|
||||
o.insert("layout".into(), camel(serde_json::to_value(l).unwrap_or(Value::Null)));
|
||||
}
|
||||
let parent_al = n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some());
|
||||
if n.layout.is_some() || parent_al || matches!(n.kind, NodeKind::Text(_)) {
|
||||
o.insert("sizing".into(), json!({"horizontal": n.child_layout.sizing_h, "vertical": n.child_layout.sizing_v}));
|
||||
}
|
||||
let cl = &n.child_layout;
|
||||
if cl.absolute {
|
||||
o.insert("absolute".into(), json!(true));
|
||||
}
|
||||
for (k, v) in [("minWidth", cl.min_w), ("maxWidth", cl.max_w), ("minHeight", cl.min_h), ("maxHeight", cl.max_h)] {
|
||||
if let Some(v) = v {
|
||||
o.insert(k.into(), json!(v));
|
||||
}
|
||||
}
|
||||
if let Some(cell) = parent_al
|
||||
.then(|| n.parent.and_then(|p| figcraft_layout::grid_geometry(doc, p)))
|
||||
.flatten()
|
||||
.and_then(|g| g.cells.into_iter().find(|c| c.id == n.id))
|
||||
{
|
||||
o.insert(
|
||||
"gridCell".into(),
|
||||
json!({"column": cell.column, "row": cell.row, "columnSpan": cell.column_span, "rowSpan": cell.row_span,
|
||||
"anchored": cl.grid_column.is_some() || cl.grid_row.is_some(), "alignH": cl.grid_align_h, "alignV": cl.grid_align_v}),
|
||||
);
|
||||
}
|
||||
if (!parent_al || cl.absolute) && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.kind.is_frame_like()) {
|
||||
o.insert("constraints".into(), serde_json::to_value(n.constraints).unwrap_or(Value::Null));
|
||||
}
|
||||
if n.kind.is_frame_like() {
|
||||
o.insert("clip".into(), json!(n.clip));
|
||||
}
|
||||
if !n.dev_status.is_none() {
|
||||
o.insert("devStatus".into(), json!(n.dev_status.id()));
|
||||
}
|
||||
if n.dev_changed {
|
||||
o.insert("devChanged".into(), json!(true));
|
||||
}
|
||||
if !n.dev_note.is_empty() {
|
||||
o.insert("devNote".into(), json!(n.dev_note));
|
||||
}
|
||||
if let NodeKind::Section { contents_hidden } = n.kind {
|
||||
o.insert("sectionContentsHidden".into(), json!(contents_hidden));
|
||||
}
|
||||
if n.is_mask {
|
||||
o.insert("isMask".into(), json!(true));
|
||||
o.insert("maskType".into(), json!(n.mask_type.id()));
|
||||
}
|
||||
if !n.grids.is_empty() {
|
||||
o.insert("grids".into(), camel(serde_json::to_value(&n.grids).unwrap_or(Value::Null)));
|
||||
}
|
||||
if !n.exports.is_empty() {
|
||||
o.insert("exports".into(), serde_json::to_value(&n.exports).unwrap_or(Value::Null));
|
||||
}
|
||||
if let Some(b) = &n.board {
|
||||
o.insert("board".into(), serde_json::to_value(b).unwrap_or(Value::Null));
|
||||
}
|
||||
if !n.annotations.is_empty() {
|
||||
o.insert("annotations".into(), serde_json::to_value(&n.annotations).unwrap_or(Value::Null));
|
||||
}
|
||||
if !n.code_links.is_empty() {
|
||||
o.insert("codeLinks".into(), camel(serde_json::to_value(&n.code_links).unwrap_or(Value::Null)));
|
||||
}
|
||||
if !n.measurements.is_empty() {
|
||||
o.insert("measurements".into(), serde_json::to_value(&n.measurements).unwrap_or(Value::Null));
|
||||
}
|
||||
let dev = crate::dev_resources::list_json(doc, n.id);
|
||||
if dev.as_array().is_some_and(|a| !a.is_empty()) {
|
||||
o.insert("devResources".into(), dev);
|
||||
}
|
||||
if !n.interactions.is_empty() {
|
||||
o.insert("interactions".into(), serde_json::to_value(&n.interactions).unwrap_or(Value::Null));
|
||||
}
|
||||
if !n.styles.is_empty() {
|
||||
o.insert("styles".into(), serde_json::to_value(&n.styles).unwrap_or(Value::Null));
|
||||
}
|
||||
if !n.bound.is_empty() {
|
||||
o.insert("boundVariables".into(), json!(n.bound));
|
||||
}
|
||||
if !n.overrides.is_empty() {
|
||||
o.insert("overrides".into(), json!(n.overrides));
|
||||
}
|
||||
if let Some(slot) = crate::components::slot_name(doc, n.id) {
|
||||
o.insert("slot".into(), json!(slot));
|
||||
}
|
||||
// An operand combining by an operation of its own.
|
||||
if let Some(op) = n.bool_op.filter(|_| crate::booleans::group_of(doc, n.id).is_some()) {
|
||||
o.insert("operandOperation".into(), json!(op));
|
||||
}
|
||||
base_props(doc, n, id, o);
|
||||
paint_props(n, o);
|
||||
layout_props(doc, n, o);
|
||||
dev_props(doc, n, o);
|
||||
}
|
||||
// Layer data written with `node.set {props: {pluginData}}` (pages too).
|
||||
if !n.plugin_data.is_empty() {
|
||||
o.insert("pluginData".into(), json!(n.plugin_data));
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Text(t) => {
|
||||
o.insert("characters".into(), json!(t.characters));
|
||||
o.insert("fontFamily".into(), json!(t.style.font_family));
|
||||
o.insert("fontStyle".into(), json!(t.style.font_style));
|
||||
o.insert("fontSize".into(), json!(t.style.font_size));
|
||||
if !t.style.variations.is_empty() {
|
||||
o.insert("fontVariations".into(), json!(t.style.variations));
|
||||
}
|
||||
o.insert("lineHeight".into(), json!(t.style.line_height.label()));
|
||||
o.insert("letterSpacing".into(), json!(t.style.letter_spacing.label()));
|
||||
o.insert("textAlign".into(), json!(t.align_h));
|
||||
o.insert("textAutoResize".into(), json!(t.auto_resize));
|
||||
if t.max_lines > 0 {
|
||||
o.insert("maxLines".into(), json!(t.max_lines));
|
||||
}
|
||||
if let Some(tp) = &t.path {
|
||||
o.insert("textPath".into(), json!(tp));
|
||||
}
|
||||
if t.style.decoration != TextDecoration::None {
|
||||
o.insert("textDecoration".into(), json!(t.style.decoration));
|
||||
}
|
||||
if let Some(l) = t.style.decoration_look.as_deref() {
|
||||
o.insert("textDecorationStyle".into(), json!(l.style));
|
||||
o.insert("textDecorationThickness".into(), json!(l.thickness.label()));
|
||||
o.insert("textDecorationOffset".into(), json!(l.offset.label()));
|
||||
o.insert("textDecorationSkipInk".into(), json!(l.skip_ink));
|
||||
o.insert("textDecorationColor".into(), l.color.map_or(json!("auto"), |c| json!(format!("#{}", c.to_hex()))));
|
||||
}
|
||||
// Lists: one style for the layer, or each paragraph's type, nesting level and start.
|
||||
if t.paragraphs.is_empty() {
|
||||
if t.list != figcraft_doc::ListStyle::None {
|
||||
o.insert("listStyle".into(), json!(t.list));
|
||||
}
|
||||
} else {
|
||||
o.insert("lineTypes".into(), t.paragraphs.iter().map(|p| json!(p.list)).collect());
|
||||
o.insert("lineIndentations".into(), t.paragraphs.iter().map(|p| json!(p.indent)).collect());
|
||||
if t.paragraphs.iter().any(|p| p.start_offset > 0) {
|
||||
o.insert("listStartOffsets".into(), t.paragraphs.iter().map(|p| json!(p.start_offset)).collect());
|
||||
}
|
||||
}
|
||||
if t.list_spacing != 0.0 {
|
||||
o.insert("listSpacing".into(), json!(t.list_spacing));
|
||||
}
|
||||
for (k, on) in [("hangingPunctuation", t.hanging_punctuation), ("hangingList", t.hanging_list)] {
|
||||
if on {
|
||||
o.insert(k.into(), json!(true));
|
||||
}
|
||||
}
|
||||
if t.leading_trim != figcraft_doc::LeadingTrim::None {
|
||||
o.insert("leadingTrim".into(), json!(t.leading_trim));
|
||||
}
|
||||
if !t.runs.is_empty() {
|
||||
o.insert("styledRuns".into(), serde_json::to_value(&t.runs).unwrap_or(Value::Null));
|
||||
}
|
||||
// Chars linked to text styles: [start, end, style id] (the layer as a whole: `styles.text`).
|
||||
let linked: Vec<Value> = t.spans().into_iter().filter_map(|(a, b, s)| s.style_id.as_ref().map(|id| json!([a, b, id]))).collect();
|
||||
if !linked.is_empty() {
|
||||
o.insert("textStyleRanges".into(), Value::Array(linked));
|
||||
}
|
||||
NodeKind::Text(t) => text_props(t, o),
|
||||
_ => kind_props(doc, n, o),
|
||||
}
|
||||
if !n.children.is_empty() {
|
||||
if depth > 0 {
|
||||
o.insert("children".into(), Value::Array(n.children.iter().map(|c| node_json(doc, *c, depth - 1)).collect()));
|
||||
} else {
|
||||
o.insert("childCount".into(), json!(n.children.len()));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn base_props(doc: &Document, n: &Node, id: NodeId, o: &mut serde_json::Map<String, Value>) {
|
||||
let c = doc.frame_relative(id).as_coeffs();
|
||||
o.insert("x".into(), json!(round(c[4])));
|
||||
o.insert("y".into(), json!(round(c[5])));
|
||||
o.insert("width".into(), json!(round(n.width)));
|
||||
o.insert("height".into(), json!(round(n.height)));
|
||||
let rot = figcraft_geom::rotation_degrees(n.transform);
|
||||
if rot.abs() > 1e-6 {
|
||||
o.insert("rotation".into(), json!(round(rot)));
|
||||
}
|
||||
if let Some(b) = doc.world_bounds(id) {
|
||||
o.insert("absoluteBounds".into(), json!({"x": round(b.x0), "y": round(b.y0), "width": round(b.width()), "height": round(b.height())}));
|
||||
}
|
||||
if !n.visible {
|
||||
o.insert("visible".into(), json!(false));
|
||||
}
|
||||
if n.locked {
|
||||
o.insert("locked".into(), json!(true));
|
||||
}
|
||||
if n.opacity < 1.0 {
|
||||
o.insert("opacity".into(), json!(n.opacity));
|
||||
}
|
||||
if !matches!(n.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) {
|
||||
o.insert("blend".into(), json!(n.blend));
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_props(n: &Node, o: &mut serde_json::Map<String, Value>) {
|
||||
if !n.fills.is_empty() {
|
||||
o.insert("fills".into(), paints(&n.fills));
|
||||
}
|
||||
if !n.strokes.is_empty() {
|
||||
o.insert("strokes".into(), paints(&n.strokes));
|
||||
o.insert("strokeWeight".into(), json!(n.stroke.weight));
|
||||
o.insert("strokeAlign".into(), json!(n.stroke.align));
|
||||
if n.stroke.sides.is_some() {
|
||||
o.insert("strokeSides".into(), json!(n.stroke.sides));
|
||||
}
|
||||
if !n.stroke.dashes.is_empty() {
|
||||
o.insert("dashes".into(), json!(n.stroke.dashes));
|
||||
}
|
||||
if n.stroke.join != StrokeJoin::Miter {
|
||||
o.insert("strokeJoin".into(), json!(n.stroke.join));
|
||||
} else if n.stroke.miter_limit != 4.0 {
|
||||
o.insert("miterAngle".into(), json!((n.stroke.miter_angle() * 100.0).round() / 100.0));
|
||||
}
|
||||
if let Some(p) = n.stroke.profile() {
|
||||
let points = crate::cmd::props::width_points_json(Some(p));
|
||||
o.insert("widthProfile".into(), json!({"preset": p.preset().map(|x| x.id()), "points": points}));
|
||||
}
|
||||
if let Some(b) = &n.stroke.brush {
|
||||
o.insert("brush".into(), camel(serde_json::to_value(b).unwrap_or(Value::Null)));
|
||||
}
|
||||
}
|
||||
if !n.effects.is_empty() {
|
||||
o.insert("effects".into(), camel(serde_json::to_value(&n.effects).unwrap_or(Value::Null)));
|
||||
}
|
||||
if n.corners.radii.iter().any(|r| *r > 0.0) {
|
||||
o.insert(
|
||||
"cornerRadius".into(),
|
||||
match n.corners.uniform() {
|
||||
Some(r) => json!(r),
|
||||
None => json!(n.corners.radii),
|
||||
},
|
||||
);
|
||||
if n.corners.smoothing > 0.0 {
|
||||
o.insert("cornerSmoothing".into(), json!(n.corners.smoothing));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn layout_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>) {
|
||||
if let Some(l) = &n.layout {
|
||||
o.insert("layout".into(), camel(serde_json::to_value(l).unwrap_or(Value::Null)));
|
||||
}
|
||||
let parent_al = n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some());
|
||||
if n.layout.is_some() || parent_al || matches!(n.kind, NodeKind::Text(_)) {
|
||||
o.insert("sizing".into(), json!({"horizontal": n.child_layout.sizing_h, "vertical": n.child_layout.sizing_v}));
|
||||
}
|
||||
let cl = &n.child_layout;
|
||||
if cl.absolute {
|
||||
o.insert("absolute".into(), json!(true));
|
||||
}
|
||||
for (k, v) in [("minWidth", cl.min_w), ("maxWidth", cl.max_w), ("minHeight", cl.min_h), ("maxHeight", cl.max_h)] {
|
||||
if let Some(v) = v {
|
||||
o.insert(k.into(), json!(v));
|
||||
}
|
||||
}
|
||||
if let Some(cell) = parent_al
|
||||
.then(|| n.parent.and_then(|p| figcraft_layout::grid_geometry(doc, p)))
|
||||
.flatten()
|
||||
.and_then(|g| g.cells.into_iter().find(|c| c.id == n.id))
|
||||
{
|
||||
o.insert(
|
||||
"gridCell".into(),
|
||||
json!({"column": cell.column, "row": cell.row, "columnSpan": cell.column_span, "rowSpan": cell.row_span,
|
||||
"anchored": cl.grid_column.is_some() || cl.grid_row.is_some(), "alignH": cl.grid_align_h, "alignV": cl.grid_align_v}),
|
||||
);
|
||||
}
|
||||
if (!parent_al || cl.absolute) && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.kind.is_frame_like()) {
|
||||
o.insert("constraints".into(), serde_json::to_value(n.constraints).unwrap_or(Value::Null));
|
||||
}
|
||||
if n.kind.is_frame_like() {
|
||||
o.insert("clip".into(), json!(n.clip));
|
||||
}
|
||||
}
|
||||
|
||||
fn dev_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>) {
|
||||
if !n.dev_status.is_none() {
|
||||
o.insert("devStatus".into(), json!(n.dev_status.id()));
|
||||
}
|
||||
if n.dev_changed {
|
||||
o.insert("devChanged".into(), json!(true));
|
||||
}
|
||||
if !n.dev_note.is_empty() {
|
||||
o.insert("devNote".into(), json!(n.dev_note));
|
||||
}
|
||||
if let NodeKind::Section { contents_hidden } = n.kind {
|
||||
o.insert("sectionContentsHidden".into(), json!(contents_hidden));
|
||||
}
|
||||
if n.is_mask {
|
||||
o.insert("isMask".into(), json!(true));
|
||||
o.insert("maskType".into(), json!(n.mask_type.id()));
|
||||
}
|
||||
if !n.grids.is_empty() {
|
||||
o.insert("grids".into(), camel(serde_json::to_value(&n.grids).unwrap_or(Value::Null)));
|
||||
}
|
||||
if !n.exports.is_empty() {
|
||||
o.insert("exports".into(), serde_json::to_value(&n.exports).unwrap_or(Value::Null));
|
||||
}
|
||||
if let Some(b) = &n.board {
|
||||
o.insert("board".into(), serde_json::to_value(b).unwrap_or(Value::Null));
|
||||
}
|
||||
if !n.annotations.is_empty() {
|
||||
o.insert("annotations".into(), serde_json::to_value(&n.annotations).unwrap_or(Value::Null));
|
||||
}
|
||||
if !n.code_links.is_empty() {
|
||||
o.insert("codeLinks".into(), camel(serde_json::to_value(&n.code_links).unwrap_or(Value::Null)));
|
||||
}
|
||||
if !n.measurements.is_empty() {
|
||||
o.insert("measurements".into(), serde_json::to_value(&n.measurements).unwrap_or(Value::Null));
|
||||
}
|
||||
let dev = crate::dev_resources::list_json(doc, n.id);
|
||||
if dev.as_array().is_some_and(|a| !a.is_empty()) {
|
||||
o.insert("devResources".into(), dev);
|
||||
}
|
||||
if !n.interactions.is_empty() {
|
||||
o.insert("interactions".into(), serde_json::to_value(&n.interactions).unwrap_or(Value::Null));
|
||||
}
|
||||
if !n.styles.is_empty() {
|
||||
o.insert("styles".into(), serde_json::to_value(&n.styles).unwrap_or(Value::Null));
|
||||
}
|
||||
if !n.bound.is_empty() {
|
||||
o.insert("boundVariables".into(), json!(n.bound));
|
||||
}
|
||||
if !n.overrides.is_empty() {
|
||||
o.insert("overrides".into(), json!(n.overrides));
|
||||
}
|
||||
if let Some(slot) = crate::components::slot_name(doc, n.id) {
|
||||
o.insert("slot".into(), json!(slot));
|
||||
}
|
||||
// An operand combining by an operation of its own.
|
||||
if let Some(op) = n.bool_op.filter(|_| crate::booleans::group_of(doc, n.id).is_some()) {
|
||||
o.insert("operandOperation".into(), json!(op));
|
||||
}
|
||||
}
|
||||
|
||||
fn text_props(t: &TextData, o: &mut serde_json::Map<String, Value>) {
|
||||
o.insert("characters".into(), json!(t.characters));
|
||||
o.insert("fontFamily".into(), json!(t.style.font_family));
|
||||
o.insert("fontStyle".into(), json!(t.style.font_style));
|
||||
o.insert("fontSize".into(), json!(t.style.font_size));
|
||||
if !t.style.variations.is_empty() {
|
||||
o.insert("fontVariations".into(), json!(t.style.variations));
|
||||
}
|
||||
o.insert("lineHeight".into(), json!(t.style.line_height.label()));
|
||||
o.insert("letterSpacing".into(), json!(t.style.letter_spacing.label()));
|
||||
o.insert("textAlign".into(), json!(t.align_h));
|
||||
o.insert("textAutoResize".into(), json!(t.auto_resize));
|
||||
if t.max_lines > 0 {
|
||||
o.insert("maxLines".into(), json!(t.max_lines));
|
||||
}
|
||||
if let Some(tp) = &t.path {
|
||||
o.insert("textPath".into(), json!(tp));
|
||||
}
|
||||
if t.style.decoration != TextDecoration::None {
|
||||
o.insert("textDecoration".into(), json!(t.style.decoration));
|
||||
}
|
||||
if let Some(l) = t.style.decoration_look.as_deref() {
|
||||
o.insert("textDecorationStyle".into(), json!(l.style));
|
||||
o.insert("textDecorationThickness".into(), json!(l.thickness.label()));
|
||||
o.insert("textDecorationOffset".into(), json!(l.offset.label()));
|
||||
o.insert("textDecorationSkipInk".into(), json!(l.skip_ink));
|
||||
o.insert("textDecorationColor".into(), l.color.map_or(json!("auto"), |c| json!(format!("#{}", c.to_hex()))));
|
||||
}
|
||||
// Lists: one style for the layer, or each paragraph's type, nesting level and start.
|
||||
if t.paragraphs.is_empty() {
|
||||
if t.list != figcraft_doc::ListStyle::None {
|
||||
o.insert("listStyle".into(), json!(t.list));
|
||||
}
|
||||
} else {
|
||||
o.insert("lineTypes".into(), t.paragraphs.iter().map(|p| json!(p.list)).collect());
|
||||
o.insert("lineIndentations".into(), t.paragraphs.iter().map(|p| json!(p.indent)).collect());
|
||||
if t.paragraphs.iter().any(|p| p.start_offset > 0) {
|
||||
o.insert("listStartOffsets".into(), t.paragraphs.iter().map(|p| json!(p.start_offset)).collect());
|
||||
}
|
||||
}
|
||||
if t.list_spacing != 0.0 {
|
||||
o.insert("listSpacing".into(), json!(t.list_spacing));
|
||||
}
|
||||
for (k, on) in [("hangingPunctuation", t.hanging_punctuation), ("hangingList", t.hanging_list)] {
|
||||
if on {
|
||||
o.insert(k.into(), json!(true));
|
||||
}
|
||||
}
|
||||
if t.leading_trim != figcraft_doc::LeadingTrim::None {
|
||||
o.insert("leadingTrim".into(), json!(t.leading_trim));
|
||||
}
|
||||
if !t.runs.is_empty() {
|
||||
o.insert("styledRuns".into(), serde_json::to_value(&t.runs).unwrap_or(Value::Null));
|
||||
}
|
||||
// Chars linked to text styles: [start, end, style id] (the layer as a whole: `styles.text`).
|
||||
let linked: Vec<Value> = t.spans().into_iter().filter_map(|(a, b, s)| s.style_id.as_ref().map(|id| json!([a, b, id]))).collect();
|
||||
if !linked.is_empty() {
|
||||
o.insert("textStyleRanges".into(), Value::Array(linked));
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>) {
|
||||
match &n.kind {
|
||||
NodeKind::Ellipse { start, sweep, inner } if (*sweep).abs() < std::f64::consts::TAU - 1e-9 || *inner > 0.0 => {
|
||||
o.insert("arc".into(), json!({"start": start.to_degrees(), "sweep": sweep.to_degrees(), "inner": inner}));
|
||||
}
|
||||
@@ -276,14 +308,6 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if !n.children.is_empty() {
|
||||
if depth > 0 {
|
||||
o.insert("children".into(), Value::Array(n.children.iter().map(|c| node_json(doc, *c, depth - 1)).collect()));
|
||||
} else {
|
||||
o.insert("childCount".into(), json!(n.children.len()));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn round(v: f64) -> f64 {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use super::derived::{ends_now, place_route, route};
|
||||
use super::edit::set_label;
|
||||
use super::items::add;
|
||||
use super::*;
|
||||
use super::{
|
||||
BoardItem, Connector, Document, End, LINE, LINE_WEIGHT, NodeId, NodeKind, Paint, Point, Result, Session, Side, StrokeCap, StrokeJoin, Value,
|
||||
VectorNetwork, bad, cap_of, get, id_of, json, num, req_targets, require, routing_of, string,
|
||||
};
|
||||
|
||||
/// One connector end from the params: a layer on this page, or a point.
|
||||
fn end_param(cmd: &str, s: &Session, p: &Value, key: &str, side_key: &str) -> Result<End> {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use super::edit::set_font_size;
|
||||
use super::items::{owner_of_text, shape_padding, sticky_font_size, text_of};
|
||||
use super::*;
|
||||
use super::{
|
||||
Affine, BTreeSet, BezPath, BoardItem, Connector, Document, End, NodeId, NodeKind, PathEl, Point, Rect, Routing, Shape, Side, Vec2, VectorNetwork,
|
||||
is_board,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Derived data (the normalize pass).
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
use super::connectors::connector_of;
|
||||
use super::derived::ends_now;
|
||||
use super::items::{add, add_text, shape_padding, text_of};
|
||||
use super::*;
|
||||
use super::{
|
||||
Affine, AutoLayout, BTreeMap, BoardItem, BoardShape, Color, Connector, DocError, Document, End, INK, NodeId, NodeKind, Paint, Result, Routing,
|
||||
Session, Sizing, StrokeCap, TextAlignH, TextAutoResize, TextData, Value, VectorNetwork, bad, cap_of, color_param, json, line_shade, num,
|
||||
req_string, req_targets, require, routing_of, shape_of, string,
|
||||
};
|
||||
|
||||
/// The label of connector `id`, if it has one.
|
||||
pub fn label_of(doc: &Document, id: NodeId) -> Option<NodeId> {
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
AUTHOR_INK, Affine, AutoLayout, BlendMode, BoardItem, BoardShape, Color, Constraints, CrossAlign, Document, Effect, EffectKind, EngineError,
|
||||
HConstraint, HIGHLIGHTER, INK, LayoutMode, MARKER, MainAlign, Node, NodeId, NodeKind, Paint, Result, SHAPE, STAMP_SIZE, STICKY, STICKY_GAP,
|
||||
STICKY_PAD, Session, Shadow, Sizing, StrokeAlign, StrokeCap, StrokeJoin, TextAlignH, TextAutoResize, TextData, VConstraint, Value, VectorNetwork,
|
||||
author, bad, boolean, color_param, get, id, id_of, json, line_shade, num, num_or, parent_of, req_num, require, shape_of, stamp_of, string,
|
||||
textedit,
|
||||
};
|
||||
|
||||
/// A new layer at the top of `parent`, at `at` in it, made by `f`.
|
||||
pub(super) fn add(
|
||||
|
||||
@@ -2,7 +2,10 @@ use super::connectors::{end_json, other_end};
|
||||
use super::derived::ends_now;
|
||||
use super::edit::label_of;
|
||||
use super::items::edit_text;
|
||||
use super::*;
|
||||
use super::{
|
||||
BoardItem, Document, ERASER_PX, End, EngineError, Gesture, NodeId, Point, PointerEvent, Result, SHAPE, STICKY, Session, Side, ToolId, id_of,
|
||||
is_board, json,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The board tools.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Where something dropped at a page point lands: the frame under the point (or the page) and, in
|
||||
//! an auto layout frame, the child slot by pointer position plus the insertion line to draw.
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{Document, LayoutMode, NodeId, NodeKind};
|
||||
use figcraft_geom::Point;
|
||||
use serde::Serialize;
|
||||
|
||||
|
||||
@@ -21,14 +21,14 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{Corners, Document, Node, NodeId, NodeKind, StrokeProps, VectorNetwork, WindingRule};
|
||||
use figcraft_geom::kurbo::PathEl;
|
||||
use figcraft_geom::{Affine, BezPath, Point, Rect, Shape};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cmd::CommandSpec;
|
||||
use crate::cmd::edit::{roots, z_sorted};
|
||||
use crate::params::*;
|
||||
use crate::params::{f64_of, get, string, targets};
|
||||
use crate::tools::{Cursor, Gesture, PointerEvent, PointerKind, ToolId};
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
|
||||
|
||||
@@ -39,7 +39,14 @@ pub fn show(s: &mut Session, command: &str, params: &Value) -> Result<()> {
|
||||
ds.text_edit = text_edit;
|
||||
match result {
|
||||
Ok(_) if !Arc::ptr_eq(&ds.doc, &base) => {
|
||||
normalize(ds.doc_mut(), &base);
|
||||
// A panic while normalising shows no preview (#1085).
|
||||
if let Err(e) = guard::run(command, || {
|
||||
normalize(ds.doc_mut(), &base);
|
||||
Ok(())
|
||||
}) {
|
||||
ds.doc = base;
|
||||
return Err(e);
|
||||
}
|
||||
ds.text_preview = Some(base);
|
||||
ds.revision += 1;
|
||||
Ok(())
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::collections::BTreeMap;
|
||||
|
||||
use figcraft_color::{ImageScaleMode, PaintKind};
|
||||
use figcraft_doc::geometry::select_target;
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{Document, LayoutMode, Node, NodeId, NodeKind, Sizing, StrokeAlign, StrokeCap, TextAutoResize, TextData, TextPath, VectorNetwork};
|
||||
use figcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::handles::{PATH_REACH_PX, label_at, locked_in, path_candidates, readable_text_fills};
|
||||
use super::*;
|
||||
use super::{
|
||||
Affine, Document, Gesture, Node, NodeId, NodeKind, Point, PointerEvent, Rect, Result, Session, Sizing, TextAutoResize, TextData, TextPath,
|
||||
ToolId, frame_at, json, select_target, textedit, within_slop,
|
||||
};
|
||||
|
||||
impl Session {
|
||||
/// Handle a double-click gesture: finish a pen path, rename or click labels/widgets,
|
||||
|
||||
@@ -2,7 +2,11 @@ use super::handles::{
|
||||
SNAP_PX, handle_at, in_quad, label_at, locked_in, on_corner_square, paints_below, resized_box, scale_factor, selection_box, selection_frame,
|
||||
shape_hint,
|
||||
};
|
||||
use super::*;
|
||||
use super::{
|
||||
Affine, ArcHandle, ArcHint, Cursor, Document, Gesture, Guide, Handle, ImageScaleMode, LayoutMode, NodeId, NodeKind, PaintKind, Point,
|
||||
PointerEvent, Rect, ResizeStart, Result, ScaleHint, Session, Sizing, Targets, TextAutoResize, ToolId, Vec2, move_world, roots, select_target,
|
||||
textedit, within_slop,
|
||||
};
|
||||
|
||||
impl Session {
|
||||
pub(super) fn move_down(&mut self, ev: &PointerEvent) -> Result<()> {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use super::handles::SNAP_PX;
|
||||
use super::pen::take_enclosed;
|
||||
use super::*;
|
||||
use super::{
|
||||
Affine, Cursor, Gesture, Node, NodeId, NodeKind, Point, PointerEvent, Rect, Result, Session, StrokeCap, Targets, ToolId, Vec2, frame_at, textedit,
|
||||
};
|
||||
|
||||
impl Session {
|
||||
pub(super) fn draw_down(&mut self, ev: &PointerEvent) -> Result<()> {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Affine, BTreeMap, Cursor, Document, Handle, NodeId, NodeKind, Point, ShapeHandle, ShapeHint, roots};
|
||||
|
||||
/// The frame whose name label (see [`ToolState::labels`]) is under `p` on `page`: the topmost
|
||||
/// one drawn, if it can be selected (still there, on the page, visible and unlocked).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Document, Gesture, Node, NodeId, Overlay, Point, PointerEvent, PointerKind, Result, Session, ShapeHandle, ToolId, json, textedit};
|
||||
|
||||
impl Session {
|
||||
/// Keys the current mode handles before menu shortcuts (vector edit mode's tools and point
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::handles::{PENCIL_STROKE, selected_end_at};
|
||||
use super::*;
|
||||
use super::{
|
||||
Affine, Document, Gesture, Guide, Node, NodeId, NodeKind, PenPath, Point, PointerEvent, Result, Session, StrokeAlign, ToolId, Vec2,
|
||||
VectorNetwork, at_45_degrees, frame_at, json, pen_handles,
|
||||
};
|
||||
|
||||
/// Pen pressure along drawn samples as width profile points `[t, scale]`: `t` by length, at most
|
||||
/// 32 points, medium pressure (0.5) at the stroke weight, from a tenth to twice it. `None` when no
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::handles::{handle_at, label_at, locked_in, on_corner_square, resize_cursor, selected_end_at};
|
||||
use super::*;
|
||||
use super::{
|
||||
Cursor, Document, Gesture, NodeId, NodeKind, Overlay, PenCursor, Point, PointerEvent, PointerKind, Rect, Result, Session, ToolId, at_45_degrees,
|
||||
frame_at, json, select_target,
|
||||
};
|
||||
|
||||
impl Session {
|
||||
/// The pointer left the canvas: what hovering there showed goes (the distances Alt or Dev Mode
|
||||
|
||||
@@ -24,14 +24,14 @@ mod width_sides_tests;
|
||||
#[cfg(test)]
|
||||
mod width_tests;
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{Corners, Document, HandleMirroring, NodeId, NodeKind, StrokeAlign, StrokeCap, StrokeJoin, VectorNetwork, Vertex, WindingRule};
|
||||
use figcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cmd::CommandSpec;
|
||||
use crate::cmd::props::enum_of;
|
||||
use crate::params::*;
|
||||
use crate::params::{boolean, f64_of, get, id, num, num_or, req_num, string, targets};
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
|
||||
pub(crate) use pointer::VGesture;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! deleting, cutting and joining. Pure functions on a [`VectorNetwork`]; the session wrappers in
|
||||
//! `vectoredit.rs` re-fit the node afterwards.
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{HandleMirroring, Region, Segment, VectorNetwork, Vertex, WindingRule};
|
||||
use figcraft_geom::kurbo::{CubicBez, ParamCurve, ParamCurveNearest};
|
||||
use figcraft_geom::{Affine, Point, Vec2};
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{Document, HandleMirroring, NodeId, NodeKind, VectorNetwork, Vertex};
|
||||
use figcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
|
||||
use super::point_snap;
|
||||
@@ -510,20 +510,25 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Another edited layer under the press (or, with the paint bucket, whose area it is in).
|
||||
fn pressed_layer(doc: &Document, ve: &VectorEdit, ev: &PointerEvent, tol: f64) -> Option<NodeId> {
|
||||
match hit_any(doc, ve, ev.p(), tol) {
|
||||
Some((id, _)) => Some(id),
|
||||
None if ve.tool == VTool::Paint => {
|
||||
let inside = |id: NodeId| network(doc, id).is_some_and(|n| n.face_at(doc.world_transform(id).inverse() * ev.p()).is_some());
|
||||
(!inside(ve.id)).then(|| ve.others.iter().map(|o| o.id).find(|id| inside(*id))).flatten()
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns false when the click left edit mode (the caller then runs the normal tool).
|
||||
fn vector_down(&mut self, ve: &VectorEdit, ev: &PointerEvent) -> Result<bool> {
|
||||
let doc = self.doc()?.clone();
|
||||
let tol = 6.0 / ev.zoom.max(1e-6);
|
||||
// A press on another edited layer's point, handle or segment (or, with the paint bucket,
|
||||
// area) makes it the active layer; a pen drawing a path keeps to its own layer.
|
||||
let other = match hit_any(&doc, ve, ev.p(), tol) {
|
||||
Some((id, _)) => Some(id),
|
||||
None if ve.tool == VTool::Paint => {
|
||||
let inside = |id: NodeId| network(&doc, id).is_some_and(|n| n.face_at(doc.world_transform(id).inverse() * ev.p()).is_some());
|
||||
(!inside(ve.id)).then(|| ve.others.iter().map(|o| o.id).find(|id| inside(*id))).flatten()
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let other = Self::pressed_layer(&doc, ve, ev, tol);
|
||||
let switched;
|
||||
let ve = match other {
|
||||
Some(id) if id != ve.id && !(ve.tool == VTool::Pen && ve.anchor.is_some()) => {
|
||||
|
||||
@@ -11,7 +11,7 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::{VHit, VTool, VectorEdit, default_points, edit, editing, indices, network, ops, selected_points, selection_position};
|
||||
use crate::cmd::CommandSpec;
|
||||
use crate::params::*;
|
||||
use crate::params::{boolean, f64_of, get, num, num_or, req_num};
|
||||
use crate::tools::{Handle, handle_at_corners};
|
||||
use crate::{Result, Session, bad, spec};
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//! The points live in the stroke's [`WidthProfile`] and apply to every subpath (positions are
|
||||
//! fractions of each subpath's length).
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{Document, NodeId, WidthProfile, WidthSide};
|
||||
use figcraft_geom::{Point, Vec2};
|
||||
use figcraft_pathops::width::{Polyline, nearest, polylines};
|
||||
use serde::Serialize;
|
||||
@@ -18,7 +18,7 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::{editing, set_state};
|
||||
use crate::cmd::CommandSpec;
|
||||
use crate::params::*;
|
||||
use crate::params::{get, num, req_index, req_num, string};
|
||||
use crate::tools::{Gesture, PointerEvent};
|
||||
use crate::{Result, Session, bad, spec};
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ use serde_json::{Value, json};
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
use crate::tools::{PointerEvent, PointerKind};
|
||||
use figcraft_doc::{ExportFormat, WidthSide};
|
||||
|
||||
fn ex(s: &mut Session, c: &str, p: Value) -> Value {
|
||||
s.execute(c, &p).unwrap_or_else(|e| panic!("{c}: {e}"))
|
||||
|
||||
@@ -6,6 +6,7 @@ use serde_json::{Value, json};
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
use crate::tools::{PointerEvent, PointerKind};
|
||||
use figcraft_doc::{ExportFormat, WidthPreset, WidthProfile, WidthSide};
|
||||
|
||||
fn ex(s: &mut Session, c: &str, p: Value) -> Value {
|
||||
s.execute(c, &p).unwrap_or_else(|e| panic!("{c}: {e}"))
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
//! Arrange: align, distribute, tidy, z-order, flips, rotations and numeric transforms.
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{Document, EffectKind, LetterSpacing, LineHeight, Node, NodeId, NodeKind, Sizing, TextAutoResize, TextStyle};
|
||||
use figcraft_geom::{Affine, Point, Rect};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::edit::{roots, z_sorted};
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::params::{boolean, get, num, num_or, req_id, req_num, req_targets, string};
|
||||
use crate::{Result, Session, bad, spec};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -405,6 +405,11 @@ fn turn_pivot(s: &Session, ids: &[NodeId]) -> Result<Option<Point>> {
|
||||
pub fn scale_node(doc: &mut Document, id: NodeId, k: f64) -> Result<()> {
|
||||
let k = if k.is_finite() && k > 0.0 { k } else { return Ok(()) };
|
||||
let sub = doc.subtree(id);
|
||||
// The layers below `id` scale whatever their constraints, so where they are layers of an
|
||||
// instance their new size and place are overrides. The edit's own diff takes geometry that
|
||||
// changed with an ancestor's size for what constraints give, so the next rebuild of the
|
||||
// instance would put them back at their main's size (#922).
|
||||
let in_instance: Vec<bool> = sub.iter().enumerate().map(|(depth, i)| depth > 0 && crate::components::inside_instance(doc, *i)).collect();
|
||||
let tiles = |paints: &mut Vec<figcraft_color::Paint>| {
|
||||
for p in paints {
|
||||
if let figcraft_color::PaintKind::Image(img) = &mut p.kind
|
||||
@@ -416,6 +421,7 @@ pub fn scale_node(doc: &mut Document, id: NodeId, k: f64) -> Result<()> {
|
||||
};
|
||||
for (depth, i) in sub.iter().enumerate() {
|
||||
let n = doc.get_mut(*i)?;
|
||||
let (w0, h0, t0) = (n.width, n.height, n.transform);
|
||||
tiles(&mut n.fills);
|
||||
tiles(&mut n.strokes);
|
||||
let cl = &mut n.child_layout;
|
||||
@@ -511,6 +517,14 @@ pub fn scale_node(doc: &mut Document, id: NodeId, k: f64) -> Result<()> {
|
||||
let c = n.transform.as_coeffs();
|
||||
n.transform = Affine::new([c[0], c[1], c[2], c[3], c[4] * k, c[5] * k]);
|
||||
}
|
||||
if in_instance.get(depth).copied().unwrap_or(false) {
|
||||
if n.width != w0 || n.height != h0 {
|
||||
n.overrides.insert("size".into());
|
||||
}
|
||||
if n.transform != t0 {
|
||||
n.overrides.insert("position".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
//! message). `author` is who is acting (default: the name from Preferences, `pref.userName`): only
|
||||
//! a message's author edits or deletes it, and only the thread's author deletes the thread.
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{Comment, Document, Message, Node, NodeId, NodeKind, geom};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::params::{boolean, get, id, id_of, num, req_index, req_num, req_string, string};
|
||||
use crate::{Session, bad, spec};
|
||||
|
||||
/// The name comments are signed with until the user sets theirs.
|
||||
|
||||
@@ -2,13 +2,18 @@
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{Document, Node, NodeId, NodeKind, PropDef, VariableType, VariableValue};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::edit::{roots, z_sorted};
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::components::*;
|
||||
use crate::params::*;
|
||||
use crate::components::{
|
||||
OVERRIDE_GROUPS, PROP_BINDING, SLOT, copy_group, create_instance, create_instance_at, default_props, default_variant, exposed_copies,
|
||||
find_variant, inside_instance, instances_of, is_slot, keep_text_overrides, owning_component, owning_instance, prop_default_value,
|
||||
rebase_instance, root_instance, set_boolean_default, slot_edited, slot_name, slots_of, takes_value, unlink_layers, variant_conflicts,
|
||||
variant_name, variant_options, variant_props, variant_set,
|
||||
};
|
||||
use crate::params::{boolean, get, id, id_of, num, req_id, req_num, req_string, req_targets, string, targets};
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
|
||||
mod instance;
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
use super::props::{Owner, add_prop_for, delete_prop, prop_defs};
|
||||
use super::*;
|
||||
use super::{
|
||||
BTreeMap, BTreeSet, Document, EngineError, NodeId, NodeKind, OVERRIDE_GROUPS, PROP_BINDING, PropDef, Result, SLOT, Session, Value, bad, boolean,
|
||||
copy_group, create_instance_at, default_props, get, id_of, inside_instance, is_slot, json, keep_text_overrides, main_of, ok, owning_component,
|
||||
owning_instance, rebase_instance, req_id, req_num, req_targets, root_instance, slot_edited, slot_name, slots_of, string, targets, unlink_layers,
|
||||
variant_options,
|
||||
};
|
||||
|
||||
pub(super) fn detach(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.detach";
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
use super::instance::preferred_arg;
|
||||
use super::variants::{fit_set, wrap_in_set};
|
||||
use super::*;
|
||||
use super::{
|
||||
BTreeMap, Document, NodeId, NodeKind, PROP_BINDING, PropDef, Result, SLOT, Session, Value, VariableType, VariableValue, bad, boolean,
|
||||
default_variant, find_variant, get, id, id_of, inside_instance, instances_of, json, main_of, ok, owning_component, prop_default_value,
|
||||
req_string, req_targets, set_boolean_default, slot_edited, slots_of, string, takes_value, targets, variant_conflicts, variant_name,
|
||||
variant_options, variant_props, variant_set,
|
||||
};
|
||||
|
||||
/// Whether instance `id` has a component property `name` that a `kind` variable can drive (a
|
||||
/// boolean property takes a boolean variable, a text or variant property a string one).
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
BTreeMap, BTreeSet, Document, EngineError, Node, NodeId, NodeKind, Result, Session, Value, bad, inside_instance, json, owning_instance,
|
||||
req_targets, roots, set_name_from, string, targets, variant_name, variant_options, variant_props, variant_set, z_sorted,
|
||||
};
|
||||
|
||||
/// Wrap components in a new component set (named after the first) placed around them.
|
||||
pub(super) fn wrap_in_set(cmd: &str, doc: &mut Document, ids: &[NodeId]) -> Result<NodeId> {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
//! Creating nodes.
|
||||
|
||||
use figcraft_color::{ImageScaleMode, Paint};
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{Document, Node, NodeId, NodeKind, StrokeCap, TextAutoResize, TextData, VectorNetwork};
|
||||
use figcraft_geom::Point;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::params::{boolean, f64_of, get, id, num, num_or, opt_id, req_targets, string};
|
||||
use crate::{Result, Session, bad, spec};
|
||||
|
||||
/// Frame presets shown by the Frame tool (category, name, width, height).
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{Document, Effect, ExportSetting, ImageData, LayoutGrid, Node, NodeId, NodeKind};
|
||||
use figcraft_geom::Affine;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::params::{
|
||||
blend, boolean, color, get, id, id_of, num, num_or, opt_index, paint_of, parse, req_id, req_index, req_num, req_string, req_targets, string,
|
||||
targets,
|
||||
};
|
||||
use crate::{BatchStep, EngineError, Result, Session, bad, spec};
|
||||
|
||||
mod clipboard;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Affine, BTreeMap, BTreeSet, Document, EngineError, ImageData, Node, NodeId, NodeKind, Result, Session, Value, bad, delete, id, json, num, ok,
|
||||
req_targets, roots, string, targets, z_sorted,
|
||||
};
|
||||
|
||||
/// The copied form of the selection.
|
||||
pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<()> {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
BTreeMap, Effect, EngineError, Map, Node, NodeId, NodeKind, Result, Session, Value, bad, blend, boolean, color, get, json, num, ok, opt_index,
|
||||
paint_of, parse, req_id, req_index, req_string, req_targets, roots, string, targets, z_sorted,
|
||||
};
|
||||
|
||||
pub(super) fn batch_rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "object.batchRename";
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Affine, BTreeMap, BTreeSet, Node, NodeId, Result, Session, Value, bad, boolean, json, num, num_or, ok, req_num, req_string, req_targets, roots,
|
||||
};
|
||||
|
||||
pub(super) fn layers_at(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (x, y) = (req_num("select.layersAt", p, "x")?, req_num("select.layersAt", p, "y")?);
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use base64::Engine as _;
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{
|
||||
BOARD_PAGE, Document, ExportFormat, ExportSetting, ExportSize, FileKind, LineHeight, Node, NodeId, NodeKind, PrintSettings, StyleKind, TextStyle,
|
||||
VdCompat,
|
||||
};
|
||||
use figcraft_format::Version;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::params::{bool_of, boolean, get, id, num, num_or, req_index, req_string, string, targets};
|
||||
use crate::{DocState, EngineError, Result, Session, bad, spec};
|
||||
|
||||
mod export;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{DocState, Document, EngineError, LineHeight, NodeId, NodeKind, Result, StyleKind, TextStyle, Value, bad, get, json};
|
||||
|
||||
/// The recovery copy at `path` as a document with unsaved changes and no path.
|
||||
pub(crate) fn recovered(path: &str) -> Result<DocState> {
|
||||
@@ -351,7 +351,7 @@ fn link_instances(doc: &mut Document) {
|
||||
|
||||
/// Largest image file read for a REST API file JSON import.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_IMAGE_FILE: u64 = 256 * 1024 * 1024;
|
||||
const MAX_IMAGE_FILE: u64 = figcraft_doc::ImageData::MAX_BYTES;
|
||||
|
||||
/// Image bytes for a REST API file JSON's `imageRef`: `images/<imageRef>[.png|.jpg|…]` next to
|
||||
/// the file.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Arc, DocState, Document, EngineError, Result, Session, Value, Version, bad, get, json, req_index, string};
|
||||
|
||||
pub(super) fn version_json(index: usize, v: &Version) -> Value {
|
||||
let mut j = json!({"id": v.id, "index": index, "name": v.name, "description": v.description, "time": v.time});
|
||||
|
||||
@@ -5,7 +5,7 @@ use figcraft_doc::{Document, ImagePreset};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::params::{f64_of, get, num, req_index, req_string, req_targets, string, targets};
|
||||
use crate::{Result, Session, bad, spec};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -6,7 +6,7 @@ use serde_json::Value;
|
||||
|
||||
use super::props::sizing;
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::params::{get, num, req_num, req_targets, string};
|
||||
use crate::{Result, Session, bad, spec};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -5,16 +5,19 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{Collection, Document, ImportedComponent, ImportedRecord, LibraryLink, LibraryManifest, NodeId, PublishedLibrary, VariableId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use figcraft_collab::message::{LibraryReply, LibraryReq};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::library::*;
|
||||
use crate::library::{
|
||||
Pending, Selection, absolute_path, accept_updates, asset_root, default_selection, find_link, import, import_styles, import_variables,
|
||||
is_remote_variable, pending, prune, publish, publishable, published_collections, published_variables,
|
||||
};
|
||||
use crate::library_server::{archive, is_server_link, read_archive, shown_path};
|
||||
use crate::live::{NetReply, NetRequest, PublishOptions};
|
||||
use crate::params::*;
|
||||
use crate::params::{boolean, get, id_of, num, req_string, string};
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
|
||||
/// Longest library description kept (chars).
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
//! Every list is stored bottom first: index 0 paints (or applies) first, the last one is on top;
|
||||
//! the Design panel shows them in reverse, the topmost row first.
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{Document, LayoutGrid, Node, NodeId};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::params::{req_index, req_targets};
|
||||
use crate::{Result, Session, spec};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
//! Pages.
|
||||
|
||||
use figcraft_doc::proto::Flow;
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{Guide, NodeId, NodeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::params::{boolean, color, id, req_index, req_num, req_string, string};
|
||||
use crate::{Session, bad, spec};
|
||||
|
||||
/// After a page edit: the file keeps a page that isn't a divider, and the canvas shows one (the
|
||||
@@ -33,6 +33,7 @@ fn page_target(s: &Session, cmd: &str, p: &Value) -> crate::Result<NodeId> {
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
/// The page and ruler guide commands.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!("page.add", "Add page", "{name?} (a name starting with a hyphen adds a divider)", true, |s, p| {
|
||||
@@ -106,8 +107,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
copies.extend(doc.subtree(*c).into_iter().zip(doc.subtree(*copy)));
|
||||
}
|
||||
// The copy keeps the page's colour, flows (pointing at the copied frames), prototype settings and guides.
|
||||
if let NodeKind::Page { background, flows, prototype, guides } = doc.get(src)?.kind.clone()
|
||||
&& let NodeKind::Page { background: b, flows: f, prototype: pr, guides: g } = &mut doc.get_mut(np)?.kind
|
||||
let guides = doc.get(src)?.guides.clone();
|
||||
doc.get_mut(np)?.guides = guides;
|
||||
if let NodeKind::Page { background, flows, prototype } = doc.get(src)?.kind.clone()
|
||||
&& let NodeKind::Page { background: b, flows: f, prototype: pr } = &mut doc.get_mut(np)?.kind
|
||||
{
|
||||
*b = background;
|
||||
*f = flows
|
||||
@@ -115,7 +118,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.filter_map(|fl| copies.iter().find(|(old, _)| *old == fl.start).map(|(_, new)| Flow { start: *new, ..fl }))
|
||||
.collect();
|
||||
*pr = prototype;
|
||||
*g = guides;
|
||||
}
|
||||
// Keep the new page right after the source.
|
||||
if let Some(si) = doc.pages.iter().position(|x| *x == src) {
|
||||
|
||||
+598
-519
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,14 @@
|
||||
//! Prototype commands: interactions, flows, settings, presenting.
|
||||
|
||||
use figcraft_doc::proto::*;
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::proto::{
|
||||
Action, DeviceRotation, Easing, Expr, Flow, Interaction, MAX_DELAY_MS, MAX_EXPR_DEPTH, OverlayPosition, PrototypeSettings, Transition, Trigger,
|
||||
walk_mut,
|
||||
};
|
||||
use figcraft_doc::{Document, NodeId, NodeKind, VariableValue, Variables};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::params::{boolean, color, f64_of, get, id, num, parse, req_id, req_num, req_targets, string};
|
||||
use crate::{Result, Session, bad, spec};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -158,7 +161,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"proto.linkNotice",
|
||||
"Notice before leaving for a link",
|
||||
"{show? (default true; false skips it)} whether a presented prototype's Open link to a website first says that it leaves the prototype, with Open link and Cancel; kept with the preferences → {shown}",
|
||||
"{show? (default true; false skips it)} whether a presented prototype's Open link the viewer clicked (or fired by a key) first says that it leaves the prototype, with Open link and Cancel (a link a delay or hover fired always does); kept with the preferences → {shown}",
|
||||
false,
|
||||
|s, p| {
|
||||
let show = boolean(p, "show").unwrap_or(true);
|
||||
@@ -498,6 +501,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::params::id_of;
|
||||
use figcraft_doc::proto::{Direction, Overflow, ScrollPosition};
|
||||
|
||||
fn frame(s: &mut Session, x: f64) -> NodeId {
|
||||
let r = s.execute("node.create", &json!({"type": "frame", "x": x, "y": 0, "width": 100, "height": 200})).unwrap();
|
||||
|
||||
@@ -10,12 +10,12 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use figcraft_doc::proto::*;
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::proto::{Action, ConditionalBlock, Expr, ExprOp, MAX_ACTION_DEPTH, walk};
|
||||
use figcraft_doc::{Variable, VariableType, VariableValue, Variables};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::*;
|
||||
use crate::params::{bool_of, f64_of, get, id, parse, req_index, req_targets, string};
|
||||
use crate::{Result, Session, bad, spec};
|
||||
|
||||
/// Most actions in one list (an interaction's, or a block's).
|
||||
|
||||
@@ -7,7 +7,7 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::devinspect::{dev_items, has_status};
|
||||
use crate::params::*;
|
||||
use crate::params::{boolean, get, req_targets, string, targets};
|
||||
use crate::{Result, Session, bad, spec};
|
||||
|
||||
/// Longest change reason kept.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user