diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 38698902..da85536c 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -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') diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 4cceb922..267479eb 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index b2384311..f61412ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. diff --git a/Cargo.lock b/Cargo.lock index 413011ab..a0f71692 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -942,12 +942,60 @@ 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-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 +1020,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", ] @@ -1545,7 +1593,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" [[package]] name = "figcraft" -version = "0.11.2" +version = "0.12.1" dependencies = [ "arboard", "craft-update", @@ -1573,7 +1621,7 @@ dependencies = [ [[package]] name = "figcraft-cli" -version = "0.11.2" +version = "0.12.1" dependencies = [ "figcraft-doc", "figcraft-engine", @@ -1587,7 +1635,7 @@ dependencies = [ [[package]] name = "figcraft-collab" -version = "0.11.2" +version = "0.12.1" dependencies = [ "base64 0.22.1", "chrono", @@ -1603,7 +1651,7 @@ dependencies = [ [[package]] name = "figcraft-collab-net" -version = "0.11.2" +version = "0.12.1" dependencies = [ "base64 0.22.1", "figcraft-collab", @@ -1626,7 +1674,7 @@ dependencies = [ [[package]] name = "figcraft-color" -version = "0.11.2" +version = "0.12.1" dependencies = [ "kurbo", "serde", @@ -1635,7 +1683,7 @@ dependencies = [ [[package]] name = "figcraft-doc" -version = "0.11.2" +version = "0.12.1" dependencies = [ "figcraft-color", "figcraft-geom", @@ -1647,7 +1695,7 @@ dependencies = [ [[package]] name = "figcraft-engine" -version = "0.11.2" +version = "0.12.1" dependencies = [ "base64 0.22.1", "craft-docpath", @@ -1679,7 +1727,7 @@ dependencies = [ [[package]] name = "figcraft-format" -version = "0.11.2" +version = "0.12.1" dependencies = [ "figcraft-doc", "figcraft-pathops", @@ -1695,7 +1743,7 @@ dependencies = [ [[package]] name = "figcraft-geom" -version = "0.11.2" +version = "0.12.1" dependencies = [ "kurbo", "serde", @@ -1703,7 +1751,7 @@ dependencies = [ [[package]] name = "figcraft-layout" -version = "0.11.2" +version = "0.12.1" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1711,7 +1759,7 @@ dependencies = [ [[package]] name = "figcraft-mcp" -version = "0.11.2" +version = "0.12.1" dependencies = [ "base64 0.22.1", "figcraft-collab-net", @@ -1727,7 +1775,7 @@ dependencies = [ [[package]] name = "figcraft-pathops" -version = "0.11.2" +version = "0.12.1" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1736,7 +1784,7 @@ dependencies = [ [[package]] name = "figcraft-pdf" -version = "0.11.2" +version = "0.12.1" dependencies = [ "figcraft-color", "figcraft-doc", @@ -1753,7 +1801,7 @@ dependencies = [ [[package]] name = "figcraft-plugin" -version = "0.11.2" +version = "0.12.1" dependencies = [ "figcraft-color", "serde", @@ -1766,7 +1814,7 @@ dependencies = [ [[package]] name = "figcraft-render" -version = "0.11.2" +version = "0.12.1" dependencies = [ "crc32fast", "figcraft-color", @@ -1784,7 +1832,7 @@ dependencies = [ [[package]] name = "figcraft-server" -version = "0.11.2" +version = "0.12.1" dependencies = [ "crc32fast", "ctrlc", @@ -1808,7 +1856,7 @@ dependencies = [ [[package]] name = "figcraft-svg" -version = "0.11.2" +version = "0.12.1" dependencies = [ "base64 0.22.1", "figcraft-color", @@ -1824,7 +1872,7 @@ dependencies = [ [[package]] name = "figcraft-text" -version = "0.11.2" +version = "0.12.1" dependencies = [ "core-text", "figcraft-doc", @@ -1840,10 +1888,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 +1916,7 @@ dependencies = [ [[package]] name = "figcraft-vectordrawable" -version = "0.11.2" +version = "0.12.1" dependencies = [ "figcraft-color", "figcraft-doc", @@ -1881,7 +1930,7 @@ dependencies = [ [[package]] name = "figcraft-web" -version = "0.11.2" +version = "0.12.1" dependencies = [ "base64 0.22.1", "eframe", @@ -1913,6 +1962,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 +2459,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 +2472,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 +2588,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 +2610,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" @@ -2731,7 +2885,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 +2998,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" @@ -3684,6 +3841,8 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ + "serde_core", + "writeable", "zerovec", ] @@ -4681,6 +4840,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" @@ -4855,6 +5026,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -5165,6 +5337,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" @@ -6152,11 +6330,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 +6422,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.11.2" +version = "0.12.1" dependencies = [ "figcraft-engine", "serde_json", @@ -6424,8 +6611,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", + "litemap", + "serde_core", "yoke", "zerofrom", + "zerovec", ] [[package]] @@ -6434,6 +6624,7 @@ version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", diff --git a/Cargo.toml b/Cargo.toml index f6fca285..b4977989 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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,15 @@ 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" } # foundation kurbo = { version = "0.13", features = ["serde"] } @@ -100,6 +102,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 +133,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 +165,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" } diff --git a/apps/figcraft-server/src/codes_tests.rs b/apps/figcraft-server/src/codes_tests.rs index 1e440a32..1e58f7c2 100644 --- a/apps/figcraft-server/src/codes_tests.rs +++ b/apps/figcraft-server/src/codes_tests.rs @@ -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"); diff --git a/apps/figcraft-server/src/library_tests.rs b/apps/figcraft-server/src/library_tests.rs index f4703f20..5dd45435 100644 --- a/apps/figcraft-server/src/library_tests.rs +++ b/apps/figcraft-server/src/library_tests.rs @@ -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. diff --git a/apps/figcraft-server/src/store/backend.rs b/apps/figcraft-server/src/store/backend.rs index af9b8bbb..95fd8948 100644 --- a/apps/figcraft-server/src/store/backend.rs +++ b/apps/figcraft-server/src/store/backend.rs @@ -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 { diff --git a/apps/figcraft-server/src/store/files.rs b/apps/figcraft-server/src/store/files.rs index 53ae9c87..565916ed 100644 --- a/apps/figcraft-server/src/store/files.rs +++ b/apps/figcraft-server/src/store/files.rs @@ -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; } diff --git a/apps/figcraft-server/src/store/journal.rs b/apps/figcraft-server/src/store/journal.rs index e3983543..5f8904fc 100644 --- a/apps/figcraft-server/src/store/journal.rs +++ b/apps/figcraft-server/src/store/journal.rs @@ -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 diff --git a/apps/figcraft-server/src/store/library.rs b/apps/figcraft-server/src/store/library.rs index adb592db..55ca50ad 100644 --- a/apps/figcraft-server/src/store/library.rs +++ b/apps/figcraft-server/src/store/library.rs @@ -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 diff --git a/apps/figcraft-server/src/store/links.rs b/apps/figcraft-server/src/store/links.rs index ad30f458..6c279219 100644 --- a/apps/figcraft-server/src/store/links.rs +++ b/apps/figcraft-server/src/store/links.rs @@ -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. diff --git a/clippy.toml b/clippy.toml index 724e05c5..305f94bd 100644 --- a/clippy.toml +++ b/clippy.toml @@ -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 diff --git a/crates/collab-net/src/hub/backend.rs b/crates/collab-net/src/hub/backend.rs index 04c5363d..e594b0f1 100644 --- a/crates/collab-net/src/hub/backend.rs +++ b/crates/collab-net/src/hub/backend.rs @@ -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)] diff --git a/crates/collab-net/src/hub/conn.rs b/crates/collab-net/src/hub/conn.rs index 109dfed9..895218c4 100644 --- a/crates/collab-net/src/hub/conn.rs +++ b/crates/collab-net/src/hub/conn.rs @@ -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); diff --git a/crates/collab-net/src/hub/hosting.rs b/crates/collab-net/src/hub/hosting.rs index 8588e56a..df8fd975 100644 --- a/crates/collab-net/src/hub/hosting.rs +++ b/crates/collab-net/src/hub/hosting.rs @@ -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 { diff --git a/crates/collab-net/src/hub/limits.rs b/crates/collab-net/src/hub/limits.rs index 333ff165..66369790 100644 --- a/crates/collab-net/src/hub/limits.rs +++ b/crates/collab-net/src/hub/limits.rs @@ -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)] diff --git a/crates/collab-net/src/hub/queue.rs b/crates/collab-net/src/hub/queue.rs index b6bce39e..1c6a5dfc 100644 --- a/crates/collab-net/src/hub/queue.rs +++ b/crates/collab-net/src/hub/queue.rs @@ -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. diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 2a284b05..3146047a 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -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; diff --git a/crates/engine/src/assets/components.rs b/crates/engine/src/assets/components.rs index 3932efff..3192c3f1 100644 --- a/crates/engine/src/assets/components.rs +++ b/crates/engine/src/assets/components.rs @@ -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; diff --git a/crates/engine/src/assets/components/diff.rs b/crates/engine/src/assets/components/diff.rs index bf742fed..41c1a424 100644 --- a/crates/engine/src/assets/components/diff.rs +++ b/crates/engine/src/assets/components/diff.rs @@ -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). diff --git a/crates/engine/src/assets/components/guard.rs b/crates/engine/src/assets/components/guard.rs index b320b7c4..0424beff 100644 --- a/crates/engine/src/assets/components/guard.rs +++ b/crates/engine/src/assets/components/guard.rs @@ -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 diff --git a/crates/engine/src/assets/components/instances.rs b/crates/engine/src/assets/components/instances.rs index 9fd4e2c2..ac7ff4ec 100644 --- a/crates/engine/src/assets/components/instances.rs +++ b/crates/engine/src/assets/components/instances.rs @@ -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 { diff --git a/crates/engine/src/assets/components/patch.rs b/crates/engine/src/assets/components/patch.rs index 8c61f0b8..8ae01bc1 100644 --- a/crates/engine/src/assets/components/patch.rs +++ b/crates/engine/src/assets/components/patch.rs @@ -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 = 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 = 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) { + 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). diff --git a/crates/engine/src/assets/components/rebase.rs b/crates/engine/src/assets/components/rebase.rs index 748a99d3..ea9cb6f3 100644 --- a/crates/engine/src/assets/components/rebase.rs +++ b/crates/engine/src/assets/components/rebase.rs @@ -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> { diff --git a/crates/engine/src/assets/components/sync.rs b/crates/engine/src/assets/components/sync.rs index c502322d..e81bf437 100644 --- a/crates/engine/src/assets/components/sync.rs +++ b/crates/engine/src/assets/components/sync.rs @@ -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> = spare; - let mut by_source: BTreeMap<(NodeId, NodeId), NodeId> = BTreeMap::new(); - let mut by_src: BTreeMap> = 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> = None; let mut main_paths: Option> = 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, + old: &BTreeMap>, + remeasure: &mut Vec, +) { + 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, old: &BTreeMap>, used: &BTreeSet, depth: usize) { + let unused: BTreeMap> = + 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> = 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>) { + 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) { + 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) -> BTreeMap { + 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>, BTreeMap<(NodeId, NodeId), NodeId>, BTreeMap>); + +/// 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>) -> 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> = 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) -> Node { // Root: follows the main except its own placement, name, size and overrides. let order: Vec = 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> = - 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> = 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 diff --git a/crates/engine/src/assets/components/tests.rs b/crates/engine/src/assets/components/tests.rs index 30254651..82aabc63 100644 --- a/crates/engine/src/assets/components/tests.rs +++ b/crates/engine/src/assets/components/tests.rs @@ -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); } diff --git a/crates/engine/src/assets/components/variants.rs b/crates/engine/src/assets/components/variants.rs index 40ac23ea..bc0d25b7 100644 --- a/crates/engine/src/assets/components/variants.rs +++ b/crates/engine/src/assets/components/variants.rs @@ -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). diff --git a/crates/engine/src/assets/library.rs b/crates/engine/src/assets/library.rs index 56b0fc07..24699ef2 100644 --- a/crates/engine/src/assets/library.rs +++ b/crates/engine/src/assets/library.rs @@ -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; diff --git a/crates/engine/src/assets/library/tests.rs b/crates/engine/src/assets/library/tests.rs index 27c9e616..c8ef2e49 100644 --- a/crates/engine/src/assets/library/tests.rs +++ b/crates/engine/src/assets/library/tests.rs @@ -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 = 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 = 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")); diff --git a/crates/engine/src/assets/library_swap.rs b/crates/engine/src/assets/library_swap.rs index 1220d24c..1dfc1691 100644 --- a/crates/engine/src/assets/library_swap.rs +++ b/crates/engine/src/assets/library_swap.rs @@ -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 { + 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 { let m = &to.manifest; @@ -64,14 +93,7 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re }) .collect(); let records: Vec = 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 = 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 = doc.nodes().filter(|n| outside(doc, n.id)).flat_map(|n| style_links(n).cloned()).collect(); let style_recs: Vec = doc.libraries.styles.iter().filter(|r| r.library == from && linked.contains(&r.local)).cloned().collect(); - let mut style_keys: BTreeMap = 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 = style_keys.iter().filter_map(|(a, k)| imported.get(k).map(|b| (a.clone(), b.clone()))).collect(); out.styles = styles.len(); diff --git a/crates/engine/src/assets/playground.rs b/crates/engine/src/assets/playground.rs index 90a627b0..5c05c3b9 100644 --- a/crates/engine/src/assets/playground.rs +++ b/crates/engine/src/assets/playground.rs @@ -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 diff --git a/crates/engine/src/assets/style_vars.rs b/crates/engine/src/assets/style_vars.rs index b2e5c182..7fb30fd0 100644 --- a/crates/engine/src/assets/style_vars.rs +++ b/crates/engine/src/assets/style_vars.rs @@ -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}; diff --git a/crates/engine/src/automation/inspect.rs b/crates/engine/src/automation/inspect.rs index 7dccd706..886d2d85 100644 --- a/crates/engine/src/automation/inspect.rs +++ b/crates/engine/src/automation/inspect.rs @@ -1,6 +1,6 @@ //! Document inspection as JSON, so agents can verify their work without screenshots. -use figcraft_doc::*; +use figcraft_doc::{Document, Node, NodeId, NodeKind, StrokeJoin, TextData, TextDecoration}; use serde_json::{Value, json}; use crate::Session; @@ -8,7 +8,6 @@ use crate::Session; /// A node and (to `depth`) its children, with the properties a designer would check. pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value { let Some(n) = doc.node(id) else { return Value::Null }; - let c = doc.frame_relative(id).as_coeffs(); let mut v = json!({ "id": n.id, "name": n.name, @@ -16,226 +15,259 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value { }); let Some(o) = v.as_object_mut() else { return v }; if !n.is_page() { - o.insert("x".into(), json!(round(c[4]))); - o.insert("y".into(), json!(round(c[5]))); - o.insert("width".into(), json!(round(n.width))); - o.insert("height".into(), json!(round(n.height))); - let rot = figcraft_geom::rotation_degrees(n.transform); - if rot.abs() > 1e-6 { - o.insert("rotation".into(), json!(round(rot))); - } - if let Some(b) = doc.world_bounds(id) { - o.insert("absoluteBounds".into(), json!({"x": round(b.x0), "y": round(b.y0), "width": round(b.width()), "height": round(b.height())})); - } - if !n.visible { - o.insert("visible".into(), json!(false)); - } - if n.locked { - o.insert("locked".into(), json!(true)); - } - if n.opacity < 1.0 { - o.insert("opacity".into(), json!(n.opacity)); - } - if !matches!(n.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) { - o.insert("blend".into(), json!(n.blend)); - } - if !n.fills.is_empty() { - o.insert("fills".into(), paints(&n.fills)); - } - if !n.strokes.is_empty() { - o.insert("strokes".into(), paints(&n.strokes)); - o.insert("strokeWeight".into(), json!(n.stroke.weight)); - o.insert("strokeAlign".into(), json!(n.stroke.align)); - if n.stroke.sides.is_some() { - o.insert("strokeSides".into(), json!(n.stroke.sides)); - } - if !n.stroke.dashes.is_empty() { - o.insert("dashes".into(), json!(n.stroke.dashes)); - } - if n.stroke.join != StrokeJoin::Miter { - o.insert("strokeJoin".into(), json!(n.stroke.join)); - } else if n.stroke.miter_limit != 4.0 { - o.insert("miterAngle".into(), json!((n.stroke.miter_angle() * 100.0).round() / 100.0)); - } - if let Some(p) = n.stroke.profile() { - let points = crate::cmd::props::width_points_json(Some(p)); - o.insert("widthProfile".into(), json!({"preset": p.preset().map(|x| x.id()), "points": points})); - } - if let Some(b) = &n.stroke.brush { - o.insert("brush".into(), camel(serde_json::to_value(b).unwrap_or(Value::Null))); - } - } - if !n.effects.is_empty() { - o.insert("effects".into(), camel(serde_json::to_value(&n.effects).unwrap_or(Value::Null))); - } - if n.corners.radii.iter().any(|r| *r > 0.0) { - o.insert( - "cornerRadius".into(), - match n.corners.uniform() { - Some(r) => json!(r), - None => json!(n.corners.radii), - }, - ); - if n.corners.smoothing > 0.0 { - o.insert("cornerSmoothing".into(), json!(n.corners.smoothing)); - } - } - if let Some(l) = &n.layout { - o.insert("layout".into(), camel(serde_json::to_value(l).unwrap_or(Value::Null))); - } - let parent_al = n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()); - if n.layout.is_some() || parent_al || matches!(n.kind, NodeKind::Text(_)) { - o.insert("sizing".into(), json!({"horizontal": n.child_layout.sizing_h, "vertical": n.child_layout.sizing_v})); - } - let cl = &n.child_layout; - if cl.absolute { - o.insert("absolute".into(), json!(true)); - } - for (k, v) in [("minWidth", cl.min_w), ("maxWidth", cl.max_w), ("minHeight", cl.min_h), ("maxHeight", cl.max_h)] { - if let Some(v) = v { - o.insert(k.into(), json!(v)); - } - } - if let Some(cell) = parent_al - .then(|| n.parent.and_then(|p| figcraft_layout::grid_geometry(doc, p))) - .flatten() - .and_then(|g| g.cells.into_iter().find(|c| c.id == n.id)) - { - o.insert( - "gridCell".into(), - json!({"column": cell.column, "row": cell.row, "columnSpan": cell.column_span, "rowSpan": cell.row_span, - "anchored": cl.grid_column.is_some() || cl.grid_row.is_some(), "alignH": cl.grid_align_h, "alignV": cl.grid_align_v}), - ); - } - if (!parent_al || cl.absolute) && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.kind.is_frame_like()) { - o.insert("constraints".into(), serde_json::to_value(n.constraints).unwrap_or(Value::Null)); - } - if n.kind.is_frame_like() { - o.insert("clip".into(), json!(n.clip)); - } - if !n.dev_status.is_none() { - o.insert("devStatus".into(), json!(n.dev_status.id())); - } - if n.dev_changed { - o.insert("devChanged".into(), json!(true)); - } - if !n.dev_note.is_empty() { - o.insert("devNote".into(), json!(n.dev_note)); - } - if let NodeKind::Section { contents_hidden } = n.kind { - o.insert("sectionContentsHidden".into(), json!(contents_hidden)); - } - if n.is_mask { - o.insert("isMask".into(), json!(true)); - o.insert("maskType".into(), json!(n.mask_type.id())); - } - if !n.grids.is_empty() { - o.insert("grids".into(), camel(serde_json::to_value(&n.grids).unwrap_or(Value::Null))); - } - if !n.exports.is_empty() { - o.insert("exports".into(), serde_json::to_value(&n.exports).unwrap_or(Value::Null)); - } - if let Some(b) = &n.board { - o.insert("board".into(), serde_json::to_value(b).unwrap_or(Value::Null)); - } - if !n.annotations.is_empty() { - o.insert("annotations".into(), serde_json::to_value(&n.annotations).unwrap_or(Value::Null)); - } - if !n.code_links.is_empty() { - o.insert("codeLinks".into(), camel(serde_json::to_value(&n.code_links).unwrap_or(Value::Null))); - } - if !n.measurements.is_empty() { - o.insert("measurements".into(), serde_json::to_value(&n.measurements).unwrap_or(Value::Null)); - } - let dev = crate::dev_resources::list_json(doc, n.id); - if dev.as_array().is_some_and(|a| !a.is_empty()) { - o.insert("devResources".into(), dev); - } - if !n.interactions.is_empty() { - o.insert("interactions".into(), serde_json::to_value(&n.interactions).unwrap_or(Value::Null)); - } - if !n.styles.is_empty() { - o.insert("styles".into(), serde_json::to_value(&n.styles).unwrap_or(Value::Null)); - } - if !n.bound.is_empty() { - o.insert("boundVariables".into(), json!(n.bound)); - } - if !n.overrides.is_empty() { - o.insert("overrides".into(), json!(n.overrides)); - } - if let Some(slot) = crate::components::slot_name(doc, n.id) { - o.insert("slot".into(), json!(slot)); - } - // An operand combining by an operation of its own. - if let Some(op) = n.bool_op.filter(|_| crate::booleans::group_of(doc, n.id).is_some()) { - o.insert("operandOperation".into(), json!(op)); - } + base_props(doc, n, id, o); + paint_props(n, o); + layout_props(doc, n, o); + dev_props(doc, n, o); } // Layer data written with `node.set {props: {pluginData}}` (pages too). if !n.plugin_data.is_empty() { o.insert("pluginData".into(), json!(n.plugin_data)); } match &n.kind { - NodeKind::Text(t) => { - o.insert("characters".into(), json!(t.characters)); - o.insert("fontFamily".into(), json!(t.style.font_family)); - o.insert("fontStyle".into(), json!(t.style.font_style)); - o.insert("fontSize".into(), json!(t.style.font_size)); - if !t.style.variations.is_empty() { - o.insert("fontVariations".into(), json!(t.style.variations)); - } - o.insert("lineHeight".into(), json!(t.style.line_height.label())); - o.insert("letterSpacing".into(), json!(t.style.letter_spacing.label())); - o.insert("textAlign".into(), json!(t.align_h)); - o.insert("textAutoResize".into(), json!(t.auto_resize)); - if t.max_lines > 0 { - o.insert("maxLines".into(), json!(t.max_lines)); - } - if let Some(tp) = &t.path { - o.insert("textPath".into(), json!(tp)); - } - if t.style.decoration != TextDecoration::None { - o.insert("textDecoration".into(), json!(t.style.decoration)); - } - if let Some(l) = t.style.decoration_look.as_deref() { - o.insert("textDecorationStyle".into(), json!(l.style)); - o.insert("textDecorationThickness".into(), json!(l.thickness.label())); - o.insert("textDecorationOffset".into(), json!(l.offset.label())); - o.insert("textDecorationSkipInk".into(), json!(l.skip_ink)); - o.insert("textDecorationColor".into(), l.color.map_or(json!("auto"), |c| json!(format!("#{}", c.to_hex())))); - } - // Lists: one style for the layer, or each paragraph's type, nesting level and start. - if t.paragraphs.is_empty() { - if t.list != figcraft_doc::ListStyle::None { - o.insert("listStyle".into(), json!(t.list)); - } - } else { - o.insert("lineTypes".into(), t.paragraphs.iter().map(|p| json!(p.list)).collect()); - o.insert("lineIndentations".into(), t.paragraphs.iter().map(|p| json!(p.indent)).collect()); - if t.paragraphs.iter().any(|p| p.start_offset > 0) { - o.insert("listStartOffsets".into(), t.paragraphs.iter().map(|p| json!(p.start_offset)).collect()); - } - } - if t.list_spacing != 0.0 { - o.insert("listSpacing".into(), json!(t.list_spacing)); - } - for (k, on) in [("hangingPunctuation", t.hanging_punctuation), ("hangingList", t.hanging_list)] { - if on { - o.insert(k.into(), json!(true)); - } - } - if t.leading_trim != figcraft_doc::LeadingTrim::None { - o.insert("leadingTrim".into(), json!(t.leading_trim)); - } - if !t.runs.is_empty() { - o.insert("styledRuns".into(), serde_json::to_value(&t.runs).unwrap_or(Value::Null)); - } - // Chars linked to text styles: [start, end, style id] (the layer as a whole: `styles.text`). - let linked: Vec = t.spans().into_iter().filter_map(|(a, b, s)| s.style_id.as_ref().map(|id| json!([a, b, id]))).collect(); - if !linked.is_empty() { - o.insert("textStyleRanges".into(), Value::Array(linked)); - } + NodeKind::Text(t) => text_props(t, o), + _ => kind_props(doc, n, o), + } + if !n.children.is_empty() { + if depth > 0 { + o.insert("children".into(), Value::Array(n.children.iter().map(|c| node_json(doc, *c, depth - 1)).collect())); + } else { + o.insert("childCount".into(), json!(n.children.len())); } + } + v +} + +fn base_props(doc: &Document, n: &Node, id: NodeId, o: &mut serde_json::Map) { + let c = doc.frame_relative(id).as_coeffs(); + o.insert("x".into(), json!(round(c[4]))); + o.insert("y".into(), json!(round(c[5]))); + o.insert("width".into(), json!(round(n.width))); + o.insert("height".into(), json!(round(n.height))); + let rot = figcraft_geom::rotation_degrees(n.transform); + if rot.abs() > 1e-6 { + o.insert("rotation".into(), json!(round(rot))); + } + if let Some(b) = doc.world_bounds(id) { + o.insert("absoluteBounds".into(), json!({"x": round(b.x0), "y": round(b.y0), "width": round(b.width()), "height": round(b.height())})); + } + if !n.visible { + o.insert("visible".into(), json!(false)); + } + if n.locked { + o.insert("locked".into(), json!(true)); + } + if n.opacity < 1.0 { + o.insert("opacity".into(), json!(n.opacity)); + } + if !matches!(n.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) { + o.insert("blend".into(), json!(n.blend)); + } +} + +fn paint_props(n: &Node, o: &mut serde_json::Map) { + if !n.fills.is_empty() { + o.insert("fills".into(), paints(&n.fills)); + } + if !n.strokes.is_empty() { + o.insert("strokes".into(), paints(&n.strokes)); + o.insert("strokeWeight".into(), json!(n.stroke.weight)); + o.insert("strokeAlign".into(), json!(n.stroke.align)); + if n.stroke.sides.is_some() { + o.insert("strokeSides".into(), json!(n.stroke.sides)); + } + if !n.stroke.dashes.is_empty() { + o.insert("dashes".into(), json!(n.stroke.dashes)); + } + if n.stroke.join != StrokeJoin::Miter { + o.insert("strokeJoin".into(), json!(n.stroke.join)); + } else if n.stroke.miter_limit != 4.0 { + o.insert("miterAngle".into(), json!((n.stroke.miter_angle() * 100.0).round() / 100.0)); + } + if let Some(p) = n.stroke.profile() { + let points = crate::cmd::props::width_points_json(Some(p)); + o.insert("widthProfile".into(), json!({"preset": p.preset().map(|x| x.id()), "points": points})); + } + if let Some(b) = &n.stroke.brush { + o.insert("brush".into(), camel(serde_json::to_value(b).unwrap_or(Value::Null))); + } + } + if !n.effects.is_empty() { + o.insert("effects".into(), camel(serde_json::to_value(&n.effects).unwrap_or(Value::Null))); + } + if n.corners.radii.iter().any(|r| *r > 0.0) { + o.insert( + "cornerRadius".into(), + match n.corners.uniform() { + Some(r) => json!(r), + None => json!(n.corners.radii), + }, + ); + if n.corners.smoothing > 0.0 { + o.insert("cornerSmoothing".into(), json!(n.corners.smoothing)); + } + } +} + +fn layout_props(doc: &Document, n: &Node, o: &mut serde_json::Map) { + if let Some(l) = &n.layout { + o.insert("layout".into(), camel(serde_json::to_value(l).unwrap_or(Value::Null))); + } + let parent_al = n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()); + if n.layout.is_some() || parent_al || matches!(n.kind, NodeKind::Text(_)) { + o.insert("sizing".into(), json!({"horizontal": n.child_layout.sizing_h, "vertical": n.child_layout.sizing_v})); + } + let cl = &n.child_layout; + if cl.absolute { + o.insert("absolute".into(), json!(true)); + } + for (k, v) in [("minWidth", cl.min_w), ("maxWidth", cl.max_w), ("minHeight", cl.min_h), ("maxHeight", cl.max_h)] { + if let Some(v) = v { + o.insert(k.into(), json!(v)); + } + } + if let Some(cell) = parent_al + .then(|| n.parent.and_then(|p| figcraft_layout::grid_geometry(doc, p))) + .flatten() + .and_then(|g| g.cells.into_iter().find(|c| c.id == n.id)) + { + o.insert( + "gridCell".into(), + json!({"column": cell.column, "row": cell.row, "columnSpan": cell.column_span, "rowSpan": cell.row_span, + "anchored": cl.grid_column.is_some() || cl.grid_row.is_some(), "alignH": cl.grid_align_h, "alignV": cl.grid_align_v}), + ); + } + if (!parent_al || cl.absolute) && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.kind.is_frame_like()) { + o.insert("constraints".into(), serde_json::to_value(n.constraints).unwrap_or(Value::Null)); + } + if n.kind.is_frame_like() { + o.insert("clip".into(), json!(n.clip)); + } +} + +fn dev_props(doc: &Document, n: &Node, o: &mut serde_json::Map) { + if !n.dev_status.is_none() { + o.insert("devStatus".into(), json!(n.dev_status.id())); + } + if n.dev_changed { + o.insert("devChanged".into(), json!(true)); + } + if !n.dev_note.is_empty() { + o.insert("devNote".into(), json!(n.dev_note)); + } + if let NodeKind::Section { contents_hidden } = n.kind { + o.insert("sectionContentsHidden".into(), json!(contents_hidden)); + } + if n.is_mask { + o.insert("isMask".into(), json!(true)); + o.insert("maskType".into(), json!(n.mask_type.id())); + } + if !n.grids.is_empty() { + o.insert("grids".into(), camel(serde_json::to_value(&n.grids).unwrap_or(Value::Null))); + } + if !n.exports.is_empty() { + o.insert("exports".into(), serde_json::to_value(&n.exports).unwrap_or(Value::Null)); + } + if let Some(b) = &n.board { + o.insert("board".into(), serde_json::to_value(b).unwrap_or(Value::Null)); + } + if !n.annotations.is_empty() { + o.insert("annotations".into(), serde_json::to_value(&n.annotations).unwrap_or(Value::Null)); + } + if !n.code_links.is_empty() { + o.insert("codeLinks".into(), camel(serde_json::to_value(&n.code_links).unwrap_or(Value::Null))); + } + if !n.measurements.is_empty() { + o.insert("measurements".into(), serde_json::to_value(&n.measurements).unwrap_or(Value::Null)); + } + let dev = crate::dev_resources::list_json(doc, n.id); + if dev.as_array().is_some_and(|a| !a.is_empty()) { + o.insert("devResources".into(), dev); + } + if !n.interactions.is_empty() { + o.insert("interactions".into(), serde_json::to_value(&n.interactions).unwrap_or(Value::Null)); + } + if !n.styles.is_empty() { + o.insert("styles".into(), serde_json::to_value(&n.styles).unwrap_or(Value::Null)); + } + if !n.bound.is_empty() { + o.insert("boundVariables".into(), json!(n.bound)); + } + if !n.overrides.is_empty() { + o.insert("overrides".into(), json!(n.overrides)); + } + if let Some(slot) = crate::components::slot_name(doc, n.id) { + o.insert("slot".into(), json!(slot)); + } + // An operand combining by an operation of its own. + if let Some(op) = n.bool_op.filter(|_| crate::booleans::group_of(doc, n.id).is_some()) { + o.insert("operandOperation".into(), json!(op)); + } +} + +fn text_props(t: &TextData, o: &mut serde_json::Map) { + o.insert("characters".into(), json!(t.characters)); + o.insert("fontFamily".into(), json!(t.style.font_family)); + o.insert("fontStyle".into(), json!(t.style.font_style)); + o.insert("fontSize".into(), json!(t.style.font_size)); + if !t.style.variations.is_empty() { + o.insert("fontVariations".into(), json!(t.style.variations)); + } + o.insert("lineHeight".into(), json!(t.style.line_height.label())); + o.insert("letterSpacing".into(), json!(t.style.letter_spacing.label())); + o.insert("textAlign".into(), json!(t.align_h)); + o.insert("textAutoResize".into(), json!(t.auto_resize)); + if t.max_lines > 0 { + o.insert("maxLines".into(), json!(t.max_lines)); + } + if let Some(tp) = &t.path { + o.insert("textPath".into(), json!(tp)); + } + if t.style.decoration != TextDecoration::None { + o.insert("textDecoration".into(), json!(t.style.decoration)); + } + if let Some(l) = t.style.decoration_look.as_deref() { + o.insert("textDecorationStyle".into(), json!(l.style)); + o.insert("textDecorationThickness".into(), json!(l.thickness.label())); + o.insert("textDecorationOffset".into(), json!(l.offset.label())); + o.insert("textDecorationSkipInk".into(), json!(l.skip_ink)); + o.insert("textDecorationColor".into(), l.color.map_or(json!("auto"), |c| json!(format!("#{}", c.to_hex())))); + } + // Lists: one style for the layer, or each paragraph's type, nesting level and start. + if t.paragraphs.is_empty() { + if t.list != figcraft_doc::ListStyle::None { + o.insert("listStyle".into(), json!(t.list)); + } + } else { + o.insert("lineTypes".into(), t.paragraphs.iter().map(|p| json!(p.list)).collect()); + o.insert("lineIndentations".into(), t.paragraphs.iter().map(|p| json!(p.indent)).collect()); + if t.paragraphs.iter().any(|p| p.start_offset > 0) { + o.insert("listStartOffsets".into(), t.paragraphs.iter().map(|p| json!(p.start_offset)).collect()); + } + } + if t.list_spacing != 0.0 { + o.insert("listSpacing".into(), json!(t.list_spacing)); + } + for (k, on) in [("hangingPunctuation", t.hanging_punctuation), ("hangingList", t.hanging_list)] { + if on { + o.insert(k.into(), json!(true)); + } + } + if t.leading_trim != figcraft_doc::LeadingTrim::None { + o.insert("leadingTrim".into(), json!(t.leading_trim)); + } + if !t.runs.is_empty() { + o.insert("styledRuns".into(), serde_json::to_value(&t.runs).unwrap_or(Value::Null)); + } + // Chars linked to text styles: [start, end, style id] (the layer as a whole: `styles.text`). + let linked: Vec = t.spans().into_iter().filter_map(|(a, b, s)| s.style_id.as_ref().map(|id| json!([a, b, id]))).collect(); + if !linked.is_empty() { + o.insert("textStyleRanges".into(), Value::Array(linked)); + } +} + +fn kind_props(doc: &Document, n: &Node, o: &mut serde_json::Map) { + match &n.kind { NodeKind::Ellipse { start, sweep, inner } if (*sweep).abs() < std::f64::consts::TAU - 1e-9 || *inner > 0.0 => { o.insert("arc".into(), json!({"start": start.to_degrees(), "sweep": sweep.to_degrees(), "inner": inner})); } @@ -276,14 +308,6 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value { } _ => {} } - if !n.children.is_empty() { - if depth > 0 { - o.insert("children".into(), Value::Array(n.children.iter().map(|c| node_json(doc, *c, depth - 1)).collect())); - } else { - o.insert("childCount".into(), json!(n.children.len())); - } - } - v } fn round(v: f64) -> f64 { diff --git a/crates/engine/src/boards/board/connectors.rs b/crates/engine/src/boards/board/connectors.rs index 753550e5..14760331 100644 --- a/crates/engine/src/boards/board/connectors.rs +++ b/crates/engine/src/boards/board/connectors.rs @@ -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 { diff --git a/crates/engine/src/boards/board/derived.rs b/crates/engine/src/boards/board/derived.rs index ac73df1e..9899632f 100644 --- a/crates/engine/src/boards/board/derived.rs +++ b/crates/engine/src/boards/board/derived.rs @@ -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). diff --git a/crates/engine/src/boards/board/edit.rs b/crates/engine/src/boards/board/edit.rs index 1770852c..9456256e 100644 --- a/crates/engine/src/boards/board/edit.rs +++ b/crates/engine/src/boards/board/edit.rs @@ -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 { diff --git a/crates/engine/src/boards/board/items.rs b/crates/engine/src/boards/board/items.rs index 6d96d34b..b76fc898 100644 --- a/crates/engine/src/boards/board/items.rs +++ b/crates/engine/src/boards/board/items.rs @@ -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( diff --git a/crates/engine/src/boards/board/tools.rs b/crates/engine/src/boards/board/tools.rs index 2dc904c9..cb3fc005 100644 --- a/crates/engine/src/boards/board/tools.rs +++ b/crates/engine/src/boards/board/tools.rs @@ -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. diff --git a/crates/engine/src/canvas/drop.rs b/crates/engine/src/canvas/drop.rs index d5e8b3cf..9e054630 100644 --- a/crates/engine/src/canvas/drop.rs +++ b/crates/engine/src/canvas/drop.rs @@ -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; diff --git a/crates/engine/src/canvas/shapebuilder.rs b/crates/engine/src/canvas/shapebuilder.rs index 99d59b45..74a4a9d1 100644 --- a/crates/engine/src/canvas/shapebuilder.rs +++ b/crates/engine/src/canvas/shapebuilder.rs @@ -21,14 +21,14 @@ use std::collections::BTreeSet; use std::sync::Arc; -use figcraft_doc::*; +use figcraft_doc::{Corners, Document, Node, NodeId, NodeKind, StrokeProps, VectorNetwork, WindingRule}; use figcraft_geom::kurbo::PathEl; use figcraft_geom::{Affine, BezPath, Point, Rect, Shape}; use serde_json::{Value, json}; use crate::cmd::CommandSpec; use crate::cmd::edit::{roots, z_sorted}; -use crate::params::*; +use crate::params::{f64_of, get, string, targets}; use crate::tools::{Cursor, Gesture, PointerEvent, PointerKind, ToolId}; use crate::{EngineError, Result, Session, bad, spec}; diff --git a/crates/engine/src/canvas/tools.rs b/crates/engine/src/canvas/tools.rs index fce14f7c..b1dc3eff 100644 --- a/crates/engine/src/canvas/tools.rs +++ b/crates/engine/src/canvas/tools.rs @@ -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; diff --git a/crates/engine/src/canvas/tools/click.rs b/crates/engine/src/canvas/tools/click.rs index 8f21aaca..a8291f0f 100644 --- a/crates/engine/src/canvas/tools/click.rs +++ b/crates/engine/src/canvas/tools/click.rs @@ -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, diff --git a/crates/engine/src/canvas/tools/drag.rs b/crates/engine/src/canvas/tools/drag.rs index 0d406673..d1db8f92 100644 --- a/crates/engine/src/canvas/tools/drag.rs +++ b/crates/engine/src/canvas/tools/drag.rs @@ -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<()> { diff --git a/crates/engine/src/canvas/tools/draw.rs b/crates/engine/src/canvas/tools/draw.rs index b594ae27..36a12bc2 100644 --- a/crates/engine/src/canvas/tools/draw.rs +++ b/crates/engine/src/canvas/tools/draw.rs @@ -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<()> { diff --git a/crates/engine/src/canvas/tools/handles.rs b/crates/engine/src/canvas/tools/handles.rs index fd765f66..7ccaca40 100644 --- a/crates/engine/src/canvas/tools/handles.rs +++ b/crates/engine/src/canvas/tools/handles.rs @@ -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). diff --git a/crates/engine/src/canvas/tools/keys.rs b/crates/engine/src/canvas/tools/keys.rs index 591f1479..a369463c 100644 --- a/crates/engine/src/canvas/tools/keys.rs +++ b/crates/engine/src/canvas/tools/keys.rs @@ -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 diff --git a/crates/engine/src/canvas/tools/pen.rs b/crates/engine/src/canvas/tools/pen.rs index 319c11b7..4857e83c 100644 --- a/crates/engine/src/canvas/tools/pen.rs +++ b/crates/engine/src/canvas/tools/pen.rs @@ -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 diff --git a/crates/engine/src/canvas/tools/pointer.rs b/crates/engine/src/canvas/tools/pointer.rs index 985e3326..550c4a66 100644 --- a/crates/engine/src/canvas/tools/pointer.rs +++ b/crates/engine/src/canvas/tools/pointer.rs @@ -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 diff --git a/crates/engine/src/canvas/vectoredit.rs b/crates/engine/src/canvas/vectoredit.rs index 03b22d22..fc263b4f 100644 --- a/crates/engine/src/canvas/vectoredit.rs +++ b/crates/engine/src/canvas/vectoredit.rs @@ -24,14 +24,14 @@ mod width_sides_tests; #[cfg(test)] mod width_tests; -use figcraft_doc::*; +use figcraft_doc::{Corners, Document, HandleMirroring, NodeId, NodeKind, StrokeAlign, StrokeCap, StrokeJoin, VectorNetwork, Vertex, WindingRule}; use figcraft_geom::{Affine, Point, Rect, Vec2}; use serde::Serialize; use serde_json::{Value, json}; use crate::cmd::CommandSpec; use crate::cmd::props::enum_of; -use crate::params::*; +use crate::params::{boolean, f64_of, get, id, num, num_or, req_num, string, targets}; use crate::{EngineError, Result, Session, bad, spec}; pub(crate) use pointer::VGesture; diff --git a/crates/engine/src/canvas/vectoredit/ops.rs b/crates/engine/src/canvas/vectoredit/ops.rs index 935f033a..8832e05d 100644 --- a/crates/engine/src/canvas/vectoredit/ops.rs +++ b/crates/engine/src/canvas/vectoredit/ops.rs @@ -2,7 +2,7 @@ //! deleting, cutting and joining. Pure functions on a [`VectorNetwork`]; the session wrappers in //! `vectoredit.rs` re-fit the node afterwards. -use figcraft_doc::*; +use figcraft_doc::{HandleMirroring, Region, Segment, VectorNetwork, Vertex, WindingRule}; use figcraft_geom::kurbo::{CubicBez, ParamCurve, ParamCurveNearest}; use figcraft_geom::{Affine, Point, Vec2}; diff --git a/crates/engine/src/canvas/vectoredit/pointer.rs b/crates/engine/src/canvas/vectoredit/pointer.rs index d977ce87..ebd8e2e3 100644 --- a/crates/engine/src/canvas/vectoredit/pointer.rs +++ b/crates/engine/src/canvas/vectoredit/pointer.rs @@ -5,7 +5,7 @@ use std::sync::Arc; -use figcraft_doc::*; +use figcraft_doc::{Document, HandleMirroring, NodeId, NodeKind, VectorNetwork, Vertex}; use figcraft_geom::{Affine, Point, Rect, Vec2}; use super::point_snap; @@ -510,20 +510,25 @@ impl Session { Ok(()) } + /// Another edited layer under the press (or, with the paint bucket, whose area it is in). + fn pressed_layer(doc: &Document, ve: &VectorEdit, ev: &PointerEvent, tol: f64) -> Option { + match hit_any(doc, ve, ev.p(), tol) { + Some((id, _)) => Some(id), + None if ve.tool == VTool::Paint => { + let inside = |id: NodeId| network(doc, id).is_some_and(|n| n.face_at(doc.world_transform(id).inverse() * ev.p()).is_some()); + (!inside(ve.id)).then(|| ve.others.iter().map(|o| o.id).find(|id| inside(*id))).flatten() + } + None => None, + } + } + /// Returns false when the click left edit mode (the caller then runs the normal tool). fn vector_down(&mut self, ve: &VectorEdit, ev: &PointerEvent) -> Result { let doc = self.doc()?.clone(); let tol = 6.0 / ev.zoom.max(1e-6); // A press on another edited layer's point, handle or segment (or, with the paint bucket, // area) makes it the active layer; a pen drawing a path keeps to its own layer. - let other = match hit_any(&doc, ve, ev.p(), tol) { - Some((id, _)) => Some(id), - None if ve.tool == VTool::Paint => { - let inside = |id: NodeId| network(&doc, id).is_some_and(|n| n.face_at(doc.world_transform(id).inverse() * ev.p()).is_some()); - (!inside(ve.id)).then(|| ve.others.iter().map(|o| o.id).find(|id| inside(*id))).flatten() - } - None => None, - }; + let other = Self::pressed_layer(&doc, ve, ev, tol); let switched; let ve = match other { Some(id) if id != ve.id && !(ve.tool == VTool::Pen && ve.anchor.is_some()) => { diff --git a/crates/engine/src/canvas/vectoredit/transform.rs b/crates/engine/src/canvas/vectoredit/transform.rs index 5efb5343..0f87a0bd 100644 --- a/crates/engine/src/canvas/vectoredit/transform.rs +++ b/crates/engine/src/canvas/vectoredit/transform.rs @@ -11,7 +11,7 @@ use serde_json::{Value, json}; use super::{VHit, VTool, VectorEdit, default_points, edit, editing, indices, network, ops, selected_points, selection_position}; use crate::cmd::CommandSpec; -use crate::params::*; +use crate::params::{boolean, f64_of, get, num, num_or, req_num}; use crate::tools::{Handle, handle_at_corners}; use crate::{Result, Session, bad, spec}; diff --git a/crates/engine/src/canvas/vectoredit/width.rs b/crates/engine/src/canvas/vectoredit/width.rs index ba51b043..13c7be0c 100644 --- a/crates/engine/src/canvas/vectoredit/width.rs +++ b/crates/engine/src/canvas/vectoredit/width.rs @@ -10,7 +10,7 @@ //! The points live in the stroke's [`WidthProfile`] and apply to every subpath (positions are //! fractions of each subpath's length). -use figcraft_doc::*; +use figcraft_doc::{Document, NodeId, WidthProfile, WidthSide}; use figcraft_geom::{Point, Vec2}; use figcraft_pathops::width::{Polyline, nearest, polylines}; use serde::Serialize; @@ -18,7 +18,7 @@ use serde_json::{Value, json}; use super::{editing, set_state}; use crate::cmd::CommandSpec; -use crate::params::*; +use crate::params::{get, num, req_index, req_num, string}; use crate::tools::{Gesture, PointerEvent}; use crate::{Result, Session, bad, spec}; diff --git a/crates/engine/src/canvas/vectoredit/width_sides_tests.rs b/crates/engine/src/canvas/vectoredit/width_sides_tests.rs index bf2dbeb0..937245ae 100644 --- a/crates/engine/src/canvas/vectoredit/width_sides_tests.rs +++ b/crates/engine/src/canvas/vectoredit/width_sides_tests.rs @@ -7,6 +7,7 @@ use serde_json::{Value, json}; use super::*; use crate::EngineError; use crate::tools::{PointerEvent, PointerKind}; +use figcraft_doc::{ExportFormat, WidthSide}; fn ex(s: &mut Session, c: &str, p: Value) -> Value { s.execute(c, &p).unwrap_or_else(|e| panic!("{c}: {e}")) diff --git a/crates/engine/src/canvas/vectoredit/width_tests.rs b/crates/engine/src/canvas/vectoredit/width_tests.rs index 54b4b0e2..8c89d4c6 100644 --- a/crates/engine/src/canvas/vectoredit/width_tests.rs +++ b/crates/engine/src/canvas/vectoredit/width_tests.rs @@ -6,6 +6,7 @@ use serde_json::{Value, json}; use super::*; use crate::EngineError; use crate::tools::{PointerEvent, PointerKind}; +use figcraft_doc::{ExportFormat, WidthPreset, WidthProfile, WidthSide}; fn ex(s: &mut Session, c: &str, p: Value) -> Value { s.execute(c, &p).unwrap_or_else(|e| panic!("{c}: {e}")) diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index 8f8e40d2..4806afb3 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -1,12 +1,12 @@ //! Arrange: align, distribute, tidy, z-order, flips, rotations and numeric transforms. -use figcraft_doc::*; +use figcraft_doc::{Document, EffectKind, LetterSpacing, LineHeight, Node, NodeId, NodeKind, Sizing, TextAutoResize, TextStyle}; use figcraft_geom::{Affine, Point, Rect}; use serde_json::{Value, json}; use super::edit::{roots, z_sorted}; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{boolean, get, num, num_or, req_id, req_num, req_targets, string}; use crate::{Result, Session, bad, spec}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/comment.rs b/crates/engine/src/cmd/comment.rs index 5ea9ee14..2dc5d372 100644 --- a/crates/engine/src/cmd/comment.rs +++ b/crates/engine/src/cmd/comment.rs @@ -6,11 +6,11 @@ use std::fmt::Write as _; -use figcraft_doc::*; +use figcraft_doc::{Comment, Document, Message, Node, NodeId, NodeKind, geom}; use serde_json::{Value, json}; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{boolean, get, id, id_of, num, req_index, req_num, req_string, string}; use crate::{Session, bad, spec}; /// The name comments are signed with until the user sets theirs. diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index 5abe79b8..0f59f41e 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -2,13 +2,18 @@ use std::collections::{BTreeMap, BTreeSet}; -use figcraft_doc::*; +use figcraft_doc::{Document, Node, NodeId, NodeKind, PropDef, VariableType, VariableValue}; use serde_json::{Value, json}; use super::edit::{roots, z_sorted}; use super::{CommandSpec, ok}; -use crate::components::*; -use crate::params::*; +use crate::components::{ + OVERRIDE_GROUPS, PROP_BINDING, SLOT, copy_group, create_instance, create_instance_at, default_props, default_variant, exposed_copies, + find_variant, inside_instance, instances_of, is_slot, keep_text_overrides, owning_component, owning_instance, prop_default_value, + rebase_instance, root_instance, set_boolean_default, slot_edited, slot_name, slots_of, takes_value, unlink_layers, variant_conflicts, + variant_name, variant_options, variant_props, variant_set, +}; +use crate::params::{boolean, get, id, id_of, num, req_id, req_num, req_string, req_targets, string, targets}; use crate::{EngineError, Result, Session, bad, spec}; mod instance; diff --git a/crates/engine/src/cmd/component/instance.rs b/crates/engine/src/cmd/component/instance.rs index ffc5ec1c..f6600c10 100644 --- a/crates/engine/src/cmd/component/instance.rs +++ b/crates/engine/src/cmd/component/instance.rs @@ -1,5 +1,10 @@ use super::props::{Owner, add_prop_for, delete_prop, prop_defs}; -use super::*; +use super::{ + BTreeMap, BTreeSet, Document, EngineError, NodeId, NodeKind, OVERRIDE_GROUPS, PROP_BINDING, PropDef, Result, SLOT, Session, Value, bad, boolean, + copy_group, create_instance_at, default_props, get, id_of, inside_instance, is_slot, json, keep_text_overrides, main_of, ok, owning_component, + owning_instance, rebase_instance, req_id, req_num, req_targets, root_instance, slot_edited, slot_name, slots_of, string, targets, unlink_layers, + variant_options, +}; pub(super) fn detach(s: &mut Session, p: &Value) -> Result { let cmd = "component.detach"; diff --git a/crates/engine/src/cmd/component/props.rs b/crates/engine/src/cmd/component/props.rs index 3f2d7002..bb3dba4c 100644 --- a/crates/engine/src/cmd/component/props.rs +++ b/crates/engine/src/cmd/component/props.rs @@ -1,6 +1,11 @@ use super::instance::preferred_arg; use super::variants::{fit_set, wrap_in_set}; -use super::*; +use super::{ + BTreeMap, Document, NodeId, NodeKind, PROP_BINDING, PropDef, Result, SLOT, Session, Value, VariableType, VariableValue, bad, boolean, + default_variant, find_variant, get, id, id_of, inside_instance, instances_of, json, main_of, ok, owning_component, prop_default_value, + req_string, req_targets, set_boolean_default, slot_edited, slots_of, string, takes_value, targets, variant_conflicts, variant_name, + variant_options, variant_props, variant_set, +}; /// Whether instance `id` has a component property `name` that a `kind` variable can drive (a /// boolean property takes a boolean variable, a text or variant property a string one). diff --git a/crates/engine/src/cmd/component/variants.rs b/crates/engine/src/cmd/component/variants.rs index f6e7ac6c..5f9e218a 100644 --- a/crates/engine/src/cmd/component/variants.rs +++ b/crates/engine/src/cmd/component/variants.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + BTreeMap, BTreeSet, Document, EngineError, Node, NodeId, NodeKind, Result, Session, Value, bad, inside_instance, json, owning_instance, + req_targets, roots, set_name_from, string, targets, variant_name, variant_options, variant_props, variant_set, z_sorted, +}; /// Wrap components in a new component set (named after the first) placed around them. pub(super) fn wrap_in_set(cmd: &str, doc: &mut Document, ids: &[NodeId]) -> Result { diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 414a2310..2e64ce04 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -1,12 +1,12 @@ //! Creating nodes. use figcraft_color::{ImageScaleMode, Paint}; -use figcraft_doc::*; +use figcraft_doc::{Document, Node, NodeId, NodeKind, StrokeCap, TextAutoResize, TextData, VectorNetwork}; use figcraft_geom::Point; use serde_json::{Value, json}; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{boolean, f64_of, get, id, num, num_or, opt_id, req_targets, string}; use crate::{Result, Session, bad, spec}; /// Frame presets shown by the Frame tool (category, name, width, height). diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 7ec63c69..2d51b958 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -2,12 +2,15 @@ use std::collections::{BTreeMap, BTreeSet}; -use figcraft_doc::*; +use figcraft_doc::{Document, Effect, ExportSetting, ImageData, LayoutGrid, Node, NodeId, NodeKind}; use figcraft_geom::Affine; use serde_json::{Map, Value, json}; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{ + blend, boolean, color, get, id, id_of, num, num_or, opt_index, paint_of, parse, req_id, req_index, req_num, req_string, req_targets, string, + targets, +}; use crate::{BatchStep, EngineError, Result, Session, bad, spec}; mod clipboard; diff --git a/crates/engine/src/cmd/edit/clipboard.rs b/crates/engine/src/cmd/edit/clipboard.rs index d0e7b2dc..0df97532 100644 --- a/crates/engine/src/cmd/edit/clipboard.rs +++ b/crates/engine/src/cmd/edit/clipboard.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Affine, BTreeMap, BTreeSet, Document, EngineError, ImageData, Node, NodeId, NodeKind, Result, Session, Value, bad, delete, id, json, num, ok, + req_targets, roots, string, targets, z_sorted, +}; /// The copied form of the selection. pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<()> { diff --git a/crates/engine/src/cmd/edit/props.rs b/crates/engine/src/cmd/edit/props.rs index f793b50c..91855c2e 100644 --- a/crates/engine/src/cmd/edit/props.rs +++ b/crates/engine/src/cmd/edit/props.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + BTreeMap, Effect, EngineError, Map, Node, NodeId, NodeKind, Result, Session, Value, bad, blend, boolean, color, get, json, num, ok, opt_index, + paint_of, parse, req_id, req_index, req_string, req_targets, roots, string, targets, z_sorted, +}; pub(super) fn batch_rename(s: &mut Session, p: &Value) -> Result { let cmd = "object.batchRename"; diff --git a/crates/engine/src/cmd/edit/select.rs b/crates/engine/src/cmd/edit/select.rs index 03bba890..ca1da3bb 100644 --- a/crates/engine/src/cmd/edit/select.rs +++ b/crates/engine/src/cmd/edit/select.rs @@ -1,4 +1,6 @@ -use super::*; +use super::{ + Affine, BTreeMap, BTreeSet, Node, NodeId, Result, Session, Value, bad, boolean, json, num, num_or, ok, req_num, req_string, req_targets, roots, +}; pub(super) fn layers_at(s: &mut Session, p: &Value) -> Result { let (x, y) = (req_num("select.layersAt", p, "x")?, req_num("select.layersAt", p, "y")?); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index fed4bff1..7c901293 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -3,12 +3,15 @@ use std::sync::Arc; use base64::Engine as _; -use figcraft_doc::*; +use figcraft_doc::{ + BOARD_PAGE, Document, ExportFormat, ExportSetting, ExportSize, FileKind, LineHeight, Node, NodeId, NodeKind, PrintSettings, StyleKind, TextStyle, + VdCompat, +}; use figcraft_format::Version; use serde_json::{Value, json}; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{bool_of, boolean, get, id, num, num_or, req_index, req_string, string, targets}; use crate::{DocState, EngineError, Result, Session, bad, spec}; mod export; diff --git a/crates/engine/src/cmd/file/load.rs b/crates/engine/src/cmd/file/load.rs index 071c651e..a033475e 100644 --- a/crates/engine/src/cmd/file/load.rs +++ b/crates/engine/src/cmd/file/load.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{DocState, Document, EngineError, LineHeight, NodeId, NodeKind, Result, StyleKind, TextStyle, Value, bad, get, json}; /// The recovery copy at `path` as a document with unsaved changes and no path. pub(crate) fn recovered(path: &str) -> Result { diff --git a/crates/engine/src/cmd/file/version.rs b/crates/engine/src/cmd/file/version.rs index 3d311a44..a46fab8e 100644 --- a/crates/engine/src/cmd/file/version.rs +++ b/crates/engine/src/cmd/file/version.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, DocState, Document, EngineError, Result, Session, Value, Version, bad, get, json, req_index, string}; pub(super) fn version_json(index: usize, v: &Version) -> Value { let mut j = json!({"id": v.id, "index": index, "name": v.name, "description": v.description, "time": v.time}); diff --git a/crates/engine/src/cmd/image.rs b/crates/engine/src/cmd/image.rs index bb11f011..c82e30d1 100644 --- a/crates/engine/src/cmd/image.rs +++ b/crates/engine/src/cmd/image.rs @@ -5,7 +5,7 @@ use figcraft_doc::{Document, ImagePreset}; use serde_json::{Value, json}; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{f64_of, get, num, req_index, req_string, req_targets, string, targets}; use crate::{Result, Session, bad, spec}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 5a0a4000..e22ca498 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -6,7 +6,7 @@ use serde_json::Value; use super::props::sizing; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{get, num, req_num, req_targets, string}; use crate::{Result, Session, bad, spec}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 7892102d..0c7edbb2 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -5,16 +5,19 @@ use std::collections::BTreeSet; use std::sync::Arc; -use figcraft_doc::*; +use figcraft_doc::{Collection, Document, ImportedComponent, ImportedRecord, LibraryLink, LibraryManifest, NodeId, PublishedLibrary, VariableId}; use serde_json::{Value, json}; use figcraft_collab::message::{LibraryReply, LibraryReq}; use super::CommandSpec; -use crate::library::*; +use crate::library::{ + Pending, Selection, absolute_path, accept_updates, asset_root, default_selection, find_link, import, import_styles, import_variables, + is_remote_variable, pending, prune, publish, publishable, published_collections, published_variables, +}; use crate::library_server::{archive, is_server_link, read_archive, shown_path}; use crate::live::{NetReply, NetRequest, PublishOptions}; -use crate::params::*; +use crate::params::{boolean, get, id_of, num, req_string, string}; use crate::{EngineError, Result, Session, bad, spec}; /// Longest library description kept (chars). diff --git a/crates/engine/src/cmd/lists.rs b/crates/engine/src/cmd/lists.rs index 287c9ddd..e53291b6 100644 --- a/crates/engine/src/cmd/lists.rs +++ b/crates/engine/src/cmd/lists.rs @@ -6,11 +6,11 @@ //! Every list is stored bottom first: index 0 paints (or applies) first, the last one is on top; //! the Design panel shows them in reverse, the topmost row first. -use figcraft_doc::*; +use figcraft_doc::{Document, LayoutGrid, Node, NodeId}; use serde_json::Value; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{req_index, req_targets}; use crate::{Result, Session, spec}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index 08e65b0b..44c7b367 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -1,11 +1,11 @@ //! Pages. use figcraft_doc::proto::Flow; -use figcraft_doc::*; +use figcraft_doc::{Guide, NodeId, NodeKind}; use serde_json::{Value, json}; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{boolean, color, id, req_index, req_num, req_string, string}; use crate::{Session, bad, spec}; /// After a page edit: the file keeps a page that isn't a divider, and the canvas shows one (the diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 30a10393..469fcb01 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -1,11 +1,15 @@ //! Property editing: `node.set` and the shorthand keys every surface uses. use figcraft_color::{Color, Paint}; -use figcraft_doc::*; +use figcraft_doc::{ + AutoLayout, Brush, ChildLayout, DecorationStyle, DevStatus, Document, Effect, GridTrack, LayoutMode, LetterSpacing, LineHeight, ListStyle, + MAX_LIST_INDENT, MaskType, Node, NodeId, NodeKind, Sizing, StrokeCap, StrokeProps, TextAutoResize, TextCase, TextData, TextDecoration, + WidthPoint, WidthPreset, WidthProfile, style_weight, valid_axis_tag, valid_feature_tag, +}; use figcraft_geom::{Affine, Point}; use serde_json::{Map, Value, json}; -use crate::params::*; +use crate::params::{bool_of, color_of, f64_of, get, paints_of}; use crate::{Result, bad}; /// The per-side stroke weight keys, in `StrokeProps::sides` order (top, right, bottom, left). @@ -409,6 +413,587 @@ fn fix_axis(doc: &mut Document, id: NodeId, is_w: bool) -> Result<()> { Ok(()) } +/// The `geometry` properties of `apply`; `false` when `k` is not one of them. +fn apply_geometry( + cmd: &str, + doc: &mut Document, + id: NodeId, + props: &Map, + k: &str, + v: &Value, + path_offset: &mut (f64, f64), +) -> Result { + match k { + "path" => *path_offset = set_path(cmd, doc, id, v, props)?, + // A new path takes the size asked for as its scale (`set_path`); the size still fixes + // its auto layout axis. + "width" | "height" | "w" | "h" if props.contains_key("path") => fix_axis(doc, id, k.starts_with('w'))?, + "x" | "y" => { + let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?; + let val = val + if k == "x" { path_offset.0 } else { path_offset.1 }; + // X/Y are the node's own origin (its rotated top-left for rotated nodes), in the + // frame it is in (groups have no space of their own). + let n = doc.get(id)?; + let group = n.parent.filter(|p| doc.node(*p).is_some_and(|pn| pn.kind.bounds_follow_children())); + let space = group.map_or(Affine::IDENTITY, |g| doc.frame_relative(g)); + let c = (space * n.transform).as_coeffs(); + let (mut tx, mut ty) = (c[4], c[5]); + if k == "x" { + tx = val; + } else { + ty = val; + } + doc.get_mut(id)?.transform = space.inverse() * Affine::new([c[0], c[1], c[2], c[3], tx, ty]); + } + "width" | "height" | "w" | "h" => { + let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0); + let is_w = k.starts_with('w'); + // With the proportions locked (the Layout panel's link) and only one side set, the + // other side follows the node's ratio; both sides in one `node.set` stay as typed. + let (nw, nh) = { + let n = doc.get(id)?; + let (ow, oh) = (n.width, n.height); + let (other, other_short) = if is_w { ("height", "h") } else { ("width", "w") }; + let linked = n.constrain_proportions && !props.contains_key(other) && !props.contains_key(other_short) && ow > 1e-9 && oh > 1e-9; + let derived = if is_w { oh * val / ow } else { ow * val / oh }; + match (is_w, linked && derived.is_finite()) { + (true, true) => (val, derived), + (false, true) => (derived, val), + (true, false) => (val, oh), + (false, false) => (ow, val), + } + }; + resize_node(doc, id, nw, nh)?; + // Explicit size makes text fixed in that dimension. + if let Some(t) = doc.get_mut(id)?.kind.text_mut() { + t.fix_size(is_w, !is_w); + } + fix_axis(doc, id, is_w)?; + } + "rotation" => { + let deg = f64_of(v).ok_or_else(|| bad(cmd, "`rotation` must be degrees"))?; + let n = doc.get_mut(id)?; + n.transform = figcraft_geom::set_rotation_about_center(n.transform, n.width, n.height, deg); + } + _ => return Ok(false), + } + Ok(true) +} + +/// The `look` properties of `apply`; `false` when `k` is not one of them. +fn apply_look(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) -> Result { + match k { + "name" => doc.get_mut(id)?.name = string_val(v).ok_or_else(|| bad(cmd, "`name` must be a string"))?, + "visible" => doc.get_mut(id)?.visible = v.as_bool().ok_or_else(|| bad(cmd, "`visible` must be a boolean"))?, + "locked" => doc.get_mut(id)?.locked = v.as_bool().ok_or_else(|| bad(cmd, "`locked` must be a boolean"))?, + "opacity" => { + let o = f64_of(v).ok_or_else(|| bad(cmd, "`opacity` must be a number"))?; + let o = if o > 1.0 { o / 100.0 } else { o }; + doc.get_mut(id)?.opacity = o.clamp(0.0, 1.0) as f32; + } + "blend" | "blendMode" => { + let b = v.as_str().and_then(figcraft_color::BlendMode::from_name).ok_or_else(|| bad(cmd, "unknown blend mode"))?; + doc.get_mut(id)?.blend = b; + } + "fills" => set_paints(doc.get_mut(id)?, true, paints_of(v).ok_or_else(|| bad(cmd, "`fills` must be paints or colours"))?), + "fill" => { + if paint_none(v) { + set_paints(doc.get_mut(id)?, true, vec![]); + } else { + let c = color_of(v).ok_or_else(|| bad(cmd, "`fill` must be a colour, \"none\" or null"))?; + set_paints(doc.get_mut(id)?, true, vec![Paint::solid(c)]); + } + } + "strokes" => set_paints(doc.get_mut(id)?, false, paints_of(v).ok_or_else(|| bad(cmd, "`strokes` must be paints or colours"))?), + "stroke" => { + if paint_none(v) { + set_paints(doc.get_mut(id)?, false, vec![]); + } else { + let c = color_of(v).ok_or_else(|| bad(cmd, "`stroke` must be a colour, \"none\" or null"))?; + set_paints(doc.get_mut(id)?, false, vec![Paint::solid(c)]); + } + } + "strokeWeight" => doc.get_mut(id)?.stroke.weight = f64_of(v).ok_or_else(|| bad(cmd, "`strokeWeight` must be a number"))?.max(0.0), + "strokeAlign" => doc.get_mut(id)?.stroke.align = enum_of(v).ok_or_else(|| bad(cmd, "strokeAlign: inside|center|outside"))?, + "strokeSides" => { + doc.get_mut(id)?.stroke.sides = if v.is_null() { None } else { Some(four(v).ok_or_else(|| bad(cmd, "strokeSides: [t,r,b,l]"))?) } + } + "strokeTopWeight" | "strokeRightWeight" | "strokeBottomWeight" | "strokeLeftWeight" => { + let i = STROKE_SIDES.iter().position(|x| *x == k).unwrap_or(0); + let w = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0); + let st = &mut doc.get_mut(id)?.stroke; + // One side set on its own turns on individual strokes, the others at the weight. + let mut sides = st.sides.unwrap_or([st.weight; 4]); + if let Some(slot) = sides.get_mut(i) { + *slot = w; + } + st.sides = Some(sides); + } + "strokeCap" => { + let c: StrokeCap = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?; + let n = doc.get_mut(id)?; + n.stroke.cap_start = c; + n.stroke.cap_end = c; + } + "startCap" => doc.get_mut(id)?.stroke.cap_start = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?, + "endCap" => doc.get_mut(id)?.stroke.cap_end = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?, + "strokeJoin" => doc.get_mut(id)?.stroke.join = enum_of(v).ok_or_else(|| bad(cmd, "strokeJoin: miter|bevel|round"))?, + "miterLimit" => doc.get_mut(id)?.stroke.miter_limit = f64_of(v).unwrap_or(4.0).clamp(1.0, 100.0), + "miterAngle" => { + let deg = f64_of(v).ok_or_else(|| bad(cmd, "`miterAngle` must be degrees"))?; + doc.get_mut(id)?.stroke.miter_limit = StrokeProps::miter_limit_for(deg); + } + "dashes" => { + let d: Vec = v.as_array().map(|a| a.iter().filter_map(f64_of).map(|x| x.max(0.0)).collect()).unwrap_or_default(); + doc.get_mut(id)?.stroke.dashes = d; + } + "widthProfile" => doc.get_mut(id)?.stroke.width_profile = width_profile_of(v).ok_or_else(|| bad(cmd, WIDTH_PROFILE_HELP))?, + "brush" | "strokeBrush" => doc.get_mut(id)?.stroke.brush = brush_of(v).ok_or_else(|| bad(cmd, BRUSH_HELP))?, + _ => return Ok(false), + } + Ok(true) +} + +/// The `corners` properties of `apply`; `false` when `k` is not one of them. +fn apply_corners(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) -> Result { + match k { + "cornerRadius" => { + let r = four(v).ok_or_else(|| bad(cmd, "cornerRadius: n or [tl,tr,br,bl]"))?.map(|x| x.max(0.0)); + let n = doc.get_mut(id)?; + n.corners.radii = r; + // A vector's corner radius rounds every point (edit mode then sets them one by one). + if let NodeKind::Vector { network } = &mut n.kind { + network.vertices.iter_mut().for_each(|p| p.radius = r[0]); + crate::vectoredit::refit(doc, id)?; + } + } + "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => { + let i = ["topLeftRadius", "topRightRadius", "bottomRightRadius", "bottomLeftRadius"].iter().position(|x| *x == k).unwrap_or(0); + let r = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0); + if let Some(slot) = doc.get_mut(id)?.corners.radii.get_mut(i) { + *slot = r; + } + } + "cornerSmoothing" => doc.get_mut(id)?.corners.smoothing = f64_of(v).unwrap_or(0.0).clamp(0.0, 1.0), + "effects" => doc.get_mut(id)?.effects = effects_from(cmd, v)?, + "clip" | "clipsContent" => doc.get_mut(id)?.clip = v.as_bool().ok_or_else(|| bad(cmd, "`clip` must be a boolean"))?, + "isMask" => doc.get_mut(id)?.is_mask = v.as_bool().unwrap_or(false), + "devStatus" => { + let st = if v.is_null() { + Some(DevStatus::None) + } else { + v.as_str().and_then(|s| DevStatus::ALL.into_iter().find(|d| d.id().eq_ignore_ascii_case(s))) + }; + let st = st.ok_or_else(|| bad(cmd, "devStatus: none|readyForDev|completed"))?; + if !st.is_none() && !crate::devinspect::has_status(doc, id) { + return Err(bad(cmd, "devStatus: only sections and frames at the top of a page or section take one")); + } + let n = doc.get_mut(id)?; + n.dev_status = st; + // Marking a layer (again) starts from its current contents. + n.dev_changed = false; + } + "maskType" => { + let t = v.as_str().and_then(|s| MaskType::ALL.into_iter().find(|m| m.id().eq_ignore_ascii_case(s))); + doc.get_mut(id)?.mask_type = t.ok_or_else(|| bad(cmd, "maskType: alpha|vector|luminance"))?; + } + _ => return Ok(false), + } + Ok(true) +} + +/// The `layout` properties of `apply`; `false` when `k` is not one of them. +fn apply_layout(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) -> Result { + match k { + "layout" | "layoutMode" => { + let n = doc.get_mut(id)?; + if !n.kind.is_frame_like() { + return Err(bad(cmd, "auto layout needs a frame")); + } + match v { + Value::Null => { + n.layout = None; + drop_layout_bindings(n); + } + Value::String(s) if s.eq_ignore_ascii_case("none") => { + n.layout = None; + drop_layout_bindings(n); + } + Value::String(s) => { + let mode = match s.to_ascii_lowercase().as_str() { + "horizontal" | "row" => LayoutMode::Horizontal, + "vertical" | "column" => LayoutMode::Vertical, + "grid" => LayoutMode::Grid, + _ => return Err(bad(cmd, "layout: none|horizontal|vertical|grid")), + }; + ensure_layout(n).mode = mode; + } + Value::Object(_) => n.layout = Some(serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("layout: {e}")))?), + _ => return Err(bad(cmd, "layout: none|horizontal|vertical|grid|{...}")), + } + } + "gap" | "itemSpacing" | "columnGap" => { + layout_mut(cmd, k, doc.get_mut(id)?)?.gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))? + } + "crossGap" | "counterAxisSpacing" | "rowGap" => { + layout_mut(cmd, k, doc.get_mut(id)?)?.cross_gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))? + } + "padding" => { + let p = four(v).ok_or_else(|| bad(cmd, "padding: n or [t,r,b,l]"))?; + layout_mut(cmd, k, doc.get_mut(id)?)?.padding = p.map(|x| x.max(0.0)); + } + "paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft" => { + let i = ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"].iter().position(|x| *x == k).unwrap_or(0); + let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0); + if let Some(slot) = layout_mut(cmd, k, doc.get_mut(id)?)?.padding.get_mut(i) { + *slot = val; + } + } + "paddingHorizontal" => { + let val = f64_of(v).unwrap_or(0.0).max(0.0); + let l = layout_mut(cmd, k, doc.get_mut(id)?)?; + l.padding[1] = val; + l.padding[3] = val; + } + "paddingVertical" => { + let val = f64_of(v).unwrap_or(0.0).max(0.0); + let l = layout_mut(cmd, k, doc.get_mut(id)?)?; + l.padding[0] = val; + l.padding[2] = val; + } + "mainAlign" | "primaryAxisAlignItems" => { + layout_mut(cmd, k, doc.get_mut(id)?)?.main_align = + enum_of(v).ok_or_else(|| bad(cmd, "mainAlign: min|center|max|spaceBetween|spaceAround|spaceEvenly"))? + } + "crossAlign" | "counterAxisAlignItems" => { + layout_mut(cmd, k, doc.get_mut(id)?)?.cross_align = enum_of(v).ok_or_else(|| bad(cmd, "crossAlign: min|center|max|baseline"))? + } + "contentAlign" | "counterAxisAlignContent" => { + layout_mut(cmd, k, doc.get_mut(id)?)?.content_align = enum_of(v).ok_or_else(|| bad(cmd, "contentAlign: auto|spaceBetween"))? + } + "wrap" => layout_mut(cmd, k, doc.get_mut(id)?)?.wrap = v.as_bool().unwrap_or(false), + "gridColumns" | "gridRows" => { + let count = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.clamp(1.0, 1000.0) as u32; + let l = layout_mut(cmd, k, doc.get_mut(id)?)?; + // Tracks past the new count go away (new tracks are 1fr). + let (n, tracks) = if k == "gridColumns" { (&mut l.grid_columns, &mut l.column_tracks) } else { (&mut l.grid_rows, &mut l.row_tracks) }; + *n = count; + tracks.truncate(count as usize); + } + "strokesIncluded" => layout_mut(cmd, k, doc.get_mut(id)?)?.strokes_included = v.as_bool().unwrap_or(false), + "reverseZ" => layout_mut(cmd, k, doc.get_mut(id)?)?.reverse_z = v.as_bool().unwrap_or(false), + _ => return Ok(false), + } + Ok(true) +} + +/// The `sizing` properties of `apply`; `false` when `k` is not one of them. +fn apply_sizing(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) -> Result { + match k { + "sizingH" | "layoutSizingHorizontal" => { + let s = sizing(v).ok_or_else(|| bad(cmd, "sizing: fixed|hug|fill"))?; + set_sizing(cmd, doc, id, s, true)?; + } + "sizingV" | "layoutSizingVertical" => { + let s = sizing(v).ok_or_else(|| bad(cmd, "sizing: fixed|hug|fill"))?; + set_sizing(cmd, doc, id, s, false)?; + } + "absolute" | "ignoreAutoLayout" => { + let on = bool_of(v).ok_or_else(|| bad(cmd, "`absolute` must be a boolean"))?; + let cl = &mut doc.get_mut(id)?.child_layout; + cl.absolute = on; + // An absolute child can't fill: it keeps its current size. + if on { + for s in [&mut cl.sizing_h, &mut cl.sizing_v] { + if *s == Sizing::Fill { + *s = Sizing::Fixed; + } + } + } + } + "minWidth" | "maxWidth" | "minHeight" | "maxHeight" => { + let val = match v { + Value::Null => None, + other => Some(f64_of(other).ok_or_else(|| bad(cmd, format!("`{k}` must be a number or null")))?.clamp(0.0, 1e7)), + }; + set_limit(&mut doc.get_mut(id)?.child_layout, k, val); + } + "columnTracks" | "gridColumnTracks" | "rowTracks" | "gridRowTracks" => { + let a = v.as_array().ok_or_else(|| bad(cmd, format!("`{k}` must be an array of tracks")))?; + let tracks = a.iter().map(track_of).collect::>>().ok_or_else(|| bad(cmd, TRACK_HELP))?; + let l = layout_mut(cmd, k, doc.get_mut(id)?)?; + if k.contains("olumn") { + l.grid_columns = (tracks.len() as u32).clamp(1, 1000); + l.column_tracks = tracks; + } else { + l.grid_rows = (tracks.len() as u32).clamp(1, 1000); + l.row_tracks = tracks; + } + } + "gridColumn" | "gridColumnAnchorIndex" | "gridRow" | "gridRowAnchorIndex" => { + let val = match v { + Value::Null => None, + Value::String(s) if s.eq_ignore_ascii_case("auto") => None, + other => { + Some(f64_of(other).ok_or_else(|| bad(cmd, format!("`{k}` must be a 0-based index, \"auto\" or null")))?.clamp(0.0, 999.0) as u32) + } + }; + let cl = &mut doc.get_mut(id)?.child_layout; + if k.starts_with("gridColumn") { + cl.grid_column = val; + } else { + cl.grid_row = val; + } + } + "gridAlignH" | "gridChildHorizontalAlign" | "gridAlignV" | "gridChildVerticalAlign" => { + let val = match v { + Value::Null => None, + Value::String(s) if s.eq_ignore_ascii_case("auto") => None, + other => Some(enum_of(other).ok_or_else(|| bad(cmd, format!("`{k}`: min|center|max|auto")))?), + }; + let cl = &mut doc.get_mut(id)?.child_layout; + if k.contains('H') { + cl.grid_align_h = val; + } else { + cl.grid_align_v = val; + } + } + "gridColumnSpan" | "gridRowSpan" => { + let span = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.clamp(1.0, 1000.0) as u32; + let cl = &mut doc.get_mut(id)?.child_layout; + if k == "gridColumnSpan" { + cl.grid_column_span = span; + } else { + cl.grid_row_span = span; + } + } + "constraints" => { + let n = doc.get_mut(id)?; + if let Some(h) = v.get("horizontal") { + n.constraints.horizontal = enum_of(h).ok_or_else(|| bad(cmd, "horizontal: left|right|leftRight|center|scale"))?; + } + if let Some(vv) = v.get("vertical") { + n.constraints.vertical = enum_of(vv).ok_or_else(|| bad(cmd, "vertical: top|bottom|topBottom|center|scale"))?; + } + } + _ => return Ok(false), + } + Ok(true) +} + +/// The `meta` properties of `apply`; `false` when `k` is not one of them. +fn apply_meta(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) -> Result { + match k { + "grids" | "layoutGrids" => doc.get_mut(id)?.grids = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("grids: {e}")))?, + "exports" | "exportSettings" => doc.get_mut(id)?.exports = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("exports: {e}")))?, + "interactions" => doc.get_mut(id)?.interactions = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("interactions: {e}")))?, + "overflow" => doc.get_mut(id)?.overflow = enum_of(v).ok_or_else(|| bad(cmd, "overflow: none|horizontal|vertical|both"))?, + "scrollPosition" => doc.get_mut(id)?.scroll_position = enum_of(v).ok_or_else(|| bad(cmd, "scrollPosition: scrolls|fixed|sticky"))?, + "constrainProportions" => doc.get_mut(id)?.constrain_proportions = v.as_bool().unwrap_or(false), + _ => return Ok(false), + } + Ok(true) +} + +/// The `text` properties of `apply`; `false` when `k` is not one of them. +fn apply_text(cmd: &str, doc: &mut Document, id: NodeId, props: &Map, k: &str, v: &Value) -> Result { + match k { + "characters" | "text" => { + let s = string_val(v).ok_or_else(|| bad(cmd, "`characters` must be a string"))?; + let n = doc.get_mut(id)?; + let t = text_mut(cmd, n)?; + let len = t.char_count(); + t.replace_range(0, len, &normalize_line_endings(&s)); + n.overrides.insert("characters".into()); + } + "fontFamily" + | "fontStyle" + | "fontWeight" + | "fontSize" + | "lineHeight" + | "letterSpacing" + | "textDecoration" + | "textCase" + | "hyperlink" + | "textFills" + | "fontVariations" + | "variationSettings" + | "textDecorationStyle" + | "textDecorationThickness" + | "textDecorationOffset" + | "textDecorationSkipInk" + | "textDecorationColor" => { + let n = doc.get_mut(id)?; + let t = text_mut(cmd, n)?; + let (start, end) = range_of(props, t.char_count()); + apply_text_style(cmd, t, start, end, k, v)?; + } + "textAlign" | "textAlignHorizontal" => { + text_mut(cmd, doc.get_mut(id)?)?.align_h = enum_of(v).ok_or_else(|| bad(cmd, "textAlign: left|center|right|justified"))? + } + "textAlignVertical" => { + text_mut(cmd, doc.get_mut(id)?)?.align_v = enum_of(v).ok_or_else(|| bad(cmd, "textAlignVertical: top|middle|bottom"))? + } + "textAutoResize" => { + text_mut(cmd, doc.get_mut(id)?)?.auto_resize = + enum_of(v).ok_or_else(|| bad(cmd, "textAutoResize: widthAndHeight|height|none|truncate"))? + } + "paragraphSpacing" => text_mut(cmd, doc.get_mut(id)?)?.paragraph_spacing = f64_of(v).unwrap_or(0.0).max(0.0), + "paragraphIndent" => text_mut(cmd, doc.get_mut(id)?)?.paragraph_indent = f64_of(v).unwrap_or(0.0).max(0.0), + "listStyle" | "indentation" => { + let t = text_mut(cmd, doc.get_mut(id)?)?; + let (start, end) = range_of(props, t.char_count()); + if k == "listStyle" { + let l: ListStyle = enum_of(v).ok_or_else(|| bad(cmd, "listStyle: none|bulleted|numbered"))?; + t.set_para_lists(start, end, |p| p.list = l); + } else { + let d = f64_of(v).filter(|d| d.is_finite()).ok_or_else(|| bad(cmd, "indentation: a list level 0-8"))?; + t.set_para_lists(start, end, |p| p.indent = d.clamp(0.0, f64::from(MAX_LIST_INDENT)) as u8); + } + } + "listSpacing" => text_mut(cmd, doc.get_mut(id)?)?.list_spacing = f64_of(v).filter(|s| s.is_finite()).unwrap_or(0.0).clamp(0.0, 10_000.0), + "hangingPunctuation" | "hangingList" => { + let on = v.as_bool().ok_or_else(|| bad(cmd, format!("`{k}` must be true or false")))?; + let t = text_mut(cmd, doc.get_mut(id)?)?; + *if k == "hangingList" { &mut t.hanging_list } else { &mut t.hanging_punctuation } = on; + } + "leadingTrim" => text_mut(cmd, doc.get_mut(id)?)?.leading_trim = enum_of(v).ok_or_else(|| bad(cmd, "leadingTrim: none|capHeight"))?, + "maxLines" => text_mut(cmd, doc.get_mut(id)?)?.max_lines = f64_of(v).unwrap_or(0.0).clamp(0.0, 10_000.0) as u32, + "features" | "textFeatures" | "fontFeatures" | "openTypeFeatures" => { + let f = features_of(cmd, v)?; + let t = text_mut(cmd, doc.get_mut(id)?)?; + t.features.clear(); + for (k, on) in f { + if let Some(on) = on { + t.features.insert(k, on); + } + } + } + "start" | "end" => {} + _ => return Ok(false), + } + Ok(true) +} + +/// The `shape` properties of `apply`; `false` when `k` is not one of them. +fn apply_shape(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) -> Result { + match k { + "pointCount" => { + let c = f64_of(v).ok_or_else(|| bad(cmd, "`pointCount` must be a number"))?.round(); + match &mut doc.get_mut(id)?.kind { + // Stars have 3 to 60 points; polygons keep the geometry's 120 (silent). + NodeKind::Star { count, .. } => *count = c.clamp(3.0, 60.0) as u32, + NodeKind::Polygon { count } => *count = c.clamp(3.0, 120.0) as u32, + _ => return Err(bad(cmd, "pointCount applies to polygons and stars")), + } + } + "innerRadius" | "ratio" => { + let r = f64_of(v).unwrap_or(0.38); + let r = if r > 1.0 { r / 100.0 } else { r }; + match &mut doc.get_mut(id)?.kind { + NodeKind::Star { inner_ratio, .. } => *inner_ratio = r.clamp(0.0, 1.0), + _ => return Err(bad(cmd, "innerRadius applies to stars")), + } + } + "arcStart" | "arcSweep" | "arcInner" => { + let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?; + match &mut doc.get_mut(id)?.kind { + NodeKind::Ellipse { start, sweep, inner } => match k { + "arcStart" => *start = val.to_radians(), + "arcSweep" => *sweep = val.clamp(-360.0, 360.0).to_radians(), + _ => *inner = (if val > 1.0 { val / 100.0 } else { val }).clamp(0.0, 0.99), + }, + _ => return Err(bad(cmd, "arc applies to ellipses")), + } + } + "description" => match &mut doc.get_mut(id)?.kind { + NodeKind::Component { description, .. } | NodeKind::ComponentSet { description } => *description = string_val(v).unwrap_or_default(), + _ => return Err(bad(cmd, "description applies to components")), + }, + "pluginData" => { + let n = doc.get_mut(id)?; + if let Some(o) = v.as_object() { + for (pk, pv) in o { + if pv.is_null() { + n.plugin_data.remove(pk); + } else { + n.plugin_data.insert(pk.clone(), string_val(pv).unwrap_or_else(|| pv.to_string())); + } + } + } + } + "fillStyle" | "strokeStyle" | "effectStyle" | "textStyle" | "gridStyle" => { + let sid = string_val(v); + crate::cmd::styles::apply_style_ref(doc, id, k, sid.as_deref())?; + } + "boundVariables" => { + let n = doc.get_mut(id)?; + if let Some(o) = v.as_object() { + for (field, var) in o { + match var.as_str() { + Some(vid) => { + if n.bound.get(field).map(String::as_str) != Some(vid) { + crate::cmd::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, true); + } + n.bound.insert(field.clone(), vid.to_string()); + } + None => { + if n.bound.remove(field).is_some() { + crate::cmd::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, false); + } + } + } + } + } + } + "variableModes" => { + let n = doc.get_mut(id)?; + if let Some(o) = v.as_object() { + for (coll, mode) in o { + match mode.as_str() { + Some(m) => { + n.var_modes.insert(coll.clone(), m.to_string()); + } + None => { + n.var_modes.remove(coll); + } + } + } + } + } + "booleanOperation" if !matches!(doc.get(id)?.kind, NodeKind::BooleanOp { .. }) => set_operand_op(doc, id, v, cmd)?, + "booleanOperation" => { + if let NodeKind::BooleanOp { op } = &mut doc.get_mut(id)?.kind { + *op = enum_of(v).ok_or_else(|| bad(cmd, BOOL_OP_HELP))?; + } + } + "operandOperation" => set_operand_op(doc, id, v, cmd)?, + "sectionContentsHidden" => { + if let NodeKind::Section { contents_hidden } = &mut doc.get_mut(id)?.kind { + *contents_hidden = v.as_bool().unwrap_or(false); + } + } + "background" => { + let c = color_of(v).ok_or_else(|| bad(cmd, "`background` must be a colour"))?; + match &mut doc.get_mut(id)?.kind { + NodeKind::Page { background, .. } => *background = c, + _ => return Err(bad(cmd, "background applies to pages")), + } + } + "componentProperties" => { + if let NodeKind::Instance { props: p, .. } = &mut doc.get_mut(id)?.kind + && let Some(o) = v.as_object() + { + for (pk, pv) in o { + p.insert(pk.clone(), pv.clone()); + } + } + } + _ => return Ok(false), + } + Ok(true) +} + /// Apply a property map to one node. `x`/`y` are relative to the parent. pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map) -> Result<()> { let mut unknown = Vec::new(); @@ -418,524 +1003,18 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map path_offset = set_path(cmd, doc, id, v, props)?, - // A new path takes the size asked for as its scale (`set_path`); the size still fixes - // its auto layout axis. - "width" | "height" | "w" | "h" if props.contains_key("path") => fix_axis(doc, id, k.starts_with('w'))?, - "x" | "y" => { - let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?; - let val = val + if k == "x" { path_offset.0 } else { path_offset.1 }; - // X/Y are the node's own origin (its rotated top-left for rotated nodes), in the - // frame it is in (groups have no space of their own). - let n = doc.get(id)?; - let group = n.parent.filter(|p| doc.node(*p).is_some_and(|pn| pn.kind.bounds_follow_children())); - let space = group.map_or(Affine::IDENTITY, |g| doc.frame_relative(g)); - let c = (space * n.transform).as_coeffs(); - let (mut tx, mut ty) = (c[4], c[5]); - if k == "x" { - tx = val; - } else { - ty = val; - } - doc.get_mut(id)?.transform = space.inverse() * Affine::new([c[0], c[1], c[2], c[3], tx, ty]); - } - "width" | "height" | "w" | "h" => { - let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0); - let is_w = k.starts_with('w'); - // With the proportions locked (the Layout panel's link) and only one side set, the - // other side follows the node's ratio; both sides in one `node.set` stay as typed. - let (nw, nh) = { - let n = doc.get(id)?; - let (ow, oh) = (n.width, n.height); - let (other, other_short) = if is_w { ("height", "h") } else { ("width", "w") }; - let linked = n.constrain_proportions && !props.contains_key(other) && !props.contains_key(other_short) && ow > 1e-9 && oh > 1e-9; - let derived = if is_w { oh * val / ow } else { ow * val / oh }; - match (is_w, linked && derived.is_finite()) { - (true, true) => (val, derived), - (false, true) => (derived, val), - (true, false) => (val, oh), - (false, false) => (ow, val), - } - }; - resize_node(doc, id, nw, nh)?; - // Explicit size makes text fixed in that dimension. - if let Some(t) = doc.get_mut(id)?.kind.text_mut() { - t.fix_size(is_w, !is_w); - } - fix_axis(doc, id, is_w)?; - } - "rotation" => { - let deg = f64_of(v).ok_or_else(|| bad(cmd, "`rotation` must be degrees"))?; - let n = doc.get_mut(id)?; - n.transform = figcraft_geom::set_rotation_about_center(n.transform, n.width, n.height, deg); - } - "name" => doc.get_mut(id)?.name = string_val(v).ok_or_else(|| bad(cmd, "`name` must be a string"))?, - "visible" => doc.get_mut(id)?.visible = v.as_bool().ok_or_else(|| bad(cmd, "`visible` must be a boolean"))?, - "locked" => doc.get_mut(id)?.locked = v.as_bool().ok_or_else(|| bad(cmd, "`locked` must be a boolean"))?, - "opacity" => { - let o = f64_of(v).ok_or_else(|| bad(cmd, "`opacity` must be a number"))?; - let o = if o > 1.0 { o / 100.0 } else { o }; - doc.get_mut(id)?.opacity = o.clamp(0.0, 1.0) as f32; - } - "blend" | "blendMode" => { - let b = v.as_str().and_then(figcraft_color::BlendMode::from_name).ok_or_else(|| bad(cmd, "unknown blend mode"))?; - doc.get_mut(id)?.blend = b; - } - "fills" => set_paints(doc.get_mut(id)?, true, paints_of(v).ok_or_else(|| bad(cmd, "`fills` must be paints or colours"))?), - "fill" => { - if paint_none(v) { - set_paints(doc.get_mut(id)?, true, vec![]); - } else { - let c = color_of(v).ok_or_else(|| bad(cmd, "`fill` must be a colour, \"none\" or null"))?; - set_paints(doc.get_mut(id)?, true, vec![Paint::solid(c)]); - } - } - "strokes" => set_paints(doc.get_mut(id)?, false, paints_of(v).ok_or_else(|| bad(cmd, "`strokes` must be paints or colours"))?), - "stroke" => { - if paint_none(v) { - set_paints(doc.get_mut(id)?, false, vec![]); - } else { - let c = color_of(v).ok_or_else(|| bad(cmd, "`stroke` must be a colour, \"none\" or null"))?; - set_paints(doc.get_mut(id)?, false, vec![Paint::solid(c)]); - } - } - "strokeWeight" => doc.get_mut(id)?.stroke.weight = f64_of(v).ok_or_else(|| bad(cmd, "`strokeWeight` must be a number"))?.max(0.0), - "strokeAlign" => doc.get_mut(id)?.stroke.align = enum_of(v).ok_or_else(|| bad(cmd, "strokeAlign: inside|center|outside"))?, - "strokeSides" => { - doc.get_mut(id)?.stroke.sides = if v.is_null() { None } else { Some(four(v).ok_or_else(|| bad(cmd, "strokeSides: [t,r,b,l]"))?) } - } - "strokeTopWeight" | "strokeRightWeight" | "strokeBottomWeight" | "strokeLeftWeight" => { - let i = STROKE_SIDES.iter().position(|x| *x == k).unwrap_or(0); - let w = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0); - let st = &mut doc.get_mut(id)?.stroke; - // One side set on its own turns on individual strokes, the others at the weight. - let mut sides = st.sides.unwrap_or([st.weight; 4]); - if let Some(slot) = sides.get_mut(i) { - *slot = w; - } - st.sides = Some(sides); - } - "strokeCap" => { - let c: StrokeCap = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?; - let n = doc.get_mut(id)?; - n.stroke.cap_start = c; - n.stroke.cap_end = c; - } - "startCap" => doc.get_mut(id)?.stroke.cap_start = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?, - "endCap" => doc.get_mut(id)?.stroke.cap_end = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?, - "strokeJoin" => doc.get_mut(id)?.stroke.join = enum_of(v).ok_or_else(|| bad(cmd, "strokeJoin: miter|bevel|round"))?, - "miterLimit" => doc.get_mut(id)?.stroke.miter_limit = f64_of(v).unwrap_or(4.0).clamp(1.0, 100.0), - "miterAngle" => { - let deg = f64_of(v).ok_or_else(|| bad(cmd, "`miterAngle` must be degrees"))?; - doc.get_mut(id)?.stroke.miter_limit = StrokeProps::miter_limit_for(deg); - } - "dashes" => { - let d: Vec = v.as_array().map(|a| a.iter().filter_map(f64_of).map(|x| x.max(0.0)).collect()).unwrap_or_default(); - doc.get_mut(id)?.stroke.dashes = d; - } - "widthProfile" => doc.get_mut(id)?.stroke.width_profile = width_profile_of(v).ok_or_else(|| bad(cmd, WIDTH_PROFILE_HELP))?, - "brush" | "strokeBrush" => doc.get_mut(id)?.stroke.brush = brush_of(v).ok_or_else(|| bad(cmd, BRUSH_HELP))?, - "cornerRadius" => { - let r = four(v).ok_or_else(|| bad(cmd, "cornerRadius: n or [tl,tr,br,bl]"))?.map(|x| x.max(0.0)); - let n = doc.get_mut(id)?; - n.corners.radii = r; - // A vector's corner radius rounds every point (edit mode then sets them one by one). - if let NodeKind::Vector { network } = &mut n.kind { - network.vertices.iter_mut().for_each(|p| p.radius = r[0]); - crate::vectoredit::refit(doc, id)?; - } - } - "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => { - let i = ["topLeftRadius", "topRightRadius", "bottomRightRadius", "bottomLeftRadius"].iter().position(|x| *x == k).unwrap_or(0); - let r = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0); - if let Some(slot) = doc.get_mut(id)?.corners.radii.get_mut(i) { - *slot = r; - } - } - "cornerSmoothing" => doc.get_mut(id)?.corners.smoothing = f64_of(v).unwrap_or(0.0).clamp(0.0, 1.0), - "effects" => doc.get_mut(id)?.effects = effects_from(cmd, v)?, - "clip" | "clipsContent" => doc.get_mut(id)?.clip = v.as_bool().ok_or_else(|| bad(cmd, "`clip` must be a boolean"))?, - "isMask" => doc.get_mut(id)?.is_mask = v.as_bool().unwrap_or(false), - "devStatus" => { - let st = if v.is_null() { - Some(DevStatus::None) - } else { - v.as_str().and_then(|s| DevStatus::ALL.into_iter().find(|d| d.id().eq_ignore_ascii_case(s))) - }; - let st = st.ok_or_else(|| bad(cmd, "devStatus: none|readyForDev|completed"))?; - if !st.is_none() && !crate::devinspect::has_status(doc, id) { - return Err(bad(cmd, "devStatus: only sections and frames at the top of a page or section take one")); - } - let n = doc.get_mut(id)?; - n.dev_status = st; - // Marking a layer (again) starts from its current contents. - n.dev_changed = false; - } - "maskType" => { - let t = v.as_str().and_then(|s| MaskType::ALL.into_iter().find(|m| m.id().eq_ignore_ascii_case(s))); - doc.get_mut(id)?.mask_type = t.ok_or_else(|| bad(cmd, "maskType: alpha|vector|luminance"))?; - } - "layout" | "layoutMode" => { - let n = doc.get_mut(id)?; - if !n.kind.is_frame_like() { - return Err(bad(cmd, "auto layout needs a frame")); - } - match v { - Value::Null => { - n.layout = None; - drop_layout_bindings(n); - } - Value::String(s) if s.eq_ignore_ascii_case("none") => { - n.layout = None; - drop_layout_bindings(n); - } - Value::String(s) => { - let mode = match s.to_ascii_lowercase().as_str() { - "horizontal" | "row" => LayoutMode::Horizontal, - "vertical" | "column" => LayoutMode::Vertical, - "grid" => LayoutMode::Grid, - _ => return Err(bad(cmd, "layout: none|horizontal|vertical|grid")), - }; - ensure_layout(n).mode = mode; - } - Value::Object(_) => n.layout = Some(serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("layout: {e}")))?), - _ => return Err(bad(cmd, "layout: none|horizontal|vertical|grid|{...}")), - } - } - "gap" | "itemSpacing" | "columnGap" => { - layout_mut(cmd, k, doc.get_mut(id)?)?.gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))? - } - "crossGap" | "counterAxisSpacing" | "rowGap" => { - layout_mut(cmd, k, doc.get_mut(id)?)?.cross_gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))? - } - "padding" => { - let p = four(v).ok_or_else(|| bad(cmd, "padding: n or [t,r,b,l]"))?; - layout_mut(cmd, k, doc.get_mut(id)?)?.padding = p.map(|x| x.max(0.0)); - } - "paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft" => { - let i = ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"].iter().position(|x| *x == k).unwrap_or(0); - let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0); - if let Some(slot) = layout_mut(cmd, k, doc.get_mut(id)?)?.padding.get_mut(i) { - *slot = val; - } - } - "paddingHorizontal" => { - let val = f64_of(v).unwrap_or(0.0).max(0.0); - let l = layout_mut(cmd, k, doc.get_mut(id)?)?; - l.padding[1] = val; - l.padding[3] = val; - } - "paddingVertical" => { - let val = f64_of(v).unwrap_or(0.0).max(0.0); - let l = layout_mut(cmd, k, doc.get_mut(id)?)?; - l.padding[0] = val; - l.padding[2] = val; - } - "mainAlign" | "primaryAxisAlignItems" => { - layout_mut(cmd, k, doc.get_mut(id)?)?.main_align = - enum_of(v).ok_or_else(|| bad(cmd, "mainAlign: min|center|max|spaceBetween|spaceAround|spaceEvenly"))? - } - "crossAlign" | "counterAxisAlignItems" => { - layout_mut(cmd, k, doc.get_mut(id)?)?.cross_align = enum_of(v).ok_or_else(|| bad(cmd, "crossAlign: min|center|max|baseline"))? - } - "contentAlign" | "counterAxisAlignContent" => { - layout_mut(cmd, k, doc.get_mut(id)?)?.content_align = enum_of(v).ok_or_else(|| bad(cmd, "contentAlign: auto|spaceBetween"))? - } - "wrap" => layout_mut(cmd, k, doc.get_mut(id)?)?.wrap = v.as_bool().unwrap_or(false), - "gridColumns" | "gridRows" => { - let count = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.clamp(1.0, 1000.0) as u32; - let l = layout_mut(cmd, k, doc.get_mut(id)?)?; - // Tracks past the new count go away (new tracks are 1fr). - let (n, tracks) = - if k == "gridColumns" { (&mut l.grid_columns, &mut l.column_tracks) } else { (&mut l.grid_rows, &mut l.row_tracks) }; - *n = count; - tracks.truncate(count as usize); - } - "strokesIncluded" => layout_mut(cmd, k, doc.get_mut(id)?)?.strokes_included = v.as_bool().unwrap_or(false), - "reverseZ" => layout_mut(cmd, k, doc.get_mut(id)?)?.reverse_z = v.as_bool().unwrap_or(false), - "sizingH" | "layoutSizingHorizontal" => { - let s = sizing(v).ok_or_else(|| bad(cmd, "sizing: fixed|hug|fill"))?; - set_sizing(cmd, doc, id, s, true)?; - } - "sizingV" | "layoutSizingVertical" => { - let s = sizing(v).ok_or_else(|| bad(cmd, "sizing: fixed|hug|fill"))?; - set_sizing(cmd, doc, id, s, false)?; - } - "absolute" | "ignoreAutoLayout" => { - let on = bool_of(v).ok_or_else(|| bad(cmd, "`absolute` must be a boolean"))?; - let cl = &mut doc.get_mut(id)?.child_layout; - cl.absolute = on; - // An absolute child can't fill: it keeps its current size. - if on { - for s in [&mut cl.sizing_h, &mut cl.sizing_v] { - if *s == Sizing::Fill { - *s = Sizing::Fixed; - } - } - } - } - "minWidth" | "maxWidth" | "minHeight" | "maxHeight" => { - let val = match v { - Value::Null => None, - other => Some(f64_of(other).ok_or_else(|| bad(cmd, format!("`{k}` must be a number or null")))?.clamp(0.0, 1e7)), - }; - set_limit(&mut doc.get_mut(id)?.child_layout, k, val); - } - "columnTracks" | "gridColumnTracks" | "rowTracks" | "gridRowTracks" => { - let a = v.as_array().ok_or_else(|| bad(cmd, format!("`{k}` must be an array of tracks")))?; - let tracks = a.iter().map(track_of).collect::>>().ok_or_else(|| bad(cmd, TRACK_HELP))?; - let l = layout_mut(cmd, k, doc.get_mut(id)?)?; - if k.contains("olumn") { - l.grid_columns = (tracks.len() as u32).clamp(1, 1000); - l.column_tracks = tracks; - } else { - l.grid_rows = (tracks.len() as u32).clamp(1, 1000); - l.row_tracks = tracks; - } - } - "gridColumn" | "gridColumnAnchorIndex" | "gridRow" | "gridRowAnchorIndex" => { - let val = match v { - Value::Null => None, - Value::String(s) if s.eq_ignore_ascii_case("auto") => None, - other => { - Some(f64_of(other).ok_or_else(|| bad(cmd, format!("`{k}` must be a 0-based index, \"auto\" or null")))?.clamp(0.0, 999.0) - as u32) - } - }; - let cl = &mut doc.get_mut(id)?.child_layout; - if k.starts_with("gridColumn") { - cl.grid_column = val; - } else { - cl.grid_row = val; - } - } - "gridAlignH" | "gridChildHorizontalAlign" | "gridAlignV" | "gridChildVerticalAlign" => { - let val = match v { - Value::Null => None, - Value::String(s) if s.eq_ignore_ascii_case("auto") => None, - other => Some(enum_of(other).ok_or_else(|| bad(cmd, format!("`{k}`: min|center|max|auto")))?), - }; - let cl = &mut doc.get_mut(id)?.child_layout; - if k.contains('H') { - cl.grid_align_h = val; - } else { - cl.grid_align_v = val; - } - } - "gridColumnSpan" | "gridRowSpan" => { - let span = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.clamp(1.0, 1000.0) as u32; - let cl = &mut doc.get_mut(id)?.child_layout; - if k == "gridColumnSpan" { - cl.grid_column_span = span; - } else { - cl.grid_row_span = span; - } - } - "constraints" => { - let n = doc.get_mut(id)?; - if let Some(h) = v.get("horizontal") { - n.constraints.horizontal = enum_of(h).ok_or_else(|| bad(cmd, "horizontal: left|right|leftRight|center|scale"))?; - } - if let Some(vv) = v.get("vertical") { - n.constraints.vertical = enum_of(vv).ok_or_else(|| bad(cmd, "vertical: top|bottom|topBottom|center|scale"))?; - } - } - "grids" | "layoutGrids" => doc.get_mut(id)?.grids = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("grids: {e}")))?, - "exports" | "exportSettings" => { - doc.get_mut(id)?.exports = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("exports: {e}")))? - } - "interactions" => { - doc.get_mut(id)?.interactions = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("interactions: {e}")))? - } - "overflow" => doc.get_mut(id)?.overflow = enum_of(v).ok_or_else(|| bad(cmd, "overflow: none|horizontal|vertical|both"))?, - "scrollPosition" => doc.get_mut(id)?.scroll_position = enum_of(v).ok_or_else(|| bad(cmd, "scrollPosition: scrolls|fixed|sticky"))?, - "constrainProportions" => doc.get_mut(id)?.constrain_proportions = v.as_bool().unwrap_or(false), - "characters" | "text" => { - let s = string_val(v).ok_or_else(|| bad(cmd, "`characters` must be a string"))?; - let n = doc.get_mut(id)?; - let t = text_mut(cmd, n)?; - let len = t.char_count(); - t.replace_range(0, len, &normalize_line_endings(&s)); - n.overrides.insert("characters".into()); - } - "fontFamily" - | "fontStyle" - | "fontWeight" - | "fontSize" - | "lineHeight" - | "letterSpacing" - | "textDecoration" - | "textCase" - | "hyperlink" - | "textFills" - | "fontVariations" - | "variationSettings" - | "textDecorationStyle" - | "textDecorationThickness" - | "textDecorationOffset" - | "textDecorationSkipInk" - | "textDecorationColor" => { - let n = doc.get_mut(id)?; - let t = text_mut(cmd, n)?; - let (start, end) = range_of(props, t.char_count()); - apply_text_style(cmd, t, start, end, k, v)?; - } - "textAlign" | "textAlignHorizontal" => { - text_mut(cmd, doc.get_mut(id)?)?.align_h = enum_of(v).ok_or_else(|| bad(cmd, "textAlign: left|center|right|justified"))? - } - "textAlignVertical" => { - text_mut(cmd, doc.get_mut(id)?)?.align_v = enum_of(v).ok_or_else(|| bad(cmd, "textAlignVertical: top|middle|bottom"))? - } - "textAutoResize" => { - text_mut(cmd, doc.get_mut(id)?)?.auto_resize = - enum_of(v).ok_or_else(|| bad(cmd, "textAutoResize: widthAndHeight|height|none|truncate"))? - } - "paragraphSpacing" => text_mut(cmd, doc.get_mut(id)?)?.paragraph_spacing = f64_of(v).unwrap_or(0.0).max(0.0), - "paragraphIndent" => text_mut(cmd, doc.get_mut(id)?)?.paragraph_indent = f64_of(v).unwrap_or(0.0).max(0.0), - "listStyle" | "indentation" => { - let t = text_mut(cmd, doc.get_mut(id)?)?; - let (start, end) = range_of(props, t.char_count()); - if k == "listStyle" { - let l: ListStyle = enum_of(v).ok_or_else(|| bad(cmd, "listStyle: none|bulleted|numbered"))?; - t.set_para_lists(start, end, |p| p.list = l); - } else { - let d = f64_of(v).filter(|d| d.is_finite()).ok_or_else(|| bad(cmd, "indentation: a list level 0-8"))?; - t.set_para_lists(start, end, |p| p.indent = d.clamp(0.0, f64::from(MAX_LIST_INDENT)) as u8); - } - } - "listSpacing" => text_mut(cmd, doc.get_mut(id)?)?.list_spacing = f64_of(v).filter(|s| s.is_finite()).unwrap_or(0.0).clamp(0.0, 10_000.0), - "hangingPunctuation" | "hangingList" => { - let on = v.as_bool().ok_or_else(|| bad(cmd, format!("`{k}` must be true or false")))?; - let t = text_mut(cmd, doc.get_mut(id)?)?; - *if k == "hangingList" { &mut t.hanging_list } else { &mut t.hanging_punctuation } = on; - } - "leadingTrim" => text_mut(cmd, doc.get_mut(id)?)?.leading_trim = enum_of(v).ok_or_else(|| bad(cmd, "leadingTrim: none|capHeight"))?, - "maxLines" => text_mut(cmd, doc.get_mut(id)?)?.max_lines = f64_of(v).unwrap_or(0.0).clamp(0.0, 10_000.0) as u32, - "features" | "textFeatures" | "fontFeatures" | "openTypeFeatures" => { - let f = features_of(cmd, v)?; - let t = text_mut(cmd, doc.get_mut(id)?)?; - t.features.clear(); - for (k, on) in f { - if let Some(on) = on { - t.features.insert(k, on); - } - } - } - "start" | "end" => {} - "pointCount" => { - let c = f64_of(v).ok_or_else(|| bad(cmd, "`pointCount` must be a number"))?.round(); - match &mut doc.get_mut(id)?.kind { - // Stars have 3 to 60 points; polygons keep the geometry's 120 (silent). - NodeKind::Star { count, .. } => *count = c.clamp(3.0, 60.0) as u32, - NodeKind::Polygon { count } => *count = c.clamp(3.0, 120.0) as u32, - _ => return Err(bad(cmd, "pointCount applies to polygons and stars")), - } - } - "innerRadius" | "ratio" => { - let r = f64_of(v).unwrap_or(0.38); - let r = if r > 1.0 { r / 100.0 } else { r }; - match &mut doc.get_mut(id)?.kind { - NodeKind::Star { inner_ratio, .. } => *inner_ratio = r.clamp(0.0, 1.0), - _ => return Err(bad(cmd, "innerRadius applies to stars")), - } - } - "arcStart" | "arcSweep" | "arcInner" => { - let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?; - match &mut doc.get_mut(id)?.kind { - NodeKind::Ellipse { start, sweep, inner } => match k { - "arcStart" => *start = val.to_radians(), - "arcSweep" => *sweep = val.clamp(-360.0, 360.0).to_radians(), - _ => *inner = (if val > 1.0 { val / 100.0 } else { val }).clamp(0.0, 0.99), - }, - _ => return Err(bad(cmd, "arc applies to ellipses")), - } - } - "description" => match &mut doc.get_mut(id)?.kind { - NodeKind::Component { description, .. } | NodeKind::ComponentSet { description } => *description = string_val(v).unwrap_or_default(), - _ => return Err(bad(cmd, "description applies to components")), - }, - "pluginData" => { - let n = doc.get_mut(id)?; - if let Some(o) = v.as_object() { - for (pk, pv) in o { - if pv.is_null() { - n.plugin_data.remove(pk); - } else { - n.plugin_data.insert(pk.clone(), string_val(pv).unwrap_or_else(|| pv.to_string())); - } - } - } - } - "fillStyle" | "strokeStyle" | "effectStyle" | "textStyle" | "gridStyle" => { - let sid = string_val(v); - crate::cmd::styles::apply_style_ref(doc, id, k, sid.as_deref())?; - } - "boundVariables" => { - let n = doc.get_mut(id)?; - if let Some(o) = v.as_object() { - for (field, var) in o { - match var.as_str() { - Some(vid) => { - if n.bound.get(field).map(String::as_str) != Some(vid) { - crate::cmd::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, true); - } - n.bound.insert(field.clone(), vid.to_string()); - } - None => { - if n.bound.remove(field).is_some() { - crate::cmd::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, false); - } - } - } - } - } - } - "variableModes" => { - let n = doc.get_mut(id)?; - if let Some(o) = v.as_object() { - for (coll, mode) in o { - match mode.as_str() { - Some(m) => { - n.var_modes.insert(coll.clone(), m.to_string()); - } - None => { - n.var_modes.remove(coll); - } - } - } - } - } - "booleanOperation" if !matches!(doc.get(id)?.kind, NodeKind::BooleanOp { .. }) => set_operand_op(doc, id, v, cmd)?, - "booleanOperation" => { - if let NodeKind::BooleanOp { op } = &mut doc.get_mut(id)?.kind { - *op = enum_of(v).ok_or_else(|| bad(cmd, BOOL_OP_HELP))?; - } - } - "operandOperation" => set_operand_op(doc, id, v, cmd)?, - "sectionContentsHidden" => { - if let NodeKind::Section { contents_hidden } = &mut doc.get_mut(id)?.kind { - *contents_hidden = v.as_bool().unwrap_or(false); - } - } - "background" => { - let c = color_of(v).ok_or_else(|| bad(cmd, "`background` must be a colour"))?; - match &mut doc.get_mut(id)?.kind { - NodeKind::Page { background, .. } => *background = c, - _ => return Err(bad(cmd, "background applies to pages")), - } - } - "componentProperties" => { - if let NodeKind::Instance { props: p, .. } = &mut doc.get_mut(id)?.kind - && let Some(o) = v.as_object() - { - for (pk, pv) in o { - p.insert(pk.clone(), pv.clone()); - } - } - } - other => unknown.push(other.to_string()), + if apply_geometry(cmd, doc, id, props, k, v, &mut path_offset)? + || apply_look(cmd, doc, id, k, v)? + || apply_corners(cmd, doc, id, k, v)? + || apply_layout(cmd, doc, id, k, v)? + || apply_sizing(cmd, doc, id, k, v)? + || apply_meta(cmd, doc, id, k, v)? + || apply_text(cmd, doc, id, props, k, v)? + || apply_shape(cmd, doc, id, k, v)? + { + continue; } + unknown.push(k.to_string()); } if !unknown.is_empty() { return Err(bad(cmd, format!("unknown properties: {} (known: {KEYS})", unknown.join(", ")))); diff --git a/crates/engine/src/cmd/proto.rs b/crates/engine/src/cmd/proto.rs index ce7219f4..c451eeb2 100644 --- a/crates/engine/src/cmd/proto.rs +++ b/crates/engine/src/cmd/proto.rs @@ -1,11 +1,14 @@ //! Prototype commands: interactions, flows, settings, presenting. -use figcraft_doc::proto::*; -use figcraft_doc::*; +use figcraft_doc::proto::{ + Action, DeviceRotation, Easing, Expr, Flow, Interaction, MAX_DELAY_MS, MAX_EXPR_DEPTH, OverlayPosition, PrototypeSettings, Transition, Trigger, + walk_mut, +}; +use figcraft_doc::{Document, NodeId, NodeKind, VariableValue, Variables}; use serde_json::{Value, json}; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{boolean, color, f64_of, get, id, num, parse, req_id, req_num, req_targets, string}; use crate::{Result, Session, bad, spec}; pub fn specs() -> Vec { @@ -498,6 +501,7 @@ fn set(s: &mut Session, p: &Value) -> Result { mod tests { use super::*; use crate::params::id_of; + use figcraft_doc::proto::{Direction, Overflow, ScrollPosition}; fn frame(s: &mut Session, x: f64) -> NodeId { let r = s.execute("node.create", &json!({"type": "frame", "x": x, "y": 0, "width": 100, "height": 200})).unwrap(); diff --git a/crates/engine/src/cmd/proto_actions.rs b/crates/engine/src/cmd/proto_actions.rs index d288a7d7..95a53a9d 100644 --- a/crates/engine/src/cmd/proto_actions.rs +++ b/crates/engine/src/cmd/proto_actions.rs @@ -10,12 +10,12 @@ use std::collections::BTreeMap; -use figcraft_doc::proto::*; -use figcraft_doc::*; +use figcraft_doc::proto::{Action, ConditionalBlock, Expr, ExprOp, MAX_ACTION_DEPTH, walk}; +use figcraft_doc::{Variable, VariableType, VariableValue, Variables}; use serde_json::{Value, json}; use super::CommandSpec; -use crate::params::*; +use crate::params::{bool_of, f64_of, get, id, parse, req_index, req_targets, string}; use crate::{Result, Session, bad, spec}; /// Most actions in one list (an interaction's, or a block's). diff --git a/crates/engine/src/cmd/section.rs b/crates/engine/src/cmd/section.rs index 08a5a7cc..28b02c53 100644 --- a/crates/engine/src/cmd/section.rs +++ b/crates/engine/src/cmd/section.rs @@ -7,7 +7,7 @@ use serde_json::{Value, json}; use super::CommandSpec; use crate::devinspect::{dev_items, has_status}; -use crate::params::*; +use crate::params::{boolean, get, req_targets, string, targets}; use crate::{Result, Session, bad, spec}; /// Longest change reason kept. diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs index 700825f2..05823cab 100644 --- a/crates/engine/src/cmd/structure.rs +++ b/crates/engine/src/cmd/structure.rs @@ -1,13 +1,16 @@ //! Structure: groups, frames, masks, boolean groups, flatten, outline stroke, auto layout, pages. use figcraft_color::{Color, Paint}; -use figcraft_doc::*; +use figcraft_doc::{ + AutoLayout, BoolOp, Corners, Document, LayoutMode, Node, NodeId, NodeKind, Region, Segment, Sizing, StrokeAlign, StrokeJoin, StrokeProps, + VectorNetwork, WindingRule, proto, +}; use figcraft_geom::{Affine, BezPath, Rect, Shape}; use serde_json::{Value, json}; use super::edit::{roots, z_sorted}; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{f64_of, get, id_of, num_or, req_id, req_index, req_num, req_targets, string, targets}; use crate::{EngineError, Result, Session, bad, spec}; /// The layer structure commands: group, frame, mask, boolean operations, flatten, auto layout diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index 9c2922d2..0066602a 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -3,11 +3,14 @@ use std::collections::BTreeMap; use figcraft_color::{Color, Paint, PaintKind}; -use figcraft_doc::*; +use figcraft_doc::{ + Collection, CollectionId, Document, Effect, EffectKind, Mode, ModeId, Node, NodeId, NodeKind, Style, StyleId, StyleKind, TextData, TextStyle, + Variable, VariableId, VariableType, VariableValue, Variables, trim_num, +}; use serde_json::{Value, json}; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{color_of, f64_of, get, id, num, paints_of, req_string, req_targets, string, targets}; use crate::{EngineError, Result, Session, bad, spec}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 3822c5c5..e8433fa5 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -1,11 +1,14 @@ //! Text menu and text editing commands. -use figcraft_doc::*; +use figcraft_doc::{ + LetterSpacing, LineHeight, ListStyle, NodeId, NodeKind, TextAlignH, TextAutoResize, TextCase, TextData, TextDecoration, TextPath, TextStyle, + VariableId, +}; use serde_json::{Map, Value, json}; use super::props::{styled_name, weight_name}; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{boolean, get, id, num, req_id, req_num, req_string, req_targets, string, targets}; use crate::{Result, Session, bad, spec, textedit}; fn finite(v: f64) -> f64 { diff --git a/crates/engine/src/cmd/variables.rs b/crates/engine/src/cmd/variables.rs index 53b62ee4..155fe519 100644 --- a/crates/engine/src/cmd/variables.rs +++ b/crates/engine/src/cmd/variables.rs @@ -4,12 +4,15 @@ use std::collections::BTreeSet; -use figcraft_doc::*; +use figcraft_doc::{ + CODE_PLATFORMS, Collection, CollectionId, Document, ModeId, NodeId, NodeKind, Variable, VariableId, VariableType, VariableValue, Variables, + normalize_scopes, scope_known, +}; use serde_json::{Value, json}; use super::lists::BoundList; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{boolean, get, num, req_string, string}; use crate::{Result, Session, bad, spec}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/variables_io.rs b/crates/engine/src/cmd/variables_io.rs index 6bd5bb40..b9e73f13 100644 --- a/crates/engine/src/cmd/variables_io.rs +++ b/crates/engine/src/cmd/variables_io.rs @@ -4,13 +4,13 @@ use std::collections::{BTreeMap, BTreeSet}; -use figcraft_doc::*; +use figcraft_doc::{Collection, CollectionId, Document, Mode, ModeId, Variable, VariableId, VariableValue, Variables, trim_num}; use serde_json::{Value, json}; use super::CommandSpec; use crate::codegen::{css_var_name, css_var_of}; use crate::library::{is_remote_collection, local_collections}; -use crate::params::*; +use crate::params::{get, string}; use crate::{EngineError, Result, Session, bad, spec}; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 7364557f..97b947a5 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -1,10 +1,10 @@ //! View menu, preferences and tools. View state is per document and drivable like everything else. -use figcraft_doc::*; +use figcraft_doc::NodeId; use serde_json::{Value, json}; use super::{CommandSpec, ok}; -use crate::params::*; +use crate::params::{boolean, get, num, num_or, req_index, req_num, req_string, string}; use crate::tools::ToolId; use crate::{EngineError, Result, Session, ViewState, bad, spec}; diff --git a/crates/engine/src/code_connect_tests.rs b/crates/engine/src/code_connect_tests.rs index 1ee785e6..24e03e4b 100644 --- a/crates/engine/src/code_connect_tests.rs +++ b/crates/engine/src/code_connect_tests.rs @@ -65,37 +65,47 @@ fn connected_instances_show_the_real_snippet() { ); assert_eq!(code(&mut s, i, "swiftui"), "import AcmeUI\n\nCard(title: \"Say \\\"hi\\\"\", style: .plain)\n"); assert!(code(&mut s, i, "compose").contains("@Composable"), "other languages stay generated"); + let screen = leptos_arkui_and_trees(&mut s, c, i); + rejects_bad_links(&mut s, c, screen); + disconnect_then_undo(&mut s, c, i); +} + +fn leptos_arkui_and_trees(s: &mut Session, c: NodeId, i: NodeId) -> NodeId { // Leptos: a `use` line and `view!` markup (strings with escapes in braces). ex( - &mut s, + s, "codeConnect.set", json!({"id": c, "language": "leptos", "importPath": "crate::ui", "template": ""}), ); - assert_eq!(code(&mut s, i, "leptos"), "use crate::ui::Card;\n\n\n"); + assert_eq!(code(s, i, "leptos"), "use crate::ui::Card;\n\n\n"); // ArkUI: an ArkTS import and an object-literal call. ex( - &mut s, + s, "codeConnect.set", json!({"id": c, "language": "arkui", "importPath": "@acme/arkui", "template": "Card({ title: {title}, dotted: {Show dot} })"}), ); - assert_eq!(code(&mut s, i, "arkui"), "import { Card } from \"@acme/arkui\";\n\nCard({ title: \"Say \\\"hi\\\"\", dotted: false })\n"); - let v = ex(&mut s, "inspect.componentCode", json!({"id": i, "language": "arkui"})); + assert_eq!(code(s, i, "arkui"), "import { Card } from \"@acme/arkui\";\n\nCard({ title: \"Say \\\"hi\\\"\", dotted: false })\n"); + let v = ex(s, "inspect.componentCode", json!({"id": i, "language": "arkui"})); assert_eq!(v["usage"], "Card({ title: \"Say \\\"hi\\\"\", dotted: false })\n"); assert_eq!(v["connected"]["import"], "import { Card } from \"@acme/arkui\";"); assert!(s.execute("codeConnect.set", &json!({"id": c, "language": "leptos", "importPath": "crate/ui"})).is_err(), "a Rust path"); - ex(&mut s, "codeConnect.remove", json!({"id": c, "language": "leptos"})); + ex(s, "codeConnect.remove", json!({"id": c, "language": "leptos"})); // inspect.componentCode: the snippet as the usage, and the link. - let v = ex(&mut s, "inspect.componentCode", json!({"id": i, "language": "swiftui"})); + let v = ex(s, "inspect.componentCode", json!({"id": i, "language": "swiftui"})); assert_eq!(v["usage"], "Card(title: \"Say \\\"hi\\\"\", style: .plain)\n"); assert_eq!(v["connected"]["import"], "import AcmeUI"); - assert_eq!(ex(&mut s, "inspect.componentCode", json!({"id": i, "language": "compose"}))["connected"], Value::Null); + assert_eq!(ex(s, "inspect.componentCode", json!({"id": i, "language": "compose"}))["connected"], Value::Null); // Inside a generated layer tree, connected instances are their snippet. - let screen = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 500, "width": 400, "height": 300, "name": "Screen"}))); - ex(&mut s, "instance.create", json!({"component": c, "x": 20, "y": 30, "parent": screen})); - assert!(code(&mut s, screen, "react").contains("•\n")); + let screen = idv(&ex(s, "frame.create", json!({"x": 0, "y": 500, "width": 400, "height": 300, "name": "Screen"}))); + ex(s, "instance.create", json!({"component": c, "x": 20, "y": 30, "parent": screen})); + assert!(code(s, screen, "react").contains("•\n")); // Literal braces stay; fields left out keep their value. - ex(&mut s, "codeConnect.set", json!({"id": c, "template": ""})); - assert_eq!(code(&mut s, c, "react"), "import { Card } from \"@acme/ui\";\n\n\n"); + ex(s, "codeConnect.set", json!({"id": c, "template": ""})); + assert_eq!(code(s, c, "react"), "import { Card } from \"@acme/ui\";\n\n\n"); + screen +} + +fn rejects_bad_links(s: &mut Session, c: NodeId, screen: NodeId) { // Validation. for bad in [ json!({"id": c, "template": "{title ? \"a\"}"}), @@ -109,13 +119,16 @@ fn connected_instances_show_the_real_snippet() { ] { assert!(s.execute("codeConnect.set", &bad).is_err(), "{bad}"); } +} + +fn disconnect_then_undo(s: &mut Session, c: NodeId, i: NodeId) { // Disconnect one language; undo brings it back. - ex(&mut s, "codeConnect.remove", json!({"id": i, "language": "react"})); - assert!(code(&mut s, i, "react").contains("export function Card({")); - assert!(code(&mut s, i, "swiftui").starts_with("import AcmeUI")); - ex(&mut s, "edit.undo", json!({})); - assert!(code(&mut s, i, "react").starts_with("import { Card }")); - assert_eq!(ex(&mut s, "codeConnect.remove", json!({"id": c}))["removed"], 3); + ex(s, "codeConnect.remove", json!({"id": i, "language": "react"})); + assert!(code(s, i, "react").contains("export function Card({")); + assert!(code(s, i, "swiftui").starts_with("import AcmeUI")); + ex(s, "edit.undo", json!({})); + assert!(code(s, i, "react").starts_with("import { Card }")); + assert_eq!(ex(s, "codeConnect.remove", json!({"id": c}))["removed"], 3); assert!(s.execute("codeConnect.remove", &json!({"id": c})).is_err(), "nothing left"); } diff --git a/crates/engine/src/content/synthetic_ds.rs b/crates/engine/src/content/synthetic_ds.rs index c3beacd5..bb62871c 100644 --- a/crates/engine/src/content/synthetic_ds.rs +++ b/crates/engine/src/content/synthetic_ds.rs @@ -11,7 +11,10 @@ use std::collections::BTreeMap; use figcraft_color::{Color, Paint}; -use figcraft_doc::*; +use figcraft_doc::{ + AutoLayout, Collection, CollectionId, CrossAlign, DocError, DocResult, Document, LayoutMode, Mode, ModeId, Node, NodeId, NodeKind, PropDef, + Sizing, Style, StyleKind, TextAutoResize, TextData, TextStyle, Variable, VariableType, VariableValue, +}; use serde_json::Value; use crate::components::{create_instance, sync_instance}; diff --git a/crates/engine/src/content/templates/dashboard.rs b/crates/engine/src/content/templates/dashboard.rs index 23ed506a..099b37bf 100644 --- a/crates/engine/src/content/templates/dashboard.rs +++ b/crates/engine/src/content/templates/dashboard.rs @@ -6,7 +6,7 @@ use figcraft_doc::NodeId; use serde_json::{Value, json}; use super::glyphs::{self, Glyph}; -use super::kit::*; +use super::kit::{ACCENT, BORDER, BOTTOM_EDGE, CARD, H, ICON, INK, Kit, MUTED, ON_BRAND, PAGE, PRIMARY, RIGHT_EDGE, SOFT, V, merged}; use crate::Result; const NAV: [(&str, Glyph); 6] = [ diff --git a/crates/engine/src/content/templates/design_system.rs b/crates/engine/src/content/templates/design_system.rs index 6f47712c..f56f5356 100644 --- a/crates/engine/src/content/templates/design_system.rs +++ b/crates/engine/src/content/templates/design_system.rs @@ -6,7 +6,7 @@ use figcraft_doc::NodeId; use serde_json::{Value, json}; -use super::kit::*; +use super::kit::{H, Kit, TYPE, V, merged}; use crate::Result; const THEME: &str = "Theme"; diff --git a/crates/engine/src/content/templates/icon_set.rs b/crates/engine/src/content/templates/icon_set.rs index 2d6671d4..caf390d5 100644 --- a/crates/engine/src/content/templates/icon_set.rs +++ b/crates/engine/src/content/templates/icon_set.rs @@ -6,7 +6,7 @@ use figcraft_doc::NodeId; use serde_json::json; use super::glyphs; -use super::kit::*; +use super::kit::{BORDER, CARD, H, ICON, INK, Kit, MUTED, PAGE, V, merged}; use crate::Result; const STROKE: &str = "Icon/Stroke"; diff --git a/crates/engine/src/content/templates/landing.rs b/crates/engine/src/content/templates/landing.rs index 7edaef03..a3b7c85f 100644 --- a/crates/engine/src/content/templates/landing.rs +++ b/crates/engine/src/content/templates/landing.rs @@ -6,7 +6,7 @@ use figcraft_doc::NodeId; use serde_json::{Value, json}; use super::glyphs::{self, Glyph}; -use super::kit::*; +use super::kit::{BORDER, BOTTOM_EDGE, CARD, H, ICON, INK, Kit, MUTED, ON_BRAND, PAGE, PRIMARY, SOFT, SUNKEN, TOP_EDGE, V, merged, shadow}; use crate::Result; /// The widest the content gets. diff --git a/crates/engine/src/content/templates/mobile.rs b/crates/engine/src/content/templates/mobile.rs index 863d3725..77e6ba6d 100644 --- a/crates/engine/src/content/templates/mobile.rs +++ b/crates/engine/src/content/templates/mobile.rs @@ -6,7 +6,7 @@ use figcraft_doc::NodeId; use serde_json::json; use super::glyphs::{self, Glyph}; -use super::kit::*; +use super::kit::{BORDER, CARD, H, ICON, INK, Kit, MUTED, ON_BRAND, PAGE, PRIMARY, SUNKEN, TOP_EDGE, V, shadow}; use crate::Result; const W: f64 = 390.0; diff --git a/crates/engine/src/content/templates/slides.rs b/crates/engine/src/content/templates/slides.rs index 322b3407..54fd376c 100644 --- a/crates/engine/src/content/templates/slides.rs +++ b/crates/engine/src/content/templates/slides.rs @@ -6,7 +6,7 @@ use figcraft_doc::NodeId; use serde_json::{Value, json}; use super::glyphs; -use super::kit::*; +use super::kit::{ACCENT, BORDER, BOTTOM_EDGE, CARD, DEEP, H, INK, Kit, MUTED, ON_BRAND, PAGE, PRIMARY, TOP_EDGE, V, merged}; use crate::Result; const W: f64 = 1920.0; diff --git a/crates/engine/src/content/templates/social.rs b/crates/engine/src/content/templates/social.rs index 61525260..73b82735 100644 --- a/crates/engine/src/content/templates/social.rs +++ b/crates/engine/src/content/templates/social.rs @@ -6,7 +6,7 @@ use figcraft_doc::NodeId; use serde_json::json; use super::glyphs; -use super::kit::*; +use super::kit::{ACCENT, DEEP, H, INK, Kit, ON_BRAND, V}; use crate::Result; /// (name, width, height, x, y) on the canvas. diff --git a/crates/engine/src/content/templates/wireframe.rs b/crates/engine/src/content/templates/wireframe.rs index 68426c07..1eb13893 100644 --- a/crates/engine/src/content/templates/wireframe.rs +++ b/crates/engine/src/content/templates/wireframe.rs @@ -6,7 +6,7 @@ use figcraft_doc::NodeId; use serde_json::{Value, json}; use super::glyphs; -use super::kit::*; +use super::kit::{BOTTOM_EDGE, H, Kit, TOP_EDGE, TYPE, V, merged}; use crate::Result; const WIRE_INK: &str = "Wire/Ink"; diff --git a/crates/engine/src/derived.rs b/crates/engine/src/derived.rs index 765e3be5..b6a001b8 100644 --- a/crates/engine/src/derived.rs +++ b/crates/engine/src/derived.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, BTreeSet, Document, Node, NodeId, board, cmd, components, measure, style_vars}; /// Nodes whose `Arc` differs from `before` (new or edited). pub fn changed_nodes(doc: &Document, before: &Document) -> Vec { diff --git a/crates/engine/src/dev/annotations.rs b/crates/engine/src/dev/annotations.rs index e6937d0e..0b27d03e 100644 --- a/crates/engine/src/dev/annotations.rs +++ b/crates/engine/src/dev/annotations.rs @@ -7,12 +7,12 @@ use std::sync::LazyLock; -use figcraft_doc::*; +use figcraft_doc::{Annotation, AnnotationCategory, Document, NodeId}; use serde_json::{Value, json}; use crate::cmd::CommandSpec; use crate::devinspect::{Prop, Section, properties}; -use crate::params::*; +use crate::params::{color_of, get, id, req_index, req_string, string, targets}; use crate::{bad, spec}; /// Most annotations on one layer. diff --git a/crates/engine/src/dev/code_connect.rs b/crates/engine/src/dev/code_connect.rs index 271193e0..8524c25a 100644 --- a/crates/engine/src/dev/code_connect.rs +++ b/crates/engine/src/dev/code_connect.rs @@ -10,14 +10,14 @@ //! keeps JSX's braces: `size={size}` → `size="Large"`, `{label}` → `{"Buy"}`), and //! `{prop ? "a" : "b"}` picks one of two strings. Any other `{…}` stays as written. -use figcraft_doc::*; +use figcraft_doc::{CodeLink, Document, NodeId, NodeKind}; use serde_json::{Value, json}; use crate::cmd::CommandSpec; use crate::codegen::{CodeLang, Ctx}; use crate::component_codegen::{Component, Param, ParamKind, describe, instance_values, js_string, main_of, value_expr}; use crate::components::{slots_of, variant_set}; -use crate::params::*; +use crate::params::{get, id_of, targets}; use crate::{EngineError, Result, Session, bad, spec}; /// Longest template (chars). diff --git a/crates/engine/src/dev/codegen.rs b/crates/engine/src/dev/codegen.rs index 093a8fd9..37dae893 100644 --- a/crates/engine/src/dev/codegen.rs +++ b/crates/engine/src/dev/codegen.rs @@ -6,12 +6,15 @@ //! output is a starting point, not a pixel-exact translation. use figcraft_color::{Color, PaintKind}; -use figcraft_doc::*; +use figcraft_doc::{ + ContentAlign, CrossAlign, Document, EffectKind, LayoutMode, LetterSpacing, LineHeight, MainAlign, Node, NodeId, NodeKind, Shadow, Sizing, + TextAlignH, TextCase, TextDecoration, Variable, +}; use serde_json::json; use crate::cmd::CommandSpec; use crate::component_codegen::{Component, Param}; -use crate::params::*; +use crate::params::{get, id_of, num_or, string, targets}; use crate::{bad, spec}; mod arkui; diff --git a/crates/engine/src/dev/codegen/arkui.rs b/crates/engine/src/dev/codegen/arkui.rs index 1d9816f6..6ce54ca9 100644 --- a/crates/engine/src/dev/codegen/arkui.rs +++ b/crates/engine/src/dev/codegen/arkui.rs @@ -1,5 +1,8 @@ use super::swift::one_line; -use super::*; +use super::{ + CodeLang, Color, CrossAlign, Ctx, LayoutMode, LetterSpacing, LineHeight, MainAlign, Node, NodeId, NodeKind, TextAlignH, TextCase, TextDecoration, + drop_shadow, first_fill, indent, literal, num, radius, stroke_color, +}; // ------------------------------------------------------------------ ArkUI (ArkTS) @@ -37,8 +40,121 @@ pub(crate) fn arkui(ctx: &Ctx, id: NodeId, depth: usize, out: &mut String, extra arkui_layer(ctx, n, depth, out, extra); } -fn arkui_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[String]) { +fn text_layer(ctx: &Ctx, n: &Node, t: &figcraft_doc::TextData, pad: &str, out: &mut String, mods: &mut Vec) { + let text = ctx.param(n, "characters").map_or_else(|| literal(&t.characters, false), |p| format!("this.{}", p.ident)); + out.push_str(&format!("{pad}Text({text})\n")); + let s = &t.style; + mods.push(format!(".fontSize({})", num(s.font_size))); + mods.push(format!(".fontWeight({})", s.weight().clamp(100, 900))); + mods.push(format!(".fontFamily({})", literal(&s.font_family, false))); + if s.italic() { + mods.push(".fontStyle(FontStyle.Italic)".into()); + } + if let Some(c) = first_fill(n) { + mods.push(format!(".fontColor({})", ark_color(c))); + } + match s.line_height { + LineHeight::Pixels(v) => mods.push(format!(".lineHeight({})", num(v))), + LineHeight::Percent(p) => mods.push(format!(".lineHeight('{}%')", num(p))), + LineHeight::Auto => {} + } + match s.letter_spacing { + LetterSpacing::Pixels(v) if v != 0.0 => mods.push(format!(".letterSpacing({})", num(v))), + LetterSpacing::Percent(p) if p != 0.0 => mods.push(format!(".letterSpacing('{}%')", num(p))), + _ => {} + } + match t.align_h { + TextAlignH::Left => {} + TextAlignH::Center => mods.push(".textAlign(TextAlign.Center)".into()), + TextAlignH::Right => mods.push(".textAlign(TextAlign.End)".into()), + TextAlignH::Justified => mods.push(".textAlign(TextAlign.JUSTIFY)".into()), + } + // Aligning needs a box to align in: the text layer's own width. + if !matches!(t.align_h, TextAlignH::Left) { + mods.push(format!(".width({})", num(n.width))); + } + match s.case { + TextCase::Upper => mods.push(".textCase(TextCase.UpperCase)".into()), + TextCase::Lower => mods.push(".textCase(TextCase.LowerCase)".into()), + _ => {} + } + match s.decoration { + TextDecoration::Underline => mods.push(".decoration({ type: TextDecorationType.Underline })".into()), + TextDecoration::Strikethrough => mods.push(".decoration({ type: TextDecorationType.LineThrough })".into()), + TextDecoration::None => {} + } +} + +fn frame_layer(ctx: &Ctx, n: &Node, depth: usize, pad: &str, out: &mut String, mods: &mut Vec) { let doc = ctx.doc; + let open = match n.layout.as_ref().map(|l| (l.mode, l)) { + Some((LayoutMode::Vertical, l)) => format!("Column({{ space: {} }})", num(l.gap)), + Some((_, l)) => format!("Row({{ space: {} }})", num(l.gap)), + None => "Stack({ alignContent: Alignment.TopStart })".to_string(), + }; + out.push_str(&format!("{pad}{open} {{\n")); + match ctx.param(n, "slot") { + // A slot shows the instance's content, or else the main's (its default builder). + Some(p) => out.push_str(&format!("{pad} this.{}()\n", p.ident)), + None => { + for c in ctx.kids(n) { + // Free children sit at their position inside a Stack, and so does a child + // that ignores its auto layout parent. + let pos: Vec = match doc.node(c).filter(|cn| n.layout.is_none() || cn.child_layout.absolute) { + Some(cn) => { + let [_, _, _, _, x, y] = cn.transform.as_coeffs(); + vec![format!(".position({{ x: {}, y: {} }})", num(x), num(y))] + } + None => Vec::new(), + }; + arkui(ctx, c, depth + 1, out, &pos); + } + } + } + out.push_str(&format!("{pad}}}\n")); + if let Some(l) = &n.layout { + layout_mods(l, mods); + } + mods.push(format!(".width({})", num(n.width))); + mods.push(format!(".height({})", num(n.height))); + if let Some(c) = first_fill(n) { + mods.push(format!(".backgroundColor({})", ark_color(c))); + } + if radius(n) > 0.0 { + mods.push(".clip(true)".into()); + } + ark_radius(n, mods); +} + +fn layout_mods(l: &figcraft_doc::AutoLayout, mods: &mut Vec) { + let [t, r, b, lft] = l.padding; + if [t, r, b, lft].iter().any(|v| *v > 0.0) { + mods.push(format!(".padding({{ top: {}, right: {}, bottom: {}, left: {} }})", num(t), num(r), num(b), num(lft))); + } + let justify = match l.main_align { + MainAlign::Min => None, + MainAlign::Center => Some("FlexAlign.Center"), + MainAlign::Max => Some("FlexAlign.End"), + MainAlign::SpaceBetween => Some("FlexAlign.SpaceBetween"), + MainAlign::SpaceAround => Some("FlexAlign.SpaceAround"), + MainAlign::SpaceEvenly => Some("FlexAlign.SpaceEvenly"), + }; + if let Some(j) = justify { + mods.push(format!(".justifyContent({j})")); + } + let cross = match l.cross_align { + CrossAlign::Min => None, + CrossAlign::Center => Some("Center"), + CrossAlign::Max => Some(if l.mode == LayoutMode::Vertical { "End" } else { "Bottom" }), + CrossAlign::Baseline => None, + }; + if let Some(c) = cross { + let ty = if l.mode == LayoutMode::Vertical { "HorizontalAlign" } else { "VerticalAlign" }; + mods.push(format!(".alignItems({ty}.{c})")); + } +} + +fn arkui_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[String]) { let pad = indent(depth); if ctx.too_deep(depth) { out.push_str(&format!("{pad}// {} …\n", one_line(&n.name))); @@ -68,48 +184,7 @@ fn arkui_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[Str let mut mods: Vec = Vec::new(); match &n.kind { NodeKind::Text(t) => { - let text = ctx.param(n, "characters").map_or_else(|| literal(&t.characters, false), |p| format!("this.{}", p.ident)); - out.push_str(&format!("{pad}Text({text})\n")); - let s = &t.style; - mods.push(format!(".fontSize({})", num(s.font_size))); - mods.push(format!(".fontWeight({})", s.weight().clamp(100, 900))); - mods.push(format!(".fontFamily({})", literal(&s.font_family, false))); - if s.italic() { - mods.push(".fontStyle(FontStyle.Italic)".into()); - } - if let Some(c) = first_fill(n) { - mods.push(format!(".fontColor({})", ark_color(c))); - } - match s.line_height { - LineHeight::Pixels(v) => mods.push(format!(".lineHeight({})", num(v))), - LineHeight::Percent(p) => mods.push(format!(".lineHeight('{}%')", num(p))), - LineHeight::Auto => {} - } - match s.letter_spacing { - LetterSpacing::Pixels(v) if v != 0.0 => mods.push(format!(".letterSpacing({})", num(v))), - LetterSpacing::Percent(p) if p != 0.0 => mods.push(format!(".letterSpacing('{}%')", num(p))), - _ => {} - } - match t.align_h { - TextAlignH::Left => {} - TextAlignH::Center => mods.push(".textAlign(TextAlign.Center)".into()), - TextAlignH::Right => mods.push(".textAlign(TextAlign.End)".into()), - TextAlignH::Justified => mods.push(".textAlign(TextAlign.JUSTIFY)".into()), - } - // Aligning needs a box to align in: the text layer's own width. - if !matches!(t.align_h, TextAlignH::Left) { - mods.push(format!(".width({})", num(n.width))); - } - match s.case { - TextCase::Upper => mods.push(".textCase(TextCase.UpperCase)".into()), - TextCase::Lower => mods.push(".textCase(TextCase.LowerCase)".into()), - _ => {} - } - match s.decoration { - TextDecoration::Underline => mods.push(".decoration({ type: TextDecorationType.Underline })".into()), - TextDecoration::Strikethrough => mods.push(".decoration({ type: TextDecorationType.LineThrough })".into()), - TextDecoration::None => {} - } + text_layer(ctx, n, t, &pad, out, &mut mods); } NodeKind::Ellipse { .. } => { out.push_str(&format!("{pad}Ellipse({{ width: {}, height: {} }})\n", num(n.width), num(n.height))); @@ -131,67 +206,7 @@ fn arkui_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[Str mods.push(format!(".height({})", num(n.height))); } k if k.is_frame_like() || matches!(k, NodeKind::Group) => { - let open = match n.layout.as_ref().map(|l| (l.mode, l)) { - Some((LayoutMode::Vertical, l)) => format!("Column({{ space: {} }})", num(l.gap)), - Some((_, l)) => format!("Row({{ space: {} }})", num(l.gap)), - None => "Stack({ alignContent: Alignment.TopStart })".to_string(), - }; - out.push_str(&format!("{pad}{open} {{\n")); - match ctx.param(n, "slot") { - // A slot shows the instance's content, or else the main's (its default builder). - Some(p) => out.push_str(&format!("{pad} this.{}()\n", p.ident)), - None => { - for c in ctx.kids(n) { - // Free children sit at their position inside a Stack, and so does a child - // that ignores its auto layout parent. - let pos: Vec = match doc.node(c).filter(|cn| n.layout.is_none() || cn.child_layout.absolute) { - Some(cn) => { - let [_, _, _, _, x, y] = cn.transform.as_coeffs(); - vec![format!(".position({{ x: {}, y: {} }})", num(x), num(y))] - } - None => Vec::new(), - }; - arkui(ctx, c, depth + 1, out, &pos); - } - } - } - out.push_str(&format!("{pad}}}\n")); - if let Some(l) = &n.layout { - let [t, r, b, lft] = l.padding; - if [t, r, b, lft].iter().any(|v| *v > 0.0) { - mods.push(format!(".padding({{ top: {}, right: {}, bottom: {}, left: {} }})", num(t), num(r), num(b), num(lft))); - } - let justify = match l.main_align { - MainAlign::Min => None, - MainAlign::Center => Some("FlexAlign.Center"), - MainAlign::Max => Some("FlexAlign.End"), - MainAlign::SpaceBetween => Some("FlexAlign.SpaceBetween"), - MainAlign::SpaceAround => Some("FlexAlign.SpaceAround"), - MainAlign::SpaceEvenly => Some("FlexAlign.SpaceEvenly"), - }; - if let Some(j) = justify { - mods.push(format!(".justifyContent({j})")); - } - let cross = match l.cross_align { - CrossAlign::Min => None, - CrossAlign::Center => Some("Center"), - CrossAlign::Max => Some(if l.mode == LayoutMode::Vertical { "End" } else { "Bottom" }), - CrossAlign::Baseline => None, - }; - if let Some(c) = cross { - let ty = if l.mode == LayoutMode::Vertical { "HorizontalAlign" } else { "VerticalAlign" }; - mods.push(format!(".alignItems({ty}.{c})")); - } - } - mods.push(format!(".width({})", num(n.width))); - mods.push(format!(".height({})", num(n.height))); - if let Some(c) = first_fill(n) { - mods.push(format!(".backgroundColor({})", ark_color(c))); - } - if radius(n) > 0.0 { - mods.push(".clip(true)".into()); - } - ark_radius(n, &mut mods); + frame_layer(ctx, n, depth, &pad, out, &mut mods); } _ => { out.push_str(&format!("{pad}// {}: export as an image asset\n{pad}Image($r('app.media.asset'))\n", n.kind.label())); diff --git a/crates/engine/src/dev/codegen/compose.rs b/crates/engine/src/dev/codegen/compose.rs index 5915ca9b..fd3ff01a 100644 --- a/crates/engine/src/dev/codegen/compose.rs +++ b/crates/engine/src/dev/codegen/compose.rs @@ -1,5 +1,5 @@ use super::swift::one_line; -use super::*; +use super::{CodeLang, Color, Ctx, LayoutMode, Node, NodeId, NodeKind, drop_shadow, first_fill, indent, literal, num, radius, stroke_color}; // ------------------------------------------------------------------ Jetpack Compose diff --git a/crates/engine/src/dev/codegen/css.rs b/crates/engine/src/dev/codegen/css.rs index c7982652..ef8fe43b 100644 --- a/crates/engine/src/dev/codegen/css.rs +++ b/crates/engine/src/dev/codegen/css.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + ContentAlign, CrossAlign, Document, EffectKind, LayoutMode, LetterSpacing, LineHeight, MainAlign, Node, NodeId, NodeKind, PaintKind, TextAlignH, + TextCase, TextDecoration, Variable, css_borders, num, +}; // ------------------------------------------------------------------ CSS @@ -85,51 +88,7 @@ pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bo put("height", tok(doc, n, &["height"], px(n.height), t)); } if let Some(l) = &n.layout { - put("display", "flex".into()); - put("flex-direction", if l.mode == LayoutMode::Vertical { "column" } else { "row" }.into()); - if l.wrap { - put("flex-wrap", "wrap".into()); - } - put("gap", tok(doc, n, &["gap"], px(l.gap), t)); - let [pt, pr, pb, pl] = l.padding; - let sides = [ - tok(doc, n, &["paddingTop", "paddingVertical", "padding"], px(pt), t), - tok(doc, n, &["paddingRight", "paddingHorizontal", "padding"], px(pr), t), - tok(doc, n, &["paddingBottom", "paddingVertical", "padding"], px(pb), t), - tok(doc, n, &["paddingLeft", "paddingHorizontal", "padding"], px(pl), t), - ]; - put("padding", sides.join(" ")); - let justify = match l.main_align { - MainAlign::Min => "flex-start", - MainAlign::Center => "center", - MainAlign::Max => "flex-end", - MainAlign::SpaceBetween => "space-between", - MainAlign::SpaceAround => "space-around", - MainAlign::SpaceEvenly => "space-evenly", - }; - put("justify-content", justify.into()); - let align = match l.cross_align { - CrossAlign::Min => "flex-start", - CrossAlign::Center => "center", - CrossAlign::Max => "flex-end", - CrossAlign::Baseline => "baseline", - }; - put("align-items", align.into()); - if l.wrap { - // The lines: spread, or packed where the items align (baseline: at the start). - let spread = l.content_align == ContentAlign::SpaceBetween; - put( - "align-content", - if spread { - "space-between" - } else if align == "baseline" { - "flex-start" - } else { - align - } - .into(), - ); - } + layout_decls(doc, n, l, t, &mut put); } if matches!(n.kind, NodeKind::Ellipse { .. }) { put("border-radius", "50%".into()); @@ -169,6 +128,65 @@ pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bo for (key, value) in css_borders(doc, n, t) { put(&key, value); } + effect_decls(n, &mut put); + if let NodeKind::Text(tx) = &n.kind { + text_decls(doc, n, tx, t, &mut put); + } + out +} + +/// The flex declarations of auto layout `l` on `n`. +fn layout_decls(doc: &Document, n: &Node, l: &figcraft_doc::AutoLayout, t: &mut Tokens, put: &mut impl FnMut(&str, String)) { + let px = |v: f64| format!("{}px", num(v)); + put("display", "flex".into()); + put("flex-direction", if l.mode == LayoutMode::Vertical { "column" } else { "row" }.into()); + if l.wrap { + put("flex-wrap", "wrap".into()); + } + put("gap", tok(doc, n, &["gap"], px(l.gap), t)); + let [pt, pr, pb, pl] = l.padding; + let sides = [ + tok(doc, n, &["paddingTop", "paddingVertical", "padding"], px(pt), t), + tok(doc, n, &["paddingRight", "paddingHorizontal", "padding"], px(pr), t), + tok(doc, n, &["paddingBottom", "paddingVertical", "padding"], px(pb), t), + tok(doc, n, &["paddingLeft", "paddingHorizontal", "padding"], px(pl), t), + ]; + put("padding", sides.join(" ")); + let justify = match l.main_align { + MainAlign::Min => "flex-start", + MainAlign::Center => "center", + MainAlign::Max => "flex-end", + MainAlign::SpaceBetween => "space-between", + MainAlign::SpaceAround => "space-around", + MainAlign::SpaceEvenly => "space-evenly", + }; + put("justify-content", justify.into()); + let align = match l.cross_align { + CrossAlign::Min => "flex-start", + CrossAlign::Center => "center", + CrossAlign::Max => "flex-end", + CrossAlign::Baseline => "baseline", + }; + put("align-items", align.into()); + if l.wrap { + // The lines: spread, or packed where the items align (baseline: at the start). + let spread = l.content_align == ContentAlign::SpaceBetween; + put( + "align-content", + if spread { + "space-between" + } else if align == "baseline" { + "flex-start" + } else { + align + } + .into(), + ); + } +} + +/// Shadows and blurs of `n`. +fn effect_decls(n: &Node, put: &mut impl FnMut(&str, String)) { let shadows: Vec = n .effects .iter() @@ -196,75 +214,82 @@ pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bo _ => {} } } - if let NodeKind::Text(tx) = &n.kind { - let s = &tx.style; - put("font-family", tok(doc, n, &["fontFamily"], format!("\"{}\"", s.font_family.replace('"', "'")), t)); - put("font-size", tok(doc, n, &["fontSize"], px(s.font_size), t)); - put("font-weight", s.weight().to_string()); - if s.italic() { - put("font-style", "italic".into()); +} + +/// The type declarations of text layer `n`. +fn text_decls(doc: &Document, n: &Node, tx: &figcraft_doc::TextData, t: &mut Tokens, put: &mut impl FnMut(&str, String)) { + let px = |v: f64| format!("{}px", num(v)); + let s = &tx.style; + put("font-family", tok(doc, n, &["fontFamily"], format!("\"{}\"", s.font_family.replace('"', "'")), t)); + put("font-size", tok(doc, n, &["fontSize"], px(s.font_size), t)); + put("font-weight", s.weight().to_string()); + if s.italic() { + put("font-style", "italic".into()); + } + match s.line_height { + LineHeight::Pixels(v) => put("line-height", tok(doc, n, &["lineHeight"], px(v), t)), + LineHeight::Percent(p) => put("line-height", format!("{}%", num(p))), + LineHeight::Auto => {} + } + match s.letter_spacing { + LetterSpacing::Pixels(v) if v != 0.0 => put("letter-spacing", tok(doc, n, &["letterSpacing"], px(v), t)), + LetterSpacing::Percent(p) if p != 0.0 => put("letter-spacing", format!("{}em", num(p / 100.0))), + _ => {} + } + match tx.align_h { + TextAlignH::Left => {} + TextAlignH::Center => put("text-align", "center".into()), + TextAlignH::Right => put("text-align", "right".into()), + TextAlignH::Justified => put("text-align", "justify".into()), + } + match s.case { + TextCase::Original => {} + TextCase::Upper => put("text-transform", "uppercase".into()), + TextCase::Lower => put("text-transform", "lowercase".into()), + TextCase::Title => put("text-transform", "capitalize".into()), + TextCase::SmallCaps => put("font-variant", "small-caps".into()), + } + match s.decoration { + TextDecoration::None => {} + TextDecoration::Underline => put("text-decoration", "underline".into()), + TextDecoration::Strikethrough => put("text-decoration", "line-through".into()), + } + decoration_decls(s, put); + if tx.leading_trim == figcraft_doc::LeadingTrim::CapHeight { + put("text-box", "trim-both cap alphabetic".into()); + } +} + +fn decoration_decls(s: &figcraft_doc::TextStyle, put: &mut impl FnMut(&str, String)) { + let px = |v: f64| format!("{}px", num(v)); + if s.decoration != TextDecoration::None { + let l = s.look(); + let len = |m: LineHeight| match m { + LineHeight::Auto => None, + LineHeight::Pixels(v) => Some(px(v)), + LineHeight::Percent(p) => Some(format!("{}%", num(p))), + }; + match l.style { + figcraft_doc::DecorationStyle::Solid => {} + figcraft_doc::DecorationStyle::Dotted => put("text-decoration-style", "dotted".into()), + figcraft_doc::DecorationStyle::Wavy => put("text-decoration-style", "wavy".into()), } - match s.line_height { - LineHeight::Pixels(v) => put("line-height", tok(doc, n, &["lineHeight"], px(v), t)), - LineHeight::Percent(p) => put("line-height", format!("{}%", num(p))), - LineHeight::Auto => {} + if let Some(v) = len(l.thickness) { + put("text-decoration-thickness", v); } - match s.letter_spacing { - LetterSpacing::Pixels(v) if v != 0.0 => put("letter-spacing", tok(doc, n, &["letterSpacing"], px(v), t)), - LetterSpacing::Percent(p) if p != 0.0 => put("letter-spacing", format!("{}em", num(p / 100.0))), - _ => {} + if let Some(c) = l.color { + put("text-decoration-color", c.to_css()); } - match tx.align_h { - TextAlignH::Left => {} - TextAlignH::Center => put("text-align", "center".into()), - TextAlignH::Right => put("text-align", "right".into()), - TextAlignH::Justified => put("text-align", "justify".into()), - } - match s.case { - TextCase::Original => {} - TextCase::Upper => put("text-transform", "uppercase".into()), - TextCase::Lower => put("text-transform", "lowercase".into()), - TextCase::Title => put("text-transform", "capitalize".into()), - TextCase::SmallCaps => put("font-variant", "small-caps".into()), - } - match s.decoration { - TextDecoration::None => {} - TextDecoration::Underline => put("text-decoration", "underline".into()), - TextDecoration::Strikethrough => put("text-decoration", "line-through".into()), - } - if s.decoration != TextDecoration::None { - let l = s.look(); - let len = |m: LineHeight| match m { - LineHeight::Auto => None, - LineHeight::Pixels(v) => Some(px(v)), - LineHeight::Percent(p) => Some(format!("{}%", num(p))), - }; - match l.style { - figcraft_doc::DecorationStyle::Solid => {} - figcraft_doc::DecorationStyle::Dotted => put("text-decoration-style", "dotted".into()), - figcraft_doc::DecorationStyle::Wavy => put("text-decoration-style", "wavy".into()), + if s.decoration == TextDecoration::Underline { + if let Some(v) = len(l.offset) { + put("text-underline-offset", v); } - if let Some(v) = len(l.thickness) { - put("text-decoration-thickness", v); + // Browsers skip ink by default; the canvas only when asked. + if !l.skip_ink { + put("text-decoration-skip-ink", "none".into()); } - if let Some(c) = l.color { - put("text-decoration-color", c.to_css()); - } - if s.decoration == TextDecoration::Underline { - if let Some(v) = len(l.offset) { - put("text-underline-offset", v); - } - // Browsers skip ink by default; the canvas only when asked. - if !l.skip_ink { - put("text-decoration-skip-ink", "none".into()); - } - } - } - if tx.leading_trim == figcraft_doc::LeadingTrim::CapHeight { - put("text-box", "trim-both cap alphabetic".into()); } } - out } /// A gradient fill on a `w`×`h` box as a CSS background: linear along its handles' direction diff --git a/crates/engine/src/dev/codegen/html.rs b/crates/engine/src/dev/codegen/html.rs index 3ae746f1..d1b144ca 100644 --- a/crates/engine/src/dev/codegen/html.rs +++ b/crates/engine/src/dev/codegen/html.rs @@ -1,5 +1,5 @@ use super::css::{Tokens, css_decls}; -use super::*; +use super::{CodeLang, Ctx, Document, LayoutMode, Node, NodeId, NodeKind, Param, Sizing, indent, num, with_v_if}; // ------------------------------------------------------------------ HTML and React markup @@ -127,6 +127,32 @@ pub(crate) struct Markup<'c, 'a> { pub fluid_leaves: f64, } +/// How `n` sits in its parent: in the flex flow, or placed at its position. +fn placement_decls(n: &Node, parent: Option<&Node>, root: bool, in_flow: bool) -> String { + let mut decls = String::new(); + if root { + decls.push_str("position: relative;\n"); + } else if in_flow { + let vertical = parent.and_then(|p| p.layout.as_ref()).is_some_and(|l| l.mode == LayoutMode::Vertical); + let (main, cross) = + if vertical { (n.child_layout.sizing_v, n.child_layout.sizing_h) } else { (n.child_layout.sizing_h, n.child_layout.sizing_v) }; + if main == Sizing::Fill { + decls.push_str("flex: 1 1 0;\n"); + } + if cross == Sizing::Fill { + decls.push_str("align-self: stretch;\n"); + } + } else { + let c = n.transform.as_coeffs(); + decls.push_str(&format!("position: absolute;\nleft: {}px;\ntop: {}px;\n", num(c[4]), num(c[5]))); + let rot = figcraft_geom::rotation_degrees(n.transform); + if rot.abs() > 0.01 { + decls.push_str(&format!("transform: rotate({}deg);\ntransform-origin: 0 0;\n", num(-rot))); + } + } + decls +} + impl<'c, 'a> Markup<'c, 'a> { pub fn new(ctx: &'c Ctx<'a>, lang: CodeLang) -> Self { Markup { @@ -196,6 +222,52 @@ impl<'c, 'a> Markup<'c, 'a> { self.rules.push((path.to_string(), class.to_string(), decls)); } + /// An instance as a call to its component (see `element`); true when it was written. + fn instance_call(&mut self, n: &Node, depth: usize, path: &str, class: &str, decls: &str, root: bool) -> bool { + let pad = self.pad(depth); + let attr = if self.lang == CodeLang::React { "className" } else { "class" }; + let connected = self.inline && crate::code_connect::link(self.ctx.doc, n.id, CodeLang::React).is_some(); + if self.code() && (!self.inline || connected) && !root && matches!(n.kind, NodeKind::Instance { .. }) { + let at = depth + usize::from(!decls.is_empty()); + let run = if self.lang == CodeLang::Leptos { ".run()" } else { "" }; + let (inner, css) = match self.ctx.param(n, "mainComponent") { + Some(p) if self.lang == CodeLang::Vue => (Some(format!("{}\n", self.pad(at), self.ident(p))), String::new()), + Some(p) => (Some(format!("{}{{{}{run}}}\n", self.pad(at), self.ident(p))), String::new()), + None => self.ctx.call(n, self.lang, at).map_or((None, String::new()), |(code, css)| (Some(code), css)), + }; + self.styles.push_str(&css); + if let Some(inner) = inner { + // A Vue guard set by `node` rides on the call when there is no wrapper. + let guard = if self.lang == CodeLang::Vue { self.v_if.take() } else { None }; + if decls.is_empty() { + self.markup.push_str(&with_v_if(&inner, guard.as_deref())); + } else { + let guard = guard.map(|g| format!(" v-if=\"{g}\"")).unwrap_or_default(); + self.markup.push_str(&format!("{pad}
\n{inner}{pad}
\n")); + self.rule(path, class, decls.to_string()); + } + return true; + } + } + false + } + + fn text_body(&self, n: &Node, t: &figcraft_doc::TextData) -> String { + match (self.ctx.param(n, "characters").filter(|_| self.code()), self.lang) { + (Some(p), CodeLang::Leptos) => format!("{{{}.clone()}}", self.ident(p)), + (Some(p), CodeLang::Vue) => format!("{{{{ {} }}}}", self.ident(p)), + (Some(p), _) => format!("{{{}}}", p.ident), + (None, CodeLang::React) => jsx_text(&t.characters), + // `view!` takes text as string literals and void elements self-closed. + (None, CodeLang::Leptos) => { + t.characters.chars().take(10_000).collect::().lines().map(|l| format!("{l:?}")).collect::>().join("
") + } + // A Vue template reads `{{ }}` as interpolation, so braces are escaped. + (None, CodeLang::Vue) => html_escape(&t.characters).replace('{', "{").replace('}', "}"), + (None, _) => html_escape(&t.characters), + } + } + fn element(&mut self, n: &Node, parent: Option<&Node>, depth: usize, path: &str) { let doc = self.ctx.doc; let root = n.id == self.ctx.root; @@ -215,53 +287,13 @@ impl<'c, 'a> Markup<'c, 'a> { let pad = self.pad(depth); let attr = if self.lang == CodeLang::React { "className" } else { "class" }; // How it sits in its parent: in the flex flow, or placed at its position. - let mut decls = String::new(); let in_flow = parent.is_some_and(|p| p.layout.is_some()) && !n.child_layout.absolute; - if root { - decls.push_str("position: relative;\n"); - } else if in_flow { - let vertical = parent.and_then(|p| p.layout.as_ref()).is_some_and(|l| l.mode == LayoutMode::Vertical); - let (main, cross) = - if vertical { (n.child_layout.sizing_v, n.child_layout.sizing_h) } else { (n.child_layout.sizing_h, n.child_layout.sizing_v) }; - if main == Sizing::Fill { - decls.push_str("flex: 1 1 0;\n"); - } - if cross == Sizing::Fill { - decls.push_str("align-self: stretch;\n"); - } - } else { - let c = n.transform.as_coeffs(); - decls.push_str(&format!("position: absolute;\nleft: {}px;\ntop: {}px;\n", num(c[4]), num(c[5]))); - let rot = figcraft_geom::rotation_degrees(n.transform); - if rot.abs() > 0.01 { - decls.push_str(&format!("transform: rotate({}deg);\ntransform-origin: 0 0;\n", num(-rot))); - } - } + let mut decls = placement_decls(n, parent, root, in_flow); // Nested instances are calls (component code), in a wrapper that places them when needed. // Page code inlines instances, except those connected to code (codeConnect.set): their // snippet, e.g. a design-system `