mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-11 16:30:12 -06:00
Merge main into #1420
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: GPT-6.1 Sol <noreply@openai.com> Co-Authored-By: GPT-6 Astra <noreply@openai.com>
This commit is contained in:
@@ -23,3 +23,27 @@ jobs:
|
||||
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
|
||||
with:
|
||||
extra-checks-script: packaging/check-app-specific.sh
|
||||
|
||||
windows-install-policy:
|
||||
timeout-minutes: 10
|
||||
name: Windows installation policy (no app build)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install the pinned packaging toolchain
|
||||
shell: pwsh
|
||||
run: |
|
||||
$tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix'
|
||||
dotnet tool install --tool-path $tools wix --version 5.0.2
|
||||
if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' }
|
||||
$tools >> $env:GITHUB_PATH
|
||||
$policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json
|
||||
foreach ($extension in @($policy.extensions)) {
|
||||
& (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2"
|
||||
if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." }
|
||||
}
|
||||
- name: Build and check non-executable installer fixtures
|
||||
shell: pwsh
|
||||
run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests')
|
||||
|
||||
@@ -26,8 +26,10 @@ permissions:
|
||||
|
||||
jobs:
|
||||
windows-arm64:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
|
||||
# Immutable supporting workflow revision until the new install contract lands upstream.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
|
||||
with:
|
||||
app-name: figcraft
|
||||
app-dir: FigCraft
|
||||
install-relative-path: Programs/ArtCraft/FigCraft
|
||||
install-scope: perUser
|
||||
|
||||
@@ -8,3 +8,5 @@ plans/
|
||||
/log/
|
||||
.DS_Store
|
||||
*.profraw
|
||||
.cache/
|
||||
.vscode/
|
||||
|
||||
@@ -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
+317
-50
@@ -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",
|
||||
]
|
||||
@@ -942,12 +942,94 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
|
||||
[[package]]
|
||||
name = "craft-i18n"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"fixed_decimal",
|
||||
"icu_decimal",
|
||||
"icu_locale_core",
|
||||
"icu_locale_fallback",
|
||||
"icu_normalizer",
|
||||
"icu_plurals",
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"icu_provider_adapters",
|
||||
"icu_provider_blob",
|
||||
"log",
|
||||
"objc2-foundation 0.3.2",
|
||||
"sys-locale",
|
||||
"unicode-bidi",
|
||||
"windows-registry",
|
||||
"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"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"craft-fonts-build",
|
||||
"craft-i18n",
|
||||
"egui",
|
||||
"epaint",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"skrifa 0.44.0",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -972,7 +1054,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-window"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"wayland-client",
|
||||
]
|
||||
@@ -986,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"
|
||||
@@ -1478,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]]
|
||||
@@ -1545,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",
|
||||
@@ -1561,6 +1653,8 @@ dependencies = [
|
||||
"fmv-macos-events",
|
||||
"image",
|
||||
"log",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"wat",
|
||||
@@ -1573,7 +1667,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-cli"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-engine",
|
||||
@@ -1587,7 +1681,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-collab"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
@@ -1598,12 +1692,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",
|
||||
@@ -1626,7 +1721,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-color"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1635,7 +1730,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-doc"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-geom",
|
||||
@@ -1647,7 +1742,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-engine"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"craft-docpath",
|
||||
@@ -1679,9 +1774,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-format"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-layout",
|
||||
"figcraft-pathops",
|
||||
"flate2",
|
||||
"image",
|
||||
@@ -1695,7 +1791,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-geom"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1703,7 +1799,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-layout"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1711,7 +1807,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-mcp"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-collab-net",
|
||||
@@ -1727,7 +1823,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-pathops"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1736,7 +1832,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-pdf"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-doc",
|
||||
@@ -1753,7 +1849,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-plugin"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"serde",
|
||||
@@ -1766,7 +1862,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-render"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"figcraft-color",
|
||||
@@ -1784,7 +1880,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-server"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"ctrlc",
|
||||
@@ -1808,7 +1904,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-svg"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-color",
|
||||
@@ -1824,7 +1920,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-text"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"core-text",
|
||||
"figcraft-doc",
|
||||
@@ -1840,10 +1936,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-ui-egui"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
"egui",
|
||||
"egui_extras",
|
||||
@@ -1867,7 +1964,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-vectordrawable"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-doc",
|
||||
@@ -1881,7 +1978,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-web"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"eframe",
|
||||
@@ -1913,6 +2010,17 @@ version = "0.1.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
|
||||
|
||||
[[package]]
|
||||
name = "fixed_decimal"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"smallvec",
|
||||
"writeable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
version = "0.5.7"
|
||||
@@ -2399,9 +2507,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.4.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd"
|
||||
checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"potential_utf",
|
||||
@@ -2412,43 +2520,110 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_core"
|
||||
version = "2.4.0"
|
||||
name = "icu_decimal"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0"
|
||||
checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"fixed_decimal",
|
||||
"icu_locale_core",
|
||||
"icu_pattern",
|
||||
"icu_plurals",
|
||||
"icu_provider",
|
||||
"serde",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_core"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"serde",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "2.4.0"
|
||||
name = "icu_locale_fallback"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599"
|
||||
checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9"
|
||||
dependencies = [
|
||||
"icu_locale_core",
|
||||
"icu_locale_fallback_data",
|
||||
"icu_provider",
|
||||
"potential_utf",
|
||||
"tinystr",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_fallback_data"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8"
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
|
||||
dependencies = [
|
||||
"icu_collections",
|
||||
"icu_normalizer_data",
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"smallvec",
|
||||
"utf16_iter",
|
||||
"utf8_iter",
|
||||
"write16",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer_data"
|
||||
version = "2.4.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc"
|
||||
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
|
||||
|
||||
[[package]]
|
||||
name = "icu_pattern"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc43ce0e76ee8cb0482dc6e2dbb9b76278e8038f45daabf2a2877577e2ceb18f"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"either",
|
||||
"serde",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_plurals"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"fixed_decimal",
|
||||
"icu_provider",
|
||||
"serde",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties"
|
||||
version = "2.4.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f"
|
||||
checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_collections",
|
||||
@@ -2461,18 +2636,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties_data"
|
||||
version = "2.4.0"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf"
|
||||
checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider"
|
||||
version = "2.4.0"
|
||||
version = "2.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514"
|
||||
checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locale_core",
|
||||
"postcard",
|
||||
"serde",
|
||||
"stable_deref_trait",
|
||||
"writeable",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
@@ -2480,6 +2658,30 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider_adapters"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820"
|
||||
dependencies = [
|
||||
"icu_locale_fallback",
|
||||
"icu_provider",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider_blob"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e"
|
||||
dependencies = [
|
||||
"icu_provider",
|
||||
"postcard",
|
||||
"serde",
|
||||
"writeable",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
@@ -2711,6 +2913,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"
|
||||
@@ -2731,7 +2943,7 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bumpalo",
|
||||
@@ -2844,6 +3056,9 @@ name = "litemap"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
@@ -2920,6 +3135,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"
|
||||
@@ -3418,7 +3651,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]]
|
||||
@@ -3684,6 +3917,8 @@ version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
@@ -4090,7 +4325,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4147,7 +4382,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4681,6 +4916,18 @@ dependencies = [
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sys-locale"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.46"
|
||||
@@ -4698,10 +4945,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]]
|
||||
@@ -4855,6 +5102,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"serde_core",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
@@ -5165,6 +5413,12 @@ dependencies = [
|
||||
"xmlwriter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf16_iter"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
|
||||
|
||||
[[package]]
|
||||
name = "utf8-zero"
|
||||
version = "0.8.1"
|
||||
@@ -5814,7 +6068,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]]
|
||||
@@ -6152,11 +6406,20 @@ dependencies = [
|
||||
"read-fonts 0.39.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "write16"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936"
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11-dl"
|
||||
@@ -6235,7 +6498,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"figcraft-engine",
|
||||
"serde_json",
|
||||
@@ -6424,8 +6687,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"serde_core",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6434,6 +6700,7 @@ version = "0.11.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec-derive",
|
||||
|
||||
+14
-6
@@ -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"
|
||||
@@ -35,13 +35,18 @@ figcraft-plugin = { path = "crates/plugin" }
|
||||
|
||||
# craft-libs crates all pin one rev (craftrules standards/shared-libraries.md): bump them together.
|
||||
# Which paths stored in a document may be followed (craft-libs).
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
# Who draws the window frame on Linux — the compositor or the app — so every Craft app shows the
|
||||
# same native titlebar per desktop (craft-libs). FigCraft only reads the probe result and maps it
|
||||
# to its own `TitleBar`, so it does not enable craft-window's optional `egui` helpers (which would
|
||||
# pull a second egui family version).
|
||||
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] }
|
||||
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
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"] }
|
||||
@@ -100,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"
|
||||
@@ -128,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"
|
||||
@@ -160,4 +168,4 @@ winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457
|
||||
|
||||
# craft-libs' vendored krilla 0.8.2: decoded PNGs are capped at 1 GiB and reserved fallibly, and a
|
||||
# panic while drawing unwinds without aborting.
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
|
||||
@@ -208,7 +208,7 @@ exposes to agents, and exported at 2x by FigCraft's own renderer. The source fil
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<h3>Prototype and hand off</h3>
|
||||
Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like <code>count + 1</code>), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, Vue, Leptos, SwiftUI, Compose) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
|
||||
Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like <code>count + 1</code>), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, Vue, Leptos, SwiftUI, Compose, ArkUI, Flutter) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
+24
-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-29 · **Change:** minor (Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; 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,12 +127,19 @@ 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-27** · M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143. Numbers unchanged.
|
||||
- **2026-10-27** · M6, #1341: FigJam sticky notes and shapes with text import their generated layers (#1397): the background (its saved vector network, or the outer fill or stroke outline when the generated geometry is damaged, before a rectangle), text with its typography and paints, saved bounds and sticky author lines, as editable frame children; missing or invalid generated text falls back to the outer text within the layer budget. Synthetic regressions and a local check of the report sample; native board metadata and direct `.jam` opening remain outside this fix. Numbers unchanged.
|
||||
- **2026-10-27** · Batch continue-on-error (M0.9, #1386): `command.batch` and MCP `batch` accept `onError: "continue"`, roll back failed steps individually and return one outcome per step. References to failed steps fail with both step numbers; successful edits stay one undo step, and the default abort mode is unchanged. Engine and MCP regression tests. 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** · M6, #1143: offline Penpot 2.x open/import/export through the editor, CLI and MCP using the existing document model. Unsupported layout settings retain saved positions and bounded source metadata with warnings; font binaries and toggle-overlay are reported, and provider fonts are never downloaded. Shared ZIP/JSON/token budgets guard untrusted input. Figma parity 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.
|
||||
- **2026-10-24** · Boards (M12): board pages get a dot grid under their layers (drawn by the canvas render after the page colour, thinned out when zoomed out); a selected connector shows its ends as handles, and dragging one onto another object re-attaches it there, onto empty canvas leaves it free (`board.reattach`, one undo step, also over MCP); connectors can be curved, and elbows go around the objects they join when the short way would cross them. Checked in the app, through MCP and in the web build ([boards-parity](docs/boards-parity.md)).
|
||||
@@ -348,7 +355,18 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-29 | minor | M1, #1464: Crop image. Object ▸ Crop image, the right-click menu (one image layer) and a crop button on the image fill row run `image.crop`: the layer's top visible image fill (or the one `index` names) switches to Crop with the picture left where Fill or Fit drew it, as one undo step, and the canvas enters crop mode (the image shows dimmed outside the layer, whose box gets corner brackets and edge bars; the layer's edges and corners crop it with the picture held still, the image drags to move and its corners scale it; Enter, Escape or a click outside finishes). A layer without an image fill, or several layers, are refused with a reason. Tests: `image_crop_tests`, `crop_image_tests`. Numbers unchanged (the menu item is FigCraft's own and not counted by `parity`) |
|
||||
| 2026-10-29 | minor | The Design panel's sections on one grid (M4, #858): 16 pt left and 8 pt right margins, two 88 pt columns with 8 pt gutters and a 24 pt button column, 24 pt rows under 10 pt captions, 4 pt between a row's two buttons; section titles stay 11 pt semibold; the panel's edge is drawn inside it, so its right margin no longer loses 1 pt to a frame stroke. Numbers unchanged |
|
||||
| 2026-10-29 | minor | The toolbar on one 32 pt grid (M4, #1210): the pill's cells, padding, gaps and divider share one grid in every state, and the Resources button is gone, as in UI3; the Assets panel opens from the left rail, Shift+I, Alt+2 and View ▸ Panels ▸ Assets. Numbers unchanged |
|
||||
| 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-27 | minor | M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143. Numbers unchanged. |
|
||||
| 2026-10-27 | minor | M6, #1341: FigJam sticky notes and shapes with text import their generated layers (#1397): the background (its saved vector network, or the outer fill or stroke outline when the generated geometry is damaged, before a rectangle), text with its typography and paints, saved bounds and sticky author lines, as editable frame children; missing or invalid generated text falls back to the outer text within the layer budget. Synthetic regressions and a local check of the report sample; native board metadata and direct `.jam` opening remain outside this fix. Numbers unchanged. |
|
||||
| 2026-10-27 | minor | Placed images keep their own size (M7, #1360): dropping, Place image, paste and `image.place` put an image at its pixel size and fit only a side past 4096 px (they fitted it into 1000 px); a PNG whose `pHYs` records a whole 2x–4x multiple of 72 dpi (macOS screenshots record 144 dpi) is placed at its point size, so an 800 × 600 px capture comes in at 400 × 300; other densities, such as 300 dpi scans, keep their pixel size; the image data stays at full resolution and an explicit width or height still wins. On macOS, paste reads the clipboard's PNG as is (its density and colour profile kept) and falls back to the decoded image. 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 | M6, #1143: offline Penpot 2.x open/import/export through the editor, CLI and MCP using the existing document model. Unsupported layout settings retain saved positions and bounded source metadata with warnings; font binaries and toggle-overlay are reported, and provider fonts are never downloaded. Shared ZIP/JSON/token budgets guard untrusted input. Figma parity 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 |
|
||||
| 2026-10-24 | minor | Boards (M12): a dot grid on board pages under the layers, connector ends dragged onto other objects or empty canvas (`board.reattach`), curved connectors, elbows that go around what they join. Boards row ~35% → ~37%, ~20% → ~21%, 30–60 h → 28–56 h; headline numbers unchanged (boards are outside the weighted sum) |
|
||||
@@ -424,6 +442,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
| 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. |
|
||||
| 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. |
|
||||
| 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. |
|
||||
| 2026-10-16 | minor | Flutter code (ours; the incumbent generates CSS, iOS and Android only): `inspect.code`, `inspect.componentCode` and `codeConnect.set` take `language: flutter` (`dart` is an alias): a `StatelessWidget` for a component (properties as constructor parameters with their defaults, instances as calls, variants as a Dart enum, swaps a `Widget`, slots a `List<Widget>?`) and a Dart widget tree for a layer (`Container` with a `BoxDecoration`, `Row`/`Column` with `spacing`, `Expanded`, `Stack` + `Positioned`, `Text` with a `TextStyle`, `Image.asset`, `Opacity`), plus Dart `import '…';` lines in Code Connect. The Dev Mode language list includes it. Numbers unchanged |
|
||||
| 2026-10-16 | minor | Adobe Fonts found on macOS (M7, #298): fonts activated through Adobe Fonts rendered in the fallback font without a warning, because Creative Cloud keeps them in its own folder as hidden files named by number, which the font scan never read. The macOS scan now includes `~/Library/Application Support/Adobe/CoreSync/plugins/livetype`, and files without an extension are indexed when they start with a font signature. Fonts are read in place. Regression test in the font database tests; docs/cli.md lists the folder. Numbers unchanged |
|
||||
| 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. |
|
||||
|
||||
@@ -11,7 +11,13 @@ use serde_json::{Value, json};
|
||||
use crate::{CliError, CliResult};
|
||||
|
||||
pub fn load(path: &str) -> CliResult<Document> {
|
||||
load_noted(path).map(|(d, _)| d)
|
||||
let (doc, notes) = load_noted(path)?;
|
||||
if ext(path) == "penpot" {
|
||||
for note in notes {
|
||||
eprintln!("note: {note}");
|
||||
}
|
||||
}
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
/// The document with the loader's notes (repairs of a damaged file, or import warnings).
|
||||
@@ -120,7 +126,8 @@ pub fn write_doc(doc: &Document, page: NodeId, out: &str) -> CliResult<()> {
|
||||
"png" | "jpg" | "jpeg" | "webp" => encode(doc, &render_page(doc, page, 1.0)?, &e)?,
|
||||
"pdf" => page_pdf(doc, page, &PrintSettings::default())?,
|
||||
"fig" => fig_bytes(doc)?,
|
||||
other => return Err(CliError::Usage(format!("unknown output format `.{other}` (figcraft, json, svg, png, jpg, webp, pdf, fig)"))),
|
||||
"penpot" => penpot_bytes(doc)?,
|
||||
other => return Err(CliError::Usage(format!("unknown output format `.{other}` (figcraft, json, svg, png, jpg, webp, pdf, fig, penpot)"))),
|
||||
};
|
||||
write(out, &bytes)
|
||||
}
|
||||
@@ -134,6 +141,14 @@ fn fig_bytes(doc: &Document) -> CliResult<Vec<u8>> {
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn penpot_bytes(doc: &Document) -> CliResult<Vec<u8>> {
|
||||
let (bytes, warnings) = figcraft_engine::cmd::file::penpot_bytes(doc).map_err(|e| CliError::Failed(e.to_string()))?;
|
||||
for w in warnings {
|
||||
eprintln!("note: {w}");
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// A page as PDF: its top-level frames one page each, or (without frames) everything on one page.
|
||||
fn page_pdf(doc: &Document, page: NodeId, print: &PrintSettings) -> CliResult<Vec<u8>> {
|
||||
let r = if doc.top_frames(page).iter().any(|f| doc.node(*f).is_some_and(|n| n.visible)) {
|
||||
@@ -237,12 +252,16 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
|
||||
println!("{out}");
|
||||
return Ok(ExitCode::SUCCESS);
|
||||
}
|
||||
if t.format.as_deref().map_or(ext(out) == "penpot", |f| f.eq_ignore_ascii_case("penpot")) {
|
||||
write(out, &penpot_bytes(&doc)?)?;
|
||||
println!("{out}");
|
||||
return Ok(ExitCode::SUCCESS);
|
||||
}
|
||||
let page = page_named(&doc, t.page.as_deref())?;
|
||||
let (scale, node, print) = (t.scale, t.node.as_deref(), &t.print);
|
||||
let fmt = match t.format.as_deref() {
|
||||
Some(f) => {
|
||||
image_format(&f.to_ascii_lowercase()).ok_or_else(|| CliError::Usage(format!("unknown --format `{f}` (png|jpg|svg|webp|pdf|xml|fig)")))?
|
||||
}
|
||||
Some(f) => image_format(&f.to_ascii_lowercase())
|
||||
.ok_or_else(|| CliError::Usage(format!("unknown --format `{f}` (png|jpg|svg|webp|pdf|xml|fig|penpot)")))?,
|
||||
None => image_format(&ext(out)).unwrap_or(ExportFormat::Png),
|
||||
};
|
||||
let target = node.map(|q| find_node(&doc, page, q)).transpose()?;
|
||||
@@ -321,6 +340,11 @@ fn refuse_dropping_images_from_script(input: Option<(&str, usize)>, active: &fig
|
||||
pub fn convert(input: &str, out: &str) -> CliResult<ExitCode> {
|
||||
let (loaded, _) = figcraft_engine::cmd::file::load_path(input, None).map_err(|e| CliError::Failed(format!("can't open {input}: {e}")))?;
|
||||
refuse_dropping_images(input, out, loaded.images_past_budget)?;
|
||||
if ext(input) == "penpot" {
|
||||
for note in &loaded.issues {
|
||||
eprintln!("note: {note}");
|
||||
}
|
||||
}
|
||||
let doc = loaded.doc;
|
||||
let page = doc
|
||||
.current_page
|
||||
@@ -366,6 +390,7 @@ pub fn parse_script(text: &str) -> Result<Vec<BatchStep>, String> {
|
||||
.enumerate()
|
||||
.map(|(i, s)| {
|
||||
let cmd = s.get("command").and_then(Value::as_str).ok_or_else(|| format!("step {i}: `command` (a string) is required"))?;
|
||||
figcraft_engine::batch::refuse_unknown_step_keys(i, &s).map_err(|e| e.to_string())?;
|
||||
let params = match s.get("params") {
|
||||
None | Some(Value::Null) => json!({}),
|
||||
Some(p @ Value::Object(_)) => p.clone(),
|
||||
@@ -443,6 +468,9 @@ mod tests {
|
||||
assert!(parse_script("[{\"command\":\"a\",\"params\":3}]").is_err());
|
||||
assert!(parse_script("not json\n").is_err());
|
||||
assert!(parse_script("42").is_err());
|
||||
// `args` for `params` would run the step without them (#1413).
|
||||
let e = parse_script(r#"[{"command":"a"},{"command":"b","args":{"x":1}}]"#).unwrap_err().to_string();
|
||||
assert!(e.contains("step 1: unknown key `args`"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -17,7 +17,7 @@ pub const USAGE: &str = "figcraft-cli — FigCraft without a window
|
||||
|
||||
USAGE:
|
||||
figcraft-cli info <file> [--json]
|
||||
figcraft-cli export <file> -o <out> [--format png|jpg|svg|webp|pdf|xml|fig] [--scale N] [--page NAME] [--node ID|NAME]
|
||||
figcraft-cli export <file> -o <out> [--format png|jpg|svg|webp|pdf|xml|fig|penpot] [--scale N] [--page NAME] [--node ID|NAME]
|
||||
[--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk] [--vd-compat bestEffort|exact|ignore]
|
||||
figcraft-cli convert <in> <out>
|
||||
figcraft-cli render <file> -o <out.png> [--scale N] [--page NAME]
|
||||
@@ -33,11 +33,11 @@ Every subcommand takes --no-system-fonts (or set FIGCRAFT_NO_SYSTEM_FONTS=1): te
|
||||
fonts built into FigCraft, for renders that match on every machine. Otherwise installed fonts are
|
||||
indexed the first time text is laid out, as in the app.
|
||||
|
||||
Files: .figcraft (native), .json (FigCraft or REST API file JSON), .fig (best effort; `info` lists what
|
||||
Files: .figcraft (native), .penpot (Penpot 2.x), .json (FigCraft or REST API file JSON), .fig (best effort; `info` lists what
|
||||
wasn't imported), .sketch (Sketch 48 or newer, best effort), .svg, .png/.jpg/.webp/.gif/.bmp (open as an
|
||||
image layer). `export` writes one file per top-level frame into the <out> directory, or one file when
|
||||
<out> has an image extension or --node is given (`fig`, or an <out> ending in .fig: the whole file as .fig, experimental; other apps may
|
||||
not open every file); PDFs take print options (a bleed like 3mm or 0.125in,
|
||||
not open every file; `penpot`, or an <out> ending in .penpot: the whole file as a Penpot 2.x archive); PDFs take print options (a bleed like 3mm or 0.125in,
|
||||
crop and bleed marks, CMYK colour). `run` executes a JSON array (or JSON lines) of
|
||||
{\"command\": id, \"params\": {...}} in a headless session and prints one JSON result line per step.
|
||||
`plugin publish` sends a plugin folder (figcraft-plugin.json, its module, an optional assets/ folder)
|
||||
|
||||
@@ -13,6 +13,40 @@ fn tmp(name: &str) -> std::path::PathBuf {
|
||||
d
|
||||
}
|
||||
|
||||
/// Conversion and image commands must report the Penpot loader's notes too.
|
||||
#[test]
|
||||
fn penpot_import_notes_reach_convert_export_and_render() {
|
||||
use figcraft_doc::proto::{Action, Easing, Interaction, Transition, Trigger};
|
||||
use figcraft_doc::{Document, NodeKind};
|
||||
let d = tmp("penpot-notes");
|
||||
let mut doc = Document::new("Prototype");
|
||||
let page = doc.first_page().unwrap();
|
||||
let home = doc.create(page, NodeKind::Frame, "Home", 0.0, 0.0, 40.0, 40.0).unwrap();
|
||||
let dest = doc.create(page, NodeKind::Frame, "Next", 60.0, 0.0, 40.0, 40.0).unwrap();
|
||||
doc.node_mut(home).unwrap().interactions.push(Interaction {
|
||||
trigger: Trigger::OnClick,
|
||||
actions: vec![Action::Navigate { dest, preserve_scroll: false }],
|
||||
transition: Transition::Instant,
|
||||
duration_ms: 0,
|
||||
easing: Easing::Linear,
|
||||
delay_ms: 0,
|
||||
});
|
||||
let input = d.join("source.penpot");
|
||||
std::fs::write(&input, figcraft_format::penpot::export(&doc).unwrap().0).unwrap();
|
||||
for (command, name) in [("convert", "out.figcraft"), ("export", "out.svg"), ("render", "out.png")] {
|
||||
let mut cmd = bin();
|
||||
cmd.arg(command).arg(&input);
|
||||
if command != "convert" {
|
||||
cmd.arg("-o");
|
||||
}
|
||||
let out = cmd.arg(d.join(name)).output().unwrap();
|
||||
let notes = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success(), "{command}: {notes}");
|
||||
assert!(notes.contains("note: Imported 1 Penpot prototype connection"), "{command}: {notes}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn option_terminator_allows_dash_prefixed_filenames() {
|
||||
let dir = tmp("option-terminator");
|
||||
|
||||
@@ -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,13 @@ 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"
|
||||
# Paste reads the clipboard's public.png as is, keeping the screen density a screenshot records.
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSPasteboard"] }
|
||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSData", "NSString"] }
|
||||
# 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 }
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use figcraft_engine::events::{self, EventLog};
|
||||
use figcraft_ui_egui::{ControlReply, ControlRequest, RequestGate, RequestState};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -58,7 +59,7 @@ pub fn drain_queue(queue: &Queue, target: &mut Vec<ControlRequest>) {
|
||||
target.extend(queue.drain(..count));
|
||||
}
|
||||
|
||||
pub fn start(port: u16, queue: Queue, ctx: CtxSlot) {
|
||||
pub fn start(port: u16, queue: Queue, ctx: CtxSlot, events: Arc<EventLog>) {
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
@@ -71,7 +72,7 @@ pub fn start(port: u16, queue: Queue, ctx: CtxSlot) {
|
||||
let spawned = std::thread::Builder::new().name("figcraft-control".into()).spawn(move || {
|
||||
let mut workers = Vec::new();
|
||||
for stream in accepted(listener.incoming(), || std::thread::sleep(ACCEPT_RETRY)) {
|
||||
spawn_connection(stream, queue.clone(), ctx.clone(), &mut workers, TIMEOUT);
|
||||
spawn_connection(stream, queue.clone(), ctx.clone(), events.clone(), &mut workers, TIMEOUT);
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
@@ -84,13 +85,20 @@ fn accepted<T>(incoming: impl Iterator<Item = std::io::Result<T>>, mut wait: imp
|
||||
incoming.filter_map(move |stream| stream.map_err(|_| wait()).ok())
|
||||
}
|
||||
|
||||
fn spawn_connection(stream: TcpStream, queue: Queue, ctx: CtxSlot, workers: &mut Vec<std::thread::JoinHandle<()>>, timeout: Duration) {
|
||||
fn spawn_connection(
|
||||
stream: TcpStream,
|
||||
queue: Queue,
|
||||
ctx: CtxSlot,
|
||||
events: Arc<EventLog>,
|
||||
workers: &mut Vec<std::thread::JoinHandle<()>>,
|
||||
timeout: Duration,
|
||||
) {
|
||||
workers.retain(|worker| !worker.is_finished());
|
||||
if workers.len() >= MAX_CONNECTIONS {
|
||||
return;
|
||||
}
|
||||
match std::thread::Builder::new().name("figcraft-control-conn".into()).spawn(move || {
|
||||
let _ = serve(stream, queue, ctx, timeout);
|
||||
let _ = serve_with_events(stream, queue, ctx, events, timeout);
|
||||
}) {
|
||||
Ok(worker) => workers.push(worker),
|
||||
Err(e) => log::error!("control connection thread: {e}"),
|
||||
@@ -100,7 +108,13 @@ fn spawn_connection(stream: TcpStream, queue: Queue, ctx: CtxSlot, workers: &mut
|
||||
/// Serve one control connection: read bounded JSON lines, reject oversized frames and a
|
||||
/// full queue, queue one request, wait under its gate, and write one reply. Non-JSON input
|
||||
/// answers with an error and closes the connection so HTTP body lines cannot become commands.
|
||||
fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> std::io::Result<()> {
|
||||
/// Serve a connection with a private event log (the tests; the app shares its session's log).
|
||||
#[cfg(test)]
|
||||
fn serve(out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> std::io::Result<()> {
|
||||
serve_with_events(out, queue, ctx, Arc::new(EventLog::default()), timeout)
|
||||
}
|
||||
|
||||
fn serve_with_events(mut out: TcpStream, queue: Queue, ctx: CtxSlot, events: Arc<EventLog>, timeout: Duration) -> std::io::Result<()> {
|
||||
out.set_read_timeout(Some(timeout))?;
|
||||
out.set_write_timeout(Some(timeout))?;
|
||||
let mut reader = BufReader::new(out.try_clone()?);
|
||||
@@ -126,6 +140,16 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s
|
||||
let id = v.get("id").cloned().unwrap_or(Value::Null);
|
||||
let method = v.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let params = v.get("params").cloned().unwrap_or(json!({}));
|
||||
// A poll waits on the shared log, not on a frame: it needs no UI thread and no
|
||||
// request slot, and it works while the window is minimised.
|
||||
if method == "events.poll" {
|
||||
let reply = match event_poll(&events, ¶ms) {
|
||||
Ok(result) => json!({"id": id, "ok": true, "result": result}),
|
||||
Err(error) => json!({"id": id, "ok": false, "error": error}),
|
||||
};
|
||||
writeln!(out, "{reply}")?;
|
||||
continue;
|
||||
}
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let gate = RequestGate::new();
|
||||
let mut pending = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
@@ -166,6 +190,16 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `events.poll {after?, timeoutMs?, topics?, limit?}`: read the shared log, waiting up to 25 s.
|
||||
fn event_poll(log: &EventLog, params: &Value) -> Result<Value, String> {
|
||||
let after = params.get("after").and_then(Value::as_u64).unwrap_or(0);
|
||||
let timeout = params.get("timeoutMs").and_then(Value::as_u64).unwrap_or(20_000).min(25_000);
|
||||
let limit = params.get("limit").and_then(Value::as_u64).unwrap_or(100).clamp(1, 1000) as usize;
|
||||
let topics = events::topics(params.get("topics"))?;
|
||||
let result = log.poll(after, &topics, limit, Duration::from_millis(timeout));
|
||||
serde_json::to_value(result).map_err(|e| format!("serialize events: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "control_server_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -198,12 +198,26 @@ fn connection_limit_rejects_excess_clients_and_reuses_finished_slots() {
|
||||
let mut clients = Vec::new();
|
||||
for _ in 0..MAX_CONNECTIONS {
|
||||
let (client, server) = pair();
|
||||
spawn_connection(server, pending.clone(), ctx.clone(), &mut workers, Duration::from_secs(5));
|
||||
spawn_connection(
|
||||
server,
|
||||
pending.clone(),
|
||||
ctx.clone(),
|
||||
Arc::new(figcraft_engine::events::EventLog::default()),
|
||||
&mut workers,
|
||||
Duration::from_secs(5),
|
||||
);
|
||||
clients.push(client);
|
||||
}
|
||||
assert_eq!(workers.len(), MAX_CONNECTIONS);
|
||||
let (mut extra, server) = pair();
|
||||
spawn_connection(server, pending.clone(), ctx.clone(), &mut workers, Duration::from_secs(5));
|
||||
spawn_connection(
|
||||
server,
|
||||
pending.clone(),
|
||||
ctx.clone(),
|
||||
Arc::new(figcraft_engine::events::EventLog::default()),
|
||||
&mut workers,
|
||||
Duration::from_secs(5),
|
||||
);
|
||||
assert_eq!(extra.read(&mut [0]).unwrap(), 0);
|
||||
assert_eq!(workers.len(), MAX_CONNECTIONS);
|
||||
for client in clients {
|
||||
@@ -215,7 +229,7 @@ fn connection_limit_rejects_excess_clients_and_reuses_finished_slots() {
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
let (mut client, server) = pair();
|
||||
spawn_connection(server, pending, ctx, &mut workers, Duration::from_secs(5));
|
||||
spawn_connection(server, pending, ctx, Arc::new(figcraft_engine::events::EventLog::default()), &mut workers, Duration::from_secs(5));
|
||||
assert_eq!(workers.len(), 1);
|
||||
client.write_all(b"invalid\n").unwrap();
|
||||
assert_eq!(reply(&mut BufReader::new(client.try_clone().unwrap()))["ok"], false);
|
||||
@@ -249,6 +263,39 @@ fn a_client_that_stops_reading_cannot_hold_a_worker() {
|
||||
worker.join().unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn events_poll_is_answered_without_the_ui_queue() {
|
||||
let (mut client, server) = pair();
|
||||
let pending = queue();
|
||||
let q = pending.clone();
|
||||
let log = Arc::new(figcraft_engine::events::EventLog::default());
|
||||
log.push("comment.added", "Doc", None, json!({"commentId": 7}));
|
||||
let l = log.clone();
|
||||
let worker = std::thread::spawn(move || serve_with_events(server, q, context(), l, Duration::from_secs(5)));
|
||||
client.write_all(b"{\"id\":1,\"method\":\"events.poll\",\"params\":{\"after\":0,\"timeoutMs\":0}}\n").unwrap();
|
||||
let mut reader = BufReader::new(client);
|
||||
let r = reply(&mut reader);
|
||||
assert_eq!(r["id"], 1);
|
||||
assert_eq!(r["result"]["events"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(r["result"]["events"][0]["kind"], "comment.added");
|
||||
assert_eq!(r["result"]["events"][0]["data"]["commentId"], 7);
|
||||
assert_eq!(r["result"]["cursor"], 1);
|
||||
assert!(pending.lock().unwrap().is_empty());
|
||||
reader.get_ref().shutdown(Shutdown::Write).unwrap();
|
||||
worker.join().unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_comment_added_in_the_session_is_pollable() {
|
||||
let mut session = figcraft_engine::Session::new();
|
||||
let log = session.events.clone();
|
||||
let id = session.execute("comment.add", &json!({"x": 10, "y": 20, "text": "Fix the padding"})).unwrap()["id"].clone();
|
||||
let r = event_poll(&log, &json!({"after": 0, "timeoutMs": 0})).unwrap();
|
||||
assert_eq!(r["events"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(r["events"][0]["kind"], "comment.added");
|
||||
assert_eq!(r["events"][0]["data"]["commentId"], id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_limit_sized_frame_is_accepted_at_eof() {
|
||||
let (mut client, server) = pair();
|
||||
@@ -342,3 +389,40 @@ fn timed_out_requests_release_a_full_deferred_ui_queue() {
|
||||
worker.join().unwrap().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn events_poll_rejects_an_unknown_topic_without_the_ui_queue() {
|
||||
let (client, server) = pair();
|
||||
let pending = queue();
|
||||
let q = pending.clone();
|
||||
let log = Arc::new(figcraft_engine::events::EventLog::default());
|
||||
let l = log.clone();
|
||||
let worker = std::thread::spawn(move || serve_with_events(server, q, context(), l, Duration::from_secs(5)));
|
||||
let mut reader = BufReader::new(client);
|
||||
reader.get_mut().write_all(b"{\"id\":1,\"method\":\"events.poll\",\"params\":{\"topics\":[\"nope\"],\"timeoutMs\":0}}\n").unwrap();
|
||||
let r = reply(&mut reader);
|
||||
assert_eq!(r["id"], 1);
|
||||
assert_eq!(r["ok"], false);
|
||||
assert!(r["error"].as_str().is_some_and(|e| e.contains("unknown topic")), "{r}");
|
||||
assert!(pending.lock().unwrap().is_empty());
|
||||
reader.get_ref().shutdown(Shutdown::Write).unwrap();
|
||||
worker.join().unwrap().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn events_poll_reports_reset_for_a_cursor_ahead_of_the_log() {
|
||||
let (client, server) = pair();
|
||||
let log = Arc::new(figcraft_engine::events::EventLog::default());
|
||||
log.push("comment.added", "Doc", None, json!({"commentId": 1}));
|
||||
let q = queue();
|
||||
let l = log.clone();
|
||||
let worker = std::thread::spawn(move || serve_with_events(server, q, context(), l, Duration::from_secs(5)));
|
||||
let mut reader = BufReader::new(client);
|
||||
reader.get_mut().write_all(b"{\"id\":1,\"method\":\"events.poll\",\"params\":{\"after\":2,\"timeoutMs\":0}}\n").unwrap();
|
||||
let r = reply(&mut reader);
|
||||
assert_eq!(r["result"]["reset"], true, "{r}");
|
||||
assert!(r["result"]["events"].as_array().unwrap().is_empty(), "{r}");
|
||||
assert_eq!(r["result"]["cursor"], 1);
|
||||
reader.get_ref().shutdown(Shutdown::Write).unwrap();
|
||||
worker.join().unwrap().unwrap();
|
||||
}
|
||||
|
||||
@@ -234,7 +234,7 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
let queue: Arc<Mutex<Vec<ControlRequest>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
if let Some(p) = port {
|
||||
control_server::start(p, queue.clone(), ctx_slot.clone());
|
||||
control_server::start(p, queue.clone(), ctx_slot.clone(), fig.session.events.clone());
|
||||
}
|
||||
let icon = services::window_icon();
|
||||
// The app id is the Wayland app_id and the X11 window class on Linux: the desktop entry's name
|
||||
@@ -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,
|
||||
|
||||
@@ -327,6 +327,9 @@ fn picked(pick: impl Future<Output = Option<rfd::FileHandle>> + 'static) -> Dial
|
||||
Box::pin(async move { pick.await.map(|f| f.path().to_path_buf()) })
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
const MACOS_PASTEBOARD_PNG: &str = "public.png";
|
||||
|
||||
/// How long a clipboard read may wait on the app that copied (Linux and the BSDs).
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
const CLIPBOARD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
@@ -438,6 +441,12 @@ impl Services for Desktop {
|
||||
}
|
||||
Some((w, h, img.bytes.into_owned()))
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
fn clipboard_get_png(&mut self) -> Option<Vec<u8>> {
|
||||
let board = objc2_app_kit::NSPasteboard::generalPasteboard();
|
||||
let png = board.dataForType(&objc2_foundation::NSString::from_str(MACOS_PASTEBOARD_PNG))?;
|
||||
(png.len() <= figcraft_ui_egui::MAX_CLIPBOARD_PNG_BYTES).then(|| png.to_vec())
|
||||
}
|
||||
fn open_url(&mut self, url: &str) {
|
||||
let _ = webbrowser::open(url);
|
||||
}
|
||||
|
||||
@@ -4,3 +4,7 @@ allow-unwrap-in-tests = true
|
||||
allow-expect-in-tests = true
|
||||
allow-panic-in-tests = true
|
||||
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
|
||||
|
||||
@@ -89,3 +89,9 @@ sync_github_name = true
|
||||
|
||||
[people.mateusvillain]
|
||||
sync_github_name = true
|
||||
|
||||
[people.culiulemon]
|
||||
sync_github_name = true
|
||||
|
||||
[people.StrajnarFilip]
|
||||
display_name = "Filip Strajnar"
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -6,6 +6,7 @@ use figcraft_doc::{Collection, Comment, Document, Message, Mode, Style, StyleKin
|
||||
|
||||
use crate::change::{Change, Entry, Json};
|
||||
use crate::message::{ClientMsg, ServerMsg};
|
||||
use crate::server::NO_SESSION_SPACE;
|
||||
use crate::sim::Sim;
|
||||
use crate::{Client, Limits, NoRecompute, Outgoing, Server};
|
||||
|
||||
@@ -92,6 +93,53 @@ fn client_ids_skip_spaces_used_by_styles_and_comments() {
|
||||
assert_eq!(join(&mut server).id(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_id_in_the_top_space_does_not_block_joins() {
|
||||
let mut d = Document::new("a");
|
||||
d.styles.push(style(format!("{STYLE_ID_PREFIX}{}", u64::MAX), "Out of range"));
|
||||
let c = comment(u64::from(u32::MAX) << 32, &d);
|
||||
d.comments.push(c);
|
||||
let mut server = Server::new(d, "", Limits::default());
|
||||
let a = join(&mut server);
|
||||
assert!(a.id() > 0 && a.id() < u32::MAX, "{}", a.id());
|
||||
// After a reload the session still takes newcomers and keeps the document as it was.
|
||||
let state = server.state().unwrap();
|
||||
let mut restored = Server::restore(state.clone(), "", Limits::default()).unwrap();
|
||||
let b = join(&mut restored);
|
||||
assert!(b.id() > a.id() && b.id() < u32::MAX, "{}", b.id());
|
||||
assert_eq!(restored.document().styles.len(), 1);
|
||||
assert_eq!(restored.document().comments.len(), 1);
|
||||
// A state saved while the counter was stuck at the top recovers too.
|
||||
let stuck = crate::SessionState { next_client: u32::MAX, ..state };
|
||||
let mut restored = Server::restore(stuck, "", Limits::default()).unwrap();
|
||||
let c = join(&mut restored);
|
||||
assert!(c.id() > a.id() && c.id() < u32::MAX, "{}", c.id());
|
||||
// A new entry may not take an id from the reserved top half of the spaces.
|
||||
for (k, space) in [NO_SESSION_SPACE, u32::MAX].into_iter().enumerate() {
|
||||
let id = format!("{STYLE_ID_PREFIX}{}", (u64::from(space) << 32) | 1);
|
||||
let ServerMsg::Reject { reason, .. } = send(&mut restored, c.id(), 1 + k as u64, vec![put_style(id.clone())]) else {
|
||||
panic!("accepted {id}")
|
||||
};
|
||||
assert!(reason.contains("id space"), "{reason}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styles_imported_from_another_session_are_accepted_and_joins_go_on() {
|
||||
let mut server = Server::new(Document::new("a"), "", Limits::default());
|
||||
let a = join(&mut server);
|
||||
// `file.import` copies styles with their ids, made by clients this session hasn't given out
|
||||
// (up to the highest space a member may use).
|
||||
for (k, space) in [a.id() + 1, a.id() + 1000, NO_SESSION_SPACE - 1].into_iter().enumerate() {
|
||||
let id = format!("{STYLE_ID_PREFIX}{}", (u64::from(space) << 32) | 1);
|
||||
assert!(matches!(send(&mut server, a.id(), 1 + k as u64, vec![put_style(id.clone())]), ServerMsg::Changes(_)), "refused {id}");
|
||||
}
|
||||
// After a restart client ids start above them, and there are plenty left.
|
||||
let mut restored = Server::restore(server.state().unwrap(), "", Limits::default()).unwrap();
|
||||
assert_eq!(join(&mut restored).id(), NO_SESSION_SPACE);
|
||||
assert_eq!(join(&mut Server::new(restored.document().clone(), "", Limits::default())).id(), NO_SESSION_SPACE);
|
||||
}
|
||||
|
||||
/// Each client makes `rounds` styles, collections (with an extra mode), variables and comments
|
||||
/// before seeing anyone else's.
|
||||
fn create_everything(doc: &mut Document, who: usize, round: usize) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -25,6 +25,12 @@ pub enum Outgoing {
|
||||
/// Why a viewer's batch is refused.
|
||||
pub const VIEW_ONLY: &str = "you can only view this file (your link is view-only)";
|
||||
|
||||
/// A new style, variable, collection or comment may not take an id in a space at or above this
|
||||
/// that isn't its creator's. Client ids start above every space in the document after a restart,
|
||||
/// so no member can use them up: at least this many always remain. Lower spaces stay open, for
|
||||
/// entries imported from other files' sessions.
|
||||
pub const NO_SESSION_SPACE: u32 = 1 << 31;
|
||||
|
||||
/// What changed a server's lasting state, in order: what a store appends to its log between
|
||||
/// snapshots ([`Server::journaling`]) and replays over the last snapshot after a restart
|
||||
/// ([`Server::replay`]). Presence and who is connected aren't in it.
|
||||
@@ -77,6 +83,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 +137,7 @@ impl Server {
|
||||
journal: None,
|
||||
signals: BTreeSet::new(),
|
||||
spotlight: None,
|
||||
resync: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,16 +159,28 @@ 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;
|
||||
server.seq = state.seq;
|
||||
server.next_client = server.next_client.max(state.next_client);
|
||||
// A counter stuck at the top (saved while an out-of-range id in the document blocked
|
||||
// every join) carries on above the ids actually handed out.
|
||||
let saved = match state.next_client {
|
||||
u32::MAX => state.issued.keys().chain(state.log.iter().map(|a| &a.author)).max().map_or(0, |c| c.saturating_add(1)),
|
||||
n => n,
|
||||
};
|
||||
server.next_client = server.next_client.max(saved);
|
||||
server.issued = state.issued.into_iter().filter(|(id, _)| *id < server.next_client).collect();
|
||||
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 +221,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 +313,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 {
|
||||
@@ -481,9 +512,9 @@ impl Server {
|
||||
|
||||
/// The document-level entries (styles, variables, collections, comments) a batch adds or
|
||||
/// removes, each with whether it is there afterwards. A new one is refused when its id lies in
|
||||
/// the space of another client this session gave an id to (unless it was removed before: undo
|
||||
/// puts entries back); ids from before the session (space 0, earlier sessions, other files)
|
||||
/// are fine.
|
||||
/// the space of another client this session gave an id to, or in a space at or above
|
||||
/// [`NO_SESSION_SPACE`] (unless it was removed before: undo puts entries back); ids from
|
||||
/// before the session (space 0, earlier sessions, other files) are fine.
|
||||
fn check_entries(&self, from: u32, changes: &[Change], after: &Document) -> Result<Vec<(Entry, bool)>, Error> {
|
||||
let before = self.state.document();
|
||||
let mut touched: BTreeSet<Entry> = BTreeSet::new();
|
||||
@@ -510,7 +541,7 @@ impl Server {
|
||||
}
|
||||
if now
|
||||
&& !self.removed.contains(&entry)
|
||||
&& let Some(space) = entry.id_space().filter(|s| *s != from && self.issued.contains_key(s))
|
||||
&& let Some(space) = entry.id_space().filter(|s| *s != from && (self.issued.contains_key(s) || *s >= NO_SESSION_SPACE))
|
||||
{
|
||||
return Err(Error::Invalid(format!("new {} isn't in client {from}'s id space (it is client {space}'s)", entry.describe())));
|
||||
}
|
||||
|
||||
+15
-1
@@ -486,9 +486,23 @@ impl ImagePaint {
|
||||
{
|
||||
return c;
|
||||
}
|
||||
self.fitted_crop(iw, ih, w, h, false)
|
||||
}
|
||||
|
||||
/// The crop transform that keeps the picture where its scale mode draws it: the crop in effect
|
||||
/// in Crop mode, the Fit placement in Fit mode, else (Fill, Tile) the Fill placement.
|
||||
pub fn placement_crop(&self, iw: f64, ih: f64, w: f64, h: f64) -> [f64; 6] {
|
||||
match self.scale_mode {
|
||||
ImageScaleMode::Crop => self.effective_crop(iw, ih, w, h),
|
||||
mode => self.fitted_crop(iw, ih, w, h, mode == ImageScaleMode::Fit),
|
||||
}
|
||||
}
|
||||
|
||||
/// The Fill placement (`fit`: the Fit placement) as a crop transform.
|
||||
fn fitted_crop(&self, iw: f64, ih: f64, w: f64, h: f64, fit: bool) -> [f64; 6] {
|
||||
let (rw, rh) = self.rotated_size(iw.max(1.0), ih.max(1.0));
|
||||
let (w, h) = (w.max(1e-6), h.max(1e-6));
|
||||
let s = (w / rw).max(h / rh);
|
||||
let s = if fit { (w / rw).min(h / rh) } else { (w / rw).max(h / rh) };
|
||||
let (x0, y0) = ((w - rw * s) / 2.0, (h - rh * s) / 2.0);
|
||||
[w / (rw * s), 0.0, 0.0, h / (rh * s), -x0 / (rw * s), -y0 / (rh * s)]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! picks the group, and a double click goes one level deeper than the selection.
|
||||
|
||||
use crate::geometry::{enter_target, select_target};
|
||||
use crate::{BoolOp, Document, NodeId, NodeKind};
|
||||
use crate::{BoolOp, Document, NodeId, NodeKind, Region, Segment, VectorNetwork, Vertex, WindingRule};
|
||||
use figcraft_geom::Point;
|
||||
|
||||
fn page(d: &Document) -> NodeId {
|
||||
@@ -40,6 +40,61 @@ fn hits_reach_operands_but_clicks_pick_the_group() {
|
||||
assert_eq!(listed, vec![f, g, b]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_hit_uses_region_paints_without_layer_fill() {
|
||||
let mut d = Document::new("region hit");
|
||||
let p = page(&d);
|
||||
let network = VectorNetwork {
|
||||
vertices: vec![Vertex::new(0.0, 0.0), Vertex::new(10.0, 0.0), Vertex::new(10.0, 10.0), Vertex::new(0.0, 10.0)],
|
||||
segments: (0..4).map(|i| Segment::line(i, (i + 1) % 4)).collect(),
|
||||
regions: vec![
|
||||
Region {
|
||||
loops: vec![vec![0, 1, 2, 3]],
|
||||
winding: WindingRule::NonZero,
|
||||
fills: Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]),
|
||||
..Default::default()
|
||||
},
|
||||
Region { loops: vec![vec![0, 1, 2, 3]], winding: WindingRule::NonZero, fills: Some(Vec::new()), ..Default::default() },
|
||||
],
|
||||
};
|
||||
let id = d.create(p, NodeKind::Vector { network }, "V", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
d.get_mut(id).unwrap().fills.clear();
|
||||
assert!(d.node(id).unwrap().fills.is_empty());
|
||||
assert_eq!(d.hit_test(p, Point::new(5.0, 5.0), 0.0, None), Some(id));
|
||||
assert_eq!(d.hit_test(p, Point::new(15.0, 5.0), 0.0, None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inherited_paint_frames_use_per_region_hit_testing() {
|
||||
let mut d = Document::new("inherited region hit");
|
||||
let p = page(&d);
|
||||
let mut network = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0), Point::new(0.0, 10.0)], true);
|
||||
network.regions[0].winding = WindingRule::EvenOdd;
|
||||
network.regions.push(network.regions[0].clone());
|
||||
let id = d.create(p, NodeKind::Vector { network }, "V", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
assert_eq!(d.hit_test(p, Point::new(5.0, 5.0), 0.0, None), None, "legacy even-odd loops cancel");
|
||||
let NodeKind::Vector { network } = &mut d.get_mut(id).unwrap().kind else { panic!("expected vector") };
|
||||
network.regions[0].paint_space = Some(crate::RegionPaintSpace { width: 10.0, height: 10.0, transform: figcraft_geom::Affine::IDENTITY });
|
||||
assert!(network.regions.iter().all(|region| region.fills.is_none()));
|
||||
assert_eq!(d.hit_test(p, Point::new(5.0, 5.0), 0.0, None), Some(id), "paint-framed regions inherit the node fill independently");
|
||||
assert_eq!(d.hit_test(p, Point::new(15.0, 5.0), 0.0, None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicitly_unfilled_regions_keep_the_stroke_only_selection_band() {
|
||||
let mut d = Document::new("stroke selection");
|
||||
let p = page(&d);
|
||||
let mut network =
|
||||
VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)], true);
|
||||
network.regions[0].fills = Some(Vec::new());
|
||||
let id = d.create(p, NodeKind::Vector { network }, "V", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
let n = d.get_mut(id).unwrap();
|
||||
n.fills.clear();
|
||||
n.strokes = vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)];
|
||||
n.stroke.weight = 1.0;
|
||||
assert_eq!(d.hit_test(p, Point::new(50.0, -4.0), 2.0, None), Some(id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_clicks_go_one_level_deeper() {
|
||||
let (d, f, g, b, r1, _) = setup();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -60,6 +60,9 @@ impl Node {
|
||||
f(&mut prototype.background);
|
||||
}
|
||||
NodeKind::Text(t) => t.for_each_color_mut(f),
|
||||
NodeKind::Vector { network } => {
|
||||
network.regions.iter_mut().filter_map(|r| r.fills.as_mut()).flatten().for_each(|p| p.for_each_color_mut(f))
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
for it in &mut self.interactions {
|
||||
|
||||
@@ -74,6 +74,23 @@ fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, o
|
||||
NodeKind::Frame | NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. } | NodeKind::Section { .. } => {
|
||||
n.local_rect().inflate(tol_l, tol_l).contains(lp)
|
||||
}
|
||||
NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some()) => {
|
||||
let rounded = network.rounded();
|
||||
let net = rounded.as_ref().unwrap_or(network);
|
||||
if !outline {
|
||||
for (i, region) in net.regions.iter().enumerate().rev() {
|
||||
let paints = region.fills.as_deref().unwrap_or(&n.fills);
|
||||
if paints.iter().any(|p| p.visible) && net.region_contains(i, lp) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let path = net.stroke_path();
|
||||
let has_fill = net.regions.iter().any(|region| region.fills.as_deref().unwrap_or(&n.fills).iter().any(|paint| paint.visible));
|
||||
let stroke_only = n.has_visible_stroke() && !has_fill;
|
||||
let reach = (n.stroke.max_weight() / 2.0).max(if stroke_only { tol_l * STROKE_REACH } else { tol_l });
|
||||
path.bounding_box().inflate(reach, reach).contains(lp) && near_path(&path, lp, reach)
|
||||
}
|
||||
_ => shape_path(n).is_some_and(|path| hits_path(n, &path, lp, tol_l, !outline && is_closed(n) && fills_point(n, &path, lp))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -128,7 +128,8 @@ impl Document {
|
||||
|
||||
/// The highest client id space any id in the document lies in (layers, comments, styles,
|
||||
/// variables, collections, modes): a session hands out client ids above it, so ids made in
|
||||
/// earlier sessions are never made again.
|
||||
/// earlier sessions are never made again. Space `u32::MAX` doesn't count: numbers too big for
|
||||
/// any client's space land there ([`space_of`]) and no session hands that client id out.
|
||||
pub fn highest_id_space(&self) -> u32 {
|
||||
let nodes = self.ids().map(|id| id.0);
|
||||
let comments = self.comments.iter().map(|c| c.id);
|
||||
@@ -136,6 +137,6 @@ impl Document {
|
||||
let vars = self.variables.variables.iter().filter_map(|v| id_number(&v.id, VARIABLE_ID_PREFIX));
|
||||
let colls = self.variables.collections.iter().filter_map(|c| id_number(&c.id, COLLECTION_ID_PREFIX));
|
||||
let modes = self.variables.collections.iter().flat_map(|c| c.modes.iter().filter_map(|m| mode_number(&m.id)));
|
||||
nodes.chain(comments).chain(styles).chain(vars).chain(colls).chain(modes).map(space_of).max().unwrap_or(0)
|
||||
nodes.chain(comments).chain(styles).chain(vars).chain(colls).chain(modes).map(space_of).filter(|s| *s != u32::MAX).max().unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,10 @@ fn the_highest_space_counts_every_kind_of_id() {
|
||||
assert_eq!(d.highest_id_space(), 9);
|
||||
d.comments.push(comment(11u64 << 32, d.first_page().unwrap()));
|
||||
assert_eq!(d.highest_id_space(), 11);
|
||||
// Numbers past every client's space (they saturate to `u32::MAX`) are no session's.
|
||||
d.styles.push(style(&format!("S:{}", u64::MAX)));
|
||||
d.comments.push(comment(u64::from(u32::MAX) << 32, d.first_page().unwrap()));
|
||||
assert_eq!(d.highest_id_space(), 11);
|
||||
// Other forms are space 0.
|
||||
assert_eq!(id_space("lib:S:99999999999", "S:"), 0);
|
||||
assert_eq!(id_space("S:+5", "S:"), 0);
|
||||
@@ -110,3 +114,11 @@ fn the_highest_space_counts_every_kind_of_id() {
|
||||
assert_eq!(id_space("S:99999999999999999999999", "S:"), 0, "too big for a number");
|
||||
assert_eq!(mode_number("1:0"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nodeid_rejects_plus_sign() {
|
||||
assert!("+12".parse::<crate::NodeId>().is_err());
|
||||
assert!("0:+12".parse::<crate::NodeId>().is_err());
|
||||
assert_eq!("12".parse::<crate::NodeId>(), Ok(crate::NodeId(12)));
|
||||
assert_eq!("0:12".parse::<crate::NodeId>(), Ok(crate::NodeId(12)));
|
||||
}
|
||||
|
||||
+40
-8
@@ -67,6 +67,10 @@ impl std::str::FromStr for NodeId {
|
||||
// Accept "12" and Figma-style "0:12".
|
||||
let t = s.trim();
|
||||
let t = t.rsplit(':').next().unwrap_or(t);
|
||||
if t.starts_with('+') {
|
||||
// `u64::parse` accepts a leading '+'; ids are digits only.
|
||||
return "-".parse::<u64>().map(NodeId);
|
||||
}
|
||||
t.parse::<u64>().map(NodeId)
|
||||
}
|
||||
}
|
||||
@@ -161,6 +165,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 +431,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);
|
||||
@@ -896,6 +915,15 @@ impl Document {
|
||||
pt.source = m.0;
|
||||
}
|
||||
}
|
||||
if let NodeKind::Vector { network } = &mut c.kind {
|
||||
for p in network.regions.iter_mut().filter_map(|r| r.fills.as_mut()).flatten() {
|
||||
if let figcraft_color::PaintKind::Pattern(pt) = &mut p.kind
|
||||
&& let Some(m) = map.get(&NodeId(pt.source))
|
||||
{
|
||||
pt.source = m.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.reserve_ids(*new_id);
|
||||
self.nodes.insert(*new_id, Arc::new(c));
|
||||
self.dirty.touch(*new_id);
|
||||
@@ -1143,8 +1171,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 +1288,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,
|
||||
@@ -300,6 +300,11 @@ pub fn paint_images<'a>(paints: impl IntoIterator<Item = &'a Paint>, out: &mut B
|
||||
/// Image hashes a node paints (fills, strokes, text and text run fills).
|
||||
pub fn node_images(n: &Node, out: &mut BTreeSet<String>) {
|
||||
paint_images(n.fills.iter().chain(&n.strokes), out);
|
||||
if let NodeKind::Vector { network } = &n.kind {
|
||||
for r in &network.regions {
|
||||
paint_images(r.fills.iter().flatten(), out);
|
||||
}
|
||||
}
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
paint_images(t.style.fills.iter().flatten(), out);
|
||||
for r in &t.runs {
|
||||
|
||||
+84
-14
@@ -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,
|
||||
@@ -218,7 +222,7 @@ pub struct Annotation {
|
||||
/// (`figcraft_engine::code_connect`).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CodeLink {
|
||||
/// `react`, `swiftui` or `compose`.
|
||||
/// `react`, `leptos`, `vue`, `swiftui`, `compose`, `arkui` or `flutter`.
|
||||
pub language: String,
|
||||
/// What the snippet imports from (`@acme/ui`, a Swift module, a Kotlin name).
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
@@ -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
|
||||
|
||||
+31
-3
@@ -125,6 +125,34 @@ fn hit_test_respects_z_and_clip() {
|
||||
assert_eq!(d.hit_test(p, Point::new(5.0, 95.0), 0.0, None), Some(f));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_paints_participate_in_colors_images_and_pattern_remapping() {
|
||||
let mut d = Document::new("region resources");
|
||||
let p = page(&d);
|
||||
let group = d.create(p, NodeKind::Group, "Group", 0.0, 0.0, 40.0, 20.0).unwrap();
|
||||
let source = d.create(group, NodeKind::Rectangle, "Source", 0.0, 0.0, 4.0, 4.0).unwrap();
|
||||
let mut network = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(20.0, 0.0), Point::new(20.0, 20.0), Point::new(0.0, 20.0)], true);
|
||||
network.regions[0].fills = Some(vec![
|
||||
figcraft_color::Paint::solid(figcraft_color::Color::from_hex("FF0000").unwrap()),
|
||||
figcraft_color::Paint::image("region-image", figcraft_color::ImageScaleMode::Fill),
|
||||
figcraft_color::Paint::pattern(source.0),
|
||||
]);
|
||||
let vector = d.create(group, NodeKind::Vector { network }, "Vector", 0.0, 0.0, 20.0, 20.0).unwrap();
|
||||
assert!(used_images(&d).contains("region-image"));
|
||||
let dup = d.duplicate_into(group, p, None).unwrap();
|
||||
let copied_children = d.node(dup).unwrap().children.clone();
|
||||
let copied_source = copied_children[0];
|
||||
let copied_vector = d.node(copied_children[1]).unwrap();
|
||||
let NodeKind::Vector { network } = &copied_vector.kind else { panic!() };
|
||||
let figcraft_color::PaintKind::Pattern(pattern) = &network.regions[0].fills.as_ref().unwrap()[2].kind else { panic!() };
|
||||
assert_eq!(pattern.source, copied_source.0);
|
||||
|
||||
let red = figcraft_color::Color::from_hex("FF0000").unwrap();
|
||||
d.map_colors(|c| if c == red { figcraft_color::Color::BLACK } else { c });
|
||||
let NodeKind::Vector { network } = &d.node(vector).unwrap().kind else { panic!() };
|
||||
assert_eq!(network.regions[0].fills.as_ref().unwrap()[0].solid_color(), Some(figcraft_color::Color::BLACK));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn click_target_rules() {
|
||||
let mut d = Document::new("x");
|
||||
@@ -212,7 +240,7 @@ fn vector_network_paths() {
|
||||
let mut bad = VectorNetwork {
|
||||
vertices: vec![Vertex::new(0.0, 0.0)],
|
||||
segments: vec![Segment::line(0, 5)],
|
||||
regions: vec![Region { loops: vec![vec![0, 9]], winding: WindingRule::EvenOdd }],
|
||||
regions: vec![Region { loops: vec![vec![0, 9]], winding: WindingRule::EvenOdd, fills: None, ..Default::default() }],
|
||||
};
|
||||
bad.sanitize();
|
||||
assert!(bad.segments.is_empty());
|
||||
@@ -534,7 +562,7 @@ fn hit_test_even_odd_vector_hole_misses() {
|
||||
VectorNetwork {
|
||||
vertices: v1.into_iter().chain(v2).collect(),
|
||||
segments: s1.into_iter().chain(s2).collect(),
|
||||
regions: vec![Region { loops: vec![vec![0, 1, 2, 3], vec![4, 5, 6, 7]], winding: rule }],
|
||||
regions: vec![Region { loops: vec![vec![0, 1, 2, 3], vec![4, 5, 6, 7]], winding: rule, ..Default::default() }],
|
||||
}
|
||||
};
|
||||
let mut d = Document::new("x");
|
||||
@@ -637,7 +665,7 @@ fn two_segment_closed_loop_rounded_corners() {
|
||||
let net = VectorNetwork {
|
||||
vertices: vec![v0, v1],
|
||||
segments: vec![Segment { a: 0, b: 1, ta: (0.0, 0.0), tb: (0.0, 0.0) }, Segment { a: 1, b: 0, ta: (0.0, 5.0), tb: (0.0, -5.0) }],
|
||||
regions: vec![Region { loops: vec![vec![0, 1]], winding: WindingRule::NonZero }],
|
||||
regions: vec![Region { loops: vec![vec![0, 1]], winding: WindingRule::NonZero, ..Default::default() }],
|
||||
};
|
||||
let r = net.rounded().unwrap();
|
||||
assert_eq!(r.regions[0].loops[0].len(), 4, "both arcs are in the loop");
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! Vector networks: vertices joined by curved segments, any number of segments per vertex, plus
|
||||
//! filled regions made of closed loops.
|
||||
|
||||
use figcraft_color::Paint;
|
||||
use figcraft_geom::kurbo::{CubicBez, ParamCurve, ParamCurveArclen, ParamCurveDeriv};
|
||||
use figcraft_geom::{BezPath, Point, Vec2};
|
||||
use figcraft_geom::{Affine, BezPath, Point, Vec2};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{StrokeCap, StrokeJoin};
|
||||
@@ -88,12 +89,44 @@ pub enum WindingRule {
|
||||
EvenOdd,
|
||||
}
|
||||
|
||||
/// The paint coordinate frame retained when a region moves or its vector is refit.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RegionPaintSpace {
|
||||
/// Width of the box that defines paint coordinates.
|
||||
pub width: f64,
|
||||
/// Height of the box that defines paint coordinates.
|
||||
pub height: f64,
|
||||
/// Maps paint-local coordinates into this network's local coordinates.
|
||||
pub transform: Affine,
|
||||
}
|
||||
|
||||
/// A filled region: loops of segment indices.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct Region {
|
||||
pub loops: Vec<Vec<usize>>,
|
||||
#[serde(default)]
|
||||
pub winding: WindingRule,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub fills: Option<Vec<Paint>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub paint_space: Option<RegionPaintSpace>,
|
||||
}
|
||||
|
||||
impl Region {
|
||||
/// Paint box and transform, falling back to the current node box for old or invalid metadata.
|
||||
pub fn paint_frame(&self, width: f64, height: f64) -> (f64, f64, Affine) {
|
||||
let Some(frame) = self.paint_space else { return (width, height, Affine::IDENTITY) };
|
||||
let coefficients = frame.transform.as_coeffs();
|
||||
let valid = frame.width.is_finite()
|
||||
&& frame.height.is_finite()
|
||||
&& frame.width > 0.0
|
||||
&& frame.height > 0.0
|
||||
&& coefficients.iter().all(|value| value.is_finite())
|
||||
&& frame.transform.determinant().is_finite()
|
||||
&& frame.transform.determinant() != 0.0
|
||||
&& frame.transform.inverse().as_coeffs().iter().all(|value| value.is_finite());
|
||||
if valid { (frame.width, frame.height, frame.transform) } else { (width, height, Affine::IDENTITY) }
|
||||
}
|
||||
}
|
||||
|
||||
/// A rounded point as drawn: point `v` moves to `a` (on segment `s1`), segment `s2` starts again
|
||||
@@ -153,7 +186,7 @@ impl VectorNetwork {
|
||||
let mut regions = Vec::new();
|
||||
if closed && n > 2 {
|
||||
segments.push(Segment::line(n - 1, 0));
|
||||
regions.push(Region { loops: vec![(0..segments.len()).collect()], winding: WindingRule::NonZero });
|
||||
regions.push(Region { loops: vec![(0..segments.len()).collect()], winding: WindingRule::NonZero, fills: None, paint_space: None });
|
||||
}
|
||||
Self { vertices, segments, regions }
|
||||
}
|
||||
@@ -242,7 +275,7 @@ impl VectorNetwork {
|
||||
}
|
||||
close_open(&mut net, &mut loop_segs, &mut all_loops, first_v, cur_v);
|
||||
if !all_loops.is_empty() {
|
||||
net.regions.push(Region { loops: all_loops, winding: WindingRule::NonZero });
|
||||
net.regions.push(Region { loops: all_loops, winding: WindingRule::NonZero, fills: None, paint_space: None });
|
||||
}
|
||||
net
|
||||
}
|
||||
@@ -637,6 +670,12 @@ impl VectorNetwork {
|
||||
s.ta = (s.ta.0 * sx, s.ta.1 * sy);
|
||||
s.tb = (s.tb.0 * sx, s.tb.1 * sy);
|
||||
}
|
||||
let scale = Affine::scale_non_uniform(sx, sy);
|
||||
for region in &mut self.regions {
|
||||
if let Some(frame) = &mut region.paint_space {
|
||||
frame.transform = scale * frame.transform;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn translate(&mut self, dx: f64, dy: f64) {
|
||||
@@ -644,6 +683,12 @@ impl VectorNetwork {
|
||||
v.x += dx;
|
||||
v.y += dy;
|
||||
}
|
||||
let translate = Affine::translate((dx, dy));
|
||||
for region in &mut self.regions {
|
||||
if let Some(frame) = &mut region.paint_space {
|
||||
frame.transform = translate * frame.transform;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounds of the curves.
|
||||
|
||||
@@ -30,7 +30,12 @@ impl Face {
|
||||
|
||||
/// The fill region for this face.
|
||||
pub fn region(&self) -> Region {
|
||||
Region { loops: self.loops.clone(), winding: if self.loops.len() > 1 { WindingRule::EvenOdd } else { WindingRule::NonZero } }
|
||||
Region {
|
||||
loops: self.loops.clone(),
|
||||
winding: if self.loops.len() > 1 { WindingRule::EvenOdd } else { WindingRule::NonZero },
|
||||
fills: None,
|
||||
paint_space: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Some point strictly inside the face (not in a hole).
|
||||
@@ -115,7 +120,13 @@ impl VectorNetwork {
|
||||
/// Like [`Self::paint`], but with `want` the face ends up filled (`Some(true)`) or unfilled
|
||||
/// (`Some(false)`) whatever it was: a paint bucket drag keeps doing what its first click did.
|
||||
pub fn paint_to(&mut self, p: Point, want: Option<bool>) -> Option<bool> {
|
||||
self.paint_to_with(p, want, None)
|
||||
}
|
||||
|
||||
/// Like [`Self::paint_to`], with the layer fill stack used for inherited regions.
|
||||
pub fn paint_to_with(&mut self, p: Point, want: Option<bool>, inherited: Option<&[figcraft_color::Paint]>) -> Option<bool> {
|
||||
let face = self.face_at(p)?;
|
||||
let legacy = self.regions.iter().all(|region| region.fills.is_none() && region.paint_space.is_none());
|
||||
let key = |loops: &[Vec<usize>]| -> Vec<Vec<usize>> {
|
||||
let mut k: Vec<Vec<usize>> = loops
|
||||
.iter()
|
||||
@@ -131,18 +142,52 @@ impl VectorNetwork {
|
||||
let faces = self.faces();
|
||||
let face_keys: Vec<Vec<Vec<usize>>> = faces.iter().map(|f| key(&f.loops)).collect();
|
||||
if !self.regions.iter().all(|r| face_keys.contains(&key(&r.loops))) {
|
||||
let filled: Vec<Region> = faces.iter().filter(|f| f.interior_point().is_some_and(|q| self.fill_contains(q))).map(Face::region).collect();
|
||||
self.regions = filled;
|
||||
if legacy {
|
||||
// Legacy vectors use one combined fill rule across all regions.
|
||||
self.regions =
|
||||
faces.iter().filter(|face| face.interior_point().is_some_and(|point| self.fill_contains(point))).map(Face::region).collect();
|
||||
} else {
|
||||
// Split each styled source region independently to retain its stack and frame.
|
||||
let source_regions = self.regions.clone();
|
||||
let mut filled = Vec::new();
|
||||
for (source_index, source) in source_regions.into_iter().enumerate() {
|
||||
let source_path = self.region_path(source_index);
|
||||
for face in &faces {
|
||||
if face.interior_point().is_some_and(|q| {
|
||||
source_path.as_ref().is_some_and(|path| match source.winding {
|
||||
WindingRule::NonZero => path.winding(q) != 0,
|
||||
WindingRule::EvenOdd => path.winding(q) % 2 != 0,
|
||||
})
|
||||
}) {
|
||||
let mut region = face.region();
|
||||
region.fills = source.fills.clone();
|
||||
region.paint_space = source.paint_space;
|
||||
filled.push(region);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.regions = filled;
|
||||
}
|
||||
}
|
||||
let k = key(&face.loops);
|
||||
match (self.regions.iter().position(|r| key(&r.loops) == k), want) {
|
||||
(Some(_), None | Some(false)) => {
|
||||
let filled = if legacy {
|
||||
self.fill_contains(p) && inherited.is_none_or(|fills| fills.iter().any(|paint| paint.visible))
|
||||
} else {
|
||||
self.regions.iter().filter(|r| key(&r.loops) == k).any(|region| match ®ion.fills {
|
||||
Some(fills) => fills.iter().any(|paint| paint.visible),
|
||||
None => inherited.is_none_or(|fills| fills.iter().any(|paint| paint.visible)),
|
||||
})
|
||||
};
|
||||
let want = want.unwrap_or(!filled);
|
||||
match (filled, want) {
|
||||
(true, false) => {
|
||||
self.regions.retain(|r| key(&r.loops) != k);
|
||||
Some(false)
|
||||
}
|
||||
(Some(_), Some(true)) => Some(true),
|
||||
(None, Some(false)) => Some(false),
|
||||
(None, _) => {
|
||||
(true, true) => Some(true),
|
||||
(false, false) => Some(false),
|
||||
(false, true) => {
|
||||
self.regions.retain(|r| key(&r.loops) != k);
|
||||
self.regions.push(face.region());
|
||||
Some(true)
|
||||
}
|
||||
|
||||
@@ -30,7 +30,32 @@ fn nested() -> VectorNetwork {
|
||||
}
|
||||
|
||||
fn region(loops: Vec<Vec<usize>>, winding: WindingRule) -> Region {
|
||||
Region { loops, winding }
|
||||
Region { loops, winding, fills: None, ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_fills_default_to_inheritance_and_keep_explicit_empty() {
|
||||
let legacy: Region = serde_json::from_value(serde_json::json!({"loops": []})).unwrap();
|
||||
assert_eq!(legacy.fills, None);
|
||||
let empty = Region { fills: Some(Vec::new()), ..legacy };
|
||||
let value = serde_json::to_value(empty).unwrap();
|
||||
assert!(value["fills"].as_array().is_some_and(Vec::is_empty));
|
||||
let inherited = serde_json::to_value(region(Vec::new(), WindingRule::NonZero)).unwrap();
|
||||
assert!(inherited.get("fills").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_paint_space_roundtrips_and_rejects_nonfinite_inverse() {
|
||||
use crate::RegionPaintSpace;
|
||||
use figcraft_geom::Affine;
|
||||
let mut r = region(Vec::new(), WindingRule::EvenOdd);
|
||||
assert_eq!(r.paint_frame(100.0, 40.0), (100.0, 40.0, Affine::IDENTITY));
|
||||
r.paint_space = Some(RegionPaintSpace { width: 30.0, height: 20.0, transform: Affine::translate((4.0, 8.0)) });
|
||||
let decoded: Region = serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap();
|
||||
assert_eq!(decoded, r);
|
||||
assert_eq!(decoded.paint_frame(100.0, 40.0), (30.0, 20.0, Affine::translate((4.0, 8.0))));
|
||||
r.paint_space.as_mut().unwrap().transform = Affine::scale_non_uniform(1e-320, 1.0);
|
||||
assert_eq!(r.paint_frame(100.0, 40.0), (100.0, 40.0, Affine::IDENTITY));
|
||||
}
|
||||
|
||||
fn p(x: f64, y: f64) -> Point {
|
||||
|
||||
@@ -6,7 +6,7 @@ use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
use figcraft_doc::{CellAlign, MainAlign, NodeId, Sizing};
|
||||
use figcraft_doc::{CellAlign, CrossAlign, LayoutMode, MainAlign, NodeId, Sizing};
|
||||
|
||||
fn id(v: Value) -> NodeId {
|
||||
id_of(&v["id"]).unwrap()
|
||||
@@ -233,3 +233,46 @@ fn a_size_given_with_a_new_path_fixes_that_axis() {
|
||||
assert_eq!(sizing(&s, v).0, Sizing::Fixed);
|
||||
assert!((size(&s, v).0 - 30.0).abs() < 1e-6, "{:?}", size(&s, v));
|
||||
}
|
||||
|
||||
/// What one call's auto layout keys give, whichever way they are spelled: the mode (a string, or
|
||||
/// an object that replaces the whole auto layout) applies first and the other keys tune it.
|
||||
#[test]
|
||||
fn auto_layout_keys_in_one_call_tune_the_mode_given_beside_them() {
|
||||
for (key, mode) in ["layout", "layoutMode"].into_iter().flat_map(|k| [(k, json!("vertical")), (k, json!({"mode": "vertical"}))]) {
|
||||
let mut props = json!({"gap": 8, "padding": 4, "crossAlign": "center"});
|
||||
props[key] = mode;
|
||||
let mut s = Session::new();
|
||||
let created = id(s.execute("node.create", &json!({"type": "frame", "props": props})).unwrap());
|
||||
let set_on = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
|
||||
set(&mut s, set_on, props.clone());
|
||||
for f in [created, set_on] {
|
||||
let l = s.doc().unwrap().node(f).unwrap().layout.clone().unwrap();
|
||||
assert_eq!((l.mode, l.gap, l.padding, l.cross_align), (LayoutMode::Vertical, 8.0, [4.0; 4], CrossAlign::Center), "{props}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto layout keys beside a `layout` that turns auto layout off would turn it back on: the call
|
||||
/// is refused and the layer stays as it was.
|
||||
#[test]
|
||||
fn auto_layout_keys_beside_no_auto_layout_are_refused() {
|
||||
let mut s = Session::new();
|
||||
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
|
||||
for props in
|
||||
[json!({"layout": null, "padding": 4}), json!({"layout": "none", "gap": 8, "padding": 4}), json!({"layoutMode": "NONE", "wrap": true})]
|
||||
{
|
||||
let e = s.execute("node.set", &json!({"id": f, "props": props})).unwrap_err().to_string();
|
||||
assert!(e.contains("turns auto layout off"), "{props}: {e}");
|
||||
assert!(s.doc().unwrap().node(f).unwrap().layout.is_none(), "{props}");
|
||||
let e = s.execute("node.create", &json!({"type": "frame", "props": props})).unwrap_err().to_string();
|
||||
assert!(e.contains("turns auto layout off"), "{props}: {e}");
|
||||
}
|
||||
let e = s.execute("node.set", &json!({"id": f, "props": {"layout": null, "gap": 8, "padding": 4}})).unwrap_err().to_string();
|
||||
assert!(e.contains("`gap`, `padding` are auto layout properties, and `layout` in the same call"), "{e}");
|
||||
let e = s.execute("node.set", &json!({"id": f, "props": {"layoutMode": "NONE", "wrap": true}})).unwrap_err().to_string();
|
||||
assert!(e.contains("`wrap` is an auto layout property, and `layoutMode` in the same call"), "{e}");
|
||||
// Without other auto layout keys, `layout: null` still removes it.
|
||||
set(&mut s, f, json!({"layout": "vertical", "gap": 8}));
|
||||
set(&mut s, f, json!({"layout": null, "name": "Plain"}));
|
||||
assert!(s.doc().unwrap().node(f).unwrap().layout.is_none());
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -52,7 +52,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![spec!(
|
||||
"component.playground",
|
||||
"Component playground",
|
||||
"{id? (an instance, a component or a component set; default the selection), properties? {name: value} (variant, boolean, text and instance-swap properties, as instance.setProperties takes them), language? css|html|react|leptos|vue|swiftui|compose|arkui (default react), path? (write the preview there as a PNG), scale? (the PNG's, default 2)} → {component, properties: [{name, type, options?, value}], language, code, width, height, path?} the component with those property values, tried on a copy: its code and its look. Nothing in the document changes",
|
||||
"{id? (an instance, a component or a component set; default the selection), properties? {name: value} (variant, boolean, text and instance-swap properties, as instance.setProperties takes them), language? css|html|react|leptos|vue|swiftui|compose|arkui|flutter (default react), path? (write the preview there as a PNG), scale? (the PNG's, default 2)} → {component, properties: [{name, type, options?, value}], language, code, width, height, path?} the component with those property values, tried on a copy: its code and its look. Nothing in the document changes",
|
||||
false,
|
||||
|s, p| {
|
||||
let cmd = "component.playground";
|
||||
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Some(_) => return Err(bad(cmd, "properties: {name: value}")),
|
||||
};
|
||||
let lang = match string(p, "language") {
|
||||
Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|vue|swiftui|compose|arkui"))?,
|
||||
Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|vue|swiftui|compose|arkui|flutter"))?,
|
||||
None => CodeLang::React,
|
||||
};
|
||||
let (copy, inst) = build(s.doc()?, target, &values)?;
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! takes a `field`, a key of that result or a slash path into it (`ids/0`, `map/3`, `bounds/x`), the
|
||||
//! way `variable.bind` names `fills/0/color`. The CLI's script runner resolves its steps the same way.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
@@ -39,12 +39,25 @@ pub fn step_name(step: usize, json: &Value) -> Result<Option<String>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuse a step key other than `command`, `params` and `as` (`step` is its index, for the
|
||||
/// error): `args` in place of `params` would run the command without them.
|
||||
pub fn refuse_unknown_step_keys(step: usize, json: &Value) -> Result<()> {
|
||||
let Some(o) = json.as_object() else { return Ok(()) };
|
||||
let unknown: Vec<String> = o.keys().filter(|k| !matches!(k.as_str(), "command" | "params" | "as")).map(|k| format!("`{k}`")).collect();
|
||||
if unknown.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let s = if unknown.len() == 1 { "" } else { "s" };
|
||||
Err(bad("command.batch", format!("step {step}: unknown key{s} {} (a step takes command, params and as)", unknown.join(", "))))
|
||||
}
|
||||
|
||||
/// The steps of one batch run so far: their results, and which step each `as` name belongs to.
|
||||
/// A nested `command.batch` step gets its own scope, so its references are to its own steps.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct BatchScope {
|
||||
names: HashMap<String, usize>,
|
||||
results: Vec<Value>,
|
||||
failed: HashSet<usize>,
|
||||
}
|
||||
|
||||
impl BatchScope {
|
||||
@@ -57,10 +70,10 @@ impl BatchScope {
|
||||
return Err(bad("command.batch", format!("step {i}: `as` \"{name}\" is already step {first}'s name")));
|
||||
}
|
||||
}
|
||||
Ok(Self { names, results: Vec::with_capacity(steps.len()) })
|
||||
Ok(Self { names, results: Vec::with_capacity(steps.len()), failed: HashSet::new() })
|
||||
}
|
||||
|
||||
/// The results of the steps run so far, in order.
|
||||
/// The results of the steps run so far, in order (failed slots hold null).
|
||||
pub fn results(&self) -> &[Value] {
|
||||
&self.results
|
||||
}
|
||||
@@ -78,6 +91,12 @@ impl BatchScope {
|
||||
self.results.push(result);
|
||||
}
|
||||
|
||||
pub(crate) fn push_failed(&mut self) {
|
||||
// Keep positions even after a failure; a successful null result is not a failed step.
|
||||
self.failed.insert(self.next_step());
|
||||
self.results.push(Value::Null);
|
||||
}
|
||||
|
||||
/// `params` of the next step with every reference in it replaced.
|
||||
pub(crate) fn resolve(&self, params: &Value) -> Result<Value> {
|
||||
self.resolve_in(params, 0)
|
||||
@@ -125,6 +144,9 @@ impl BatchScope {
|
||||
}
|
||||
(n, format!("step {n} (\"{name}\")"))
|
||||
};
|
||||
if self.failed.contains(&n) {
|
||||
return Err(fail(format!("references {who}, which failed")));
|
||||
}
|
||||
let path = match o.get("field") {
|
||||
None => "id",
|
||||
Some(Value::String(f)) => f.as_str(),
|
||||
|
||||
@@ -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;
|
||||
@@ -165,297 +165,24 @@ pub fn node_json_with_options(doc: &Document, id: NodeId, depth: usize, options:
|
||||
let Some(n) = doc.node(id) else { return Value::Null };
|
||||
let mut v = json!({"id": n.id});
|
||||
let Some(o) = v.as_object_mut() else { return v };
|
||||
// Keep unselected properties lazy, including paint, text and layout JSON.
|
||||
macro_rules! put {
|
||||
($key:expr, $value:expr $(,)?) => {
|
||||
if options.includes($key) {
|
||||
o.insert($key.into(), $value);
|
||||
}
|
||||
};
|
||||
}
|
||||
put!("name", json!(n.name));
|
||||
put!("type", json!(n.kind.type_name()));
|
||||
if options.explicit("visible") || (!n.is_page() && !n.visible) {
|
||||
put!("visible", json!(n.visible));
|
||||
insert_prop(o, options, "name", || json!(n.name));
|
||||
insert_prop(o, options, "type", || json!(n.kind.type_name()));
|
||||
if options.explicit("visible") {
|
||||
insert_prop(o, options, "visible", || json!(n.visible));
|
||||
}
|
||||
if !n.is_page() {
|
||||
if options.includes("x") || options.includes("y") {
|
||||
let c = doc.frame_relative(id).as_coeffs();
|
||||
put!("x", json!(round(c[4])));
|
||||
put!("y", json!(round(c[5])));
|
||||
}
|
||||
put!("width", json!(round(n.width)));
|
||||
put!("height", json!(round(n.height)));
|
||||
if options.includes("rotation") {
|
||||
let rot = figcraft_geom::rotation_degrees(n.transform);
|
||||
if rot.abs() > 1e-6 {
|
||||
put!("rotation", json!(round(rot)));
|
||||
}
|
||||
}
|
||||
if let Some(b) = options.includes("absoluteBounds").then(|| doc.world_bounds(id)).flatten() {
|
||||
put!("absoluteBounds", json!({"x": round(b.x0), "y": round(b.y0), "width": round(b.width()), "height": round(b.height())}));
|
||||
}
|
||||
if n.locked {
|
||||
put!("locked", json!(true));
|
||||
}
|
||||
if n.opacity < 1.0 {
|
||||
put!("opacity", json!(n.opacity));
|
||||
}
|
||||
if !matches!(n.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) {
|
||||
put!("blend", json!(n.blend));
|
||||
}
|
||||
if !n.fills.is_empty() {
|
||||
put!("fills", paints(&n.fills));
|
||||
}
|
||||
if !n.strokes.is_empty() {
|
||||
put!("strokes", paints(&n.strokes));
|
||||
put!("strokeWeight", json!(n.stroke.weight));
|
||||
put!("strokeAlign", json!(n.stroke.align));
|
||||
if n.stroke.sides.is_some() {
|
||||
put!("strokeSides", json!(n.stroke.sides));
|
||||
}
|
||||
if !n.stroke.dashes.is_empty() {
|
||||
put!("dashes", json!(n.stroke.dashes));
|
||||
}
|
||||
if n.stroke.join != StrokeJoin::Miter {
|
||||
put!("strokeJoin", json!(n.stroke.join));
|
||||
} else if n.stroke.miter_limit != 4.0 {
|
||||
put!("miterAngle", json!((n.stroke.miter_angle() * 100.0).round() / 100.0));
|
||||
}
|
||||
if let Some(p) = n.stroke.profile().filter(|_| options.includes("widthProfile")) {
|
||||
let points = crate::cmd::props::width_points_json(Some(p));
|
||||
put!("widthProfile", json!({"preset": p.preset().map(|x| x.id()), "points": points}));
|
||||
}
|
||||
if let Some(b) = &n.stroke.brush {
|
||||
put!("brush", camel(serde_json::to_value(b).unwrap_or(Value::Null)));
|
||||
}
|
||||
}
|
||||
if !n.effects.is_empty() {
|
||||
put!("effects", camel(serde_json::to_value(&n.effects).unwrap_or(Value::Null)));
|
||||
}
|
||||
if n.corners.radii.iter().any(|r| *r > 0.0) {
|
||||
put!(
|
||||
"cornerRadius",
|
||||
match n.corners.uniform() {
|
||||
Some(r) => json!(r),
|
||||
None => json!(n.corners.radii),
|
||||
},
|
||||
);
|
||||
if n.corners.smoothing > 0.0 {
|
||||
put!("cornerSmoothing", json!(n.corners.smoothing));
|
||||
}
|
||||
}
|
||||
if let Some(l) = &n.layout {
|
||||
put!("layout", 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(_)) {
|
||||
put!("sizing", json!({"horizontal": n.child_layout.sizing_h, "vertical": n.child_layout.sizing_v}));
|
||||
}
|
||||
let cl = &n.child_layout;
|
||||
if cl.absolute {
|
||||
put!("absolute", 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 {
|
||||
put!(k, json!(v));
|
||||
}
|
||||
}
|
||||
if let Some(cell) = (parent_al && options.includes("gridCell"))
|
||||
.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))
|
||||
{
|
||||
put!(
|
||||
"gridCell",
|
||||
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()) {
|
||||
put!("constraints", serde_json::to_value(n.constraints).unwrap_or(Value::Null));
|
||||
}
|
||||
if n.kind.is_frame_like() {
|
||||
put!("clip", json!(n.clip));
|
||||
}
|
||||
if !n.dev_status.is_none() {
|
||||
put!("devStatus", json!(n.dev_status.id()));
|
||||
}
|
||||
if n.dev_changed {
|
||||
put!("devChanged", json!(true));
|
||||
}
|
||||
if !n.dev_note.is_empty() {
|
||||
put!("devNote", json!(n.dev_note));
|
||||
}
|
||||
if let NodeKind::Section { contents_hidden } = n.kind {
|
||||
put!("sectionContentsHidden", json!(contents_hidden));
|
||||
}
|
||||
if n.is_mask {
|
||||
put!("isMask", json!(true));
|
||||
put!("maskType", json!(n.mask_type.id()));
|
||||
}
|
||||
if !n.grids.is_empty() {
|
||||
put!("grids", camel(serde_json::to_value(&n.grids).unwrap_or(Value::Null)));
|
||||
}
|
||||
if !n.exports.is_empty() {
|
||||
put!("exports", serde_json::to_value(&n.exports).unwrap_or(Value::Null));
|
||||
}
|
||||
if let Some(b) = &n.board {
|
||||
put!("board", serde_json::to_value(b).unwrap_or(Value::Null));
|
||||
}
|
||||
if !n.annotations.is_empty() {
|
||||
put!("annotations", serde_json::to_value(&n.annotations).unwrap_or(Value::Null));
|
||||
}
|
||||
if !n.code_links.is_empty() {
|
||||
put!("codeLinks", camel(serde_json::to_value(&n.code_links).unwrap_or(Value::Null)));
|
||||
}
|
||||
if !n.measurements.is_empty() {
|
||||
put!("measurements", serde_json::to_value(&n.measurements).unwrap_or(Value::Null));
|
||||
}
|
||||
if options.includes("devResources") {
|
||||
let dev = crate::dev_resources::list_json(doc, n.id);
|
||||
if dev.as_array().is_some_and(|a| !a.is_empty()) {
|
||||
put!("devResources", dev);
|
||||
}
|
||||
}
|
||||
if !n.interactions.is_empty() {
|
||||
put!("interactions", serde_json::to_value(&n.interactions).unwrap_or(Value::Null));
|
||||
}
|
||||
if !n.styles.is_empty() {
|
||||
put!("styles", serde_json::to_value(&n.styles).unwrap_or(Value::Null));
|
||||
}
|
||||
if !n.bound.is_empty() {
|
||||
put!("boundVariables", json!(n.bound));
|
||||
}
|
||||
if !n.overrides.is_empty() {
|
||||
put!("overrides", json!(n.overrides));
|
||||
}
|
||||
if let Some(slot) = options.includes("slot").then(|| crate::components::slot_name(doc, n.id)).flatten() {
|
||||
put!("slot", json!(slot));
|
||||
}
|
||||
// An operand combining by an operation of its own.
|
||||
if let Some(op) = n.bool_op.filter(|_| options.includes("operandOperation") && crate::booleans::group_of(doc, n.id).is_some()) {
|
||||
put!("operandOperation", json!(op));
|
||||
}
|
||||
base_props(doc, n, id, o, options);
|
||||
paint_props(n, o, options);
|
||||
layout_props(doc, n, o, options);
|
||||
dev_props(doc, n, o, options);
|
||||
}
|
||||
// Layer data written with `node.set {props: {pluginData}}` (pages too).
|
||||
if !n.plugin_data.is_empty() {
|
||||
put!("pluginData", json!(n.plugin_data));
|
||||
insert_prop(o, options, "pluginData", || json!(n.plugin_data));
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Text(t) => {
|
||||
put!(
|
||||
"characters",
|
||||
if options.outline {
|
||||
let mut chars = t.characters.chars();
|
||||
let mut text: String = chars.by_ref().take(120).collect();
|
||||
if chars.next().is_some() {
|
||||
text.push_str("...");
|
||||
}
|
||||
json!(text)
|
||||
} else {
|
||||
json!(t.characters)
|
||||
}
|
||||
);
|
||||
put!("fontFamily", json!(t.style.font_family));
|
||||
put!("fontStyle", json!(t.style.font_style));
|
||||
put!("fontSize", json!(t.style.font_size));
|
||||
if !t.style.variations.is_empty() {
|
||||
put!("fontVariations", json!(t.style.variations));
|
||||
}
|
||||
put!("lineHeight", json!(t.style.line_height.label()));
|
||||
put!("letterSpacing", json!(t.style.letter_spacing.label()));
|
||||
put!("textAlign", json!(t.align_h));
|
||||
put!("textAutoResize", json!(t.auto_resize));
|
||||
if t.max_lines > 0 {
|
||||
put!("maxLines", json!(t.max_lines));
|
||||
}
|
||||
if let Some(tp) = &t.path {
|
||||
put!("textPath", json!(tp));
|
||||
}
|
||||
if t.style.decoration != TextDecoration::None {
|
||||
put!("textDecoration", json!(t.style.decoration));
|
||||
}
|
||||
if let Some(l) = t.style.decoration_look.as_deref() {
|
||||
put!("textDecorationStyle", json!(l.style));
|
||||
put!("textDecorationThickness", json!(l.thickness.label()));
|
||||
put!("textDecorationOffset", json!(l.offset.label()));
|
||||
put!("textDecorationSkipInk", json!(l.skip_ink));
|
||||
put!("textDecorationColor", 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 {
|
||||
put!("listStyle", json!(t.list));
|
||||
}
|
||||
} else {
|
||||
put!("lineTypes", t.paragraphs.iter().map(|p| json!(p.list)).collect());
|
||||
put!("lineIndentations", t.paragraphs.iter().map(|p| json!(p.indent)).collect());
|
||||
if t.paragraphs.iter().any(|p| p.start_offset > 0) {
|
||||
put!("listStartOffsets", t.paragraphs.iter().map(|p| json!(p.start_offset)).collect());
|
||||
}
|
||||
}
|
||||
if t.list_spacing != 0.0 {
|
||||
put!("listSpacing", json!(t.list_spacing));
|
||||
}
|
||||
for (k, on) in [("hangingPunctuation", t.hanging_punctuation), ("hangingList", t.hanging_list)] {
|
||||
if on {
|
||||
put!(k, json!(true));
|
||||
}
|
||||
}
|
||||
if t.leading_trim != figcraft_doc::LeadingTrim::None {
|
||||
put!("leadingTrim", json!(t.leading_trim));
|
||||
}
|
||||
if !t.runs.is_empty() {
|
||||
put!("styledRuns", 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`).
|
||||
if options.includes("textStyleRanges") {
|
||||
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() {
|
||||
put!("textStyleRanges", Value::Array(linked));
|
||||
}
|
||||
}
|
||||
}
|
||||
NodeKind::Ellipse { start, sweep, inner } if (*sweep).abs() < std::f64::consts::TAU - 1e-9 || *inner > 0.0 => {
|
||||
put!("arc", json!({"start": start.to_degrees(), "sweep": sweep.to_degrees(), "inner": inner}));
|
||||
}
|
||||
NodeKind::Polygon { count } => {
|
||||
put!("pointCount", json!(count));
|
||||
}
|
||||
NodeKind::Star { count, inner_ratio } => {
|
||||
put!("pointCount", json!(count));
|
||||
put!("innerRadius", json!(inner_ratio));
|
||||
}
|
||||
NodeKind::BooleanOp { op } => {
|
||||
put!("booleanOperation", json!(op));
|
||||
}
|
||||
NodeKind::Instance { component, props } => {
|
||||
put!("mainComponent", json!(component));
|
||||
put!("mainComponentName", json!(doc.node(*component).map(|c| c.name.clone())));
|
||||
if !props.is_empty() {
|
||||
put!("componentProperties", json!(props));
|
||||
}
|
||||
}
|
||||
NodeKind::Component { description, props } => {
|
||||
if !description.is_empty() {
|
||||
put!("description", json!(description));
|
||||
}
|
||||
if !props.is_empty() {
|
||||
put!("componentPropertyDefinitions", serde_json::to_value(props).unwrap_or(Value::Null));
|
||||
}
|
||||
}
|
||||
NodeKind::Vector { network } => {
|
||||
put!("vertices", json!(network.vertices.len()));
|
||||
put!("segments", json!(network.segments.len()));
|
||||
}
|
||||
NodeKind::Page { background, flows, .. } => {
|
||||
put!("background", json!(background.to_css_hex()));
|
||||
if !flows.is_empty() {
|
||||
put!("flows", serde_json::to_value(flows).unwrap_or(Value::Null));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
NodeKind::Text(t) => text_props(t, o, options),
|
||||
_ => kind_props(doc, n, o, options),
|
||||
}
|
||||
if !n.children.is_empty() {
|
||||
if depth > 0 {
|
||||
@@ -467,6 +194,307 @@ pub fn node_json_with_options(doc: &Document, id: NodeId, depth: usize, options:
|
||||
v
|
||||
}
|
||||
|
||||
/// Build a property only when it was requested (or no field list was given).
|
||||
fn insert_prop(o: &mut serde_json::Map<String, Value>, options: &InspectOptions, key: &str, value: impl FnOnce() -> Value) {
|
||||
if options.includes(key) {
|
||||
o.insert(key.into(), value());
|
||||
}
|
||||
}
|
||||
|
||||
fn base_props(doc: &Document, n: &Node, id: NodeId, o: &mut serde_json::Map<String, Value>, options: &InspectOptions) {
|
||||
if options.includes("x") || options.includes("y") {
|
||||
let c = doc.frame_relative(id).as_coeffs();
|
||||
insert_prop(o, options, "x", || json!(round(c[4])));
|
||||
insert_prop(o, options, "y", || json!(round(c[5])));
|
||||
}
|
||||
insert_prop(o, options, "width", || json!(round(n.width)));
|
||||
insert_prop(o, options, "height", || json!(round(n.height)));
|
||||
if options.includes("rotation") {
|
||||
let rot = figcraft_geom::rotation_degrees(n.transform);
|
||||
if rot.abs() > 1e-6 {
|
||||
insert_prop(o, options, "rotation", || json!(round(rot)));
|
||||
}
|
||||
}
|
||||
if let Some(b) = options.includes("absoluteBounds").then(|| doc.world_bounds(id)).flatten() {
|
||||
insert_prop(
|
||||
o,
|
||||
options,
|
||||
"absoluteBounds",
|
||||
|| json!({"x": round(b.x0), "y": round(b.y0), "width": round(b.width()), "height": round(b.height())}),
|
||||
);
|
||||
}
|
||||
if !n.visible {
|
||||
insert_prop(o, options, "visible", || json!(false));
|
||||
}
|
||||
if n.locked {
|
||||
insert_prop(o, options, "locked", || json!(true));
|
||||
}
|
||||
if n.opacity < 1.0 {
|
||||
insert_prop(o, options, "opacity", || json!(n.opacity));
|
||||
}
|
||||
if !matches!(n.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) {
|
||||
insert_prop(o, options, "blend", || json!(n.blend));
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_props(n: &Node, o: &mut serde_json::Map<String, Value>, options: &InspectOptions) {
|
||||
if !n.fills.is_empty() {
|
||||
insert_prop(o, options, "fills", || paints(&n.fills));
|
||||
}
|
||||
if !n.strokes.is_empty() {
|
||||
insert_prop(o, options, "strokes", || paints(&n.strokes));
|
||||
insert_prop(o, options, "strokeWeight", || json!(n.stroke.weight));
|
||||
insert_prop(o, options, "strokeAlign", || json!(n.stroke.align));
|
||||
if n.stroke.sides.is_some() {
|
||||
insert_prop(o, options, "strokeSides", || json!(n.stroke.sides));
|
||||
}
|
||||
if !n.stroke.dashes.is_empty() {
|
||||
insert_prop(o, options, "dashes", || json!(n.stroke.dashes));
|
||||
}
|
||||
if n.stroke.join != StrokeJoin::Miter {
|
||||
insert_prop(o, options, "strokeJoin", || json!(n.stroke.join));
|
||||
} else if n.stroke.miter_limit != 4.0 {
|
||||
insert_prop(o, options, "miterAngle", || json!((n.stroke.miter_angle() * 100.0).round() / 100.0));
|
||||
}
|
||||
if let Some(p) = n.stroke.profile() {
|
||||
insert_prop(o, options, "widthProfile", || {
|
||||
let points = crate::cmd::props::width_points_json(Some(p));
|
||||
json!({"preset": p.preset().map(|x| x.id()), "points": points})
|
||||
});
|
||||
}
|
||||
if let Some(b) = &n.stroke.brush {
|
||||
insert_prop(o, options, "brush", || camel(serde_json::to_value(b).unwrap_or(Value::Null)));
|
||||
}
|
||||
}
|
||||
if !n.effects.is_empty() {
|
||||
insert_prop(o, options, "effects", || camel(serde_json::to_value(&n.effects).unwrap_or(Value::Null)));
|
||||
}
|
||||
if n.corners.radii.iter().any(|r| *r > 0.0) {
|
||||
insert_prop(o, options, "cornerRadius", || match n.corners.uniform() {
|
||||
Some(r) => json!(r),
|
||||
None => json!(n.corners.radii),
|
||||
});
|
||||
if n.corners.smoothing > 0.0 {
|
||||
insert_prop(o, options, "cornerSmoothing", || json!(n.corners.smoothing));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn layout_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>, options: &InspectOptions) {
|
||||
if let Some(l) = &n.layout {
|
||||
insert_prop(o, options, "layout", || 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(_)) {
|
||||
insert_prop(o, options, "sizing", || json!({"horizontal": n.child_layout.sizing_h, "vertical": n.child_layout.sizing_v}));
|
||||
}
|
||||
let cl = &n.child_layout;
|
||||
if cl.absolute {
|
||||
insert_prop(o, options, "absolute", || 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 {
|
||||
insert_prop(o, options, k, || json!(v));
|
||||
}
|
||||
}
|
||||
if let Some(cell) = (parent_al && options.includes("gridCell"))
|
||||
.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))
|
||||
{
|
||||
insert_prop(o, options, "gridCell", || {
|
||||
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()) {
|
||||
insert_prop(o, options, "constraints", || serde_json::to_value(n.constraints).unwrap_or(Value::Null));
|
||||
}
|
||||
if n.kind.is_frame_like() {
|
||||
insert_prop(o, options, "clip", || json!(n.clip));
|
||||
}
|
||||
}
|
||||
|
||||
fn dev_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>, options: &InspectOptions) {
|
||||
if !n.dev_status.is_none() {
|
||||
insert_prop(o, options, "devStatus", || json!(n.dev_status.id()));
|
||||
}
|
||||
if n.dev_changed {
|
||||
insert_prop(o, options, "devChanged", || json!(true));
|
||||
}
|
||||
if !n.dev_note.is_empty() {
|
||||
insert_prop(o, options, "devNote", || json!(n.dev_note));
|
||||
}
|
||||
if let NodeKind::Section { contents_hidden } = n.kind {
|
||||
insert_prop(o, options, "sectionContentsHidden", || json!(contents_hidden));
|
||||
}
|
||||
if n.is_mask {
|
||||
insert_prop(o, options, "isMask", || json!(true));
|
||||
insert_prop(o, options, "maskType", || json!(n.mask_type.id()));
|
||||
}
|
||||
if !n.grids.is_empty() {
|
||||
insert_prop(o, options, "grids", || camel(serde_json::to_value(&n.grids).unwrap_or(Value::Null)));
|
||||
}
|
||||
if !n.exports.is_empty() {
|
||||
insert_prop(o, options, "exports", || serde_json::to_value(&n.exports).unwrap_or(Value::Null));
|
||||
}
|
||||
if let Some(b) = &n.board {
|
||||
insert_prop(o, options, "board", || serde_json::to_value(b).unwrap_or(Value::Null));
|
||||
}
|
||||
if !n.annotations.is_empty() {
|
||||
insert_prop(o, options, "annotations", || serde_json::to_value(&n.annotations).unwrap_or(Value::Null));
|
||||
}
|
||||
if !n.code_links.is_empty() {
|
||||
insert_prop(o, options, "codeLinks", || camel(serde_json::to_value(&n.code_links).unwrap_or(Value::Null)));
|
||||
}
|
||||
if !n.measurements.is_empty() {
|
||||
insert_prop(o, options, "measurements", || serde_json::to_value(&n.measurements).unwrap_or(Value::Null));
|
||||
}
|
||||
if options.includes("devResources") {
|
||||
let dev = crate::dev_resources::list_json(doc, n.id);
|
||||
if dev.as_array().is_some_and(|a| !a.is_empty()) {
|
||||
insert_prop(o, options, "devResources", || dev);
|
||||
}
|
||||
}
|
||||
if !n.interactions.is_empty() {
|
||||
insert_prop(o, options, "interactions", || serde_json::to_value(&n.interactions).unwrap_or(Value::Null));
|
||||
}
|
||||
if !n.styles.is_empty() {
|
||||
insert_prop(o, options, "styles", || serde_json::to_value(&n.styles).unwrap_or(Value::Null));
|
||||
}
|
||||
if !n.bound.is_empty() {
|
||||
insert_prop(o, options, "boundVariables", || json!(n.bound));
|
||||
}
|
||||
if !n.overrides.is_empty() {
|
||||
insert_prop(o, options, "overrides", || json!(n.overrides));
|
||||
}
|
||||
if let Some(slot) = options.includes("slot").then(|| crate::components::slot_name(doc, n.id)).flatten() {
|
||||
insert_prop(o, options, "slot", || json!(slot));
|
||||
}
|
||||
// An operand combining by an operation of its own.
|
||||
if let Some(op) = n.bool_op.filter(|_| options.includes("operandOperation") && crate::booleans::group_of(doc, n.id).is_some()) {
|
||||
insert_prop(o, options, "operandOperation", || json!(op));
|
||||
}
|
||||
}
|
||||
|
||||
fn text_props(t: &TextData, o: &mut serde_json::Map<String, Value>, options: &InspectOptions) {
|
||||
insert_prop(o, options, "characters", || {
|
||||
if options.outline {
|
||||
let mut chars = t.characters.chars();
|
||||
let mut text: String = chars.by_ref().take(120).collect();
|
||||
if chars.next().is_some() {
|
||||
text.push_str("...");
|
||||
}
|
||||
json!(text)
|
||||
} else {
|
||||
json!(t.characters)
|
||||
}
|
||||
});
|
||||
insert_prop(o, options, "fontFamily", || json!(t.style.font_family));
|
||||
insert_prop(o, options, "fontStyle", || json!(t.style.font_style));
|
||||
insert_prop(o, options, "fontSize", || json!(t.style.font_size));
|
||||
if !t.style.variations.is_empty() {
|
||||
insert_prop(o, options, "fontVariations", || json!(t.style.variations));
|
||||
}
|
||||
insert_prop(o, options, "lineHeight", || json!(t.style.line_height.label()));
|
||||
insert_prop(o, options, "letterSpacing", || json!(t.style.letter_spacing.label()));
|
||||
insert_prop(o, options, "textAlign", || json!(t.align_h));
|
||||
insert_prop(o, options, "textAutoResize", || json!(t.auto_resize));
|
||||
if t.max_lines > 0 {
|
||||
insert_prop(o, options, "maxLines", || json!(t.max_lines));
|
||||
}
|
||||
if let Some(tp) = &t.path {
|
||||
insert_prop(o, options, "textPath", || json!(tp));
|
||||
}
|
||||
if t.style.decoration != TextDecoration::None {
|
||||
insert_prop(o, options, "textDecoration", || json!(t.style.decoration));
|
||||
}
|
||||
if let Some(l) = t.style.decoration_look.as_deref() {
|
||||
insert_prop(o, options, "textDecorationStyle", || json!(l.style));
|
||||
insert_prop(o, options, "textDecorationThickness", || json!(l.thickness.label()));
|
||||
insert_prop(o, options, "textDecorationOffset", || json!(l.offset.label()));
|
||||
insert_prop(o, options, "textDecorationSkipInk", || json!(l.skip_ink));
|
||||
insert_prop(o, options, "textDecorationColor", || 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 {
|
||||
insert_prop(o, options, "listStyle", || json!(t.list));
|
||||
}
|
||||
} else {
|
||||
insert_prop(o, options, "lineTypes", || t.paragraphs.iter().map(|p| json!(p.list)).collect());
|
||||
insert_prop(o, options, "lineIndentations", || t.paragraphs.iter().map(|p| json!(p.indent)).collect());
|
||||
if t.paragraphs.iter().any(|p| p.start_offset > 0) {
|
||||
insert_prop(o, options, "listStartOffsets", || t.paragraphs.iter().map(|p| json!(p.start_offset)).collect());
|
||||
}
|
||||
}
|
||||
if t.list_spacing != 0.0 {
|
||||
insert_prop(o, options, "listSpacing", || json!(t.list_spacing));
|
||||
}
|
||||
for (k, on) in [("hangingPunctuation", t.hanging_punctuation), ("hangingList", t.hanging_list)] {
|
||||
if on {
|
||||
insert_prop(o, options, k, || json!(true));
|
||||
}
|
||||
}
|
||||
if t.leading_trim != figcraft_doc::LeadingTrim::None {
|
||||
insert_prop(o, options, "leadingTrim", || json!(t.leading_trim));
|
||||
}
|
||||
if !t.runs.is_empty() {
|
||||
insert_prop(o, options, "styledRuns", || 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`).
|
||||
if options.includes("textStyleRanges") {
|
||||
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() {
|
||||
insert_prop(o, options, "textStyleRanges", || Value::Array(linked));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>, options: &InspectOptions) {
|
||||
match &n.kind {
|
||||
NodeKind::Ellipse { start, sweep, inner } if (*sweep).abs() < std::f64::consts::TAU - 1e-9 || *inner > 0.0 => {
|
||||
insert_prop(o, options, "arc", || json!({"start": start.to_degrees(), "sweep": sweep.to_degrees(), "inner": inner}));
|
||||
}
|
||||
NodeKind::Polygon { count } => {
|
||||
insert_prop(o, options, "pointCount", || json!(count));
|
||||
}
|
||||
NodeKind::Star { count, inner_ratio } => {
|
||||
insert_prop(o, options, "pointCount", || json!(count));
|
||||
insert_prop(o, options, "innerRadius", || json!(inner_ratio));
|
||||
}
|
||||
NodeKind::BooleanOp { op } => {
|
||||
insert_prop(o, options, "booleanOperation", || json!(op));
|
||||
}
|
||||
NodeKind::Instance { component, props } => {
|
||||
insert_prop(o, options, "mainComponent", || json!(component));
|
||||
insert_prop(o, options, "mainComponentName", || json!(doc.node(*component).map(|c| c.name.clone())));
|
||||
if !props.is_empty() {
|
||||
insert_prop(o, options, "componentProperties", || json!(props));
|
||||
}
|
||||
}
|
||||
NodeKind::Component { description, props } => {
|
||||
if !description.is_empty() {
|
||||
insert_prop(o, options, "description", || json!(description));
|
||||
}
|
||||
if !props.is_empty() {
|
||||
insert_prop(o, options, "componentPropertyDefinitions", || serde_json::to_value(props).unwrap_or(Value::Null));
|
||||
}
|
||||
}
|
||||
NodeKind::Vector { network } => {
|
||||
insert_prop(o, options, "vertices", || json!(network.vertices.len()));
|
||||
insert_prop(o, options, "segments", || json!(network.segments.len()));
|
||||
}
|
||||
NodeKind::Page { background, flows, .. } => {
|
||||
insert_prop(o, options, "background", || json!(background.to_css_hex()));
|
||||
if !flows.is_empty() {
|
||||
insert_prop(o, options, "flows", || serde_json::to_value(flows).unwrap_or(Value::Null));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn round(v: f64) -> f64 {
|
||||
figcraft_geom::round_to(v, 2)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,13 @@ pub fn f64_of(v: &Value) -> Option<f64> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A 0-1 fraction: a `%` suffix or a number above 1 is a percentage ("1%" is 0.01, 50 is 0.5).
|
||||
pub fn fraction_of(v: &Value) -> Option<f64> {
|
||||
let f = f64_of(v)?;
|
||||
let percent = v.as_str().is_some_and(|s| s.trim().ends_with('%'));
|
||||
Some(if percent || f > 1.0 { f / 100.0 } else { f })
|
||||
}
|
||||
|
||||
pub fn num(p: &Value, key: &str) -> Option<f64> {
|
||||
get(p, key).and_then(f64_of)
|
||||
}
|
||||
@@ -167,7 +174,7 @@ pub fn color_of(v: &Value) -> Option<Color> {
|
||||
Value::String(s) => Color::parse_css(s),
|
||||
Value::Object(_) => serde_json::from_value::<Color>(v.clone()).ok().map(Color::sanitized),
|
||||
Value::Array(a) => {
|
||||
let c: Vec<f32> = a.iter().filter_map(|x| x.as_f64()).map(|f| f as f32).collect();
|
||||
let c = a.iter().map(|x| x.as_f64().map(|f| f as f32)).collect::<Option<Vec<f32>>>()?;
|
||||
match c.as_slice() {
|
||||
[r, g, b] => Some(Color::new(*r, *g, *b, 1.0)),
|
||||
[r, g, b, a] => Some(Color::new(*r, *g, *b, *a)),
|
||||
@@ -278,3 +285,16 @@ pub fn parse<T: serde::de::DeserializeOwned>(cmd: &str, p: &Value, key: &str) ->
|
||||
Some(v) => serde_json::from_value(v.clone()).map(Some).map_err(|e| bad(cmd, format!("`{key}`: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod color_of_tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn color_of_rejects_non_numeric_array_entries() {
|
||||
assert_eq!(color_of(&json!([1, "x", 0, 0])), None);
|
||||
assert_eq!(color_of(&json!([1, 0, 0, "x"])), None);
|
||||
assert_eq!(color_of(&json!([1, 0, 0, 0.5])), Some(Color::new(1.0, 0.0, 0.0, 0.5)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
//! A batch that goes on after a failed step (`onError: "continue"`) reports the live events of the
|
||||
//! steps that stayed, not of the ones rolled back.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn a_continued_batch_reports_only_the_events_of_steps_that_stayed() {
|
||||
let mut s = Session::new();
|
||||
let before = s.events.latest();
|
||||
let r = s
|
||||
.execute(
|
||||
"command.batch",
|
||||
&json!({"onError": "continue", "commands": [
|
||||
{"command": "comment.add", "params": {"x": 10, "y": 10, "text": "Stays"}},
|
||||
{"command": "command.batch", "params": {"commands": [
|
||||
{"command": "comment.add", "params": {"x": 20, "y": 20, "text": "Rolled back"}},
|
||||
{"command": "node.set", "params": {"ids": [999_999], "props": {"name": "x"}}}
|
||||
]}},
|
||||
{"command": "comment.add", "params": {"x": 30, "y": 30, "text": "Stays too"}}
|
||||
]}),
|
||||
)
|
||||
.unwrap();
|
||||
let ok: Vec<bool> = r.as_array().unwrap().iter().map(|o| o["ok"].as_bool().unwrap()).collect();
|
||||
assert_eq!(ok, [true, false, true]);
|
||||
let texts: Vec<String> = s.doc().unwrap().comments.iter().map(|c| c.first.text.clone()).collect();
|
||||
assert_eq!(texts, ["Stays", "Stays too"]);
|
||||
let events = s.events.poll(before, &["comments".into()], 10, Duration::ZERO).events;
|
||||
assert_eq!(events.iter().map(|e| e.kind.as_str()).collect::<Vec<_>>(), ["comment.added", "comment.added"]);
|
||||
}
|
||||
@@ -59,6 +59,90 @@ fn failed_group_reopens_documents_closed_inside_it() {
|
||||
assert!(s.closed_in_group.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_group_restores_each_tabs_history_after_undo_close_and_open() {
|
||||
let mut s = Session::new();
|
||||
let mut originals = Vec::new();
|
||||
for (index, name) in ["First", "Second"].into_iter().enumerate() {
|
||||
if index > 0 {
|
||||
s.execute("file.new", &json!({"name": name})).unwrap();
|
||||
}
|
||||
let created = s
|
||||
.execute(
|
||||
"command.batch",
|
||||
&json!({
|
||||
"label": format!("{name} shape"),
|
||||
"commands": [{"command": "shape.rectangle", "params": {
|
||||
"x": index * 20, "y": 0, "width": 10, "height": 10, "props": {"name": name}
|
||||
}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let original = id(&created[0]);
|
||||
s.execute(
|
||||
"command.batch",
|
||||
&json!({
|
||||
"label": format!("{name} name"),
|
||||
"commands": [{"command": "node.set", "params": {"id": original, "props": {"name": format!("{name} renamed")}}}]
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.execute("edit.undo", &json!({})).unwrap(), json!({"undone": true}));
|
||||
assert_eq!(s.undo_depth(), (1, 1));
|
||||
originals.push(original);
|
||||
}
|
||||
let uids: Vec<_> = s.docs.iter().map(|ds| ds.uid).collect();
|
||||
let before_docs: Vec<_> = s.docs.iter().map(|ds| ds.doc.clone()).collect();
|
||||
let before_labels: Vec<_> = s
|
||||
.docs
|
||||
.iter()
|
||||
.map(|ds| {
|
||||
(ds.history.undo.iter().map(|h| h.label.clone()).collect::<Vec<_>>(), ds.history.redo.iter().map(|h| h.label.clone()).collect::<Vec<_>>())
|
||||
})
|
||||
.collect();
|
||||
assert_ne!(before_labels[0], before_labels[1]);
|
||||
let active = s.active;
|
||||
|
||||
let result = s.execute(
|
||||
"command.batch",
|
||||
&json!({
|
||||
"commands": [
|
||||
{"command": "edit.undo"},
|
||||
{"command": "doc.nextTab"},
|
||||
{"command": "edit.undo"},
|
||||
{"command": "file.close", "params": {"index": 0, "force": true}},
|
||||
{"command": "file.new", "params": {"name": "Temporary"}},
|
||||
{"command": "no.such.command"}
|
||||
]
|
||||
}),
|
||||
);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(s.docs.iter().map(|ds| ds.uid).collect::<Vec<_>>(), uids);
|
||||
assert_eq!(s.active, active);
|
||||
assert!(s.closed_in_group.is_empty());
|
||||
for (index, ds) in s.docs.iter().enumerate() {
|
||||
assert_eq!(ds.doc, before_docs[index]);
|
||||
assert_eq!(ds.history.undo.iter().map(|h| h.label.clone()).collect::<Vec<_>>(), before_labels[index].0);
|
||||
assert_eq!(ds.history.redo.iter().map(|h| h.label.clone()).collect::<Vec<_>>(), before_labels[index].1);
|
||||
}
|
||||
// Travel through each restored tab's real history, including the pre-existing redo step.
|
||||
for _ in 0..2 {
|
||||
let index = s.active;
|
||||
let original = originals[index];
|
||||
assert_eq!(s.execute("edit.undo", &json!({})).unwrap(), json!({"undone": true}));
|
||||
assert!(!s.doc().unwrap().contains(original));
|
||||
assert_eq!(s.execute("edit.redo", &json!({})).unwrap(), json!({"redone": true}));
|
||||
assert_eq!(s.doc().unwrap(), before_docs[index].as_ref());
|
||||
assert_eq!(s.execute("edit.redo", &json!({})).unwrap(), json!({"redone": true}));
|
||||
let name = &before_docs[index].node(original).unwrap().name;
|
||||
assert_eq!(s.doc().unwrap().node(original).unwrap().name, format!("{name} renamed"));
|
||||
assert_eq!(s.execute("edit.undo", &json!({})).unwrap(), json!({"undone": true}));
|
||||
assert_eq!(s.doc().unwrap(), before_docs[index].as_ref());
|
||||
s.execute("doc.nextTab", &json!({})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_plugin_run_restores_each_document_without_restoring_unrelated_state() {
|
||||
let mut s = Session::new();
|
||||
@@ -87,6 +171,121 @@ fn failed_plugin_run_restores_each_document_without_restoring_unrelated_state()
|
||||
assert_eq!(s.message, before_message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_group_keeps_the_undo_steps_it_undid() {
|
||||
let mut s = Session::new();
|
||||
let original = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
|
||||
let before_doc = s.doc().unwrap().clone();
|
||||
assert_eq!(s.undo_depth(), (1, 0));
|
||||
|
||||
let result = s.execute(
|
||||
"command.batch",
|
||||
&json!({
|
||||
"commands": [
|
||||
{"command": "edit.undo"},
|
||||
{"command": "no.such.command"}
|
||||
]
|
||||
}),
|
||||
);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(s.doc().unwrap(), &before_doc);
|
||||
assert_eq!(s.undo_depth(), (1, 0), "the undo step survives the failed batch");
|
||||
assert_eq!(s.execute("edit.undo", &json!({})).unwrap(), json!({"undone": true}));
|
||||
assert!(!s.doc().unwrap().contains(original));
|
||||
assert_eq!(s.execute("edit.redo", &json!({})).unwrap(), json!({"redone": true}));
|
||||
assert!(s.doc().unwrap().contains(original));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_group_keeps_history_after_undo_and_redo() {
|
||||
let mut s = Session::new();
|
||||
let first = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
|
||||
let second = id(&s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap());
|
||||
assert!(s.undo().unwrap());
|
||||
let before_doc = s.doc().unwrap().clone();
|
||||
let before_labels = s.undo_labels(10);
|
||||
assert_eq!(s.undo_depth(), (1, 1));
|
||||
|
||||
let result = s.execute(
|
||||
"command.batch",
|
||||
&json!({
|
||||
"commands": [
|
||||
{"command": "edit.undo"},
|
||||
{"command": "edit.redo"},
|
||||
{"command": "no.such.command"}
|
||||
]
|
||||
}),
|
||||
);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(s.doc().unwrap(), &before_doc);
|
||||
assert_eq!(s.undo_depth(), (1, 1));
|
||||
assert_eq!(s.undo_labels(10), before_labels);
|
||||
assert!(s.undo().unwrap());
|
||||
assert!(!s.doc().unwrap().contains(first));
|
||||
assert!(s.redo().unwrap());
|
||||
assert!(s.doc().unwrap().contains(first));
|
||||
assert!(!s.doc().unwrap().contains(second));
|
||||
assert!(s.redo().unwrap());
|
||||
assert!(s.doc().unwrap().contains(second));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_outer_group_keeps_the_undo_steps_a_nested_batch_undid() {
|
||||
let mut s = Session::new();
|
||||
let original = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
|
||||
let before_doc = s.doc().unwrap().clone();
|
||||
|
||||
let result = s.execute(
|
||||
"command.batch",
|
||||
&json!({
|
||||
"commands": [
|
||||
{"command": "command.batch", "params": {"commands": [{"command": "edit.undo"}]}},
|
||||
{"command": "no.such.command"}
|
||||
]
|
||||
}),
|
||||
);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(s.doc().unwrap(), &before_doc);
|
||||
assert_eq!(s.undo_depth(), (1, 0));
|
||||
assert!(s.undo().unwrap());
|
||||
assert!(!s.doc().unwrap().contains(original));
|
||||
assert!(s.redo().unwrap());
|
||||
assert!(s.doc().unwrap().contains(original));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_group_restores_the_small_history_limit() {
|
||||
let mut s = Session::new();
|
||||
s.ds_mut().unwrap().history.limit = 1;
|
||||
let first = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
|
||||
let before_doc = s.doc().unwrap().clone();
|
||||
|
||||
let result = s.execute(
|
||||
"command.batch",
|
||||
&json!({
|
||||
"commands": [
|
||||
{"command": "edit.undo"},
|
||||
{"command": "shape.rectangle", "params": {"x": 20, "y": 0, "width": 10, "height": 10}},
|
||||
{"command": "no.such.command"}
|
||||
]
|
||||
}),
|
||||
);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(s.doc().unwrap(), &before_doc);
|
||||
assert_eq!(s.undo_depth(), (1, 0));
|
||||
assert_eq!(s.ds().unwrap().history.limit, 1);
|
||||
let second = id(&s.execute("shape.rectangle", &json!({"x": 40, "y": 0, "width": 10, "height": 10})).unwrap());
|
||||
assert_eq!(s.undo_depth(), (1, 0), "new edits still respect the limit");
|
||||
assert!(s.undo().unwrap());
|
||||
assert!(s.doc().unwrap().contains(first));
|
||||
assert!(!s.doc().unwrap().contains(second));
|
||||
assert!(!s.undo().unwrap(), "the oldest step was dropped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_group_keeps_the_redo_steps() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -280,3 +280,375 @@ fn a_failing_command_names_its_step_and_rolls_back() {
|
||||
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, name);
|
||||
assert_eq!(s.ds().unwrap().history.undo.len(), undo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_continue_batch_can_undo_then_redo_between_steps() {
|
||||
for mode in ["abort", "continue"] {
|
||||
let mut s = Session::new();
|
||||
let rect = id(&s.execute("node.create", &json!({"type": "rectangle"})).unwrap());
|
||||
let before = s.doc().unwrap().clone();
|
||||
let results =
|
||||
s.execute("command.batch", &json!({"onError": mode, "commands": [{"command": "edit.undo"}, {"command": "edit.redo"}]})).unwrap();
|
||||
let undo = if mode == "continue" { &results[0]["result"] } else { &results[0] };
|
||||
let redo = if mode == "continue" { &results[1]["result"] } else { &results[1] };
|
||||
assert_eq!(undo["undone"], true, "{mode}: {results}");
|
||||
assert_eq!(redo["redone"], true, "{mode}: {results}");
|
||||
assert!(s.doc().unwrap().contains(rect));
|
||||
assert_eq!(s.doc().unwrap(), &before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_continue_batch_can_open_a_document_after_closing_the_last_tab() {
|
||||
let mut s = Session::new();
|
||||
let uid = s.ds().unwrap().uid;
|
||||
let results = s
|
||||
.execute(
|
||||
"command.batch",
|
||||
&json!({"onError": "continue", "commands": [
|
||||
{"command": "file.close", "params": {"force": true}},
|
||||
{"command": "file.new"},
|
||||
{"command": "page.rename", "params": {"name": "New page"}}
|
||||
]}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(results.as_array().unwrap().iter().all(|r| r["ok"] == true), "{results}");
|
||||
assert_eq!(s.docs.len(), 1);
|
||||
assert_ne!(s.ds().unwrap().uid, uid);
|
||||
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, "New page");
|
||||
assert!(s.closed_in_group.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_continue_batch_repeats_the_last_successful_command_after_a_failed_step() {
|
||||
let mut s = Session::new();
|
||||
s.execute("node.create", &json!({"type": "rectangle"})).unwrap();
|
||||
let last = s.last_command.clone();
|
||||
let results = s
|
||||
.execute(
|
||||
"command.batch",
|
||||
&json!({"onError": "continue", "commands": [
|
||||
{"command": "command.batch", "params": {"commands": [
|
||||
{"command": "node.create", "params": {"type": "text", "text": "Rolled back"}},
|
||||
{"command": "no.such.command"}
|
||||
]}},
|
||||
{"command": "edit.repeat"}
|
||||
]}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(results[0]["ok"], false);
|
||||
assert_eq!(results[1]["ok"], true);
|
||||
let repeated = s.doc().unwrap().node(id(&results[1]["result"])).unwrap();
|
||||
assert!(matches!(repeated.kind, figcraft_doc::NodeKind::Rectangle), "{repeated:?}");
|
||||
assert_eq!(s.last_command, last);
|
||||
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_continue_batch_keeps_successful_steps_as_one_undo_entry() {
|
||||
let mut s = Session::new();
|
||||
let frame = id(&s.execute("node.create", &json!({"type": "frame", "width": 50, "height": 50})).unwrap());
|
||||
let text = id(&s.execute("node.create", &json!({"type": "text", "text": "orig"})).unwrap());
|
||||
assert_eq!((frame.0, text.0), (2, 3));
|
||||
let undo = s.undo_depth().0;
|
||||
let name = s.doc().unwrap().node(s.page().unwrap()).unwrap().name.clone();
|
||||
let results = s
|
||||
.execute(
|
||||
"command.batch",
|
||||
&json!({"label": "Continue", "onError": "continue", "commands": [
|
||||
{"command": "text.set", "params": {"ids": [3], "text": "novo"}},
|
||||
{"command": "text.set", "params": {"ids": [2], "text": "erro"}},
|
||||
{"command": "page.rename", "params": {"name": "Depois do erro"}}
|
||||
]}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(results.as_array().unwrap().len(), 3);
|
||||
assert_eq!(results[0], json!({"step": 0, "command": "text.set", "ok": true, "result": {"ok": true}}));
|
||||
assert_eq!(results[2], json!({"step": 2, "command": "page.rename", "ok": true, "result": {"ok": true}}));
|
||||
assert_eq!(results[1]["step"], 1);
|
||||
assert_eq!(results[1]["command"], "text.set");
|
||||
assert_eq!(results[1]["ok"], false);
|
||||
let error = results[1]["error"].as_str().unwrap();
|
||||
assert!(error.contains("step 1:") && error.contains("is not text"), "{error}");
|
||||
assert!(results[1].get("result").is_none());
|
||||
undo_and_redo_the_continued_batch(&mut s, frame, text, &name, undo);
|
||||
}
|
||||
|
||||
/// The successful steps of the batch above undo and redo as one step.
|
||||
fn undo_and_redo_the_continued_batch(s: &mut Session, frame: figcraft_doc::NodeId, text: figcraft_doc::NodeId, name: &str, undo: usize) {
|
||||
assert!(matches!(&s.doc().unwrap().node(text).unwrap().kind, figcraft_doc::NodeKind::Text(t) if t.characters == "novo"));
|
||||
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, "Depois do erro");
|
||||
assert_eq!(s.undo_depth(), (undo + 1, 0));
|
||||
assert_eq!(s.undo_labels(1), ["Continue"]);
|
||||
assert!(s.undo().unwrap());
|
||||
assert!(matches!(&s.doc().unwrap().node(text).unwrap().kind, figcraft_doc::NodeKind::Text(t) if t.characters == "orig"));
|
||||
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, name);
|
||||
assert!(s.doc().unwrap().contains(frame), "undo leaves earlier edits alone");
|
||||
assert_eq!(s.undo_depth(), (undo, 1));
|
||||
assert!(s.redo().unwrap());
|
||||
assert!(matches!(&s.doc().unwrap().node(text).unwrap().kind, figcraft_doc::NodeKind::Text(t) if t.characters == "novo"));
|
||||
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, "Depois do erro");
|
||||
assert_eq!(s.undo_depth(), (undo + 1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_continue_batch_rolls_back_partial_steps_and_keeps_reference_positions() {
|
||||
let mut s = Session::new();
|
||||
let text = id(&s.execute("node.create", &json!({"type": "text", "text": "orig"})).unwrap());
|
||||
let frame = id(&s.execute("node.create", &json!({"type": "frame"})).unwrap());
|
||||
let results = s
|
||||
.execute(
|
||||
"command.batch",
|
||||
&json!({"onError": "continue", "commands": [
|
||||
{"command": "text.set", "as": "bad", "params": {"ids": [text.0, frame.0], "text": "partial"}},
|
||||
{"command": "node.create", "as": "card", "params": {"type": "frame"}},
|
||||
{"command": "node.create", "params": {"type": "text", "parent": {"$step": 0}}},
|
||||
{"command": "node.create", "params": {"type": "text", "parent": {"$ref": "bad"}}},
|
||||
{"command": "node.create", "params": {"type": "text", "parent": {"$step": 1}}},
|
||||
{"command": "node.create", "params": {"type": "text", "parent": {"$ref": "card"}}},
|
||||
{"command": "node.set", "params": {"ids": [{"$step": 4}], "props": {"name": "Child"}}}
|
||||
]}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(results.as_array().unwrap().len(), 7);
|
||||
for (i, result) in results.as_array().unwrap().iter().enumerate() {
|
||||
assert_eq!(result["step"], i);
|
||||
assert_eq!(result["ok"], ![0, 2, 3].contains(&i));
|
||||
}
|
||||
assert!(results[2]["error"].as_str().unwrap().contains("step 2: references step 0, which failed"));
|
||||
assert!(results[3]["error"].as_str().unwrap().contains("step 3: references step 0 (\"bad\"), which failed"));
|
||||
assert!(
|
||||
matches!(&s.doc().unwrap().node(text).unwrap().kind, figcraft_doc::NodeKind::Text(t) if t.characters == "orig"),
|
||||
"the failed step's partial edit was rolled back"
|
||||
);
|
||||
let card = id(&results[1]["result"]);
|
||||
for i in [4, 5] {
|
||||
assert_eq!(s.doc().unwrap().node(id(&results[i]["result"])).unwrap().parent, Some(card), "references use raw results, not outcomes");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().node(id(&results[4]["result"])).unwrap().name, "Child");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_batch_policies_run_nothing() {
|
||||
for mode in [json!("skip"), json!(""), json!(true), json!(3), json!(["continue"]), json!({})] {
|
||||
let mut s = Session::new();
|
||||
let before = s.doc().unwrap().clone();
|
||||
let journal = s.journal.clone();
|
||||
let error = s
|
||||
.execute("command.batch", &json!({"onError": mode, "commands": [{"command": "page.rename", "params": {"name": "Changed"}}]}))
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, EngineError::BadParams { .. }));
|
||||
assert!(error.to_string().contains("`onError` must be \"abort\" or \"continue\""), "{error}");
|
||||
assert_eq!(s.doc().unwrap(), &before);
|
||||
assert_eq!(s.journal, journal);
|
||||
assert_eq!(s.undo_depth(), (0, 0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_batch_structure_runs_nothing_in_either_mode() {
|
||||
let rename = json!({"command": "page.rename", "params": {"name": "Changed"}});
|
||||
for mode in ["abort", "continue"] {
|
||||
for commands in [
|
||||
json!(null),
|
||||
json!({}),
|
||||
json!([rename, null]),
|
||||
json!([rename, {}]),
|
||||
json!([rename, {"command": 3}]),
|
||||
json!([rename, {"command": "page.rename", "as": ""}]),
|
||||
json!([rename, {"command": "page.rename", "as": 3}]),
|
||||
json!([{"command": "page.rename", "as": "same", "params": {"name": "Changed"}}, {"command": "select.none", "as": "same"}]),
|
||||
] {
|
||||
let mut s = Session::new();
|
||||
let before = s.doc().unwrap().clone();
|
||||
let journal = s.journal.clone();
|
||||
let result = s.execute("command.batch", &json!({"onError": mode, "commands": commands}));
|
||||
assert!(matches!(result, Err(EngineError::BadParams { .. })), "{mode}: {result:?}");
|
||||
assert_eq!(s.doc().unwrap(), &before);
|
||||
assert_eq!(s.journal, journal);
|
||||
assert_eq!(s.undo_depth(), (0, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_abort_batch_keeps_the_default_results_and_errors() {
|
||||
let commands = json!([
|
||||
{"command": "node.create", "params": {"type": "frame"}},
|
||||
{"command": "node.create", "params": {"type": "text", "text": "orig"}}
|
||||
]);
|
||||
let mut default = Session::new();
|
||||
let mut abort = Session::new();
|
||||
let raw = default.execute("command.batch", &json!({"commands": commands})).unwrap();
|
||||
assert_eq!(abort.execute("command.batch", &json!({"onError": "abort", "commands": commands})).unwrap(), raw);
|
||||
assert!(raw[0].get("id").is_some() && raw[0].get("ok").is_none(), "still raw results");
|
||||
let commands = json!([
|
||||
{"command": "text.set", "params": {"ids": [3], "text": "novo"}},
|
||||
{"command": "text.set", "params": {"ids": [2], "text": "erro"}},
|
||||
{"command": "page.rename", "params": {"name": "Depois do erro"}}
|
||||
]);
|
||||
let before = abort.doc().unwrap().clone();
|
||||
let undo = abort.undo_depth();
|
||||
let error = default.execute("command.batch", &json!({"commands": commands})).unwrap_err();
|
||||
let explicit = abort.execute("command.batch", &json!({"onError": "abort", "commands": commands})).unwrap_err();
|
||||
assert_eq!(explicit.to_string(), error.to_string());
|
||||
assert!(matches!(explicit, EngineError::BadParams { .. }));
|
||||
assert_eq!(abort.doc().unwrap(), &before);
|
||||
assert_eq!(abort.undo_depth(), undo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_batch_error_policies_are_local() {
|
||||
for outer in ["abort", "continue"] {
|
||||
let mut s = Session::new();
|
||||
let results = s
|
||||
.execute(
|
||||
"command.batch",
|
||||
&json!({"onError": outer, "commands": [
|
||||
{"command": "command.batch", "as": "inner", "params": {"onError": "continue", "commands": [
|
||||
{"command": "node.create", "as": "card", "params": {"type": "frame"}},
|
||||
{"command": "no.such.command"},
|
||||
{"command": "node.create", "params": {"type": "text", "parent": {"$ref": "card"}}}
|
||||
]}},
|
||||
{"command": "node.set", "params": {"ids": [{"$ref": "inner", "field": "2/result/id"}], "props": {"name": "Inner child"}}}
|
||||
]}),
|
||||
)
|
||||
.unwrap();
|
||||
let inner = if outer == "continue" { &results[0]["result"] } else { &results[0] };
|
||||
assert_eq!(inner[1]["ok"], false);
|
||||
let child = s.doc().unwrap().node(id(&inner[2]["result"])).unwrap();
|
||||
assert_eq!(child.name, "Inner child");
|
||||
assert_eq!(child.parent, Some(id(&inner[0]["result"])));
|
||||
assert_eq!(s.undo_depth(), (1, 0));
|
||||
assert!(s.undo().unwrap());
|
||||
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len(), 0);
|
||||
assert!(s.redo().unwrap());
|
||||
assert!(s.doc().unwrap().contains(id(&inner[2]["result"])));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_outer_continue_batch_rolls_back_a_failed_inner_abort_batch() {
|
||||
for explicit in [false, true] {
|
||||
let mut s = Session::new();
|
||||
let mut inner = json!({"commands": [
|
||||
{"command": "file.close", "params": {"force": true}},
|
||||
{"command": "file.new", "params": {"name": "Temporary"}},
|
||||
{"command": "node.create", "params": {"type": "frame"}},
|
||||
{"command": "no.such.command"}
|
||||
]});
|
||||
if explicit {
|
||||
inner["onError"] = json!("abort");
|
||||
}
|
||||
let uid = s.ds().unwrap().uid;
|
||||
let results = s
|
||||
.execute(
|
||||
"command.batch",
|
||||
&json!({"onError": "continue", "commands": [
|
||||
{"command": "page.rename", "params": {"name": "Before"}},
|
||||
{"command": "command.batch", "params": inner},
|
||||
{"command": "page.rename", "params": {"name": "After"}}
|
||||
]}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(results[1]["ok"], false);
|
||||
assert!(results[1]["error"].as_str().unwrap().contains("unknown command"));
|
||||
assert_eq!(results[2]["ok"], true);
|
||||
assert_eq!(s.docs.len(), 1);
|
||||
assert_eq!(s.ds().unwrap().uid, uid);
|
||||
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, "After");
|
||||
assert!(s.doc().unwrap().node(s.page().unwrap()).unwrap().children.is_empty());
|
||||
assert!(s.closed_in_group.is_empty());
|
||||
assert_eq!(s.undo_depth(), (1, 0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_outer_abort_batch_rolls_back_successes_of_an_inner_continue_batch() {
|
||||
let mut s = Session::new();
|
||||
let before = s.doc().unwrap().clone();
|
||||
let result = s.execute(
|
||||
"command.batch",
|
||||
&json!({"commands": [
|
||||
{"command": "command.batch", "params": {"onError": "continue", "commands": [
|
||||
{"command": "node.create", "params": {"type": "frame"}},
|
||||
{"command": "no.such.command"}
|
||||
]}},
|
||||
{"command": "no.such.command"}
|
||||
]}),
|
||||
);
|
||||
assert!(matches!(result, Err(EngineError::UnknownCommand(_))));
|
||||
assert_eq!(s.doc().unwrap(), &before);
|
||||
assert_eq!(s.undo_depth(), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_all_failed_continue_batch_adds_no_undo_and_keeps_redo() {
|
||||
let mut s = Session::new();
|
||||
let frame = id(&s.execute("node.create", &json!({"type": "frame"})).unwrap());
|
||||
s.execute("page.rename", &json!({"name": "Redo name"})).unwrap();
|
||||
assert!(s.undo().unwrap());
|
||||
let depth = s.undo_depth();
|
||||
let before = s.doc().unwrap().clone();
|
||||
let results = s
|
||||
.execute(
|
||||
"command.batch",
|
||||
&json!({"onError": "continue", "commands": [
|
||||
{"command": "node.set", "params": {"ids": [frame.0, 999999], "props": {"name": "Partial"}}},
|
||||
{"command": "no.such.command"},
|
||||
{"command": "node.create", "params": {"type": "text", "parent": {"$step": 0}}}
|
||||
]}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(results.as_array().unwrap().iter().all(|r| r["ok"] == false));
|
||||
assert_eq!(s.doc().unwrap(), &before);
|
||||
assert_eq!(s.undo_depth(), depth);
|
||||
assert!(s.redo().unwrap());
|
||||
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, "Redo name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_continue_batch_returns_an_empty_array_without_undo() {
|
||||
let mut s = Session::new();
|
||||
assert_eq!(s.execute("command.batch", &json!({"onError": "continue", "commands": []})).unwrap(), json!([]));
|
||||
assert_eq!(s.undo_depth(), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_null_batch_result_is_not_a_failed_slot() {
|
||||
let mut scope = crate::BatchScope::default();
|
||||
scope.push(serde_json::Value::Null);
|
||||
scope.push_failed();
|
||||
let null = scope.resolve(&json!({"$step": 0})).unwrap_err().to_string();
|
||||
assert!(null.contains("step 0's result has no `id`") && !null.contains("which failed"), "{null}");
|
||||
let failed = scope.resolve(&json!({"$step": 1})).unwrap_err().to_string();
|
||||
assert!(failed.contains("step 2: references step 1, which failed"), "{failed}");
|
||||
assert_eq!(scope.next_step(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_continue_batch_reports_runtime_errors_and_runs_later_steps() {
|
||||
let mut s = Session::new();
|
||||
let results = s
|
||||
.execute(
|
||||
"command.batch",
|
||||
&json!({"onError": "continue", "commands": [
|
||||
{"command": "no.such.command"},
|
||||
{"command": "node.create", "params": {"type": "no-such-type"}},
|
||||
{"command": "node.create", "params": {"type": "text", "parent": {"$ref": "missing"}}},
|
||||
{"command": "node.create", "params": {"type": "text", "parent": {"$step": 99}}},
|
||||
{"command": "node.create", "params": {"type": "frame"}},
|
||||
{"command": "node.set", "params": {"ids": [{"$step": 4, "field": "missing"}], "props": {"name": "No"}}},
|
||||
{"command": "page.rename", "params": {"name": "Done"}}
|
||||
]}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(results.as_array().unwrap().len(), 7);
|
||||
for (i, needle) in [(0, "unknown command"), (1, "unknown type"), (2, "names no step"), (3, "earlier step"), (5, "has no `missing`")] {
|
||||
assert_eq!(results[i]["ok"], false);
|
||||
assert!(results[i]["error"].as_str().unwrap().contains(needle), "{}", results[i]);
|
||||
}
|
||||
assert_eq!(results[4]["ok"], true);
|
||||
assert_eq!(results[6]["ok"], true);
|
||||
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, "Done");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
//!
|
||||
//! The selected shapes' outlines split the plane into **regions** (the faces of their arrangement):
|
||||
//! connected areas covered by the same shapes. Merging regions makes one new vector from them,
|
||||
//! styled like the top-most shape covering the first region; removing regions deletes them. Either
|
||||
//! way every shape covering a picked region loses that area (it becomes a vector, or is deleted
|
||||
//! when nothing is left), so the rest of the drawing stays exactly where it was.
|
||||
//! styled like the top-most fill or stroke covering the first selected region (using that vector
|
||||
//! region's paints when applicable); removing regions deletes them. Either way every shape covering
|
||||
//! a picked region loses that area (it becomes a vector, or is deleted when nothing is left), so
|
||||
//! the rest of the drawing stays exactly where it was.
|
||||
//!
|
||||
//! **Strokes and open paths.** A visible stroke takes part as its outlined area (a [`Part`] of its
|
||||
//! own, above the shape's fill), so stroked lines and open paths split the regions they cross and
|
||||
@@ -21,14 +22,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};
|
||||
|
||||
@@ -37,6 +38,9 @@ use crate::{EngineError, Result, Session, bad, spec};
|
||||
pub struct Part {
|
||||
pub id: NodeId,
|
||||
pub stroke: bool,
|
||||
/// The source vector region for a fill (`None` for ordinary fills and strokes).
|
||||
#[serde(skip)]
|
||||
pub region: Option<usize>,
|
||||
}
|
||||
|
||||
/// One region of the arrangement, in page coordinates.
|
||||
@@ -126,7 +130,16 @@ fn world_area(doc: &Document, part: Part) -> Option<(BezPath, bool)> {
|
||||
if !(figcraft_doc::geometry::is_closed(n) || matches!(n.kind, NodeKind::BooleanOp { .. } | NodeKind::Text(_))) {
|
||||
return None;
|
||||
}
|
||||
figcraft_render::node_outline(doc, n)?
|
||||
match (&n.kind, part.region) {
|
||||
(NodeKind::Vector { network }, Some(i)) => {
|
||||
let rounded = network.rounded();
|
||||
let net = rounded.as_ref().unwrap_or(network);
|
||||
let region = net.regions.get(i)?;
|
||||
let path = net.region_path(i)?;
|
||||
(path, if region.winding == WindingRule::EvenOdd { figcraft_render::FillRule::EvenOdd } else { figcraft_render::FillRule::Winding })
|
||||
}
|
||||
_ => figcraft_render::node_outline(doc, n)?,
|
||||
}
|
||||
};
|
||||
p.apply_affine(doc.world_transform(part.id));
|
||||
(!p.elements().is_empty() && figcraft_pathops::finite(&p)).then_some((p, rule == figcraft_render::FillRule::EvenOdd))
|
||||
@@ -155,10 +168,17 @@ fn compute(doc: Arc<Document>, uid: u64, ids: &[NodeId]) -> Result<Arrangement>
|
||||
let mut inputs = Vec::new();
|
||||
for id in eligible(&doc, ids) {
|
||||
let before = parts.len();
|
||||
for part in [Part { id, stroke: false }, Part { id, stroke: true }] {
|
||||
if inputs.len() < figcraft_pathops::MAX_ARRANGEMENT_INPUTS
|
||||
&& let Some(o) = world_area(&doc, part)
|
||||
{
|
||||
let fill_parts: Vec<Part> = match &doc.get(id)?.kind {
|
||||
NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some()) => {
|
||||
network.regions.iter().enumerate().map(|(region, _)| Part { id, stroke: false, region: Some(region) }).collect()
|
||||
}
|
||||
_ => vec![Part { id, stroke: false, region: None }],
|
||||
};
|
||||
for part in fill_parts.into_iter().chain([Part { id, stroke: true, region: None }]) {
|
||||
if let Some(o) = world_area(&doc, part) {
|
||||
if inputs.len() >= figcraft_pathops::MAX_ARRANGEMENT_INPUTS {
|
||||
return Err(EngineError::Other("shape builder: too many fill regions and strokes to process safely".into()));
|
||||
}
|
||||
parts.push(part);
|
||||
inputs.push(o);
|
||||
}
|
||||
@@ -253,6 +273,68 @@ fn network_from(path: &BezPath, to_local: Affine) -> Option<VectorNetwork> {
|
||||
Some(net)
|
||||
}
|
||||
|
||||
fn append_network(into: &mut VectorNetwork, mut part: VectorNetwork) {
|
||||
let (vertices, segments) = (into.vertices.len(), into.segments.len());
|
||||
for segment in &mut part.segments {
|
||||
segment.a += vertices;
|
||||
segment.b += vertices;
|
||||
}
|
||||
for region in &mut part.regions {
|
||||
for loop_segments in &mut region.loops {
|
||||
for segment in loop_segments {
|
||||
*segment += segments;
|
||||
}
|
||||
}
|
||||
}
|
||||
into.vertices.extend(part.vertices);
|
||||
into.segments.extend(part.segments);
|
||||
into.regions.extend(part.regions);
|
||||
}
|
||||
|
||||
fn paint_frame(region: Option<&figcraft_doc::Region>, node: &Node) -> (f64, f64, Affine) {
|
||||
region.map_or((node.width, node.height, Affine::IDENTITY), |r| r.paint_frame(node.width, node.height))
|
||||
}
|
||||
|
||||
/// Rebuild `id` from each remaining source region, retaining region order, paint stacks and frames.
|
||||
fn reshape_vector_regions(doc: &mut Document, id: NodeId, areas: &[(usize, BezPath)]) -> Result<()> {
|
||||
let old = doc.get(id)?.clone();
|
||||
let (world, network) = match &old.kind {
|
||||
NodeKind::Vector { network } => (doc.world_transform(id), network),
|
||||
_ => return Err(EngineError::Other("expected a vector with regions".into())),
|
||||
};
|
||||
if !(world.determinant().is_finite() && world.determinant().abs() > 1e-12) {
|
||||
return Err(EngineError::Other("can't reshape a layer scaled to nothing".into()));
|
||||
}
|
||||
let mut rebuilt = VectorNetwork::default();
|
||||
for (index, area) in areas {
|
||||
let Some(source) = network.regions.get(*index) else { continue };
|
||||
let Some(mut part) = network_from(area, world.inverse()) else { return Err(EngineError::Other("invalid geometry".into())) };
|
||||
if part.regions.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (width, height, frame) = paint_frame(Some(source), &old);
|
||||
for region in &mut part.regions {
|
||||
region.fills = source.fills.clone();
|
||||
region.paint_space = Some(figcraft_doc::RegionPaintSpace { width, height, transform: frame });
|
||||
}
|
||||
append_network(&mut rebuilt, part);
|
||||
}
|
||||
let Some(bounds) = rebuilt.bounds() else {
|
||||
let n = doc.get_mut(id)?;
|
||||
n.kind = NodeKind::Vector { network: rebuilt };
|
||||
return Ok(());
|
||||
};
|
||||
rebuilt.translate(-bounds.x0, -bounds.y0);
|
||||
let n = doc.get_mut(id)?;
|
||||
n.kind = NodeKind::Vector { network: rebuilt };
|
||||
n.children.clear();
|
||||
n.corners = Corners::default();
|
||||
n.transform *= Affine::translate((bounds.x0, bounds.y0));
|
||||
n.width = bounds.width();
|
||||
n.height = bounds.height();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replace shape `id`'s geometry with the page-space area `area`, keeping its transform and style.
|
||||
fn reshape(doc: &mut Document, id: NodeId, area: &BezPath) -> Result<()> {
|
||||
let w = doc.world_transform(id);
|
||||
@@ -273,10 +355,16 @@ fn reshape(doc: &mut Document, id: NodeId, area: &BezPath) -> Result<()> {
|
||||
crate::vectoredit::refit(doc, id)
|
||||
}
|
||||
|
||||
/// A new vector for the page-space area `area`, styled like part `style` (a stroke part: filled
|
||||
/// with the stroke's paints), just above its shape.
|
||||
/// A new vector for page-space `area`, styled like `style` (a stroke part: filled with stroke
|
||||
/// paints), just above its shape. Vector region paints and their original frame stay attached.
|
||||
fn create_merged(doc: &mut Document, style: Part, area: &BezPath) -> Result<NodeId> {
|
||||
let mut src = doc.get(style.id)?.clone();
|
||||
let source_region = match (&src.kind, style.region) {
|
||||
(NodeKind::Vector { network }, Some(i)) => network.regions.get(i),
|
||||
_ => None,
|
||||
};
|
||||
let (paint_width, paint_height, source_paint_frame) = paint_frame(source_region, &src);
|
||||
let region_fills = source_region.and_then(|r| r.fills.clone());
|
||||
if style.stroke {
|
||||
src.fills = std::mem::take(&mut src.strokes);
|
||||
src.stroke = StrokeProps::default();
|
||||
@@ -291,6 +379,12 @@ fn create_merged(doc: &mut Document, style: Part, area: &BezPath) -> Result<Node
|
||||
let mut net = network_from(area, pw.inverse()).ok_or_else(|| EngineError::Other("invalid geometry".into()))?;
|
||||
let bb = net.bounds().ok_or_else(|| EngineError::Other("empty region".into()))?;
|
||||
net.translate(-bb.x0, -bb.y0);
|
||||
let to_world = pw * Affine::translate((bb.x0, bb.y0));
|
||||
let paint_transform = to_world.inverse() * doc.world_transform(style) * source_paint_frame;
|
||||
for region in &mut net.regions {
|
||||
region.fills = region_fills.clone();
|
||||
region.paint_space = Some(figcraft_doc::RegionPaintSpace { width: paint_width, height: paint_height, transform: paint_transform });
|
||||
}
|
||||
let id = doc.alloc_id();
|
||||
let mut v = Node::new(id, NodeKind::Vector { network: net }, src.name.clone(), bb.width(), bb.height());
|
||||
v.transform = Affine::translate((bb.x0, bb.y0));
|
||||
@@ -321,7 +415,9 @@ fn apply(s: &mut Session, cmd: &str, p: &Value, merge: bool) -> Result<Value> {
|
||||
// Merging every region of a single shape would only replace it with a copy of itself.
|
||||
let whole_shape =
|
||||
touched.len() == 1 && a.regions.iter().enumerate().all(|(i, r)| pick.contains(&i) || !r.sources.iter().any(|x| touched.contains(x)));
|
||||
if merge && whole_shape {
|
||||
// A vector's distinct region paints are separate fill parts that merging does unify.
|
||||
let fill_parts = touched_parts.iter().filter(|part| !part.stroke).count();
|
||||
if merge && whole_shape && fill_parts <= 1 {
|
||||
return Ok(json!({"id": null, "changed": [], "removed": [], "outlines": [], "regions": pick}));
|
||||
}
|
||||
let doc = s.doc_mut()?;
|
||||
@@ -347,7 +443,8 @@ fn apply(s: &mut Session, cmd: &str, p: &Value, merge: bool) -> Result<Value> {
|
||||
let mut changed = Vec::new();
|
||||
let mut outlined: Vec<(NodeId, NodeId)> = Vec::new();
|
||||
for src in a.sources.iter().copied().filter(|i| touched.contains(i)) {
|
||||
let (fill, stroke) = (Part { id: src, stroke: false }, Part { id: src, stroke: true });
|
||||
let stroke = Part { id: src, stroke: true, region: None };
|
||||
let fill_parts: Vec<Part> = a.parts.iter().copied().filter(|part| part.id == src && !part.stroke).collect();
|
||||
// The stroke becomes an outline of its own (what's left of it), the shape loses it.
|
||||
if a.parts.contains(&stroke) {
|
||||
if let Some(area) = kept(stroke)? {
|
||||
@@ -357,16 +454,35 @@ fn apply(s: &mut Session, cmd: &str, p: &Value, merge: bool) -> Result<Value> {
|
||||
n.strokes.clear();
|
||||
n.styles.stroke = None;
|
||||
}
|
||||
let left = if !a.parts.contains(&fill) {
|
||||
None
|
||||
} else if touched_parts.contains(&fill) {
|
||||
kept(fill)?
|
||||
} else {
|
||||
if fill_parts.is_empty() {
|
||||
doc.remove(src)?;
|
||||
removed.push(src);
|
||||
continue;
|
||||
}
|
||||
let touched_fills: Vec<Part> = fill_parts.iter().copied().filter(|part| touched_parts.contains(part)).collect();
|
||||
if touched_fills.is_empty() {
|
||||
// The fill wasn't picked: the shape stays as it is.
|
||||
changed.push(src);
|
||||
continue;
|
||||
};
|
||||
match left {
|
||||
}
|
||||
if fill_parts.iter().any(|part| part.region.is_some()) {
|
||||
let mut areas = Vec::new();
|
||||
for part in &fill_parts {
|
||||
let Some(region) = part.region else { continue };
|
||||
if let Some(area) = kept(*part)? {
|
||||
areas.push((region, area));
|
||||
}
|
||||
}
|
||||
if areas.is_empty() {
|
||||
doc.remove(src)?;
|
||||
removed.push(src);
|
||||
} else {
|
||||
reshape_vector_regions(doc, src, &areas)?;
|
||||
changed.push(src);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match kept(fill_parts[0])? {
|
||||
Some(area) => {
|
||||
reshape(doc, src, &area)?;
|
||||
changed.push(src);
|
||||
@@ -427,7 +543,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"shapeBuilder.merge",
|
||||
"Merge regions",
|
||||
"{ids?, regions?: [i, ...], points?: [[x, y], ...] (page)} → {id, changed, removed, outlines, regions} — one new shape from the regions, styled like the top-most fill or stroke over the first; the shapes lose those areas (strokes of the shapes involved become outlined vectors: `outlines`)",
|
||||
"{ids?, regions?: [i, ...], points?: [[x, y], ...] (page)} → {id, changed, removed, outlines, regions} — one new shape from the regions, styled like the top-most fill or stroke over the first selected region (using its vector-region paints); the shapes lose those areas (strokes of the shapes involved become outlined vectors: `outlines`)",
|
||||
true,
|
||||
|s, p| apply(s, "shapeBuilder.merge", p, true)
|
||||
),
|
||||
@@ -464,6 +580,10 @@ pub fn key(s: &mut Session, name: &str) -> Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "shapebuilder_region_paints_tests.rs"]
|
||||
mod region_paints_tests;
|
||||
|
||||
impl Session {
|
||||
/// The Shape builder tool: hover highlights a region, click/drag collects regions, release
|
||||
/// merges (or removes, with Alt or in Remove mode) them as one undo step. Clicking outside
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
use figcraft_color::{Color, Gradient, GradientKind, Paint};
|
||||
use figcraft_doc::{NodeId, NodeKind, Region, VectorNetwork, WindingRule};
|
||||
use figcraft_geom::{Affine, Point, Shape};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
use crate::shapebuilder::arrangement;
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
fn id(v: &Value) -> NodeId {
|
||||
id_of(&v["id"]).unwrap()
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64, y: f64, width: f64, height: f64, fill: &str) -> NodeId {
|
||||
id(&ex(s, "shape.rectangle", json!({"x": x, "y": y, "width": width, "height": height, "props": {"fill": fill}})))
|
||||
}
|
||||
|
||||
fn append(into: &mut VectorNetwork, mut part: VectorNetwork) {
|
||||
let (vertices, segments) = (into.vertices.len(), into.segments.len());
|
||||
for segment in &mut part.segments {
|
||||
segment.a += vertices;
|
||||
segment.b += vertices;
|
||||
}
|
||||
for region in &mut part.regions {
|
||||
for lp in &mut region.loops {
|
||||
for segment in lp {
|
||||
*segment += segments;
|
||||
}
|
||||
}
|
||||
}
|
||||
into.vertices.extend(part.vertices);
|
||||
into.segments.extend(part.segments);
|
||||
into.regions.extend(part.regions);
|
||||
}
|
||||
|
||||
fn box_path(x0: f64, y0: f64, x1: f64, y1: f64) -> VectorNetwork {
|
||||
VectorNetwork::from_points(&[Point::new(x0, y0), Point::new(x1, y0), Point::new(x1, y1), Point::new(x0, y1)], true)
|
||||
}
|
||||
|
||||
fn split_vector(s: &mut Session) -> NodeId {
|
||||
let id = rect(s, 0.0, 0.0, 100.0, 100.0, "#00ff00");
|
||||
let mut network = VectorNetwork::default();
|
||||
append(&mut network, box_path(0.0, 0.0, 50.0, 100.0));
|
||||
append(&mut network, box_path(50.0, 0.0, 100.0, 100.0));
|
||||
network.regions[0].fills = Some(vec![Paint::solid(Color::from_hex("#ff0000").unwrap())]);
|
||||
network.regions[1].fills = Some(vec![Paint::solid(Color::from_hex("#0000ff").unwrap())]);
|
||||
s.doc_mut().unwrap().get_mut(id).unwrap().kind = NodeKind::Vector { network };
|
||||
id
|
||||
}
|
||||
|
||||
fn network(s: &Session, id: NodeId) -> &VectorNetwork {
|
||||
let NodeKind::Vector { network } = &s.doc().unwrap().node(id).unwrap().kind else { panic!("expected vector") };
|
||||
// This helper's borrow is tied to the session document.
|
||||
network
|
||||
}
|
||||
|
||||
fn world_region_area(s: &Session, id: NodeId, index: usize) -> f64 {
|
||||
let doc = s.doc().unwrap();
|
||||
let mut path = network(s, id).region_path(index).unwrap();
|
||||
path.apply_affine(doc.world_transform(id));
|
||||
path.area().abs()
|
||||
}
|
||||
|
||||
fn normalized_paint_world(s: &Session, id: NodeId, index: usize) -> Affine {
|
||||
let doc = s.doc().unwrap();
|
||||
let node = doc.node(id).unwrap();
|
||||
let (width, height, transform) = network(s, id).regions[index].paint_frame(node.width, node.height);
|
||||
doc.world_transform(id) * transform * Affine::scale_non_uniform(width, height)
|
||||
}
|
||||
|
||||
fn assert_same_transform(a: Affine, b: Affine) {
|
||||
for (x, y) in a.as_coeffs().into_iter().zip(b.as_coeffs()) {
|
||||
assert!((x - y).abs() < 1e-8, "paint frame changed: {a:?} vs {b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrangement_keeps_internal_paint_boundaries_when_another_shape_crosses_them() {
|
||||
let mut s = Session::new();
|
||||
let vector = split_vector(&mut s);
|
||||
let crossing = rect(&mut s, 25.0, -10.0, 50.0, 120.0, "#ffffff");
|
||||
s.set_selection(vec![vector, crossing]);
|
||||
let a = arrangement(&mut s, &[vector, crossing]).unwrap();
|
||||
let left = a.region_at(Point::new(35.0, 50.0)).unwrap();
|
||||
let right = a.region_at(Point::new(65.0, 50.0)).unwrap();
|
||||
assert_ne!(left, right, "the old paint boundary must split the crossing shape's overlap");
|
||||
assert!(a.regions[left].parts.iter().any(|p| p.id == vector && p.region == Some(0)));
|
||||
assert!(a.regions[right].parts.iter().any(|p| p.id == vector && p.region == Some(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_unstyled_nested_evenodd_regions_keep_the_hole_unpainted() {
|
||||
let mut s = Session::new();
|
||||
let vector = rect(&mut s, 0.0, 0.0, 100.0, 100.0, "#00ff00");
|
||||
let mut net = box_path(0.0, 0.0, 100.0, 100.0);
|
||||
let inner = VectorNetwork::from_points(&[Point::new(25.0, 25.0), Point::new(25.0, 75.0), Point::new(75.0, 75.0), Point::new(75.0, 25.0)], true);
|
||||
append(&mut net, inner);
|
||||
for region in &mut net.regions {
|
||||
region.winding = WindingRule::EvenOdd;
|
||||
region.fills = None;
|
||||
region.paint_space = None;
|
||||
}
|
||||
s.doc_mut().unwrap().get_mut(vector).unwrap().kind = NodeKind::Vector { network: net };
|
||||
let crossing = rect(&mut s, -5.0, 45.0, 15.0, 10.0, "#ffffff");
|
||||
s.set_selection(vec![vector, crossing]);
|
||||
let a = arrangement(&mut s, &[vector, crossing]).unwrap();
|
||||
assert!(a.region_at(Point::new(50.0, 50.0)).is_none(), "the legacy combined even-odd fill leaves its nested center empty");
|
||||
|
||||
ex(&mut s, "shapeBuilder.remove", json!({"points": [[5, 50]]}));
|
||||
let NodeKind::Vector { network } = &s.doc().unwrap().node(vector).unwrap().kind else { panic!("expected vector") };
|
||||
assert!(!network.fill_contains(Point::new(50.0, 50.0)), "the nested hole survives removal");
|
||||
assert!(network.fill_contains(Point::new(85.0, 50.0)), "unselected outer area remains filled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_all_painted_regions_uses_the_first_picked_region_style() {
|
||||
let mut s = Session::new();
|
||||
let vector = split_vector(&mut s);
|
||||
s.set_selection(vec![vector]);
|
||||
let result = ex(&mut s, "shapeBuilder.merge", json!({"points": [[25, 50], [75, 50]]}));
|
||||
let merged = id(&result);
|
||||
assert!(!s.doc().unwrap().contains(vector));
|
||||
assert_eq!(network(&s, merged).regions[0].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "FF0000");
|
||||
assert!((world_region_area(&s, merged, 0) - 10_000.0).abs() < 0.1);
|
||||
s.undo().unwrap();
|
||||
assert_eq!(network(&s, vector).regions.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_all_inherited_gradient_regions_with_distinct_frames_is_not_a_noop() {
|
||||
let mut s = Session::new();
|
||||
let vector = split_vector(&mut s);
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let node = doc.get_mut(vector).unwrap();
|
||||
node.fills = vec![Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::from_hex("#ff0000").unwrap()))];
|
||||
let NodeKind::Vector { network } = &mut node.kind else { panic!("expected vector") };
|
||||
for (region, offset) in network.regions.iter_mut().zip([0.0, 12.0]) {
|
||||
region.fills = None;
|
||||
region.paint_space = Some(figcraft_doc::RegionPaintSpace { width: 100.0, height: 100.0, transform: Affine::translate((offset, 0.0)) });
|
||||
}
|
||||
}
|
||||
let before_a = normalized_paint_world(&s, vector, 0);
|
||||
let before_b = normalized_paint_world(&s, vector, 1);
|
||||
assert_ne!(before_a, before_b, "source regions use distinct inherited-gradient frames");
|
||||
|
||||
s.set_selection(vec![vector]);
|
||||
let result = ex(&mut s, "shapeBuilder.merge", json!({"points": [[25, 50], [75, 50]]}));
|
||||
let merged = id(&result);
|
||||
assert!(!s.doc().unwrap().contains(vector), "the selected regions are merged into a new vector");
|
||||
assert_eq!(network(&s, merged).regions.len(), 1);
|
||||
assert_eq!(network(&s, merged).regions[0].fills, None, "node gradient remains inherited");
|
||||
assert!(matches!(s.doc().unwrap().node(merged).unwrap().fills[0].kind, figcraft_color::PaintKind::Gradient(_)));
|
||||
assert_same_transform(normalized_paint_world(&s, merged, 0), before_a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exceeding_arrangement_capacity_never_drops_unprocessed_regions() {
|
||||
let mut s = Session::new();
|
||||
let vector = split_vector(&mut s);
|
||||
let mut net = VectorNetwork::default();
|
||||
for i in 0..=figcraft_pathops::MAX_ARRANGEMENT_INPUTS {
|
||||
let mut part = box_path(i as f64 * 12.0, 0.0, i as f64 * 12.0 + 10.0, 10.0);
|
||||
part.regions[0].fills = Some(vec![Paint::solid(Color::BLACK)]);
|
||||
append(&mut net, part);
|
||||
}
|
||||
s.doc_mut().unwrap().get_mut(vector).unwrap().kind = NodeKind::Vector { network: net.clone() };
|
||||
s.set_selection(vec![vector]);
|
||||
let error = s.execute("shapeBuilder.remove", &json!({"points": [[5, 5]]})).unwrap_err();
|
||||
assert!(error.to_string().contains("too many fill regions"));
|
||||
assert_eq!(network(&s, vector), &net);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_changes_only_the_picked_painted_face() {
|
||||
let mut s = Session::new();
|
||||
let vector = split_vector(&mut s);
|
||||
let crossing = rect(&mut s, 25.0, -10.0, 50.0, 120.0, "#ffffff");
|
||||
s.set_selection(vec![vector, crossing]);
|
||||
ex(&mut s, "shapeBuilder.remove", json!({"points": [[35, 50]]}));
|
||||
assert!((world_region_area(&s, vector, 0) - 2500.0).abs() < 0.1);
|
||||
assert!((world_region_area(&s, vector, 1) - 5000.0).abs() < 0.1, "the untouched blue region keeps its area");
|
||||
let net = network(&s, vector);
|
||||
assert_eq!(net.regions[0].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "FF0000");
|
||||
assert_eq!(net.regions[1].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "0000FF");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reshape_keeps_inherited_and_explicitly_empty_region_fills_distinct() {
|
||||
let mut s = Session::new();
|
||||
let vector = split_vector(&mut s);
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let NodeKind::Vector { network } = &mut doc.get_mut(vector).unwrap().kind else { panic!("expected vector") };
|
||||
network.regions[0].fills = Some(Vec::new());
|
||||
network.regions[1].fills = None;
|
||||
}
|
||||
let crossing = rect(&mut s, 25.0, -10.0, 50.0, 120.0, "#ffffff");
|
||||
s.set_selection(vec![vector, crossing]);
|
||||
ex(&mut s, "shapeBuilder.remove", json!({"points": [[35, 50]]}));
|
||||
assert_eq!(network(&s, vector).regions[0].fills, Some(Vec::new()));
|
||||
assert_eq!(network(&s, vector).regions[1].fills, None);
|
||||
assert_eq!(s.doc().unwrap().node(vector).unwrap().fills[0].solid_color().unwrap().to_hex(), "00FF00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_uses_selected_region_paints_and_retains_other_region_stack() {
|
||||
let mut s = Session::new();
|
||||
// Create crossing first, then vector above it so vector region supplies merge appearance.
|
||||
let crossing = rect(&mut s, 25.0, -10.0, 50.0, 120.0, "#ffffff");
|
||||
let vector = split_vector(&mut s);
|
||||
s.set_selection(vec![crossing, vector]);
|
||||
let result = ex(&mut s, "shapeBuilder.merge", json!({"points": [[35, 50]]}));
|
||||
let merged = id(&result);
|
||||
let merged_net = network(&s, merged);
|
||||
assert_eq!(merged_net.regions[0].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "FF0000");
|
||||
assert!((world_region_area(&s, vector, 1) - 5000.0).abs() < 0.1, "unselected blue region survives");
|
||||
assert_eq!(network(&s, vector).regions[1].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "0000FF");
|
||||
assert!((world_region_area(&s, merged, 0) - 2500.0).abs() < 0.1);
|
||||
assert!(s.undo().unwrap());
|
||||
assert_eq!(network(&s, vector).regions[0].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "FF0000");
|
||||
assert!(s.redo().unwrap());
|
||||
assert_eq!(network(&s, merged).regions[0].fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "FF0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merged_region_keeps_a_transformed_source_gradient_frame() {
|
||||
let mut s = Session::new();
|
||||
let crossing = rect(&mut s, 0.0, 0.0, 300.0, 200.0, "#ffffff");
|
||||
let vector = split_vector(&mut s);
|
||||
let mut transform = Affine::translate((100.0, 20.0)) * Affine::rotate(0.2);
|
||||
transform *= Affine::scale_non_uniform(1.2, 0.8);
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let node = doc.get_mut(vector).unwrap();
|
||||
node.transform = transform;
|
||||
let NodeKind::Vector { network } = &mut node.kind else { panic!("expected vector") };
|
||||
network.regions[0].fills = Some(vec![Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::from_hex("#ff0000").unwrap()))]);
|
||||
}
|
||||
let before = normalized_paint_world(&s, vector, 0);
|
||||
let point = s.doc().unwrap().world_transform(vector) * Point::new(35.0, 50.0);
|
||||
s.set_selection(vec![crossing, vector]);
|
||||
let result = ex(&mut s, "shapeBuilder.merge", json!({"points": [[point.x, point.y]]}));
|
||||
let merged = id(&result);
|
||||
assert!(matches!(&network(&s, merged).regions[0].fills.as_ref().unwrap()[0].kind, figcraft_color::PaintKind::Gradient(_)));
|
||||
assert_same_transform(before, normalized_paint_world(&s, merged, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removal_preserves_holes_and_transformed_gradient_frames() {
|
||||
let mut s = Session::new();
|
||||
let vector = rect(&mut s, 0.0, 0.0, 100.0, 100.0, "#00ff00");
|
||||
let mut outer = box_path(0.0, 0.0, 100.0, 100.0);
|
||||
let inner = VectorNetwork::from_points(&[Point::new(25.0, 25.0), Point::new(25.0, 75.0), Point::new(75.0, 75.0), Point::new(75.0, 25.0)], true);
|
||||
let outer_loop = outer.regions[0].loops[0].clone();
|
||||
let inner_loop: Vec<usize> = inner.regions[0].loops[0].iter().map(|i| i + outer.segments.len()).collect();
|
||||
append(&mut outer, inner);
|
||||
outer.regions = vec![Region {
|
||||
loops: vec![outer_loop, inner_loop],
|
||||
winding: WindingRule::EvenOdd,
|
||||
fills: Some(vec![Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::from_hex("#ff0000").unwrap()))]),
|
||||
paint_space: None,
|
||||
}];
|
||||
let transform = Affine::translate((120.0, 20.0)) * Affine::rotate(0.2);
|
||||
{
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let node = doc.get_mut(vector).unwrap();
|
||||
node.kind = NodeKind::Vector { network: outer };
|
||||
node.transform = transform;
|
||||
}
|
||||
let before = normalized_paint_world(&s, vector, 0);
|
||||
let world = s.doc().unwrap().world_transform(vector) * Point::new(10.0, 50.0);
|
||||
let overlay = rect(&mut s, world.x - 5.0, world.y - 5.0, 10.0, 10.0, "#ffffff");
|
||||
s.set_selection(vec![overlay, vector]);
|
||||
ex(&mut s, "shapeBuilder.remove", json!({"points": [[world.x, world.y]]}));
|
||||
let net = network(&s, vector);
|
||||
assert_eq!(net.regions.len(), 1);
|
||||
assert_eq!(net.regions[0].loops.len(), 3, "the original hole and removed face stay separate loops");
|
||||
assert!(!net.region_contains(0, Point::new(50.0, 50.0)), "the hole remains empty");
|
||||
assert!(!net.region_contains(0, Point::new(10.0, 50.0)), "the removed face remains empty");
|
||||
assert!(net.region_contains(0, Point::new(90.0, 50.0)), "the unselected ring remains filled");
|
||||
assert_same_transform(before, normalized_paint_world(&s, vector, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_the_left_region_keeps_the_right_region_paint_frame() {
|
||||
let mut s = Session::new();
|
||||
let vector = split_vector(&mut s);
|
||||
{
|
||||
let NodeKind::Vector { network } = &mut s.doc_mut().unwrap().get_mut(vector).unwrap().kind else { panic!("expected vector") };
|
||||
network.regions[1].fills = Some(vec![Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::from_hex("#0000ff").unwrap()))]);
|
||||
}
|
||||
let before = normalized_paint_world(&s, vector, 1);
|
||||
s.set_selection(vec![vector]);
|
||||
ex(&mut s, "shapeBuilder.remove", json!({"points": [[25, 50]]}));
|
||||
let node = s.doc().unwrap().node(vector).unwrap();
|
||||
assert!((node.width - 50.0).abs() < 1e-6, "the layer is reboxed to the surviving region: {}", node.width);
|
||||
let net = network(&s, vector);
|
||||
assert_eq!(net.regions.len(), 1);
|
||||
assert!(matches!(&net.regions[0].fills.as_ref().unwrap()[0].kind, figcraft_color::PaintKind::Gradient(_)));
|
||||
assert!((world_region_area(&s, vector, 0) - 5000.0).abs() < 1e-6);
|
||||
assert_same_transform(before, normalized_paint_world(&s, vector, 0));
|
||||
}
|
||||
@@ -138,6 +138,24 @@ impl Targets {
|
||||
self.boxes.push(r);
|
||||
}
|
||||
|
||||
/// Take back what [`Targets::add_box`] added for `r`.
|
||||
pub fn remove_box(&mut self, r: Rect) {
|
||||
let take = |v: &mut Vec<Span>, s: Span| {
|
||||
if let Some(i) = v.iter().rposition(|x| *x == s) {
|
||||
v.remove(i);
|
||||
}
|
||||
};
|
||||
for x in [r.x0, r.center().x, r.x1] {
|
||||
take(&mut self.xs, (x, r.y0, r.y1));
|
||||
}
|
||||
for y in [r.y0, r.center().y, r.y1] {
|
||||
take(&mut self.ys, (y, r.x0, r.x1));
|
||||
}
|
||||
if let Some(i) = self.boxes.iter().rposition(|b| *b == r) {
|
||||
self.boxes.remove(i);
|
||||
}
|
||||
}
|
||||
|
||||
/// A point to snap onto (a vector point, a segment midpoint): lines through it on both axes,
|
||||
/// and a marker when a moving point lands on it.
|
||||
pub fn add_point(&mut self, p: Point) {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
//! Live text previews: what a text command would do, shown on the canvas without an undo step —
|
||||
//! while the pointer is over a font family or style, a text style in the style browser or a
|
||||
//! font's named instance, or while a variable font axis slider is dragged. The document under the preview is kept and comes
|
||||
//! back when the preview ends ([`end`], `text.endPreview`). Every other command ends it first, so
|
||||
//! committing (running the command for real) applies to the document itself as one undo step, and
|
||||
//! Escape just ends the preview.
|
||||
//! Live previews: what a command would do, shown on the canvas without an undo step — while the
|
||||
//! pointer is over a font family or style, a text style in the style browser, a font's named
|
||||
//! instance or a blend mode, or while a variable font axis slider is dragged. The document under
|
||||
//! the preview is kept and comes back when the preview ends ([`end`], `text.endPreview`). Every
|
||||
//! other command ends it first, so committing (running the command for real) applies to the
|
||||
//! document itself as one undo step, and Escape just ends the preview.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -14,8 +14,19 @@ use crate::{EngineError, Result, Session, bad, find_command, guard, normalize};
|
||||
/// The commands that show or end a preview (they don't end the one showing).
|
||||
pub const COMMANDS: [&str; 2] = ["text.preview", "text.endPreview"];
|
||||
|
||||
/// What can be previewed: text styles, font styles and axis values.
|
||||
pub const PREVIEWABLE: [&str; 4] = ["style.apply", "library.applyStyle", "text.setVariations", "text.style"];
|
||||
/// What can be previewed: text styles, font styles and axis values, layers' properties and the
|
||||
/// blend of a paint on a layer, a selection or a style.
|
||||
pub const PREVIEWABLE: [&str; 9] = [
|
||||
"style.apply",
|
||||
"library.applyStyle",
|
||||
"text.setVariations",
|
||||
"text.style",
|
||||
"node.set",
|
||||
"fill.set",
|
||||
"stroke.set",
|
||||
"selection.setPaint",
|
||||
"style.update",
|
||||
];
|
||||
|
||||
/// Show `command params` as a live preview (in place of an earlier one).
|
||||
pub fn show(s: &mut Session, command: &str, params: &Value) -> Result<()> {
|
||||
@@ -39,7 +50,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;
|
||||
@@ -422,6 +422,9 @@ pub(crate) enum Gesture {
|
||||
/// A Shift-press on this selected layer: it leaves the selection on release, unless the
|
||||
/// press became a drag.
|
||||
deselect: Option<NodeId>,
|
||||
/// A press on one layer of a multi-selection that keeps the selection to drag it all: a
|
||||
/// release without a drag selects just this layer.
|
||||
keep: Option<NodeId>,
|
||||
},
|
||||
Resize {
|
||||
start: Point,
|
||||
|
||||
@@ -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<()> {
|
||||
@@ -90,6 +94,10 @@ impl Session {
|
||||
let behind_sel = |t: Option<NodeId>| {
|
||||
t.is_none_or(|t| sel.iter().any(|s| *s != t && (doc.is_ancestor(t, *s) || (in_own_box(*s) && paints_below(&doc, t, *s)))))
|
||||
};
|
||||
// What the press hit (a label or a painted layer): a click that doesn't become a drag
|
||||
// selects just it. A press that reaches nothing above the selection uses the selection as
|
||||
// its drag target instead; that fallback is not a clicked layer.
|
||||
let clicked = target;
|
||||
let target = match sel.iter().copied().find(|s| !locked_in(&doc, *s)) {
|
||||
Some(first) if label.is_none() && !ev.shift && behind_sel(target) && selection_box(&doc, &sel).is_some_and(|q| in_quad(&q, ev.p())) => {
|
||||
Some(first)
|
||||
@@ -98,13 +106,18 @@ impl Session {
|
||||
};
|
||||
match target {
|
||||
Some(target) => {
|
||||
// Clicking inside the current selection's box keeps it (drag them all).
|
||||
// Clicking inside the current selection's box keeps it (drag them all); a click
|
||||
// that doesn't become a drag narrows it to the clicked layer.
|
||||
let in_sel_box = sel.len() > 1 && doc.bounds_of(&sel).is_some_and(|b| b.contains(ev.p())) && !ev.shift;
|
||||
// Shift on a selected layer takes it out on release if the press doesn't become a
|
||||
// drag; a drag moves the selection with it. Where no drag can start, it goes now.
|
||||
let deselect = (ev.shift && sel.contains(&target)).then_some(target);
|
||||
let without_target = || sel.iter().copied().filter(|x| *x != target).collect::<Vec<_>>();
|
||||
let new_sel = if in_sel_box && (sel.contains(&target) || sel.iter().any(|s| doc.is_ancestor(*s, target))) {
|
||||
// A release without a drag narrows the selection to the layer the press hit. A
|
||||
// selected frame's name label sits outside the selection bounds, so it counts too.
|
||||
let keep = clicked
|
||||
.filter(|c| (label.is_some() || in_sel_box) && !ev.cmd && (sel.contains(c) || sel.iter().any(|s| doc.is_ancestor(*s, *c))));
|
||||
let new_sel = if keep.is_some() {
|
||||
sel.clone()
|
||||
} else if ev.shift && deselect.is_none() {
|
||||
let mut s2 = sel.clone();
|
||||
@@ -116,10 +129,13 @@ impl Session {
|
||||
vec![target]
|
||||
};
|
||||
self.set_selection(new_sel.clone());
|
||||
// Dev Mode only selects (Shift-click still deselects): nothing moves.
|
||||
// Dev Mode only selects (Shift-click still deselects): nothing moves. No move
|
||||
// gesture starts, so a click selects the layer it hit now instead of on release.
|
||||
if !edits {
|
||||
if deselect.is_some() {
|
||||
self.set_selection(without_target());
|
||||
} else if let Some(k) = keep.filter(|_| sel.len() > 1) {
|
||||
self.set_selection(vec![k]);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
@@ -128,6 +144,9 @@ impl Session {
|
||||
if new_sel.is_empty() || doc.node(target).is_some_and(|n| n.locked) || ids.is_empty() {
|
||||
if deselect.is_some() {
|
||||
self.set_selection(without_target());
|
||||
} else if let Some(k) = keep {
|
||||
// No drag can start here, so the click narrows the selection now.
|
||||
self.set_selection(vec![k]);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
@@ -144,7 +163,7 @@ impl Session {
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
self.tool.gesture = Gesture::Move { start: ev.p(), ids, targets, duplicate: ev.alt, moved: false, deselect };
|
||||
self.tool.gesture = Gesture::Move { start: ev.p(), ids, targets, duplicate: ev.alt, moved: false, deselect, keep };
|
||||
self.tool.overlay.cursor = if ev.alt { Cursor::Copy } else { Cursor::Default };
|
||||
}
|
||||
None => {
|
||||
@@ -159,7 +178,20 @@ impl Session {
|
||||
}
|
||||
|
||||
pub(super) fn drag(&mut self, ev: &PointerEvent) -> Result<()> {
|
||||
let g = std::mem::take(&mut self.tool.gesture);
|
||||
let mut g = std::mem::take(&mut self.tool.gesture);
|
||||
// Alt makes a move a copy whenever it goes down, before the drag or during it, and letting
|
||||
// go of it moves the original again; a copy snaps to the layers it came from too.
|
||||
if let Gesture::Move { targets, duplicate, .. } = &mut g
|
||||
&& *duplicate != ev.alt
|
||||
{
|
||||
*duplicate = ev.alt;
|
||||
if let Some(b) = targets.moving {
|
||||
if ev.alt { targets.add_box(b) } else { targets.remove_box(b) }
|
||||
}
|
||||
if let Some(it) = self.ds_mut().ok().and_then(|ds| ds.interaction.as_mut()) {
|
||||
it.label = if ev.alt { "Duplicate" } else { "Move" }.into();
|
||||
}
|
||||
}
|
||||
let res = match &g {
|
||||
Gesture::None => return self.hover(ev),
|
||||
Gesture::Marquee { start, additive, base, within } => {
|
||||
@@ -514,15 +546,8 @@ impl Session {
|
||||
}
|
||||
let Some(data) = doc.images.get(&img.image) else { continue };
|
||||
let (iw, ih) = (f64::from(data.width), f64::from(data.height));
|
||||
let crop = if img.scale_mode == ImageScaleMode::Crop {
|
||||
img.effective_crop(iw, ih, w, h)
|
||||
} else {
|
||||
let (rw, rh) = img.rotated_size(iw.max(1.0), ih.max(1.0));
|
||||
let s = if img.scale_mode == ImageScaleMode::Fit { (w / rw).min(h / rh) } else { (w / rw).max(h / rh) };
|
||||
[w / (rw * s), 0.0, 0.0, h / (rh * s), (rw * s - w) / (2.0 * rw * s), (rh * s - h) / (2.0 * rh * s)]
|
||||
};
|
||||
// A degenerate (zero-scale) layer has no inverse: its image keeps its placement.
|
||||
let crop = (Affine::new(crop) * to_old).as_coeffs();
|
||||
let crop = (Affine::new(img.placement_crop(iw, ih, w, h)) * to_old).as_coeffs();
|
||||
if !crop.iter().all(|c| c.is_finite()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -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<()> {
|
||||
@@ -98,6 +100,10 @@ impl Session {
|
||||
}
|
||||
|
||||
pub(super) fn up(&mut self, ev: &PointerEvent) -> Result<()> {
|
||||
// Alt went down or up since the last move: the drop is a copy or a move as the release has it.
|
||||
if matches!(&self.tool.gesture, Gesture::Move { moved: true, duplicate, .. } if *duplicate != ev.alt) {
|
||||
self.drag(ev)?;
|
||||
}
|
||||
let g = std::mem::take(&mut self.tool.gesture);
|
||||
self.tool.overlay.marquee = None;
|
||||
self.tool.overlay.guides.clear();
|
||||
@@ -109,7 +115,7 @@ impl Session {
|
||||
self.tool.overlay.drop_cell = None;
|
||||
let drop_target = self.tool.overlay.drop_target.take();
|
||||
match g {
|
||||
Gesture::Move { ids, moved, duplicate, start, deselect, .. } => {
|
||||
Gesture::Move { ids, moved, duplicate, start, deselect, keep, .. } => {
|
||||
if moved {
|
||||
// A free move lands in the frame under the pointer (or the page) where it was
|
||||
// dropped; layers in an auto layout frame already sit in their slot.
|
||||
@@ -154,6 +160,13 @@ impl Session {
|
||||
if let Some(d) = deselect {
|
||||
let sel = self.selection().into_iter().filter(|x| *x != d).collect();
|
||||
self.set_selection(sel);
|
||||
} else if let Some(k) = keep {
|
||||
// A click (not a drag) on one layer of a multi-selection selects just it.
|
||||
let doc = self.doc()?.clone();
|
||||
let sel = self.selection();
|
||||
if sel.len() > 1 && (sel.contains(&k) || sel.iter().any(|s| doc.is_ancestor(*s, k))) {
|
||||
self.set_selection(vec![k]);
|
||||
}
|
||||
}
|
||||
if deselect.is_none() && !(ev.shift || ev.cmd || duplicate) {
|
||||
self.widget_click(start, ev.tol() * 2.0)?;
|
||||
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user